feat(agentex): materialized Task.current_state for reactive state observability#372
feat(agentex): materialized Task.current_state for reactive state observability#372qwhex wants to merge 5 commits into
Conversation
✱ Stainless preview buildsThis PR will update the openapi python typescript Edit this comment to update them. They will appear in their respective SDK's changelogs. ✅ agentex-sdk-python studio · code · diff
✅ agentex-sdk-openapi studio · code · diff
✅ agentex-sdk-typescript studio · code · diff
This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push. |
…ervability
Expose an agent's StateMachine current state to REST/SSE consumers via a
nullable, opaque `Task.current_state` label. It is written through the existing
UPDATE_TASK / PUT /tasks/{id} path, which already publishes a `task_updated` SSE
event carrying the full task, so updates are reactive (push) while GET /tasks/{id}
remains the authoritative point-read for load/reconnect reconciliation.
The field is framework-agnostic and default-null: agents opt in by writing it;
existing tasks and stream consumers are unaffected (additive, backward compatible).
- ORM column + Alembic migration (nullable add, down_revision a1b2c3d4e5f6)
- Task response schema, UpdateTaskRequest, TaskEntity + converter
- update_mutable_fields_on_task applies it in the same update_task write as
task_metadata (single task_updated publish)
- both PUT routes thread it through
- regenerated openapi.yaml
- tests: route PUT/null/no-clobber, task_updated carries current_state,
service-layer emit+persist, extra="ignore" forward-compat pin
…ll, bounds Resolve the review findings on the current_state observability change: - Blocker: route current_state (and task_metadata) through a new column-scoped atomic UPDATE (TaskRepository/AgentTaskService.update_mutable_fields) instead of update_task's whole-row session.merge of a stale entity. Touching only the supplied columns means a concurrent status transition or params merge can no longer be silently reverted (terminal task revert / deleted task resurrection / lost param edit). update_task keeps its full-row semantics for the status-writing callers (delete/fail/forward) that need them. - Support clearing current_state: an UNSET sentinel distinguishes an explicit null (clears the label) from an omitted field (left untouched), driven off the request's model_fields_set. - Bound the label: String(255) column + max_length on the request/response schema (new field, no back-compat risk) so it can't amplify unboundedly onto every task_updated SSE payload. Single source of truth: CURRENT_STATE_MAX_LENGTH. - Make the migration idempotent (ADD/DROP COLUMN IF [NOT] EXISTS), width 255. - Align the domain-entity field title with the API schema title. - Fix stale migration-linter path/flag in CLAUDE.md (scripts/ci_tools/migration_lint.py --base). - Tests: single-atomic-write (spy), status no-clobber regression, clear-to-null, by-name route, empty-string, over-length 422, combined update; robust bounded wait in the new stream test. Regenerated openapi.yaml.
- Add the real use-case-level clobber regression test: it forces a stale read (entity fetched RUNNING, task transitioned to COMPLETED before the write) and asserts status stays COMPLETED. This fails on the old whole-row-merge path and passes on the column-scoped update — the prior service-level test could not catch the regression (it exercised the new primitive directly, which is clobber-proof by construction); its docstring is corrected accordingly. - update_mutable_fields_on_task now raises ItemDoesNotExist when the atomic update reports the row vanished, instead of silently returning stale data (consistent with the not-found contract and sibling transition methods; defensive — no live hard-delete path reaches it). - Drop max_length from the response Task.current_state field: input is already bounded by UpdateTaskRequest + the String(255) column, and enforcing it on the read path would turn a future column-widening into a 500 on every read. - Make the repository update_mutable_fields empty-fields branch's contract honest in its docstring (it's an unreachable defensive no-op). - Assert the intermediate set in the clear-to-null test so the clear is a real transition, not a null→null no-op. - Re-await the cancelled collector in the stream test to avoid a dangling-task warning at teardown. - Remove the now-unused `import sqlalchemy as sa` from the migration. - Regenerated openapi.yaml (maxLength now only on the request schema).
Self-review cleanup: tighten the multi-line comments and docstrings added for current_state down to single lines (drop AI-slop narration; keep only the non-obvious why), per the repo's comment-discipline convention.
a098182 to
0f54477
Compare
- Guardrail update_mutable_fields against writing non-mutable columns: an allowlist (task_metadata, current_state) rejects anything else, so a future caller can't route status/params through it and bypass their atomic CAS. - Dedupe merge_params and update_mutable_fields into a shared _update_returning helper (one UPDATE ... RETURNING body); drop the unreachable empty-fields branch that contradicted the None-on-missing contract. - Single-source the current_state bound as CURRENT_STATE_MAX_LENGTH (new src/utils/task_constants.py) so the request-validation limit and the String(...) column width can't drift; both import it. - Correct the sentinel docstring wording (partial update, not PATCH) and ruff-format the two files the formatter had left unwrapped.
Summary
Adds a materialized, opaque
Task.current_statelabel so any REST/SSE consumer (e.g. a browser frontend) can answer "what state is this agent's state machine in?" — reactively and reliably, with a minimal, backward-compatible, semantics-free change.Today the only externally-visible signal is
task.status(the Temporal workflow lifecycle:RUNNING/COMPLETED/…), which staysRUNNINGwhether the agent is mid-turn or idle waiting for input. An agent'sStateMachinecurrent state is known internally (get_current_state(), a@workflow.query) but never escapes the workflow to a client-reachable read path, so every UI reinvents "is the agent working?" on the message stream.This is the server-side slice of the state-observability spec (§B). The SDK auto-emit (§A) and OneEdge frontend (§C) land in their own repos.
Design
Task.current_state: str | Nonecolumn, read authoritatively viaGET /tasks/{id}.UPDATE_TASK/PUT /tasks/{id}path, which already publishes atask_updatedSSE event carrying the full task. So reactivity is free; a lost live event heals on the next reconnect's point-read.str, default-null, no per-app config, no idle/working interpretation in the framework. Not coupled toStateMachine; any agent can write any label. Orthogonal totask.status.Changes
TaskORM.current_state) + Alembic migration — nullableadd_column,down_revision=a1b2c3d4e5f6(passes migration-lint: onlynullable=Falseadds are blocked)Taskresponse schema,UpdateTaskRequest,TaskEntity+convert_task_to_entityupdate_mutable_fields_on_taskappliescurrent_statein the sameupdate_taskwrite astask_metadata→ a singletask_updatedpublish (not routed throughmerge_params, which does not publish)/{id},/name/{name}) thread it throughopenapi.yamlBackward compatibility
Additive + nullable + default-null → existing tasks and stream consumers unaffected. New SDK sending
current_stateto an old server is ignored (extra="ignore"), pinned by a test.Tests
current_state; null/omitted → 200; metadata-only update does not clobber ittask_updatedcarriescurrent_statecurrent_stateUpdateTaskRequestignores unknown fields (guards theextra="ignore"assumption)