Skip to content

feat(decisioning): handoff_to_workflow — externally-completed task primitive#336

Merged
bokelley merged 1 commit intomainfrom
bokelley/decisioning-handoff-to-workflow
May 1, 2026
Merged

feat(decisioning): handoff_to_workflow — externally-completed task primitive#336
bokelley merged 1 commit intomainfrom
bokelley/decisioning-handoff-to-workflow

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 1, 2026

Summary

Third dispatch arm: `ctx.handoff_to_workflow(fn)` for adopter-owned external workflows that complete on their own schedule (human queue review, batch jobs, Airflow DAGs, ML pipelines, scheduled cron).

Naming: `handoff_to_workflow` over `handoff_to_human` — the primitive isn't human-specific; the external completer could be a person, batch job, ML pipeline, or any system that calls back via `registry.complete()`. Matches Camunda / Temporal / Step Functions vocabulary and pairs symmetrically with `handoff_to_task`.

Mental model:

Method Who completes? When?
return `Success` directly Seller (sync) Now
`ctx.handoff_to_task(fn)` Framework runs `fn` in background Within process, seconds-minutes
`ctx.handoff_to_workflow(fn)` Adopter's external system calls `task.complete()` later Hours-days, outside framework

Wire-shape parity: all three async paths project to `{task_id, status: 'submitted'}`. Buyers can't tell which path the seller took.

Rollback: if the enqueue fn raises, the just-allocated task_id is discarded from the registry via the new `TaskRegistry.discard()` method. Buyer never sees a Submitted envelope referencing an orphan id.

TaskHandoff docstring update: drops the `handoff_to_human` v4.5.0 forward-promise (now obsolete since the primitive ships in 4.4.0); points adopters at `handoff_to_workflow` for queued-approval flows.

New surfaces

  • `adcp.decisioning.WorkflowHandoff` marker class (type-identity dispatch, subclass rejection — same posture as `TaskHandoff`)
  • `RequestContext.handoff_to_workflow(fn)` method
  • `TaskRegistry.discard(task_id)` Protocol method + `InMemoryTaskRegistry.discard()` impl
  • `adcp.decisioning.dispatch._project_workflow_handoff()` projection
  • `_invoke_platform_method` checks both markers

Test plan

  • 14 new tests in `tests/test_decisioning_workflow_handoff.py` covering: marker shape, wire-shape parity, sync + async enqueue, rollback on exception (sync + async paths), registry state, external completion via `registry.complete()`/`fail()`, end-to-end via `_invoke_platform_method`, no background coroutine spawned (distinct from TaskHandoff), public exports
  • One existing test updated: duck-typed stub now declares `discard` to satisfy the Protocol
  • `pytest tests/` — 2266 passed (up from 2252)
  • `mypy src/adcp/decisioning/` clean across 22 source files
  • Pre-commit gates pass

Release plan

10th PR accumulating into the held release-please PR #328 alongside foundation (#316), codemod ergonomics (#329), parity rename (#330), F12 (#331), and the four breadth-sprint specialism batches (#332#335). Ships in 4.4.0 once salesagent validates.

🤖 Generated with Claude Code

…imitive

Adds a third dispatch arm distinct from sync return and TaskHandoff:
``ctx.handoff_to_workflow(fn)`` for adopter-owned external workflows
that complete on their own schedule (human queue review, batch jobs,
Airflow DAGs, ML pipelines, scheduled cron).

Naming choice: ``handoff_to_workflow`` over ``handoff_to_human``.
The primitive isn't human-specific — the external completer could
be a person, a nightly batch job, an ML pipeline, or any
adopter-owned system that calls back via ``registry.complete()``
later. ``workflow`` matches industry-standard vocabulary (Camunda,
Temporal, Step Functions) and pairs symmetrically with the existing
``handoff_to_task`` (framework's task vs. adopter's workflow).

Mental model:

* Sync return — Seller answers immediately.
* ``handoff_to_task(fn)`` — Framework runs ``fn`` in background;
  ``fn`` returns terminal artifact within seconds-to-minutes.
* ``handoff_to_workflow(fn)`` — ``fn`` runs ONCE to register work
  into adopter's external system; framework persists ``submitted``
  state and returns wire envelope; adopter's external system later
  calls ``registry.complete()`` / ``registry.fail()`` directly when
  the work finishes (hours-to-days).

Wire-shape parity: all three project to ``{task_id, status:
'submitted'}`` for the async paths. Buyers can't tell which path
the seller took.

New surfaces:

* ``adcp.decisioning.WorkflowHandoff`` marker class +
  ``is_workflow_handoff()`` type-identity dispatch helper.
* ``RequestContext.handoff_to_workflow(fn)`` method.
* ``TaskRegistry.discard(task_id)`` Protocol method +
  ``InMemoryTaskRegistry.discard()`` implementation. Used by the
  workflow projection's rollback path — if enqueue fn raises, the
  just-allocated task_id is removed so the buyer never sees an
  orphan id.
* ``adcp.decisioning.dispatch._project_workflow_handoff()``.
  Allocates task_id via ``registry.issue``, calls enqueue fn (sync
  via executor + contextvars snapshot, async awaited inline), rolls
  back via ``registry.discard`` on ``BaseException``, returns
  Submitted envelope. NO background coroutine.
* ``_invoke_platform_method`` checks both markers; routes to the
  matching projection.

TaskHandoff docstring updated: drops the ``handoff_to_human`` v4.5.0
forward-promise (now obsolete — ``handoff_to_workflow`` ships in
4.4.0). Points adopters at the new primitive for queued-approval
flows.

Public exports: ``WorkflowHandoff`` added to
``adcp.decisioning.__all__``.

Test coverage in ``tests/test_decisioning_workflow_handoff.py`` (14
new tests):

* Marker shape: type-identity dispatch, subclass rejection.
* Wire-shape parity: Submitted envelope identical to TaskHandoff.
* Sync + async enqueue both supported.
* Rollback: enqueue exception → ``registry.discard()`` → no orphan
  task_id reaches the buyer (sync + async paths).
* Registry persists ``submitted`` state correctly.
* External completion via ``registry.complete()`` / ``registry.fail()``.
* End-to-end via ``_invoke_platform_method``.
* No background coroutine spawned (distinct from TaskHandoff).
* Public exports present.

One existing test updated:
``tests/test_decisioning_task_registry.py::test_custom_registry_satisfies_protocol_via_duck_typing``
declares ``discard`` on its stub since the Protocol added it.

2266 tests pass (up from 2252).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit bff48ab into main May 1, 2026
12 checks passed
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