The backend deterministically emits agent.task.completed / agent.task.failed
at every CAS-won execution terminal of a task-path run, delivered over the existing EVT-001
subscription-dispatch machinery. The one exception is the synchronous /chat route
(chat_execution_service.run_chat_turn — a sequential chat_with_agent, unless the #946
pull pilot reroutes it through /task): it writes its terminal without emitting. A caller/orchestrator that subscribed to a worker's
agent.task.* events is woken with an automatic 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 and is a down-payment on Epic
#1045 → #1081 (pull coordination).
Additive & inert by default: zero matching subscriptions ⇒ zero agent_events
rows, zero dispatch, unchanged behavior. No schema change, no migration, no feature
flag, no new config.
EVT-001 (agent-event-subscriptions.md) carries only
agent-emitted events: an agent's LLM calls the emit_event tool, and the emit route
derives source_agent from the MCP auth context. Every event before #1578 was "an
agent's LLM decided to emit."
These are the first system-emitted events: synthesized by the deterministic backend
chokepoint with no LLM in the loop, source_agent = the executing agent, in the
reserved agent.task.* namespace. Same tables (agent_events,
agent_event_subscriptions), same find_matching → trigger_subscription delivery;
different producer.
As an orchestrator agent, I dispatch a long task to a worker
(chat_with_agent(parallel=true, async=true)), my turn ends, and the worker finishes
minutes later. I want to be woken with a report-back task carrying the worker's
outcome — so I can report to the human — instead of holding a coroutine or polling.
- Producer (backend, deterministic): every CAS-won terminal writer — no user-facing entry. See the coverage table below.
- Consumer:
subscribe_to_event(source_agent=<worker>, event_type="agent.task.completed" | "agent.task.failed", target_message="…{{payload.status}}…")(MCP) orPOST /api/agents/{orchestrator}/event-subscriptions. Requires the standard EVT-001 permission (subscriber must be permitted to call the source agent). Cross-agent only — self-subscription toagent.task.*is blocked.
Every terminal writer gates on a db.update_execution_status(...) CAS returning a won
bool. The shared helper is invoked only on won, fire-and-forget, fail-open. This is
the invariant: any future terminal writer that bypasses the helper silently won't emit.
| Terminal writer | Terminals | File | Emits |
|---|---|---|---|
apply_result SUCCESS branch |
success | services/task_execution_service.py (won-only side effects) |
agent.task.completed |
apply_result failure branch |
agent-HTTP-error, CANCELLED, SKILL_NOT_FOUND | same (if won:) |
agent.task.failed |
_write_terminal_and_gate |
timeout, budget-exhausted, unexpected-exception (+ circuit-open/capacity/ephemeral inline) | services/task_execution_service.py (if won and agent_name:) |
agent.task.failed |
| #1083 lease-reaper (async died) | LEASE_EXPIRED | services/cleanup_service.py::_process_stale_slot_reclaims (both if updated: sites) |
agent.task.failed |
pull sink apply_task_result |
success/failure | services/pull_coordination_service.py (if won:) — dark until a pull pilot |
completed/failed |
| bulk watchdog sweeps (#1714) | stale / no-session | cleanup_service.py::_sweep_stale_executions / _sweep_no_session_executions → _emit_bulk_terminal_events |
agent.task.failed per CAS-won row — subscriber-gated (has_task_terminal_subscribers() short-circuit + per-agent matching in the helper), paced in batches (no herd, no drop), fail-open |
Both #1083 caller paths (inline sync + async result-callback) converge on apply_result,
so both are covered there. The async-callback replay-ACK (_AUTHORITATIVE_TERMINALS,
routers/agents.py) + the CAS block guarantee no double-emit for a replayed or
CAS-lost terminal (AC #2).
Sibling spawn at the same chokepoints (ent#224/ent#265).
channel_completion_report.spawn_completion_report now rides beside
spawn_task_terminal_event at the CAS-won apply_result success branch, the
apply_result failure/cancel branch (added by ent#265 D3 — it previously
emitted the event but never the channel report), and _write_terminal_and_gate
— reporting a delegated/background terminal back into its originating
Slack/Telegram chat. Same discipline: won-only, fire-and-forget, fail-open,
its own at-most-once via effect_guard (#1084). The lease-reaper, bulk
sweeps, and pull sink emit the #1578 event but do NOT spawn the channel report
(v1 boundary). See channel-completion-report.md.
Known second-terminal correction (not a replay double-fire). _AUTHORITATIVE_TERMINALS
is {SUCCESS, CANCELLED, SKIPPED} — deliberately excluding FAILED so the #1083
"late SUCCESS corrects a reaper LEASE_EXPIRED" path stays open. In that narrow race the
lease-reaper first CAS-wins RUNNING→FAILED (emits agent.task.failed), then a late
result callback CAS-wins FAILED→SUCCESS (emits agent.task.completed) — two distinct
CAS-won terminals ⇒ two events for one execution_id. Both wins are legitimate (genuinely
changing state, not a replay), so the CAS gating is correct; the subscriber is woken twice
and MUST treat a later agent.task.completed for the same execution_id as superseding
an earlier agent.task.failed. The flat payload's execution_id is the correlation key that
makes this de-dup possible on the subscriber side.
[terminal writer] ── CAS won? ──▶ spawn_task_terminal_event(agent, eid, terminal_status, summary, ...)
│ (fire-and-forget, fail-open)
▼
emit_task_terminal_event (services/event_dispatch_service.py)
1. read the execution row once
2. recursion-break: row.triggered_by == "event" ⇒ return (no emit)
3. find_matching_event_subscriptions(agent, event_type)
└─ empty ⇒ return (NO agent_events row, NO dispatch) ── AC #1/#5
4. build flat payload {execution_id, status, triggered_by,
summary_or_error, duration_ms, cost, fan_out_id, loop_id}
5. create_agent_event(...)
6. for each sub: trigger_subscription(sub, event)
│ loopback POST /api/agents/{subscriber}/task
▼ (async, admin JWT, X-Event-Trigger tag)
subscriber's queue ── report-back task
emit_task_terminal_event(agent_name, execution_id, *, terminal_status, summary_or_error, duration_ms, cost)— async, fail-open (whole body try/except-swallowed). Matching-sub gated; recursion-break; readstriggered_by/fan_out_id/loop_id(+ duration/cost fallback) from the row once. Passeschain_depth = row.chain_depth + 1to each dispatch (#2973) — the finished row is no longer running, so the subscriber's/taskcould not derive it — and the max depth when the row read fails.spawn_task_terminal_event(...)— sync strong-refasyncio.create_taskwrapper; every terminal writer calls this one wrapper (no per-module spawner, noawait).- Status → event.
terminal_status == SUCCESS→agent.task.completed; elseagent.task.failed. Branch on the status string, never onTaskExecutionErrorCodeidentity (fieldless-@dataclass__eq__returns True for any two members — #1085 footgun). The payloadstatusisenvelope.status.value(a plain(str, Enum)), so{{payload.status}}interpolates as"success", not"TaskExecutionStatus.SUCCESS". - Extraction (Invariant #1).
trigger_subscription/_interpolate_template/_get_internal_tokenmoved verbatim out ofrouters/event_subscriptions.pyso a service (task_execution_service) can reuse the dispatch primitive without importing a router. The router imports them back. Verified cycle-free.
RESERVED_EVENT_PREFIX = "agent.task.".
- Reject reserved emit — both emit routes (
emit_event,emit_event_for_agent) 400 onagent.task.*(agents cannot spoof the deterministic backend contract). - Block reserved self-subscription —
source == subscriberunderagent.task.*is rejected on create AND update (400). The PUT guard closes the bypass where a benignfoo.barself-sub is updated intoagent.task.completed. - Recursion-break (the decisive one) —
trigger_subscription, when the dispatched event is reserved-namespace, stamps the loopback/taskwithX-Event-Triggerplus a backend-internalX-Internal-Secret(C-003);routers/chat.pypersists that spawned execution'striggered_by = "event"(RESERVED_EVENT_TRIGGER, already a reserved value in_AUTONOMOUS_TRIGGERS) only when the internal secret verifies — so an external/taskcaller spoofingX-Event-Triggeralone cannot suppress a real agent's completion event (event_dispatch_service.verify_internal_dispatch_secret, constant-time). The emit helper then suppresses re-emission when the terminating execution carries the tag. Breaks self / A↔B / A→B→C→A auto-emit cycles at the root — the autonomous-runaway class deterministic backend auto-emit would otherwise introduce (each hop = a full LLM turn + spend). A benignfoo.bar→completion chain is unaffected (its task keeps its normaltriggered_by).
Delivery reuses EVT-001 subscription dispatch (trigger_subscription → HTTP loopback
POST /api/agents/{subscriber}/task, async, minted with a short-lived admin JWT). It wakes
a subscriber whose container is running — including a #1402 parked-but-running
orchestrator. It is NOT durable: a stopped subscriber's loopback returns 503 and is
swallowed — the agent_events row persists, the wake does not. The durable "reply lands in
the caller's queue" successor is the pull migration's queue (Epic #1045/#1081). The
loopback + admin-JWT internals are pull-transitional (a service self-calling its own
HTTP API, backend-pinned coroutine, admin scope) — NOT a stable contract; pull replaces
them.
It is deliberately not the WS event_bus (RELIABILITY-003) — a broadcast can't wake a
parked/stopped agent; a queued task can.
summary_or_error (the worker's response / error, sanitize_response-cleaned +
truncated to TASK_EVENT_SUMMARY_MAX ≈ 2000) is injected into the subscriber's task prompt
for a richer wake. sanitize_response strips credentials, not prompt-injection — this
is the same interpolation surface EVT-001 already exposes for agent-emitted payloads, now
produced deterministically. Flagged here honestly rather than removed (per the issue spec).
Reuses the EVT-001 tables — no schema change:
agent_events— one row per emitted terminal event (only when a subscription matched).agent_event_subscriptions— the subscriptions (UNIQUE(subscriber_agent, source_agent, event_type)).- The recursion-break tag reuses the existing
schedule_executions.triggered_byTEXT column (value"event").
tests/unit/test_1578_task_completion_events.py (27 tests, pure unit — no live backend):
- Emit helper — fired (success/failed/cancelled → right event type + payload), not-fired
on no matching sub (AC #1/#5), recursion-break (
triggered_by="event"⇒ no emit), status-as-.value, payloadfan_out_id/loop_id, duration/cost row fallback, summary truncation, fail-open (db raises ⇒ no propagation),execution_id=Noneno-op. - Every CAS-won writer spawns on won / not on lost CAS:
apply_resultinline path (success + failure),_write_terminal_and_gate(the timeout class — the critical regression pin), the #1083 lease-reaper (_process_stale_slot_reclaims), the pull sink (apply_task_result, incl. replayed-terminal ⇒ no emit). - Both #1083 terminal paths, genuinely — an inline
apply_resultcall AND the async result-callback endpoint (agent_execution_result) driving the realapply_result→ emit. Two distinct entry points, not two tests on the same inline call. - Reserved-namespace guards (router coroutines) — emit
agent.task.completed→ 400; create self-sub → 400; PUT benign self-sub → reserved → 400; cross-agent subscribe → allowed.
Run: pytest tests/unit/test_1578_task_completion_events.py -q.
- agent-event-subscriptions.md — the EVT-001 pub/sub this reuses for delivery (agent-emitted events; the producer contrast).
- task-execution-service.md —
apply_result+_write_terminal_and_gate, the terminal writers this hooks; the #1083 async dispatch + result-callback path both terminal paths converge on. - redelivery-governor.md — the #1085 correlated-failure controls over the same #1083 re-delivery path.