Skip to content

feat: event-driven governance (sync + async event rules) (#230) - #568

Draft
krokoko wants to merge 6 commits into
mainfrom
feat/230-event-governance
Draft

krokoko wants to merge 6 commits into
mainfrom
feat/230-event-governance

Conversation

@krokoko

@krokoko krokoko commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

You can now govern autonomous agents by meaningful moments ("PR about to open," "cost exceeded $25," "milestone reached") — with rules that either block in real time or react after the fact, that survive container restarts, that can be safely dry-run first, and that are now authored alongside workflows ready for a shared registry.

Closes #230.

What this adds

A unified Event Governance layer. Before this, the only controls were tool-level Cedar policies (gate a bash command / file write). Operators think in moments, not tools — this adds declarative event rules (when X happens → do Y) that fire on lifecycle/milestone/checkpoint/cost events.

Each rule has: on (event), optional when (field matchers + aggregate conditions), action (require_approval / notify / escalate / cancel_task / inject_nudge / observe_only), mode (enforce or observe_only), and evaluation (sync or async).

Two planes, one catalog:

  • Sync — in-agent (Python), at pipeline checkpoints, can block (e.g. pause for approval before the PR is created).
  • Async — the FanOut stream consumer (TypeScript), reacts (notify / escalate / cancel / durable cost & turn ceilings) without blocking the hot path.

Cross-language parity between the two evaluators is locked by shared fixtures tested on both sides. Precedence is explicit: tool Cedar hard-deny always wins; async never overrides a sync deny; every action is deduped by (task_id, rule_id, correlation_id).

Configuration lives on the repo Blueprint (inline rules or a pinned versioned pack). Rules are frozen onto the TaskRecord at submit time, so a task's governance can't shift under it mid-run.

CLI surface (bgagent): rules list (resolved rules for a repo), rules eval --fixture (local dry-run); notifications/approvals flow through the existing watch / pending / approve / deny with an [observe] tag for dry-run rules.

Deferred (follow-ups, not in scope for this PR)

  • Async require_approval on a still-RUNNING task flips the DB status but does not signal the running agent to pause (only the sync gate blocks in-process). The supported path today is post-hoc gates (e.g. pr_created). An in-agent poll would make async approval block a live task — worth a follow-up issue.
  • Async approval cap: the async path doesn't consult the per-task approval_gate_cap the sync path enforces.
  • Parity harness covers on/when/aggregate matching but not the evaluation sync/async split or the policy_decision metadata builders — extending the shared fixtures would close that.
  • EventRule as a discriminated union (on action) would make illegal mode/action combos unrepresentable rather than validated by convention.

Testing

  • CDK: 2297 tests pass, coverage thresholds hold.
  • Agent: full quality suite green (1192 tests), event-governance + parity + gate suites verified.
  • CLI: build + eslint green.
  • Docs Starlight mirror in sync.

Pre-existing, not from this branch: security:sast flags agent/pyproject.toml missing a uv dependency cooldown, and security:secrets flags historical leaks across all commits. Both reproduce on main.

Will turn to ready once live tests are all done

bgagent and others added 6 commits July 8, 2026 17:42
)

Add declarative event rules with sync gates in the agent runtime and async
evaluation in FanOut, plus contracts, API/CLI surfaces, and platform-default
rule pack resolution frozen on task creation.

Co-authored-by: Cursor <cursoragent@cursor.com>
…le-language question (#230)

Aggregate ceiling rules were unreliable:
- turn_count_gte rules never fired — the agent emitted `turn` but every
  evaluator reads `turn_count` (NaN → no match). Agent now also emits the
  cumulative aliases `turn_count` / `cumulative_cost_usd` that the rules read.
- cost/turn aggregates were reconstructed from the current event only, so the
  per-session SDK reset (PR-fix retries, container restart) undercounted them.
  evaluateAsyncEventRules now persists a monotonic high-water mark on the
  TaskRecord (climb-only conditional UpdateItem) and evaluates against it, so
  ceilings survive restarts. Fanout's ad-hoc reconstruction is removed.

Resolves the §10 rule-language open question: declarative field matchers are
permanent; Cedar-on-events is rejected (Cedar is authorization, has no
aggregation, and would add a third Cedar runtime for no gain).

ponytail: high-water is max(session totals), not a cross-session SUM — upgrade
path noted on the TaskRecord fields if PR-fix retries must accrue.
- rethrow retryable infra errors (throttle/5xx) from task load and
  high-water update so the stream retries instead of silently
  under-counting a cost/turn ceiling
- seed the monotonic cost mark from authoritative task.cost_usd so a
  ceiling can trip on a non-cost event and the first eval of an
  already-costed task
- normalize aggregate metadata aliases on both evaluators; drop the
  duplicate cumulative_* / turn_count aliases the progress writer emitted
- default gate_on_event_async ts_module to the real task_state so a
  missing module pauses for approval instead of crashing
- key get-event-rules cache on repo + workflow_ref (rules depend on the
  workflow's eventRulePack)
- add coverage for the new retry/seed paths
Move the event-rules asset tree (schema, packs, parity fixtures) from
contracts/ to agent/event-rules/, alongside agent/workflows/ — one asset
tree, one authoring UX, and the natural source for the future
RegistryService (#246).

The agent runtime never loads these files (it receives resolved
event_rules on the TaskRecord), so only the three file consumers move:
the CDK pack resolver (bundle-time import), the CLI --fixture loader, and
the Python + TS parity/evaluator tests. Dual-plane parity is unchanged —
the CDK-resolved rules remain the single source both planes consume.

Repoints resolver import, CLI fixture path, four test fixture paths,
blueprint schema comment, and EVENT_GOVERNANCE.md (+ Starlight mirror).
Multi-agent review surfaced that the branch hardened three DB read/persist
paths against throttles but left the actual enforcement writes swallowing
them — defeating the ceiling/approval guarantees the branch existed to fix.

- cancel_task and async require_approval now rethrow retryable infra errors
  (throttle/5xx) so the FanOut record retries instead of silently letting a
  task run past its cost ceiling / skip its approval gate; benign conditional
  failures on the approval path are classified and swallowed as INFO
- lift isRetryableInfraError into shared retryable-error.ts (approval module
  consumes it without a circular import)
- release the idempotency marker when an enforce action rethrows, so the
  retried record can re-claim and re-run rather than being deduped away
- observe_only mode no longer fires notify/escalate side effects — gate the
  whole action block on enforce (was only guarding nudge/approval/cancel)
- inject_nudge rethrows retryable errors too
- parseEventRules (TS + Python) logs dropped malformed rules instead of
  silently vanishing a ceiling rule; Python now requires id AND on (no more
  KeyError on missing on)
- add turn_count_gte to the inline BlueprintEventRule.when.aggregate type
- drop severity 'critical' from schema + both type mirrors (the agent
  PolicyEngine only knows low/medium/high)
- remove dead evaluation-filter branch in bgagent rules eval

Tests: async require_approval (enforce + observe), inject_nudge truncation,
idempotency dedup skip, cancel terminal-status guard, retryable-release-and-
rethrow, unknown-pack 422, and a new event-rule-pack-resolver suite covering
inline/pack merge + override. cdk 2297 pass, agent + cli green.
…om 2nd review

- policy_decision emit is now inside the release-guarded try alongside the
  enforce action: a retryable throttle on the audit emit (which rethrows)
  used to leave the idempotency marker claimed but the action un-run, so the
  stream retry skipped the rule entirely — dropping a cost ceiling / approval
  gate silently. Emit + action now share one release-on-failure path.
- cancelTaskByRule swallows a benign ConditionalCheckFailedException (task
  raced to terminal between load and the cancel Update) as INFO instead of
  rethrowing, mirroring the approval path — a CCF no longer parks the record
  in batchItemFailures forever.
- claimIdempotency rethrows retryable infra errors instead of proceeding on an
  unclaimed marker, which risked a double notify/escalate on the eventual retry.

Nits: comment get-event-rules cache as best-effort/per-instance; note that the
CLI rules-eval matcher is a dry-run copy with the parity suites as source of truth.

Tests: emit-failure-release, cancel CCF swallow (no release), claim-throttle
rethrow. cdk 2300 pass, cli green.
@scottschreckengaust scottschreckengaust added the v1 Version 1 label Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Version 1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: Event-Driven Governance and Actions

2 participants