Skip to content

fix(council): verify CDP endpoint identity before attaching; recover orphaned results - #71

Draft
intellegix wants to merge 1 commit into
masterfrom
fix/pplx-cdp-identity-gate
Draft

fix(council): verify CDP endpoint identity before attaching; recover orphaned results#71
intellegix wants to merge 1 commit into
masterfrom
fix/pplx-cdp-identity-gate

Conversation

@intellegix

Copy link
Copy Markdown
Owner

Fixes the machine-wide Perplexity research outage of 2026-08-07 (four consecutive
research_query failures at ~128s, blocking the mandated research pipeline for
~17 live sessions).

Root cause — the runner attached to the wrong Chrome

_start_via_cdp() treated "port 9222 answers /json/version" as proof the session
keeper was alive. It isn't — any process can serve plausible JSON on a port. Three
facts lined up:

  1. PerplexitySessionKeeper has been Disabled since 07-30 (1451 missed runs).
    Research kept working because CDP attach quietly failed and the runner fell back
    to its local-launch path.
  2. A stale session_keeper.cdp from 08-02 still pointed at 9222, recording a PID
    dead since the reboot. Nothing invalidated it.
  3. browser-relay/relay.mjs (the /takeover phone relay) claimed port 9222 at
    12:57
    , on a throwaway C:\Temp\igx-cdp-profile.

From 12:57 the health check passed against the relay's Chrome. Every run attached to
a profile with zero Perplexity cookies, hit a logged-out wall, and was torn down ~128s
later. Timeline is exact: last success 11:49 (pre-reboot), relay up 12:57,
first failure 13:19.

This also explains the misleading symptoms: BROWSER_BUSY with active: null (slots
leaked by runs dying inside the doomed attach), and "a dead browser that killed two
other sessions' queries" (attach closes every /search/ page in the shared context).

The reboot was a trigger, not the cause. A reachable port was being treated as a
trusted port.

Changes

council_browser.py — prove endpoint identity before attaching, layered so an
indeterminate probe degrades to the next check rather than blocking a healthy keeper:

  • recorded PID must be alive (stale .cdp file is deleted)
  • DevToolsActivePort GUID in the keeper's profile must match the endpoint's
    webSocketDebuggerUrl — the canonical launch-time↔discovery-time match
  • failing that, the port owner's command line must name session_keeper_profile
  • post-attach, the context must carry a Perplexity auth cookie — which also catches
    a live keeper whose login expired, previously indistinguishable from this failure

The "synthesize a .cdp file because 9222 answers" fast path is gated behind the same
ownership check. Any refusal falls back to local launch.

council_query.pyinvocation_id is a fresh UUID per call, so a caller dying
orphans a completed result (this happened today: council_fae5c9e0.json). Results now
carry a query_fingerprint; run_browser_query checks for a matching, usable, recent
result before re-running. Age comes from the recorded timestamp rather than file
mtime, so re-saving a recovered orphan can't keep it alive indefinitely. 2h window,
bounded scan, error stubs never served, opt out with COUNCIL_NO_ORPHAN_RECOVERY=1.

Verification

Patch landed 14:07:55 PT. From perplexity-queue.json:

seq time duration result
905 13:50 128.2s ❌ browser closed
906 14:01 128.1s ❌ browser closed
908 14:08 159.5s completed (first post-patch run)
  • Gates unit-tested against live machine state: refuse the relay's Chrome via both
    stale-PID and foreign-owner paths; refuse wrong-GUID and wrong-port; accept a
    simulated healthy keeper, so the CDP path still works when the keeper returns.
  • Orphan recovery tested against the real council_fae5c9e0.json — recovers the
    identical synthesis, respects the 2h window, immune to mtime reset, returns None
    for an unseen query.
  • End-to-end through the MCP tool: two brand-new queries completed (159.5s, 177.7s); a
    repeated query returned instantly from cache.

Not changed here (flagged for a deliberate decision)

  • The keeper task is still Disabled. Research works without it via the launch path.
  • Port 9222 is contended between session_keeper.py and browser-relay/relay.mjs,
    whose header documents 9222 as its takeover contract. If the keeper is re-enabled
    while the relay is up, the keeper will fail to bind. Latent collision; needs an
    explicit port-registry assignment.
  • BROWSER_BUSY vs BROWSER_DEAD still aren't distinguished caller-side.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VsT8c4wZQYv2fRN6szvEQ6

…orphaned results

Machine-wide Perplexity research outage on 2026-08-07: four consecutive
research_query runs failed at ~128s with "Page.wait_for_timeout: Target page,
context or browser has been closed".

Root cause: the runner attached to the wrong Chrome. _start_via_cdp() treated
"port 9222 answers /json/version" as proof the session keeper was alive. Three
facts lined up: the PerplexitySessionKeeper task has been Disabled since 07-30;
a stale session_keeper.cdp from 08-02 still pointed at 9222 recording a PID dead
since the reboot; and browser-relay/relay.mjs (the /takeover phone relay) claimed
port 9222 at 12:57 with a throwaway C:\Temp\igx-cdp-profile. From 12:57 the health
check passed against the relay's cookie-less Chrome, so every run navigated to a
logged-out wall and was torn down ~128s later. Last success 11:49 (pre-reboot,
launch path); relay up 12:57; first failure 13:19.

A reachable port was being treated as a trusted port.

council_browser.py — prove endpoint identity before attaching, layered so an
indeterminate probe degrades to the next check rather than blocking a healthy
keeper:
  - recorded PID must be alive (stale .cdp file is deleted)
  - DevToolsActivePort GUID in the keeper's profile must match the endpoint's
    webSocketDebuggerUrl (canonical launch-time/discovery-time match)
  - failing that, the port owner's command line must name session_keeper_profile
  - post-attach, the context must carry a Perplexity auth cookie — which also
    catches a live keeper whose login has expired
The "synthesize a .cdp file because 9222 answers" fast path is gated behind the
same ownership check. Any refusal falls back to local launch, which is what was
working before 12:57.

council_query.py — invocation_id is a fresh UUID per call, so a caller dying
orphans a completed result (council_fae5c9e0.json). Results now carry a
query_fingerprint and run_browser_query checks for a matching, usable, recent
result before re-running. Age comes from the recorded timestamp rather than file
mtime, so re-saving a recovered orphan cannot keep it alive indefinitely.

Verified: first post-patch run completed in 159.5s where the two runs before it
failed at 128s. Gates unit-tested against live machine state — refuse the relay's
Chrome, wrong GUID and wrong port; accept a simulated healthy keeper. Orphan
recovery verified against the real council_fae5c9e0.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsT8c4wZQYv2fRN6szvEQ6
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.

1 participant