feat(workflows): task execution telemetry and structured status - #187
Draft
Tsuyoshi Ushio (TsuyoshiUshio) wants to merge 9 commits into
Conversation
Tsuyoshi Ushio (TsuyoshiUshio)
force-pushed
the
tsuyoshiushio-workflow-timeout-continuation
branch
from
August 29, 2026 21:10
2fce92a to
f78d313
Compare
Records the third stacked slice of the task execution policy in FRD 0004: the Activity-owned span, the versioned structured status, and the compatibility boundary between them. Decisions 82-89 capture the resolutions from the independent architecture review: telemetry is emitted per Activity delivery rather than per orchestrator scheduling decision, the disposition reports what the Activity did rather than what Durable will decide, counters carry only low-cardinality dimensions, failure spans are classified explicitly because terminal failures are returned, the new status version is gated on the persisted execution policy, a continued node keeps its scheduler state so the shipped aggregate contract is untouched, the node that ends a workflow is published before the failure propagates, and per-instance failure detail is capped deterministically. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds a `workflow.task.activity` span helper plus `azure_functions_agents.workflow_task.attempts` and `.outcomes` counters. The counters take a deliberately narrow dimension set. Workflow ids, node instance ids, target names, and handler-authored error codes are unbounded, so a counter keyed on them would create one time series per task attempt; they stay on the span, where per-attempt cardinality is the point. `complete()` sets the span's error status explicitly because a workflow task failure is *returned* rather than raised, so the span would otherwise close successfully for every terminal failure. `_ensure_metrics` now creates each instrument in its own guard. A meter that rejects one instrument previously nulled every counter in the runtime. `bounded_attribute` collapses control characters in identifier-like attributes, which `bounded_content` (sized for model content) does not. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Wraps a policy-aware delivery in the `workflow.task.activity` span. Telemetry belongs in the Activity, not the orchestrator: an orchestrator re-executes from history on every replay, so a span opened there would be re-emitted for work that already happened, while this body runs exactly once per delivery. The attempt loop is split out of `invoke_policy_handler` into `_run_policy_attempt`, which classifies every failure and returns it instead of raising. The caller records the outcome and closes the span before raising the private Durable retry marker, so a declared retry is not recorded as an unhandled span exception on every attempt. `disposition` reports what the Activity did -- `return_result`, `request_durable_retry`, `return_failure`, `abort` -- never what Durable will decide, which the Activity cannot see. The attempt number stays absent for the same reason (Decision 73). `early_policy_outcome_with_telemetry` covers the deliveries that never reach a handler, reading attributes defensively because the input behind a handler_contract outcome is by definition untrusted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…dler Authorization denials, unregistered targets, and malformed persisted input return a policy-aware failure without ever invoking a handler, so they would otherwise be the only Activity deliveries with no span at all -- exactly the deliveries an operator most needs to see. Also passes the Sub Agent target type through to the delivery span so the two Activities are distinguishable in a query. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A dynamically controlled plan whose persisted input froze at least one
execution policy now publishes schema_version 3. A plan that froze none keeps
emitting version 2 byte-identically, so an older history replays unchanged and
a version 2 client is unaffected.
Version 3 adds retry_driver="durable", the counts pending / failed /
failed_continued, the per-node max_attempts, and the sanitized
last_failure_kind / last_error_code of a continued failure. Attempts in flight
stay absent: Durable owns the budget and a replayed orchestration cannot
observe it, so the status discloses the declared budget and the driver rather
than claiming observability it does not have.
failed_continued is a reporting projection, not a scheduler state.
_aggregate_dynamic_node copies the instance state into the persisted
{index, status, result} aggregate, so a continued instance stays completed and
carries private metadata the status reads instead. That metadata is recorded
where the continuation is decided rather than inferred from the committed
result, because a handler may legitimately return a failed key of its own.
The node whose failure ends a workflow is now marked and published before the
exception propagates, so the last status names it instead of freezing on the
pre-failure snapshot. An opaque Durable failure carries no sanitized cause, so
none is attributed.
Per-instance failure detail is capped in (node id, index) order: MAX_NODES
instances with maximal failure fields would approach Durable's custom-status
size limit, and an order-based cap keeps the status a pure function of
persisted history.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The chat card accepts schema_version 3 and renders its new counts, the frozen attempt budget, and the stable error code of a continued failure. A continued instance counts toward its for_each node's progress because it ran to a committed result. An unknown version still degrades to compact JSON, so a client that ships before the next version is never broken by it. The card also needed an explicit shrink floor and border-box sizing: a structured status line has no spaces to break on, so it overflowed its column instead of wrapping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Asserts the invariants the slice depends on rather than the shape it happens to emit: telemetry only where the work runs, a status that replays identically, and a bounded public surface. - one span per delivery, with the frozen policy and no attempt number; - a retryable failure recorded before the private Durable marker is raised; - denied and malformed deliveries recorded without trusting their payload; - no span at all for a task that froze no policy; - counters that carry only low-cardinality dimensions; - version 2 preserved for a plan with no frozen policy; - a continued failure reported as failed_continued while the ordered aggregate still reports completed; - the node that ended a workflow named in the final status, and none named for an opaque Durable failure; - a maximally expanded plan inside Durable's custom-status size limit, with a deterministic failure-detail cap; - the UI rendering version 3 and still degrading an unknown version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the workflow.task.activity span, its attribute table, and the two low-cardinality counters to the Observability section, and documents custom_status schema version 3 alongside the shapes it does not replace. Also updates the engine, activity, and observability module map rows plus the observability extension-point note in docs/architecture.md, and cross-links the continuation and retry sections to where per-attempt visibility actually lives. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The early-failure path reads its policy fields from the payload that, for a handler_contract outcome, is by definition the one that just failed validation -- and max_attempts and continue_on_error are metric dimensions, so an out-of-domain value there creates a bogus time series rather than a bogus span attribute. They are now admitted only inside their validated domain. Identifiers stay best-effort because they never leave the span. Also corrects the documented truncation order for per-instance failure detail (persisted plan order, then instance index -- not sorted node id), and surfaces a continued instance's error code on a for_each node in the chat card, where it is only present per instance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Tsuyoshi Ushio (TsuyoshiUshio)
force-pushed
the
tsuyoshiushio-workflow-execution-telemetry
branch
from
August 29, 2026 21:20
f141216 to
457d39e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
This is PR 3 of 3 in the decomposition of #170.
tsuyoshiushio-workflow-native-retrymaincontinue_on_errortsuyoshiushio-workflow-timeout-continuationtsuyoshiushio-workflow-native-retrytsuyoshiushio-workflow-execution-telemetrytsuyoshiushio-workflow-timeout-continuationThe base is deliberately #186's branch, not
main, so the GitHub diff showsonly this slice. Review #185 → #186 → this PR in order.
What this adds
The task execution policy is only useful if an operator can see what it did.
Two surfaces carry that, split along the Durable replay boundary.
1. Activity-side telemetry
Each delivery of a policy-aware task records one
workflow.task.activityspanfrom the Activity — never from the orchestrator, which re-executes from
history on every replay and would re-emit the span for work that already
happened. Deliveries a policy denies before the handler runs (authorization,
unregistered target, malformed persisted input) record the same span, so the
deliveries an operator most needs to see are not the ones without telemetry.
Attributes are
af.workflow_task.*:workflow_id,task_id,node_instance_id,target_type,target_name,max_attempts,timeout_ms,continue_on_error,retry_driver, plus the terminaloutcome_kind,error_code, anddisposition.dispositionreports what the Activity did —return_result,request_durable_retry,return_failure,abort— never what Durable willdecide, which the Activity cannot see. The attempt number stays absent for the
same reason (FRD 0004 Decision 73).
Two counters (
azure_functions_agents.workflow_task.attempts/.outcomes)carry a deliberately narrow dimension set. Workflow ids, node instance ids,
target names, and handler-authored error codes would create one time series per
task attempt, so they stay on the span.
_ensure_metricsnow creates each instrument in its own guard: a meter thatrejected one instrument previously nulled every counter in the runtime.
2. Structured
custom_statusschema version 3A dynamically controlled plan whose persisted input froze at least one
execution policy publishes
schema_version: 3. It addsretry_driver: "durable", the countspending/failed/failed_continued,the per-node
max_attempts, and the sanitizedlast_failure_kind/last_error_codeof a continued failure.The node whose failure ends a workflow is now marked and published before the
exception propagates, so the last status names it instead of freezing on the
pre-failure snapshot.
3. UI
formatWorkflowStatusaccepts version 3 and renders the new counts, the frozenbudget, and a continued failure's code. Two small CSS additions let a longer
status line wrap inside the card instead of overflowing it. Nothing else in the
UI changed.
Compatibility
same keys, same values, same
materialized_totalsemantics. Version 3 isgated on the persisted orchestration input alone, so an older history replays
unchanged and the version cannot differ between replays.
schema_versionand degrades to compact JSON, exactly as it already must forany future version.
{"id", "result"}Activity envelope, stringcustom_statusfor static plans.failed_continuedis a reporting projection._aggregate_dynamic_nodecopies the instance state into the persisted{index, status, result}aggregate, so a continued instance stayscompletedand carries private metadata only the status reads. Everyterminal-state predicate in the scheduler is unchanged.
workflows started by the newer runtime.
Rebase notes
#186 was rebased onto #185's
f7bc03e7("select over wave tasks instead ofnesting composites"), which replaced
task_all(wave)with_await_wave/_first_wave_failure. This branch was replayed onto the new head withgit rebase --onto+--force-with-lease; all nine commits are intact and nonested
task_allwas reintroduced.Two conflicts, both resolved toward the new flow:
engine.py, static scheduler —_decode_wave_failurenow receives thesingle failure
_first_wave_failureselected rather than the whole waveresult.
docs/frds/0004-dynamic-workflows.md— the two slices' review entries areboth kept.
The new flow also improved one behavior.
_await_wavereports each node's ownoutcome in wave order, so the instance that ends a workflow is now identified
positionally instead of by decoding a sanitized marker. An opaque Durable
failure is therefore attributed just as precisely as a classified one (only the
reason is still left to the Durable error), and
_decode_wave_failurekeepsits original single-value signature — so this PR no longer touches it at all.
Decision 88 and its test were updated accordingly; the engine diff shrank by 15
lines.
The PR-3 orchestrator test harness was ported to the new
context.task_any([cancel, *wave_tasks])selection loop, matching the harness#186 already uses.
Explicitly out of scope
Read-only Skill approval, extension bundle sweeps, unrelated runner behavior,
unrelated UI cleanup, further SSE/streaming client changes, sample rewrites,
plans/ExecPlan files,uv.lockchurn, and all other #170 collateral. Nochange to
config/schema.py, sofront-matter-reference.mdis unaffected.status_envelope()already passescustom_statusthrough unchanged, so nopublic serialization change was needed.
Reviews
findings: a new
failed_continuedscheduler state would have changed theshipped aggregate contract; the slice claimed exhaustion observability the
runtime did not have; counters reused high-cardinality span attributes;
failure spans would have closed as successful (failures are returned) while
the private retry marker would have been recorded as an exception on every
attempt; and the FRD did not yet record the contract. All resolved —
Decisions 82–89.
the early-failure path fed unvalidated history values into metric
dimensions. Fixed (Decision 90), plus a corrected truncation-order claim and a
for_eachinstance error code the card was dropping.Validation (re-run after the rebase)
ruff check src tests— cleanmypy src(strict) — clean— 167 passed
pytest --cov— 1203 passed, 89% total coveragemkdocs build --strict— cleanReal Functions-host E2E for PR 1 now lives in #185 (
bd56857f) and is runacross the stack separately.
Retarget / rebase plan
main.mainand rebased; its diff reduces to slice 2.mainand rebased onto the merged feat(workflows): per-attempt task timeout and continue-on-error #186.Every commit here touches only PR-3 files and depends on PR-2 semantics but not
PR-2 commit ids, so the series stays rebaseable and cherry-pickable.