Skip to content

feat: Dynamic Project Registry (Phase 1) - #2

Closed
marcmantei wants to merge 449 commits into
mainfrom
issue-1-dynamic-registry-github-app
Closed

feat: Dynamic Project Registry (Phase 1)#2
marcmantei wants to merge 449 commits into
mainfrom
issue-1-dynamic-registry-github-app

Conversation

@marcmantei

@marcmantei marcmantei commented Mar 12, 2026

Copy link
Copy Markdown
Owner

Summary

Implements #1Dynamic Project Registry + GitHub App Migration.

Phase 1: Dynamic Project Registry

  • Auto-discovery: Background loop runs gh repo list per configured GitHub owner, reconciles against a SQLite registry_repos table
  • Per-repo config overrides: worker_model and enabled flags preserved across syncs
  • Auto-clone: Optionally clones new repos to a configured base directory
  • Exclude patterns: Simple glob support to skip repos (e.g. marcmantei/liralot-config)
  • Notifications: Sends a summary message (new/archived repos) via MessagingManager when notification_target is set
  • REST API: 5 endpoints under /api/registry/ — list, get, update overrides, trigger sync, status
  • Hot-reload: Reads RegistryConfig from RuntimeConfig (ArcSwap) each sync iteration

Phase 2: GitHub App Migration (infrastructure, no code changes)

  • GitHub App "Lira Spacebot" created and installed on all marcmantei repos
  • Tailscale Funnel replaces Cloudflare quick tunnel — stable URL https://liralot.tail65530c.ts.net
  • Old per-repo webhooks removed (6 Cloudflare webhook registrations cleaned up)
  • spacebot-gh-tunnel.service disabled — no longer needed
  • New repos automatically covered by the GitHub App (no per-repo webhook setup)

Config example

[defaults.registry]
enabled = true
github_owners = ["marcmantei"]
clone_base_dir = "/home/sira"
sync_interval_secs = 3600
auto_clone = true
exclude_patterns = ["marcmantei/liralot-config"]
notification_target = "telegram:1285309093"

Files added/changed

  • migrations/20260312000001_registry.sql — new table + unique index
  • src/registry.rs, src/registry/store.rs, src/registry/sync.rs — store + sync + notifications
  • src/api/registry.rs — API handlers
  • src/config/{types,toml_schema,load,runtime}.rsRegistryConfig wired through config pipeline
  • src/api/{server,state,agents}.rs, src/lib.rs, src/main.rs — integration points

Test plan

  • All 633 lib tests pass
  • 9 registry-specific tests (store CRUD, exclude matching, JSON deser)
  • Live: registry sync discovered 22 active repos, cloned new ones, excluded liralot-config
  • Live: GitHub App webhook delivery verified (issue create/close on ChargePilot-Launch)
  • Live: Tailscale Funnel stable across service restarts

🤖 Generated with Claude Code
via Happy

jamiepine and others added 30 commits March 5, 2026 12:29
The supervisor was killing active workers after 300s because the timeout
was measured from spawn time, ignoring ongoing activity. Workers running
tools and reporting status were still getting killed.

Track last_activity_at on WorkerTracker, refreshed by WorkerStatus,
ToolStarted, and ToolCompleted events. Timeout now fires only after
10 minutes of inactivity (up from 5 minutes total lifetime).
Prevents a race where run_health_tick() could filter on last_activity_at
before pending WorkerStatus/ToolStarted/ToolCompleted events update it.
…r-idle-timeout

fix(cortex): use idle time instead of lifetime for worker timeout
docs: sync rig usage/design docs with implementation
The supervisor was still killing idle OpenCode workers after 10 minutes
of inactivity because last_activity_at stops updating when a worker
enters idle state (waiting for follow-up input).

Add is_idle flag to WorkerTracker:
- Set when WorkerIdle event is observed
- Cleared by track_worker_activity (any WorkerStatus/ToolStarted/
  ToolCompleted event means the worker resumed)
- Overdue filter skips workers with is_idle=true

This complements the last_activity_at fix from spacedriveapp#332 — that fix
prevents killing workers that are actively running tools, this fix
prevents killing workers that are legitimately waiting for follow-up.
…layed

Worker/branch results persisted in the LLM context in three overlapping
places with no expiry, causing the channel to reiterate stale results on
subsequent unrelated messages:

1. Status block 'Recently Completed' rendered full 500-char summaries
   into the system prompt on every turn indefinitely
2. History summary injection pushed raw result text as a permanent
   assistant message after retrigger
3. Channel prompt instruction said 'you must relay' with no awareness
   of whether results had already been relayed

Add a relayed flag to CompletedItem and mark items relayed after a
successful retrigger turn. The status block now filters out relayed
items so the LLM only sees unrelayed work in 'Recently Completed'.
Relayed items expire from the status block after 5 minutes.

The LLM's natural-language relay reply remains in conversation history
unchanged — the channel retains full context of what was discussed, it
just stops being told to relay results it already relayed.

Also fixes a pre-existing bug where completed_items pruning (cap at 10)
only ran in the BranchResult arm, never for WorkerComplete events.
…encode worker colors

Thread the interactive bool from spawn-time through DB persistence,
API responses, SSE events, and frontend rendering so the UI can show
whether a worker accepts follow-up input.

Differentiate opencode workers visually with zinc/neutral colors
(vs amber for builtin) across channel timeline, channel cards, and
webchat panel.
…erating-relayed-worker-results

fix(channel): stop re-summarising worker results that were already relayed
Both OpenCode and builtin interactive workers now emit a
WorkerInitialResult after each successful follow-up cycle, not just
the initial task. The channel already handles this event by queuing
a retrigger, so routed follow-up results now relay to users
automatically.

Result text is scrubbed through the secrets store before emission,
matching the initial-result path.
…-web-embed

feat: embed OpenCode web UI in worker detail view
Both OpenCode and builtin interactive workers now write a transcript
snapshot to the DB each time they go idle (initial task + every
follow-up). If spacebot restarts while a worker is waiting for
input, the transcript survives rather than being lost.

OpenCode workers get an optional sqlite_pool field; builtin workers
already have it via AgentDeps. The snapshot overwrites the blob each
time with the complete accumulated history.
On startup, query worker_runs for idle interactive workers and attempt
to resume them instead of marking them as failed:

- Split reconciliation: only running workers are marked failed; idle
  workers are left for reconnection
- New get_idle_interactive_workers() query loads idle workers with
  transcript blobs and session metadata
- transcript_to_history() converts persisted TranscriptStep[] back into
  Rig Message history for builtin worker resume
- Builtin Worker::resume_interactive() creates a worker with prior
  history, skips initial task execution, enters follow-up loop directly
- OpenCode OpenCodeWorker::resume_interactive() reconnects to existing
  server+session via get_or_create(), verifies session via get_messages(),
  repopulates accumulated_parts, enters follow-up loop
- resume_idle_worker_into_state() orchestrates resumption into a
  ChannelState (handles both builtin and opencode worker types)
- Startup code in main.rs queries idle workers, groups by channel_id,
  pre-creates channels with outbound routing, and resumes each worker
- Workers that fail to resume are marked failed via fail_idle_worker()
…limit

OpenCode workers can no longer be spawned as non-interactive (fire-and-forget).
The spawn_worker tool forces interactive=true for opencode, and
spawn_opencode_worker_from_state rejects interactive=false as defense-in-depth.

Adds directory claim/release tracking to OpenCodeServerPool so only one
opencode worker can be active per directory at a time. The directory is
claimed before the worker starts and released after it completes (success
or failure).
Six fixes from review bot findings:

1. Rebuild SpacebotHook with the correct existing worker ID in
   Worker::resume_interactive() — Self::build() creates a hook with a
   fresh random ProcessId, but the resumed worker needs events published
   under the original ID that matches the DB row and state tracking.

2. Load restored transcript into `history` (not `compacted_history`)
   so the LLM actually sees prior conversation context on the first
   post-restart follow-up call.

3. COALESCE(tool_calls, 0) in get_idle_interactive_workers() query to
   prevent sqlx::FromRow failure on rows where tool_calls is NULL.

4. Replace .ok() with proper error logging on fail_idle_worker() calls
   in main.rs startup — these are SQL errors, not dropped channel sends.

5. Persist recovered transcript before emitting idle in the OpenCode
   resume path, so a second crash doesn't lose the rebuilt state.

6. Make persist_transcript() and persist_transcript_snapshot() async
   and await them directly instead of fire-and-forget tokio::spawn.
   Ensures "idle implies persisted" and prevents out-of-order writes
   where an older snapshot could overwrite a newer one.
Previously, when an idle worker couldn't be reconnected on startup
(e.g. OpenCode server died with kill_on_drop, session expired), the
code called fail_idle_worker() which transitioned the row to 'failed'.

Now the worker stays as 'idle' in the DB with its transcript preserved.
The resume attempt is best-effort — if it fails, the worker is
effectively dead but its history remains available for inspection in
the UI. Only dangling 'running' workers (from unclean shutdown) get
marked as failed via reconcile_running_workers_for_agent().
…el after restart

Two bugs preventing idle worker visibility after restart:

1. opencode_session_id was never persisted to the DB. The
   OpenCodeSessionCreated event is filtered out by event_is_for_channel()
   (returns false for that variant), so the channel handler's
   log_opencode_metadata() call was unreachable dead code. Fix: persist
   session metadata directly from the OpenCode worker's run() method
   via sqlite_pool, bypassing the event system entirely.

2. When resume fails (e.g. session expired because server was killed on
   shutdown), the channel's status block had no knowledge of the idle
   worker. Fix: on resume failure, still register the worker into the
   status block as 'idle (session expired)' so it appears in the
   channel's UI and the LLM knows about it.
Add overflow-x-hidden to messages scroll container, min-w-0 overflow-hidden
to user message bubble, and break-all with whitespace-pre-wrap to message
text to ensure long unbreakable strings wrap correctly.
Fixes zombie cycle where OpenCode workers with expired sessions were
left as 'idle' in the DB on every restart, reappearing in the channel
status block as 'idle (session expired)' and showing as 'running' in
the workers tab with no way to dismiss them.

- Retire irrecoverable idle workers as 'done' (not 'failed') on startup
  since the worker completed its work — only the follow-up session expired
- Pre-filter OpenCode workers without session metadata before creating
  the channel (these can never resume due to kill_on_drop)
- Stop registering expired workers in the channel status block
- Add channel_id to OpenCodeSessionCreated event so it routes through
  event_is_for_channel() properly, removing the persist_opencode_metadata
  workaround (log_opencode_metadata in channel.rs is no longer dead code)
- Add TranscriptStep::UserText variant to preserve user vs assistant text
  distinction across transcript serialization round-trips
- Emit TypingState SSE events from pre-created channel outbound router
Resumed OpenCode workers were losing their original directory because
it was never persisted to the DB — resume fell back to workspace_dir,
spawning a new server in the wrong directory and ignoring the
one-per-directory limit.

- Add 'directory' column to worker_runs (new migration)
- Persist directory from spawn_opencode_worker_from_state via UPDATE
- Read directory from IdleWorkerRow in resume_idle_worker_into_state
- Fall back to workspace_dir only when directory is NULL (pre-existing
  workers without the column)
…worker-resilience

fix: persist transcripts on idle + reconnect idle workers on restart
Replace the iframe-based OpenCode embedding with a Shadow DOM approach
that mounts the SolidJS SPA directly into a React-owned DOM node. This
eliminates all the iframe shims (history patches, TextDecoderStream
polyfills, WebKit EventSource workarounds, CSP stripping) while
providing proper CSS isolation.

Key changes:
- Add OpenCodeEmbed React component with Shadow DOM mounting, CSS
  injection, and SSE directory probing for correct event routing
- Add directory field to ProcessEvent::WorkerStarted to fix the race
  condition where worker directory was set via a separate fire-and-forget
  UPDATE that could lose to the INSERT
- Remove log_worker_directory (superseded by directory in WorkerStarted)
- Strip iframe HTML rewriting from opencode_proxy.rs (now a clean
  transparent reverse proxy for API/SSE traffic only)
- Add build script and justfile recipe (just build-opencode-embed) to
  build the embed bundle from a pinned OpenCode commit
- Vendor embed source files in interface/opencode-embed-src/ so the
  build doesn't depend on a local OpenCode checkout
jamiepine and others added 27 commits March 12, 2026 01:38
… handle, signal validation

- Swap abort/drain order in cancel_worker_with_reason so the worker is
  stopped before draining live_worker_transcripts, preventing late events
  from being lost.

- Pass shared live_worker_transcripts Arc into Channel::new so the
  ChannelControlHandle (cloned during construction) references the same
  backing map as ApiState. Previously the control handle captured the
  standalone empty map, causing cortex-driven cancellation to miss
  transcript data.

- Change parse_implicit_signal_shorthand to return Result<Option<…>, String>
  so malformed shorthands (bad UUID, short phone, empty group) surface
  structured validation errors instead of falling through to a generic
  'no channel found' message.
- Set current_inbound in handle_message_batch() from the last non-system
  message so the RoutedSender carries correct routing metadata (e.g.
  Slack thread_ts) instead of stale/empty data.

- Replace `let _ =` with `.ok()` on best-effort send_routed calls for
  Thinking and StopTyping status updates per repo conventions.

- Thread slack_thread_ts through add_channel_tools into the cron default
  delivery target so cron jobs created inside a Slack thread post results
  back into that thread. Encodes thread_ts as a #thread:<ts> suffix in
  the broadcast target string, parsed by Slack's broadcast() method.
…ead-conversation-id

fix(slack): normalize conversation ID to prevent thread splits
…ool-call-serialization

fix: preserve reasoning in assistant tool-call conversion
…8-panic

fix: use byte slice in loop_guard to avoid UTF-8 panic on multibyte content
…tance-guard

fix: prevent deletion of instance-level skills via agent API
…-panic

fix: UTF-8 safe string truncation in browser tool and debug logging
fix(config): override host when deployed to docker
…lidation-docs

fix: harden Discord poll handling and refresh docs
# Conflicts:
#	src/tools/send_message_to_another_channel.rs
Streaming providers (e.g. Kimi) sometimes send content chunks that are
just whitespace. The trim().is_empty() guard in collect_openai_text_content
and extract_text_content_from_responses_output_item dropped these segments,
causing missing spaces in reconstructed output — especially at sentence
boundaries and around numbers in lists.

Change to is_empty() so only truly empty strings are filtered, preserving
legitimate whitespace tokens from streaming deltas.
…-whitespace-drop

fix: preserve whitespace-only content segments in streaming responses
Add a SQLite-backed registry that auto-discovers GitHub repositories
via `gh repo list`, syncs periodically in the background, and
optionally auto-clones new repos. Per-repo config overrides (model,
enabled) are preserved across syncs.

New modules:
- registry/store.rs — RegistryStore CRUD (upsert, list, overrides, link)
- registry/sync.rs — discovery via gh CLI, reconciliation, auto-clone
- api/registry.rs — 5 REST endpoints for repos, overrides, sync, status

Config: [defaults.registry] section with github_owners, clone_base_dir,
sync_interval_secs, auto_clone, exclude_patterns.

Wired into RuntimeConfig (hot-reloadable), AgentDeps, ApiState, and
main agent init loop with background sync task.

9 new tests, all 633 tests pass.

Refs: #1

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Send a summary message via MessagingManager when registry sync discovers
new repos or archives removed ones. Configurable via `notification_target`
in [defaults.registry] (e.g. "telegram:1285309093").

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
The sync loop was spawned before messaging_manager was set on AgentDeps,
so notifications were never sent. Now spawns after init, reading the
sync status from ApiState.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
1. Fix cortex profile 404: default routing model was
   "anthropic/claude-sonnet-4" (doesn't exist), now uses
   "anthropic/claude-sonnet-4-20250514".

2. Fix worker outcome reporting: when a webhook-triggered worker
   completes and Lira relays the result via send_message_to_another_channel
   (e.g. to Telegram), the retrigger system didn't recognize it as a
   successful relay because only the reply tool set the replied_flag.
   Now SendMessageTool also sets the flag on successful delivery.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
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.