Skip to content

feat(agents): heartbeat & agent triggers - #537

Open
OchnikBartek wants to merge 30 commits into
mainfrom
feat/agent-triggers
Open

feat(agents): heartbeat & agent triggers#537
OchnikBartek wants to merge 30 commits into
mainfrom
feat/agent-triggers

Conversation

@OchnikBartek

@OchnikBartek OchnikBartek commented Aug 10, 2026

Copy link
Copy Markdown
Member

Summary

Heartbeat & agent triggers for #44 — an agent can now run itself with nobody at the
keyboard. A trigger is a row beside the agent (modelled on AgentExposure, deliberately
outside the portable spec), a once-a-minute heartbeat fires the due ones through the
ordinary run path, and a fired run runs as the trigger's creator with membership
re-resolved each fire.

The branch was cut plan-first (docs/design/heartbeat-triggers-plan.md) and now carries
the full implementation: both schedule triggers (interval and cron) and event
triggers (GitHub / email / LinkedIn / webhook), an in-place cadence editor, optional human
titles, and five UI surfaces.

What it introduces

Two kinds of trigger, one table (agent_triggers, AgentTrigger)

  • Schedule (ScheduleKind = interval | cron) — an interval floor of 60s, or a
    crontab expression validated with croniter. next_fire_at is computed on write and
    advanced under the heartbeat's lock.
  • Event (EventSource = github | email | linkedin | webhook) — a per-source
    event_config filter and a signing secret sealed through the one vault
    (event_secret_encrypted + secret_key_version), exactly as a channel bot stores its
    secret. An event row has a null next_fire_at; nothing is due until an event lands.
  • A trigger_type discriminator and a single shape CHECK (ck_trigger_shape) keep "what
    makes this due" to exactly one answer per row.

The cadence engine — a trigger's cadence can be changed in place (interval↔cron, a
new interval, a new expression) without delete-and-recreate. _resolve_cadence infers the
kind from the fields sent, clears the opposite field, and refuses an unschedulable edit as
a 422 naming the field rather than a 500 from the shape CHECK.

A human title (name, migration 0024) — a trigger is listed by an optional title,
falling back to the agent's name, so two schedules on one agent no longer read identically.

RunSurface.SCHEDULE — a fired run is stamped schedule, a first-class surface in run
history (?surface=schedule) beside web/api/slack/embed. Triggered runs stay in Runs as
well as the new Scheduled view.

The heartbeatcheck_agent_triggers_flow (IntervalSchedule(60)) claims due
triggers FOR UPDATE SKIP LOCKED, advances next_fire_at under the lock, and submits each
via run_deployment; run_scheduled_trigger_flow is one fired run. A fire runs as the
creator (membership re-resolved each time), opens one run-log conversation per trigger,
passes no message_history (fires are stateless), and disables-rather-than-retries on a
refusal.

Endpoints

  • GET /triggers — org-wide listing (the same shape as the org-wide /runs), feeding the
    chat sidebar and the Activity view.
  • GET/POST/PATCH/DELETE /agents/{agent_id}/triggers and
    POST /agents/{agent_id}/triggers/{trigger_id}/run.
  • Gated per-resource by agents:run through the registry; a refusal is reported as
    not-found, so agent ids stay unprobeable.

Five UI surfaces

  1. Availability tabTriggersPanel on the agent page, managed at the agents:run
    floor (running an agent, not publishing it).
  2. Chat sidebar — a triggers section; a run-less trigger opens its empty run-log
    conversation on click.
  3. The form dialog — a cadence builder (hour input, daily / every-N-days / weekly /
    monthly presets, custom weekday selection, raw cron), the title field, and event-source
    configuration.
  4. Activity → Scheduled tab — org-wide scheduled/triggered runs.
  5. Trigger row & summary — the human-readable cadence ("Every 2 hours",
    "Cron 0 9 * * * (UTC)", "On inbound email") wherever a trigger is listed.

Migrations

0022_agent_triggers0023_agent_event_triggers0024_agent_trigger_name, chained
onto main's head 0021_drop_channel_tools_bindings. alembic check reports no drift; the
chain applies and rolls back cleanly (tests/test_migrations.py, plus a manual
forwards/back round-trip on a throwaway database).

How it was verified

The branch was merged onto origin/main (0.0.103) in place, so its diff against main is
the feature alone.

  • make check green — lint (ruff / ty / eslint / prettier / tsc / i18n / codespell),
    make test (4540 passed, platform-layer coverage 100%), make test-frontend-cov
    (3936 passed), make build-frontend, make docs-build, make audit.
  • Migrations0022–0024 forward and back, alembic check clean.
  • Tests at every layer, including a real-database proof that two heartbeats never
    double-fire one trigger, and that a budget-exhausted fired run is recorded
    budget_exceeded and not retried.

Checks

  • Tests cover the new behaviour, and would fail without the change
  • make check passes (lint, format, types)
  • Platform-layer coverage is still 100%
  • No capability was added or changed (triggers are operational state beside the agent,
    not a capability)
  • alembic downgrade base && alembic upgrade head works for 0022–0024

Follow-up (tracked as #589)

Two fire()-hardening items are addressed in #589, a pull request stacked on this branch
(base: feat/agent-triggers), rather than in this PR. Both were flagged in review and are
resolved here as triage, with the real fix tracked in #589:

  • last_run_id self-overlap. The heartbeat's "skip a trigger whose previous run has
    not reached a terminal status" guard joins on last_run_id, which the child flow writes
    only after execute returns — so while a run is executing the guard still sees the
    previous (terminal) run, and a run slower than the interval can overlap itself. The fix
    is an in-flight marker the heartbeat sets in the same UPDATE that advances
    next_fire_at, cleared by the child in a finally.
  • Partial-dispatch batch. One failed run_deployment aborts the heartbeat's dispatch
    loop after the claims are already committed, so the rest of that batch has next_fire_at
    advanced with no run — a missed fire. The fix isolates each dispatch so one failure does
    not drop the others.

Review

All review threads are resolved; the two fire()-hardening findings above are resolved as
triage, with the fix tracked in #589. The design doc stays in the PR: docs/design/ is
exclude_docs-configured engineering material for review, matching four such docs already
on main.

Closes #44

A design doc under docs/design/, plan-first per #44 — no implementation.
It answers the issue's five questions and corrects three stale premises in
its "what already exists".

Decided:
- Triggers are a row (agent_triggers), not an AgentSpec field, so SPEC_VERSION
  stays at 8. Argued from the spec's own docstring, which excludes "where the
  agent runs and who may use it" — a trigger is both. Modelled on
  AgentExposure, the existing "operational, not part of what the agent is" row.
- A triggered run runs as its creator, membership re-resolved each fire —
  the pattern channel mentions and the embed widget already settled. No
  invented fallback user; AuthContext.subject_id refuses one loudly.
- One heartbeat flow on IntervalSchedule(60), copying check_scheduled_syncs_flow,
  scanning due rows — not a Prefect deployment per trigger.
- Runaway guards: an interval floor, a no-self-overlap claim (FOR UPDATE SKIP
  LOCKED) that also bounds this feature's slice of #15b, and the agent/org
  budget inherited whole via execute -> prepare -> _assemble; a BUDGET_EXCEEDED
  run returns without retrying.
- Output sink: a per-fire conversation so the run appears in Activity; approvals
  and budget alerts already fire from the run path.

Corrected in the issue's premises (verified against 3c19eae):
- RunSurface.SCHEDULE does not exist; the enum mentions it only as #207's
  cautionary tale. Re-adding it is now correct because this is what writes it,
  and needs no migration — surface is a String(16) column.
- drain() is unwired to the API lifespan (#11); the design sidesteps it by
  running in the worker rather than an in-process spawn.
- The heartbeat and run paths cited by line have since moved.

Scoped out, with reasons: email-in (its own issue), cron expressions (interval
first), a "run completed" notification (fast follow), and the general #15b race.

Five open questions carried to @DEENUU1, the creator-gone fallback the only
real fork. Not reviewed yet, so the issue stays open.

Refs #44
@OchnikBartek OchnikBartek added the enhancement New feature or request label Aug 10, 2026
@OchnikBartek OchnikBartek self-assigned this Aug 10, 2026
@OchnikBartek
OchnikBartek requested a review from DEENUU1 August 10, 2026 13:29

@DEENUU1 DEENUU1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good plan, and thanks for doing the reading before the code — this is the shape #44 asked for. I checked every code anchor against main and they all hold: RunSurface has no SCHEDULE and surface is a String(16) so no migration; drain() really is CLI-only; execute → prepare → _assemble is the funnel with the budget read inside _assemble; the spec docstring does exclude "where it runs / who may use it"; and exclude_docs: design/ is in mkdocs.yml. That accuracy is most of why the plan is trustworthy.

Answering the five, since they're pointed at me:

  1. Creator-gone — yes, auto-disable + audit, and notify an admin. It's the only option that doesn't either retry forever or silently move the bill onto someone who didn't ask for it.
  2. Spec vs row — yes, keep it a row. Strongest call in the plan; more inline.
  3. Single heartbeat — yes.
  4. Output sink — the per-fire conversation works, but see my note in §5: I'd lean to one conversation per trigger rather than one per fire.
  5. Email split — yes, cron-only here, email as its own issue.

Two things to sort before implementation, both where the plan leans on the sync precedent. The no-overlap guard needs a column the agent_triggers table doesn't have yet, and "copying check_scheduled_syncs_flow exactly" overstates it — that flow neither locks rows nor submits-and-returns, so both guards are net-new code rather than something to inherit. Details inline.

Comment thread docs/design/heartbeat-triggers-plan.md
Comment thread docs/design/heartbeat-triggers-plan.md
Comment thread docs/design/heartbeat-triggers-plan.md Outdated
Comment thread docs/design/heartbeat-triggers-plan.md Outdated
Comment thread docs/design/heartbeat-triggers-plan.md Outdated
@DEENUU1 reviewed the plan (approved the shape; every code anchor holds) and
answered the five open questions, with three corrections that change the schema
and the flow rather than only the prose. Revised to match:

- Q4 output sink: one conversation PER TRIGGER (a run-log appended to each fire),
  not per fire — per-fire is ~1440 conversations/day, unbounded. Adds a
  conversation_id to the agent_triggers row; Activity is unaffected because each
  fire still writes its own agent_run.
- Correction A (schema): the no-overlap guard needs to find the previous run's
  status, and nothing linked a run back to its trigger. Added last_run_id (FK
  agent_runs, SET NULL) to the table. Stated the FOR UPDATE SKIP LOCKED claim is
  net-new, not inherited — get_due_for_sync takes no lock.
- Correction B (flow): dropped "copy check_scheduled_syncs_flow exactly" — that
  flow asyncio.gathers and awaits its children, so a slow run holds the 60s tick
  and the next tick double-fires. Specified run_deployment(timeout=0) to
  submit-and-return.
- Correction C (permissions): the creator-gone pre-check must use per-resource
  resolve_access(AGENTS_RUN), not role-level role_has — a creator with a revoked
  grant on this agent would pass a role check, then be refused inside execute(),
  which raises AuthorizationError and Prefect retries it. An authz refusal is now
  caught and treated exactly like BUDGET_EXCEEDED: disable, never retry.

The five answers (auto-disable + audit + notify admin; row not spec; single
heartbeat; per-trigger conversation; email split out) are recorded in the final
section, and a test for the revoked-grant path is added to §9. Still plan-only;
implementation follows on this branch.

Refs #44
The data layer for agenticos#44. A trigger is operational state beside the agent,
like an exposure, so it is a row (agent_triggers) rather than an AgentSpec field:
the spec is exported across organizations and a trigger carries a subject
(created_by_user_id) and deployment-local state (next_fire_at, last_run_id, the
run-log conversation_id) that cannot travel with it. SPEC_VERSION does not move.

- AgentTrigger + ScheduleKind, modelled on AgentExposure. last_run_id (the
  no-overlap guard reads its status) and conversation_id (one run-log per trigger)
  are the two net-new columns the exposure never needed.
- Three CHECKs, declared on the model and the migration together so the
  integration tests that build the schema from the models can prove they reject a
  row: the schedule_kind vocabulary, the interval/cron discriminator, and the 60s
  interval floor. cron is modelled but refused at creation until the interval-first
  follow-up lands, so widening it is a code change, not a migration on live rows.
- RunSurface gains SCHEDULE. No migration: surface is a String(16) column with no
  CHECK, so the value is a one-line enum addition. The enum docstring's cautionary
  note about SCHEDULE being a value nobody writes is updated - #44 is its writer.

Verified against a real pgvector pg16 database: alembic upgrade head applies it,
alembic check reports no drift (the hand-written constraint and index names match
the model's naming convention), downgrade -1 then upgrade head round-trips, and
tests/test_migrations.py passes (4 passed) cycling the whole chain.

Refs #44
The service, HTTP surface and worker flows for agenticos#44, on top of the
agent_triggers table. Interval schedules only; cron is refused at creation.

Manage (schema, repository, service, routes under /agents/{id}/triggers):
- Per-resource routes with no require() gate; AgentTriggerService resolves
  agents:run through the registry (grant-aware) and reports a refusal as
  not-found, so agent ids stay unprobeable - the exposure routes' rule.
- create/list/update/delete, each audited. TriggerCreate validates the
  interval/cron shape before the row's CHECK can turn it into an
  unreadable IntegrityError.

Fire (AgentTriggerService.fire + the two Prefect flows):
- A fired run runs as the trigger's creator, membership re-resolved every fire.
  No membership -> disable. The grants-aware pre-check mirrors the run path
  (registry.get(..., agents:run)); a refusal that still escapes execute() is
  caught, not raised, so it cannot become a Prefect retry (Correction C).
- Budgets are inherited whole: execute() records a BUDGET_EXCEEDED run and
  returns normally, so an exhausted budget is refused, not retried.
- Output is one run-log conversation per trigger, opened once; each fire is its
  own agent_run stamped RunSurface.SCHEDULE, so Activity is unaffected.
- Heartbeat check_agent_triggers_flow (IntervalSchedule 60) claims due triggers
  FOR UPDATE SKIP LOCKED, advances next_fire_at under the lock, and submits one
  run per trigger via run_deployment(timeout=0) - submit-and-return, so a slow
  run never holds the tick open and the next tick cannot double-fire. This
  diverges from check_scheduled_syncs_flow, which gathers-and-awaits (Correction B).

Auto-disable writes an admin-visible audit entry (actor null - the platform, not
a person, made the call). The push email to admins is the plan's deferred
fast-follow, batched with the run-completed notification, since both need the
email-template build.

Refs #44
Tests for the trigger service, repository and routes, plus the coverage/type
gate registration that holds them to 100%. Verified: `make test` reports
100% (0 missed, 0 partial) with the three modules in the include lists.

- test_agent_triggers.py: the service, branch by branch. The refusals #44 names
  - a cron schedule, an agent the creator can no longer run (grants-aware
  pre-check, Correction C), a budget-exceeded run recorded and not retried, a
  creator who left the org - alongside runs-as-creator and the once-per-trigger
  run-log.
- test_agent_trigger_repo.py: reads the statements back (a `_RecordingSession`),
  asserting the org scope on reads and that the claim carries `FOR UPDATE OF
  agent_triggers SKIP LOCKED` and the terminal-status join.
- test_agent_trigger_routes.py: the thin handlers - total, delegation, 204.
- pyproject.toml + tests/api/test_platform_routes.py: the three modules join the
  coverage and ty include lists; the trigger service joins RESOURCE_AWARE_SERVICES,
  so the per-resource routes are authorized-by-service, not ungated (the
  completeness sweep proves it).

Existing tests updated for the new reality (these were the regressions the change
introduces, fixed here):
- test_run_surface.py: the vocabulary is seven surfaces now, including schedule;
  playground stays absent (no writer), schedule earns its place (the heartbeat).
- test_prefect_app.py: the runner registers run-scheduled-trigger and
  agent-triggers-check.
- test_run_history_routes.py: `?surface=schedule` is a valid filter now, not a
  422; playground still is.

Refs #44
Concepts gains a fifth noun - a trigger, an agent running itself on a schedule - beside Exposure: operational state, not part of the spec, running as its creator re-resolved each fire, stamped the schedule surface, appending to one run-log conversation. Governance states the one thing 'unattended' changes: a fired run refused by the budget waits for the next due time rather than retrying, and a creator who can no longer run the agent disables the trigger with an audit entry.

Refs #44
…eal database

Integration tests for what a mock cannot show: the CHECK constraints reject an interval below the floor, an interval trigger with no interval, and a schedule kind outside the vocabulary; the claim returns exactly the due-active-attributable rows and skips one whose last run is unfinished; and - the guarantee the whole design rests on - a row one heartbeat locked FOR UPDATE SKIP LOCKED is handed to no second heartbeat, so a tick that overruns its window cannot double-fire a trigger.

Refs #44
Two coverage-driven refinements to the trigger service, both to keep the 100% branch gate honest rather than papered over:

- create() narrows interval_seconds with a cast instead of a `or 0` fallback. Cron is refused just above, so the schema guarantees an interval is present; the fallback was an unreachable branch the gate could never cover.

- fire() no longer repeats the `created_by is None` check in its guard. _creator_context already returns None for both a null creator and a missing membership, so the guard made that branch unreachable; letting the context own it covers a user deleted outright (SET NULL) through the same disable path.

Refs #44

@DEENUU1 DEENUU1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, after the revision — this time reading it for how it holds up under load and whether it stretches to other trigger kinds. The revision is honest work: the corrections are folded in rather than papered over, and advancing next_fire_at from now (not from the previous mark) quietly avoids the post-downtime backfill storm that already OOM-killed this worker once (the war story at prefect_app.py:95).

One real hole is left, and it's in the guard we added last round: last_run_id is written only after the run finishes, so the no-overlap check is blind for exactly the window it exists to cover — any run longer than its interval double-fires. Details inline, with a fix that's one column and one lease. The rest is naming things the plan currently leaves implicit: the runner's 5-slot ceiling (PREFECT_RUNNER_LIMIT), that a fire is stateless, that a parked approval stalls the schedule (boundedly), and whether email triggers will ever live in this table. None of those change the architecture — they change what the implementation must not get wrong.

Comment thread docs/design/heartbeat-triggers-plan.md
Comment thread docs/design/heartbeat-triggers-plan.md
Comment thread docs/design/heartbeat-triggers-plan.md
Comment thread docs/design/heartbeat-triggers-plan.md
Comment thread docs/design/heartbeat-triggers-plan.md
@OchnikBartek OchnikBartek changed the title feat(agents): heartbeat & agent triggers (plan first) feat(agents): heartbeat & agent triggers Aug 10, 2026
@OchnikBartek
OchnikBartek marked this pull request as ready for review August 10, 2026 21:07
The docstring named an internal review correction, which reads as narration a future reader has no context for. The standing explanation - why the pre-check resolves access per row rather than checking a role - stays.

Refs #44
A trigger could only fire on a fixed interval; "daily at 09:00" was
unreachable. Cron was modelled - the column, the ScheduleKind value and the
CHECK branch all existed - but the service refused it at creation, so the
stored vocabulary was half-served.

Compute the next fire for a cron schedule with croniter, evaluated in UTC, and
drop the create-time refusal. next_fire computation now goes through one
_next_fire(schedule_kind, ...) used by both create and the heartbeat's
claim-and-advance, so an interval is now + interval and a cron is its next
matching instant after now - neither ever a burst of catch-up runs a worker
owes nobody after being down.

A crontab expression is something no database CHECK can judge, so TriggerCreate
parses it with croniter.is_valid: a garbled expression is a 422 naming the
field, never a schedule stored to fire nothing. UTC keeps a schedule surviving
a restart with no stored timezone to reason about; a caller who means a local
hour converts it when building the expression (a timezone column is a possible
follow-up, not this change).

croniter ships no type information, so the two calls into it are isolated in
_cron_next and annotated by a cast rather than an ignore. The trigger service
stays at 100%. docs/concepts.md updated to describe both schedule kinds.
The run-log conversation was opened lazily, on a trigger's first fire. A
schedule created a minute ago but not yet due therefore had no conversation to
point at, so the sidebar could not offer it as a clickable item until it had
already run once.

create() now opens that conversation eagerly and stamps conversation_id, so a
new schedule is a clickable, if empty, item straight away. _run_log is
unchanged in body and stays the idempotent fallback the fire path calls: it
still short-circuits on a set conversation_id, and still reopens a fresh log if
the conversation was deleted (the FK is SET NULL). Per trigger, never per fire.
A schedule could only run when its cadence came due; there was no way to try
one from the UI without waiting for the next fire (or editing the cadence to
force it). POST /agents/{agent_id}/triggers/{trigger_id}/run adds that.

run_now resolves agents:run on the agent through _owned - the same per-row
floor as scheduling it - then calls the existing fire() inline, exactly as the
interactive POST /agents/{id}/run runs an agent in-request. The fire still runs
as the trigger's creator, so a schedule runs as one identity however it is set
off, and next_fire_at is left untouched: running now is an extra fire, not a
reschedule. _owned and fire share one session, so the fire's last_run_id stamp
is visible on the row the route returns. A paused schedule is respected
(fire no-ops on it), so the UI offers this only on a live one.

The per-resource route carries no require() gate, by the same rule the other
trigger routes follow, and test_platform_routes proves it delegates to the
grants-aware service. Service stays at 100%.
The per-agent listing (GET /agents/{id}/triggers) was the only way to read
triggers, so the org-wide surfaces this feature needs - a sidebar section, an
Activity tab - had nothing to call. Add GET /triggers, the organization's whole
set of schedules and event triggers in one paginated listing.

Access stays per agent, not a role gate on the route: the service asks
visible_resource_ids which agents the caller's role and grants reach (None
meaning all), and filters to those - a caller who can reach no agent gets an
empty list, never another tenant's schedules, and an empty visible set is
"nothing", not "no filter". Each row is a TriggerRead enriched with its agent's
name, resolved by the query's join rather than a lookup per row, because a row
shown away from its agent's page has to name it.

The route is its own top-level /triggers, not /agents/triggers: the latter
would be shadowed by /agents/{agent_id} from the registry, registered first.
Same shape as the org-wide /runs listing. It carries no require() gate, by the
same rule the other trigger routes follow, and delegates to the grants-aware
service - test_platform_routes proves it. Service and repo stay at 100%.
An agent could run itself only on the clock. This adds the second concept
behind #44 - an event trigger, fired when something arrives - to the same
`agent_triggers` table, told apart by a new `trigger_type` discriminator
(`schedule` | `event`). The product calls the two "Schedule" and "Trigger";
the code keeps one table.

An event trigger carries an `event_source` (`github`, `email`), a per-source
`event_config` filter, and the secret its inbound webhook is verified against -
sealed for the organization through the one vault and stored inline with its
`secret_key_version`, exactly as a channel bot stores its signing secret. It
has no `next_fire_at`: nothing is due until a delivery lands, so the column
becomes nullable and the heartbeat's `next_fire_at <= now` claim excludes an
event row without a special case. One shape CHECK spans both concepts, so a row
is never a half-schedule-half-event, and the event branch also requires the
sealed secret to be present.

Delivery is a signed webhook at POST /api/v1/webhooks/triggers/{source}/{id},
authenticated by the trigger's own HMAC secret rather than a session - GitHub's
X-Hub-Signature-256, or the same scheme under X-Signature-256 for an email
relay. The route verifies and matches inline, then dispatches the fire to a
background task and returns 202, so a provider's ~10s timeout never catches an
agent run - the shape the Slack webhook already uses. A verified delivery with
nothing to do (unknown, inactive, wrong source, or a payload the filter
rejects) answers 202 all the same, so the response cannot tell an existing
trigger from a missing one; only a signature that fails to verify is a 403, and
a body that is not a JSON object a 400. The matched event's payload is rendered
and appended to the trigger's prompt, so the fired run sees which issue or email
set it off. No plaintext secret reaches any response, log or audit entry.

Verified: the migration chain runs forwards and back (4 passed) and `alembic
check` reports no model drift; the CHECK constraints reject a malformed event
row against a live database (integration, 12 passed); the service, route,
`trigger_events` verifier and background dispatcher are unit-tested to 100% on
the gated modules; `test_platform_routes` (206) confirms the webhook is a
deliberate open route and the per-agent routes still resolve `agents:run` per
row. `make lint` is clean. The full platform gate is 100% (the trigger modules
carry no un-hit line; run without integration the suite is 3755 passed).

Docs updated in the same change: concepts, governance and secrets now describe
the event concept, the run-now fire, and the per-trigger webhook secret.

Part-of #44
The types, query keys and hooks the trigger UI consumes, cloned from the
exposures pattern so the surfaces built on them stay conventional.

`Trigger` mirrors the backend `TriggerRead` field for field, including the two
concepts on one shape (`trigger_type`, the schedule cadence, the event source
and filter) and the derived `webhook_path` an event trigger shows. `useTriggers`
is one agent's CRUD plus run-now; `useOrgTriggers` is the read-only org-wide
list behind the sidebar and the Activity tab. Every mutation invalidates the
shared `qk.triggers.all()` prefix rather than patching, because the server
resolves and derives fields on the row it returns - the run-log conversation it
opens, the webhook path it computes - so the org-wide surfaces refetch too. A
per-field PATCH carries exactly the fields it was sent, the same discipline
exposures follows, so a pause cannot overwrite an environment rebound in
between.

The org list reaches a new same-origin proxy at /api/triggers -> /api/v1/triggers
(one line, `platformProxy()`, mirroring /api/runs); the per-agent routes reuse
the existing /api/agents proxy.

Verified: 13 hook tests (vitest) cover the reads, the writes, run-now, the
enabled gate and that a failed org request is reported rather than rendered as
"no triggers". tsc, eslint and prettier are clean on the new files.

Part-of #44

@bll-kacper-wlodarczyk bll-kacper-wlodarczyk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third pass, on the code this time. This is strong work — the refusals are exactly the tests I'd have asked for, the SKIP LOCKED claim is proven on a real database, and disable-not-retry is symmetric across the budget and the authz doors, which was the easiest thing to get wrong.

Three blockers, each with its fix inline: the migration chains off 0012 while main is at 0021, which is single-handedly both red CI jobs; the no-overlap guard still can't see a run in flight (the one open thread from my second pass, now traced through the runner's commit timing); and repointing a trigger's environment 500s because a raw UUID reaches the JSONB audit column. Two smaller suggestions and a stale plan line beside those. The remaining plan-doc notes from my second pass (the parked-approval policy sentence, the email-vs-kind table decision) can ride along in the same push.

Comment thread backend/alembic/versions/0013_agent_triggers.py Outdated
Comment thread backend/app/repositories/agent_trigger.py
Comment thread backend/app/services/agent_trigger.py Outdated
Comment thread backend/app/services/agent_trigger.py
Comment thread backend/app/worker/tasks/trigger_tasks.py
Comment thread docs/design/heartbeat-triggers-plan.md
Comment thread backend/tests/integration/test_agent_trigger_schema.py
The first UI surface for #44: a panel on the agent's availability tab that
lists what makes it run itself, and one shared dialog that creates and edits
both concepts.

The panel lists each trigger by what fires it - "Every 15 minutes", a cron
line, "On new GitHub issues" - with its message, and per-row pause/resume, run
now and delete. Its empty state says what the absence means: an agent with no
triggers is not misconfigured, it just answers only when messaged. Every row
action and the create buttons are hidden, not merely disabled, for a caller
without `agents:run` - the role-level answer the page passes in, which the
server still resolves per row.

The dialog is the "shared form" the plan calls for. Creating chooses
schedule-or-event and its cadence (an interval built from a value and a unit, or
a raw cron) or its source (a GitHub issue or an inbound email, with a generated
signing secret and, for email, optional subject/sender filters); editing may
change only the message and the environment, because a trigger's shape is set
once - exactly what the server's shape CHECK and update schema allow, so the
form never offers an edit the API would refuse. An event trigger's editor shows
the webhook URL to paste into the provider, and every editor carries Run now.

`triggerSummary` reduces a trigger to a discriminated union so the label is a
fixed translation key and a count - an interval is an ICU plural per unit, not
English glued together - and both `en.json` and `pl.json` carry the copy.

Verified: 33 vitest tests across the panel, the form (both concepts, both
schedule kinds, the environment, run-now, the webhook URL, a refused create that
loses nothing, the validity gate) and the summary; the trigger components are at
100% lines and functions, the format helper likewise. `make lint-frontend`
passes - the i18n guard finds no hardcoded copy and the catalog test is green.
tsc, eslint and prettier are clean. The one red spec in the full coverage run is
a pre-existing lazy-render flake in chat-parts, unrelated to this change (it
passes standalone, and a different unrelated spec flakes on a re-run).

Part-of #44
The org-wide surface for #44: a "Scheduled" tab beside Runs and Spend that
lists what every agent is set to do on its own, named by its agent, with the
same per-row pause/resume, run-now, edit and delete the agent's own panel has.

The row is factored out as `TriggerRow`, shared by this tab and the agent panel
(which this refactors onto it), so a pause looks and behaves identically in
both. Each row keeps its own `useTriggers` keyed on the trigger's agent, which
is what lets a list spanning many agents act on any row - a single hook would
reach only one agent's writes.

A failed request is its own state, not an empty list: the Activity page fans out
to several queries, and "nothing scheduled" and "the request 502'd" are the same
pixels otherwise, so `useOrgTriggers` now surfaces the error and the tab says it
out loud. Managing a row is gated on the role-level `agents:run` (the server
still resolves it per row), and the list only carries triggers on agents the
caller may already see.

Verified: 5 vitest tests for the tab (the list named by agent, the empty state,
a failed request said out loud, a row pause, and no actions for a viewer) plus
the 33 that already covered the panel and the form, all green after the refactor.
The trigger components and the tab are at 100% lines and functions. make
lint-frontend passes - the i18n guard is clean and both en.json and pl.json
carry the new copy.

Part-of #44
The last two surfaces for #44, both in the conversation sidebar.

The New Chat button becomes a split button: the wide half starts a chat
exactly as before, and a chevron opens New schedule / New trigger - the two
kinds of conversation nobody types into. No agent is in context there, so the
shared form gains a picker over the published agents, seeded with the user's
starred default (the same resolution the chat's own agent picker makes) and
falling back to the first published one. Picking a different agent resets the
environment choice, because a named environment belongs to one agent and
carrying it across would be refused on create.

Below it, a collapsed "Schedules & triggers" section lists the organization's
triggers - fetched only when expanded, so the sidebar's first paint costs no
extra request. Clicking an item follows what the item has: a trigger that has
never fired opens its editor (there is nothing to read yet, and Run now lives
there), one with runs opens its run-log conversation through the sidebar's own
selection handler. A per-row menu carries edit, pause/resume, run now and
delete, since four inline icon buttons do not fit a 256px rail. A failed load
is its own sentence, not an empty section.

Verified: 11 tests for the section (fetch-on-expand, the empty and failed
states, both click routes, every menu action, closing the editor), 3 for the
split button in the sidebar's own harness (New Chat unchanged, both menu items
opening the right dialog), and 3 for the picker (starred default, first
published fallback, creating on the picked agent - drafts not offered). The
sidebar's pre-existing 17 tests stay green. Full trigger suite: 86 passed.
tsc, eslint, prettier and the i18n guard are clean; en.json and pl.json carry
the new copy.

Part-of #44
… gaps

Two threads landed together: more sources for a user to fire on, and the
backend defects a maintainer pass turned up.

Sources. `EventSource` gains `linkedin` and the catch-all `webhook` beside
`github` and `email`, each with its own typed `event_config` filter (LinkedIn by
author/text; the generic webhook takes none - filtering is the sender's job) and
its own render into the run's prompt. All four verify the same HMAC-SHA256 the
existing two do and reach the one match-then-fire path; a fifth source is a value
in the enum, a branch in `trigger_events`, and one word in the vocabulary CHECK.
The migration and the model move in lockstep - the shape CHECK's source list is
edited in `0014` itself, which has not shipped (origin is at `0013`).

Fixes, most severe first:

- The webhook URL shown to paste into a provider was built on the *frontend*
  origin, which 404s: the webhook is served by the API host. `TriggerRead` now
  returns the full `webhook_url` on `PUBLIC_BASE_URL`, the one address channel
  webhooks and OAuth callbacks already use.
- The org-wide listing filtered on the grant ids alone, which under-included: it
  hid triggers on an org-visible agent that the agent's own page shows. It now
  applies the same owned-or-org-visible-or-shared predicate `agent_repo.list_visible`
  uses, so the two surfaces agree, and the empty-grant short-circuit is gone.
- `hmac.compare_digest` raises `TypeError` on a non-ASCII str, so a high byte in
  the signature header turned a 403 into a 500 on an internet-facing route; the
  comparison is now over bytes.
- `TriggerUpdate` accepted an explicit `null` for `prompt`/`interval_seconds`/
  `is_active`, which `exclude_unset` cannot tell from omitted and which hit a NOT
  NULL column as a 500; a before-validator refuses it as a 422.
- Run-now is now audited under the caller. The run still runs as the creator, but
  who pressed the button - and spent the money - was invisible to the trail.
- Retiming an interval, or resuming a schedule, recomputes `next_fire_at` from
  now, so shrinking "daily" to "every five minutes" no longer waits out the day.
- `GithubTriggerConfig.actions` is a `Literal` of GitHub's issue actions, so a
  typo like `opnened` is a 422 rather than a filter stored to match nothing.
- The `/triggers` prefix is added to `test_platform_routes`' sweep, and the
  webhook route's docstring no longer overclaims (a *verified* delivery gives
  nothing away; a bad signature is a deliberate 403).

Verified: 334 unit + 17 integration/migration tests pass, including the new
source verify/match/render, the visibility predicate, the null-update refusal,
the run-now audit, the next-fire recompute, and a schema test that every shipped
`EventSource` value is in the CHECK. `alembic check` reports no drift; the
migration round-trips. ruff, ty and the platform 100% gate on the trigger
modules are clean. Docs: concepts and secrets describe the four sources.

Part-of #44
The frontend half of the source widening and the review's UI findings.

The create dialog offers all four sources - GitHub, email, LinkedIn, a generic
webhook - each with a one-line note on where its delivery comes from and only the
filters that source actually applies (subject/sender for email, author/text for
LinkedIn, none for GitHub or the generic webhook). The summary and the type
mirror the backend's four.

Gaps closed:

- The biggest dead end: creating an event trigger closed the dialog and the user
  never saw the webhook URL, so nothing could ever fire it. It now stays open on
  the full URL (built server-side, correct for the API host) with a Copy button;
  a schedule, which has nothing to hand off, still closes.
- Delete was one un-confirmed click on a destructive action, in the panel, the
  Activity row and the sidebar menu; all three now confirm first.
- The sidebar's create menu and per-row actions rendered for everyone. Managing a
  trigger is `agents:run`, so they are gated - not rendered and then 403'd - while
  a viewer still sees the list (viewing is `agents:view`), and a run-less item no
  longer opens an editor a viewer could not save.
- The editor PATCHed `prompt` and `environment_id` on every save; it now sends
  only the fields that changed, so a prompt tweak cannot overwrite an environment
  somebody rebound in between.
- `webhook_path` became `webhook_url` throughout, since the backend now returns
  the full URL rather than a path the browser would resolve against the wrong
  origin.

Verified: the full frontend coverage gate is green - 3832 tests, 100% lines,
statements and functions, branches over threshold - covering the four sources,
the webhook-URL reveal and its Copy, the confirmed deletes, and a viewer who
sees the list but no controls. `make lint-frontend` passes; the i18n guard is
clean and both `en.json` and `pl.json` carry the new copy. The new `/triggers`
proxy is added to the proxy-mount sweep.

Part-of #44
Live end-to-end verification caught a 500 the whole test suite missed: creating
a trigger returned `500 ResponseValidationError` on `updated_at` with
`MissingGreenlet: greenlet_spawn has not been called`.

Opening the run-log conversation eagerly flushes a `conversation_id` update, and
the row's server-side `updated_at` (`onupdate=now()`) is expired on the instance
by that flush. The route then serializes the ORM row to `TriggerRead`, which
reads every attribute synchronously - so the expired `updated_at` tries to
lazy-load inside Pydantic, off the async greenlet, and 500s. `run_now` had the
same shape: its fire flushes `last_run_id`, expiring `updated_at` the same way.
Both now `await self.db.refresh(trigger)` before returning, so the row is fully
loaded when it is serialized. (`update` already refreshes as its last write.)

No mocked-service test could see this: the API tests return a `TriggerRead` from
a mocked service and never serialize a live row. The regression test drives the
real service against a real session and then serializes - the exact failing path
- so it 500s without the refresh and passes with it.

Verified live against a running backend (local process, real DB + Redis): create
a schedule -> 201 with its next fire and eager conversation; create a GitHub
event trigger -> 201 with the full `webhook_url` on the API host; the org listing
names both by agent; a webhook with a valid HMAC -> 202, a wrong or missing
signature -> 403, a non-issues event -> 202. 99 unit + integration tests pass;
ruff and ty are clean.

Part-of #44
The agent builder's loading skeleton keyed its seven placeholder tabs on
their Tailwind width class, and three of those widths repeat, so React saw
duplicate keys and warned the placeholders could be dropped or reordered.
Key on the index instead - the strip never reorders.
Resuming a paused schedule recomputes its next fire and records the change in
the audit trail's `details`, a JSONB column. That column serializes with the
default `json.dumps`, which cannot encode a datetime, so the flush raised and
the request 500'd - where pausing, whose only change is a bool, went through.
The changes are passed through `jsonable_encoder` first, the encoder the error
responses already use.

Verified with an integration test driving create -> pause -> resume ->
serialize against a real session and audit: it fails without the encoder and
passes with it.
Clicking a trigger in the sidebar section opened its run-log conversation only
once it had fired; before that it opened the editor. But that conversation is
created eagerly on create, so a run-less trigger has an empty one to show -
which is what clicking the item should do. It now opens the conversation
whenever there is one and falls back to the editor only when there is none. A
viewer, who cannot edit, still opens the read-only conversation.
Three changes to how a schedule is set up and managed, sharing the one editor.

The "at a set time" tab was a raw crontab field; it is now a builder - a time
plus a repeat (every day, every N days, chosen weekdays, or a day of the month),
with a "Custom (cron)" escape hatch for anything the presets miss. It composes
the expression, so a non-technical user never writes crontab, and a live summary
restates the choice. Editing a cron schedule seeds the builder back from its
expression.

A schedule's cadence can now be changed in place - a new interval, a new cron, or
a switch between the two - instead of deleting and recreating it. `TriggerUpdate`
carries the cadence fields; the service resolves the pair to exactly one of
interval/cron (inferring the kind from the field sent), validates a cron, and
recomputes the next fire. An event has no cadence, so one on it is refused.

A trigger can carry an optional title (a nullable `name`, migration 0015), shown
instead of the agent's name wherever it is listed; null falls back to the agent
name so an untitled trigger and an older row both still read.
Bring the agent-triggers feature onto main (0.0.103) so the branch's diff
against main is the feature alone. Conflicts and the semantic auto-merges:

- agents/[id] availability tab: keep TriggersPanel, drop ChannelBotsPanel -
  main deleted the panel and its import in the channel-bot refactor.
- messages/en.json: keep the Scheduled-tab label beside main's new
  spend-by-person keys.
- RunSurface gained EMBED on main and SCHEDULE on the branch; the merged enum
  and its docstring carry both. prefect_app registers main's sweeps beside the
  triggers heartbeat. query-keys, deps and the __init__ registries auto-merged
  and were read back by hand rather than trusted.
- surface stays an unconstrained String(16), so "schedule" needs no CHECK.

Renumber the trigger migrations 0013-0015 -> 0022-0024, chaining onto main's
head 0021_drop_channel_tools_bindings; alembic heads now reports one head.
uv.lock --check passes against the merged pyproject.
main's i18n guard scans toast copy, so the five hardcoded strings in
use-triggers.ts failed lint once the merge brought the stricter guard in.
Move them into the triggers namespace (en + pl) and read them with
useTranslations.
agent_trigger_repo.create gained a required keyword-only name; the repo
test still called it without one and failed under the full suite. Add the
missing case for switching a cron schedule to interval with no value to
fall back on - it raises rather than writing an unschedulable row -
restoring the platform layer to 100%.
@OchnikBartek
OchnikBartek marked this pull request as ready for review August 11, 2026 21:45

@DEENUU1 DEENUU1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fourth pass, focused on what landed after my last review: the event triggers end to end, the cadence editor, the titles, and the UI surfaces. This is strong work. The secret handling is exactly right — sealed on create, absent from every audit entry and every response, with a test proving precisely that — the webhook answers 202 whether or not there was anything to do so trigger ids stay unprobeable, and the disable-not-retry symmetry now covers access withdrawn mid-run too. The #589 deferrals are fine as triaged.

One thing I'd fix before merging: GET /triggers is a collection listing with no require() gate, and the comment defending that in test_platform_routes.py leans on a claim that isn't true — /runs does carry require(RUNS_VIEW) (backend/app/api/routes/v1/runs.py:42). No live hole today, since every role holds agents:view, but it's our own hard rule and the fix is one line. Beside that: the event-context size cap is applied to the generic webhook but not to GitHub, email or LinkedIn bodies, and a few small nits inline.

Comment thread backend/app/api/routes/v1/agent_triggers.py Outdated
Comment thread backend/tests/api/test_platform_routes.py Outdated
Comment thread backend/app/services/trigger_events.py
Comment thread backend/app/services/trigger_events.py Outdated
Comment thread backend/app/services/agent_trigger.py
Comment thread backend/alembic/versions/0023_agent_event_triggers.py Outdated
Comment thread backend/app/db/models/agent_trigger.py Outdated
Comment thread backend/app/schemas/agent_trigger.py
Review feedback on #537, addressed together.

Gate the org-wide listing. GET /triggers was the one platform collection
route with no require() gate; it now carries require(agents:view), the same
coarse first door as GET /agents and the org-wide GET /runs. No live hole
today - every role holds agents:view at SHARED or better - but without the
gate it silently disagreed with the agent listing for any caller require()
would refuse there (an app admin with role="", or any future role without
agents:view). The service's per-agent visibility filtering stays behind the
gate. The permission-sweep table and its stub list learn the route; the
module docstring names the org-wide exception and why it alone is gated.

Bound event-body rendering. A GitHub issue body reaches 65,536 characters
and its author is anyone who can open an issue on the watched repo; email and
LinkedIn bodies are whatever the relay forwards, all three appended to the
fired run's message untrimmed. Only the generic webhook was capped. Pulled
that cap into a _clip helper and applied it to every free-text field;
_WEBHOOK_CONTEXT_LIMIT is now _CONTEXT_LIMIT since it is no longer webhook-only.

Casefold the email and LinkedIn filters. Their substring filters were
case-sensitive, so a sender filter of @Vstorm.co never matched john@vstorm.co
(a domain is case-insensitive by spec) and a subject filter of "invoice"
missed "Invoice" - a trigger that silently never fires, with nothing to say
why. Both sides are casefolded now. GitHub's filter is left exact: it matches
a canonical action value, not free text.

Three stale comments: the 0023 downgrade comment ("pre-0014" -> the 0022
shape it restores), the model's conversation column ("opened on the first
fire" -> "when the trigger is created", which create() has done since), and
the platform-test prefix comment that justified the missing gate by citing
/runs as ungated when /runs is gated.

Deferred, not folded in: run_now runs the whole agent execution inside the
HTTP request, so a slow run behind nginx 504s while it commits server-side
and invites a second press (a second fire). The reviewer flagged it
non-blocking and did not hold the PR for it; filed as #658 with the
run_deployment(timeout=0) fix shape rather than widening this change.

Verified: make test 4547 passed at 100% coverage; make lint-backend clean.
No migration changed. No docs page describes these routes or filters, so
none is owed.

Refs #658
DEENUU1
DEENUU1 previously approved these changes Aug 13, 2026

@DEENUU1 DEENUU1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fifth pass, on the last push (bf5c989). Every finding from my fourth pass is addressed the way I asked: GET /triggers now carries require(agents:view) and the route sweep in test_platform_routes.py covers it as a gated call instead of exempting it on a wrong claim about /runs; the clip is hoisted into _clip() and applied to the GitHub, email and LinkedIn bodies, with tests proving the header survives a 10k-character paste; the email and LinkedIn filters casefold both sides; and both stale comments are fixed.

I went looking for something new to hold this on and didn't find it. The rename left no stale references, the gate matches its siblings (GET /agents on agents:view, GET /runs on runs:view), the webhook's create_task has no committed-row hazard because prepare_event_fire writes nothing the task would need to read, and the fields left unclipped (subject, title, author) are all bounded in practice while the deliverer holds the secret. The deferrals stand as triaged: #589 for the fire() hardening, #658 for the inline run-now.

CI is green across the board, e2e included. Approving — good work carrying this through five passes.

Two non-trivial resolutions:

Migrations. main advanced to 0027 (hosted embeds, message author/ordinal)
since this branch first renumbered onto 0021, so its 0022-0024 collided
with main's 0022-0027 and left the chain with two heads. Renumbered the
trigger migrations 0022_agent_triggers -> 0028, 0023_agent_event_triggers
-> 0029, 0024_agent_trigger_name -> 0030 onto 0027_message_ordinal; the
chain has a single head (0030) again and tests/test_migrations.py round-trips.

frontend/messages/pl.json. Resolved as the union of both branches' i18n
keys via a 3-way merge off the index stages - base 491, this branch +110,
main +586, result 1187 leaves, with no key changed on both sides. en.json
merged without conflict and carries the same union.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent Schedules & Triggers: set up and manage unattended runs from the UI

3 participants