feat(events): system-emitted agent.task.completed/failed at execution terminal (#1578)#1680
Conversation
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>
vybe
left a comment
There was a problem hiding this comment.
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.
Fixes #1578
What
The backend now deterministically emits
agent.task.completed/agent.task.failedat 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 pollingget_execution_result. Implements the missing half ofTARGET_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, reservedagent.task.*namespace.How
services/event_dispatch_service.py::emit_task_terminal_event(+ strong-refspawn_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 ofrouters/event_subscriptions.pyso the service layer no longer imports a router (Invariant Fix: Add missing Docker labels to system agent container #1, verified cycle-free).apply_result(success + failure),_write_terminal_and_gate(timeout/budget/crash + inline circuit-open/capacity/ephemeral — the wedge cases that never reachapply_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 onapply_result.wonCAS branch — a replayed/late refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083 callback or lease-expiry race fires nothing.emit_eventintoagent.task.*(400); reserved self-subscription blocked on create and update (400); recursion-break — a task spawned by a reserved-namespace dispatch is persistedtriggered_by="event"and the helper suppresses re-emission, breaking self / A↔B / A→B→C→A cycles at the root.{execution_id, status, triggered_by, summary_or_error, duration_ms, cost, fan_out_id, loop_id}— flat,{{payload.field}}-interpolable;statusis the string value.fan_out_id/loop_idcarried for the future pull fan-out join envelope.agent_events/agent_event_subscriptions+ the existingtriggered_bycolumn — no schema change, no migration, no flag, no config. Fail-open: a broken emit never touches the billed terminal.Security hardening (final commit)
X-Event-Triggeris honored only with a valid backend-internalX-Internal-Secret(C-003, constant-time) — an external/taskcaller can't spoof it to suppress a real completion.summary_or_erroris credential-sanitized at the single emit chokepoint over a 2×cap window before truncation, so a secret straddlingTASK_EVENT_SUMMARY_MAXcan'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 theagent_eventsrow persists. Durable "lands in the caller's queue" delivery is the pull migration's job — the same names + payload carry over unchanged.Acceptance criteria
apply_resultdispatches matching subs with templated payload; no matching sub ⇒ no row/dispatch (AC Fix: Add missing Docker labels to system agent container #1, Setup improvements #5)subscribe_to_eventunchanged; permission gate holds; self-sub to reserved namespace blocked (AC Feature/vector log retention #3)apply_resultcovered — sync inline + refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083 async callback + timeout/crash writer + lease-reaper + pull sink (AC fix: add missing logging_config.py to backend Dockerfile #4)events.tsnotes the reservedagent.task.*namespace (AC Feature/process engine #6)TARGET_ARCHITECTURE.md§Async-First reconciled → IMPLEMENTED with event-bus→EVT-001 divergence note; Epic Epic: Agent Infrastructure #1045 table row added under feat: pull / work-stealing coordination (push→pull dispatch) #1081 (AC security: Fix token logging and add HTML reports to gitignore #7)Tests
tests/unit/test_1578_task_completion_events.py— 37 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. Adjacenttest_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 entriesdocs/memory/feature-flows/task-completion-events.md(new) + index Recent-Updates row + catalog entry + back-linksdocs/memory/requirements/roadmap.md§17.2adocs/planning/TARGET_ARCHITECTURE.md§Async-First + Epic Epic: Agent Infrastructure #1045 tablesrc/mcp-server/src/tools/events.ts— reserved-namespace note on both tools🤖 Generated with Claude Code