Skip to content

feat(events): system-emitted agent.task.completed/failed at execution terminal (#1578)#1680

Merged
vybe merged 13 commits into
devfrom
AndriiPasternak31/issue-1578
Jul 17, 2026
Merged

feat(events): system-emitted agent.task.completed/failed at execution terminal (#1578)#1680
vybe merged 13 commits into
devfrom
AndriiPasternak31/issue-1578

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Fixes #1578

What

The backend now deterministically emits agent.task.completed / agent.task.failed at every CAS-won execution terminal, delivered over the existing EVT-001 subscription-dispatch machinery — so a subscribed orchestrator is woken with a report-back task when a long async task finishes, instead of polling get_execution_result. Implements the missing half of TARGET_ARCHITECTURE.md §Async-First Communication; a down-payment on Epic #1045#1081.

These are the first system-emitted events (deterministic backend chokepoint, no LLM in the loop), distinct from EVT-001's agent-emitted emit_event. Same tables, same delivery, reserved agent.task.* namespace.

How

  • Shared emit helper services/event_dispatch_service.py::emit_task_terminal_event (+ strong-ref spawn_task_terminal_event), matching-subscription gated (zero subs ⇒ zero rows, no dispatch). The EVT-001 dispatch primitives (trigger_subscription, _interpolate_template, _get_internal_token) were extracted verbatim out of routers/event_subscriptions.py so the service layer no longer imports a router (Invariant Fix: Add missing Docker labels to system agent container #1, verified cycle-free).
  • CAS-won coverage across every terminal writer: apply_result (success + failure), _write_terminal_and_gate (timeout/budget/crash + inline circuit-open/capacity/ephemeral — the wedge cases that never reach apply_result), the refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083 lease-reaper (cleanup_service), and the pull sink (pull_coordination_service, dark until a pilot). Both refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083 caller paths (inline sync + async callback) converge on apply_result.
  • Exactly-once: emit runs strictly inside the won CAS branch — a replayed/late refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083 callback or lease-expiry race fires nothing.
  • Reserved namespace + 3-layer loop safety: agents can't emit_event into agent.task.* (400); reserved self-subscription blocked on create and update (400); recursion-break — a task spawned by a reserved-namespace dispatch is persisted triggered_by="event" and the helper suppresses re-emission, breaking self / A↔B / A→B→C→A cycles at the root.
  • Payload {execution_id, status, triggered_by, summary_or_error, duration_ms, cost, fan_out_id, loop_id} — flat, {{payload.field}}-interpolable; status is the string value. fan_out_id/loop_id carried for the future pull fan-out join envelope.
  • Additive & inert: reuses agent_events / agent_event_subscriptions + the existing triggered_by column — no schema change, no migration, no flag, no config. Fail-open: a broken emit never touches the billed terminal.

Security hardening (final commit)

  • Recursion-break X-Event-Trigger is honored only with a valid backend-internal X-Internal-Secret (C-003, constant-time) — an external /task caller can't spoof it to suppress a real completion.
  • summary_or_error is credential-sanitized at the single emit chokepoint over a 2×cap window before truncation, so a secret straddling TASK_EVENT_SUMMARY_MAX can't leak an un-redactable head.

Delivery scope (honest)

Best-effort today: wakes a running (incl. #1402 parked-but-running) subscriber via loopback async /task; a stopped subscriber's wake is dropped though the agent_events row persists. Durable "lands in the caller's queue" delivery is the pull migration's job — the same names + payload carry over unchanged.

Acceptance criteria

Tests

tests/unit/test_1578_task_completion_events.py37 unit tests: fired / not-fired (no sub) / replay / CAS-lost, recursion-break, spoof-guard (helper + router call-site), summary sanitize incl. boundary-straddle, and every terminal writer including both #1083 entry points. Adjacent test_1083_* / test_946_* suites re-run green (no regression).

Docs

  • docs/memory/architecture.md — Task Completion Events cross-cutting block + EVT-001 / service / events.ts / DB-schema entries
  • docs/memory/feature-flows/task-completion-events.md (new) + index Recent-Updates row + catalog entry + back-links
  • docs/memory/requirements/roadmap.md §17.2a
  • docs/planning/TARGET_ARCHITECTURE.md §Async-First + Epic Epic: Agent Infrastructure #1045 table
  • src/mcp-server/src/tools/events.ts — reserved-namespace note on both tools

🤖 Generated with Claude Code

AndriiPasternak31 and others added 12 commits July 17, 2026 17:21
Rule #1 — document the new capability before implementing it. Adds
§17.2a beside EVT-001: backend emits agent.task.completed/failed at every
CAS-won terminal, matching-sub gated, reserved namespace + recursion-break,
EVT-001 dispatch (best-effort to a running subscriber), inert by default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…it helper (#1578)

New services/event_dispatch_service.py:
- trigger_subscription / _interpolate_template / _get_internal_token moved
  verbatim out of routers/event_subscriptions.py so a service (TES) can reuse
  the EVT-001 dispatch primitive without importing a router (Invariant #1).
- emit_task_terminal_event + spawn_task_terminal_event (#1578): the single
  shared helper the backend fires at every CAS-won terminal — matching-sub
  gated (empty => no row, no dispatch), recursion-break on triggered_by='event',
  flat interpolable payload incl. fan_out_id/loop_id, fail-open.
- trigger_subscription stamps the reserved-namespace loopback with
  X-Event-Trigger so the spawned task persists triggered_by='event'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- event_subscriptions.py: import the extracted event_dispatch_service (delete
  the moved defs); reject agent emits into agent.task.* on both emit routes
  (400); block reserved-namespace self-subscription on create AND update (400);
  dispatch via event_dispatch_service.trigger_subscription.
- chat.py: read X-Event-Trigger on /task; a reserved-namespace dispatch persists
  triggered_by='event' (threaded via triggered_by_override) so the emit helper
  suppresses that task's own terminal event — the recursion-break.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- apply_result SUCCESS won path -> agent.task.completed (sanitized_resp, cost,
  duration); covers both #1083 paths (inline sync + async result-callback both
  converge here).
- apply_result FAILURE won path -> agent.task.failed (envelope.status carries
  failed/cancelled; salvage cost).
- _write_terminal_and_gate gains agent_name and emits agent.task.failed on won —
  covering the timeout / budget-exhausted / unexpected-exception (+ inline
  circuit-open/capacity/ephemeral) terminals that never reach apply_result, the
  exact wedge case the feature exists for. agent_name threaded from all 4
  execute_task call sites.

All emits are CAS-won only, fire-and-forget, fail-open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…als (#1578)

_sweep_stale_slots' two per-execution CAS-won sites (async lease expired with no
result callback) now emit agent.task.failed — waking a subscribed orchestrator
on the exact wedge the feature exists for. Fire-and-forget + fail-open; the bulk
watchdog sweeps stay a documented residual (no per-row context).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
apply_task_result's CAS-won path now emits agent.task.completed/failed. Dark
until a pull pilot is enabled (#1081) but wired so a pilot doesn't silently
regress report-back. CAS-won only (replay/late report short-circuits or loses
the CAS), fire-and-forget + fail-open. Coordinate with obasilakis (his file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Invariant #13 doc-only: emit_event notes agent.task.* is reserved/backend-emitted
(rejected if emitted); subscribe_to_event notes you can subscribe to a source
agent's agent.task.completed/failed for an automatic report-back task instead of
polling, with the payload shape and the no-self-subscription rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#1578)

27 tests, all green:
- Emit helper: fired (success/failed/cancelled), not-fired-no-sub (AC #1/#5),
  recursion-break, status-as-.value (#1085 footgun), payload shape
  (fan_out_id/loop_id), duration/cost row fallback, truncation, fail-open.
- Every CAS-won terminal writer spawns on won / not on lost CAS: apply_result
  inline path, _write_terminal_and_gate (timeout class — the critical pin), the
  #1083 lease-reaper, and the pull sink.
- Both #1083 terminal paths GENUINELY (dossier §5): inline apply_result call AND
  the async result-callback endpoint driving the REAL apply_result — distinct
  entry points, not two tests on the same inline call.
- Reserved-namespace guards: emit 400, create self-sub 400, PUT self-sub 400,
  cross-agent subscribe allowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1578)

New feature-flows/task-completion-events.md: end-to-end report-back path, the
terminal-writer coverage table, system- vs agent-emitted split, 3-layer loop
safety, honest best-effort delivery note, content-trust note, test map. Adds the
Recent Updates row + category index entry (feedback: always add the index row),
and cross-link pointers from task-execution-service.md and
agent-event-subscriptions.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New 'Task Completion Events (#1578)' subsystem block: system- vs agent-emitted,
  shared event_dispatch_service helper, CAS-won terminal-writer coverage table,
  3-layer loop safety, honest best-effort delivery note.
- Pointer from Fire-and-Forget Dispatch (#1083); services-catalog row for
  event_dispatch_service.py; reserved-namespace note on the events.ts MCP row +
  the agent_events DDL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nted (#1578)

Mark the agent.task.completed/failed contract IMPLEMENTED, note the divergence
(EVT-001 subscription dispatch, best-effort to a running subscriber; durable
pull-queue delivery deferred; not the WS event_bus) and the pull-sink coverage.
Add a '├─ Bankable win 3' #1578 row under the #1081 umbrella.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t summary (#1578)

Two review findings on the #1578 completion-event path, both pre-merge:

1. Spoofable recursion-break. The reserved-namespace loopback was suppressed
   purely on an `X-Event-Trigger` header, so an external `/task` caller could
   spoof it to suppress a real agent's completion event. The header is now
   honored ONLY with a valid backend-internal `X-Internal-Secret` (C-003):
   `trigger_subscription` stamps both, `execute_parallel_task` verifies via
   `event_dispatch_service.verify_internal_dispatch_secret` (constant-time)
   before persisting `triggered_by="event"`.

2. Un-sanitized failure summary. Success/pull terminals sanitize upstream, but
   the failure error strings (`envelope.error` / `str(exc)`) reached the emit
   helper raw. `emit_task_terminal_event` now credential-sanitizes at the single
   chokepoint over a 2x-cap window BEFORE the final truncation, so a secret
   straddling the `TASK_EVENT_SUMMARY_MAX` boundary can't leak an un-redactable
   head fragment.

Tests: +7 (spoof-guard at the helper AND the router call-site; sanitize incl.
the boundary-straddle case) -> 37 pass. Docs: architecture.md delivery note +
feature-flow recursion-break/second-terminal notes updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review July 17, 2026 17:19
…e-flows index rows (#1578 + #1593)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Validated via /validate-pr. Well-shaped first system-emitted event producer: emit strictly on CAS-won branches (exactly-once, replay-safe), every terminal writer covered (apply_result ×2, _write_terminal_and_gate, lease-reaper, pull sink), recursion-break authenticated with the C-003 internal secret (constant-time) so an external caller can't spoof suppression, reserved-namespace guards on emit + self-sub (create AND update), credential-sanitized summary over a 2×cap window, fail-open throughout. Dispatch primitives extracted verbatim to the service layer (Invariant #1); events.ts descriptions updated (Invariant #13). 37 tests incl. spoof + boundary-straddle. Conflict with dev (feature-flows index) resolved by merge keeping both rows; 52 tests re-run green on the merged tree. All checks green.

@vybe
vybe merged commit be131ad into dev Jul 17, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants