Repository navigation
feat(decisioning): lifecycle observer hook on PgTaskRegistry / TaskRegistry for task metrics #1296
Description
Summary
On 8.0.0, PgTaskRegistry has no way to observe task lifecycle transitions. Its constructor (adcp/decisioning/pg/task_registry.py:135-163) accepts only pool, task_webhook_outbox, webhook_signing_scope_resolver and a private _table. issue, update_progress, complete and fail all return None. An adopter therefore cannot count tasks created, completed or failed, or measure time to terminal, without forking or subclassing the registry. PgBuyerAgentRegistry already has this kind of hook (add_mutation_observer, decisioning/pg/buyer_agent_registry.py:371-408), and the task registry has no equivalent.
Details
Subclassing and overriding the write methods doesn't produce correct metrics:
complete()returnsNoneboth when it performs theworking -> completedtransition and when it is an idempotent repeat with an equal result (task_registry.py:403-454; the repeat path is at 449-452). A wrapper can't tell the first terminal transition from a retry, so it double-counts.update_progress()silently no-ops on unknown or terminal tasks, and doesn't report whethersubmitted -> workinghappened (task_registry.py:376-402).issue()takes eight keyword arguments plus**_extra, five of them for webhook registration. A subclass has to mirror that signature and keep it in sync on every upgrade.
Only the registry knows, inside its transaction, whether a row actually changed state. That makes it the right place for the hook.
Why an adopter hits this
Embedded Sales Agent moved async get_products onto the SDK's PgTaskRegistry (core/decisioning/task_registry.py). Its earlier registry emitted get_products_async_tasks_created_total, ..._completed_total and ..._failed_total. Those series are now kept at zero on purpose, so existing dashboards don't show no-data alerts. src/core/metrics.py (~lines 87-106) states the reason: "The exact SDK PgTaskRegistry has no lifecycle-observer hook, so these counters intentionally remain at zero." Operators have lost visibility into async task throughput and failure rate. The only metric ESA still records, task expiry, comes from its own tasks_get shim, not the registry.
Ask
Add an observer seam that both InMemoryTaskRegistry and PgTaskRegistry honor. For example:
TaskTransition = Literal["issued", "working", "completed", "failed", "discarded"]
class TaskLifecycleObserver(Protocol):
def __call__(self, event: TaskTransition, *, task_id: str, account_id: str,
task_type: str, created_at: float, updated_at: float) -> None: ...
PgTaskRegistry(pool=..., observers=[my_observer]) # or registry.add_lifecycle_observer(...)The contract:
- Fire only after commit.
- Fire only when a row actually changed state, never on idempotent repeats or silent no-ops.
- Log and swallow observer exceptions, as
add_mutation_observerdoes.
A smaller fallback: have complete, fail and update_progress return a bool saying whether the transition was applied, so a thin subclass can count correctly.
Activity
bokelley commentedon Oct 2, 2026
Triage
Classification: Feature request
Bucket(s): client (no matching repo label — gap noted)
Status: ready-to-implement
What the experts said:
TaskRegistryProtocol (@runtime_checkable); three impl blockers: mutableobservers=[]default (useNone), Pgupdate_progressSQL needsRETURNINGold state to distinguishsubmitted→workingfrom idempotent,discardSQL needsRETURNING task_idfor no-op detection;bool-return fallback is breaking — drop it.add_lifecycle_observer()/remove_lifecycle_observer()(mirrorsPgBuyerAgentRegistry) over constructor param; fire observers outside DB transaction and asyncio lock; rename"issued"→"submitted"inTaskTransitionto matchTaskStatevocabulary.My take: Additive feature with direct codebase precedent. Experts converge on API shape and impl requirements with no unresolved disagreements.
Implementation scope:
TaskLifecycleObserverProtocol +TaskTransition = Literal["submitted", "working", "completed", "failed", "discarded"]totask_registry.py; addadd_lifecycle_observer/remove_lifecycle_observerto both concrete classes (not toTaskRegistryProtocol)(event, *, task_id, account_id, task_type, created_at, updated_at); log-and-swallow (match_notify_mutationatpg/buyer_agent_registry.py:371)update_progress→RETURNING stateto detect realsubmitted→working;discard→DELETE … RETURNING task_id;InMemoryTaskRegistry.discard→ checkpop()returnpytest tests/test_decisioning_task_registry*,mypy src/adcp/decisioning/,ruff check . && black --check .Triaged by Claude Code. Session: https://claude.ai/code/session_011567bv6By9jafEm1bK8Qd7
Generated by Claude Code
bokelley commentedon Oct 4, 2026
Fixed by #1323 (squash 9a70217): committed task lifecycle transitions now notify registered observers after commit, outside locks, without duplicate or no-op events.