Triggers and the Event BusStanding rules that bind an event source to a continuation — with exactly-once firing and a loop guard.

A trigger is standing configuration that binds an event source (something happened) to a continuation (what to do about it). It is how the Harness acts without a person present, and the third leg of long-running autonomy: parking makes waiting free, SignalRun lets a human end a wait, and triggers let events end one automatically.

Today a trigger is a row in agent_triggers, managed through five protocol actions (ListAgentTriggers, GetAgentTrigger, CreateAgentTrigger, UpdateAgentTrigger, DeleteAgentTrigger).

Sources

type AgentTriggerSource = | {type: 'document-comment'; resource: string; author?: string} | {type: 'user-mention'; mentionedAccounts: string[]; resourcePrefix?: string} | {type: 'site-update'; resourcePrefix: string; eventTypes?: string[]} | {type: 'schedule'; schedule: AgentScheduleTrigger} | {type: 'run-completed'; agentId?: string; status?: 'succeeded' | 'failed' | 'canceled'; titleMatch?: string}

Schedules are interval (every N minutes/hours), weekly (days, time, timezone), or once (a timestamp). A once schedule is disabled at fire time, not after the run completes — the comment in the code explains why: "so a slow run can't let the same occurrence fire twice."

run-completed is the chaining source — "when the nightly research run finishes, draft the summary" — and it is not an activity event at all. It fires inline from #onRunFinalized, which already runs for every terminal run, so it needed no new monitor and no new poll loop.

Continuations

type TriggerContinuation = | {kind: 'newThread'} | {kind: 'wake'; signal: string; runId?: string; payload?: unknown}

An omitted continuation means newThread — which is why the column is nullable and no migration was needed to add the field.

wake is the one that pays for the parking machinery twice. It delivers a signal into a parked run's ctx.waitForEvent, riding the exact same transactional path as SignalRun: a trigger that wakes a run is mechanically a signal with a different sender. With a runId it targets one park; without one it means "unblock whoever on this account is parked on this signal." If nobody is listening, the firing is recorded status: 'no-listener' rather than discarded — the history shows the trigger fired and found no one.

appendTo (post into an existing thread) and runPlan (start a run from a saved plan) are designed but not built; they were dropped from the slice rather than half-built.

The monitors

Two poll loops, both built on PollLoop (agents/src/poll-loop.ts), which is deliberately boring: fire immediately, then on an interval, with an overlap guard (a tick that is still running blocks the next) and a timeout, and errors swallowed and logged rather than killing the loop.

    ActivityMonitor pages the activity feed per account, skipping schedule triggers entirely. Its watermark advances per event, after each firing is durably recorded — so a crash mid-page cannot skip past an event that never fired. On a cold start it seeds the watermark and only processes events at or after the earliest enabled trigger's creation time, so a new trigger does not stampede through history.

    ScheduleMonitor just asks processScheduledTriggers(now); dueOccurrence computes the next occurrence from lastFiredAt ?? createdAt.

Crucially, an incoming activity event is delivered to parked run waits first, and only then scanned against triggers — work already underway beats work about to start.

Exactly-once, and the loop guard

Firing is idempotent by database constraint, not by careful code: trigger_firings has UNIQUE (account_id, trigger_id, activity_key) and every firing path does INSERT OR IGNORE and checks whether the insert changed anything. Dispatch is deduplicated a second time by deriving the run id from the firing id.

One subtlety the key encoding solves: a comment that @-mentions someone arrives as two events (the comment and its citation twin). activityFiringKey collapses them onto one key so a single mention produces a single firing.

For run-completed, the danger is different — two triggers can chain into each other forever. A run started by a trigger carries its firing, and #triggerAlreadyInChain walks that chain back up to TRIGGER_CHAIN_MAX_HOPS = 8; if the trigger already appears, it does not fire again. Running out of hops is treated as a loop. This is the check that stops a runaway at 3 a.m., and it has a test that forges a chain to prove it.

Matching

Activity criteria are matched by one shared function, matchesActivityCriteria — the same code that answers "does this event match this trigger?" and "does this event answer this parked run's wait?", so the two can never drift apart. Every present field is a conjunct, resources are compared canonically (hm:// and gateway URLs normalize to the same thing), and an empty criteria object matches nothing rather than everything.

What is not built

The designed evolution is triggers as documents in ~/triggers/ — readable and editable as text, like every other part of the Space. The heart of it is a consent rule: an agent may write a trigger document freely, but it lands as a draft no matter what the agent put in the status field, and only a user action can activate it. The argument (from agents/docs/harness/m6-event-bus-design.md) is the sharpest security reasoning in the codebase — a trigger is standing authority to act unattended, and an agent that could grant itself that authority, including on the strength of a web page it just read, would have an unbounded prompt-injection surface.

Not built: trigger documents, the draft→active consent step, the migration off agent_triggers, the protocol deletion, the document-change source, appendTo/runPlan, per-trigger budgets, and firing history as runs. Also worth knowing: agent_triggers.cooldown_ms exists as a column but nothing reads or writes it — the older plan document claims per-trigger cooldowns shipped, and the code disagrees.

The desktop can render a run-completed trigger but cannot yet create one.

Related

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime