feat: hosted reasoning-channel delivery (pull-based, tenant-scoped)#407
feat: hosted reasoning-channel delivery (pull-based, tenant-scoped)#407glassBead-tc wants to merge 3 commits into
Conversation
Hosted (multi-tenant) Cloud Run can't deliver the in-process /events SSE stream across replicas, so the reasoning channel had nothing to consume. Add a tenant-scoped, append-only protocol_events table (claims/hub RLS pattern) fed by the same onProtocolEvent emit stream, so hosted persistence equals local SSE byte-for-byte across all nine lifecycle types. Distinct from protocol_history, which stores a lossy operation- level subset. Expose it via GET /protocol/events?changed_since=, scoped to the caller's workspace by API key so no key reads another tenant's events. Implements SPEC-REASONING-CHANNEL-HOSTED:c2, c3. Storage + cross-tenant negative-control tests pass against local Supabase; the authed HTTP e2e is deploy-gated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Against a hosted server the channel can't hold an SSE connection (multi- replica), so add a PollingEventClient that pulls GET /protocol/events with a changed_since cursor. It primes to the tail on connect so a fresh channel reacts to new events instead of replaying old sessions, and derives the top-level sessionId from data.session_id (the shape EventFilter expects). The channel selects transport by URL host (localhost -> SSE, remote -> poll), overridable via THOUGHTBOX_CHANNEL_MODE. Local SSE delivery is unchanged. Implements SPEC-REASONING-CHANNEL-HOSTED:c4, c5. Polling unit tests run under the root vitest (plugin test path added); plugin build excludes __tests__ from the shipped dist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Updates to Preview Branch (feat/hosted-reasoning-channel-server) ↗︎
Tasks are run on every commit but only new migration files are pushed.
View logs for this Workflow Run ↗︎. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 754533f28f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .from('protocol_events') | ||
| .select('id, source, type, event_timestamp, data') | ||
| .eq('tenant_workspace_id', this.tenantWorkspaceId) | ||
| .gt('id', cursor) |
There was a problem hiding this comment.
Use a commit-safe cursor for protocol_events
In hosted workspaces with concurrent protocol event appends, this high-water mark can silently skip rows. PostgreSQL identity/sequence values are allocated before commit, so if event id 11 commits and is returned before an earlier transaction with id 10 becomes visible, the client advances its cursor to 11 and the later-visible id 10 will never match id > cursor; Cloud Run replicas/fire-and-forget appends make that interleaving possible. Use a cursor based on commit-visible ordering or poll with overlap/deduping so committed rows cannot be skipped.
Useful? React with 👍 / 👎.
| } catch (error) { | ||
| this.reportError(error); | ||
| } | ||
| this.scheduleNext(this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS); |
There was a problem hiding this comment.
Do not emit backlog after failed priming
When the initial prime-to-tail request fails transiently, cursor remains at 0 but the client still enters the normal emitting poll loop. The next successful poll will therefore deliver the existing backlog that priming was supposed to suppress, which can replay completed Ulysses/Theseus sessions into the user's channel after a startup blip. Keep retrying the non-emitting prime path, or otherwise mark priming incomplete, before scheduling normal polls.
Useful? React with 👍 / 👎.
Greptile SummaryImplements the hosted (multi-tenant) reasoning-channel delivery path: a
Confidence Score: 4/5Safe to merge; all multi-tenant guards and tenant-isolation properties are correctly enforced. The issues found are in the polling client's session-filter handling and observability, not in data isolation or correctness. The migration, storage layer, pull endpoint, and transport-selection logic are all solid. The main concern is in plugins/thoughtbox-claude-code/src/polling-event-client.ts — the session-filter gap and prime-failure behavior warrant a second look before this becomes a higher-traffic path. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant CC as Claude Code
participant CH as thoughtbox-channel (plugin)
participant SRV as Thoughtbox Server (Cloud Run)
participant DB as Supabase (protocol_events)
Note over CH: selectTransport(baseUrl)<br/>localhost → SSE, remote → poll
alt Local mode (SSE)
CC->>CH: MCP session start
CH->>SRV: "GET /events?session_id=... (SSE, Bearer key)"
SRV-->>CH: text/event-stream (in-process broadcast)
SRV->>CH: protocol event (ThoughtboxEvent)
CH->>CC: notifications/claude/channel
else Hosted mode (poll)
CC->>CH: MCP session start
CH->>SRV: GET /protocol/events (prime to tail, no emit)
SRV->>DB: "SELECT id, ... WHERE tenant=T AND id > 0"
DB-->>SRV: existing events
SRV-->>CH: "{events, cursor}"
Note over CH: cursor advanced to tail, onConnect fired
loop every 3 s
CH->>SRV: "GET /protocol/events?changed_since=cursor"
SRV->>DB: "SELECT ... WHERE tenant=T AND id > cursor"
DB-->>SRV: new events
SRV-->>CH: "{events, nextCursor}"
CH->>CC: notifications/claude/channel (per new event)
end
Note over SRV: onProtocolEvent (MCP session)
SRV->>DB: INSERT INTO protocol_events (tenant, type, data, ...)
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant CC as Claude Code
participant CH as thoughtbox-channel (plugin)
participant SRV as Thoughtbox Server (Cloud Run)
participant DB as Supabase (protocol_events)
Note over CH: selectTransport(baseUrl)<br/>localhost → SSE, remote → poll
alt Local mode (SSE)
CC->>CH: MCP session start
CH->>SRV: "GET /events?session_id=... (SSE, Bearer key)"
SRV-->>CH: text/event-stream (in-process broadcast)
SRV->>CH: protocol event (ThoughtboxEvent)
CH->>CC: notifications/claude/channel
else Hosted mode (poll)
CC->>CH: MCP session start
CH->>SRV: GET /protocol/events (prime to tail, no emit)
SRV->>DB: "SELECT id, ... WHERE tenant=T AND id > 0"
DB-->>SRV: existing events
SRV-->>CH: "{events, cursor}"
Note over CH: cursor advanced to tail, onConnect fired
loop every 3 s
CH->>SRV: "GET /protocol/events?changed_since=cursor"
SRV->>DB: "SELECT ... WHERE tenant=T AND id > cursor"
DB-->>SRV: new events
SRV-->>CH: "{events, nextCursor}"
CH->>CC: notifications/claude/channel (per new event)
end
Note over SRV: onProtocolEvent (MCP session)
SRV->>DB: INSERT INTO protocol_events (tenant, type, data, ...)
end
|
| const override = process.env.THOUGHTBOX_CHANNEL_MODE; | ||
| if (override === "sse" || override === "poll") return override; | ||
| try { |
There was a problem hiding this comment.
Invalid
THOUGHTBOX_CHANNEL_MODE value silently falls through
If THOUGHTBOX_CHANNEL_MODE is set to anything other than "sse" or "poll" (e.g., a typo like "SSE" or "polling"), the function silently falls back to URL-based detection with no log output. This makes misconfiguration hard to diagnose. A one-line warning before the fallback would surface the bad value immediately.
| const override = process.env.THOUGHTBOX_CHANNEL_MODE; | |
| if (override === "sse" || override === "poll") return override; | |
| try { | |
| const override = process.env.THOUGHTBOX_CHANNEL_MODE; | |
| if (override === "sse" || override === "poll") return override; | |
| if (override !== undefined) { | |
| console.error(`[Channel] Unknown THOUGHTBOX_CHANNEL_MODE="${override}"; falling back to URL-based detection`); | |
| } | |
| try { |
Prompt To Fix With AI
This is a comment left during a code review.
Path: plugins/thoughtbox-claude-code/src/thoughtbox-channel.ts
Line: 35-37
Comment:
**Invalid `THOUGHTBOX_CHANNEL_MODE` value silently falls through**
If `THOUGHTBOX_CHANNEL_MODE` is set to anything other than `"sse"` or `"poll"` (e.g., a typo like `"SSE"` or `"polling"`), the function silently falls back to URL-based detection with no log output. This makes misconfiguration hard to diagnose. A one-line warning before the fallback would surface the bad value immediately.
```suggestion
const override = process.env.THOUGHTBOX_CHANNEL_MODE;
if (override === "sse" || override === "poll") return override;
if (override !== undefined) {
console.error(`[Channel] Unknown THOUGHTBOX_CHANNEL_MODE="${override}"; falling back to URL-based detection`);
}
try {
```
How can I resolve this? If you propose a fix, please make it concise.|
@cursoragent Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes. Issue 1 of 3plugins/thoughtbox-claude-code/src/polling-event-client.ts:497-513
Issue 2 of 3plugins/thoughtbox-claude-code/src/thoughtbox-channel.ts:35-37 If Issue 3 of 3plugins/thoughtbox-claude-code/src/polling-event-client.ts:431-446 If |
|
Summary:
Tests:
Notes:
|


What
Delivers the hosted (multi-tenant) path for the reasoning channel: protocol lifecycle events reach the Claude Code channel from the deployed server, scoped per workspace. Implements
SPEC-REASONING-CHANNEL-HOSTED:c2–c5.Stacked on #406 (plugin wiring / c1) — review/merge that first; this targets its branch and shows only the hosted path.
Why it was needed
In hosted mode the server (a) never passed
onProtocolEventin the multi-tenant session branch and (b) only mounts/eventsSSE locally — and in-process SSE can't span Cloud Run replicas anyway. So a hosted channel had nothing to consume.Changes
protocol_eventstable (c2) — tenant-scoped + RLS (claims/hub pattern), append-only, bigint identity as the pull cursor. Migration20260615000000. A dedicated table, notprotocol_history(which stores only a lossy operation-level subset — missing init/visa/complete and the ulysses/theseus taxonomy). Fed by the sameonProtocolEventemit stream, so hosted persistence == local SSE byte-for-byte.GET /protocol/events?changed_since=<cursor>, workspace resolved from the API key; a key can never read another tenant's events.PollingEventClientin the plugin; the channel selects SSE (localhost) vs polling (remote) by URL host, overridable viaTHOUGHTBOX_CHANNEL_MODE. Primes to the tail on connect (no replay); derivessessionIdfromdata.session_id.Design note: pull, not push
Mirrors B3's pull philosophy (
SPEC-AGX-SUBSTRATE §11.1). Realtime/push delivery is deferred to B6/B8 so the realtime transport is defined once, there.Verification
protocol_eventsmigration applied to local Supabase; types regenerated./protocol/eventsmounted and auth-gated (401 on missing/bogus key). Full authed e2e is deploy-gated (same posture as claims c2).tsc --noEmit+oxlintclean.Deploy note
Touches
supabase/migrations/**→ staging auto-apply on push. Out-of-order timestamp is tolerated (set-based applier + sorted-set drift check).🤖 Generated with Claude Code