Skip to content

feat(agentex): materialized Task.current_state for reactive state observability#372

Draft
qwhex wants to merge 5 commits into
mainfrom
feat/task-current-state-observability
Draft

feat(agentex): materialized Task.current_state for reactive state observability#372
qwhex wants to merge 5 commits into
mainfrom
feat/task-current-state-observability

Conversation

@qwhex

@qwhex qwhex commented Jul 22, 2026

Copy link
Copy Markdown

Summary

Adds a materialized, opaque Task.current_state label 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 stays RUNNING whether the agent is mid-turn or idle waiting for input. An agent's StateMachine current 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

  • Source of truth — nullable Task.current_state: str | None column, read authoritatively via GET /tasks/{id}.
  • Reactivity — written through the existing UPDATE_TASK / PUT /tasks/{id} path, which already publishes a task_updated SSE event carrying the full task. So reactivity is free; a lost live event heals on the next reconnect's point-read.
  • General & semantics-free — opaque str, default-null, no per-app config, no idle/working interpretation in the framework. Not coupled to StateMachine; any agent can write any label. Orthogonal to task.status.

Changes

  • ORM column (TaskORM.current_state) + Alembic migration — nullable add_column, down_revision=a1b2c3d4e5f6 (passes migration-lint: only nullable=False adds are blocked)
  • Task response schema, UpdateTaskRequest, TaskEntity + convert_task_to_entity
  • update_mutable_fields_on_task applies current_state in the same update_task write as task_metadata → a single task_updated publish (not routed through merge_params, which does not publish)
  • both PUT routes (/{id}, /name/{name}) thread it through
  • regenerated openapi.yaml

Backward compatibility

Additive + nullable + default-null → existing tasks and stream consumers unaffected. New SDK sending current_state to an old server is ignored (extra="ignore"), pinned by a test.

Tests

  • Route: PUT sets current_state; null/omitted → 200; metadata-only update does not clobber it
  • Stream: task_updated carries current_state
  • Service unit: emit persists + publishes with current_state
  • Compat: UpdateTaskRequest ignores unknown fields (guards the extra="ignore" assumption)

Note: unit/integration tests here use Docker/testcontainers via conftest, not provisioned in my local env — they compile and follow existing patterns but were not executed. openapi regen (imports the whole app), migration-lint, and ruff on src/ all pass locally.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the agentex-sdk SDKs with the following commit messages.

openapi

feat(api): add current_state field to task responses and update request

python

feat(api): add current_state parameter to tasks update methods

typescript

feat(api): add current_state field to tasks resource

Edit this comment to update them. They will appear in their respective SDK's changelogs.

agentex-sdk-python studio · code · diff

Your SDK build had at least one "warning" diagnostic, but this did not represent a regression.
generate ⚠️build ✅lint ❗test ✅

pip install https://pkg.stainless.com/s/agentex-sdk-python/5cf1213a479195ba76f32c45458defd2c3c5f8ff/agentex_client-0.20.0-py3-none-any.whl
agentex-sdk-openapi studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅

agentex-sdk-typescript studio · code · diff

Your SDK build had at least one "warning" diagnostic, but this did not represent a regression.
generate ⚠️build ✅lint ✅test ✅

npm install https://pkg.stainless.com/s/agentex-sdk-typescript/a8141b923cb34d5e38077ddc016661d502101c8d/dist.tar.gz

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-07-24 20:35:57 UTC

qwhex added 4 commits July 23, 2026 15:33
…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.
@qwhex
qwhex force-pushed the feat/task-current-state-observability branch from a098182 to 0f54477 Compare July 23, 2026 13:33
- 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.
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.

1 participant