Skip to content

Commit bd3b22c

Browse files
committed
feat: add PluginResult.wait_for_background_tasks()
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
1 parent d99a079 commit bd3b22c

4 files changed

Lines changed: 56 additions & 14 deletions

File tree

cpex/framework/manager.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -769,9 +769,10 @@ async def _run_fire_and_forget_task(
769769
local_context: PluginContext,
770770
semaphore: Optional[asyncio.Semaphore],
771771
extensions: Optional[Extensions] = None,
772-
) -> None:
772+
) -> Optional[PluginErrorModel]:
773773
"""Execute a plugin as a fire-and-forget background task.
774774
775+
Returns None on success, or a PluginErrorModel if the plugin raised.
775776
Errors are logged but never propagated — background tasks cannot halt the pipeline.
776777
If on_error=DISABLE, the plugin is added to the runtime-disabled set.
777778
"""
@@ -781,11 +782,13 @@ async def _run_fire_and_forget_task(
781782
await self._execute_with_timeout(hook_ref, payload, local_context, extensions=extensions)
782783
else:
783784
await self._execute_with_timeout(hook_ref, payload, local_context, extensions=extensions)
784-
except Exception:
785+
return None
786+
except Exception as exc:
785787
logger.error("Plugin %s failed in fire-and-forget mode (ignored)", hook_ref.plugin_ref.name)
786788
if hook_ref.plugin_ref.on_error == OnError.DISABLE:
787789
self._runtime_disabled.add(hook_ref.plugin_ref.name)
788790
# FAIL and IGNORE both just log for FIRE_AND_FORGET mode (background can't halt pipeline)
791+
return PluginErrorModel(message=repr(exc), plugin_name=hook_ref.plugin_ref.name)
789792

790793
async def execute_plugin(
791794
self,

cpex/framework/models.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,10 +1500,8 @@ class PluginResult(BaseModel, Generic[T]):
15001500
violation (Optional[PluginViolation]): violation object.
15011501
metadata (Optional[dict[str, Any]]): additional metadata.
15021502
background_tasks (list[asyncio.Task]): asyncio.Task handles for any FIRE_AND_FORGET
1503-
plugins scheduled during this invocation. Use
1504-
``await asyncio.gather(*result.background_tasks, return_exceptions=True)``
1505-
to deterministically wait for all background tasks to complete (useful in tests).
1506-
This field is excluded from model serialization.
1503+
plugins scheduled during this invocation. Use ``wait_for_background_tasks()``
1504+
to await them and collect any errors. This field is excluded from model serialization.
15071505
15081506
Examples:
15091507
>>> result = PluginResult()
@@ -1537,6 +1535,20 @@ class PluginResult(BaseModel, Generic[T]):
15371535
metadata: Optional[dict[str, Any]] = Field(default_factory=dict)
15381536
background_tasks: list[asyncio.Task] = Field(default_factory=list, exclude=True)
15391537

1538+
async def wait_for_background_tasks(self) -> "list[PluginErrorModel]":
1539+
"""Await all FIRE_AND_FORGET background tasks and return any errors.
1540+
1541+
Returns an empty list if all tasks completed without error.
1542+
1543+
Examples:
1544+
>>> result = PluginResult()
1545+
>>> # errors = await result.wait_for_background_tasks()
1546+
"""
1547+
if not self.background_tasks:
1548+
return []
1549+
results = await asyncio.gather(*self.background_tasks, return_exceptions=True)
1550+
return [r for r in results if isinstance(r, PluginErrorModel)]
1551+
15401552

15411553
class GlobalContext(BaseModel):
15421554
"""The global context, which shared across all plugins.

docs/specs/plugin-framework-spec.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,11 @@ class PluginResult(Generic[T]):
188188
background_tasks: list[asyncio.Task] = [] # excluded from serialization
189189
```
190190

191-
`background_tasks` contains the `asyncio.Task` handles for any `FIRE_AND_FORGET` plugins scheduled during the invocation. Use it to wait for background tasks without sleep delays:
191+
`background_tasks` contains the `asyncio.Task` handles for any `FIRE_AND_FORGET` plugins scheduled during the invocation. Use `wait_for_background_tasks()` to await them and collect any errors:
192192

193193
```python
194194
result, _ = await manager.invoke_hook(...)
195-
await asyncio.gather(*result.background_tasks, return_exceptions=True)
195+
errors = await result.wait_for_background_tasks() # list[PluginErrorModel], empty on success
196196
```
197197

198198
The `PluginViolation` type carries structured policy failure information:

tests/unit/cpex/framework/test_plugin_modes.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async def prompt_pre_fetch(self, payload, context):
102102
assert not finished.is_set()
103103

104104
# Wait deterministically for the background task to complete
105-
await asyncio.gather(*result.background_tasks, return_exceptions=True)
105+
await result.wait_for_background_tasks()
106106
assert finished.is_set()
107107

108108
await manager.shutdown()
@@ -129,8 +129,35 @@ async def prompt_pre_fetch(self, payload, context):
129129

130130
assert result.continue_processing
131131

132-
# Wait deterministically for the background task to run and silently fail
133-
await asyncio.gather(*result.background_tasks, return_exceptions=True)
132+
# Wait for the background task; errors are returned, not raised
133+
await result.wait_for_background_tasks()
134+
135+
await manager.shutdown()
136+
137+
138+
@pytest.mark.asyncio
139+
async def test_wait_for_background_tasks_returns_errors():
140+
"""wait_for_background_tasks() returns a PluginErrorModel for each failed task."""
141+
142+
class BrokenPlugin(Plugin):
143+
async def prompt_pre_fetch(self, payload, context):
144+
raise RuntimeError("boom")
145+
146+
manager = await _make_manager()
147+
cfg = make_plugin_config("BrokenFnF", PluginMode.FIRE_AND_FORGET)
148+
plugin = BrokenPlugin(cfg)
149+
150+
with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get:
151+
mock_get.return_value = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))]
152+
payload = PromptPrehookPayload(prompt_id="test", args={})
153+
global_context = GlobalContext(request_id="wait_errors")
154+
155+
result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context)
156+
157+
errors = await result.wait_for_background_tasks()
158+
assert len(errors) == 1
159+
assert errors[0].plugin_name == "BrokenFnF"
160+
assert "RuntimeError" in errors[0].message
134161

135162
await manager.shutdown()
136163

@@ -357,7 +384,7 @@ async def prompt_pre_fetch(self, payload, context):
357384
assert result.continue_processing
358385

359386
# Wait deterministically for all background FIRE_AND_FORGET tasks to complete
360-
await asyncio.gather(*result.background_tasks, return_exceptions=True)
387+
await result.wait_for_background_tasks()
361388

362389
# With pool=1, max concurrency should be 1
363390
assert concurrency_high_water <= 1
@@ -620,7 +647,7 @@ async def prompt_pre_fetch(self, payload, context):
620647

621648
assert result.continue_processing
622649
# F&F is async — wait for it deterministically
623-
await asyncio.gather(*result.background_tasks, return_exceptions=True)
650+
await result.wait_for_background_tasks()
624651

625652
assert phase_log == ["seq", "xform", "audit", "conc", "fnf"]
626653

@@ -666,7 +693,7 @@ async def prompt_pre_fetch(self, payload, context):
666693
# FIRE_AND_FORGET has not yet completed (fire-and-forget)
667694
assert not fire_and_forget_started.is_set()
668695

669-
await asyncio.gather(*result.background_tasks, return_exceptions=True)
696+
await result.wait_for_background_tasks()
670697
assert "fire_and_forget" in phase_log
671698
# FIRE_AND_FORGET always comes after sequential in the log
672699
assert phase_log.index("sequential") < phase_log.index("fire_and_forget")

0 commit comments

Comments
 (0)