Skip to content

Report why a Copilot turn never reaches the gateway - #310

Merged
anonpran merged 2 commits into
stagingfrom
nanda/transcript-exit-observability
Sep 14, 2026
Merged

anonpran merged 2 commits into
stagingfrom
nanda/transcript-exit-observability

Conversation

@anonpran

@anonpran anonpran commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Why

build_exchange_from_transcript has four exits that return None, and none of them logs anything (grep -c log_error over the function returns 0). A None exchange then fails the gate in the Stop handler:

if exchange and (forwarded_now or text_sig != last_text_sig or usage):

send_to_api is 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:

user api_call errors (30d) backfilled after live
Ameer.Khan 270 284
Ahsan.Amir 12 13
Ramya.Kumar 0 77
Veronika.S 0 33

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:

line condition
5167 transcript missing or not yet written
5182 transcript unreadable
5230 turn start anchor not found
5390 no messages assembled

The bare except Exception at 5182 is narrowed to (OSError, UnicodeDecodeError). Anything else now propagates to main'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 clean staging (verified in a separate worktree, same counts), so they are pre-existing and unrelated.

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge, although the non-blocking transcript error diagnostic still lacks enough context to debug unexpected exceptions.

Findings

  1. P2 Unexpected Errors Lose Context

Summary

  • Reports missing transcript files using a basename-only identifier.
  • Reports transcript read failures, missing turn anchors, and empty message assembly.
  • Preserves the existing control flow and exception handling behavior.

Reviews (3) · Last reviewed commit: "Keep the broad except and log the basena..."

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
@anonpran
anonpran requested a review from a team September 14, 2026 11:05
Comment thread copilot/hooks/unbound.py Outdated
Comment thread copilot/hooks/unbound.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Agentic security review of the Copilot transcript observability changes. One medium finding on the new transcript missing error path.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread copilot/hooks/unbound.py Outdated

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ 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 into error.log and 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 the fallback_session_id if 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.

Pre-existing (not introduced by this PR)

  • Semgrep insecure-file-permissions at copilot/hooks/unbound.py:5529 is on a line this diff does not touch. It is also a false positive: 0o700 gives access to the owner only, which is stricter than the 0o644 that 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
Comment thread copilot/hooks/unbound.py
Comment on lines +5185 to +5186
except Exception as e:
log_error(f"transcript unreadable: {type(e).__name__}", 'transcript')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Security consensus: no issues found. (reviewers: Claude, Semgrep, Gitleaks)


🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 364e763f · 2026-09-14T11:31Z

@anonpran
anonpran merged commit f4efff2 into staging Sep 14, 2026
4 checks passed
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.

3 participants