Skip to content

Commit d99a079

Browse files
committed
feat: expose background_tasks in PluginResult for fire-and-forget synchronization
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 #25 Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
1 parent a42ef02 commit d99a079

4 files changed

Lines changed: 36 additions & 13 deletions

File tree

cpex/framework/manager.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ async def execute(
354354
)
355355

356356
# FIRE_AND_FORGET: fire-and-forget background tasks (fires last with final payload snapshot)
357-
self._fire_and_forget_tasks(
357+
bg_tasks = self._fire_and_forget_tasks(
358358
fire_and_forget_refs,
359359
payload,
360360
global_context,
@@ -373,6 +373,7 @@ async def execute(
373373
modified_extensions=current_extensions,
374374
violation=None,
375375
metadata=combined_metadata,
376+
background_tasks=bg_tasks,
376377
),
377378
res_local_contexts,
378379
)
@@ -688,7 +689,7 @@ def _build_halt_result(
688689
extensions: Optional[Extensions] = None,
689690
) -> tuple[PluginResult, dict]:
690691
"""Schedule fire-and-forget tasks and build a pipeline-halting result."""
691-
self._fire_and_forget_tasks(
692+
bg_tasks = self._fire_and_forget_tasks(
692693
fire_and_forget_refs,
693694
payload,
694695
global_context,
@@ -704,6 +705,7 @@ def _build_halt_result(
704705
modified_payload=current_payload,
705706
violation=violation,
706707
metadata=combined_metadata,
708+
background_tasks=bg_tasks,
707709
),
708710
res_local_contexts,
709711
)
@@ -728,12 +730,14 @@ def _fire_and_forget_tasks(
728730
res_local_contexts: dict,
729731
semaphore: Optional[asyncio.Semaphore],
730732
extensions: Optional[Extensions] = None,
731-
) -> None:
733+
) -> list[asyncio.Task]:
732734
"""Schedule all FIRE_AND_FORGET plugins as fire-and-forget background tasks.
733735
734736
May be called from an early-exit path or from the normal completion path.
735737
Each FIRE_AND_FORGET plugin receives an isolated snapshot of the payload at call time.
738+
Returns the list of asyncio.Task handles for all newly scheduled tasks.
736739
"""
740+
tasks: list[asyncio.Task] = []
737741
for ref in fire_and_forget_refs:
738742
local_context_key = global_context.request_id + ref.plugin_ref.uuid
739743
if local_context_key in res_local_contexts:
@@ -752,9 +756,11 @@ def _fire_and_forget_tasks(
752756
)
753757
local_context = PluginContext(global_context=tmp_gc)
754758
res_local_contexts[local_context_key] = local_context
755-
asyncio.create_task(
759+
task = asyncio.create_task(
756760
self._run_fire_and_forget_task(ref, task_input, local_context, semaphore, extensions=extensions)
757761
)
762+
tasks.append(task)
763+
return tasks
758764

759765
async def _run_fire_and_forget_task(
760766
self,

cpex/framework/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
# Standard
13+
import asyncio
1314
import logging
1415
import os
1516
import re
@@ -1498,6 +1499,11 @@ class PluginResult(BaseModel, Generic[T]):
14981499
(e.g., updated HTTP headers from token delegation, appended security labels).
14991500
violation (Optional[PluginViolation]): violation object.
15001501
metadata (Optional[dict[str, Any]]): additional metadata.
1502+
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.
15011507
15021508
Examples:
15031509
>>> result = PluginResult()
@@ -1522,11 +1528,14 @@ class PluginResult(BaseModel, Generic[T]):
15221528
False
15231529
"""
15241530

1531+
model_config = ConfigDict(arbitrary_types_allowed=True)
1532+
15251533
continue_processing: bool = True
15261534
modified_payload: Optional[T] = None
15271535
modified_extensions: Optional[Extensions] = None
15281536
violation: Optional[PluginViolation] = None
15291537
metadata: Optional[dict[str, Any]] = Field(default_factory=dict)
1538+
background_tasks: list[asyncio.Task] = Field(default_factory=list, exclude=True)
15301539

15311540

15321541
class GlobalContext(BaseModel):

docs/specs/plugin-framework-spec.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ class PluginResult(Generic[T]):
185185
modified_payload: T | None = None
186186
violation: PluginViolation | None = None
187187
metadata: dict[str, Any] = {}
188+
background_tasks: list[asyncio.Task] = [] # excluded from serialization
189+
```
190+
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:
192+
193+
```python
194+
result, _ = await manager.invoke_hook(...)
195+
await asyncio.gather(*result.background_tasks, return_exceptions=True)
188196
```
189197

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

tests/unit/cpex/framework/test_plugin_modes.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ async def prompt_pre_fetch(self, payload, context):
101101
assert result.continue_processing
102102
assert not finished.is_set()
103103

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

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

130130
assert result.continue_processing
131131

132-
# Allow background task to run and silently fail
133-
await asyncio.sleep(0.05)
132+
# Wait deterministically for the background task to run and silently fail
133+
await asyncio.gather(*result.background_tasks, return_exceptions=True)
134134

135135
await manager.shutdown()
136136

@@ -356,8 +356,8 @@ async def prompt_pre_fetch(self, payload, context):
356356

357357
assert result.continue_processing
358358

359-
# Allow all background FIRE_AND_FORGET tasks to complete
360-
await asyncio.sleep(0.1)
359+
# Wait deterministically for all background FIRE_AND_FORGET tasks to complete
360+
await asyncio.gather(*result.background_tasks, return_exceptions=True)
361361

362362
# With pool=1, max concurrency should be 1
363363
assert concurrency_high_water <= 1
@@ -619,8 +619,8 @@ async def prompt_pre_fetch(self, payload, context):
619619
result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context)
620620

621621
assert result.continue_processing
622-
# F&F is async — wait for it
623-
await asyncio.sleep(0.1)
622+
# F&F is async — wait for it deterministically
623+
await asyncio.gather(*result.background_tasks, return_exceptions=True)
624624

625625
assert phase_log == ["seq", "xform", "audit", "conc", "fnf"]
626626

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

669-
await asyncio.sleep(0.1)
669+
await asyncio.gather(*result.background_tasks, return_exceptions=True)
670670
assert "fire_and_forget" in phase_log
671671
# FIRE_AND_FORGET always comes after sequential in the log
672672
assert phase_log.index("sequential") < phase_log.index("fire_and_forget")

0 commit comments

Comments
 (0)