Skip to content

fix(serve): make Omni bridge optional so serve survives NATS-less dev - #1239

Merged
namastex888 merged 2 commits into
devfrom
fix/genie-serve-stability
Apr 20, 2026
Merged

namastex888 merged 2 commits into
devfrom
fix/genie-serve-stability

Conversation

@namastex888

@namastex888 namastex888 commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • genie serve exited with code 1 whenever the Omni bridge could not reach NATS (the default on any dev machine), which made every CLI command hang for 16s and blocked testing. The bridge is now optional: on start failure, serve logs Omni bridge: degraded — <reason> and continues. Set GENIE_OMNI_REQUIRED=1 to restore strict (exit-1) behavior.
  • Bundles the wider genie-serve-stability hardening: {pid}:{startTime} process identity for correct PID staleness checks (new src/lib/process-identity.ts), dead-socket reconciliation for stale workers, a zombie dead-pane pass for idle/working rows, and regression tests across serve, db, and agent-registry.
  • Fixes two pre-existing gate blockers so bun run check and the pre-push hook go green:
    • Replace stale genie reset refs in skills/pm/SKILL.md and skills/genie-hacks/SKILL.md with genie task unblock (the current command).
    • Realpath tmpdir() in src/__tests__/migrate.test.ts so the macOS /var → /private/var symlink doesn't break path assertions.

Root cause of the original report: the fix existed only in the working tree — the globally installed ~/.bun/bin/genie was a stale published build that still had process.exit(1) on bridge failure. Landing and publishing this PR ensures users who run genie serve pick up the optional bridge.

Wish: .genie/wishes/genie-serve-stability/WISH.md

Test plan

  • bun run typecheck — clean
  • bun run lint — only pre-existing complexity warnings in agents.ts (not touched by this PR)
  • bun run skills:lint — OK (0 missing)
  • bun test — 2493/2493 pass (previously 4 pre-existing macOS failures in migrate.test.ts — fixed here)
  • Manual: genie serve with no NATS running — prints Omni bridge: degraded — CONNECTION_REFUSED, continues, PID file present, SIGTERM shuts down cleanly
  • Reviewer to verify: GENIE_OMNI_REQUIRED=1 genie serve start --foreground with no NATS still exits 1 (strict-mode regression check)

Summary by CodeRabbit

  • New Features

    • Serve process now validates process identity using PID and start-time to prevent stale daemon conflicts.
    • Omni bridge startup is now optional by default; specify GENIE_OMNI_REQUIRED=1 to make it mandatory.
    • Improved timeout error messages that identify specific failure modes (stale PID, daemon not running, etc.).
  • Bug Fixes

    • Enhanced tmux-based worker reconciliation to detect and recover from dead zombie processes.
    • Strengthened PID file cleanup logic to prevent serve shutdown races.
    • Improved serve graceful shutdown with signal handling and proper event emission.
  • Documentation

    • Updated CLI guidance for task unblocking workflows.
    • Added serve stability specification with bug fix and validation criteria.

@coderabbitai

coderabbitai Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 294 files, which is 144 over the limit of 150.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f6f97aa2-26aa-4398-890d-9023a6c24584

📥 Commits

Reviewing files that changed from the base of the PR and between 5e28b7f and d3ea283.

⛔ Files ignored due to path filters (6)
  • .genie/assets/commits-30d.svg is excluded by !**/*.svg
  • .genie/assets/loc-30d.svg is excluded by !**/*.svg
  • .genie/assets/releases-30d.svg is excluded by !**/*.svg
  • CHANGELOG.md is excluded by !*.md
  • README.md is excluded by !*.md
  • VELOCITY.md is excluded by !*.md
📒 Files selected for processing (294)
  • .claude-plugin/marketplace.json
  • .genie/agents/metrics-updater/daily-stats.jsonl
  • .genie/agents/metrics-updater/runs.jsonl
  • .genie/agents/metrics-updater/state.json
  • .genie/brainstorms/genie-serve-structured-observability/DESIGN.md
  • .genie/brainstorms/genie-serve-structured-observability/DRAFT.md
  • .genie/reports/trace-genie-spawn-wrong-window.md
  • .genie/wishes/genie-serve-stability/WISH.md
  • .genie/wishes/genie-serve-structured-observability/WISH.md
  • .genie/wishes/turn-session-contract/WISH.md
  • .genie/wishes/wish-command-group-restructure/WISH.md
  • .gitignore
  • docs/ARCHITECTURE.md
  • docs/CLI-REFERENCE.md
  • docs/EVENT-EMITTERS-INVENTORY.md
  • docs/SPAWN-AUTO-RESUME.md
  • docs/SPAWN-TEAM-RESOLUTION.md
  • docs/detectors/schema-audit.md
  • docs/observability-acid-tests.sql
  • docs/observability-consumers.md
  • docs/observability-contract.md
  • docs/observability-rollout.md
  • docs/templates/observability-v0-flip.md
  • knip.json
  • package.json
  • packages/watchdog/README.md
  • packages/watchdog/config/alerts.yaml.example
  • packages/watchdog/package.json
  • packages/watchdog/src/alert.ts
  • packages/watchdog/src/cli.ts
  • packages/watchdog/src/config.ts
  • packages/watchdog/src/index.ts
  • packages/watchdog/src/install.ts
  • packages/watchdog/src/probe.ts
  • packages/watchdog/systemd/genie-watchdog.service
  • packages/watchdog/systemd/genie-watchdog.timer
  • packages/watchdog/test/alert.test.ts
  • packages/watchdog/test/config.test.ts
  • packages/watchdog/test/install.test.ts
  • packages/watchdog/test/isolation.test.ts
  • packages/watchdog/tsconfig.json
  • plugins/genie/.claude-plugin/plugin.json
  • plugins/genie/agents/engineer/AGENTS.md
  • plugins/genie/agents/pm/AGENTS.md
  • plugins/genie/agents/pm/HEARTBEAT.md
  • plugins/genie/agents/team-lead/AGENTS.md
  • plugins/genie/agents/team-lead/HEARTBEAT.md
  • plugins/genie/package.json
  • plugins/genie/rules/genie-orchestration.md
  • scripts/lint-emit-discipline.ts
  • scripts/reconcile-orphans.test.ts
  • scripts/reconcile-orphans.ts
  • scripts/regenerate-wish-fixtures.ts
  • scripts/tests/repro-canonical-uuid.sh
  • scripts/tests/repro-yaml-config.sh
  • scripts/tmux/genie.tmux.conf
  • scripts/tmux/tui-tmux.conf
  • skills/brainstorm/SKILL.md
  • skills/docs/SKILL.md
  • skills/dream/SKILL.md
  • skills/fix/SKILL.md
  • skills/genie-hacks/SKILL.md
  • skills/genie/SKILL.md
  • skills/genie/reference/lifecycle.md
  • skills/pm/SKILL.md
  • skills/refine/SKILL.md
  • skills/report/SKILL.md
  • skills/review/SKILL.md
  • skills/trace/SKILL.md
  • skills/wish/SKILL.md
  • skills/work/SKILL.md
  • src/__tests__/agent-yaml-migration.integration.test.ts
  • src/__tests__/migrate.test.ts
  • src/__tests__/resume.test.ts
  • src/__tests__/tui-spawn-dx.integration.test.ts
  • src/__tests__/wish-cli.integration.test.ts
  • src/consumers/runbook-r1/detector.ts
  • src/consumers/runbook-r1/index.ts
  • src/db/migrations/037_runtime_events_otel_columns.sql
  • src/db/migrations/038_runtime_events_partition.sql
  • src/db/migrations/039_runtime_events_siblings.sql
  • src/db/migrations/040_listen_channel_split.sql
  • src/db/migrations/041_rbac_roles.sql
  • src/db/migrations/042_executor_turn_columns.sql
  • src/db/migrations/043_detector_events_schema.sql
  • src/db/migrations/043_executor_read_role.sql
  • src/db/migrations/044_phase_b_flip_defaults.sql
  • src/db/migrations/044_phase_b_flip_defaults.test.ts
  • src/db/migrations/detector-migration.test.ts
  • src/db/migrations/observability-migrations.test.ts
  • src/detectors/__fixtures__/hello.ts
  • src/detectors/__tests__/fire-budget.test.ts
  • src/detectors/__tests__/pattern-2-team-ls-drift.test.ts
  • src/detectors/__tests__/plugin-api.test.ts
  • src/detectors/index.ts
  • src/detectors/pattern-2-team-ls-drift.ts
  • src/genie-commands/__tests__/session.test.ts
  • src/genie-commands/doctor.test.ts
  • src/genie-commands/doctor.ts
  • src/genie-commands/observability-health.ts
  • src/genie.ts
  • src/hooks/__tests__/session-sync.test.ts
  • src/hooks/handlers/auto-spawn.ts
  • src/hooks/handlers/session-sync.ts
  • src/hooks/index.ts
  • src/lib/__tests__/auto-resume-zombie-cap.test.ts
  • src/lib/__tests__/emit-integrity.test.ts
  • src/lib/__tests__/transport-aware-liveness.test.ts
  • src/lib/__tests__/zombie-spawns.test.ts
  • src/lib/agent-directory.test.ts
  • src/lib/agent-directory.ts
  • src/lib/agent-identity-sync.test.ts
  • src/lib/agent-migrate.test.ts
  • src/lib/agent-migrate.ts
  • src/lib/agent-registry.test.ts
  • src/lib/agent-registry.ts
  • src/lib/agent-sync.ts
  • src/lib/agent-yaml.test.ts
  • src/lib/agent-yaml.ts
  • src/lib/claude-native-teams.test.ts
  • src/lib/claude-native-teams.ts
  • src/lib/db.test.ts
  • src/lib/db.ts
  • src/lib/emit.ts
  • src/lib/events/audit-chain.test.ts
  • src/lib/events/audit-chain.ts
  • src/lib/events/consumer-state.test.ts
  • src/lib/events/consumer-state.ts
  • src/lib/events/rbac.test.ts
  • src/lib/events/rbac.ts
  • src/lib/events/redactors.ts
  • src/lib/events/registry.ts
  • src/lib/events/schemas/_scaffold.ts
  • src/lib/events/schemas/agent.lifecycle.ts
  • src/lib/events/schemas/audit.export.ts
  • src/lib/events/schemas/audit.un_hash.ts
  • src/lib/events/schemas/cache.hit.ts
  • src/lib/events/schemas/cache.invalidate.ts
  • src/lib/events/schemas/cli.command.ts
  • src/lib/events/schemas/consumer.heartbeat.ts
  • src/lib/events/schemas/consumer.lagged.ts
  • src/lib/events/schemas/correlation.orphan.rate.ts
  • src/lib/events/schemas/detector.disabled.ts
  • src/lib/events/schemas/emit.backpressure.critical.ts
  • src/lib/events/schemas/emitter.latency_p99.ts
  • src/lib/events/schemas/emitter.queue.depth.ts
  • src/lib/events/schemas/emitter.rejected.ts
  • src/lib/events/schemas/emitter.shedding_load.ts
  • src/lib/events/schemas/error.raised.ts
  • src/lib/events/schemas/executor.row.written.ts
  • src/lib/events/schemas/executor.write.ts
  • src/lib/events/schemas/hook.delivery.ts
  • src/lib/events/schemas/mailbox.delivery.ts
  • src/lib/events/schemas/notify.delivery.lag.ts
  • src/lib/events/schemas/permissions.deny.ts
  • src/lib/events/schemas/permissions.grant.ts
  • src/lib/events/schemas/resume.attempt.ts
  • src/lib/events/schemas/rot.team-ls-drift.detected.ts
  • src/lib/events/schemas/runbook.triggered.ts
  • src/lib/events/schemas/schema.violation.ts
  • src/lib/events/schemas/schemas.test.ts
  • src/lib/events/schemas/session.id.written.ts
  • src/lib/events/schemas/session.reconciled.ts
  • src/lib/events/schemas/state_transition.ts
  • src/lib/events/schemas/stream.gap.detected.ts
  • src/lib/events/schemas/team.create.ts
  • src/lib/events/schemas/team.disband.ts
  • src/lib/events/schemas/tmux.pane.placed.ts
  • src/lib/events/schemas/wish.dispatch.ts
  • src/lib/events/tier.ts
  • src/lib/events/tokens.test.ts
  • src/lib/events/tokens.ts
  • src/lib/events/v2-query.ts
  • src/lib/executor-read.test.ts
  • src/lib/executor-read.ts
  • src/lib/executor-registry.test.ts
  • src/lib/executor-registry.ts
  • src/lib/executor-types.ts
  • src/lib/idle-timeout.test.ts
  • src/lib/inbox-watcher.test.ts
  • src/lib/inbox-watcher.ts
  • src/lib/lockfile.ts
  • src/lib/mailbox.ts
  • src/lib/observability-flag.test.ts
  • src/lib/observability-flag.ts
  • src/lib/otel-receiver.test.ts
  • src/lib/otel-receiver.ts
  • src/lib/pane-trap.test.ts
  • src/lib/pane-trap.ts
  • src/lib/process-identity.ts
  • src/lib/protocol-router.test.ts
  • src/lib/protocol-router.ts
  • src/lib/provider-adapters.test.ts
  • src/lib/provider-adapters.ts
  • src/lib/providers/claude-code.test.ts
  • src/lib/providers/codex.test.ts
  • src/lib/runtime-events.ts
  • src/lib/scheduler-daemon.test.ts
  • src/lib/scheduler-daemon.ts
  • src/lib/session-filewatch.test.ts
  • src/lib/session-filewatch.ts
  • src/lib/skill-close-verb.test.ts
  • src/lib/spawn-command.test.ts
  • src/lib/spawn-invocation.test.ts
  • src/lib/spawn-invocation.ts
  • src/lib/team-auto-spawn.ts
  • src/lib/team-drift-sources.ts
  • src/lib/team-lead-command.test.ts
  • src/lib/team-lead-command.ts
  • src/lib/team-manager.test.ts
  • src/lib/team-manager.ts
  • src/lib/tmux-alive.test.ts
  • src/lib/tmux-resolve.test.ts
  • src/lib/tmux-wrapper.test.ts
  • src/lib/tmux-wrapper.ts
  • src/lib/tmux.test.ts
  • src/lib/tmux.ts
  • src/lib/trace-context.test.ts
  • src/lib/trace-context.ts
  • src/lib/turn-close.test.ts
  • src/lib/turn-close.ts
  • src/lib/wish-state.test.ts
  • src/lib/wish-state.ts
  • src/serve/__tests__/detector-scheduler.test.ts
  • src/serve/detector-scheduler.ts
  • src/services/__tests__/fixtures/wishes/clean-minimal/expected-violations.json
  • src/services/__tests__/fixtures/wishes/clean-minimal/fixture.json
  • src/services/__tests__/fixtures/wishes/clean-minimal/input.md
  • src/services/__tests__/fixtures/wishes/clean-multi-group/expected-violations.json
  • src/services/__tests__/fixtures/wishes/clean-multi-group/fixture.json
  • src/services/__tests__/fixtures/wishes/clean-multi-group/input.md
  • src/services/__tests__/fixtures/wishes/depends-on-dangling/expected-violations.json
  • src/services/__tests__/fixtures/wishes/depends-on-dangling/fixture.json
  • src/services/__tests__/fixtures/wishes/depends-on-dangling/input.md
  • src/services/__tests__/fixtures/wishes/depends-on-malformed-fixable/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/depends-on-malformed-fixable/expected-violations.json
  • src/services/__tests__/fixtures/wishes/depends-on-malformed-fixable/fixture.json
  • src/services/__tests__/fixtures/wishes/depends-on-malformed-fixable/input.md
  • src/services/__tests__/fixtures/wishes/empty-out-scope/expected-violations.json
  • src/services/__tests__/fixtures/wishes/empty-out-scope/fixture.json
  • src/services/__tests__/fixtures/wishes/empty-out-scope/input.md
  • src/services/__tests__/fixtures/wishes/metadata-missing-status/expected-violations.json
  • src/services/__tests__/fixtures/wishes/metadata-missing-status/fixture.json
  • src/services/__tests__/fixtures/wishes/metadata-missing-status/input.md
  • src/services/__tests__/fixtures/wishes/missing-acceptance-field/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/missing-acceptance-field/expected-violations.json
  • src/services/__tests__/fixtures/wishes/missing-acceptance-field/fixture.json
  • src/services/__tests__/fixtures/wishes/missing-acceptance-field/input.md
  • src/services/__tests__/fixtures/wishes/missing-deliverables-field/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/missing-deliverables-field/expected-violations.json
  • src/services/__tests__/fixtures/wishes/missing-deliverables-field/fixture.json
  • src/services/__tests__/fixtures/wishes/missing-deliverables-field/input.md
  • src/services/__tests__/fixtures/wishes/missing-depends-on-field/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/missing-depends-on-field/expected-violations.json
  • src/services/__tests__/fixtures/wishes/missing-depends-on-field/fixture.json
  • src/services/__tests__/fixtures/wishes/missing-depends-on-field/input.md
  • src/services/__tests__/fixtures/wishes/missing-exec-groups-header/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/missing-exec-groups-header/expected-violations.json
  • src/services/__tests__/fixtures/wishes/missing-exec-groups-header/fixture.json
  • src/services/__tests__/fixtures/wishes/missing-exec-groups-header/input.md
  • src/services/__tests__/fixtures/wishes/missing-goal-field/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/missing-goal-field/expected-violations.json
  • src/services/__tests__/fixtures/wishes/missing-goal-field/fixture.json
  • src/services/__tests__/fixtures/wishes/missing-goal-field/input.md
  • src/services/__tests__/fixtures/wishes/missing-validation-command/expected-violations.json
  • src/services/__tests__/fixtures/wishes/missing-validation-command/fixture.json
  • src/services/__tests__/fixtures/wishes/missing-validation-command/input.md
  • src/services/__tests__/fixtures/wishes/missing-validation-field/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/missing-validation-field/expected-violations.json
  • src/services/__tests__/fixtures/wishes/missing-validation-field/fixture.json
  • src/services/__tests__/fixtures/wishes/missing-validation-field/input.md
  • src/services/__tests__/fixtures/wishes/portuguese-group-headers/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/portuguese-group-headers/expected-violations.json
  • src/services/__tests__/fixtures/wishes/portuguese-group-headers/fixture.json
  • src/services/__tests__/fixtures/wishes/portuguese-group-headers/input.md
  • src/services/__tests__/fixtures/wishes/scope-section-missing/expected-violations.json
  • src/services/__tests__/fixtures/wishes/scope-section-missing/fixture.json
  • src/services/__tests__/fixtures/wishes/scope-section-missing/input.md
  • src/services/__tests__/fixtures/wishes/todo-placeholders/expected-violations.json
  • src/services/__tests__/fixtures/wishes/todo-placeholders/fixture.json
  • src/services/__tests__/fixtures/wishes/todo-placeholders/input.md
  • src/services/__tests__/fixtures/wishes/validation-not-fenced/expected-fixed.md
  • src/services/__tests__/fixtures/wishes/validation-not-fenced/expected-violations.json
  • src/services/__tests__/fixtures/wishes/validation-not-fenced/fixture.json
  • src/services/__tests__/fixtures/wishes/validation-not-fenced/input.md
  • src/services/__tests__/wish-fixtures.test.ts
  • src/services/__tests__/wish-lint.test.ts
  • src/services/__tests__/wish-parser.test.ts
  • src/services/executors/__tests__/claude-code-deliver.test.ts
  • src/services/executors/claude-code.test.ts
  • src/services/executors/claude-code.ts
  • src/services/wish-lint.ts
  • src/services/wish-parser.ts
  • src/services/wish-schema.ts

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
📝 Walkthrough

Walkthrough

This PR implements a comprehensive stabilization plan for the genie serve workflow. It introduces identity-aware PID tracking (combining process ID with kernel-derived start time), adds tmux socket/pane health checks for detecting zombie agents, implements branched error messages for serve timeout scenarios, and includes extensive test coverage for serve lifecycle and agent reconciliation behaviors.

Changes

Cohort / File(s) Summary
Wish Specification & Docs
.genie/wishes/genie-serve-stability/WISH.md, skills/genie-hacks/SKILL.md, skills/pm/SKILL.md
New serve stability wish specification defining five bug scenarios and execution strategy; updated CLI guidance replacing genie reset with genie task unblock for task-specific unblocking.
Process Identity Tracking
src/lib/process-identity.ts
New module exporting getProcessStartTime(pid) that retrieves kernel-derived process start time on macOS (via ps) and Linux (via /proc), returning null for invalid/dead PIDs or platform errors.
Serve PID File & Lifecycle Management
src/term-commands/serve.ts, src/term-commands/serve.test.ts
Updated serve.pid format from single integer to {pid}:{startTime} for identity validation; refactored PID cleanup with race-safe re-read checks and unconditional fallback; made Omni bridge optional unless GENIE_OMNI_REQUIRED=1; added graceful shutdown with signal handling (SIGINT, SIGHUP, uncaughtException), promise-aware service stops, and branched timeout messages. Added integration tests spawning real serve process with refused NATS URL, validating daemon lifecycle and event logging.
Daemon Auto-Start & Timeout Handling
src/lib/db.ts, src/lib/db.test.ts
Refactored autoStartDaemon() to validate PID identity using start-time match instead of simple liveness; treats legacy/stale/unparseable PIDs as stale and triggers fresh spawn; introduced AutoStartOutcome tracking (missing, stale, alive) and branched timeout error messages in _ensurePgserve() naming the specific failure mode. Added test hook __setSpawnDaemonForTest() to stub daemon spawn; comprehensive test coverage for PID format parsing, identity validation, and timeout message branches.
Tmux Socket Health Check
src/lib/tmux.ts
Added exported helper isTmuxSocketAlive(socketName) that validates tmux socket file existence at /tmp/tmux-<uid>/<socketName>.
Agent Reconciliation & Zombie Detection
src/lib/agent-registry.ts, src/lib/agent-registry.test.ts
Extended reconcileStaleSpawns() with third pass detecting dead-pane zombies; introduced dead-tmux-socket short-circuit bucketing candidates by socket name and bulk-updating stale agents on dead sockets to state='error' with cleared pane_id; added resolveWorkerSocketName() helper for socket path derivation. Comprehensive test coverage including tmux unreachability scenarios, synthetic/non-numeric pane IDs, and live-socket counter-cases.
Test Infrastructure
src/__tests__/migrate.test.ts
Updated test setup to resolve symlinks via realpathSync() before computing testDir, ensuring macOS /var → /private/var path consistency with resolved filesystem paths.

Sequence Diagrams

sequenceDiagram
    participant Client as Client Process
    participant FileSystem as File System
    participant ProcessKernel as Process Kernel
    participant Database as Database<br/>(pgserve)
    
    rect rgba(76, 175, 80, 0.5)
    Note over Client,Database: Identity-Aware Daemon Auto-Start (New)
    Client->>FileSystem: read serve.pid
    FileSystem-->>Client: {pid:startTime} or legacy {pid}
    
    alt PID file missing
        Client->>Client: spawn new daemon
        Client->>ProcessKernel: getProcessStartTime(pid)
        ProcessKernel-->>Client: startTime token
        Client->>FileSystem: write {pid}:{startTime}
    else PID exists, validate identity
        Client->>ProcessKernel: getProcessStartTime(stored.pid)
        ProcessKernel-->>Client: current startTime
        alt startTime matches
            Client->>Database: connect (reuse existing daemon)
        else startTime mismatch (recycled PID)
            Client->>FileSystem: unlink serve.pid
            Client->>Client: spawn new daemon
        end
    end
    end
Loading
sequenceDiagram
    participant Scheduler as Scheduler
    participant Registry as Agent Registry
    participant FileSystem as File System
    participant Tmux as Tmux
    participant Database as Database
    
    rect rgba(33, 150, 243, 0.5)
    Note over Scheduler,Database: Zombie Agent Detection & Dead-Socket Fast Path (New)
    Scheduler->>Registry: reconcileStaleSpawns()
    Registry->>Database: query agents (idle/working/permission/question)
    Database-->>Registry: agent list with pane_ids
    
    loop Per tmux socket in candidates
        Registry->>FileSystem: check /tmp/tmux-{uid}/{socketName}
        FileSystem-->>Registry: socket exists?
        alt Socket dead (missing)
            Registry->>Database: bulk UPDATE agents on dead socket
            Database-->>Registry: state='error', pane_id=''
        else Socket alive
            loop Per agent on live socket
                Registry->>Tmux: isPaneAlive(pane_id)
                Tmux-->>Registry: pane state
            end
        end
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% 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 title accurately summarizes the main change: making the Omni bridge optional so serve continues without NATS, which is the primary user-facing fix in this PR.

✏️ 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 fix/genie-serve-stability

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.

@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: 5e28b7f005

ℹ️ 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 +634 to +638
if (process.env.GENIE_OMNI_REQUIRED === '1') {
console.error(` Omni bridge: FAILED — ${msg}`);
process.exit(1);
}
console.warn(` Omni bridge: degraded — ${msg}; set GENIE_OMNI_REQUIRED=1 to make this fatal`);

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 Stop partially-started Omni bridge on degraded fallback

In the non-strict fallback, we log degraded mode and continue when bridge.start() throws, but we do not run bridge.stop(). OmniBridge.start() can fail after allocating resources (for example after connecting to NATS but before full initialization, such as PG setup/schema failures), so this leaves a partially initialized bridge alive for the rest of the serve process; because handles.omniBridge is never set, normal shutdown will not clean it up. Add a best-effort await bridge.stop().catch(...) in this catch path before continuing.

Useful? React with 👍 / 👎.

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

Caution

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

⚠️ Outside diff range comments (1)
src/term-commands/serve.ts (1)

721-754: ⚠️ Potential issue | 🟡 Minor

Multi-signal race: forceTimer can be scheduled twice.

gracefulExit guards re-entry on shutdownStarted, but that flag is only flipped inside shutdown() (Line 650), after gracefulExit has already scheduled its forceTimer and entered the await in shutdown(). A second signal arriving before shutdown()'s first synchronous statements runs will see shutdownStarted === false, schedule a second forceTimer, and re-enter shutdown() — where the real guard then short-circuits. You end up with two timers, and the first one to fire wins the exit code (1 instead of 130/143).

🔧 Local guard
+  let gracefulExitStarted = false;
   const gracefulExit = (exitCode: number): void => {
-    // Guard against multiple concurrent signals
-    if (shutdownStarted) return;
+    if (gracefulExitStarted) return;
+    gracefulExitStarted = true;
🤖 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 721 - 754, The gracefulExit function
can schedule multiple forceTimer instances because shutdownStarted is only set
inside shutdown(); set the shutdown guard immediately at the start of
gracefulExit to prevent re-entry before scheduling timers—e.g., set
shutdownStarted = true as the first statement in gracefulExit (before creating
forceTimer) so subsequent signals return early; keep the rest of logic
(forceTimer setup, shutdown().catch().finally() that clears the timer,
removeServePid and process.exit) unchanged so the single timer is the only one
that can fire.
🤖 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/agent-registry.test.ts`:
- Around line 430-475: The test overwrites process.env.GENIE_TMUX_SOCKET and
blindly deletes it in finally, which clobbers any preexisting value; update the
dead-socket-worker-1/2 test (and the other live-socket test around the
reconcileStaleSpawns tests) to snapshot const prev =
process.env.GENIE_TMUX_SOCKET before assigning a test socket, and in the finally
restore it with process.env.GENIE_TMUX_SOCKET = prev (or delete if prev is
undefined) so existing env is preserved; touch the tests referencing
reconcileStaleSpawns and the GENIE_TMUX_SOCKET manipulation to follow this
pattern.

In `@src/lib/agent-registry.ts`:
- Around line 317-340: The socket bucketing via socketBuckets is redundant
because resolveWorkerSocketName() returns a single process-wide value; instead,
call resolveWorkerSocketName() once outside the loops and branch: if
isTmuxSocketAlive(socket) is true then directly assign liveSpawning =
staleWithPane and liveActive = activeDeadCandidates (or push all rows into those
arrays), else iterate staleWithPane and activeDeadCandidates to handle the dead
case; update uses of socketBuckets, spawning/active variables and remove the Map
scaffolding (or if you prefer to keep it for future per-row sockets, add a clear
TODO above socketBuckets explaining it is intentional scaffolding referencing
resolveWorkerSocketName, socketBuckets, staleWithPane, activeDeadCandidates,
liveSpawning, liveActive, and isTmuxSocketAlive).
- Around line 335-381: The dead-socket branch (inside the socketBuckets loop
using isTmuxSocketAlive) currently only records an aggregate
recordAuditEvent('worker', socketName, 'recovery_socket_dead', ...) and skips
emitting per-worker state_changed audit rows like the live-socket branch does;
update the code so that inside the loops iterating bucket.spawning and
bucket.active (where you already update agent rows and push resetIds) you also
call recordAuditEvent with the per-worker event (use entity_type = 'worker' and
entity_id = row.id, event type 'state_changed' and include previous/new state
metadata) for every agent actually transitioned, and change the aggregate event
to use an appropriate entity_type such as 'tmux_socket' (keep entity_id =
socketName) so the aggregate recovery_socket_dead event no longer mislabels
workers.

In `@src/lib/db.test.ts`:
- Around line 572-578: The test currently only scans db.ts source text; instead
call the function under test (autoStartDaemon) and exercise each timeout branch
by stubbing the environment/IO checks: simulate "no PID" by making
readFile/exists checks behave as if no ~/.genie/serve.pid is present and assert
the thrown message contains "genie serve not running. Run: genie serve start";
simulate "stale PID" by returning a PID file whose process check fails and
assert the thrown message contains "Stale ~/.genie/serve.pid"; simulate
"unhealthy pgserve" by stubbing the health/connect check (e.g., the method that
probes pgserve port) to return failure and assert the thrown message contains
"pgserve did not respond on port". Use the same test file (db.test.ts),
sinon/jest mocks or fixtures to replace filesystem and network checks around
autoStartDaemon, and assert on the thrown errors rather than searching source
text.
- Around line 472-480: The afterEach cleanup in the test sets
process.env.GENIE_HOME = undefined which assigns the string "undefined" and
pollutes other tests; update the afterEach block (the cleanup around
__setSpawnDaemonForTest and origGenieHome) to remove the environment variable
instead: if origGenieHome is defined restore it, otherwise use delete
process.env.GENIE_HOME so GENIE_HOME is actually removed from the environment.

In `@src/lib/db.ts`:
- Around line 345-364: Between detecting a stale identity and calling
unlinkSync(pidPath), add a re-check: read the pid file again
(fs.readFileSync(pidPath) parse to pid and startTime) and compare both values to
the pid and recordedStartTime you just used to classify it; only call
unlinkSync(pidPath) if they still match. If the re-read shows a different live
identity, treat that as "alive" (set lastAutoStartOutcome='alive',
lastAutoStartPid to the re-read pid and return) instead of removing the file; if
the re-read fails because the file is already gone, continue treating it as
stale and proceed to unlink/spawn as before. Use the same parsing logic as
earlier (the code around getProcessStartTime, pid, recordedStartTime and
spawnDaemon) and wrap filesystem ops in try/catch to preserve existing
error-handling behavior.
- Around line 261-275: Duplicate default spawn logic exists inside
__setSpawnDaemonForTest; extract the original implementation into a single
reusable constant (e.g., DEFAULT_SPAWN_DAEMON) defined next to where spawnDaemon
is declared, then have __setSpawnDaemonForTest set spawnDaemon = fn ??
DEFAULT_SPAWN_DAEMON so the default body is only maintained in one place; update
any references to use DEFAULT_SPAWN_DAEMON and ensure behavior (detached, stdio,
env, child.unref()) is preserved.
- Around line 442-459: The pidLabel fallback currently uses outcomeAtStart which
can be a state word like 'alive' and produce confusing messages; update the
logic that computes pidLabel (where pidLabel = pidAtStart ?? outcomeAtStart ??
'unknown') to prefer an actual PID source instead of outcomeAtStart — e.g., use
lastAutoStartPid or another stored PID (pidAtStart ?? lastAutoStartPid ??
'unknown') and keep the existing early-return checks around outcomeAtStart
('stale'/'missing') intact so the final thrown Error for the nonresponsive
pgserve always shows a numeric PID or 'unknown' rather than the outcome string.

In `@src/lib/tmux.ts`:
- Around line 556-559: isTmuxSocketAlive currently hard-codes `/tmp/tmux-<uid>`
so sockets placed under a custom TMUX_TMPDIR are treated as dead; update
isTmuxSocketAlive to compute the socket directory from process.env.TMUX_TMPDIR
(falling back to '/tmp') and join that with `tmux-<uid>` and the socketName
(e.g., join(tmuxTmpDir, `tmux-${uid}`, socketName)) so reconcileStaleSpawns no
longer mis-classifies live sockets; also update the JSDoc for isTmuxSocketAlive
to mention that the socket directory is determined by the TMUX_TMPDIR
environment variable.

In `@src/term-commands/serve.test.ts`:
- Around line 112-134: The spawned serve process is inheriting the test runner
cwd so it exits with "No workspace found"; update the spawn call
(spawn(BUN_PATH, [GENIE_ENTRY, 'serve', ...], { env: mergedEnv, stdio: [...] }))
to include cwd: testDir (or ensure a workspace is created and chdir to testDir
before spawning) so the child runs in the temporary workspace where GENIE_HOME
points; keep mergedEnv and stdio intact.

In `@src/term-commands/serve.ts`:
- Around line 760-771: The uncaughtException handler can call gracefulExit(1)
which may return immediately if shutdownStarted is already true, leaving the
process running; update the uncaughtException handler to call gracefulExit(1)
and if shutdownStarted is true (or if gracefulExit returns without scheduling an
exit) forcefully call process.exit(1) so the process terminates; reference the
gracefulExit function, the shutdownStarted flag, and the uncaughtException
handler and ensure removeServePid semantics remain unchanged.

---

Outside diff comments:
In `@src/term-commands/serve.ts`:
- Around line 721-754: The gracefulExit function can schedule multiple
forceTimer instances because shutdownStarted is only set inside shutdown(); set
the shutdown guard immediately at the start of gracefulExit to prevent re-entry
before scheduling timers—e.g., set shutdownStarted = true as the first statement
in gracefulExit (before creating forceTimer) so subsequent signals return early;
keep the rest of logic (forceTimer setup, shutdown().catch().finally() that
clears the timer, removeServePid and process.exit) unchanged so the single timer
is the only one that can fire.
🪄 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: 8ca1cb3f-b059-43fe-91c1-af8798a3f84e

📥 Commits

Reviewing files that changed from the base of the PR and between f4188ad and 5e28b7f.

📒 Files selected for processing (12)
  • .genie/wishes/genie-serve-stability/WISH.md
  • skills/genie-hacks/SKILL.md
  • skills/pm/SKILL.md
  • src/__tests__/migrate.test.ts
  • src/lib/agent-registry.test.ts
  • src/lib/agent-registry.ts
  • src/lib/db.test.ts
  • src/lib/db.ts
  • src/lib/process-identity.ts
  • src/lib/tmux.ts
  • src/term-commands/serve.test.ts
  • src/term-commands/serve.ts

Comment on lines +430 to +475
test('flips workers registered on a dead socket to error (Bug 4)', async () => {
// Bug 4 repro: workers recorded on a tmux socket that no longer exists
// were stuck in 'idle'/'working' forever because reconcileStaleSpawns
// catches the TmuxUnreachableError from isPaneAlive and skips the worker.
// After the fix, a dead socket is detected once up-front and every
// worker on it is transitioned to 'error' with pane_id cleared.
//
// We pick a socket name that we can guarantee does NOT exist under
// /tmp/tmux-<uid>/ — any random UUID works.
const deadSocketName = `genie-dead-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
process.env.GENIE_TMUX_SOCKET = deadSocketName;
try {
const oldChange = new Date(Date.now() - 5_000).toISOString();
await register(
makeAgent({
id: 'dead-socket-worker-1',
paneId: '%9999',
state: 'idle',
startedAt: oldChange,
lastStateChange: oldChange,
}),
);
await register(
makeAgent({
id: 'dead-socket-worker-2',
paneId: '%9998',
state: 'working',
startedAt: oldChange,
lastStateChange: oldChange,
}),
);

const reset = await reconcileStaleSpawns(2);
expect(reset).toContain('dead-socket-worker-1');
expect(reset).toContain('dead-socket-worker-2');

const a1 = await get('dead-socket-worker-1');
expect(a1!.state).toBe('error');
expect(a1!.paneId).toBe('');
const a2 = await get('dead-socket-worker-2');
expect(a2!.state).toBe('error');
expect(a2!.paneId).toBe('');
} finally {
// biome-ignore lint/performance/noDelete: assigning undefined would set the string "undefined"
delete process.env.GENIE_TMUX_SOCKET;
}

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

Restore the original GENIE_TMUX_SOCKET.

These tests overwrite a process-global env var and then always delete it. If the runner already had GENIE_TMUX_SOCKET set, later tests lose that value. Snapshot and restore it in both tests.

Proposed cleanup pattern
       const deadSocketName = `genie-dead-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
+      const originalSocketName = process.env.GENIE_TMUX_SOCKET;
       process.env.GENIE_TMUX_SOCKET = deadSocketName;
       try {
         const oldChange = new Date(Date.now() - 5_000).toISOString();
@@
       } finally {
-        // biome-ignore lint/performance/noDelete: assigning undefined would set the string "undefined"
-        delete process.env.GENIE_TMUX_SOCKET;
+        if (originalSocketName === undefined) {
+          // biome-ignore lint/performance/noDelete: assigning undefined would set the string "undefined"
+          delete process.env.GENIE_TMUX_SOCKET;
+        } else {
+          process.env.GENIE_TMUX_SOCKET = originalSocketName;
+        }
       }

Apply the same restore pattern around the live-socket test.

Also applies to: 493-512

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

In `@src/lib/agent-registry.test.ts` around lines 430 - 475, The test overwrites
process.env.GENIE_TMUX_SOCKET and blindly deletes it in finally, which clobbers
any preexisting value; update the dead-socket-worker-1/2 test (and the other
live-socket test around the reconcileStaleSpawns tests) to snapshot const prev =
process.env.GENIE_TMUX_SOCKET before assigning a test socket, and in the finally
restore it with process.env.GENIE_TMUX_SOCKET = prev (or delete if prev is
undefined) so existing env is preserved; touch the tests referencing
reconcileStaleSpawns and the GENIE_TMUX_SOCKET manipulation to follow this
pattern.

Comment thread src/lib/agent-registry.ts
Comment thread src/lib/agent-registry.ts
Comment on lines +335 to +381
for (const [socketName, bucket] of socketBuckets) {
if (isTmuxSocketAlive(socketName)) {
liveSpawning.push(...bucket.spawning);
liveActive.push(...bucket.active);
continue;
}
// Socket is dead — mark every candidate on it as error in one pass.
const allIds = [...bucket.spawning.map((r) => r.id), ...bucket.active.map((r) => r.id)];
if (allIds.length === 0) continue;

// Per-row state-guarded UPDATE preserves the concurrent-transition
// race protection from the original code.
for (const row of bucket.spawning) {
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = 'spawning'
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from spawning → error`,
);
resetIds.push(row.id);
}
}
for (const row of bucket.active) {
const prevState = row.state;
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = ${prevState}
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from ${prevState} → error`,
);
resetIds.push(row.id);
}
}
recordAuditEvent('worker', socketName, 'recovery_socket_dead', 'reconciler', {
socket: socketName,
worker_ids: allIds,
worker_count: allIds.length,
}).catch(() => {});
}

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

Dead-socket path skips per-worker state_changed audit events.

The live-socket branch at Lines 405–432 emits a per-worker state_changed audit event when a zombie is reset, matching the pattern used everywhere else in this file (see also Lines 274 and 394). The dead-socket branch records only one aggregate recovery_socket_dead event keyed by the socket name — so workers killed via the fast path are invisible to per-worker state-transition audit queries and dashboards that pivot on entity_id.

Also, using socketName as the entity_id under entity_type = 'worker' is a type/id mismatch; consider entity_type = 'tmux_socket' (or similar) for the aggregate event, and still emit per-worker state_changed rows inside the loops at Lines 347 and 361.

🔧 Suggested patch
       for (const row of bucket.spawning) {
         const updated = await sql<{ id: string }[]>`
           UPDATE agents
           SET state = 'error', last_state_change = now(), pane_id = ''
           WHERE id = ${row.id} AND state = 'spawning'
           RETURNING id
         `;
         if (updated.length > 0) {
           console.error(
             `[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from spawning → error`,
           );
+          recordAuditEvent('worker', row.id, 'state_changed', 'reconciler', {
+            state: 'error',
+            reason: 'socket_dead',
+            previous_state: 'spawning',
+            socket: socketName,
+          }).catch(() => {});
           resetIds.push(row.id);
         }
       }
       for (const row of bucket.active) {
         const prevState = row.state;
         const updated = await sql<{ id: string }[]>`
           UPDATE agents
           SET state = 'error', last_state_change = now(), pane_id = ''
           WHERE id = ${row.id} AND state = ${prevState}
           RETURNING id
         `;
         if (updated.length > 0) {
           console.error(
             `[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from ${prevState} → error`,
           );
+          recordAuditEvent('worker', row.id, 'state_changed', 'reconciler', {
+            state: 'error',
+            reason: 'socket_dead',
+            previous_state: prevState,
+            socket: socketName,
+          }).catch(() => {});
           resetIds.push(row.id);
         }
       }
📝 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
for (const [socketName, bucket] of socketBuckets) {
if (isTmuxSocketAlive(socketName)) {
liveSpawning.push(...bucket.spawning);
liveActive.push(...bucket.active);
continue;
}
// Socket is dead — mark every candidate on it as error in one pass.
const allIds = [...bucket.spawning.map((r) => r.id), ...bucket.active.map((r) => r.id)];
if (allIds.length === 0) continue;
// Per-row state-guarded UPDATE preserves the concurrent-transition
// race protection from the original code.
for (const row of bucket.spawning) {
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = 'spawning'
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from spawning → error`,
);
resetIds.push(row.id);
}
}
for (const row of bucket.active) {
const prevState = row.state;
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = ${prevState}
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from ${prevState} → error`,
);
resetIds.push(row.id);
}
}
recordAuditEvent('worker', socketName, 'recovery_socket_dead', 'reconciler', {
socket: socketName,
worker_ids: allIds,
worker_count: allIds.length,
}).catch(() => {});
}
for (const [socketName, bucket] of socketBuckets) {
if (isTmuxSocketAlive(socketName)) {
liveSpawning.push(...bucket.spawning);
liveActive.push(...bucket.active);
continue;
}
// Socket is dead — mark every candidate on it as error in one pass.
const allIds = [...bucket.spawning.map((r) => r.id), ...bucket.active.map((r) => r.id)];
if (allIds.length === 0) continue;
// Per-row state-guarded UPDATE preserves the concurrent-transition
// race protection from the original code.
for (const row of bucket.spawning) {
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = 'spawning'
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from spawning → error`,
);
recordAuditEvent('worker', row.id, 'state_changed', 'reconciler', {
state: 'error',
reason: 'socket_dead',
previous_state: 'spawning',
socket: socketName,
}).catch(() => {});
resetIds.push(row.id);
}
}
for (const row of bucket.active) {
const prevState = row.state;
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = ${prevState}
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from ${prevState} → error`,
);
recordAuditEvent('worker', row.id, 'state_changed', 'reconciler', {
state: 'error',
reason: 'socket_dead',
previous_state: prevState,
socket: socketName,
}).catch(() => {});
resetIds.push(row.id);
}
}
recordAuditEvent('worker', socketName, 'recovery_socket_dead', 'reconciler', {
socket: socketName,
worker_ids: allIds,
worker_count: allIds.length,
}).catch(() => {});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/agent-registry.ts` around lines 335 - 381, The dead-socket branch
(inside the socketBuckets loop using isTmuxSocketAlive) currently only records
an aggregate recordAuditEvent('worker', socketName, 'recovery_socket_dead', ...)
and skips emitting per-worker state_changed audit rows like the live-socket
branch does; update the code so that inside the loops iterating bucket.spawning
and bucket.active (where you already update agent rows and push resetIds) you
also call recordAuditEvent with the per-worker event (use entity_type = 'worker'
and entity_id = row.id, event type 'state_changed' and include previous/new
state metadata) for every agent actually transitioned, and change the aggregate
event to use an appropriate entity_type such as 'tmux_socket' (keep entity_id =
socketName) so the aggregate recovery_socket_dead event no longer mislabels
workers.

Comment thread src/lib/db.test.ts
Comment on lines +472 to +480
afterEach(async () => {
// Always restore the real spawn fn so other tests aren't affected.
const { __setSpawnDaemonForTest } = await import('./db.js');
__setSpawnDaemonForTest(null);
if (origGenieHome !== undefined) {
process.env.GENIE_HOME = origGenieHome;
} else {
process.env.GENIE_HOME = undefined;
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verifies Node/Bun-style env assignment behavior without touching repo files.
node -e 'process.env.GENIE_HOME = undefined; console.log(String(process.env.GENIE_HOME)); delete process.env.GENIE_HOME; console.log(process.env.GENIE_HOME === undefined)'

Repository: automagik-dev/genie

Length of output: 77


🏁 Script executed:

sed -n '472,480p' src/lib/db.test.ts

Repository: automagik-dev/genie

Length of output: 406


Delete GENIE_HOME instead of assigning undefined to prevent test isolation failure.

Assigning process.env.GENIE_HOME = undefined sets it to the string "undefined" rather than removing it. This pollutes the global test environment, causing subsequent tests to resolve paths under a bogus GENIE_HOME value.

Use delete process.env.GENIE_HOME instead:

Fix
     if (origGenieHome !== undefined) {
       process.env.GENIE_HOME = origGenieHome;
     } else {
-      process.env.GENIE_HOME = undefined;
+      // biome-ignore lint/performance/noDelete: assigning undefined would set the string "undefined"
+      delete process.env.GENIE_HOME;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/db.test.ts` around lines 472 - 480, The afterEach cleanup in the test
sets process.env.GENIE_HOME = undefined which assigns the string "undefined" and
pollutes other tests; update the afterEach block (the cleanup around
__setSpawnDaemonForTest and origGenieHome) to remove the environment variable
instead: if origGenieHome is defined restore it, otherwise use delete
process.env.GENIE_HOME so GENIE_HOME is actually removed from the environment.

Comment thread src/lib/db.test.ts
Comment thread src/lib/db.ts
Comment on lines +442 to +459

// Branch the timeout error so the user sees the actual failure mode.
const home = process.env.GENIE_HOME ?? GENIE_HOME;
const pidPath = join(home, 'serve.pid');
const hasPidFile = existsSync(pidPath);
const currentPort = readLockfile() ?? getPort();
if (outcomeAtStart === 'stale') {
throw new Error(
`Stale ~/.genie/serve.pid (PID ${pidAtStart ?? 'unknown'} was not our serve). Removed and retried — if this persists, run: genie serve start`,
);
}
if (!hasPidFile) {
throw new Error('genie serve not running. Run: genie serve start');
}
const pidLabel = pidAtStart ?? outcomeAtStart ?? 'unknown';
throw new Error(
`genie serve is running (PID ${pidLabel}) but pgserve did not respond on port ${currentPort} within 16s. Try: genie serve restart, or check ~/.genie/logs/scheduler.log`,
);

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

pidLabel can print a state word as a PID.

On Line 456, pidLabel = pidAtStart ?? outcomeAtStart ?? 'unknown' falls back to the outcome string ('missing' | 'stale' | 'alive') when pidAtStart is null. The user would then see genie serve is running (PID alive) but pgserve did not respond…, which reads as nonsense. This branch is only reached when outcomeAtStart === 'alive' (stale/missing already returned earlier), and lastAutoStartPid is set on the alive path — but the fallback exists and will fire on any future refactor where pidAtStart is null.

🔧 Proposed fix
-  const pidLabel = pidAtStart ?? outcomeAtStart ?? 'unknown';
+  const pidLabel = pidAtStart ?? 'unknown';
📝 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
// Branch the timeout error so the user sees the actual failure mode.
const home = process.env.GENIE_HOME ?? GENIE_HOME;
const pidPath = join(home, 'serve.pid');
const hasPidFile = existsSync(pidPath);
const currentPort = readLockfile() ?? getPort();
if (outcomeAtStart === 'stale') {
throw new Error(
`Stale ~/.genie/serve.pid (PID ${pidAtStart ?? 'unknown'} was not our serve). Removed and retried — if this persists, run: genie serve start`,
);
}
if (!hasPidFile) {
throw new Error('genie serve not running. Run: genie serve start');
}
const pidLabel = pidAtStart ?? outcomeAtStart ?? 'unknown';
throw new Error(
`genie serve is running (PID ${pidLabel}) but pgserve did not respond on port ${currentPort} within 16s. Try: genie serve restart, or check ~/.genie/logs/scheduler.log`,
);
// Branch the timeout error so the user sees the actual failure mode.
const home = process.env.GENIE_HOME ?? GENIE_HOME;
const pidPath = join(home, 'serve.pid');
const hasPidFile = existsSync(pidPath);
const currentPort = readLockfile() ?? getPort();
if (outcomeAtStart === 'stale') {
throw new Error(
`Stale ~/.genie/serve.pid (PID ${pidAtStart ?? 'unknown'} was not our serve). Removed and retried — if this persists, run: genie serve start`,
);
}
if (!hasPidFile) {
throw new Error('genie serve not running. Run: genie serve start');
}
const pidLabel = pidAtStart ?? 'unknown';
throw new Error(
`genie serve is running (PID ${pidLabel}) but pgserve did not respond on port ${currentPort} within 16s. Try: genie serve restart, or check ~/.genie/logs/scheduler.log`,
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/db.ts` around lines 442 - 459, The pidLabel fallback currently uses
outcomeAtStart which can be a state word like 'alive' and produce confusing
messages; update the logic that computes pidLabel (where pidLabel = pidAtStart
?? outcomeAtStart ?? 'unknown') to prefer an actual PID source instead of
outcomeAtStart — e.g., use lastAutoStartPid or another stored PID (pidAtStart ??
lastAutoStartPid ?? 'unknown') and keep the existing early-return checks around
outcomeAtStart ('stale'/'missing') intact so the final thrown Error for the
nonresponsive pgserve always shows a numeric PID or 'unknown' rather than the
outcome string.

Comment thread src/lib/tmux.ts
Comment on lines +556 to +559
export function isTmuxSocketAlive(socketName: string | undefined | null): boolean {
if (!socketName) return false;
const uid = process.getuid?.() ?? 501;
return existsSync(join(`/tmp/tmux-${uid}`, socketName));

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

🧩 Analysis chain

🌐 Web query:

tmux default socket path TMUX_TMPDIR tmux-UID -L socket

💡 Result:

The default tmux socket path is $TMUX_TMPDIR/tmux-$UID/default, where TMUX_TMPDIR defaults to /tmp if unset. Thus, it is typically /tmp/tmux-$UID/default (e.g., /tmp/tmux-1000/default for UID 1000). TMUX_TMPDIR specifies the base directory for the tmux-UID subdirectory containing sockets. Set it in the environment before starting tmux (requires server restart to take effect). The -L option specifies a socket name (default: "default") within that directory, enabling multiple servers like tmux -L mysocket. The -S option overrides with a full custom socket path, ignoring -L and the default directory. The tmux-UID directory is created by tmux and must not be world-readable/writable/executable for security. Inside tmux panes, $TMUX contains the full socket path (first part before comma). Examples: - Default: tmux new-session → /tmp/tmux-1000/default - Custom dir: TMUX_TMPDIR=/var/tmux tmux new-session → /var/tmux/tmux-1000/default - Multiple servers: tmux -L work new-session → $TMUX_TMPDIR/tmux-$UID/work - Full path: tmux -S /path/to/socket new-session

Citations:


🏁 Script executed:

# Find all usages of isTmuxSocketAlive
rg "isTmuxSocketAlive" --type ts -B 2 -A 2

Repository: automagik-dev/genie

Length of output: 1065


🏁 Script executed:

# Examine agent-registry.ts to understand the context
fd agent-registry

Repository: automagik-dev/genie

Length of output: 119


🏁 Script executed:

# Check the full implementation of isTmuxSocketAlive and surrounding context
sed -n '550,565p' src/lib/tmux.ts

Repository: automagik-dev/genie

Length of output: 708


🏁 Script executed:

# Look for socket bucket setup and where socketName originates
sed -n '270,310p' src/lib/agent-registry.ts

Repository: automagik-dev/genie

Length of output: 2122


🏁 Script executed:

# Search for where socket names are collected/stored
rg "socketName" src/lib/agent-registry.ts -B 3 -A 3

Repository: automagik-dev/genie

Length of output: 1068


🏁 Script executed:

# Check if socketName could ever be a full path (from -S or $TMUX)
rg "TMUX|socketName|\-S" src/lib/ --type ts -A 2 | head -80

Repository: automagik-dev/genie

Length of output: 3985


🏁 Script executed:

# Verify the exact structure of agent-registry socket grouping to confirm socketName is just the name
sed -n '200,260p' src/lib/agent-registry.ts | grep -A 50 "socketBuckets"

Repository: automagik-dev/genie

Length of output: 45


🏁 Script executed:

# Check if there's any extraction/parsing of socket names that might affect them
rg "pane_id|socket" src/lib/agent-registry.ts | grep -i "split\|substring\|extract" | head -20

Repository: automagik-dev/genie

Length of output: 45


🏁 Script executed:

# Find socketBuckets construction more directly
rg "socketBuckets" src/lib/agent-registry.ts -B 5 -A 10

Repository: automagik-dev/genie

Length of output: 1559


🏁 Script executed:

# Check where socket names come from in the agent records
rg "socket_name|pane_id" src/lib/agent-registry.ts -B 2 -A 2 | head -60

Repository: automagik-dev/genie

Length of output: 3878


🏁 Script executed:

# Find the resolveWorkerSocketName function
rg "resolveWorkerSocketName" src/lib/agent-registry.ts -B 3 -A 8

Repository: automagik-dev/genie

Length of output: 1308


🏁 Script executed:

# Also check the tmux.ts file for any other socket-related functions
sed -n '1,50p' src/lib/tmux.ts | head -40

Repository: automagik-dev/genie

Length of output: 1082


🏁 Script executed:

# Verify the fix is complete - check if there are any other socket path constructions
rg "tmux-\${uid}|/tmp/tmux" src/lib/ --type ts

Repository: automagik-dev/genie

Length of output: 173


🏁 Script executed:

# Check if TMUX_TMPDIR is used elsewhere in the codebase
rg "TMUX_TMPDIR" src/ --type ts

Repository: automagik-dev/genie

Length of output: 45


🏁 Script executed:

# Check for other socket path constructions (fixed regex)
rg "/tmp/tmux|tmux-uid|TMUX_TMPDIR" src/lib/ --type ts

Repository: automagik-dev/genie

Length of output: 1537


🏁 Script executed:

# Check if socketName could ever contain a full path with slashes
rg "socketName|socket" src/lib/agent-registry.ts | grep -E "split|substring|indexOf|slice"

Repository: automagik-dev/genie

Length of output: 45


🏁 Script executed:

# Look at the comment in tmux.ts about socket paths
sed -n '545,560p' src/lib/tmux.ts

Repository: automagik-dev/genie

Length of output: 813


🏁 Script executed:

# Check if there are any tests for isTmuxSocketAlive that might be affected
rg "isTmuxSocketAlive" src/lib/*.test.ts -B 3 -A 5

Repository: automagik-dev/genie

Length of output: 45


🏁 Script executed:

# Look for any existing tests that set TMUX_TMPDIR
rg "TMUX_TMPDIR" . --type ts --type json

Repository: automagik-dev/genie

Length of output: 45


🏁 Script executed:

# Check the test file for isTmuxSocketAlive context
grep -r "isTmuxSocketAlive\|tmux.*socket" src/lib/*.test.ts | head -20

Repository: automagik-dev/genie

Length of output: 274


Fix socket path to respect TMUX_TMPDIR.

isTmuxSocketAlive() hard-codes /tmp/tmux-<uid> for socket lookup, but tmux respects $TMUX_TMPDIR (defaults to /tmp). When TMUX_TMPDIR is set to a custom directory, live sockets there are incorrectly classified as dead, causing reconcileStaleSpawns() to mark all workers on that socket as error.

The JSDoc comment also needs updating to document this configuration option.

Fix
 export function isTmuxSocketAlive(socketName: string | undefined | null): boolean {
   if (!socketName) return false;
   const uid = process.getuid?.() ?? 501;
-  return existsSync(join(`/tmp/tmux-${uid}`, socketName));
+  const tmuxTmpDir = process.env.TMUX_TMPDIR ?? '/tmp';
+  return existsSync(join(tmuxTmpDir, `tmux-${uid}`, socketName));
 }

Also update the JSDoc to clarify that the socket directory is determined by $TMUX_TMPDIR.

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

In `@src/lib/tmux.ts` around lines 556 - 559, isTmuxSocketAlive currently
hard-codes `/tmp/tmux-<uid>` so sockets placed under a custom TMUX_TMPDIR are
treated as dead; update isTmuxSocketAlive to compute the socket directory from
process.env.TMUX_TMPDIR (falling back to '/tmp') and join that with `tmux-<uid>`
and the socketName (e.g., join(tmuxTmpDir, `tmux-${uid}`, socketName)) so
reconcileStaleSpawns no longer mis-classifies live sockets; also update the
JSDoc for isTmuxSocketAlive to mention that the socket directory is determined
by the TMUX_TMPDIR environment variable.

Comment on lines +112 to +134
const proc = spawn(BUN_PATH, [GENIE_ENTRY, 'serve', 'start', '--foreground', '--headless'], {
env: mergedEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
proc.stdout?.on('data', (chunk: Buffer) => {
stdout.buffer += chunk.toString('utf-8');
});
proc.stderr?.on('data', (chunk: Buffer) => {
stderr.buffer += chunk.toString('utf-8');
});

const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
proc.on('exit', (code, signal) => resolve({ code, signal }));
});

return { child: proc, stdout, stderr, exit };
}

beforeEach(() => {
testDir = join(tmpdir(), `genie-serve-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
genieHome = join(testDir, '.genie');
mkdirSync(genieHome, { recursive: true });

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 | 🔴 Critical

Spawn serve from the initialized temp workspace.

The child inherits the test runner cwd while GENIE_HOME points at testDir/.genie; CI shows it exits with No workspace found before the bridge/PID assertions run. Pass the temp workspace as cwd or initialize one before spawning.

Proposed fix
   const proc = spawn(BUN_PATH, [GENIE_ENTRY, 'serve', 'start', '--foreground', '--headless'], {
+    cwd: testDir,
     env: mergedEnv,
     stdio: ['ignore', 'pipe', 'pipe'],
   });
📝 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
const proc = spawn(BUN_PATH, [GENIE_ENTRY, 'serve', 'start', '--foreground', '--headless'], {
env: mergedEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
proc.stdout?.on('data', (chunk: Buffer) => {
stdout.buffer += chunk.toString('utf-8');
});
proc.stderr?.on('data', (chunk: Buffer) => {
stderr.buffer += chunk.toString('utf-8');
});
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
proc.on('exit', (code, signal) => resolve({ code, signal }));
});
return { child: proc, stdout, stderr, exit };
}
beforeEach(() => {
testDir = join(tmpdir(), `genie-serve-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
genieHome = join(testDir, '.genie');
mkdirSync(genieHome, { recursive: true });
const proc = spawn(BUN_PATH, [GENIE_ENTRY, 'serve', 'start', '--foreground', '--headless'], {
cwd: testDir,
env: mergedEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
proc.stdout?.on('data', (chunk: Buffer) => {
stdout.buffer += chunk.toString('utf-8');
});
proc.stderr?.on('data', (chunk: Buffer) => {
stderr.buffer += chunk.toString('utf-8');
});
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
proc.on('exit', (code, signal) => resolve({ code, signal }));
});
return { child: proc, stdout, stderr, exit };
}
beforeEach(() => {
testDir = join(tmpdir(), `genie-serve-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
genieHome = join(testDir, '.genie');
mkdirSync(genieHome, { recursive: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/term-commands/serve.test.ts` around lines 112 - 134, The spawned serve
process is inheriting the test runner cwd so it exits with "No workspace found";
update the spawn call (spawn(BUN_PATH, [GENIE_ENTRY, 'serve', ...], { env:
mergedEnv, stdio: [...] })) to include cwd: testDir (or ensure a workspace is
created and chdir to testDir before spawning) so the child runs in the temporary
workspace where GENIE_HOME points; keep mergedEnv and stdio intact.

Comment thread src/term-commands/serve.ts
Comment on lines +760 to 771
// `exit` handler can only run synchronous code — ensure the PID file is gone
// even if we reach process exit without going through gracefulExit (e.g. the
// scheduler's `done` promise resolved normally).
process.on('exit', () => {
removeServePid();
});
process.on('SIGINT', () => {
forceKillShutdown();
process.exit(130);

// Last-resort handler: surface the error, attempt cleanup, then exit 1.
process.on('uncaughtException', (err) => {
console.error('Uncaught exception in genie serve:', err);
gracefulExit(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.

⚠️ Potential issue | 🟡 Minor

uncaughtException → gracefulExit can silently become a no-op.

If the uncaught exception originates inside shutdown() (e.g. from await schedulerHandle.done rejecting in a way not caught by the inner try, or from omniBridge.stop() throwing synchronously before the .catch), shutdownStarted is already true, so gracefulExit(1) early-returns at Line 723 and no exit is scheduled. The process keeps running after a fatal error, and only the forceTimer from the original signal (if any) will eventually kill it — otherwise it hangs.

The exit handler will still removeServePid, but only once the process actually exits. Consider forcing an process.exit(1) directly in the uncaughtException path if shutdown was already in flight.

🤖 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 760 - 771, The uncaughtException
handler can call gracefulExit(1) which may return immediately if shutdownStarted
is already true, leaving the process running; update the uncaughtException
handler to call gracefulExit(1) and if shutdownStarted is true (or if
gracefulExit returns without scheduling an exit) forcefully call process.exit(1)
so the process terminates; reference the gracefulExit function, the
shutdownStarted flag, and the uncaughtException handler and ensure
removeServePid semantics remain unchanged.

@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 introduces several stability fixes for genie serve, including making the Omni bridge optional, implementing PID identity verification via process start times, and improving worker reconciliation by checking for tmux socket existence. It also adds comprehensive lifecycle tests and descriptive error messages for various failure modes. Review feedback focuses on optimizing database interactions by batching updates during reconciliation and ensuring consistent state management by clearing pane_id in all recovery paths.

Comment thread src/lib/agent-registry.ts
Comment on lines +347 to +375
for (const row of bucket.spawning) {
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = 'spawning'
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from spawning → error`,
);
resetIds.push(row.id);
}
}
for (const row of bucket.active) {
const prevState = row.state;
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now(), pane_id = ''
WHERE id = ${row.id} AND state = ${prevState}
RETURNING id
`;
if (updated.length > 0) {
console.error(
`[reconcile] Reset agent ${row.id} (dead socket ${socketName}, pane ${row.pane_id}) from ${prevState} → error`,
);
resetIds.push(row.id);
}
}

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 sequential UPDATE queries inside these loops can be optimized by performing a single batch update per bucket using WHERE id IN ${sql(ids)}. This significantly reduces database round-trips, which is especially important when reconciling a large number of stale agents. Additionally, resolveWorkerSocketName() should be called once outside the loops to avoid redundant environment variable lookups and object creation on every iteration.

Comment thread src/lib/agent-registry.ts
Comment on lines +410 to +415
const updated = await sql<{ id: string }[]>`
UPDATE agents
SET state = 'error', last_state_change = now()
WHERE id = ${row.id} AND state = ${prevState}
RETURNING id
`;

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 pane_id should be cleared here as well to maintain consistency across all recovery paths. When a pane is confirmed dead, its ID is no longer valid and should be removed from the database record.

          const updated = await sql<{ id: string }[]>`
            UPDATE agents
            SET state = 'error', last_state_change = now(), pane_id = ''
            WHERE id = ${row.id} AND state = ${prevState}
            RETURNING id
          `;

`genie serve` exited with code 1 whenever the Omni bridge could not
reach NATS (default on any dev machine), which made every CLI command
hang for 16s and blocked testing. Make the bridge optional: on start
failure, log `Omni bridge: degraded — <reason>` and continue. Set
`GENIE_OMNI_REQUIRED=1` to preserve strict (exit-1) behavior.

Bundles related genie-serve-stability fixes: `{pid}:{startTime}`
identity for PID staleness checks (new process-identity module),
dead-socket reconciliation for stale workers, zombie dead-pane pass
for idle/working rows, and regression tests across serve / db /
agent-registry.

Wish: .genie/wishes/genie-serve-stability/WISH.md
On macOS `os.tmpdir()` returns `/var/folders/...` but `/var` is a
symlink to `/private/var`. `planMigration()` resolves symlinks on
its input paths, so tests that compared against the raw tmpdir
string failed with `/private/var/...` vs `/var/...`. Realpath the
test directory in `beforeEach` so expected and actual paths align
on macOS and Linux alike.
@namastex888
namastex888 force-pushed the fix/genie-serve-stability branch from 5e28b7f to d3ea283 Compare April 20, 2026 20:41
@namastex888
namastex888 changed the base branch from main to dev April 20, 2026 20:42
@namastex888
namastex888 merged commit 5008875 into dev Apr 20, 2026
5 of 6 checks passed
@automagik-genie
automagik-genie deleted the fix/genie-serve-stability branch September 25, 2026 04:50
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