feat(agents): heartbeat & agent triggers - #537
Conversation
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
DEENUU1
left a comment
There was a problem hiding this comment.
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:
- 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.
- Spec vs row — yes, keep it a row. Strongest call in the plan; more inline.
- Single heartbeat — yes.
- 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.
- 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.
@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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.
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%.
DEENUU1
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.
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, deliberatelyoutside 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 carriesthe 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)ScheduleKind=interval|cron) — an interval floor of 60s, or acrontab expression validated with
croniter.next_fire_atis computed on write andadvanced under the heartbeat's lock.
EventSource=github|email|linkedin|webhook) — a per-sourceevent_configfilter and a signing secret sealed through the one vault(
event_secret_encrypted+secret_key_version), exactly as a channel bot stores itssecret. An event row has a null
next_fire_at; nothing is due until an event lands.trigger_typediscriminator and a single shape CHECK (ck_trigger_shape) keep "whatmakes 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_cadenceinfers thekind 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, migration0024) — 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 stampedschedule, a first-class surface in runhistory (
?surface=schedule) beside web/api/slack/embed. Triggered runs stay in Runs aswell as the new Scheduled view.
The heartbeat —
check_agent_triggers_flow(IntervalSchedule(60)) claims duetriggers
FOR UPDATE SKIP LOCKED, advancesnext_fire_atunder the lock, and submits eachvia
run_deployment;run_scheduled_trigger_flowis one fired run. A fire runs as thecreator (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 arefusal.
Endpoints
GET /triggers— org-wide listing (the same shape as the org-wide/runs), feeding thechat sidebar and the Activity view.
GET/POST/PATCH/DELETE /agents/{agent_id}/triggersandPOST /agents/{agent_id}/triggers/{trigger_id}/run.agents:runthrough the registry; a refusal is reported asnot-found, so agent ids stay unprobeable.
Five UI surfaces
TriggersPanelon the agent page, managed at theagents:runfloor (running an agent, not publishing it).
conversation on click.
monthly presets, custom weekday selection, raw cron), the title field, and event-source
configuration.
"Cron 0 9 * * * (UTC)", "On inbound email") wherever a trigger is listed.
Migrations
0022_agent_triggers→0023_agent_event_triggers→0024_agent_trigger_name, chainedonto main's head
0021_drop_channel_tools_bindings.alembic checkreports no drift; thechain applies and rolls back cleanly (
tests/test_migrations.py, plus a manualforwards/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 isthe feature alone.
make checkgreen — 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.0022–0024forward and back,alembic checkclean.double-fire one trigger, and that a budget-exhausted fired run is recorded
budget_exceededand not retried.Checks
make checkpasses (lint, format, types)not a capability)
alembic downgrade base && alembic upgrade headworks for0022–0024Follow-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 areresolved here as triage, with the real fix tracked in #589:
last_run_idself-overlap. The heartbeat's "skip a trigger whose previous run hasnot reached a terminal status" guard joins on
last_run_id, which the child flow writesonly after
executereturns — so while a run is executing the guard still sees theprevious (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
UPDATEthat advancesnext_fire_at, cleared by the child in afinally.run_deploymentaborts the heartbeat's dispatchloop after the claims are already committed, so the rest of that batch has
next_fire_atadvanced 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 astriage, with the fix tracked in #589. The design doc stays in the PR:
docs/design/isexclude_docs-configured engineering material for review, matching four such docs alreadyon main.
Closes #44