Skip to content

feat: gex-poller — scheduled MCP extraction into timeseries.db - #2

Merged
filipesalvio-code merged 19 commits into
mainfrom
feat/gex-poller
Aug 3, 2026
Merged

feat: gex-poller — scheduled MCP extraction into timeseries.db#2
filipesalvio-code merged 19 commits into
mainfrom
feat/gex-poller

Conversation

@filipesalvio-code

@filipesalvio-code filipesalvio-code commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Implementation of docs/superpowers/plans/2026-08-01-gex-poller.md. CodeRabbit gate 1: tasks 1-3 (scaffold, db, normalize).

Summary by CodeRabbit

  • New Features

    • Added automated collection and SQLite storage of poller time-series data.
    • Added support for MenthorQ and SpotGamma data normalization, validation, and fallback snapshots.
    • Added reliable MCP tool communication with timeout and process-failure handling.
    • Added scrape tracking, JSONL logs, status reporting, failure monitoring, and optional macOS notifications.
    • Added market-hours scheduling and one-shot/status command-line operations.
    • Added macOS LaunchAgent configuration for recurring polling.
  • Documentation

    • Added setup and operations guidance for installing, running, monitoring, and removing the poller.
  • Tests

    • Added comprehensive coverage for polling, parsing, storage, monitoring, scheduling, and integration scenarios.
    • Added project coverage reporting and test categorization.

Greptile Summary

Introduces gex-poller, a new LaunchAgent-scheduled subsystem that spawns the MenthorQ and SpotGamma MCP servers via stdio JSON-RPC, normalises their responses, and persists time-series data into a local SQLite timeseries.db. The implementation is well-structured with a clean separation of concerns across db, normalize, mcp_client, observe, schedule, and poll modules, and is backed by comprehensive unit and integration tests.

  • poller/db.py: Defines the SQLite schema (6 data tables + scrape_runs audit table), a one-time migration for the put_call_ratio unique constraint, and row-insert/cycle-audit helpers. The migration uses executescript without a wrapping BEGIN/COMMIT, making it non-atomic and capable of orphaning data if interrupted.
  • poller/mcp_client.py: Minimal stdio JSON-RPC client with timeout, process-death detection, and notification filtering; the success path result[\"content\"][0][\"text\"] on line 112 remains unguarded against an empty content list.
  • poller/normalize.py + poller/poll.py: Response normalisation and the per-tool polling loop; the put_call_ratio branch still accesses d[\"timestamp\"] via direct dict lookup (raising an opaque KeyError) rather than through _req().

Confidence Score: 4/5

Safe to merge for day-to-day use; the non-atomic migration is the one concrete data-loss path, but it only fires during an upgrade from the old PCR schema and only if the process is killed in the middle of four fast DDL statements.

The non-atomic migration in _migrate_put_call_ratio can silently drop all historical put_call_ratio rows if the process is interrupted between the table rename and the table create. The three issues carried over from round 1 (empty-content IndexError escaping run_cycle, opaque KeyError for a missing PCR timestamp, and unvalidated column names in INSERT) also remain open. The rest of the implementation — scheduling, observability, the MCP client, and the test suite — is solid.

Files Needing Attention: poller/db.py (migration atomicity), poller/mcp_client.py (empty content list on success path), poller/poll.py (exception catch tuple), poller/normalize.py (direct dict access for PCR timestamp)

Important Files Changed

Filename Overview
poller/db.py SQLite schema + write helpers; _migrate_put_call_ratio uses a non-atomic executescript that can orphan historical data if interrupted mid-flight; column names are interpolated into INSERT SQL without an allowlist.
poller/mcp_client.py Minimal stdio JSON-RPC client; the success path result["content"][0]["text"] (line 112) raises IndexError on an empty content list — IndexError is not caught in run_cycle's exception tuple, leaving the cycle row without a finished_at.
poller/normalize.py MCP response parser/normalizer; d["timestamp"] in the put_call_ratio branch (line 76) raises an opaque KeyError instead of the informative ValueError that every other required field uses via _req().
poller/poll.py Core polling cycle; exception catch tuple (line 43) omits IndexError, so an empty-content MCP response from call_tool propagates out of run_cycle before finish_cycle() is called.
poller/observe.py JSONL logger, failure streak counter, macOS notify, and status_report; clean implementation with good rotation and correct julianday boundary in the 24h window query.
poller/schedule.py Market-hours gate with correct DST handling via ZoneInfo; timezone-naive datetimes are treated as UTC as documented.
poller/com.gexhub.poller.plist LaunchAgent plist with hardcoded user-specific absolute paths (/Users/filipesalvio/...) in ProgramArguments, WorkingDirectory, and log paths — breaks for any other clone; test_plist.py also hardcodes this path.
.github/workflows/ci.yml Adds unit (100%) and integration (80%) coverage gates; the bare pytest -q step preceding both gates means integration tests run twice in CI, but all gates pass correctly.
tests/poller/test_db.py Comprehensive DB layer tests including migration roundtrip, idempotency, and source derivation; no test for the interrupted-migration failure mode.
tests/poller/test_poll.py Integration tests cover full cycle, per-tool error isolation, BrokenPipeError, and sqlite3.OperationalError; good coverage of run_cycle paths.

Sequence Diagram

sequenceDiagram
    participant LA as LaunchAgent (every 15 min)
    participant Poll as poller.poll.main()
    participant Sched as schedule.in_market_window()
    participant MCP_M as McpClient (menthorq)
    participant MCP_SG as McpClient (spotgamma)
    participant Norm as normalize.to_rows()
    participant DB as db (timeseries.db)
    participant Log as JsonlLogger

    LA->>Poll: spawn python -m poller.poll
    Poll->>Sched: in_market_window(now)?
    alt outside market hours
        Sched-->>Poll: False exit 0
    else inside market hours
        Sched-->>Poll: True
        Poll->>DB: init_db() / migrate
        Poll->>MCP_M: enter (initialize handshake)
        Poll->>MCP_SG: enter (initialize handshake)
        Poll->>DB: begin_cycle()
        loop each (key, tool, args) in CAPTURE
            Poll->>MCP_M: call_tool(tool, args)
            MCP_M-->>Poll: ToolResult
            Poll->>Norm: to_rows(tool, result, captured_at)
            Norm-->>Poll: (table, rows)
            Poll->>DB: insert_rows(table, rows)
            Poll->>DB: record_call(cid, tool, ...)
        end
        Poll->>DB: finish_cycle(cid)
        Poll->>Log: log event cycle
        Poll->>DB: failure_streak()
        opt streak gte 3
            Poll->>Poll: notify_macos(...)
        end
        Poll-->>LA: exit 0/1
    end
Loading

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
### Issue 1
poller/db.py:64-78
**Non-atomic migration can orphan historical PCR data**

Python's `executescript()` issues an implicit `COMMIT` before running the script, leaving all subsequent statements in autocommit mode — each statement commits immediately with no rollback on failure. If the process is killed after `ALTER TABLE put_call_ratio RENAME TO put_call_ratio_old` commits (step 1) but before `CREATE TABLE put_call_ratio` runs (step 2), `sqlite_master` no longer has a `put_call_ratio` entry. On the next startup `_migrate_put_call_ratio` evaluates `row is None → return`, skips the migration entirely, and `conn.executescript(_SCHEMA)` creates a fresh empty `put_call_ratio` via `CREATE TABLE IF NOT EXISTS` — leaving all the historical rows stranded in `put_call_ratio_old` with no path to recover them.

Adding `BEGIN IMMEDIATE;\n` at the start of the script and `\nCOMMIT;` at the end makes the entire migration a single SQLite transaction; a mid-flight crash rolls it back so the old schema is intact on the next startup and migration is reattempted correctly.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (2): Last reviewed commit: "docs: correct gated-endpoint examples in..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

also fix pytest import shadowing: drop tests/poller/__init__.py (it
shadowed the poller package) and add pythonpath=["."] so repo-root
modules import cleanly alongside the legacy from-conftest import
@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds an MCP client, response normalization, SQLite persistence, polling orchestration, market-hour scheduling, operational reporting, command-line status output, launch-agent deployment, and pytest coverage.

Changes

Poller foundation

Layer / File(s) Summary
MCP transport and process lifecycle
poller/mcp_client.py, tests/poller/*mcp*, tests/poller/conftest.py, tests/poller/test_mcp_client.py
Adds stdio JSON-RPC communication, initialization, tool calls, timeouts, process cleanup, fake MCP servers, and integration tests.
Response parsing and row normalization
poller/normalize.py, tests/poller/test_normalize.py
Parses tool responses and HTTP errors. MenthorQ and SpotGamma payloads map to validated table rows. Unknown payloads use snapshot rows.
SQLite schema and persistence lifecycle
poller/db.py, tests/poller/test_db.py
Creates time-series tables and scrape_runs. Cycle and tool-call helpers persist audit data. Batch insertion validates tables and ignores duplicates.
Polling cycle and CLI execution
poller/poll.py, tests/poller/test_poll.py
Runs configured tool captures, stores normalized results, records per-call errors, applies market-hour gating, manages clients, and returns cycle status codes.
Scheduling and operational reporting
poller/schedule.py, poller/observe.py, poller/status.py, tests/poller/test_schedule.py, tests/poller/test_observe.py, tests/poller/test_status.py
Adds market-window checks, JSONL logging with rollover, failure-streak calculation, macOS notifications, status reports, and a status CLI.
Deployment and test configuration
pyproject.toml, poller/com.gexhub.poller.plist, docs/RUNBOOK.md, tests/poller/test_plist.py, tests/test_positioning_artifact.py
Adds coverage settings, test markers, optional live-test selection, launch-agent configuration, operational commands, and a package-qualified test import.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

I’m a rabbit with packets that hop through the night,
Rows land in SQLite, deduplicated right.
Cycles record errors, schedules mark time,
Tests guard each timeout and payload line.
Hop, hop—the poller is ready to run!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the scheduled MCP extraction into timeseries.db, which is the main change in the pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gex-poller

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@poller/db.py`:
- Around line 27-32: Update the put_call_ratio table definition to make the
uniqueness key include source alongside ticker and ts. Modify the UNIQUE
constraint in the CREATE TABLE statement so rows from menthorq_put_call_ratio
and spotgamma_equity_put_call_ratio with the same ticker and timestamp are
retained separately, while duplicate rows from the same source remain ignored.
- Around line 72-79: Update record_call to accept a source argument and use it
in the scrape_runs INSERT instead of the hardcoded empty string. Propagate the
real source through every record_call call site, and extend the relevant
database test to assert that per-call rows retain the supplied source.
- Around line 63-69: Update begin_cycle to retrieve the inserted row’s cycle_id
by its connection-scoped inserted-row identity, rather than querying
MAX(cycle_id) after commit. Preserve the existing insert and commit behavior
while ensuring the returned ID always belongs to this cycle.

In `@poller/normalize.py`:
- Around line 53-91: Update to_rows handling for the menthorq_gamma_levels and
menthorq_dealer_positioning branches so missing required fields ticker,
frequency, timestamp, or reference_timestamp are caught and raised as
descriptive ValueError exceptions instead of propagating KeyError. Preserve the
existing row mappings and optional-field behavior.

In `@tests/poller/test_db.py`:
- Line 5: Replace the module-level pytestmark in the poller database tests with
the integration marker, so all tests calling init_db with a SQLite file are
categorized as integration tests. Align this with the marker definitions in
pyproject.toml and remove the unit classification.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f54e5504-a6d2-443f-ba58-8bdfeb25381e

📥 Commits

Reviewing files that changed from the base of the PR and between 728afbe and 58d9d27.

📒 Files selected for processing (6)
  • poller/__init__.py
  • poller/db.py
  • poller/normalize.py
  • pyproject.toml
  • tests/poller/test_db.py
  • tests/poller/test_normalize.py

Comment thread poller/db.py
Comment thread poller/db.py Outdated
Comment thread poller/db.py
Comment thread poller/normalize.py
Comment thread tests/poller/test_db.py

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@poller/db.py`:
- Around line 30-31: Update the database initialization/schema setup for the
put_call_ratio table so existing databases with UNIQUE (ticker, ts) are migrated
to the new UNIQUE (ticker, ts, source) constraint, or explicitly recreate the
database under a documented policy. Ensure the migration preserves existing data
and add coverage for upgrading a database created with the previous schema.
- Around line 64-70: Update begin_cycle’s scrape_runs insert to avoid the SQLite
RETURNING clause, or explicitly enforce SQLite version >=3.35.0 before executing
it; ensure older host-linked SQLite versions do not fail before recording the
cycle.

In `@poller/normalize.py`:
- Around line 41-44: Update _req to reject required fields whose values are None
or blank strings, while preserving its existing missing-field validation. In the
dealer normalization branch, retrieve ticker through _req instead of converting
a missing ticker to an empty string. Add regression coverage for null and
missing dealer tickers, ensuring both are rejected before insertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a8f5d556-7737-414b-ae12-69a9ea7bed82

📥 Commits

Reviewing files that changed from the base of the PR and between 58d9d27 and 9d03195.

📒 Files selected for processing (4)
  • poller/db.py
  • poller/normalize.py
  • tests/poller/test_db.py
  • tests/poller/test_normalize.py

Comment thread poller/db.py
Comment thread poller/db.py
Comment thread poller/normalize.py Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@poller/mcp_client.py`:
- Around line 39-41: Update the initialization flow around the first
_rpc("initialize", ...) call to inspect its response before sending
notifications/initialized. Raise McpError when the response contains an error or
its negotiated protocolVersion differs from _PROTOCOL; only call
_rpc("notifications/initialized", is_notification=True) after validation
succeeds.
- Around line 72-87: Update McpClient._rpc to read responses until the matching
request ID is found, skipping notifications, rejecting server-initiated requests
and unexpected IDs, and preserve notification behavior; ensure tools/call is
tested against an intervening notification. Update __enter__ to validate the
initialize response, propagate initialization errors, require a supported
negotiated protocolVersion, and send notifications/initialized only after
validation.

In `@poller/observe.py`:
- Around line 40-42: Update the osascript invocation in the poller flow to pass
a finite timeout, and catch subprocess.TimeoutExpired alongside OSError so a
blocked command is handled without stopping scheduled polling.
- Around line 37-40: Update notify_macos so title and message are not
interpolated into AppleScript source: use a constant on run argv handler and
pass both values as separate osascript arguments, preserving notification
behavior. Extend the notify_macos tests with quotes and AppleScript syntax in
both values, verifying they are passed as arguments rather than embedded in the
script.
- Around line 47-50: Update the cycle query in the observe logic around
conn.execute to compare timestamps using julianday(started_at) >=
julianday('now', '-1 day') instead of text datetime comparison, ensuring the
24-hour cutoff works with _utcnow()’s T separator and +00:00 offset. Add a test
covering the exact cutoff boundary and confirming records older than 24 hours
are excluded.

In `@poller/schedule.py`:
- Around line 5-13: Update poller/schedule.py lines 5-13 in in_market_window to
convert now to America/New_York, then evaluate the local weekday and 09:30 <=
time < 16:00 window so daylight-saving and standard-time offsets are handled
correctly. Update tests/poller/test_schedule.py lines 10-25 to add standard-time
weekday boundary cases for January 5, 2026 at 14:30 UTC and 21:00 UTC.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5d88d8b-5538-4005-a549-be988d024677

📥 Commits

Reviewing files that changed from the base of the PR and between 9d03195 and f750cc6.

📒 Files selected for processing (12)
  • poller/mcp_client.py
  • poller/observe.py
  • poller/schedule.py
  • tests/__init__.py
  • tests/poller/conftest.py
  • tests/poller/fake_mcp_server.py
  • tests/poller/hang_mcp_server.py
  • tests/poller/silent_mcp_server.py
  • tests/poller/test_mcp_client.py
  • tests/poller/test_observe.py
  • tests/poller/test_schedule.py
  • tests/test_positioning_artifact.py

Comment thread poller/mcp_client.py Outdated
Comment thread poller/mcp_client.py Outdated
Comment thread poller/observe.py Outdated
Comment thread poller/observe.py Outdated
Comment thread poller/observe.py Outdated
Comment thread poller/schedule.py Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
poller/db.py (1)

120-130: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Derive insert columns per row, not from rows[0] only.

cols = list(rows[0].keys()) derives the column set from the first row only, then [r.get(c) for c in cols] for r in rows] applies that fixed column set to every row. If a later row in the same batch has extra keys absent from rows[0], those values are silently dropped; if a later row is missing a key present in rows[0], it silently becomes None instead of failing.

Every current caller in poller/normalize.py builds each row in a batch from the same literal dict shape, so this does not manifest today. It is still a fragile, undocumented precondition on a general-purpose helper; a future heterogeneous batch would silently lose data instead of raising an error.

🛡️ Proposed fix: validate row shape before insert
     cols = list(rows[0].keys())
+    if any(set(r.keys()) != set(cols) for r in rows[1:]):
+        raise ValueError(f"insert_rows: heterogeneous row keys for table {table!r}")
     sql = f"INSERT OR IGNORE INTO {table} ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@poller/db.py` around lines 120 - 130, Update insert_rows to validate that
every row has the same key set before constructing the INSERT statement, rather
than deriving columns solely from rows[0]. Raise a clear error for heterogeneous
row shapes; preserve the existing empty-batch behavior and insertion/counting
flow for valid batches.
poller/normalize.py (1)

75-80: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Guard ticker and timestamp in the put/call-ratio branch, matching the sibling branches.

d.get("ticker", d.get("sym", "")) on Line 76 silently defaults to "", and d["timestamp"] is a direct, unguarded subscript. This repeats the exact pattern already fixed for menthorq_dealer_positioning (Line 69, now using _req), but this branch was left out of that fix.

Consequences:

  • A missing timestamp raises a raw KeyError instead of the descriptive ValueError used by every other branch in to_rows.
  • A blank or missing ticker either violates the ticker TEXT NOT NULL constraint in put_call_ratio with an unhandled sqlite3.IntegrityError, or two distinct sources both missing a ticker collide under the same (ticker="", ts, source) key, defeating the source-aware uniqueness constraint added in poller/db.py.

No test in tests/poller/test_normalize.py covers null, missing, or blank ticker/timestamp for menthorq_put_call_ratio or spotgamma_equity_put_call_ratio, unlike the equivalent dealer-positioning and gamma-level tests.

🛡️ Proposed fix
     if tool in ("menthorq_put_call_ratio", "spotgamma_equity_put_call_ratio"):
-        row = {"ticker": d.get("ticker", d.get("sym", "")), "ts": d["timestamp"],
+        row = {"ticker": _req(tool, d, "ticker") if "ticker" in d else _req(tool, d, "sym"),
+               "ts": _req(tool, d, "timestamp"),
                "volume_calls": d.get("volume_calls"), "volume_puts": d.get("volume_puts"),
                "ratio": d.get("put_call_ratio"),
                "payload": payload, "captured_at": captured_at, "source": src}
         return "put_call_ratio", [row]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@poller/normalize.py` around lines 75 - 80, Update the put/call-ratio branch
in to_rows to validate ticker and timestamp using the existing _req helper,
matching the menthorq_dealer_positioning and other sibling branches. Replace the
default-empty ticker lookup and direct timestamp subscript with guarded values
before constructing the row, and add coverage in the poller normalization tests
for missing, null, and blank ticker or timestamp inputs for both supported
tools.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@poller/mcp_client.py`:
- Around line 95-102: Update the response loop in the relevant RPC method to
validate each line and convert malformed JSON or non-dict values into McpError
instead of leaking JSONDecodeError or TypeError. Establish an absolute deadline
before the loop, compute the remaining budget before every _readline call, and
pass that remaining timeout so skipped notifications cannot extend the total
wait beyond self._read_timeout; preserve response-ID validation and notification
skipping.

In `@poller/observe.py`:
- Around line 55-58: Update the cycle query used by status_report to order
grouped rows by cycle_id descending before the results are read via cycles[0].
Add ORDER BY cycle_id DESC to ensure the newest cycle is consistently reported
as last_cycle.

---

Outside diff comments:
In `@poller/db.py`:
- Around line 120-130: Update insert_rows to validate that every row has the
same key set before constructing the INSERT statement, rather than deriving
columns solely from rows[0]. Raise a clear error for heterogeneous row shapes;
preserve the existing empty-batch behavior and insertion/counting flow for valid
batches.

In `@poller/normalize.py`:
- Around line 75-80: Update the put/call-ratio branch in to_rows to validate
ticker and timestamp using the existing _req helper, matching the
menthorq_dealer_positioning and other sibling branches. Replace the
default-empty ticker lookup and direct timestamp subscript with guarded values
before constructing the row, and add coverage in the poller normalization tests
for missing, null, and blank ticker or timestamp inputs for both supported
tools.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b1302edb-fc03-499a-9ea0-0c6dd48e30cf

📥 Commits

Reviewing files that changed from the base of the PR and between f750cc6 and a63a4b3.

📒 Files selected for processing (11)
  • poller/db.py
  • poller/mcp_client.py
  • poller/normalize.py
  • poller/observe.py
  • poller/schedule.py
  • tests/poller/fake_mcp_server.py
  • tests/poller/test_db.py
  • tests/poller/test_mcp_client.py
  • tests/poller/test_normalize.py
  • tests/poller/test_observe.py
  • tests/poller/test_schedule.py

Comment thread poller/mcp_client.py
Comment on lines +95 to +102
while True:
resp = json.loads(self._readline())
if "id" not in resp or "method" in resp:
continue # server notification; not a response
if resp["id"] != self._next_id:
raise McpError(
f"unexpected response id {resp['id']!r}, expected {self._next_id}")
return resp

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate response shape and bound the total wait across skipped notifications.

Two gaps in the new response loop:

  1. json.loads(self._readline()) on Line 96 has no guard. A malformed or non-JSON line from the subprocess raises a raw json.JSONDecodeError instead of McpError. A non-dict JSON value (for example a bare number) makes "id" not in resp raise TypeError instead of McpError.
  2. _readline() applies self._read_timeout per individual read call. Because this loop can now iterate multiple times to skip notifications, a server that emits notifications faster than the timeout keeps _rpc blocked past the nominal _read_timeout budget.
🛡️ Proposed fix for malformed/non-dict responses
         while True:
-            resp = json.loads(self._readline())
+            try:
+                resp = json.loads(self._readline())
+            except json.JSONDecodeError as e:
+                raise McpError(f"invalid JSON from server: {e}") from e
+            if not isinstance(resp, dict):
+                raise McpError(f"unexpected non-object response: {resp!r}")
             if "id" not in resp or "method" in resp:
                 continue  # server notification; not a response
             if resp["id"] != self._next_id:
                 raise McpError(
                     f"unexpected response id {resp['id']!r}, expected {self._next_id}")
             return resp

For the timeout budget, track an absolute deadline before the loop and pass the remaining time to _readline on each iteration, so the total wait never exceeds self._read_timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@poller/mcp_client.py` around lines 95 - 102, Update the response loop in the
relevant RPC method to validate each line and convert malformed JSON or non-dict
values into McpError instead of leaking JSONDecodeError or TypeError. Establish
an absolute deadline before the loop, compute the remaining budget before every
_readline call, and pass that remaining timeout so skipped notifications cannot
extend the total wait beyond self._read_timeout; preserve response-ID validation
and notification skipping.

Comment thread poller/observe.py Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/RUNBOOK.md`:
- Around line 170-172: Update the manual run commands in the RUNBOOK section to
explicitly execute from the repository root, using a repository cd or absolute
paths. Ensure both the one-shot poll command and status command resolve the
virtual environment, poller module, database, and logs relative to the intended
repository.

In `@poller/poll.py`:
- Around line 36-44: Broaden the per-tool exception handling in the run_cycle
polling loop to catch AttributeError and TypeError in addition to the existing
exceptions, ensuring malformed tool payloads are recorded through record_call
and do not skip finish_cycle or abort remaining CAPTURE entries. Add a test in
test_poll.py covering a successful response with malformed data such as data:
None.
- Around line 62-70: Update the except McpError path in the poller cycle flow to
record setup failures before returning: call begin_cycle, record_call with a
non-null tool, the McpError, and source="poller", then call finish_cycle and
failure_streak. Preserve the existing spawn_failure log and return behavior
after recording the failed cycle.

In `@tests/poller/test_plist.py`:
- Around line 16-19: Strengthen the LaunchAgent assertions in the plist test by
comparing ProgramArguments to the exact expected list, rather than checking
membership. Replace the WorkingDirectory suffix check and log-path key-presence
checks with exact value assertions for WorkingDirectory, StandardOutPath, and
StandardErrorPath, using the expected contract values defined by the test setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d5ec61a-965c-4f3d-92ed-eb0aa06e3b07

📥 Commits

Reviewing files that changed from the base of the PR and between a63a4b3 and d67ca31.

📒 Files selected for processing (7)
  • docs/RUNBOOK.md
  • poller/com.gexhub.poller.plist
  • poller/poll.py
  • poller/status.py
  • tests/poller/test_plist.py
  • tests/poller/test_poll.py
  • tests/poller/test_status.py

Comment thread docs/RUNBOOK.md
Comment on lines +170 to +172
One shot: `.venv/bin/python3 -m poller.poll --once`
Status: `.venv/bin/python3 -m poller.status`
Logs: `logs/poller.jsonl` (cycles), `logs/launchagent.log` (stdout/stderr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

State the required working directory for manual runs.

The one-shot and status commands use a relative .venv/bin/python3. poller.poll also defaults to relative timeseries.db and logs/poller.jsonl (poller/poll.py Lines 50-54). A run from another directory can fail to import poller or write a second database and log. Add an explicit repository cd or use absolute paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/RUNBOOK.md` around lines 170 - 172, Update the manual run commands in
the RUNBOOK section to explicitly execute from the repository root, using a
repository cd or absolute paths. Ensure both the one-shot poll command and
status command resolve the virtual environment, poller module, database, and
logs relative to the intended repository.

Comment thread poller/poll.py
Comment on lines +36 to +44
try:
result = clients[key].call_tool(tool, args)
table, rows = to_rows(tool, result, cap)
written = insert_rows(conn, table, rows)
record_call(conn, cid, tool, result.http_status, written, None)
rows_total += written
except (McpError, ValueError, KeyError, sqlite3.Error) as e:
errors += 1
record_call(conn, cid, tool, None, 0, str(e)[:300])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Broaden the per-tool exception handling to avoid aborting the whole cycle.

The except tuple only covers McpError, ValueError, KeyError, sqlite3.Error. to_rows (poller/normalize.py) accesses d.get(...) and iterates items with it.get(...)/_first(it, ...) without verifying d/it is a dict. A tool response that is technically successful (result.ok) but returns data: None or a list containing a non-dict item raises AttributeError or TypeError, neither of which is caught here.

When that happens, the exception escapes run_cycle, the remaining CAPTURE entries for this cycle are skipped, and finish_cycle(conn, cid) on line 45 never runs, leaving the cycle record unfinished. This also propagates up through main(), which only catches McpError (line 65), crashing the process with an unhandled traceback instead of exiting cleanly with errors >= 1.

Widen the catch so a single malformed payload cannot take down the entire polling cycle:

🐛 Proposed fix
-        except (McpError, ValueError, KeyError, sqlite3.Error) as e:
+        except (McpError, ValueError, KeyError, TypeError, AttributeError, sqlite3.Error) as e:
             errors += 1
             record_call(conn, cid, tool, None, 0, str(e)[:300])

Also consider adding a test_poll.py case (e.g. a fixture with data: None) that exercises this path, since none of the current tests cover an unexpected/malformed payload shape.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
result = clients[key].call_tool(tool, args)
table, rows = to_rows(tool, result, cap)
written = insert_rows(conn, table, rows)
record_call(conn, cid, tool, result.http_status, written, None)
rows_total += written
except (McpError, ValueError, KeyError, sqlite3.Error) as e:
errors += 1
record_call(conn, cid, tool, None, 0, str(e)[:300])
try:
result = clients[key].call_tool(tool, args)
table, rows = to_rows(tool, result, cap)
written = insert_rows(conn, table, rows)
record_call(conn, cid, tool, result.http_status, written, None)
rows_total += written
except (McpError, ValueError, KeyError, TypeError, AttributeError, sqlite3.Error) as e:
errors += 1
record_call(conn, cid, tool, None, 0, str(e)[:300])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@poller/poll.py` around lines 36 - 44, Broaden the per-tool exception handling
in the run_cycle polling loop to catch AttributeError and TypeError in addition
to the existing exceptions, ensuring malformed tool payloads are recorded
through record_call and do not skip finish_cycle or abort remaining CAPTURE
entries. Add a test in test_poll.py covering a successful response with
malformed data such as data: None.

Comment thread poller/poll.py
Comment on lines +62 to +70
try:
with McpClient(MENTHORQ_ARGV) as m, McpClient(SPOTGAMMA_ARGV) as sg:
rep = run_cycle({"m": m, "sg": sg}, conn, logger)
except McpError as e:
logger.log({"event": "spawn_failure", "error": str(e)[:300]})
return 1
if failure_streak(conn) >= 3:
notify_macos("gex-poller", "3 consecutive failed cycles — check logs/poller.jsonl")
return 0 if rep["errors"] == 0 else 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect failure_streak's implementation to confirm what it counts as a "failed cycle".
fd -a 'observe.py' poller
rg -n -B2 -A20 'def failure_streak' poller/observe.py

Repository: filipesalvio-code/gex-hub

Length of output: 861


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- poller files ---'
git ls-files poller
printf '%s\n' '--- poll.py ---'
cat -n poller/poll.py
printf '%s\n' '--- observe.py ---'
cat -n poller/observe.py
printf '%s\n' '--- cycle and schema definitions/usages ---'
rg -n -C 4 'def (begin_cycle|record_call|finish_cycle)|CREATE TABLE.*scrape_runs|scrape_runs|run_cycle\(' poller

Repository: filipesalvio-code/gex-hub

Length of output: 12854


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- db.py schema and cycle helpers ---'
cat -n poller/db.py | sed -n '1,125p'
printf '%s\n' '--- McpClient context-manager behavior ---'
cat -n poller/mcp_client.py | sed -n '1,180p'
printf '%s\n' '--- tests and failure-streak references ---'
git ls-files | rg '(^|/)(test|tests)/|test_.*\.py$|.*_test\.py$' || true
rg -n -C 3 'failure_streak|spawn_failure|begin_cycle|record_call|finish_cycle' --glob '*.py' .
printf '%s\n' '--- standalone SQL shape probe ---'
python3 - <<'PY'
import sqlite3

conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE scrape_runs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  cycle_id INTEGER NOT NULL,
  source TEXT NOT NULL,
  tool TEXT,
  started_at TEXT NOT NULL,
  finished_at TEXT,
  http_status INTEGER,
  rows_written INTEGER DEFAULT 0,
  error TEXT
);
""")

def streak():
    cycles = conn.execute(
        "SELECT cycle_id, MAX(error IS NOT NULL) AS failed FROM scrape_runs"
        " WHERE tool IS NOT NULL GROUP BY cycle_id ORDER BY cycle_id DESC"
    ).fetchall()
    n = 0
    for row in cycles:
        if row["failed"]:
            n += 1
        else:
            break
    return n, [(row["cycle_id"], row["failed"]) for row in cycles]

# A run_cycle failure has a failed tool row plus its cycle marker.
conn.execute("INSERT INTO scrape_runs VALUES (1,1,'poller',NULL,'t','t',NULL,0,NULL)")
conn.execute("INSERT INTO scrape_runs VALUES (2,1,'menthorq','menthorq_x','t','t',NULL,0,'spawn/call error')")
# A successful cycle has a non-error tool row.
conn.execute("INSERT INTO scrape_runs VALUES (3,2,'poller',NULL,'t','t',NULL,0,NULL)")
conn.execute("INSERT INTO scrape_runs VALUES (4,2,'menthorq','menthorq_x','t','t',200,1,NULL)")
print("run_cycle-shaped data:", streak())

# A spawn-only cycle with no tool row is excluded.
conn.execute("INSERT INTO scrape_runs VALUES (5,3,'poller',NULL,'t','t',NULL,0,NULL)")
print("spawn-only marker:", streak())

# Adding the proposed failed tool row makes that cycle count.
conn.execute("INSERT INTO scrape_runs VALUES (6,3,'spotgamma','mcp_spawn','t','t',NULL,0,'spawn error')")
print("spawn row:", streak())
PY

Repository: filipesalvio-code/gex-hub

Length of output: 21132


Record McpClient setup failures as failed cycles before returning.

Call begin_cycle, record_call with a non-null tool and error, finish_cycle, and failure_streak in the except McpError path. Pass source="poller" to record_call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@poller/poll.py` around lines 62 - 70, Update the except McpError path in the
poller cycle flow to record setup failures before returning: call begin_cycle,
record_call with a non-null tool, the McpError, and source="poller", then call
finish_cycle and failure_streak. Preserve the existing spawn_failure log and
return behavior after recording the failed cycle.

Comment on lines +16 to +19
assert d["ProgramArguments"][0] == VENV_PYTHON
assert "-m" in d["ProgramArguments"] and "poller.poll" in d["ProgramArguments"]
assert d["WorkingDirectory"].endswith("gex-hub")
assert "StandardOutPath" in d and "StandardErrorPath" in d

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the complete LaunchAgent contract.

Membership checks allow invalid argument order and extra arguments. The suffix and key-presence checks allow incorrect working and log paths. Assert the exact ProgramArguments list and exact values for WorkingDirectory, StandardOutPath, and StandardErrorPath.

Suggested assertions
-    assert d["ProgramArguments"][0] == VENV_PYTHON
-    assert "-m" in d["ProgramArguments"] and "poller.poll" in d["ProgramArguments"]
-    assert d["WorkingDirectory"].endswith("gex-hub")
-    assert "StandardOutPath" in d and "StandardErrorPath" in d
+    repo_root = PLIST.parents[1]
+    assert d["ProgramArguments"] == [VENV_PYTHON, "-m", "poller.poll"]
+    assert d["WorkingDirectory"] == str(repo_root)
+    assert d["StandardOutPath"] == str(repo_root / "logs" / "launchagent.log")
+    assert d["StandardErrorPath"] == str(repo_root / "logs" / "launchagent.log")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert d["ProgramArguments"][0] == VENV_PYTHON
assert "-m" in d["ProgramArguments"] and "poller.poll" in d["ProgramArguments"]
assert d["WorkingDirectory"].endswith("gex-hub")
assert "StandardOutPath" in d and "StandardErrorPath" in d
repo_root = PLIST.parents[1]
assert d["ProgramArguments"] == [VENV_PYTHON, "-m", "poller.poll"]
assert d["WorkingDirectory"] == str(repo_root)
assert d["StandardOutPath"] == str(repo_root / "logs" / "launchagent.log")
assert d["StandardErrorPath"] == str(repo_root / "logs" / "launchagent.log")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/poller/test_plist.py` around lines 16 - 19, Strengthen the LaunchAgent
assertions in the plist test by comparing ProgramArguments to the exact expected
list, rather than checking membership. Replace the WorkingDirectory suffix check
and log-path key-presence checks with exact value assertions for
WorkingDirectory, StandardOutPath, and StandardErrorPath, using the expected
contract values defined by the test setup.

@filipesalvio-code

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread poller/mcp_client.py
Comment on lines +106 to +113
if "error" in r:
raise McpError(f"{name}: {r['error'].get('message')}")
result = r.get("result", {})
if result.get("isError"):
text = result["content"][0]["text"] if result.get("content") else "isError"
return ToolResult(name, ok=False, error=text[:300], raw=text)
text = result["content"][0]["text"]
return parse_tool_text(name, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Unguarded content[0] access raises IndexError outside the catch

The success path text = result["content"][0]["text"] will raise IndexError when an MCP server returns a well-formed but empty content list ({"result": {"content": []}}), and KeyError when "content" is absent entirely. Neither exception is in run_cycle's catch tuple (McpError, ValueError, KeyError, sqlite3.Error)IndexError escapes entirely, propagates out of run_cycle before finish_cycle is called, and the cycle row in scrape_runs is left without a finished_at, while main() receives an unhandled exception and no structured log entry is written.

Prompt To Fix With AI
This is a comment left during a code review.
Path: poller/mcp_client.py
Line: 106-113

Comment:
**Unguarded `content[0]` access raises `IndexError` outside the catch**

The success path `text = result["content"][0]["text"]` will raise `IndexError` when an MCP server returns a well-formed but empty content list (`{"result": {"content": []}}`), and `KeyError` when `"content"` is absent entirely. Neither exception is in `run_cycle`'s catch tuple `(McpError, ValueError, KeyError, sqlite3.Error)``IndexError` escapes entirely, propagates out of `run_cycle` before `finish_cycle` is called, and the cycle row in `scrape_runs` is left without a `finished_at`, while `main()` receives an unhandled exception and no structured log entry is written.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Cursor Fix in Codex

Comment thread poller/normalize.py
Comment on lines +75 to +76
if tool in ("menthorq_put_call_ratio", "spotgamma_equity_put_call_ratio"):
row = {"ticker": d.get("ticker", d.get("sym", "")), "ts": d["timestamp"],

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 d["timestamp"] raises an opaque KeyError on missing field — every other required field in to_rows uses _req() which raises an informative ValueError. The KeyError IS caught by run_cycle, but the recorded error string will be "'timestamp'" rather than "menthorq_put_call_ratio: missing required field 'timestamp'", making log-based debugging harder.

Suggested change
if tool in ("menthorq_put_call_ratio", "spotgamma_equity_put_call_ratio"):
row = {"ticker": d.get("ticker", d.get("sym", "")), "ts": d["timestamp"],
if tool in ("menthorq_put_call_ratio", "spotgamma_equity_put_call_ratio"):
row = {"ticker": d.get("ticker", d.get("sym", "")), "ts": _req(tool, d, "timestamp"),
Prompt To Fix With AI
This is a comment left during a code review.
Path: poller/normalize.py
Line: 75-76

Comment:
`d["timestamp"]` raises an opaque `KeyError` on missing field — every other required field in `to_rows` uses `_req()` which raises an informative `ValueError`. The `KeyError` IS caught by `run_cycle`, but the recorded error string will be `"'timestamp'"` rather than `"menthorq_put_call_ratio: missing required field 'timestamp'"`, making log-based debugging harder.

```suggestion
    if tool in ("menthorq_put_call_ratio", "spotgamma_equity_put_call_ratio"):
        row = {"ticker": d.get("ticker", d.get("sym", "")), "ts": _req(tool, d, "timestamp"),
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Cursor Fix in Codex

Comment thread poller/db.py
Comment on lines +119 to +126

def insert_rows(conn: sqlite3.Connection, table: str, rows: list[dict]) -> int:
if table not in DATA_TABLES:
raise ValueError(f"unknown table: {table}")
if not rows:
return 0
cols = list(rows[0].keys())
sql = f"INSERT OR IGNORE INTO {table} ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})"

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 Column names interpolated into SQL without validation

cols = list(rows[0].keys()) are injected verbatim into the INSERT statement: f"INSERT OR IGNORE INTO {table} ({','.join(cols)}) VALUES (...)". The table name is validated against DATA_TABLES, but the column names are not. A caller passing a row dict with a key like "gex_1, payload) SELECT 1; --" would produce malformed (or exploitable) SQL. Today all callers in normalize.py use hardcoded keys, but the lack of a column-name allowlist at the DB layer is one refactor away from a latent injection path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: poller/db.py
Line: 119-126

Comment:
**Column names interpolated into SQL without validation**

`cols = list(rows[0].keys())` are injected verbatim into the `INSERT` statement: `f"INSERT OR IGNORE INTO {table} ({','.join(cols)}) VALUES (...)"`. The table name is validated against `DATA_TABLES`, but the column names are not. A caller passing a row dict with a key like `"gex_1, payload) SELECT 1; --"` would produce malformed (or exploitable) SQL. Today all callers in `normalize.py` use hardcoded keys, but the lack of a column-name allowlist at the DB layer is one refactor away from a latent injection path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Cursor Fix in Codex

Comment on lines +1 to +17
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.gexhub.poller</string>
<key>ProgramArguments</key>
<array>
<string>/Users/filipesalvio/gex-hub/.venv/bin/python3</string>
<string>-m</string>
<string>poller.poll</string>
</array>
<key>WorkingDirectory</key><string>/Users/filipesalvio/gex-hub</string>
<key>StartInterval</key><integer>900</integer>
<key>StandardOutPath</key><string>/Users/filipesalvio/gex-hub/logs/launchagent.log</string>
<key>StandardErrorPath</key><string>/Users/filipesalvio/gex-hub/logs/launchagent.log</string>
</dict>
</plist>

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 Hardcoded user-specific paths committed to shared repo

/Users/filipesalvio/gex-hub is hardcoded as both the Python interpreter path and WorkingDirectory. test_plist.py also hardcodes this value in VENV_PYTHON, so anyone cloning the repo to a different machine will have a test that asserts an incorrect path. The RUNBOOK should either document how to regenerate the plist or substitute $HOME / a placeholder, and the test should assert the structural contract (e.g., path ends with .venv/bin/python3) rather than a literal user path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: poller/com.gexhub.poller.plist
Line: 1-17

Comment:
**Hardcoded user-specific paths committed to shared repo**

`/Users/filipesalvio/gex-hub` is hardcoded as both the Python interpreter path and `WorkingDirectory`. `test_plist.py` also hardcodes this value in `VENV_PYTHON`, so anyone cloning the repo to a different machine will have a test that asserts an incorrect path. The RUNBOOK should either document how to regenerate the plist or substitute `$HOME` / a placeholder, and the test should assert the structural contract (e.g., path ends with `.venv/bin/python3`) rather than a literal user path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Cursor Fix in Codex

Comment thread poller/db.py
Comment on lines +64 to +78
def _migrate_put_call_ratio(conn: sqlite3.Connection) -> None:
row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table'"
" AND name='put_call_ratio'").fetchone()
if row is None or "UNIQUE (ticker, ts, source)" in row[0]:
return
conn.executescript(
"ALTER TABLE put_call_ratio RENAME TO put_call_ratio_old;\n"
+ _PCR_NEW_SQL + "\n"
"INSERT OR IGNORE INTO put_call_ratio"
" (ticker, ts, volume_calls, volume_puts, ratio, payload, captured_at, source)"
" SELECT ticker, ts, volume_calls, volume_puts, ratio, payload, captured_at, source"
" FROM put_call_ratio_old;\n"
"DROP TABLE put_call_ratio_old;")
conn.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Non-atomic migration can orphan historical PCR data

Python's executescript() issues an implicit COMMIT before running the script, leaving all subsequent statements in autocommit mode — each statement commits immediately with no rollback on failure. If the process is killed after ALTER TABLE put_call_ratio RENAME TO put_call_ratio_old commits (step 1) but before CREATE TABLE put_call_ratio runs (step 2), sqlite_master no longer has a put_call_ratio entry. On the next startup _migrate_put_call_ratio evaluates row is None → return, skips the migration entirely, and conn.executescript(_SCHEMA) creates a fresh empty put_call_ratio via CREATE TABLE IF NOT EXISTS — leaving all the historical rows stranded in put_call_ratio_old with no path to recover them.

Adding BEGIN IMMEDIATE;\n at the start of the script and \nCOMMIT; at the end makes the entire migration a single SQLite transaction; a mid-flight crash rolls it back so the old schema is intact on the next startup and migration is reattempted correctly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: poller/db.py
Line: 64-78

Comment:
**Non-atomic migration can orphan historical PCR data**

Python's `executescript()` issues an implicit `COMMIT` before running the script, leaving all subsequent statements in autocommit mode — each statement commits immediately with no rollback on failure. If the process is killed after `ALTER TABLE put_call_ratio RENAME TO put_call_ratio_old` commits (step 1) but before `CREATE TABLE put_call_ratio` runs (step 2), `sqlite_master` no longer has a `put_call_ratio` entry. On the next startup `_migrate_put_call_ratio` evaluates `row is None → return`, skips the migration entirely, and `conn.executescript(_SCHEMA)` creates a fresh empty `put_call_ratio` via `CREATE TABLE IF NOT EXISTS` — leaving all the historical rows stranded in `put_call_ratio_old` with no path to recover them.

Adding `BEGIN IMMEDIATE;\n` at the start of the script and `\nCOMMIT;` at the end makes the entire migration a single SQLite transaction; a mid-flight crash rolls it back so the old schema is intact on the next startup and migration is reattempted correctly.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Cursor Fix in Codex

@filipesalvio-code
filipesalvio-code merged commit e052cb4 into main Aug 3, 2026
6 checks passed
@filipesalvio-code
filipesalvio-code deleted the feat/gex-poller branch August 3, 2026 00:52
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