planai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
planai/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ # Copyright 2024 Niels Provos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # src/planai/__init__.py
15
+
16
+ from .cached_task import CachedTaskWorker
17
+ from .graph import Graph
18
+ from .joined_task import JoinedTaskWorker
19
+ from .llm_interface import llm_from_config
20
+ from .llm_task import CachedLLMTaskWorker, LLMTaskWorker
21
+ from .task import TaskWorker, TaskWorkItem
22
+
23
+ # If you want to control what gets imported with "from planai import *"
24
+ __all__ = [
25
+ "Graph",
26
+ "llm_from_config",
27
+ "LLMTaskWorker",
28
+ "TaskWorker",
29
+ "TaskWorkItem",
30
+ "CachedTaskWorker",
31
+ "CachedLLMTaskWorker",
32
+ "JoinedTaskWorker",
33
+ ]
planai/cached_task.py ADDED
@@ -0,0 +1,113 @@
1
+ # Copyright 2024 Niels Provos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import hashlib
15
+ import logging
16
+ import threading
17
+ from typing import List, Type
18
+
19
+ from diskcache import Cache
20
+ from pydantic import Field, PrivateAttr
21
+
22
+ from .task import TaskWorker, TaskWorkItem
23
+
24
+
25
+ class CachedTaskWorker(TaskWorker):
26
+ cache_dir: str = Field("./cache", description="Directory to store the cache")
27
+ cache_size_limit: int = Field(
28
+ 25_000_000_000, description="Cache size limit in bytes"
29
+ )
30
+ _cache: Cache = PrivateAttr()
31
+ _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
32
+
33
+ def __init__(self, **data):
34
+ super().__init__(**data)
35
+ self._cache = Cache(self.cache_dir, size_limit=self.cache_size_limit)
36
+
37
+ def _pre_consume_work(self, task: TaskWorkItem):
38
+ self.pre_consume_work(task)
39
+
40
+ cache_key = self._get_cache_key(task)
41
+ cached_results = self._cache.get(cache_key)
42
+
43
+ if cached_results is not None:
44
+ logging.info("Cache hit for %s with key: %s", self.name, cache_key)
45
+ self._publish_cached_results(cached_results, task)
46
+ else:
47
+ logging.info("Cache miss for %s with key: %s", self.name, cache_key)
48
+ self.consume_work(task)
49
+
50
+ self.post_consume_work(task)
51
+
52
+ def pre_consume_work(self, task: TaskWorkItem):
53
+ """
54
+ This method is called before consuming the work item. It will be called even if the task has been cached.
55
+ It can be used for state manipulation, e.g. changing state for a class specific cache key.
56
+
57
+ Args:
58
+ task (TaskWorkItem): The work item to be consumed.
59
+
60
+ Returns:
61
+ None
62
+ """
63
+ pass
64
+
65
+ def post_consume_work(self, task: TaskWorkItem):
66
+ """
67
+ This method is called after consuming a work item in the task. It will be called even if the task has been cached.
68
+ It can be used for state manipulation, e.g. changing state for a class specific cache key.
69
+
70
+ Args:
71
+ task (TaskWorkItem): The work item that was consumed.
72
+
73
+ Returns:
74
+ None
75
+ """
76
+ pass
77
+
78
+ def _get_cache_key(self, task: TaskWorkItem) -> str:
79
+ """Generate a unique cache key for the input task."""
80
+ task_dict = task.model_dump()
81
+ task_str = str(sorted(task_dict.items())) # Ensure consistent ordering
82
+ task_str += f" - {self.name}" # Include the task name to avoid collisions
83
+ for output_type in self.output_types:
84
+ task_str += f" - {output_type.__name__}"
85
+ if extra_key := self.extra_cache_key(task):
86
+ task_str += f" - {extra_key}"
87
+ return hashlib.sha1(task_str.encode()).hexdigest()
88
+
89
+ def extra_cache_key(self, task: TaskWorkItem) -> str:
90
+ """Can be implemented by subclasses to provide additional cache key information."""
91
+ return ""
92
+
93
+ def _publish_cached_results(
94
+ self, cached_results: List[TaskWorkItem], input_task: TaskWorkItem
95
+ ):
96
+ """Publish cached results."""
97
+ for result in cached_results:
98
+ super().publish_work(result, input_task=input_task)
99
+
100
+ def publish_work(self, task: TaskWorkItem, input_task: TaskWorkItem):
101
+ """Publish work and cache the results."""
102
+ super().publish_work(task, input_task=input_task)
103
+
104
+ cache_key = self._get_cache_key(input_task)
105
+ try:
106
+ # since the cache key includes the name of this task worker, we can use the lock in this class
107
+ with self._lock:
108
+ cached_results = self._cache.get(cache_key, default=[])
109
+ cached_results.append(task)
110
+ self._cache.set(cache_key, cached_results)
111
+ logging.info("Task %s cached results for key: %s", self.name, cache_key)
112
+ except Exception as e:
113
+ logging.error("Error caching results for key %s: %s", cache_key, str(e))
planai/dispatcher.py ADDED
@@ -0,0 +1,236 @@
1
+ # Copyright 2024 Niels Provos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import logging
15
+ import threading
16
+ import time
17
+ from collections import defaultdict, deque
18
+ from queue import Empty, Queue
19
+ from threading import Event, Lock
20
+ from typing import TYPE_CHECKING, DefaultDict, Dict, List, Tuple, Type
21
+
22
+ from .task import TaskWorker, TaskWorkItem
23
+ from .web_interface import is_quit_requested, run_web_interface
24
+
25
+ if TYPE_CHECKING:
26
+ from .graph import Graph
27
+
28
+
29
+ # Type aliases
30
+ TaskID = int
31
+ TaskName = str
32
+ ProvenanceChain = Tuple[Tuple[TaskName, TaskID], ...]
33
+
34
+
35
+ class Dispatcher:
36
+ def __init__(self, graph: "Graph", web_port=5000):
37
+ self.graph = graph
38
+ self.work_queue = Queue()
39
+ self.provenance: DefaultDict[ProvenanceChain, int] = defaultdict(int)
40
+ self.notifiers: DefaultDict[ProvenanceChain, List[TaskWorker]] = defaultdict(
41
+ list
42
+ )
43
+ self.provenance_lock = Lock()
44
+ self.notifiers_lock = Lock()
45
+ self.stop_event = Event()
46
+ self.active_tasks = 0
47
+ self.task_completion_event = threading.Event()
48
+ self.web_port = web_port
49
+ self.debug_active_tasks: Dict[str, Tuple[TaskWorker, TaskWorkItem]] = {}
50
+ self.completed_tasks: deque = deque(maxlen=100) # Keep last 100 completed tasks
51
+ self.total_completed_tasks = 0
52
+ self.task_id_counter = 0
53
+ self.task_lock = threading.Lock()
54
+
55
+ def _add_provenance(self, task: TaskWorkItem):
56
+ with self.provenance_lock:
57
+ for i in range(1, len(task._provenance) + 1):
58
+ prefix = tuple(task._provenance[:i])
59
+ self.provenance[prefix] = self.provenance.get(prefix, 0) + 1
60
+
61
+ def _remove_provenance(self, task: TaskWorkItem):
62
+ to_notify = set()
63
+ with self.provenance_lock:
64
+ for i in range(1, len(task._provenance) + 1):
65
+ prefix = tuple(task._provenance[:i])
66
+ self.provenance[prefix] -= 1
67
+ if self.provenance[prefix] == 0:
68
+ del self.provenance[prefix]
69
+ to_notify.add(prefix)
70
+
71
+ for prefix in to_notify:
72
+ self._notify_task_completion(prefix)
73
+
74
+ def watch(self, prefix: ProvenanceChain, notifier: TaskWorker) -> bool:
75
+ if not isinstance(prefix, tuple):
76
+ raise ValueError("Prefix must be a tuple")
77
+ with self.notifiers_lock:
78
+ if notifier not in self.notifiers[prefix]:
79
+ self.notifiers[prefix].append(notifier)
80
+ return True
81
+ return False
82
+
83
+ def unwatch(self, prefix: ProvenanceChain, notifier: TaskWorker) -> bool:
84
+ if not isinstance(prefix, tuple):
85
+ raise ValueError("Prefix must be a tuple")
86
+ with self.notifiers_lock:
87
+ if prefix in self.notifiers and notifier in self.notifiers[prefix]:
88
+ self.notifiers[prefix].remove(notifier)
89
+ if not self.notifiers[prefix]:
90
+ del self.notifiers[prefix]
91
+ return True
92
+ return False
93
+
94
+ def _notify_task_completion(self, prefix: tuple):
95
+ to_notify = []
96
+ with self.notifiers_lock:
97
+ for notifier in self.notifiers[prefix]:
98
+ to_notify.append((notifier, prefix))
99
+
100
+ for notifier, prefix in to_notify:
101
+ self.active_tasks += 1
102
+ future = self.graph._thread_pool.submit(notifier.notify, prefix)
103
+ future.add_done_callback(self._notify_completed)
104
+
105
+ def _dispatch_once(self) -> bool:
106
+ try:
107
+ worker, task = self.work_queue.get(timeout=1)
108
+ self.active_tasks += 1
109
+ future = self.graph._thread_pool.submit(self._execute_task, worker, task)
110
+
111
+ # Use a named function instead of a lambda to avoid closure issues
112
+ def task_completed_callback(future, task=task):
113
+ self._task_completed(task, future)
114
+
115
+ future.add_done_callback(task_completed_callback)
116
+ return True
117
+
118
+ except Empty:
119
+ return False
120
+
121
+ def dispatch(self):
122
+ while (
123
+ not self.stop_event.is_set()
124
+ or not self.work_queue.empty()
125
+ or self.active_tasks > 0
126
+ ):
127
+ self._dispatch_once()
128
+
129
+ def _execute_task(self, worker: TaskWorker, task: TaskWorkItem):
130
+ task_id = self._get_next_task_id()
131
+ with self.task_lock:
132
+ self.debug_active_tasks[task_id] = (worker, task)
133
+
134
+ try:
135
+ worker._pre_consume_work(task)
136
+ finally:
137
+ with self.task_lock:
138
+ if task_id in self.debug_active_tasks:
139
+ del self.debug_active_tasks[task_id]
140
+ self.completed_tasks.appendleft(
141
+ (task_id, worker, task)
142
+ ) # may need to move this to _task_completed
143
+
144
+ def _get_next_task_id(self):
145
+ with self.task_lock:
146
+ self.task_id_counter += 1
147
+ return self.task_id_counter
148
+
149
+ def _task_to_dict(self, worker: TaskWorker, task: TaskWorkItem) -> Dict:
150
+ return {
151
+ "id": self._get_task_id(task),
152
+ "type": type(task).__name__,
153
+ "worker": worker.name,
154
+ "provenance": [f"{worker}_{id}" for worker, id in task._provenance],
155
+ "input_provenance": [
156
+ input_task.model_dump() for input_task in task._input_provenance
157
+ ],
158
+ }
159
+
160
+ def get_queued_tasks(self) -> List[Dict]:
161
+ return [
162
+ self._task_to_dict(worker, task) for worker, task in self.work_queue.queue
163
+ ]
164
+
165
+ def get_active_tasks(self) -> List[Dict]:
166
+ with self.task_lock:
167
+ return [
168
+ self._task_to_dict(worker, task)
169
+ for task_id, (worker, task) in self.debug_active_tasks.items()
170
+ ]
171
+
172
+ def get_completed_tasks(self) -> List[Dict]:
173
+ with self.task_lock:
174
+ return [
175
+ self._task_to_dict(worker, task)
176
+ for task_id, worker, task in self.completed_tasks
177
+ ]
178
+
179
+ def _get_task_id(self, task: TaskWorkItem) -> str:
180
+ # Use the last entry in the _provenance list as the task ID
181
+ if task._provenance:
182
+ return f"{task._provenance[-1][0]}_{task._provenance[-1][1]}"
183
+ else:
184
+ # Fallback in case _provenance is empty
185
+ return f"unknown_{id(task)}"
186
+
187
+ def _notify_completed(self, future):
188
+ self.active_tasks -= 1
189
+ if self.active_tasks == 0 and self.work_queue.empty():
190
+ self.task_completion_event.set()
191
+
192
+ def _task_completed(self, task: TaskWorkItem, future):
193
+ try:
194
+ # This will raise an exception if the task failed
195
+ result = future.result()
196
+
197
+ # Handle successful task completion
198
+ logging.info(f"Task {task} completed successfully")
199
+ self.total_completed_tasks += 1
200
+
201
+ except Exception as e:
202
+ # Handle task failure
203
+ logging.exception(f"Task {task} failed with exception: {str(e)}")
204
+
205
+ # Anything else that needs to be done when a task fails?
206
+
207
+ finally:
208
+ # This code will run whether the task succeeded or failed
209
+ self._remove_provenance(task)
210
+ self.active_tasks -= 1
211
+ if self.active_tasks == 0 and self.work_queue.empty():
212
+ self.task_completion_event.set()
213
+
214
+ def add_work(self, worker: TaskWorker, task: TaskWorkItem):
215
+ self._add_provenance(task)
216
+ self.work_queue.put((worker, task))
217
+
218
+ def stop(self):
219
+ self.stop_event.set()
220
+
221
+ def wait_for_completion(self, wait_for_quit=False):
222
+ self.task_completion_event.wait()
223
+
224
+ if wait_for_quit:
225
+ while not is_quit_requested():
226
+ # Sleep for a short time to avoid busy waiting
227
+ time.sleep(0.1)
228
+
229
+ def start_web_interface(self):
230
+ web_thread = threading.Thread(
231
+ target=run_web_interface, args=(self, self.web_port)
232
+ )
233
+ web_thread.daemon = (
234
+ True # This ensures the web thread will exit when the main thread exits
235
+ )
236
+ web_thread.start()
planai/graph.py ADDED
@@ -0,0 +1,172 @@
1
+ # Copyright 2024 Niels Provos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from threading import Thread
16
+ from typing import Dict, List, Optional, Set, Tuple, Type
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
19
+
20
+ from .dispatcher import Dispatcher
21
+ from .task import TaskWorker, TaskWorkItem
22
+
23
+
24
+ class Graph(BaseModel):
25
+ model_config = ConfigDict(arbitrary_types_allowed=True)
26
+
27
+ name: str
28
+ workers: Set[TaskWorker] = Field(default_factory=set)
29
+ dependencies: Dict[TaskWorker, List[TaskWorker]] = Field(default_factory=dict)
30
+
31
+ _dispatcher: Optional[Dispatcher] = PrivateAttr(default=None)
32
+ _thread_pool: Optional[ThreadPoolExecutor] = PrivateAttr(default=None)
33
+
34
+ def __init__(self, **data):
35
+ super().__init__(**data)
36
+ if self._thread_pool is None:
37
+ self._thread_pool = ThreadPoolExecutor()
38
+
39
+ def add_worker(self, task: TaskWorker) -> "Graph":
40
+ """Add a task to the Graph."""
41
+ if task in self.workers:
42
+ raise ValueError(f"Task {task} already exists in the Graph.")
43
+ self.workers.add(task)
44
+ self.dependencies[task] = []
45
+ task.set_graph(self)
46
+ return self
47
+
48
+ def add_workers(self, *workers: TaskWorker) -> "Graph":
49
+ """Add multiple tasks to the Graph."""
50
+ for worker in workers:
51
+ self.add_worker(worker)
52
+ return self
53
+
54
+ def set_dependency(
55
+ self, upstream: TaskWorker, downstream: TaskWorker
56
+ ) -> TaskWorker:
57
+ """Set a dependency between two tasks."""
58
+ if upstream not in self.workers or downstream not in self.workers:
59
+ raise ValueError(
60
+ "Both tasks must be added to the Graph before setting dependencies."
61
+ )
62
+
63
+ if downstream not in self.dependencies[upstream]:
64
+ self.dependencies[upstream].append(downstream)
65
+ upstream.register_consumer(
66
+ task_cls=downstream.get_taskworkitem_class(),
67
+ consumer=downstream,
68
+ )
69
+
70
+ return downstream
71
+
72
+ def validate_graph(self) -> None:
73
+ """Return the execution order of tasks based on dependencies."""
74
+ pass
75
+
76
+ def run(self, initial_tasks: List[Tuple[TaskWorker, TaskWorkItem]]) -> None:
77
+ """Execute the Graph by initiating source tasks."""
78
+ dispatcher = Dispatcher(self)
79
+ dispatch_thread = Thread(target=dispatcher.dispatch)
80
+ dispatch_thread.start()
81
+ dispatcher.start_web_interface()
82
+ self._dispatcher = dispatcher
83
+
84
+ # let the workers now that we are about to start
85
+ for worker in self.workers:
86
+ worker.init()
87
+
88
+ for worker, task in initial_tasks:
89
+ success, error = worker.validate_taskworkitem(type(task), worker)
90
+ if not success:
91
+ raise error
92
+
93
+ dispatcher.add_work(worker, task)
94
+
95
+ # Wait for all tasks to complete
96
+ dispatcher.wait_for_completion(wait_for_quit=True)
97
+ dispatcher.stop()
98
+ dispatch_thread.join()
99
+ self._thread_pool.shutdown(wait=True)
100
+
101
+ for worker in self.workers:
102
+ worker.completed()
103
+
104
+ def __str__(self) -> str:
105
+ return f"Graph: {self.name} with {len(self.workers)} tasks"
106
+
107
+ def __repr__(self) -> str:
108
+ return self.__str__()
109
+
110
+
111
+ def main():
112
+ # Define custom TaskWorkItem classes
113
+ class Task1WorkItem(TaskWorkItem):
114
+ data: str
115
+
116
+ class Task2WorkItem(TaskWorkItem):
117
+ processed_data: str
118
+
119
+ class Task3WorkItem(TaskWorkItem):
120
+ final_result: str
121
+
122
+ # Define custom TaskWorker classes
123
+ class Task1Worker(TaskWorker):
124
+ output_types: Set[Type[TaskWorkItem]] = {Task2WorkItem}
125
+
126
+ def consume_work(self, task: Task1WorkItem):
127
+ print(f"Task1 consuming: {task.data}")
128
+ processed = f"Processed: {task.data.upper()}"
129
+ self.publish_work(Task2WorkItem(processed_data=processed), input_task=task)
130
+
131
+ class Task2Worker(TaskWorker):
132
+ output_types: Set[Type[TaskWorkItem]] = {Task3WorkItem}
133
+
134
+ def consume_work(self, task: Task2WorkItem):
135
+ print(f"Task2 consuming: {task.processed_data}")
136
+ final = f"Final: {task.processed_data}!"
137
+ self.publish_work(Task3WorkItem(final_result=final), input_task=task)
138
+
139
+ class Task3Worker(TaskWorker):
140
+ output_types: Set[Type[TaskWorkItem]] = set()
141
+
142
+ def consume_work(self, task: Task3WorkItem):
143
+ print(f"Task3 consuming: {task.final_result}")
144
+ print("Workflow complete!")
145
+
146
+ # Create Graph
147
+ graph = Graph(name="Simple Workflow")
148
+
149
+ # Create tasks
150
+ task1 = Task1Worker()
151
+ task2 = Task2Worker()
152
+ task3 = Task3Worker()
153
+
154
+ # Add tasks to Graph
155
+ graph.add_worker(task1).add_worker(task2).add_worker(task3)
156
+
157
+ # Set dependencies
158
+ graph.set_dependency(task1, task2).next(task3)
159
+
160
+ # Validate Graph
161
+ execution_order = graph.validate_graph()
162
+ print(f"Execution order: {execution_order}")
163
+
164
+ # Prepare initial work item
165
+ initial_work = [(task1, Task1WorkItem(data="Hello, Graph!"))]
166
+
167
+ # Run the Graph
168
+ graph.run(initial_work)
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
planai/joined_task.py ADDED
@@ -0,0 +1,56 @@
1
+ # Copyright 2024 Niels Provos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from abc import abstractmethod
15
+ from typing import Dict, List, Type
16
+
17
+ from pydantic import PrivateAttr
18
+
19
+ from .cached_task import CachedTaskWorker
20
+ from .task import TaskWorker, TaskWorkItem
21
+
22
+
23
+ class JoinedTaskWorker(TaskWorker):
24
+ join_type: Type[TaskWorker]
25
+ _joined_results: Dict[tuple, List[TaskWorkItem]] = PrivateAttr(default_factory=dict)
26
+
27
+ @abstractmethod
28
+ def consume_work_joined(self, task: List[TaskWorkItem]):
29
+ """
30
+ A subclass needs to implement consume_work only for the type hint.
31
+ It still needs to call the super() method in consume_work.
32
+ All accumulated results will be delivered to this method.
33
+ """
34
+ pass
35
+
36
+ def consume_work(self, task: TaskWorkItem):
37
+ prefix = task.prefix_for_input_task(self.join_type)
38
+ if prefix is None:
39
+ raise ValueError(
40
+ f"Task {task} does not have a prefix for {self.join_type.__name__} in provenance."
41
+ )
42
+ if not prefix in self._joined_results:
43
+ # we will register the watch for the prefix when we see it for the first time.
44
+ if not self.watch(prefix):
45
+ raise ValueError(
46
+ f"Task {task} does not have a watch for {self.join_type} in provenance."
47
+ )
48
+
49
+ # we accumulate the results by the prefix described by join_type
50
+ # we will deliver them to the sub-class when we get notified
51
+ self._joined_results.setdefault(prefix, []).append(task)
52
+
53
+ def notify(self, prefix: str):
54
+ if prefix not in self._joined_results:
55
+ raise ValueError(f"Task {prefix} does not have any results to join.")
56
+ self.consume_work_joined(self._joined_results.pop(prefix))