ADR-046 — Event-driven runs: a unified trigger spine (board events first)
Status: accepted (phases 1–4) (2026-06-25) — shipped end-to-end, local + cloud-ready: the spine (pkg/trigger + pkg/eventbus); board events (board-mode promote); a direct-mode launcher over runview.Service; run-completion chaining (run.finished/failed/cancelled); scheduled (in-process Scheduler over schedule-kind subscriptions, the local twin of cloudsched); git-forge events on the bus (the shared webhook launch tail emits a SourceForge event, observational via the launched_run_id marker so it can't double-launch); and custom ingress (POST /api/v1/triggers/emit injects a SourceCustom event onto the spine — the first-class extensibility point for arbitrary external systems). The studio Automations view (/triggers, gated on triggers_enabled) lists and manages every subscription by repo/by bot with a create form per source. Staged follow-ons: the forge cutover (spine becomes the forge launcher, inline path retired behind a parity flag), forge-derived subscription provisioning, and dispatcher EngineRunner convergence.
Context
Iterion could already launch a bot run from several triggers, but each was a siloed implementation with its own config model, storage, and launch path:
- Host-crontab schedules —
pkg/cli/schedule.go→RunRun()directly. - Dispatcher tracker-polling —
pkg/dispatcher/polls native/github/forgejo every 30s →Runner.Dispatch(DispatchSpec). - Inbound forge webhooks —
pkg/webhooks/+pkg/server/webhooks_*.go→invocation_dispatch.go→runview.Launch. - Per-bot
invocations:DSL —bundle.Invocation{Kind: forge|command| schedule|board}— the closest existing unifying contract, but only the webhook/forge path consumed it.
The costs: three non-converged launch paths (RunRun, runview.Launch, Runner.Dispatch); an iterion board event could only fire a bot via the 30s poll; no run-completion → run chaining; pkg/cloudsched existed but never fired; and no first-class custom-integration trigger beyond the single generic webhook.
Decision
Two composing layers, plus one canonical event channel.
Capability stays where it is.
bundle.Invocationremains the bot-authored "what surfaces can fire me" — no repo/tenant/cron knowledge enters a manifest.InvocationKindBoardgains an optionalboard:block (on/to_states/all_labels) with fail-fast parse validation.A new binding layer —
pkg/trigger.trigger.Subscriptionbinds(event filter) → (bot launch into a target): one row per(tenant, repo, bot, invocation-kind). The puretrigger.Matcheris the union of every legacy family's allowlists (sources/kinds/actions/repos/ authors/labels/subject-states), so each old config maps on without losing fidelity.SubscriptionStoremirrorsforge.RepoIntegrationStore(in-memory for local single-host, Mongo for cloud), withListByRepo/ListByBotpowering the "by repo / by bot" surfaces andListCandidatesthe evaluator hot path.A new internal event bus —
pkg/eventbus.Bus{Publish, Subscribe}with two impls:InProcBus(local, lossy fan-out à larunview.EventBroker) andNATSBus(cloud, a newITERION_EVENTSstream /iterion.events.*subject — deliberately separate from theiterion.queue.runswork queue, because events are at-least-once fan-out notifications, not exactly-once locked work). The sametrigger.Evaluatorconsumes the bus identically in both modes; only theBusimpl is injected differently.
The packages avoid an import cycle by layering: eventbus imports trigger (for Event/Matcher); trigger imports neither eventbus nor runview. The evaluator's Handle(ctx, Event) error matches eventbus.Handler, so the wiring layer (pkg/server) connects them; the board source publishes through a tiny local trigger.Publisher interface that Bus satisfies structurally.
First source — iterion board events (the proof)
A native-board card created/moved/labeled fires a bot without the 30s wait:
- Ingress reuses the existing tail.
pkg/trigger/board_source.gosubscribes tonative.Store.Subscribe(the writer-agnostic events.jsonl tailer) — no new store append point — normalizesnative.Event→trigger.Event{Source: board, Kind: card.*}, and publishes to the bus. Lifecycle copied frompkg/server/watch_coordinator.go. - Effect is "promote the card," never launch directly.
NativeBoardEffect.Promotestamps the matched card'sBot/BotArgs(and, if the subscription names one, an eligible state). The dispatcher's existingtracker.Claimstays the sole launch authority — so the event fast-path and the poll safety-net are structurally unable to double-launch, and the promote is idempotent (a card already pinned to the bot is left untouched, so an event storm converges). After a real change it nudges the dispatcher (Manager.Refresh()) so the card dispatches now instead of at the next poll. - The poll is supplemented, not replaced. fsnotify drops under back-pressure and the tail starts at EOF with no replay, so the 30s poll remains the reconciliation net; the dispatcher becomes one (privileged) bus consumer.
Wired in pkg/server (StartTriggerCoordinator, reached by both iterion studio and iterion dispatch); discovery-driven via cli.buildLocalTriggerStore (a bot opts into event-driven promotion purely by adding a board: block — zero engine/CLI edit). REST CRUD at /api/v1/triggers, gated by server_info.triggers_enabled.
Why not …
- Reuse
runview.Launchfrompkg/triggerdirectly? It would cycle once run-completion emits events back into the bus fromrunview. TheLauncherinterface lives intrigger; itsrunview-wrapping impl lives in the wiring layer. - Reuse the runs queue for events? Different delivery semantics; reusing it would force runner pods to filter events and corrupt queue-position math.
- Put repo/tenant/cron on the manifest? That conflates a bot capability with a deployment binding; the orchestrator generates subscriptions from invocations instead.
- Replace the dispatcher poll? The CAS-claim is the repo's hardest-won correctness; the spine feeds it events and consumes its launches, it does not absorb its scheduler.
Consequences / staged follow-ons (same spine)
Each later source = "a source adapter that publishes a trigger.Event + an effect choice (promote-card vs direct launch)":
- Launch-path convergence — DONE for the direct path:
serviceLauncher(pkg/server/trigger_launcher.go) wrapsrunview.Service.Launch, resolving the bot viabotregistry.ResolveBotPath. DONE for the dispatcher's execution step behind a default-off flag — see the 2026-07-16 update below. STAGED: routingRunRun, and flipping the dispatcher flag on by default (the production cutover, which reconciles the pipeline-concurrency gate / broker fan-out the full Service layers on). - Run-completion chaining — DONE:
runview.Service.emitRunCompletionpublishesrun.finished/run.failed/run.cancelledonto the shared bus (wired viaSetEventPublisher(coord.Bus())); a direct-mode subscription matchingSource: runfires the downstream bot. TheActorcarries the upstream bot id so a chain can key on "after feature-dev finishes". 2026-07-21 update (web-notifications work): the event construction is now the sharedtrigger.BuildRunOutcome(kind derivation, tenant+owner enrichment, per-episode ID), cloud runner pods publish the same outcome events onto the NATSBus (Runner.fireOutcomeEvent— closing the "runner-pod runs are invisible to the spine" gap), and the NATSBus is wired for real initerion server/runner(cfg.EventsBus, injected into the trigger coordinator so every consumer rides ONE bus). First cloud consumer: theusernotifydispatcher (browser web push onrun.paused+ terminals, queue groupusernotify— docs/notifications.md). The evaluator itself still subscribes on the coordinator's bus (local); pointing a cloud evaluator at the NATSBus stays a staged follow-on. - Scheduled — DONE:
trigger.Schedulerticks schedule-kind subscriptions on theirCronand fires them via the launcher, scoped to the local tenant "" (no-op in cloud, where cloudsched's CAS ticker stays authoritative for real tenants).FromScheduleInvocationseeds a schedule subscription from a bot'ssuggested_cron. STAGED: foldingcloudsched.ScheduledBotintoSubscription{Invocation: schedule}so cloud + local share one table. - Git-forge — DONE (on the bus): the shared launch tail (
insertAndLaunchWebhook) emits aSourceForgeevent with thelaunched_run_idmarker, so forge is a unified, observable source and the evaluator never re-launches it. STAGED (the cutover): when a forge subscription matches, skip the inline launch and let the spine launch (stop setting the marker); parity-gate via thewebhooks.Deliveryaudit, then retire the inline path. The per-eventpayload["vars"]carrier the evaluator now merges is the vehicle for forge's dynamic launch vars. - Custom ingress — DONE:
POST /api/v1/triggers/emit(requireAuth) forcesSource: custom(cannot spoof a board/forge event) and publishes onto the bus; matching custom subscriptions fire asynchronously. STAGED: a cloud signed-token variant alongside the local auth gate. - Studio Automations view + cloud team-scoped REST + a file-backed local subscription store (the memory store is rebuilt from manifests each start).
Update 2026-07-16 — Launch-path convergence primitive (LaunchSpec fields + RunLauncher seam)
The dispatcher's EngineRunner.Dispatch can now route its fresh-dispatch execution step through runview.Service.Launch — the single launch authority — instead of building a private engine. Shipped behind a default-off flag; the direct-engine path is unchanged unless opted in.
The four per-launch LaunchSpec additions (pkg/runview/service.go), each a per-launch override of the service-level default:
WorkDir— the per-issue worktree, so${PROJECT_DIR}resolves there (runtime.WithWorkDir), not the daemon cwd.DailyCap— the dispatcher's singleton-SpendStore cost guard (runtime.WithDailyCap), so every concurrent run writes the one ledger.SourceRef— the originating kanban issue stamped onto the run record (runtime.WithSource).ExtraObservers— event observers fired on every run event, not just engine-level ones. Delivered through two disjoint observer seams:runtime.WithEventObserverfor engine emits, andExecutorSpec.EventObservers(attached to the backend hooks' redacting emitter) for the high-frequency tool events that flow straight to the store through the backend hook layer. The two event sets are disjoint — the two are the onlyAppendEventchokepoints during a run — so each event reaches the observer exactly once, keeping the dispatcher's stall watermark alive on long agent nodes. No store wrapper is interposed (see the dated update below).
A fifth field, OnOutcome func(error) — the return-path completion of the four data fields. A blocking caller (the dispatcher routing through Launch) needs the run's terminal Go error, not just its persisted status: scheduleRetry text-matches the error for sandbox-setup backoff (isSandboxSetupError) and doomed-resume detection (isResumeSourceChanged), so a status-reconstructed error would silently change the retry cadence. OnOutcome fires once in the run goroutine with bodyErr just before LaunchResult.Done closes (the channel-close is the happens-before edge), so the caller reads the exact error engine.Run returned. Fire-and-forget launches (CLI / studio / webhook) leave it nil.
The seam (pkg/dispatcher/engine_runner.go): a RunLauncher interface (LaunchAndWait(ctx, LaunchSpec) error) with a ServiceRunLauncher adapter over *runview.Service, wired via NewEngineRunner's new WithRunLauncher option and gated on ITERION_DISPATCH_VIA_SERVICE. dispatchViaService translates the DispatchSpec invariants into the LaunchSpec fields and blocks on the launcher.
Decisions / trade-offs
- Interface seam, not a hard dependency.
EngineRunnercalls theRunLauncherinterface; the concreterunview.Servicebinding is injected by the wiring layer. Keeps the routing testable in isolation and the default path allocation-free. - Resume and bundle dispatches stay on the direct engine path. Resume reuses the checkpoint + worktree the runner already owns, and a
.botzrunner shares one opened bundle handle across dispatches — neither maps cleanly onto a statelessService.Launchyet. The gate excludes both, so the convergence covers exactly the fresh plain-.botdispatch. - Default-off flag over a hard cutover. The full
Servicelayers a pipeline-concurrency gate, broker fan-out, supervisors, a session board and a completion notifier onto every launch. For the dispatcher-observable invariants (stall / caps / retry / source) the two paths are proven byte-identical by a diff-run test (engine_runner_via_service_test.go), but the extra machinery is why the production cutover (flag-on by default, Service wired into the daemon Manager) is deliberately staged rather than shipped here. map[string]any→map[string]stringvar stringification.LaunchSpec.Varsis stringly-typed; dispatcher bot-args are strings in practice, and a non-string value is rendered with%vso it still reaches the bot.
Key files
New: pkg/trigger/{event,subscription,store,memstore,mongostore,evaluator, launcher,board_source}.go, pkg/eventbus/{bus,inproc}.go, pkg/server/{trigger_coordinator,triggers_routes}.go, pkg/cli/trigger.go. Modified: pkg/bundle/manifest.go (InvocationBoard), pkg/queue/nats/nats.go (ITERION_EVENTS), pkg/dispatcher/manager.go (Refresh nudger), pkg/server/{server,server_info}.go, pkg/cli/{studio,dispatch}.go.
Launch-path convergence (2026-07-16): pkg/runview/service.go + pkg/runview/service_launch.go (LaunchSpec WorkDir/ExtraObservers/DailyCap/SourceRef/OnOutcome + launchExtras wiring), pkg/dispatcher/engine_runner.go (RunLauncher / ServiceRunLauncher / WithRunLauncher / dispatchViaService).
Update 2026-07-16 — ExtraObservers via observer seams, not a store wrapper
The first cut delivered ExtraObservers by wrapping the store.RunStore (an observerStore, mirroring the dispatcher's heartbeatStore) so every AppendEvent fanned out to the observers. That wrapper shadowed the concrete *FilesystemRunStore against the executor's and sandbox's optional- capability type-probes: PlanWriter (todo/plan snapshots), RunFilesStore (EnsureRunFilesDir/ListRunFiles/OpenRunFile — the run-files host/sandbox bind-mount parity), and QueuedInboxVersioner all resolved to nil on the dispatcher-via-service path (ITERION_DISPATCH_VIA_SERVICE=1) because Go interface embedding forwards only the base RunStore methods, not the optional interfaces satisfied concretely. The convergence route therefore silently degraded the very launch it claimed to reuse — a plain studio launch keeps those capabilities.
Decision. Drop the store wrapper. The launch hands the executor + engine the raw s.store, and ExtraObservers ride two disjoint seams:
- engine events →
runtime.WithEventObserver(wired inengineOptionsfromlaunchExtras.observers); - backend-hook events (the high-frequency tool stream) →
ExecutorSpec.EventObservers, threaded intoNewStoreEventHooksand fired by theredactingEmitter— already the singleAppendEventchokepoint for hook events, where capability detection happens on the original emitter before wrapping, so nothing is shadowed.
A grep proof underpins completeness: during a run there are exactly two AppendEvent chokepoints — runtime emitBranch (fires e.onEvent) and the redactingEmitter (fires the executor observers) — so the union covers every event with no double-fire. engine_runner_via_service_test.go still proves the dispatcher-observable event set is byte-identical to the direct path, and TestLaunchStore_KeepsOptionalCapabilities + TestLaunch_HonoursDispatcherConvergenceFields (asserting the artifact_files area is created under an observed launch) pin the capability-preservation invariant.
Trade-off. The two-seam delivery leans on the invariant that engine and hook emits are disjoint; a future third AppendEvent call site would need a matching seam. That is a cheaper, more honest cost than a wrapper that must hand-forward every present and future optional capability to avoid silent degradation — the failure mode a RunStore decorator structurally invites. Files: removed pkg/runview/observer_store.go; pkg/backend/model/hooks.go (redactingEmitter.observers + NewStoreEventHooks variadic), pkg/runview/executor.go (ExecutorSpec.EventObservers), pkg/runview/service_launch.go (raw store + launchExtras.observers).
