Python: include checkpoint_id on AG-UI interrupt metadata (#8150) - #8163
Python: include checkpoint_id on AG-UI interrupt metadata (#8150)#8163lsmlhi_25 (FOWEPJF255) wants to merge 5 commits into
Conversation
…8150) Attach the pause workflow checkpoint id to RUN_FINISHED interrupt metadata.agent_framework so multi-worker clients can resume via forwardedProps.checkpoint_id without a side channel.
There was a problem hiding this comment.
🟡 Changes recommended
Checkpoint selection can advertise a stale or different request owner’s checkpoint ID.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds checkpoint IDs to AG-UI workflow interrupt metadata for durable multi-worker resume.
Changes:
- Resolves and attaches pause checkpoint IDs to interrupts.
- Adds helper and checkpoint-resume coverage.
File summaries
| File | Description |
|---|---|
_workflow_run.py |
Adds checkpoint lookup and interrupt metadata enrichment. |
test_workflow_run.py |
Tests attachment and checkpoint ID propagation. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
|
Addressed review feedback:
|
|
|
Evan Mattson (@moonbox3) Thanks 鈥?addressed your checklist:
|
Move pause-checkpoint selection into Workflow and require a run-scoped baseline so leftover runner ids are not advertised across runs.
|
Evan Mattson (@moonbox3) Follow-up for the run-scoped / core-helper review:
Commit: |
Unblocks Package Checks / merge-gatekeeper on PR microsoft#8163.
| candidates: list[str] = [] | ||
| if runner_candidate is not None: | ||
| candidates.append(runner_candidate) | ||
| if known_checkpoint_id is not None and known_checkpoint_id not in candidates: | ||
| candidates.append(str(known_checkpoint_id)) | ||
|
|
||
| if storage is None and not use_context_storage: | ||
| # Without storage we cannot prove coverage; only advertise a run-scoped runner id. | ||
| return runner_candidate | ||
|
|
||
| for candidate in candidates: | ||
| try: | ||
| if storage is not None: | ||
| checkpoint = await storage.load(candidate) | ||
| else: | ||
| checkpoint = await self._runner.context.load_checkpoint(candidate) | ||
| except Exception: # pragma: no cover - storage/type drift | ||
| logger.debug("Could not load pause checkpoint candidate %s", candidate, exc_info=True) | ||
| continue | ||
| if checkpoint is None: | ||
| continue | ||
| pending = checkpoint.pending_request_info_events or {} | ||
| if ids.issubset({str(key) for key in dict(pending)}): | ||
| return candidate |
There was a problem hiding this comment.
Following up on the run-scoping feedback: could we exclude the incoming restored checkpoint from these candidates after execution resumes? A supported response handler can reissue the same request ID, and if the new pause save fails, known_checkpoint_id still loads the old checkpoint and the key-only coverage check at _workflow.py:1344-1346 accepts it. The emitted ID then restores pre-response state, so tracking the checkpoint successfully created by this run rather than the restored ID would avoid replaying stale work.
| def get_last_checkpoint_id(self) -> str | None: | ||
| """Return the checkpoint id last persisted or restored by this workflow runner. | ||
|
|
||
| Capture this value before ``run()`` when a host needs a run-scoped pause id: | ||
| after the run, only a different id indicates *this* run advanced the chain. | ||
| """ | ||
| checkpoint_id = self._runner._previous_checkpoint_id # pyright: ignore[reportPrivateUsage] | ||
| return str(checkpoint_id) if checkpoint_id is not None else None | ||
|
|
||
| async def resolve_pause_checkpoint_id( | ||
| self, | ||
| request_ids: Collection[str], | ||
| *, | ||
| checkpoint_storage: CheckpointStorage | None = None, | ||
| known_checkpoint_id: str | None = None, | ||
| baseline_checkpoint_id: str | None = None, | ||
| ) -> str | None: |
There was a problem hiding this comment.
Would it make sense for Workflow to own the run boundary used by resolve_pause_checkpoint_id()? Every caller now has to call get_last_checkpoint_id() before run(), retain it across every exit path, and pass it back as baseline_checkpoint_id; mistiming that sequence lets a restored checkpoint look newly persisted. Tracking the checkpoint created by the most recent run inside Workflow would remove that ordering protocol while preserving the AG-UI behavior.
Motivation & Context
AG-UI workflow interrupts need a durable pause
checkpoint_idso multi-worker clients can resume viaforwardedProps.checkpoint_idwithout a side-channel lookup (Option A from #8150).Description & Review Guide
checkpoint_idto each interrupt'smetadata.agent_frameworkwhen AG-UI workflow runs finish with interrupts and checkpoint storage is active.get_latest(workflow_name=...)(avoids cross-owner races / stale ids).run_workflow_streamdoes not passcheckpoint_storage.interruptsis empty; remove the unusedcheckpoint_idkwarg on_workflow_interrupt_metadata.get_latest, builder-only storage path, and empty-interrupt short-circuit.Related Issue
Fixes #8150
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) 鈥?a workflow keeps the label and title prefix in sync automatically.