ttasks 0.2.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.
- ttasks/__init__.py +53 -0
- ttasks/_events.py +96 -0
- ttasks/_exceptions.py +45 -0
- ttasks/_executor.py +645 -0
- ttasks/_graph.py +409 -0
- ttasks/_sqlite.py +587 -0
- ttasks/_store.py +174 -0
- ttasks/_task.py +398 -0
- ttasks/_version.py +24 -0
- ttasks/py.typed +0 -0
- ttasks-0.2.0.dist-info/METADATA +511 -0
- ttasks-0.2.0.dist-info/RECORD +13 -0
- ttasks-0.2.0.dist-info/WHEEL +4 -0
ttasks/_executor.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""Task execution and process-management helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from ._events import EventBus, TaskEvent, TaskEventType
|
|
18
|
+
from ._exceptions import TaskCancelled, TaskExecutionError, TaskTimeoutError
|
|
19
|
+
from ._task import Task, TaskResult, TaskStatus, TaskType, TerminationReason
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ._store import Store
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, init=False)
|
|
26
|
+
class TaskContext:
|
|
27
|
+
"""Read-only execution view passed to task handlers.
|
|
28
|
+
|
|
29
|
+
The executor owns lifecycle transitions. Handlers receive this context so
|
|
30
|
+
they can inspect task data, cancellation state, and direct upstream task
|
|
31
|
+
refs without being given the public Task state-machine mutation API for the
|
|
32
|
+
current task.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_task: Task
|
|
36
|
+
_upstream: Mapping[str, Task]
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
task: Task,
|
|
41
|
+
upstream: Mapping[str, Task] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Create a context for task with read-only upstream task refs."""
|
|
44
|
+
object.__setattr__(self, "_task", task)
|
|
45
|
+
object.__setattr__(self, "_upstream", MappingProxyType(dict(upstream or {})))
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def id(self) -> str:
|
|
49
|
+
"""Return the task identity."""
|
|
50
|
+
return self._task.id
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def title(self) -> str:
|
|
54
|
+
"""Return the task title."""
|
|
55
|
+
return self._task.title
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def description(self) -> str:
|
|
59
|
+
"""Return the task description."""
|
|
60
|
+
return self._task.description
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def payload(self) -> str:
|
|
64
|
+
"""Return the task payload."""
|
|
65
|
+
return self._task.payload
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def type(self) -> TaskType:
|
|
69
|
+
"""Return the task type."""
|
|
70
|
+
return self._task.type
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def timeout(self) -> float | None:
|
|
74
|
+
"""Return the task timeout."""
|
|
75
|
+
return self._task.timeout
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def status(self) -> TaskStatus:
|
|
79
|
+
"""Return the task's current live status."""
|
|
80
|
+
return self._task.status
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def cancelled(self) -> bool:
|
|
84
|
+
"""Return whether cancellation has been requested for the task."""
|
|
85
|
+
return self.status == TaskStatus.CANCELLED
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def upstream(self) -> Mapping[str, Task]:
|
|
89
|
+
"""Return direct upstream task refs keyed by task ID."""
|
|
90
|
+
return self._upstream
|
|
91
|
+
|
|
92
|
+
def raise_if_cancelled(self) -> None:
|
|
93
|
+
"""Raise TaskCancelled if cancellation has been requested."""
|
|
94
|
+
if self.cancelled:
|
|
95
|
+
raise TaskCancelled(f"Task {self.id!r} was cancelled")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Handler contract: returning any value means success and the value is
|
|
99
|
+
# normalized into TaskResult. Raising TaskCancelled means cancelled. Raising any
|
|
100
|
+
# other exception means failed. Handlers that run subprocesses should raise
|
|
101
|
+
# TaskExecutionError or TaskTimeoutError to preserve structured process output.
|
|
102
|
+
TaskHandler = Callable[[TaskContext], Any]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TaskExecutor:
|
|
106
|
+
"""Dispatch tasks to registered handlers and manage task state transitions.
|
|
107
|
+
|
|
108
|
+
When constructed with ``store``, the executor auto-persists each task to
|
|
109
|
+
``store.tasks`` on every lifecycle transition (RUNNING, SUCCEEDED, FAILED,
|
|
110
|
+
CANCELLED). Persistence runs *before* the corresponding lifecycle event is
|
|
111
|
+
emitted so subscribers can read a consistent store. Persistence failures
|
|
112
|
+
do not propagate as task failures; instead they are recorded on
|
|
113
|
+
:attr:`persistence_errors` and emitted as
|
|
114
|
+
:attr:`TaskEventType.PERSISTENCE_FAILED` events.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, store: Store | None = None, *, _register_defaults: bool = True):
|
|
118
|
+
"""Create an executor optionally backed by ``store`` for auto-persist.
|
|
119
|
+
|
|
120
|
+
Built-in BASH, POWERSHELL, PROMPT, and AGENT handlers are registered
|
|
121
|
+
automatically. Use :meth:`empty` to construct an executor without them.
|
|
122
|
+
"""
|
|
123
|
+
self._handlers: dict[TaskType, TaskHandler] = {}
|
|
124
|
+
self._running_processes: dict[str, subprocess.Popen[str]] = {}
|
|
125
|
+
self.events = EventBus()
|
|
126
|
+
self.store = store
|
|
127
|
+
self.persistence_errors: list[tuple[str, BaseException]] = []
|
|
128
|
+
self.graph_persistence_errors: list[tuple[str, BaseException]] = []
|
|
129
|
+
if _register_defaults:
|
|
130
|
+
self.register(TaskType.BASH, self._run_bash)
|
|
131
|
+
self.register(TaskType.POWERSHELL, self._run_powershell)
|
|
132
|
+
self.register(TaskType.PROMPT, make_copilot_prompt_handler())
|
|
133
|
+
self.register(TaskType.AGENT, make_copilot_agent_handler())
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def empty(cls, store: Store | None = None) -> TaskExecutor:
|
|
137
|
+
"""Construct an executor with no handlers pre-registered."""
|
|
138
|
+
return cls(store=store, _register_defaults=False)
|
|
139
|
+
|
|
140
|
+
def register(self, task_type: TaskType, handler: TaskHandler) -> None:
|
|
141
|
+
"""Register callable handler as the executor for task_type."""
|
|
142
|
+
if not isinstance(task_type, TaskType):
|
|
143
|
+
raise TypeError("task_type must be a TaskType")
|
|
144
|
+
if not callable(handler):
|
|
145
|
+
raise TypeError("handler must be callable")
|
|
146
|
+
self._handlers[task_type] = handler
|
|
147
|
+
|
|
148
|
+
def is_registered(self, task_type: TaskType) -> bool:
|
|
149
|
+
"""Return whether a handler is registered for ``task_type``."""
|
|
150
|
+
if not isinstance(task_type, TaskType):
|
|
151
|
+
raise TypeError("task_type must be a TaskType")
|
|
152
|
+
return task_type in self._handlers
|
|
153
|
+
|
|
154
|
+
def is_running(self, task_id: str) -> bool:
|
|
155
|
+
"""Return whether task_id currently has a live subprocess."""
|
|
156
|
+
process = self._running_processes.get(task_id)
|
|
157
|
+
return process is not None and process.poll() is None
|
|
158
|
+
|
|
159
|
+
def mark_blocked(self, task: Task, parent_id: str | None) -> None:
|
|
160
|
+
"""Transition ``task`` to BLOCKED, recording the parent that caused it.
|
|
161
|
+
|
|
162
|
+
Public seam used by :class:`TaskGraph` (and any custom scheduler) to
|
|
163
|
+
signal that ``task`` cannot proceed because an upstream dependency
|
|
164
|
+
failed the readiness contract (it failed, was cancelled, or is itself
|
|
165
|
+
blocked). Records ``parent_id`` on the task via
|
|
166
|
+
:meth:`Task._set_blocked_by`, drives the lifecycle transition, and
|
|
167
|
+
emits the BLOCKED event so observers and the store see the outcome.
|
|
168
|
+
"""
|
|
169
|
+
previous_status = task.status
|
|
170
|
+
task._set_blocked_by(parent_id)
|
|
171
|
+
task.transition_to(TaskStatus.BLOCKED)
|
|
172
|
+
self._emit(task, TaskEventType.BLOCKED, previous_status)
|
|
173
|
+
|
|
174
|
+
def _terminalize(
|
|
175
|
+
self,
|
|
176
|
+
task: Task,
|
|
177
|
+
result: TaskResult,
|
|
178
|
+
status: TaskStatus,
|
|
179
|
+
*,
|
|
180
|
+
previous: TaskStatus,
|
|
181
|
+
event_type: TaskEventType,
|
|
182
|
+
error: str | None = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Drive a single terminal write: result → transition → emit.
|
|
185
|
+
|
|
186
|
+
``result`` is always attached so race orderings still leave a
|
|
187
|
+
TaskResult in place. If ``task`` is already in ``status`` (e.g. an
|
|
188
|
+
external ``cancel()`` raced ahead mid-execute) the transition is
|
|
189
|
+
skipped because the state-machine rejects self-transitions on
|
|
190
|
+
terminal states, but the event still fires so the executor remains
|
|
191
|
+
the single source of terminal events for its own ``execute()`` call.
|
|
192
|
+
"""
|
|
193
|
+
task._set_result(result)
|
|
194
|
+
if task.status != status:
|
|
195
|
+
task.transition_to(status, error=error)
|
|
196
|
+
self._emit(task, event_type, previous, error)
|
|
197
|
+
|
|
198
|
+
def _emit(
|
|
199
|
+
self,
|
|
200
|
+
task: Task,
|
|
201
|
+
event_type: TaskEventType,
|
|
202
|
+
previous_status: TaskStatus | None,
|
|
203
|
+
error: str | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Persist ``task`` if a store is configured, then emit a lifecycle event."""
|
|
206
|
+
self._persist(task)
|
|
207
|
+
self.events.emit(
|
|
208
|
+
TaskEvent(
|
|
209
|
+
type=event_type,
|
|
210
|
+
task_id=task.id,
|
|
211
|
+
task=task,
|
|
212
|
+
timestamp=datetime.now(),
|
|
213
|
+
previous_status=previous_status,
|
|
214
|
+
status=task.status,
|
|
215
|
+
error=error,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _persist(self, task: Task) -> None:
|
|
220
|
+
"""Auto-save ``task`` to the configured store.
|
|
221
|
+
|
|
222
|
+
Failures are recorded on :attr:`persistence_errors` and emitted as
|
|
223
|
+
``PERSISTENCE_FAILED`` events; they never propagate to the caller.
|
|
224
|
+
"""
|
|
225
|
+
if self.store is None:
|
|
226
|
+
return
|
|
227
|
+
try:
|
|
228
|
+
self.store.tasks.save(task)
|
|
229
|
+
except BaseException as error:
|
|
230
|
+
self.persistence_errors.append((task.id, error))
|
|
231
|
+
self.events.emit(
|
|
232
|
+
TaskEvent(
|
|
233
|
+
type=TaskEventType.PERSISTENCE_FAILED,
|
|
234
|
+
task_id=task.id,
|
|
235
|
+
task=task,
|
|
236
|
+
timestamp=datetime.now(),
|
|
237
|
+
previous_status=None,
|
|
238
|
+
status=task.status,
|
|
239
|
+
error=str(error),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _persist_graph(self, graph: Any) -> None:
|
|
244
|
+
"""Auto-save ``graph`` to the configured store.
|
|
245
|
+
|
|
246
|
+
Failures are recorded on :attr:`graph_persistence_errors` and surfaced
|
|
247
|
+
via :func:`warnings.warn`; they never propagate to the caller. Graph
|
|
248
|
+
persistence has no event type because :class:`TaskEvent` is
|
|
249
|
+
task-centric; the list is the discovery channel.
|
|
250
|
+
"""
|
|
251
|
+
if self.store is None:
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
self.store.graphs.save(graph)
|
|
255
|
+
except BaseException as error:
|
|
256
|
+
self.graph_persistence_errors.append((graph.id, error))
|
|
257
|
+
warnings.warn(
|
|
258
|
+
f"graph persistence failed for graph {graph.id!r}: {error}",
|
|
259
|
+
stacklevel=2,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def cancel(self, task: Task) -> None:
|
|
263
|
+
"""Cancel a task and terminate its subprocess if one is active.
|
|
264
|
+
|
|
265
|
+
SUCCEEDED is an irreversible sink: cancel() is a silent no-op rather
|
|
266
|
+
than raising, so callers don't need to know which states accept
|
|
267
|
+
transitions. For tasks that were not actively executing (PENDING /
|
|
268
|
+
FAILED / BLOCKED) this emits a CANCELLED event and attaches a
|
|
269
|
+
CANCELLED ``TaskResult`` so observers and the store see the outcome.
|
|
270
|
+
Cancelling a RUNNING task does **not** emit here: the active
|
|
271
|
+
``execute()`` loop owns the terminal event for that task and
|
|
272
|
+
will emit CANCELLED when its handler unwinds via
|
|
273
|
+
:class:`TaskCancelled`. Cancelling an already-CANCELLED task is
|
|
274
|
+
a no-op on Task state but still reaps any lingering subprocess
|
|
275
|
+
so duplicate requests stay harmless yet complete.
|
|
276
|
+
"""
|
|
277
|
+
if task.status == TaskStatus.SUCCEEDED:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
previous = task.status
|
|
281
|
+
task.cancel()
|
|
282
|
+
|
|
283
|
+
process = self._running_processes.get(task.id)
|
|
284
|
+
if process is not None and process.poll() is None:
|
|
285
|
+
self._terminate_process(process)
|
|
286
|
+
|
|
287
|
+
if previous in {TaskStatus.PENDING, TaskStatus.FAILED, TaskStatus.BLOCKED}:
|
|
288
|
+
now = datetime.now()
|
|
289
|
+
result = TaskResult(
|
|
290
|
+
task_id=task.id,
|
|
291
|
+
status=TaskStatus.CANCELLED,
|
|
292
|
+
started_at=now,
|
|
293
|
+
finished_at=now,
|
|
294
|
+
duration=0.0,
|
|
295
|
+
error="cancelled",
|
|
296
|
+
termination_reason="cancelled",
|
|
297
|
+
)
|
|
298
|
+
self._terminalize(
|
|
299
|
+
task,
|
|
300
|
+
result,
|
|
301
|
+
TaskStatus.CANCELLED,
|
|
302
|
+
previous=previous,
|
|
303
|
+
event_type=TaskEventType.CANCELLED,
|
|
304
|
+
error="cancelled",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def execute(
|
|
308
|
+
self,
|
|
309
|
+
task: Task,
|
|
310
|
+
upstream: Mapping[str, Task] | None = None,
|
|
311
|
+
) -> TaskResult:
|
|
312
|
+
"""Execute task with its registered handler.
|
|
313
|
+
|
|
314
|
+
upstream contains direct dependency task refs keyed by task ID. Single
|
|
315
|
+
task execution normally leaves it empty; TaskGraph populates it from
|
|
316
|
+
the graph ledger before submitting each non-root task.
|
|
317
|
+
|
|
318
|
+
Execution always moves through RUNNING first. Returning from a handler
|
|
319
|
+
means success and moves the task to SUCCEEDED; raising from a handler means
|
|
320
|
+
failure unless cancellation happened while the handler was in flight.
|
|
321
|
+
Handlers should signal cooperative cancellation by raising TaskCancelled
|
|
322
|
+
rather than mutating task state directly; the executor performs the
|
|
323
|
+
CANCELLED transition.
|
|
324
|
+
|
|
325
|
+
A non-zero subprocess return code is not interpreted here for arbitrary
|
|
326
|
+
custom handlers. Handlers that want subprocess failures represented as
|
|
327
|
+
structured TaskResult data should raise TaskExecutionError or
|
|
328
|
+
TaskTimeoutError.
|
|
329
|
+
"""
|
|
330
|
+
if not task.can_transition_to(TaskStatus.RUNNING):
|
|
331
|
+
raise ValueError(f"Cannot execute task with status {task.status.value!r}")
|
|
332
|
+
|
|
333
|
+
handler = self._handlers.get(task.type)
|
|
334
|
+
if handler is None:
|
|
335
|
+
message = f"No handler registered for task type {task.type.value!r}"
|
|
336
|
+
finished_at = datetime.now()
|
|
337
|
+
failed_result = TaskResult(
|
|
338
|
+
task_id=task.id,
|
|
339
|
+
status=TaskStatus.FAILED,
|
|
340
|
+
started_at=finished_at,
|
|
341
|
+
finished_at=finished_at,
|
|
342
|
+
duration=0.0,
|
|
343
|
+
error=message,
|
|
344
|
+
termination_reason="handler",
|
|
345
|
+
)
|
|
346
|
+
self._terminalize(
|
|
347
|
+
task,
|
|
348
|
+
failed_result,
|
|
349
|
+
TaskStatus.FAILED,
|
|
350
|
+
previous=TaskStatus.PENDING,
|
|
351
|
+
event_type=TaskEventType.FAILED,
|
|
352
|
+
error=message,
|
|
353
|
+
)
|
|
354
|
+
raise ValueError(message)
|
|
355
|
+
|
|
356
|
+
previous_status = task.status
|
|
357
|
+
task.transition_to(TaskStatus.RUNNING)
|
|
358
|
+
self._emit(task, TaskEventType.STARTED, previous_status)
|
|
359
|
+
started_at = datetime.now()
|
|
360
|
+
started_monotonic = time.monotonic()
|
|
361
|
+
|
|
362
|
+
def build_result(status: TaskStatus, **extras: Any) -> TaskResult:
|
|
363
|
+
"""Build the terminal TaskResult for ``task``."""
|
|
364
|
+
finished_at = datetime.now()
|
|
365
|
+
return TaskResult(
|
|
366
|
+
task_id=task.id,
|
|
367
|
+
status=status,
|
|
368
|
+
started_at=started_at,
|
|
369
|
+
finished_at=finished_at,
|
|
370
|
+
duration=time.monotonic() - started_monotonic,
|
|
371
|
+
**extras,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
context = TaskContext(task, upstream=upstream)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
raw_result = handler(context)
|
|
378
|
+
context.raise_if_cancelled()
|
|
379
|
+
finished_at = datetime.now()
|
|
380
|
+
duration = time.monotonic() - started_monotonic
|
|
381
|
+
result = TaskResult.from_raw(
|
|
382
|
+
task,
|
|
383
|
+
raw_result,
|
|
384
|
+
status=TaskStatus.SUCCEEDED,
|
|
385
|
+
started_at=started_at,
|
|
386
|
+
finished_at=finished_at,
|
|
387
|
+
duration=duration,
|
|
388
|
+
)
|
|
389
|
+
self._terminalize(
|
|
390
|
+
task,
|
|
391
|
+
result,
|
|
392
|
+
TaskStatus.SUCCEEDED,
|
|
393
|
+
previous=TaskStatus.RUNNING,
|
|
394
|
+
event_type=TaskEventType.SUCCEEDED,
|
|
395
|
+
)
|
|
396
|
+
return result
|
|
397
|
+
except TaskCancelled as e:
|
|
398
|
+
cancelled_result = build_result(
|
|
399
|
+
TaskStatus.CANCELLED, error=str(e), termination_reason="cancelled"
|
|
400
|
+
)
|
|
401
|
+
self._terminalize(
|
|
402
|
+
task,
|
|
403
|
+
cancelled_result,
|
|
404
|
+
TaskStatus.CANCELLED,
|
|
405
|
+
previous=TaskStatus.RUNNING,
|
|
406
|
+
event_type=TaskEventType.CANCELLED,
|
|
407
|
+
error=str(e),
|
|
408
|
+
)
|
|
409
|
+
raise
|
|
410
|
+
except Exception as e:
|
|
411
|
+
if task.status == TaskStatus.CANCELLED:
|
|
412
|
+
cancelled = TaskCancelled(f"Task {task.id!r} was cancelled")
|
|
413
|
+
cancelled_result = build_result(
|
|
414
|
+
TaskStatus.CANCELLED, error=str(e), termination_reason="cancelled"
|
|
415
|
+
)
|
|
416
|
+
self._terminalize(
|
|
417
|
+
task,
|
|
418
|
+
cancelled_result,
|
|
419
|
+
TaskStatus.CANCELLED,
|
|
420
|
+
previous=TaskStatus.RUNNING,
|
|
421
|
+
event_type=TaskEventType.CANCELLED,
|
|
422
|
+
error=str(e),
|
|
423
|
+
)
|
|
424
|
+
raise cancelled from e
|
|
425
|
+
if isinstance(e, TaskExecutionError | TaskTimeoutError):
|
|
426
|
+
completed = e.completed
|
|
427
|
+
if isinstance(e, TaskExecutionError):
|
|
428
|
+
err_text = completed.stderr or str(e)
|
|
429
|
+
reason: TerminationReason = "exit_code"
|
|
430
|
+
else:
|
|
431
|
+
err_text = str(e)
|
|
432
|
+
reason = "timeout"
|
|
433
|
+
failed_result = build_result(
|
|
434
|
+
TaskStatus.FAILED,
|
|
435
|
+
output=completed.stdout or "",
|
|
436
|
+
error=err_text,
|
|
437
|
+
returncode=completed.returncode,
|
|
438
|
+
raw=completed,
|
|
439
|
+
termination_reason=reason,
|
|
440
|
+
)
|
|
441
|
+
else:
|
|
442
|
+
failed_result = build_result(
|
|
443
|
+
TaskStatus.FAILED, error=str(e), termination_reason="handler"
|
|
444
|
+
)
|
|
445
|
+
self._terminalize(
|
|
446
|
+
task,
|
|
447
|
+
failed_result,
|
|
448
|
+
TaskStatus.FAILED,
|
|
449
|
+
previous=TaskStatus.RUNNING,
|
|
450
|
+
event_type=TaskEventType.FAILED,
|
|
451
|
+
error=str(e),
|
|
452
|
+
)
|
|
453
|
+
raise
|
|
454
|
+
|
|
455
|
+
def _run_command(
|
|
456
|
+
self,
|
|
457
|
+
context: TaskContext,
|
|
458
|
+
args: str | list[str],
|
|
459
|
+
*,
|
|
460
|
+
shell: bool = False,
|
|
461
|
+
) -> subprocess.CompletedProcess[str]:
|
|
462
|
+
"""Run a subprocess for task and enforce cancellation/timeout behavior.
|
|
463
|
+
|
|
464
|
+
context.timeout=None follows subprocess semantics: wait indefinitely
|
|
465
|
+
unless another caller cancels the task through TaskExecutor.cancel().
|
|
466
|
+
Non-zero exits raise TaskExecutionError; timeouts raise
|
|
467
|
+
TaskTimeoutError. Both exceptions carry a CompletedProcess so execute()
|
|
468
|
+
can attach stdout, stderr, returncode, and raw process details.
|
|
469
|
+
"""
|
|
470
|
+
process = subprocess.Popen(
|
|
471
|
+
args,
|
|
472
|
+
shell=shell,
|
|
473
|
+
text=True,
|
|
474
|
+
stdout=subprocess.PIPE,
|
|
475
|
+
stderr=subprocess.PIPE,
|
|
476
|
+
start_new_session=True,
|
|
477
|
+
)
|
|
478
|
+
self._running_processes[context.id] = process
|
|
479
|
+
if context.cancelled:
|
|
480
|
+
self._terminate_process(process)
|
|
481
|
+
try:
|
|
482
|
+
try:
|
|
483
|
+
stdout, stderr = process.communicate(timeout=context.timeout)
|
|
484
|
+
except subprocess.TimeoutExpired as e:
|
|
485
|
+
self._terminate_process(process)
|
|
486
|
+
stdout, stderr = process.communicate()
|
|
487
|
+
message = f"Task timed out after {context.timeout} seconds"
|
|
488
|
+
completed = subprocess.CompletedProcess(
|
|
489
|
+
args=args,
|
|
490
|
+
returncode=process.returncode,
|
|
491
|
+
stdout=stdout,
|
|
492
|
+
stderr=stderr,
|
|
493
|
+
)
|
|
494
|
+
raise TaskTimeoutError(message, completed) from e
|
|
495
|
+
finally:
|
|
496
|
+
self._running_processes.pop(context.id, None)
|
|
497
|
+
|
|
498
|
+
result = subprocess.CompletedProcess(
|
|
499
|
+
args=args,
|
|
500
|
+
returncode=process.returncode,
|
|
501
|
+
stdout=stdout,
|
|
502
|
+
stderr=stderr,
|
|
503
|
+
)
|
|
504
|
+
if result.returncode != 0:
|
|
505
|
+
if context.cancelled:
|
|
506
|
+
raise TaskCancelled(f"Task {context.id!r} was cancelled")
|
|
507
|
+
message = result.stderr or f"exited with code {result.returncode}"
|
|
508
|
+
raise TaskExecutionError(message, result)
|
|
509
|
+
return result
|
|
510
|
+
|
|
511
|
+
@staticmethod
|
|
512
|
+
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
|
513
|
+
"""Terminate a process group, escalating to SIGKILL if needed."""
|
|
514
|
+
try:
|
|
515
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
516
|
+
except ProcessLookupError:
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
process.wait(timeout=5)
|
|
521
|
+
except subprocess.TimeoutExpired:
|
|
522
|
+
try:
|
|
523
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
524
|
+
except ProcessLookupError:
|
|
525
|
+
return
|
|
526
|
+
process.wait()
|
|
527
|
+
|
|
528
|
+
def _run_bash(self, context: TaskContext) -> subprocess.CompletedProcess[str]:
|
|
529
|
+
"""Run trusted bash payload text through the system shell."""
|
|
530
|
+
# Intentionally uses shell=True because TaskType.BASH represents trusted
|
|
531
|
+
# shell code, not a shell-free argv command.
|
|
532
|
+
return self._run_command(context, context.payload, shell=True)
|
|
533
|
+
|
|
534
|
+
def _run_powershell(self, context: TaskContext) -> subprocess.CompletedProcess[str]:
|
|
535
|
+
"""Run trusted PowerShell payload text with pwsh."""
|
|
536
|
+
return self._run_command(context, ["pwsh", "-Command", context.payload])
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
DEFAULT_COPILOT_PROMPT_MODEL = "gpt-5.4-mini"
|
|
540
|
+
DEFAULT_COPILOT_PROMPT_TIMEOUT = 60.0
|
|
541
|
+
DEFAULT_COPILOT_AGENT_MODEL = "gpt-5.5"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _make_copilot_handler(
|
|
545
|
+
*,
|
|
546
|
+
model: str,
|
|
547
|
+
default_timeout: float | None,
|
|
548
|
+
tools_enabled: bool,
|
|
549
|
+
) -> TaskHandler:
|
|
550
|
+
"""Return a handler that drives one Copilot turn per task execution."""
|
|
551
|
+
if not model:
|
|
552
|
+
raise ValueError("model must not be empty")
|
|
553
|
+
|
|
554
|
+
def handler(context: TaskContext) -> str:
|
|
555
|
+
"""Run one synchronous Copilot task through the async SDK."""
|
|
556
|
+
return asyncio.run(
|
|
557
|
+
_run_copilot_text(
|
|
558
|
+
context,
|
|
559
|
+
model=model,
|
|
560
|
+
default_timeout=default_timeout,
|
|
561
|
+
tools_enabled=tools_enabled,
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
return handler
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def make_copilot_prompt_handler(
|
|
569
|
+
*,
|
|
570
|
+
model: str = DEFAULT_COPILOT_PROMPT_MODEL,
|
|
571
|
+
timeout: float = DEFAULT_COPILOT_PROMPT_TIMEOUT,
|
|
572
|
+
) -> TaskHandler:
|
|
573
|
+
"""Return a PROMPT handler backed by the GitHub Copilot SDK.
|
|
574
|
+
|
|
575
|
+
The handler sends context.payload as a single-turn text prompt, disables
|
|
576
|
+
tools with an empty available_tools allowlist, and returns the assistant
|
|
577
|
+
message content as task output. context.timeout overrides timeout per task.
|
|
578
|
+
"""
|
|
579
|
+
if timeout <= 0:
|
|
580
|
+
raise ValueError("timeout must be greater than 0")
|
|
581
|
+
return _make_copilot_handler(
|
|
582
|
+
model=model, default_timeout=timeout, tools_enabled=False,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def make_copilot_agent_handler(
|
|
587
|
+
*,
|
|
588
|
+
model: str = DEFAULT_COPILOT_AGENT_MODEL,
|
|
589
|
+
) -> TaskHandler:
|
|
590
|
+
"""Return an AGENT handler backed by the GitHub Copilot SDK.
|
|
591
|
+
|
|
592
|
+
The handler sends context.payload as a single-turn agent instruction,
|
|
593
|
+
leaves Copilot's default tools enabled, approves permission requests, and
|
|
594
|
+
returns the assistant message content as task output. context.timeout is
|
|
595
|
+
used when provided; otherwise no ttasks timeout is applied.
|
|
596
|
+
"""
|
|
597
|
+
return _make_copilot_handler(
|
|
598
|
+
model=model, default_timeout=None, tools_enabled=True,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
async def _run_copilot_text(
|
|
603
|
+
context: TaskContext,
|
|
604
|
+
*,
|
|
605
|
+
model: str,
|
|
606
|
+
default_timeout: float | None,
|
|
607
|
+
tools_enabled: bool,
|
|
608
|
+
) -> str:
|
|
609
|
+
"""Send one Copilot turn and return assistant text."""
|
|
610
|
+
from copilot import CopilotClient
|
|
611
|
+
from copilot.generated.session_events import AssistantMessageData
|
|
612
|
+
from copilot.session import PermissionHandler
|
|
613
|
+
|
|
614
|
+
context.raise_if_cancelled()
|
|
615
|
+
effective_timeout = (
|
|
616
|
+
context.timeout if context.timeout is not None else default_timeout
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
async with CopilotClient() as client:
|
|
620
|
+
context.raise_if_cancelled()
|
|
621
|
+
if tools_enabled:
|
|
622
|
+
session_context = await client.create_session(
|
|
623
|
+
on_permission_request=PermissionHandler.approve_all,
|
|
624
|
+
model=model,
|
|
625
|
+
)
|
|
626
|
+
else:
|
|
627
|
+
session_context = await client.create_session(
|
|
628
|
+
on_permission_request=PermissionHandler.approve_all,
|
|
629
|
+
model=model,
|
|
630
|
+
available_tools=[],
|
|
631
|
+
)
|
|
632
|
+
async with session_context as session:
|
|
633
|
+
context.raise_if_cancelled()
|
|
634
|
+
send_and_wait: Any = session.send_and_wait
|
|
635
|
+
response = await send_and_wait(
|
|
636
|
+
context.payload,
|
|
637
|
+
timeout=effective_timeout,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
context.raise_if_cancelled()
|
|
641
|
+
if response is None or not isinstance(response.data, AssistantMessageData):
|
|
642
|
+
return ""
|
|
643
|
+
return response.data.content or ""
|
|
644
|
+
|
|
645
|
+
|