Skip to content

feat(omni): unified executor layer — merge World B into World A - #1062

Merged
namastex888 merged 16 commits into
devfrom
unified-executor-layer
Apr 5, 2026
Merged

namastex888 merged 16 commits into
devfrom
unified-executor-layer

Conversation

@namastex888

@namastex888 namastex888 commented Apr 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Merges the genie CLI's two parallel session/executor subsystems into a single unified layer:

  • World A (mature): executor-registry.ts, PG-backed sessions/executors/session_content/audit_events tables, session-capture.ts filewatch
  • World B (omni-bridge): services/executor.ts IExecutor interface, in-memory session Map

After this PR, World B executors (claude-sdk, claude-code) register in World A on spawn, update state on deliver, and terminate on shutdown — making them visible to genie ls, genie sessions, audit trails, and the session observatory.

Supersedes PR #1042 with a proper 12-group wish-driven implementation.

Key changes

  • safePgCall pattern: Single PG entry point in omni-bridge with graceful degradation (try → log → fallback → flip pgAvailable=false on connection errors)
  • World A registration: Both claude-sdk.ts and claude-code.ts call findOrCreateAgent + createAndLinkExecutor on spawn, updateExecutorState on deliver, terminateExecutor on shutdown
  • Audit events: Shared AuditEventType enum with dotted naming (executor.spawn, deliver.start, session.resumed, etc.) in src/lib/audit-events.ts
  • SDK session capture: sdk-session-capture.ts writes SDK conversation turns to session_content via safePgCall
  • Lazy resume: findLatestByMetadata({agentId, source, chatId}) queries executors table with new partial JSONB index for fast omni-context lookups
  • GENIE_EXECUTOR flip switch: executor-config.ts with precedence: CLI override > env > persisted config > default 'tmux'
  • CLI enhancements: genie omni status shows pgAvailable + executor count; genie sessions/ls/exec gain --source filter
  • Migration: 026_executors_omni_metadata_index.sql adds partial index on (agent_id, metadata->>'source', metadata->>'chat_id') WHERE ended_at IS NULL
  • World B demoted: services/executor.ts documented as compatibility shim; elimination path charted

Stats

  • 60 files changed, +7639/-188 lines
  • 12 execution groups across 4 waves
  • 58 new tests (resume, session capture, config, bridge)
  • Full suite: 2127 pass, 0 fail (worktree), 2040 pass, 0 fail (main repo)

Test plan

  • tsc --noEmit — clean
  • biome check . — clean (pre-existing warnings only)
  • bunx knip — clean
  • bun test — 2127/0 (worktree), 2040/0 (main repo)
  • Cross-file mock leak fixed (bun mock.module cleanup in afterAll)
  • Manual: genie omni status shows pgAvailable + active executor count
  • Manual: genie ls --source omni filters to omni-bridge spawned executors
  • Manual: SDK executor appears in genie sessions list after omni-bridge spawn

Test added 16 commits April 4, 2026 22:11
Implements Group 3 of the unified-executor-layer wish: the omni-bridge must
start without PG and gracefully degrade on runtime failures so a dropped PG
connection never drops a user reply.

- Add `pgAvailable: boolean` on OmniBridge and expose it on BridgeStatus
- New private `probePg()` runs at start() after NATS connects; never throws,
  logs warn on failure, and sets pgAvailable=false for degraded mode
- New private `safePgCall<T>(op, fn, fallback, ctx?)` helper — single entry
  point for every downstream PG call. Try-once semantics, 2s runtime timeout,
  fallback return on error, flips pgAvailable=false on connection-level errors
- Inject hooks: `BridgeConfig.pgProvider` and `BridgeConfig.natsConnectFn` for
  hermetic unit tests (default to lib/db.getConnection() and nats.connect)
- Helper utilities: `withTimeout` and `isPgConnectionError` classify failures
  by postgres.js code and common message fragments
- Tests in src/services/__tests__/omni-bridge.test.ts cover: degraded startup
  (provider throws, SELECT 1 fails), mid-run connection loss flips the flag,
  non-connection errors keep the flag true, fast-path short-circuit when PG
  was never healthy, and the happy path forwards the fn result.

Downstream groups (4, 5, 6, 7) will wire their PG writes through safePgCall.
…low-query test

Closes the three gaps flagged in WISH Post-Audit Decision 3 before Wave 2
dispatches. Groups 4/5/6/7 depend on these changes to start.

1. `safePgCall` is now `public` on `OmniBridge` (Decision 2). Downstream
   executors hold an `OmniBridge` reference and call the method directly,
   e.g. `bridge.safePgCall('op', fn, fallback, { chatId })`. Tests updated
   to drop the `(bridge as any)` workaround.

2. `probePg()` now classifies startup errors (Decision 3):
   - Connection-level (ECONNREFUSED, connection terminated, …) → degrade
     gracefully, log warn, `pgAvailable=false`. Dev without PG still works.
   - Anything else (schema mismatch, missing relation, permission denied)
     → fail-fast with an actionable error pointing at the migration
     command. Matches the wish's PG Error Handling Strategy table row for
     "Migration missing / schema mismatch" — silent corruption is worse
     than noisy startup failure.

3. New unit tests in `src/services/__tests__/omni-bridge.test.ts`:
   - Fail-fast path: SELECT 1 rejects with "relation 'sessions' does not
     exist" → `start()` throws with "PG schema mismatch … migrate" hint.
   - Fail-fast variant: provider throws a non-connection error with
     code 42501 (permission denied) → `start()` throws with hint.
   - Slow-query fallback: fn delays 2.5s (beyond the 2s runtime budget)
     → `safePgCall` returns fallback, `pgAvailable` STAYS true (slow is
     not a connection loss), elapsed time confirms the budget held.

Validation:
  bun run typecheck                                    → clean
  bun run lint                                         → 0 errors
  bun test src/services/__tests__/omni-bridge.test.ts  → 9 pass / 31 expects
Group 5 — SDK sessions now produce the same sessions + session_content
rows that session-capture.ts (filewatch) produces for tmux sessions.

New files:
- src/lib/audit-events.ts: shared AuditEventType union + recordAuditEvent
  helper (writes to audit_events via safePgCall, used by Group 5 & 7)
- src/services/executors/sdk-session-capture.ts: startSession, recordTurn,
  updateTurnCount, endSession — all writes through SafePgCallFn
- src/services/executors/__tests__/sdk-session-capture.test.ts: 15 tests

Integration in claude-sdk.ts _processDelivery():
- deliver.start / deliver.end audit events bracketing each query
- User + assistant turns recorded in session_content per delivery
- PG session row created lazily on first delivery, ended on shutdown
- All writes skip silently when safePgCall is null (degraded mode)
…sdk test mocks

- Add _sql parameter to happySafePgCall/degradedSafePgCall to match SafePgCallFn type
- Add mockSql tagged-template stub so audit-events callbacks don't crash
- Add missing findLatestByMetadata mock to executor-registry mock
- Add mock parameter types to createAndLinkExecutorMock for call destructuring
- Restore SDK query mock in concurrent delivery afterEach to prevent test pollution
Add bridge restart recovery for in-flight Claude sessions:

- Migration 026: partial JSONB index on executors(agent_id, source, chat_id)
  for fast omni-sourced executor lookups (WHERE ended_at IS NULL)
- executor-registry: add findLatestByMetadata, relinkExecutorToAgent,
  updateClaudeSessionId helpers
- claude-sdk spawn(): before creating fresh executor, look up existing
  live executor via findLatestByMetadata and reuse it (with session ID)
- claude-sdk deliver(): detect resume rejection (SDK returns different
  session ID), persist new session ID, write session.resume_rejected audit
- Audit events: session.resumed, session.created_fresh, session.resume_rejected
- 11 new tests covering all resume paths in claude-sdk-resume.test.ts
Remove .genie/wishes/ from .gitignore so wish plans and audit
artifacts are version-controlled alongside the code they describe.
SafePgCallFn type, safePgCall injection, tmux World A
registration — left uncommitted by dead agents.
…p semantics

status() now queries the executors table for active omni session count
and executor IDs when PG is available, falling back to the local Map
size in degraded mode. BridgeStatus gains an executorIds field.

The sessions Map is documented as holding process-local runtime handles
only (executor instances, idle timers, message buffers) — PG via
executor-registry is the source of truth for session identity/state.

All existing status() callers updated to await the now-async method.
Two new tests cover PG-backed and degraded-mode status paths.
Status now shows: bridge state, NATS connection, pgAvailable,
executor type (tmux/sdk), PG-backed active executor count with
fallback to in-memory, and executor IDs from PG. Extracted
printStatus helper to reduce cognitive complexity.
Add resolveExecutorType() with clear precedence:
  1. CLI --executor flag (override)
  2. GENIE_EXECUTOR env var (replaces GENIE_EXECUTOR_TYPE)
  3. Persisted config (~/.genie/config.json → omni.executor)
  4. Default: 'tmux'

- New: src/lib/executor-config.ts — single resolver function
- Modified: omni-bridge constructor uses resolver instead of inline logic
- Modified: genie omni start --executor passes through to resolver
- Modified: genie omni status shows resolved executor type
- Added executor field to OmniConfigSchema for persistent config
- 10 tests covering precedence, invalid values, and fallbacks
…path

Extract SafePgCallFn to lib/safe-pg-call.ts (fixes cross-layer import from
lib/audit-events → services/executor). Slim executor.ts from 99 to 43 lines
by removing verbose JSDoc and section separators. Add TODO markers on
OmniSession and IExecutor pointing to World A replacements.

OmniSession references now confined to executor.ts + 3 direct consumers
(omni-bridge, claude-code executor, claude-sdk executor) — no leakage
outside src/services/.
Bridge is a message source, not a state owner. All session state
lives in the existing executors/sessions tables.
Add audit-events.ts and executor-config.ts as entry points.
Remove stale ignoreBinaries (which) and ignoreDependencies (esbuild).
Group 11 accidentally deleted this new test file (mistaking it
for World B). Also sync knip.json after linter adjustments.
…tests

claude-sdk-resume.test.ts used mock.module for audit-events.js and
sdk-session-capture.js. bun's mock.module is process-global and leaked
into sdk-session-capture.test.ts, causing 14 failures in the full suite
while passing in isolation.

Fix: remove both mock.module calls from the resume test. Instead:
- sdk-session-capture runs unmodified (safePgCall is wired with a
  tracking fake sql that returns row-shaped results)
- audit events are tracked via safePgCall's op log (op = 'audit:<type>')
  instead of a mock.module spy on recordAuditEvent
- The rejection audit test extracts attrs from the serialized JSON in
  the safePgCall SQL values

Result: 2127 pass, 0 fail across 111 files (matching dev's 0-fail baseline).
Prevents bun mock.module registrations (6 calls) from leaking into
subsequent test files. Same pattern as claude-sdk-resume.test.ts fix.
@coderabbitai

coderabbitai Bot commented Apr 5, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 09a30d40-b06d-4d07-bf28-0c4cd3d3d3b6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch unified-executor-layer

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.

@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 implements the "Unified Executor Layer" by merging the omni bridge's session management into the core executor registry. It introduces a metadata index for session lookups, lazy resume for Claude sessions, and a runtime switch to choose between tmux and sdk executors. Additionally, it adds a safePgCall utility for graceful degradation when PostgreSQL is offline and implements inline session content capture for the SDK executor. Feedback suggests using undefined instead of empty strings for missing audit event IDs to ensure proper database NULL values and recommends consolidating duplicated ls command definitions to improve maintainability.

Comment thread src/lib/audit-events.ts
attrs: Record<string, unknown>,
): Promise<void> {
const entityType = (attrs.entity_type as string) ?? 'executor';
const entityId = (attrs.entity_id as string) ?? (attrs.executor_id as string) ?? '';

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.

medium

For consistency with how actor is handled (defaulting to null), it would be better for entityId to default to a value that results in NULL in the database, rather than an empty string ''. Using undefined will achieve this with postgres.js and is generally a better representation for a missing value in a database context. This also aligns with the optional nature of executorId in SafePgCallContext.

Suggested change
const entityId = (attrs.entity_id as string) ?? (attrs.executor_id as string) ?? '';
const entityId = (attrs.entity_id as string) ?? (attrs.executor_id as string);

Comment thread src/genie.ts
Comment on lines 487 to +491
.command('ls')
.description('List registered agents with runtime status')
.option('--json', 'Output as JSON')
.action(async (options: { json?: boolean }) => {
.option('--source <name>', 'Filter by executor metadata source (e.g. omni)')
.action(async (options: { json?: boolean; source?: string }) => {

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.

medium

The ls command is also defined in src/term-commands/agent/list.ts. Having the command definition in two places can lead to inconsistencies and maintenance issues. It would be better to consolidate the definition in one place, likely src/term-commands/agent/list.ts, and have genie.ts just register it. If this consolidation introduces a circular dependency, use dynamic imports (require() or import()) to break the cycle as per repository guidelines.

References
  1. Use dynamic imports (require() or import()) to break circular dependency cycles that would otherwise occur at module load time.

Comment on lines 12 to +16
.alias('ls')
.description('List registered agents with runtime status')
.option('--json', 'Output as JSON')
.action(async (options: { json?: boolean }) => {
.option('--source <name>', 'Filter by executor metadata source (e.g. omni)')
.action(async (options: { json?: boolean; source?: string }) => {

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.

medium

The ls command is also defined in src/genie.ts. This duplication can cause maintenance problems. It's recommended to have a single source of truth for the command definition, probably in this file, and remove the definition from genie.ts. If this consolidation introduces a circular dependency, use dynamic imports (require() or import()) to break the cycle as per repository guidelines.

References
  1. Use dynamic imports (require() or import()) to break circular dependency cycles that would otherwise occur at module load time.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47f4364c26

ℹ️ 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".

Comment on lines +433 to +435
if (claudeSessionId && state.dbSessionId?.startsWith('sdk-')) {
const newId = await startSession(this.safePgCall, state.executorId ?? sessionKey, claudeSessionId, agentName);
if (newId) state.dbSessionId = newId;

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 Badge Preserve captured turns when re-keying session IDs

When a fresh SDK chat starts without a Claude session ID, captureUserTurn writes the first user message into a synthetic sdk-* session row; then this block creates a second row keyed by the real Claude session ID and switches state.dbSessionId to it. Because no migration/update happens, the transcript is split across two sessions IDs, total_turns is updated on the new row only, and shutdown later ends only the new row, leaving the synthetic row active/orphaned. This corrupts session history for the most common first-turn path.

Useful? React with 👍 / 👎.

Comment on lines +296 to +297
// State: working (query in flight) — before the blocking operation starts.
if (state.executorId) await this.updateState(state.executorId, 'working', session.chatId);

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 Badge Reset executor state when SDK delivery errors

This method marks the executor as working before running the query, but idle is only written on the success path; any exception from runQuery, stream consumption, capture writes, or publish exits early and never performs the working → idle transition. Since deliver() intentionally swallows queue errors, these failures do not propagate to a caller that could repair state, so executors can remain stuck as working indefinitely after transient delivery errors.

Useful? React with 👍 / 👎.

@namastex888 namastex888 changed the title feat: unified executor layer — merge World B into World A feat(omni): unified executor layer — merge World B into World A Apr 5, 2026
@namastex888
namastex888 merged commit 5f71cde into dev Apr 5, 2026
6 checks passed
namastex888 pushed a commit that referenced this pull request Apr 5, 2026
Audit all 80+ wishes against dev codebase. 17 had stale statuses:

SHIPPED (14):
- unified-omni-bridge (PRs #1063, #1065)
- fix-omni-bridge-hardening (PR #1065)
- unified-executor-layer (PR #1062)
- auto-orchestrate, fix-depends-parser, parallel-execution
- task-projects, test-pg-ram-isolation, task-auto-close-on-merge
- worktree-out-of-repo, docs-overhaul, genie-hacks-community-docs
- multi-agent-session-isolation, session-auto-create

OBSOLETE (3):
- genie-omni-marriage (superseded by smaller wishes)
- fix-session-uuid-resume (replaced by --continue by name)
- qa-dev-to-main (time-bound QA from March 20)
@namastex888
namastex888 deleted the unified-executor-layer branch April 10, 2026 16:18
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