Astrolabe / ajxpublic /korvus
Repository

korvus CLI and Process Wiring

reference scope cmd/korvus Verification passed
  • cmd/korvus/main.go
  • cmd/korvus/alerts.go
  • newAlertIngestor
  • registerDocsSearch
  • buildAgent
  • startWorkspaceSync
  • sessionDocs
  • buildDocsIndexer
  • openPool
Relevant source files · 13

cmd/korvus is the single binary that runs Korvus. It is not a thin wrapper around a library — it is the place where every subsystem is constructed, ordered, connected to its neighbours and finally torn down in the right sequence. The package doc in main.go states the rule the whole file obeys: the startup order is load-bearing, not stylistic. Every other package in the repository is written to be constructible on its own; this one is the only place that knows all of them at once, and it is therefore the place where the ordering constraints live.

Two files make up the package. main.go holds the process lifecycle and the construction of the database, telemetry, MCP, workspace, docs, agent, blob, session and transport subsystems. alerts.go holds the small adapter that lets the alert path open a session through the same defaults a manual CreateSession would apply.

Entry point and the shape of run

main parses two flags — -config (default korvus.yaml) and -log-level (default info) — and hands them to run, which owns the entire process lifecycle main. run returns an error rather than calling os.Exit so that every deferred cleanup registered inside it actually runs; an os.Exit in main would skip them all, including the final span flush run.

run is a single long function, and that is deliberate: it reads as the process's own table of contents. The startup sequence it performs is:

  1. Config. config.Load produces the config.Config everything else is derived from; a bad file fails before any socket is opened or migration runs main.go. The details of that file and its validation live in Configuration Loading and Validation.
  2. Logging. setupLogging installs a JSON slog handler on stdout, because the deployment target is a container where a collector parses the stream setupLogging.
  3. Signal trapping. signal.NotifyContext installs the interrupt/SIGTERM context before any subsystem starts, so a Ctrl-C during a slow migration or a hanging MCP dial unwinds through the ordered shutdown rather than killing the process mid-step main.go.
  4. Telemetry. telemetry.Setup installs the global tracer provider and returns a flush function main.go. This happens before anything instrumented runs, because internal/agent and the transport's otelconnect interceptor take their tracer from the global provider at construction — installing the SDK afterwards leaves them holding no-ops telemetry.Setup. See OpenTelemetry Setup and GenAI Semantic Attributes.
  5. Database. openPool, then store.Migrate, then the store itself.
  6. Embeddings and docs indexer.
  7. MCP discovery.
  8. Workspace sync, then the docs_search tool registration.
  9. Skill catalog, then the agent, the blob store, the session manager and the alert ingestor.
  10. Transport, and finally server.Run(ctx) blocking until the context is cancelled.

Database: pool, ping, migrate

openPool builds the pgxpool and proves the database is reachable before startup continues openPool. pgxpool.New is lazy, so without an explicit ping a wrong DSN would first surface inside Migrate as a lock-acquisition failure — which reads as a broken migration rather than a bad connection string openPool. The ping is bounded by databasePingTimeout, ten seconds: long enough for a container whose database sidecar is still starting, short enough that a wrong host fails with a named cause instead of hanging databasePingTimeout.

Two ordering requirements meet inside openPool, and the comment there is worth reading as a whole. pgx reads ConnConfig.Tracer at pool construction and offers no way to attach a tracer afterwards, so the tracer must be assigned before NewWithConfig; and the tracer resolves the global TracerProvider when built, so telemetry.Setup must already have run — not because an early tracer would be dead, but because Setup installs an explicit noop provider when telemetry is disabled and delegation is once-only openPool. The tracer itself lives in PostgreSQL Persistence, Migrations and Vector Search.

Migrations run immediately after the pool is open, and the store is built from the migrated pool main.go. Only then is the embedder constructed, because the dimension check queries the live column and must see the schema this binary defines buildEmbedder. A width mismatch therefore fails at boot rather than as an opaque pgvector insert error at the first workspace sync.

Docs: indexer, tool registration, session pinning

Documentation retrieval is the one capability in Korvus that is assembled from three separate decisions rather than one, and the three of them are deliberately kept in step.

buildDocsIndexer returns the indexer, or nil when embeddings are not configured: retrieval is optional, and a deployment without it simply has no docs_search tool buildDocsIndexer. The indexer itself is docs.NewIndexer over the store and the embedder NewIndexer; the corpus it maintains is described in Documentation Chunking, Indexing and Search.

registerDocsSearch offers docs_search to the agent only when there is something to search — an indexer and a workspace to index registerDocsSearch. Embeddings without a workspace would advertise a tool that can only ever answer "not indexed", so the tool is withheld and the reason logged instead. The registration itself goes through the same MCP registry as the remote servers, under the name docs ServerName, with an in-process client wrapping the indexer NewClient. Because tool names are the model-facing namespace, a colliding name is a hard error RegisterServer. The registration runs before skill.Load, so a skill gated on the docs server sees it as connected registerDocsSearch.

sessionDocs gives the session manager the indexer it uses to pin each run's docs_search to the revision that run works against, under the same condition registerDocsSearch offers the tool at all sessionDocs. The manager's dependency is the session.DocsSearch interface, whose single method hands back a per-session client plus a release function DocsSearch.

Workspace sync

startWorkspaceSync performs the first workspace sync and leaves a background goroutine refreshing it on the configured interval startWorkspaceSync. Three decisions here are worth calling out.

The return type is the transport's interface, not *workspace.Syncer. Returning the concrete pointer and assigning a nil one to Options.Workspace would produce a non-nil interface holding a nil pointer, so the handler's if s.workspace == nil guard would pass and the first status call would panic on the nil receiver startWorkspaceSync. Returning the interface makes the unconfigured case a true nil. The same reasoning recurs in buildBlobStore for session.Config.Blobs buildBlobStore and in sessionDocs.

An unconfigured workspace.url is not an error. The function returns a nil interface and the rest of the process runs without a workspace startWorkspaceSync. The same care is taken one step later, where the workspace is asserted to the session.Seeder interface rather than returned separately, so an unconfigured workspace yields a nil Seeder and never a typed nil the manager would call main.go.

The first sync failing is a startup error. A deployment coming up with no checkout at all has no skills, no docs and no templates, so every session it then accepted would run degraded; failing loudly at boot beats serving an empty workspace startWorkspaceSync. Later failures are handled the opposite way, inside the syncer's own run loop, which swallows tick failures so a git outage cannot freeze the checkout until a restart startWorkspaceSync. The syncer and its checkout semantics are covered in Workspace Sync, Checkout and Materialization.

When an indexer is present, the docs base is re-indexed inside every successful sync, the initial one included, so the first session already has docs_search over the revision it runs against startWorkspaceSync. The hook itself is docsAfterSync, which returns a nil hook for a nil indexer rather than a closure over a nil pointer that would panic on the first sync docsAfterSync.

MCP discovery and the skill catalog

MCP discovery is best-effort: mcp.ConnectAll dials every configured server and registers the reachable ones, recording a failure for each server that could not be dialled or whose tools collided with an already-registered name ConnectAll. A failure degrades the skill catalog and is reported through SystemService, but never fails startup main.go.

The skill catalog is loaded after discovery, because skill.Load filters out any skill whose mcp_server frontmatter names a server that is not actually connected Load. The catalog is what an unpinned session's prompt is built from; a session pinned to a workspace commit gets that commit's skills/ instead main.go. Frontmatter parsing itself is described in Skill Catalog and Frontmatter Parsing, and the MCP layer in MCP Client, Registry and Server Connection.

Agent construction

buildAgent constructs the agent from config buildAgent. It looks up providers.default in the provider definitions, maps the definition to a model.Provider implementation through newProvider, and assembles an agent.Config carrying the primary provider, the tool executor, the turn log and the broadcaster main.go. A fallback provider is added only when providers.fallback is set, because a deployment without one is valid — the sticky failover simply has nowhere to switch to buildAgent.

The lookup of providers.default is checked even though cfg.Validate has already proven the name resolves. A miss at that point would be a validation bug rather than operator error, but it is still checked: the alternative is a nil provider reaching agent.New buildAgent. Provider types and their error kinds live in Model Providers, Embeddings and Error Kinds.

The broadcaster is constructed outside buildAgent and shared with the transport main.go. Constructing it inside would give the transport a different instance, and the live WatchSession stream would never see a turn NewTurnBroadcaster. The run loop it feeds is described in Agent Run Loop, Tool Execution and Provider Calls.

Sessions, blobs and scratch reaping

The blob store is chosen by blob.path; an unset path yields a nil interface, and an endpoint alone is reported as not yet implemented rather than silently doing nothing buildBlobStore. See Blob Storage Backend and Key Rules.

Stale session scratch directories are reaped before the session manager exists, so no session of this process can be among the directories reaped main.go and ReapScratch.

The session manager is then built with the store, the agent as its runner, the incident registry, the blob store, the scratch root, the workspace seeder, the docs search, the MCP server list, the unpinned skill catalog and the shared broadcaster main.go. The lifecycle it drives is covered in Session Manager and Lifecycle and Session Persistence, Reopen and Scratch Seeding.

Alerts

alerts.go holds one small struct and one constructor. alertSessionStarter starts the session of an alert-opened incident with the same defaults CreateSession fills for an empty request: the primary model, session.default_template, and the workspace revision currently being served alertSessionStarter. The workspace revision is read from the syncer's status, and only when a workspace is configured.

newAlertIngestor translates the incident configuration into an incident.IngestorConfig — dedup window, debounce delay, ignored labels, close-on-resolved — and hands over the alert store and the session starter newAlertIngestor. The ingestor itself refuses a config the alert path cannot run with, rather than deferring the failure to the first alert NewIngestor. Fingerprinting and debounce are described in Alert Ingestion, Fingerprinting and Debounce; the manual incident RPCs live in Incident Manager: Open, List, Update, Close.

Transport

The transport is assembled last and reports on everything above it. transport.NewMux builds one http.ServeMux carrying the Connect handlers, /healthz and the Alertmanager webhook NewMux, and transport.NewServer binds the port and enforces the read-header and shutdown timeouts NewServer. The mux options carry the registry, the configured server list, the skill catalog, the pool, the store, the session manager, the defaults, the incident manager, the broadcaster, the workspace syncer and the alert ingestor main.go. The details of that layer are in Connect API Server and Service Handlers and Watch Streams, Read APIs, Webhooks and Proto Conversion.

server.Run(ctx) serves until the context is cancelled, then drains in-flight requests Server::Run.

Shutdown order

Shutdown runs in reverse of startup, with one deliberate exception: the signal itself stops the alert ingestor and cancels running sessions before the HTTP drain main.go. A live WatchSession ends only with its session, so draining HTTP first would wait out ShutdownTimeout for every attached watcher.

Three defers make this work. sessions.Stop is registered after pool.Close's defer, so it runs before it: runs are detached from the RPC that started them, so stopping the server does not stop them, and each one's terminal write needs the pool still open main.go. alerts.Stop is registered after that, so it runs before it too, and it is additionally fired on the signal — a debounce timer must not open an incident whose session the stopping manager would refuse, and deliveries during the drain get a 503 that Alertmanager redelivers main.go. The blob store's close is registered before sessions.Stop's defer, so it runs after it: a session settling during shutdown is still harvesting into that store main.go. Finally, pending spans flush last, on a fresh context, because ctx is already cancelled by the time the flush runs and an export on a cancelled context drops exactly the spans the flush exists to save main.go.

Manager::Stop cancels every run this process owns and waits for each to settle Manager::Stop.

Relationships

graph TD
    main["main"] --> run["run"]
    run --> openPool["openPool"]
    run --> buildEmbedder["buildEmbedder"]
    run --> buildDocsIndexer["buildDocsIndexer"]
    run --> startWorkspaceSync["startWorkspaceSync"]
    run --> registerDocsSearch["registerDocsSearch"]
    run --> skillLoad["skill.Load"]
    run --> buildAgent["buildAgent"]
    run --> buildBlobStore["buildBlobStore"]
    run --> reapScratch["session.ReapScratch"]
    run --> newManager["session.NewManager"]
    run --> newAlertIngestor["newAlertIngestor"]
    run --> newMux["transport.NewMux"]
    newMux --> newServer["transport.NewServer"]
    startWorkspaceSync --> syncer["workspace.Syncer"]
    buildDocsIndexer --> indexer["docs.Indexer"]
    registerDocsSearch --> docsClient["docs.Client"]
    sessionDocs["sessionDocs"] --> indexer
    newManager --> docsSearch["session.DocsSearch"]
    buildAgent --> agentNew["agent.New"]
    buildAgent --> broadcaster["agent.TurnBroadcaster"]
    newManager --> broadcaster
    newMux --> broadcaster
    newAlertIngestor --> starter["alertSessionStarter"]
    starter --> newManager
    run --> connectAll["mcp.ConnectAll"]
    connectAll --> registry["mcp.Registry"]
    registerDocsSearch --> registry
    skillLoad --> registry
    buildAgent --> registry

Reference

Symbol Kind What it does Location
main.go file Process entry point; flag parsing and the run call main.go
alerts.go file Alert-side wiring: session starter and ingestor construction alerts.go
main function Parses -config and -log-level, exits on startup failure main
run function Owns the whole process lifecycle, startup order and shutdown order run
setupLogging function Installs the process-wide JSON slog handler on stdout setupLogging
openPool function Builds the pgx pool, attaches the query tracer and pings the database openPool
startWorkspaceSync function Performs the first git sync, starts the refresh goroutine, returns a transport.WorkspaceSyncer startWorkspaceSync
buildDocsIndexer function Returns the docs indexer, or nil when embeddings are absent buildDocsIndexer
docsAfterSync function Adapts the indexer to the workspace sync hook; nil indexer yields a nil hook docsAfterSync
serverRegistrar interface The single registry method registerDocsSearch needs, for testability serverRegistrar
registerDocsSearch function Registers the docs MCP server when embeddings and a workspace are both present registerDocsSearch
sessionDocs function Returns the session.DocsSearch the manager pins each run's docs tool to, or a true nil sessionDocs
buildAgent function Builds the agent from the provider definitions, tools, turn log and broadcaster buildAgent
buildBlobStore function Chooses the blob backend from blob.path, returning it with its close function buildBlobStore
sessionDefaults function Resolves the model and template CreateSession falls back to sessionDefaults
newProvider function Maps one provider definition to a model.Provider implementation newProvider
buildEmbedder function Builds the embeddings client and validates the vector width against the live column buildEmbedder
newAlertIngestor function Translates incident config into an incident.Ingestor newAlertIngestor
alertSessionStarter struct Opens a session for an alert-opened incident with the standard session defaults alertSessionStarter

Generated by astrolabe from commit 0c2dca73 on branch main.