Skip to content

chore: rolling promotion dev -> main - #1116

Merged
namastex888 merged 17 commits into
mainfrom
dev
Apr 9, 2026
Merged

namastex888 merged 17 commits into
mainfrom
dev

Conversation

@namastex888

@namastex888 namastex888 commented Apr 9, 2026 •

Copy link
Copy Markdown
Contributor

Rolling Promotion PR

Auto-maintained rolling promotion PR from dev to main.

Process:

  • This PR is automatically created and kept open
  • Human reviews and merges when ready
  • Label ready-to-merge added when all checks pass

IMPORTANT: Merge with "Create a merge commit" — NEVER squash.
Squash merging breaks history sync between dev and main,
causing the next rolling PR to show all commits again.

Human approval required for merge to production.

Summary by CodeRabbit

  • New Features

    • SDK audit events now appear in unified logs and show a “[SDK]” tag in terminal output.
    • DB-backed persistent bridge sessions with recovery; Omni Bridge is managed at startup/shutdown and its status is shown in serve/status and doctor.
    • Streaming sessions now emit session lines in NDJSON/JSON for easier tooling consumption.
  • Tests

    • Added integration tests for SDK audit events, ordering, and filtering.
  • Chores

    • Bumped package/plugin version to 4.260409.8.

github-actions Bot and others added 2 commits April 9, 2026 12:40
`genie log` now aggregates SDK agent events from the audit_events table,
so SDK agents (NATS bridge / --executor sdk) get the same observability
as CLI agents.

- Add 'sdk' to RuntimeEventSource type
- Add readSdkAuditEvents() querying audit_events by actor (agentId)
- Map sdk.* event types to existing LogEventKind (no new kinds)
- Wire into readAgentLog, readTeamLog, and follow mode (cursor-based poller)
- Add [SDK] visual tag in log output to distinguish SDK vs CLI events
- Add 12 new tests (unit + integration) for SDK event aggregation

Closes #1101

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 9, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6b78ccc3-e38b-4db6-93b3-dab6c0fadf12

📥 Commits

Reviewing files that changed from the base of the PR and between 72c7dff and 42c3062.

📒 Files selected for processing (4)
  • .claude-plugin/marketplace.json
  • package.json
  • plugins/genie/.claude-plugin/plugin.json
  • plugins/genie/package.json

📝 Walkthrough

Walkthrough

Bumps package/plugin versions to 4.260409.8 and adds SDK audit event support: new runtime source 'sdk', DB reader/mapper for SDK audit rows, inclusion of SDK events in agent/team log readers and follow polling, plus related CLI, session persistence, test, and streaming adjustments.

Changes

Cohort / File(s) Summary
Version bumps
\.claude-plugin/marketplace.json, package.json, plugins/genie/.claude-plugin/plugin.json, plugins/genie/package.json
Updated genie package/plugin version from 4.260409.4 → 4.260409.8.
Runtime event type
src/lib/runtime-events.ts
Extended RuntimeEventSource union to include 'sdk'.
Unified log & SDK audit integration
src/lib/unified-log.ts, src/lib/unified-log.test.ts
Added SDK kind map, sdkAuditRowToLogEvent, readSdkAuditEvents; integrated SDK events into readAgentLog/readTeamLog; added DB poller in startPgFollow with dedup/emission and stop handling; tests updated/added for SDK audit rows.
Bridge session persistence
src/services/bridge-session-store.ts, src/db/migrations/035_bridge_sessions.sql, src/services/omni-bridge.ts
Added BridgeSessionStore and migration for genie_bridge_sessions; omni-bridge persists/recovers sessions, records activity, updates/closes PG rows, and adjusts stop/spawn/remove semantics.
Omni serve integration & CLI
src/term-commands/serve.ts, src/term-commands/omni.ts, src/genie-commands/doctor.ts
Wired omni bridge startup/shutdown into serve, added --standalone for omni start, status printing, and a doctor check for Omni Bridge.
Agents streaming & session handling
src/term-commands/agents.ts
Refactored runSdkQuery streaming pipeline; emits genie_session NDJSON/JSON when streaming and dbSessionId present; changed tool correlation and message processing; removed embedded-tool assistant final record.
Terminal display
src/term-commands/log.ts
formatEventBlock appends [SDK] tag when event.source === 'sdk'.
SDK test mocks consolidation
src/__tests__/_shared-sdk-query-mock.ts, src/__tests__/sdk-integration.test.ts, src/services/executors/__tests__/_sdk-mocks.ts
Added shared SDK mock module and migrated tests to use it (removed per-file module mocks and duplicated generators).
Tests & expectations
src/services/__tests__/omni-bridge.test.ts, src/term-commands/msg.test.ts, src/lib/unified-log.test.ts
Updated tests to reflect tmux shutdown semantics, explicit promptMode: 'append', and added SDK-audit related test coverage.
Lint/no-op directives
src/services/executors/claude-sdk.ts, src/services/omni-bridge.ts
Added biome-ignore complexity directives on private methods (no logic changes).

Sequence Diagram

sequenceDiagram
    participant Client
    participant UnifiedLog as readAgentLog/readTeamLog
    participant DB as audit_events DB
    participant Mapper as sdkAuditRowToLogEvent
    participant Follower as startPgFollow
    participant Deduper as dedupAndEmit

    Client->>UnifiedLog: request unified log (agentId/teamId)
    par concurrent fetch
        UnifiedLog->>UnifiedLog: fetch runtime events
    and
        UnifiedLog->>DB: query audit_events (entity_type='sdk_message', actor=agentId)
        DB-->>UnifiedLog: audit rows
        UnifiedLog->>Mapper: map rows -> LogEvent (source='sdk')
        Mapper-->>UnifiedLog: SDK LogEvents
    end
    UnifiedLog->>Client: return combined events (sorted & filtered)
    Note over Follower,DB: Background follow poller
    Follower->>DB: poll new audit_events (cursor by max id)
    DB-->>Follower: new rows
    Follower->>Mapper: map -> LogEvent
    Mapper->>Deduper: dedupAndEmit(mapped events)
    Deduper-->>Client: streamed LogEvents
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'chore: rolling promotion dev -> main' accurately describes the main purpose—an automated rolling promotion from dev to main branch with human review required.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

genie and others added 2 commits April 9, 2026 13:59
Tests for --append-system-prompt-file were reading the local genie
config instead of using an explicit promptMode, causing failures when
the local config has promptMode: "system".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat(log): bridge SDK agent events into genie log

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates the version number for the genie plugin across multiple configuration and package files, specifically incrementing it from 4.260409.4 to 4.260409.5. There are no review comments to provide feedback on.

@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 the current code and only fix it if needed.

Inline comments:
In `@src/lib/unified-log.ts`:
- Around line 240-247: The query currently uses `ORDER BY created_at ASC LIMIT
${limit}` which returns oldest rows first and breaks `filter.last`; change the
SQL in the `sql.unsafe(...)` call to `ORDER BY created_at DESC LIMIT ${limit}`
so the newest rows are selected, then reverse the resulting `rows` array before
passing it downstream (or into `applyLogFilter()`), ensuring the returned order
is ascending by created_at while keeping the source-side limit behavior.
- Around line 226-249: readSdkAuditEvents currently only filters by entity_type
and actor, which allows SDK audit rows to leak across repos/teams; update the
audit schema/payload to include a repository or team discriminator (e.g.,
repo_id or team_id) and ensure all SDK audit emits include that discriminator,
then modify readSdkAuditEvents to add a WHERE condition (append to conditions
and values) that filters by the incoming repo/team id from the LogFilter before
executing the sql. Also update any other SDK-audit read logic (the other SDK
read at the later block referenced) to apply the same repo/team filter and
ensure the AuditEventRow type and any emit/insert paths include the new
discriminator field so the DB queries can filter correctly.
- Around line 251-256: The loops in readAgentLog/readTeamLog (and the similar
loop later) drop rows when SDK_KIND_MAP[row.event_type] is falsy even though
sdkAuditRowToLogEvent already handles unknown types by mapping to "system";
remove the SDK_KIND_MAP conditional and always call sdkAuditRowToLogEvent(row)
and push its result into events (and do the same in the second occurrence) so
unmapped SDK event types fall back to system instead of being silently excluded.

In `@src/term-commands/msg.test.ts`:
- Around line 262-267: The test title says it verifies the default promptMode
path but passes promptMode:'append', so either (A) change this test to not pass
promptMode (remove promptMode from the options passed to buildTeamLeadCommand)
so it exercises the options?.promptMode ?? loadGenieConfigSync().promptMode
branch and still asserts '--append-system-prompt-file' and the file path, or (B)
add a new deterministic test that calls buildTeamLeadCommand('genie', {
systemPromptFile: '/tmp/test-agents.md' }) while mocking loadGenieConfigSync()
to return promptMode:'append' and then assert the flag and path; reference
buildTeamLeadCommand and loadGenieConfigSync when implementing the change.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0f454c0e-250f-480c-9916-503739784047

📥 Commits

Reviewing files that changed from the base of the PR and between 7849a27 and a03c844.

📒 Files selected for processing (9)
  • .claude-plugin/marketplace.json
  • package.json
  • plugins/genie/.claude-plugin/plugin.json
  • plugins/genie/package.json
  • src/lib/runtime-events.ts
  • src/lib/unified-log.test.ts
  • src/lib/unified-log.ts
  • src/term-commands/log.ts
  • src/term-commands/msg.test.ts

Comment thread src/lib/unified-log.ts
Comment on lines +226 to +249
async function readSdkAuditEvents(agentId: string, filter?: LogFilter): Promise<LogEvent[]> {
try {
if (!(await isAvailable())) return [];
const sql = await getConnection();

const conditions = [`entity_type = 'sdk_message'`, 'actor = $1'];
const values: unknown[] = [agentId];
let paramIdx = 2;

if (filter?.since) {
conditions.push(`created_at >= $${paramIdx++}::timestamptz`);
values.push(filter.since);
}

const where = `WHERE ${conditions.join(' AND ')}`;
const limit = filter?.last ?? 500;

const rows = (await sql.unsafe(
`SELECT id, entity_type, entity_id, event_type, actor, details, created_at
FROM audit_events ${where}
ORDER BY created_at ASC
LIMIT ${limit}`,
values,
)) as unknown as AuditEventRow[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

SDK audit reads ignore repo/team scope and can leak events across workspaces.

These queries only filter on entity_type='sdk_message' and actor, so genie log for one repo/team will also pull SDK rows emitted by the same actor in other repos. Every other source in this aggregator is repo-scoped; this one is global. Please add a repo/team discriminator to the audit payload/schema and filter on it before merging or emitting.

Also applies to: 479-487

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/unified-log.ts` around lines 226 - 249, readSdkAuditEvents currently
only filters by entity_type and actor, which allows SDK audit rows to leak
across repos/teams; update the audit schema/payload to include a repository or
team discriminator (e.g., repo_id or team_id) and ensure all SDK audit emits
include that discriminator, then modify readSdkAuditEvents to add a WHERE
condition (append to conditions and values) that filters by the incoming
repo/team id from the LogFilter before executing the sql. Also update any other
SDK-audit read logic (the other SDK read at the later block referenced) to apply
the same repo/team filter and ensure the AuditEventRow type and any emit/insert
paths include the new discriminator field so the DB queries can filter
correctly.

Comment thread src/lib/unified-log.ts
Comment on lines +240 to +247
const where = `WHERE ${conditions.join(' AND ')}`;
const limit = filter?.last ?? 500;

const rows = (await sql.unsafe(
`SELECT id, entity_type, entity_id, event_type, actor, details, created_at
FROM audit_events ${where}
ORDER BY created_at ASC
LIMIT ${limit}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The SDK source-side limit returns the oldest rows, not the latest ones.

ORDER BY created_at ASC LIMIT ${limit} makes filter.last wrong for SDK events, and after 500 SDK rows the recent ones disappear entirely from the aggregated log. Fetch the newest rows (DESC) and reverse them, or remove the source-side limit and let applyLogFilter() own last.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/unified-log.ts` around lines 240 - 247, The query currently uses
`ORDER BY created_at ASC LIMIT ${limit}` which returns oldest rows first and
breaks `filter.last`; change the SQL in the `sql.unsafe(...)` call to `ORDER BY
created_at DESC LIMIT ${limit}` so the newest rows are selected, then reverse
the resulting `rows` array before passing it downstream (or into
`applyLogFilter()`), ensuring the returned order is ascending by created_at
while keeping the source-side limit behavior.

Comment thread src/lib/unified-log.ts
Comment on lines +251 to +256
const events: LogEvent[] = [];
for (const row of rows) {
// Only include event types we have a mapping for
if (SDK_KIND_MAP[row.event_type]) {
events.push(sdkAuditRowToLogEvent(row));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unmapped SDK event types are being dropped instead of falling back to system.

sdkAuditRowToLogEvent() already has a safe fallback for unknown event_types, but both readers still guard on SDK_KIND_MAP[row.event_type]. That means any new SDK event type is silently excluded from readAgentLog, readTeamLog, and follow mode instead of showing up as a system event.

Suggested fix
-    const events: LogEvent[] = [];
-    for (const row of rows) {
-      // Only include event types we have a mapping for
-      if (SDK_KIND_MAP[row.event_type]) {
-        events.push(sdkAuditRowToLogEvent(row));
-      }
-    }
+    const events: LogEvent[] = rows.map((row) => sdkAuditRowToLogEvent(row));
-    for (const row of rows) {
-      if (SDK_KIND_MAP[row.event_type]) {
-        dedupAndEmit(sdkAuditRowToLogEvent(row));
-      }
+    for (const row of rows) {
+      dedupAndEmit(sdkAuditRowToLogEvent(row));
       sdkLastId = Math.max(sdkLastId, Number(row.id));
     }

Also applies to: 489-492

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/unified-log.ts` around lines 251 - 256, The loops in
readAgentLog/readTeamLog (and the similar loop later) drop rows when
SDK_KIND_MAP[row.event_type] is falsy even though sdkAuditRowToLogEvent already
handles unknown types by mapping to "system"; remove the SDK_KIND_MAP
conditional and always call sdkAuditRowToLogEvent(row) and push its result into
events (and do the same in the second occurrence) so unmapped SDK event types
fall back to system instead of being silently excluded.

Comment on lines 262 to 267
test('includes --append-system-prompt-file when systemPromptFile provided (default promptMode)', async () => {
const { buildTeamLeadCommand } = await import('../lib/team-lead-command.js');
const cmd = buildTeamLeadCommand('genie', { systemPromptFile: '/tmp/test-agents.md' });
const cmd = buildTeamLeadCommand('genie', { systemPromptFile: '/tmp/test-agents.md', promptMode: 'append' });
expect(cmd).toContain('--append-system-prompt-file');
expect(cmd).toContain('/tmp/test-agents.md');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

This test no longer exercises the default promptMode path.

Line 264 now forces promptMode: 'append', so the title is inaccurate and the options?.promptMode ?? loadGenieConfigSync().promptMode branch is no longer covered. Please either rename the case or add a separate deterministic test for the config-backed default.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/term-commands/msg.test.ts` around lines 262 - 267, The test title says it
verifies the default promptMode path but passes promptMode:'append', so either
(A) change this test to not pass promptMode (remove promptMode from the options
passed to buildTeamLeadCommand) so it exercises the options?.promptMode ??
loadGenieConfigSync().promptMode branch and still asserts
'--append-system-prompt-file' and the file path, or (B) add a new deterministic
test that calls buildTeamLeadCommand('genie', { systemPromptFile:
'/tmp/test-agents.md' }) while mocking loadGenieConfigSync() to return
promptMode:'append' and then assert the flag and path; reference
buildTeamLeadCommand and loadGenieConfigSync when implementing the change.

filipexyz and others added 12 commits April 9, 2026 13:20
- processMessage: unified handler for streaming/non-streaming
- Flat events: tool_call, tool_result, assistant as individual rows
- Emits genie_session with PG session ID before streaming starts
- Removed resultText accumulation (each assistant msg saved individually)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bridge is now managed as a subprocess by genie serve:
- Auto-starts after approval handler (step 6 in startup sequence)
- Graceful shutdown via SIGTERM (detaches sessions, doesn't kill them)
- Status shown in `genie serve status` output
- `genie doctor` reports bridge health (NATS connection, sessions, PG backing)
- `genie omni start` warns if bridge already managed by serve (use --standalone to force)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…sions

Replace non-null assertions with narrowed locals in session ID persistence,
add biome-ignore for pre-existing complexity in spawnSession and _processDelivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track bridge sessions in dedicated genie_bridge_sessions table so they
survive process restarts. Sessions are created on spawn, updated on
message delivery, closed on removal, and orphaned on bridge startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Knip dead-code check flagged BridgeSessionRow and CreateSessionOpts as
unused exports — they are only consumed within bridge-session-store.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- On startup: recover active sessions from PG, verify tmux panes alive,
  re-attach to surviving sessions, orphan dead ones
- Graceful shutdown: SIGTERM detaches from tmux panes without killing,
  leaves PG rows as 'active' for recovery on restart
- Duplicate guard: if two PG rows map to same agent:chat key, orphan the later one
- SDK sessions (no tmux pane) are always orphaned since they can't survive restart

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract the SDK `mock.module('@anthropic-ai/claude-agent-sdk')` registration
into `_shared-sdk-query-mock.ts` so both `sdk-integration.test.ts` and the
executor tests (`_sdk-mocks.ts`) use the same `queryMock` instance.

Previously, each file registered its own competing mock. Since bun's
`mock.module()` is process-global and first-registration-wins, whichever
file loaded first locked the global cache — making the other file's mock
assertions fail (0 calls). This caused 11 persistent test failures in the
full suite that passed individually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: flat session event recording + genie_session event
Tmux sessions are now detached (not shut down) during graceful stop so
they can be recovered on restart. Updated test expectation accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat: bridge session resilience — serve integration, PG persistence, graceful shutdown

@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: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/db/migrations/035_bridge_sessions.sql`:
- Around line 5-27: The schema allows multiple rows with status='active' for the
same logical session (instance_id, chat_id), which breaks recovery assumptions;
add a unique partial index to enforce at most one active row per session key by
creating a unique index on (instance_id, chat_id) WHERE status = 'active' (e.g.,
CREATE UNIQUE INDEX IF NOT EXISTS uniq_bridge_sessions_active_per_key ON
genie_bridge_sessions(instance_id, chat_id) WHERE status = 'active'); reference
the genie_bridge_sessions table and the existing status, instance_id, chat_id
columns when making this migration change.

In `@src/genie-commands/doctor.ts`:
- Around line 312-327: checkBridge currently assumes it runs inside the bridge
process by calling getBridge() which is null for the CLI; update checkBridge to
treat a null getBridge() as "not local" and perform a remote health probe
instead: keep the existing local-path logic using getBridge() to report status
when present, but when getBridge() is null call the bridge health endpoint
(e.g., HTTP GET to the configured bridge host/port or a known health path) and
base the CheckResult on that response; only return the "not running in this
process" warning if both the local getBridge() is null and the remote health
probe fails/unreachable. Ensure you reference checkBridge and getBridge in the
change and use the configured/default bridge address for the remote probe.

In `@src/services/__tests__/omni-bridge.test.ts`:
- Around line 533-535: Add a parallel assertion for the SDK shutdown path: when
invoking stop() in the test variant that takes the SDK branch, assert that
calls.shutdown was invoked (e.g., expect(calls.shutdown.length).toBe(1)) and
that the SDK-specific persisted-rows cleanup was executed (assert the mock/call
used for closing persisted rows was called). Locate the existing tmux assertion
around expect(calls.shutdown.length).toBe(0) and add a second assertion in the
SDK scenario that validates shutdown was called and the persisted-rows cleanup
mock was invoked so the new SDK cleanup path is covered.

In `@src/services/bridge-session-store.ts`:
- Around line 115-128: The list() method in BridgeSessionStore is truncating
results with a hard LIMIT 100 which breaks recoverSessions() (called with
status='active') and leaves older active rows stranded; update the list(status?:
...) implementation to return all matching rows instead of capping at 100 (or
implement pagination/cursoring if you must cap): remove the LIMIT 100 from the
SQL used when status is provided (and from the no-status branch if global
truncation is unintended), or implement a paginated loop in recoverSessions()
that repeatedly calls list() with an offset/last-id until all
genie_bridge_sessions rows (BridgeSessionRow) are processed so recoverSessions()
handles every active session.

In `@src/services/omni-bridge.ts`:
- Around line 1125-1133: Change removeSession to perform synchronous teardown by
making it async and awaiting the PG close before deleting the in-memory entry:
if entry?.idleTimer clear it, then if closeId and this.sessionStore call and
await the Promise returned by safePgCall (which should be adapted to return a
Promise) that runs new BridgeSessionStore(sql).close(closeId), and only after
that await resolve call this.sessions.delete(key). Update all callers (including
handleTurnTimeout and any reset/idle flows that currently delete the map entry
directly) to await this.removeSession(key) instead of deleting the entry,
ensuring the DB row is closed before a replacement session can be created.

In `@src/term-commands/omni.ts`:
- Around line 67-75: The current guard in the omni start action uses the
process-local getBridge() which cannot detect a bridge started by a separate
genie serve process; replace or augment that check with an inter-process
existence probe (e.g., try to connect to the bridge's known admin/health TCP
port or unix socket, or check a well-known PID/lock file that genie serve
writes) and only allow starting when that probe fails or when options.standalone
is true; update the omni start handler (the .action block that calls
getBridge()) to call a new helper like isBridgeRunningRemotely() (or reuse an
existing network/IPC connector) and use its result instead of getBridge() to
prevent starting a second bridge.

In `@src/term-commands/serve.ts`:
- Around line 614-618: The code currently fire-and-forgets OmniBridge.stop()
(when handles.omniBridge exists) which causes its async cleanup to be abandoned;
update shutdown() so it awaits handles.omniBridge.stop() and propagates/reports
errors instead of swallowing them, i.e. call await handles.omniBridge.stop() (or
Promise.resolve then await) and only then set handles.omniBridge = null; also
adjust the signal handlers that call shutdown() to await the returned promise
before calling process.exit to ensure OmniBridge.stop() finishes and its
NATS/SDK cleanup completes.
- Around line 885-899: printBridgeStatus currently calls getBridge() which only
returns a singleton in the current CLI process, so `genie serve status` can't
see a bridge running in the foreground/daemon; change printBridgeStatus to query
the running serve process over its management/status endpoint (or IPC socket)
instead of calling getBridge() locally — attempt an HTTP/IPC request to the
serve daemon's status API and parse the returned {connected, activeSessions,
queueDepth} (fall back to calling getBridge() only if the daemon endpoint is
unreachable) so that bridge.status() values reflect the actual running service
rather than the local singleton.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d1df89c7-4119-4fba-8884-2c8a136366bd

📥 Commits

Reviewing files that changed from the base of the PR and between 5699871 and 72c7dff.

📒 Files selected for processing (7)
  • src/db/migrations/035_bridge_sessions.sql
  • src/genie-commands/doctor.ts
  • src/services/__tests__/omni-bridge.test.ts
  • src/services/bridge-session-store.ts
  • src/services/omni-bridge.ts
  • src/term-commands/omni.ts
  • src/term-commands/serve.ts

Comment on lines +5 to +27
CREATE TABLE IF NOT EXISTS genie_bridge_sessions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
executor_id TEXT REFERENCES executors(id) ON DELETE SET NULL,
instance_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
tmux_pane_id TEXT,
claude_session_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'closed', 'orphaned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);

CREATE INDEX IF NOT EXISTS idx_bridge_sessions_status
ON genie_bridge_sessions(status);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_instance_chat
ON genie_bridge_sessions(instance_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
ON genie_bridge_sessions(status, last_activity_at)
WHERE status = 'active';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Enforce one active row per bridge session key.

The new indexes speed lookups, but nothing here prevents two status='active' rows for the same logical session. The runtime already assumes singular active rows during recovery, so duplicate inserts will make restart recovery nondeterministic and keep stale sessions alive in PG.

Suggested migration
 CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
   ON genie_bridge_sessions(status, last_activity_at)
   WHERE status = 'active';
+
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_bridge_sessions_active_key
+  ON genie_bridge_sessions(instance_id, agent_name, chat_id)
+  WHERE status = 'active';
📝 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
CREATE TABLE IF NOT EXISTS genie_bridge_sessions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
executor_id TEXT REFERENCES executors(id) ON DELETE SET NULL,
instance_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
tmux_pane_id TEXT,
claude_session_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'closed', 'orphaned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_status
ON genie_bridge_sessions(status);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_instance_chat
ON genie_bridge_sessions(instance_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
ON genie_bridge_sessions(status, last_activity_at)
WHERE status = 'active';
CREATE TABLE IF NOT EXISTS genie_bridge_sessions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
executor_id TEXT REFERENCES executors(id) ON DELETE SET NULL,
instance_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
agent_name TEXT NOT NULL,
tmux_pane_id TEXT,
claude_session_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'closed', 'orphaned')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_status
ON genie_bridge_sessions(status);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_instance_chat
ON genie_bridge_sessions(instance_id, chat_id);
CREATE INDEX IF NOT EXISTS idx_bridge_sessions_active
ON genie_bridge_sessions(status, last_activity_at)
WHERE status = 'active';
CREATE UNIQUE INDEX IF NOT EXISTS uniq_bridge_sessions_active_key
ON genie_bridge_sessions(instance_id, agent_name, chat_id)
WHERE status = 'active';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/migrations/035_bridge_sessions.sql` around lines 5 - 27, The schema
allows multiple rows with status='active' for the same logical session
(instance_id, chat_id), which breaks recovery assumptions; add a unique partial
index to enforce at most one active row per session key by creating a unique
index on (instance_id, chat_id) WHERE status = 'active' (e.g., CREATE UNIQUE
INDEX IF NOT EXISTS uniq_bridge_sessions_active_per_key ON
genie_bridge_sessions(instance_id, chat_id) WHERE status = 'active'); reference
the genie_bridge_sessions table and the existing status, instance_id, chat_id
columns when making this migration change.

Comment on lines +312 to +327
async function checkBridge(): Promise<CheckResult[]> {
const results: CheckResult[] = [];

try {
const { getBridge } = await import('../services/omni-bridge.js');
const bridge = getBridge();

if (!bridge) {
results.push({
name: 'Bridge running',
status: 'warn',
message: 'not running in this process',
suggestion: 'Bridge starts automatically with: genie serve',
});
return results;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This health check only works inside the bridge-owning process.

genie doctor runs in a separate CLI process, so getBridge() will usually be null even while genie serve has a healthy bridge running. That makes doctor report a warning on healthy installs instead of reporting real bridge state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie-commands/doctor.ts` around lines 312 - 327, checkBridge currently
assumes it runs inside the bridge process by calling getBridge() which is null
for the CLI; update checkBridge to treat a null getBridge() as "not local" and
perform a remote health probe instead: keep the existing local-path logic using
getBridge() to report status when present, but when getBridge() is null call the
bridge health endpoint (e.g., HTTP GET to the configured bridge host/port or a
known health path) and base the CheckResult on that response; only return the
"not running in this process" warning if both the local getBridge() is null and
the remote health probe fails/unreachable. Ensure you reference checkBridge and
getBridge in the change and use the configured/default bridge address for the
remote probe.

Comment on lines +533 to +535
// Tmux sessions are detached (not shut down) during graceful stop
// so they can be recovered on restart. Shutdown is NOT called for tmux sessions.
expect(calls.shutdown.length).toBe(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Add the SDK shutdown case alongside this tmux assertion.

This update covers the tmux-detach branch, but stop() now has separate SDK behavior and closes persisted rows only there. Without an SDK-specific test, that new cleanup path can regress unnoticed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/__tests__/omni-bridge.test.ts` around lines 533 - 535, Add a
parallel assertion for the SDK shutdown path: when invoking stop() in the test
variant that takes the SDK branch, assert that calls.shutdown was invoked (e.g.,
expect(calls.shutdown.length).toBe(1)) and that the SDK-specific persisted-rows
cleanup was executed (assert the mock/call used for closing persisted rows was
called). Locate the existing tmux assertion around
expect(calls.shutdown.length).toBe(0) and add a second assertion in the SDK
scenario that validates shutdown was called and the persisted-rows cleanup mock
was invoked so the new SDK cleanup path is covered.

Comment on lines +115 to +128
async list(status?: 'active' | 'closed' | 'orphaned'): Promise<BridgeSessionRow[]> {
if (status) {
return this.sql<BridgeSessionRow[]>`
SELECT * FROM genie_bridge_sessions
WHERE status = ${status}
ORDER BY started_at DESC
LIMIT 100
`;
}
return this.sql<BridgeSessionRow[]>`
SELECT * FROM genie_bridge_sessions
ORDER BY started_at DESC
LIMIT 100
`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

list() is truncating crash recovery.

recoverSessions() uses this method for status='active', so the hard LIMIT 100 silently skips older live rows. Once active rows exceed that cap, anything past the newest 100 is neither recovered nor orphaned and stays stranded as active in PG.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bridge-session-store.ts` around lines 115 - 128, The list()
method in BridgeSessionStore is truncating results with a hard LIMIT 100 which
breaks recoverSessions() (called with status='active') and leaves older active
rows stranded; update the list(status?: ...) implementation to return all
matching rows instead of capping at 100 (or implement pagination/cursoring if
you must cap): remove the LIMIT 100 from the SQL used when status is provided
(and from the no-status branch if global truncation is unintended), or implement
a paginated loop in recoverSessions() that repeatedly calls list() with an
offset/last-id until all genie_bridge_sessions rows (BridgeSessionRow) are
processed so recoverSessions() handles every active session.

Comment on lines 1125 to 1133
private removeSession(key: string): void {
const entry = this.sessions.get(key);
if (entry?.idleTimer) clearTimeout(entry.idleTimer);
// Close session in PG
const closeId = entry?.pgBridgeSessionId;
if (closeId && this.sessionStore) {
this.safePgCall('session_close', (sql) => new BridgeSessionStore(sql).close(closeId), undefined);
}
this.sessions.delete(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make persisted session teardown synchronous for callers.

This helper now owns the PG close, but it starts that write without awaiting it and then deletes the in-memory entry immediately. Reset/idle flows can spawn a replacement before the old row is closed, and handleTurnTimeout() still bypasses this helper with a raw delete on Line 905, so duplicate or permanently-active rows can remain in genie_bridge_sessions.

Suggested direction
-  private removeSession(key: string): void {
+  private async removeSession(key: string): Promise<void> {
     const entry = this.sessions.get(key);
     if (entry?.idleTimer) clearTimeout(entry.idleTimer);
     const closeId = entry?.pgBridgeSessionId;
     if (closeId && this.sessionStore) {
-      this.safePgCall('session_close', (sql) => new BridgeSessionStore(sql).close(closeId), undefined);
+      await this.safePgCall('session_close', (sql) => new BridgeSessionStore(sql).close(closeId), undefined);
     }
     this.sessions.delete(key);
   }

And update the callers, including the timeout path, to await this.removeSession(...) instead of deleting the map entry directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/omni-bridge.ts` around lines 1125 - 1133, Change removeSession
to perform synchronous teardown by making it async and awaiting the PG close
before deleting the in-memory entry: if entry?.idleTimer clear it, then if
closeId and this.sessionStore call and await the Promise returned by safePgCall
(which should be adapted to return a Promise) that runs new
BridgeSessionStore(sql).close(closeId), and only after that await resolve call
this.sessions.delete(key). Update all callers (including handleTurnTimeout and
any reset/idle flows that currently delete the map entry directly) to await
this.removeSession(key) instead of deleting the entry, ensuring the DB row is
closed before a replacement session can be created.

Comment thread src/term-commands/omni.ts
Comment on lines +67 to +75
.option('--standalone', 'Force standalone mode (skip serve check)')
.action(async (options) => {
// Check if serve is already managing a bridge
const { getBridge } = await import('../services/omni-bridge.js');
if (!options.standalone && getBridge()) {
console.log('[genie omni] Bridge is already managed by genie serve.');
console.log(' Use `genie omni status` to check, or `genie omni start --standalone` to force.');
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This guard cannot see the bridge started by genie serve.

getBridge() is process-local. genie omni start runs in a fresh CLI process, so this check still returns null while another process already owns a live bridge. The command can therefore start a second subscriber, and both bridges will consume omni.message.> traffic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/term-commands/omni.ts` around lines 67 - 75, The current guard in the
omni start action uses the process-local getBridge() which cannot detect a
bridge started by a separate genie serve process; replace or augment that check
with an inter-process existence probe (e.g., try to connect to the bridge's
known admin/health TCP port or unix socket, or check a well-known PID/lock file
that genie serve writes) and only allow starting when that probe fails or when
options.standalone is true; update the omni start handler (the .action block
that calls getBridge()) to call a new helper like isBridgeRunningRemotely() (or
reuse an existing network/IPC connector) and use its result instead of
getBridge() to prevent starting a second bridge.

Comment on lines +614 to +618
// 2.55. Stop Omni bridge (graceful: detach sessions, don't kill them)
if (handles.omniBridge) {
handles.omniBridge.stop().catch(() => {});
handles.omniBridge = null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't fire-and-forget OmniBridge.stop() here.

OmniBridge.stop() now does real cleanup: it drains NATS and closes SDK bridge-session rows. Because the signal handlers exit the process immediately after shutdown(), this promise is very likely abandoned before it finishes, leaving stale active rows behind.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/term-commands/serve.ts` around lines 614 - 618, The code currently
fire-and-forgets OmniBridge.stop() (when handles.omniBridge exists) which causes
its async cleanup to be abandoned; update shutdown() so it awaits
handles.omniBridge.stop() and propagates/reports errors instead of swallowing
them, i.e. call await handles.omniBridge.stop() (or Promise.resolve then await)
and only then set handles.omniBridge = null; also adjust the signal handlers
that call shutdown() to await the returned promise before calling process.exit
to ensure OmniBridge.stop() finishes and its NATS/SDK cleanup completes.

Comment on lines +885 to +899
/** Print Omni bridge status */
async function printBridgeStatus(): Promise<void> {
try {
const { getBridge } = await import('../services/omni-bridge.js');
const bridge = getBridge();
if (bridge) {
const s = await bridge.status();
const tag = s.connected ? 'connected' : 'disconnected';
console.log(` omni-bridge: ${tag} (${s.activeSessions} sessions, queue: ${s.queueDepth})`);
} else {
console.log(' omni-bridge: stopped');
}
} catch {
console.log(' omni-bridge: unavailable');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

serve status cannot see the managed bridge from another process.

getBridge() only reports the singleton in the current process. When users run genie serve status, they're in a new CLI process, so this prints omni-bridge: stopped even if the foreground/daemon serve instance has it running.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/term-commands/serve.ts` around lines 885 - 899, printBridgeStatus
currently calls getBridge() which only returns a singleton in the current CLI
process, so `genie serve status` can't see a bridge running in the
foreground/daemon; change printBridgeStatus to query the running serve process
over its management/status endpoint (or IPC socket) instead of calling
getBridge() locally — attempt an HTTP/IPC request to the serve daemon's status
API and parse the returned {connected, activeSessions, queueDepth} (fall back to
calling getBridge() only if the daemon endpoint is unreachable) so that
bridge.status() values reflect the actual running service rather than the local
singleton.

@namastex888
namastex888 merged commit 15b7b56 into main Apr 9, 2026
2 checks passed
namastex888 added a commit that referenced this pull request Apr 14, 2026
chore: rolling promotion dev -> main
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.

2 participants