Skip to content

feat: expose background_tasks in PluginResult for fire-and-forget synchronization#33

Merged
araujof merged 2 commits into
contextforge-org:mainfrom
ajbozarth:feat/expose-background-tasks
Apr 23, 2026
Merged

feat: expose background_tasks in PluginResult for fire-and-forget synchronization#33
araujof merged 2 commits into
contextforge-org:mainfrom
ajbozarth:feat/expose-background-tasks

Conversation

@ajbozarth
Copy link
Copy Markdown
Contributor

Summary

Adds a background_tasks field to PluginResult containing the asyncio.Task
handles created for FIRE_AND_FORGET plugins. Callers can now await background
tasks deterministically instead of using arbitrary sleep delays.

Closes: #25

Changes

  • PluginResult gains a background_tasks: list[asyncio.Task] field (excluded
    from serialization; defaults to empty list for backward compatibility)
  • _fire_and_forget_tasks() now returns the list of task handles instead of None
  • Both the normal execution path and the halt path in execute() populate
    result.background_tasks
  • Five tests in test_plugin_modes.py updated to use
    await asyncio.gather(*result.background_tasks, return_exceptions=True)
    in place of asyncio.sleep() workarounds
  • PluginResult entry in docs/specs/plugin-framework-spec.md updated

Checks

  • make lint passes
  • make test passes
  • CHANGELOG updated (if user-facing)

Notes

arbitrary_types_allowed=True is added to PluginResult.model_config to
allow Pydantic to accept asyncio.Task as a field type — the same config
already used on PluginPayload. The exclude=True on the field keeps
.model_dump() / .model_dump_json() output unchanged.

…chronization

Adds a background_tasks field to PluginResult containing the asyncio.Task
handles created for FIRE_AND_FORGET plugins. Callers can now await background
tasks deterministically instead of relying on arbitrary sleep delays.

Closes contextforge-org#25

Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
Copy link
Copy Markdown
Contributor

@araujof araujof left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR @ajbozarth! Nice work. This is a good change that preserves backwards compatibility. The test migration from asyncio.sleep() to direct task awaits is also a good win for determinism and CI reliability.

What I verified

  • All scheduling paths (execute() normal completion + _build_halt_result halt path) correctly populate background_tasks.
  • exclude=True keeps the field out of model_dump() / model_dump_json(), so MCP, gRPC, and isolated-worker serialization are unaffected.
  • arbitrary_types_allowed=True is consistent with the existing pattern on PluginPayload.
  • Task lifecycle is improved, callers now hold strong references to fire-and-forget tasks via the result.

Worth noting

  • Remote callers (MCP/gRPC/isolated) always receive background_tasks=[]; they can't synchronize with tasks in the remote process. Not a bug, but worth a docstring mention if remote sync ever becomes a requirement.
  • Callers now have .cancel() access on fire-and-forget tasks. If that's undesirable, wrapping in asyncio.shield or returning Sequence[Awaitable] would restrict it. Not required today.

@terylt can you review to see if you spot any issues, specially any undesirable interactions with the upcoming Rust Core for CPEX?

@araujof araujof added this to CPEX Apr 22, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in CPEX Apr 22, 2026
@araujof araujof added this to the 0.1.0 milestone Apr 22, 2026
@araujof araujof moved this from Backlog to In review in CPEX Apr 22, 2026
@terylt
Copy link
Copy Markdown
Contributor

terylt commented Apr 22, 2026

Overall @ajbozarth Nice work and thanks for the contribution!

I have one suggestion just to help keep parity with the rust core we are building out. Rather than exposing background_tasks as the primary API, consider adding a wait_for_background_tasks() method on PluginResult that wraps the asyncio.gather call and returns list[PluginError] — an empty list on success, populated with errors from any tasks that failed. Callers would use errors = await result.wait_for_background_tasks() instead of manually gathering the task list.

The internal list can stay for advanced use cases, but the method gives a cleaner public API and a consistent return type for error handling. This also aligns with the Rust core — PipelineResult will expose the same wait_for_background_tasks() method backed by tokio::JoinHandles, converting failures to PluginError before returning. The PyO3 wrapper will map it to a Python awaitable with the same signature. Keeping the API shape consistent across both runtimes means plugins and tests work the same way regardless of which backend is running.

Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
@ajbozarth
Copy link
Copy Markdown
Contributor Author

Thanks for the feedback — addressed in the second commit.

_run_fire_and_forget_task now returns PluginErrorModel | None instead
of swallowing exceptions, so errors survive the task boundary.
wait_for_background_tasks() on PluginResult does the gather internally
and returns list[PluginErrorModel] — empty on success, one entry per
failed task. All five tests updated to use it, plus a new test that asserts
on the returned error model directly. Spec doc updated to match.

Copy link
Copy Markdown
Contributor

@terylt terylt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Nice work @ajbozarth!

@araujof araujof merged commit e0a5728 into contextforge-org:main Apr 23, 2026
22 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in CPEX Apr 23, 2026
@ajbozarth ajbozarth deleted the feat/expose-background-tasks branch April 23, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Expose FIRE_AND_FORGET task handles on PluginResult for deterministic awaiting

3 participants