Skip to main content

Outbound Event Stream ([events])

Gitmoot can push a small, versioned, redacted JSON event to one HTTP endpoint whenever a job reaches a terminal state, pauses awaiting a human, or an organization role remains in a configured alert condition. This is an off-by-default, best-effort outbound seam (#446): with no [events] config and no organization event rules, nothing is constructed and behavior is byte-identical to a build without it.

It complements — it does not replace — the existing observability surfaces (gitmoot job watch, the local dashboard, PR comments). The typed lifecycle events recorded in SQLite (job_events) are unchanged; this is an additional fan-out, not a migration.

What it emits

The pilot emits a tight allowlist of event types over the webhook transport:

event_typeWhen
job.finisheda job reaches the succeeded terminal state
job.faileda job reaches the failed terminal state
job.blockeda job reaches the blocked terminal state
job.needs_attentiona tree pauses awaiting a human (the escalate_human pause today)
job.deferredthe daemon re-queued a run whose delivery failed on a retryable operational blocker (runtime auth, rate limit/quota, network/GitHub outage, checkout contention) — it will be re-dispatched automatically. Since #532 slice E this is a first-class transition emitted INSTEAD of job.failed (no preceding job.failed for that run)
org.input_pendinga Herdr pane bound to an organization role continuously reports an interactive input dialog past the configured threshold
org.replythe daemon drains a durable, coalesced batch of notes addressed to one organization role
candidate.awaiting_promotiona SkillOpt template candidate becomes pending after import (always, off the auto-promote policy)
candidate.auto_promotedthe off-by-default [skillopt].auto_promote policy auto-promoted a candidate to current (also the canary GRADUATE event)
candidate.canary_startedthe off-by-default [skillopt].auto_promote_canary policy promoted a candidate to the canary state behind the live champion
candidate.rolled_backthe canary regression window auto-rolled-back a canary on a material regression (champion stays current, canary rejected)

Each terminal transition emits exactly once. org.input_pending is an episode alert instead: it repeats at most once per configured interval while the same interactive dialog condition remains observed. The engine owns the succeeded/failed/blocked emit on its Mailbox terminal path; the daemon owns the pre-flight (queued -> failed|blocked) and permission-blocked cases that never pass through that path. job.needs_attention is emitted once per fresh escalation round (a re-advance does not re-emit).

job.deferred is a first-class transition — a deferred run does NOT emit job.failed first. When a run's delivery fails on a classified operational blocker (runtime auth rejected, provider rate limit/quota, network/GitHub outage, or a self-healing checkout contention), the classification happens pre-terminally in the mailbox seam (#532 slice E): the run is re-queued directly and the daemon emits only job.deferred for that job_id (detail carries the blocker class, the attempt N/M retry budget, and the earliest retry at timestamp), then silently re-dispatches after the hold. Product failures (the agent answered with a gitmoot_result, including decision=failed) still emit job.failed and are never auto-retried.

Migration note: before slice E the daemon emitted job.failed then job.deferred for the same run (the accepted first-slice flap). That flap is gone. The safe consumer rule is unchanged and forward-compatible: treat job.failed as final only when it is not immediately followed by a job.deferred for the same job_id.

The two candidate.* events come from the skillopt import / train continue CLI path, NOT the daemon. When a candidate becomes pending, candidate.awaiting_promotion is emitted once carrying the version id (job_id), the template id (root_id), and a redacted score/samples/CI reason — independent of the auto-promote policy, so a human is notified even in the manual default. If [skillopt].auto_promote is on and every configured guardrail holds, the candidate is promoted via the existing promote path and candidate.auto_promoted fires so the change can be reviewed or rolled back. The adjacent dashboard Attention page also lists every pending candidate (read-only), so candidates are visible locally even with [events] off.

When [skillopt].auto_promote_canary is on and auto_promote_canary_sample is a fraction in (0,1], a guardrails-pass candidate is promoted to a canary instead of straight to current: it routes that sampled fraction of new job resolutions while the prior champion stays the live current version, and candidate.canary_started fires (carrying the canary version id, template id, and the sample fraction). The daemon then watches a bounded regression window over the canary's harvested verifiable outcomes (reusing the Mode A trace signal, no new evaluator) and compares it to the prior champion: on parity-or-better it graduates the canary to current and emits candidate.auto_promoted; on a material regression it auto-rolls-back — the champion stays current, the canary is rejected, and candidate.rolled_back fires. It is fail-safe: too few canary outcomes, no champion baseline, or feedback it could not read all hold (keep sampling), never rolling back on unread evidence and never graduating without confirming non-regression. With the knob off (the default) promotion is the unchanged direct path and no canary event is ever emitted.

The following event_type values are reserved for the graduate step. They are enumerated in the contract so a consumer can switch over them forward-compatibly without a schema bump when they start arriving, but the pilot does not emit them: job.started, delegation.escalation, delegation.finalized, orchestration.finished.

The event contract (schema_version = 1)

Every event is a single JSON object:

{
"schema_version": 1,
"event_type": "job.needs_attention",
"job_id": "implement-task-7",
"root_id": "coordinator-job-id",
"repo": "owner/repo",
"status": "awaiting_human",
"ts": "2026-06-16T12:00:00Z",
"detail": "Choose the rollout window",
"cause": "escalation"
}
FieldTypeNotes
schema_versionintContract version. Currently 1. A breaking field change bumps it.
event_typestringThe enum above.
job_idstringOpaque job id this event is about.
root_idstringThe coordination tree's root id, so a consumer can aggregate a run client-side.
repostringowner/repo only — never an absolute checkout path.
statusstringTerminal/lifecycle state (succeeded/failed/blocked/awaiting_human/input_pending/attempted).
tsstringRFC3339 emit time.
detailstringShort redacted human-facing string (failure summary, the escalation question).
causestringOptional internal discriminator (escalation, ask_gate, merge_guard, permission_guard, blocked_since, input_pending_since, or addressed_note).

cause is an additive optional field, so schema_version remains 1 and existing events serialize unchanged. It is a trusted enum assigned at emit sites, not free text, and therefore is not passed through path scrubbing or redaction.

There is no synthetic orchestration.finished in the pilot — every event carries root_id, so a consumer groups a run client-side. Server-side tree-convergence aggregation is a documented graduate item.

Redaction

Every outbound free-text string field is redacted at event construction through the same redactor Gitmoot uses for off-box PR/issue comments and bug reports (workflow.RedactCommentText): GitHub tokens, OpenAI keys, AWS secrets, and api_key/token/secret/password assignments are replaced with [REDACTED]. The detail field is then additionally scrubbed of absolute filesystem paths — host home layout, checkout and worktree paths embedded in pre-flight failure detail (e.g. git worktree add /root/.gitmoot/...) collapse to <path> — so host layout and usernames never leak. repo is reduced to owner/repo only. No absolute paths, raw runtime output, or secrets leave the box.

Best-effort delivery

Delivery is fire-and-forget. A slow, hung, erroring, or full consumer never blocks or fails a job — this mirrors the escalate_human notifier contract:

  • Each event is handed to a small buffered channel drained by one background goroutine, so concurrent emits from many workers serialize cleanly.
  • Each POST is bounded by [events].timeout (default 2s). A hung consumer times out and the event is dropped.
  • On a full buffer, a transport error, or a non-2xx response the event is dropped and a single best-effort event_sink_drop job event is recorded locally (so a drop is observable without coupling delivery to the job).

Webhook delivery has no outbox or retry. Reply, blocked, and escalation role wakes are the exception: their producer-agnostic SQL outbox is described below.

Organization event-rule wakes

The daemon can also use the same event seam to wake a Herdr pane bound to an organization role. This is independently off by default: zero event_rules rows means no evaluator and no Herdr call, whether or not the webhook is on.

[org.roles."maintainer"]
parent = "owner"
scope = ["owner/*"]
pane = "w1:p2"
gitmoot org events rule add --on guard --match owner/repo --wake maintainer
gitmoot org events rule add --on blocked --repo tendwire --wake maintainer
gitmoot org events rule add --on pane_input_pending --wake maintainer
gitmoot org events rule add --on reply --wake maintainer
gitmoot org events rule add --on reply --wake operator --scope observer
gitmoot org events rule list
gitmoot org events rule set-scope <rule-id> observer
gitmoot org events rule rm <rule-id>

Rules default to --scope addressed: when an event carries a target role, only the matching addressed rule receives it. During rule evaluation, --scope observer exempts a rule from that addressee gate. Events without a target role keep matching both scopes exactly as before. Durable-outbox claim authorization is scope-blind: among enabled, filter-matching rules for the event's own kind, wake-role equality with the addressed target is the only routing condition. An observer-scoped rule is therefore delivered when its wake role equals the target; when it differs and no other target-role rule authorizes the batch, the batch remains pending. set-scope changes an existing rule between addressed and observer. Upgrades preserve the existing global view by promoting non-reply rules with an empty match filter to observer; reply rules remain addressed because reply already carries a target role. gitmoot doctor warns when an event kind with a production target-role writer has no enabled observer rule, including when the rule set is empty. Filtered non-reply rules remain addressed after upgrade and must be promoted manually with set-scope when observer delivery is intended.

Kinds are escalation, attention, guard, job-terminal, blocked, recycle-overdue, pane_input_pending, and reply. The v1 --match filter is a case-insensitive substring tested against the event repo and job id; empty matches all. --repo is an alias for that same filter; pass only one of the two flags. A plain job.blocked event matches both job-terminal and blocked, while guard-caused blocks match guard first. A synthesized blocked_since event matches only blocked. Task episodes due in one evaluator pass are emitted as one oldest-first digest; blocked roles retain one event per role. Set [orchestrate].blocked_role_wake_after to a positive Go duration to emit such an event when a task or Herdr role stays blocked past the threshold, re-nudging at most once per that interval while it remains blocked (so a dropped wake self-heals on the next interval instead of being lost). The same threshold and repeat interval apply to org.input_pending: Herdr's input_pending: true overrides the pane's last idle or working status, opens a separately keyed episode, and matches only pane_input_pending. 0s (the default) disables these evaluators. An episode is cleared on a definitive non-matching observation, or once the subject stops matching for a short grace (so a role gone for good is not leaked); a brief unknown/absent snapshot blip within that grace never resets it, and a later re-block starts a fresh episode. Each synthesized event's detail carries the stable since-time, so a re-nudge (same job_id + same since) is distinguishable from a fresh episode. reply, blocked, and escalation consume durable wake-outbox obligations. Reply obligations come from addressed workflow notes and kind=chat messages and only wake the role named by both the message and the rule. Non-triggering chat back-links such as job_result do not enter the outbox. Blocked and escalation obligations retain the redacted source event. Reply rows commit in the source note/chat transaction. Resolving a typed org escalation addresses the resolution marker to its parsed asker, so the same transaction records a pending reply obligation that wakes the asker. A legacy typed escalation with no identifiable asker still resolves, prints a warning, and records no invented target. Blocked and escalation rows are synchronously persisted after the source transition; insert failure is logged but cannot roll back the emitting job. gitmoot org escalate addresses the same durable note and reply obligation upward to an ancestor or downward to a descendant. Upward questions remain escalations; downward questions are asks. Same-role questions are invalid, and peers are refused by a safe command-level default because no configurable peer-question policy exists. The daemon uses rolling five-second windows per event kind and role, so same-kind events coalesce while a blocked event and a reply for the same role remain separate. Pending, attempted, delivered, stalled, failed, and delivery_unknown remain queryable per outbox row, and outstanding obligations contribute to daemon tick health. A quiet burst tail is flushed by a later daemon tick; it does not require another event. If the outbox or its delivery rules cannot be queried, or an outbox row cannot be parsed or claimed, the drain is logged as unhealthy and retried on a later tick without aborting unrelated repository work. The wake role's config sets pane = "<pane-id-or-label>": a wX:pY value must name a currently live pane, while any other value is a unique pane label resolved to the current id at wake time (so a recycled pane is still reached). The same explicit binding drives live org presence; an unresolved binding reports unknown presence, skips the wake with an observable log, and increments the role's missed-wake counter. gitmoot org validate reports unresolved roles, roles without enabled wake routes, and unclaimed labeled panes. Wake delivery runs herdr agent prompt <pane> <prompt> --wait --timeout 8000 and treats result.type = "agent_prompted" — and a post-delivery error.code = "timeout" — as delivered, error.code = "agent_prompt_stalled" as not delivered. Missing bindings, unavailable Herdr, stalls, and transport errors are swallowed after lightweight logging; they never block or fail a job.

Each agent_prompt_stalled outcome increments a durable, consecutive counter for the wake role; a delivered prompt resets that role's counter. Transport errors and other non-delivery outcomes do not change it because a Herdr outage is infrastructure failure, not evidence that every role ignored a wake. Set [orchestrate].max_consecutive_missed_wakes to a positive integer to append a ⚠ flagged (N missed wakes) marker to that role in gitmoot org chart and gitmoot org status; their JSON rows expose missed_wakes, flagged, and flag_reason. The default 0 disables flagging while leaving the best-effort counter available.

Configuration

Add an [events] section to the Gitmoot config file:

[events]
# The single endpoint each event is POSTed to as application/json.
# Empty (the default) means the webhook transport is OFF. Organization event
# rules, when explicitly added, can still consume the same local event seam.
webhook_url = "https://example.com/gitmoot-events"

# Per-POST timeout (Go duration). Default 2s. Bounds a hung consumer.
timeout = "2s"

# RESERVED for the graduate Unix-socket transport. Parsed and validated but
# UNUSED by the pilot (webhook-only).
# socket_path = "/run/gitmoot/events.sock"
KeyDefaultMeaning
webhook_url""HTTP(S) endpoint. Empty = OFF.
timeout2sPer-POST timeout (Go duration). Must be positive.
socket_path""Reserved for the graduate Unix-socket transport (unused today).

webhook_url must be an http:// or https:// URL. An invalid timeout duration or a non-http(s) URL is rejected at config load.

Graduate (go/no-go)

The pilot validates the contract and the best-effort guarantee with the least scaffolding. The following are out of scope and gated behind an explicit go/no-go on the tracking issue:

  • The Unix-socket transport (socket_path), behind the same Sink seam.
  • More event types (job.started, delegation.*, orchestration.finished).
  • A webhook outbox for at-least-once HTTP delivery (addressed-message role wakes already use a local durable outbox).
  • A server-side synthetic orchestration.finished (reliable tree convergence).