ttasks 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (45) hide show
  1. {ttasks-0.3.0 → ttasks-0.4.0}/PKG-INFO +1 -1
  2. {ttasks-0.3.0 → ttasks-0.4.0}/docs/quickstart.md +20 -0
  3. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/__init__.py +2 -0
  4. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_executor.py +93 -15
  5. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_graph.py +8 -1
  6. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_version.py +2 -2
  7. ttasks-0.4.0/src/ttasks/copilot.py +329 -0
  8. ttasks-0.4.0/tests/test_copilot_session.py +549 -0
  9. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_e2e.py +52 -0
  10. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_executor.py +256 -0
  11. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_public_api.py +3 -0
  12. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_workflow.py +139 -14
  13. {ttasks-0.3.0 → ttasks-0.4.0}/.github/workflows/docs.yml +0 -0
  14. {ttasks-0.3.0 → ttasks-0.4.0}/.github/workflows/publish.yml +0 -0
  15. {ttasks-0.3.0 → ttasks-0.4.0}/.gitignore +0 -0
  16. {ttasks-0.3.0 → ttasks-0.4.0}/.python-version +0 -0
  17. {ttasks-0.3.0 → ttasks-0.4.0}/.vscode/launch.json +0 -0
  18. {ttasks-0.3.0 → ttasks-0.4.0}/.vscode/settings.json +0 -0
  19. {ttasks-0.3.0 → ttasks-0.4.0}/README.md +0 -0
  20. {ttasks-0.3.0 → ttasks-0.4.0}/docs/index.md +0 -0
  21. {ttasks-0.3.0 → ttasks-0.4.0}/docs/patterns/finally-tasks.md +0 -0
  22. {ttasks-0.3.0 → ttasks-0.4.0}/docs/patterns/progress-and-output.md +0 -0
  23. {ttasks-0.3.0 → ttasks-0.4.0}/docs/patterns/retries-and-cancellation.md +0 -0
  24. {ttasks-0.3.0 → ttasks-0.4.0}/docs/reference/api.md +0 -0
  25. {ttasks-0.3.0 → ttasks-0.4.0}/docs/tutorials/async-execution.md +0 -0
  26. {ttasks-0.3.0 → ttasks-0.4.0}/docs/tutorials/graph-workflows.md +0 -0
  27. {ttasks-0.3.0 → ttasks-0.4.0}/docs/tutorials/task-execution.md +0 -0
  28. {ttasks-0.3.0 → ttasks-0.4.0}/examples/finally_tasks.py +0 -0
  29. {ttasks-0.3.0 → ttasks-0.4.0}/main.py +0 -0
  30. {ttasks-0.3.0 → ttasks-0.4.0}/mkdocs.yml +0 -0
  31. {ttasks-0.3.0 → ttasks-0.4.0}/pyproject.toml +0 -0
  32. {ttasks-0.3.0 → ttasks-0.4.0}/scripts/preflight.py +0 -0
  33. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_events.py +0 -0
  34. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_exceptions.py +0 -0
  35. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_sqlite.py +0 -0
  36. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_store.py +0 -0
  37. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/_task.py +0 -0
  38. {ttasks-0.3.0 → ttasks-0.4.0}/src/ttasks/py.typed +0 -0
  39. {ttasks-0.3.0 → ttasks-0.4.0}/tests/conftest.py +0 -0
  40. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_events.py +0 -0
  41. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_sqlite_store.py +0 -0
  42. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_store.py +0 -0
  43. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_task.py +0 -0
  44. {ttasks-0.3.0 → ttasks-0.4.0}/tests/test_task_factories.py +0 -0
  45. {ttasks-0.3.0 → ttasks-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ttasks
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: github-copilot-sdk>=0.1.0
@@ -19,6 +19,26 @@ assert task.result is result
19
19
  and Copilot agent tasks. You can override any handler or create an executor with
20
20
  no defaults by using `TaskExecutor.empty()`.
21
21
 
22
+ ## Share a Copilot agent session
23
+
24
+ The default Copilot agent handler creates a fresh Copilot session for each task.
25
+ Use `CopilotAgentSession` when multiple `Task.agent(...)` tasks should share one
26
+ conversation:
27
+
28
+ ```python
29
+ from ttasks import CopilotAgentSession, Task, TaskExecutor, TaskType
30
+
31
+ with CopilotAgentSession(working_directory="/path/to/repo") as agent:
32
+ executor = TaskExecutor()
33
+ executor.register(TaskType.AGENT, agent.handler())
34
+
35
+ executor.execute(Task.agent("Create a first change."))
36
+ executor.execute(Task.agent("Continue from the previous change."))
37
+ ```
38
+
39
+ Shared sessions preserve conversation state across agent tasks. The handler
40
+ serializes turns through the session, including when used by `TaskGraph`.
41
+
22
42
  ## Run a graph
23
43
 
24
44
  ```python
@@ -20,12 +20,14 @@ from ._store import (
20
20
  Store,
21
21
  )
22
22
  from ._task import Task, TaskResult, TaskStatus, TaskType
23
+ from .copilot import CopilotAgentSession
23
24
 
24
25
  __all__ = [
25
26
  "EventBus",
26
27
  "InMemoryStore",
27
28
  "SQLiteStore",
28
29
  "Store",
30
+ "CopilotAgentSession",
29
31
  "RetryPolicy",
30
32
  "Task",
31
33
  "TaskCancelled",
@@ -11,9 +11,10 @@ import time
11
11
  import warnings
12
12
  from collections.abc import Callable, Mapping
13
13
  from concurrent.futures import Future, ThreadPoolExecutor
14
+ from contextlib import suppress
14
15
  from dataclasses import dataclass
15
16
  from datetime import datetime
16
- from threading import RLock, Thread
17
+ from threading import RLock, Thread, current_thread
17
18
  from types import MappingProxyType
18
19
  from typing import TYPE_CHECKING, Any, TextIO, cast
19
20
 
@@ -236,6 +237,8 @@ class TaskExecutor:
236
237
  :meth:`Future.cancel` only cancels work that has not started yet; cancel
237
238
  running tasks through :meth:`cancel`.
238
239
  """
240
+ self._validate_task(task)
241
+ policy = self._resolve_retry_policy(retry_policy)
239
242
  # Shallow-copy the mapping so caller mutation cannot race the worker;
240
243
  # Task refs themselves intentionally remain shared.
241
244
  upstream_snapshot = dict(upstream or {})
@@ -244,12 +247,27 @@ class TaskExecutor:
244
247
  raise RuntimeError("executor is shut down")
245
248
  if self._pool is None:
246
249
  self._pool = ThreadPoolExecutor(thread_name_prefix="ttasks")
247
- return self._pool.submit(
248
- self.execute,
250
+ future = self._pool.submit(
251
+ self._execute_submitted,
249
252
  task,
250
253
  upstream_snapshot,
251
- retry_policy=retry_policy,
254
+ policy,
252
255
  )
256
+ future.add_done_callback(
257
+ lambda submitted: self.cancel(task) if submitted.cancelled() else None
258
+ )
259
+ return future
260
+
261
+ def _execute_submitted(
262
+ self,
263
+ task: Task,
264
+ upstream: Mapping[str, Task],
265
+ retry_policy: RetryPolicy,
266
+ ) -> TaskResult:
267
+ """Execute submitted work, preserving queued cancellation semantics."""
268
+ if task.status == TaskStatus.CANCELLED:
269
+ raise TaskCancelled(f"Task {task.id!r} was cancelled")
270
+ return self.execute(task, upstream, retry_policy=retry_policy)
253
271
 
254
272
  def shutdown(self) -> None:
255
273
  """Shut down async submission, waiting for submitted work to finish.
@@ -265,7 +283,15 @@ class TaskExecutor:
265
283
  pool = self._pool
266
284
  self._pool = None
267
285
  if pool is not None:
268
- pool.shutdown(wait=True)
286
+ current = current_thread()
287
+ pool_threads = list(getattr(pool, "_threads", ()))
288
+ if current in pool_threads:
289
+ pool.shutdown(wait=False)
290
+ for thread in pool_threads:
291
+ if thread is not current:
292
+ thread.join()
293
+ else:
294
+ pool.shutdown(wait=True)
269
295
 
270
296
  def close(self) -> None:
271
297
  """Alias for :meth:`shutdown` for resource-cleanup contexts."""
@@ -417,10 +443,11 @@ class TaskExecutor:
417
443
  self.store.graphs.save(graph)
418
444
  except BaseException as error:
419
445
  self.graph_persistence_errors.append((graph.id, error))
420
- warnings.warn(
421
- f"graph persistence failed for graph {graph.id!r}: {error}",
422
- stacklevel=2,
423
- )
446
+ with suppress(Warning):
447
+ warnings.warn(
448
+ f"graph persistence failed for graph {graph.id!r}: {error}",
449
+ stacklevel=2,
450
+ )
424
451
 
425
452
  def cancel(self, task: Task) -> None:
426
453
  """Cancel a task and terminate its subprocess if one is active.
@@ -495,7 +522,8 @@ class TaskExecutor:
495
522
  ``retry_policy`` retries failed attempts for this single task only.
496
523
  Cancellation is never retried.
497
524
  """
498
- policy = retry_policy or RetryPolicy()
525
+ self._validate_task(task)
526
+ policy = self._resolve_retry_policy(retry_policy)
499
527
  if policy.max_attempts == 1 or self._handlers.get(task.type) is None:
500
528
  return self._execute_once(task, upstream)
501
529
 
@@ -513,7 +541,7 @@ class TaskExecutor:
513
541
  if out_of_attempts or task.status != TaskStatus.FAILED:
514
542
  raise
515
543
  if policy.backoff:
516
- time.sleep(policy.backoff)
544
+ self._sleep_retry_backoff(task, policy.backoff)
517
545
  if task.status == TaskStatus.CANCELLED:
518
546
  raise TaskCancelled(
519
547
  f"Task {task.id!r} was cancelled",
@@ -521,6 +549,35 @@ class TaskExecutor:
521
549
 
522
550
  raise AssertionError("unreachable retry loop exit") # pragma: no cover
523
551
 
552
+ @staticmethod
553
+ def _validate_task(task: Task) -> None:
554
+ """Reject malformed task arguments before accessing Task internals."""
555
+ if not isinstance(task, Task):
556
+ raise TypeError("task must be a Task")
557
+
558
+ @staticmethod
559
+ def _resolve_retry_policy(retry_policy: RetryPolicy | None) -> RetryPolicy:
560
+ """Return a concrete RetryPolicy, rejecting malformed public input."""
561
+ if retry_policy is None:
562
+ return RetryPolicy()
563
+ if not isinstance(retry_policy, RetryPolicy):
564
+ raise TypeError("retry_policy must be a RetryPolicy")
565
+ return retry_policy
566
+
567
+ @staticmethod
568
+ def _sleep_retry_backoff(task: Task, backoff: float) -> None:
569
+ """Sleep between retry attempts while periodically observing cancel()."""
570
+ if backoff <= 0.5:
571
+ time.sleep(backoff)
572
+ return
573
+
574
+ deadline = time.monotonic() + backoff
575
+ while task.status != TaskStatus.CANCELLED:
576
+ remaining = deadline - time.monotonic()
577
+ if remaining <= 0:
578
+ return
579
+ time.sleep(min(remaining, 0.05))
580
+
524
581
  def _execute_once(
525
582
  self,
526
583
  task: Task,
@@ -714,6 +771,16 @@ class TaskExecutor:
714
771
  stderr_thread.start()
715
772
  timed_out = False
716
773
  timeout_error: subprocess.TimeoutExpired | None = None
774
+ deadline = (
775
+ None if context.timeout is None else time.monotonic() + context.timeout
776
+ )
777
+
778
+ def remaining_timeout() -> float | None:
779
+ """Return remaining wall-clock timeout for process/output draining."""
780
+ if deadline is None:
781
+ return None
782
+ return max(0.0, deadline - time.monotonic())
783
+
717
784
  if context.cancelled:
718
785
  self._terminate_process(process)
719
786
  try:
@@ -724,14 +791,23 @@ class TaskExecutor:
724
791
  timed_out = True
725
792
  timeout_error = e
726
793
  finally:
727
- stdout_thread.join()
728
- stderr_thread.join()
794
+ for thread in (stdout_thread, stderr_thread):
795
+ thread.join(remaining_timeout())
796
+ if (
797
+ not timed_out
798
+ and deadline is not None
799
+ and (stdout_thread.is_alive() or stderr_thread.is_alive())
800
+ ):
801
+ self._terminate_process(process)
802
+ timed_out = True
803
+ if timed_out:
804
+ stdout_thread.join()
805
+ stderr_thread.join()
729
806
  self._running_processes.pop(context.id, None)
730
807
 
731
808
  stdout = "".join(stdout_chunks)
732
809
  stderr = "".join(stderr_chunks)
733
810
  if timed_out:
734
- assert timeout_error is not None
735
811
  message = f"Task timed out after {context.timeout} seconds"
736
812
  completed = subprocess.CompletedProcess(
737
813
  args=args,
@@ -739,7 +815,9 @@ class TaskExecutor:
739
815
  stdout=stdout,
740
816
  stderr=stderr,
741
817
  )
742
- raise TaskTimeoutError(message, completed) from timeout_error
818
+ if timeout_error is not None:
819
+ raise TaskTimeoutError(message, completed) from timeout_error
820
+ raise TaskTimeoutError(message, completed)
743
821
 
744
822
  result = subprocess.CompletedProcess(
745
823
  args=args,
@@ -72,6 +72,8 @@ class TaskGraph:
72
72
  self._tasks[task.id] = task
73
73
  deps: list[str] = []
74
74
  for dep in after:
75
+ if not isinstance(dep, Task):
76
+ raise TypeError(f"Expected Task dependency, got {type(dep).__name__}")
75
77
  if dep.id not in deps:
76
78
  deps.append(dep.id)
77
79
  self._deps[task.id] = deps
@@ -349,11 +351,12 @@ class TaskGraph:
349
351
 
350
352
  def inactive(tid: str) -> bool:
351
353
  """Return whether tid can no longer change in this run."""
354
+ if tid in futures:
355
+ return futures[tid].done()
352
356
  task = self._tasks[tid]
353
357
  return (
354
358
  (task.is_terminal and not retryable_this_run(tid))
355
359
  or tid in self._errors
356
- or (tid in futures and futures[tid].done())
357
360
  )
358
361
 
359
362
  def ready(tid: str) -> bool:
@@ -373,6 +376,10 @@ class TaskGraph:
373
376
  authoritative.
374
377
  """
375
378
  for d in self._deps[tid]:
379
+ if d in futures and not futures[d].done():
380
+ continue
381
+ if d in self._errors:
382
+ return d
376
383
  if self._tasks[d].status.is_bad and not retryable_this_run(d):
377
384
  return d
378
385
  return None
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,329 @@
1
+ """Copilot-backed task handlers and session helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from concurrent.futures import CancelledError as FutureCancelledError
8
+ from concurrent.futures import TimeoutError as FutureTimeoutError
9
+ from threading import Event, Lock, Thread
10
+ from typing import Any, Self
11
+
12
+ from ._exceptions import TaskCancelled
13
+ from ._executor import DEFAULT_COPILOT_AGENT_MODEL, TaskContext, TaskHandler
14
+
15
+
16
+ class CopilotAgentSession:
17
+ """Long-lived Copilot agent session with a ``TaskExecutor`` handler.
18
+
19
+ A shared session preserves Copilot conversation state across multiple
20
+ ``Task.agent(...)`` executions. Use a fresh ``CopilotAgentSession`` for an
21
+ independent conversational lane.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ model: str = DEFAULT_COPILOT_AGENT_MODEL,
28
+ reasoning_effort: str | None = None,
29
+ working_directory: str | None = None,
30
+ timeout: float | None = None,
31
+ on_event: Callable[[Any], None] | None = None,
32
+ **session_options: Any,
33
+ ) -> None:
34
+ """Configure a reusable Copilot agent session.
35
+
36
+ ``session_options`` are passed through to
37
+ ``CopilotClient.create_session(...)``. Permission requests default to
38
+ ``PermissionHandler.approve_all`` to match the existing one-shot AGENT
39
+ handler unless ``on_permission_request`` is supplied.
40
+ """
41
+ if not model:
42
+ raise ValueError("model must not be empty")
43
+ if timeout is not None and timeout <= 0:
44
+ raise ValueError("timeout must be greater than 0")
45
+
46
+ self.model = model
47
+ self.reasoning_effort = reasoning_effort
48
+ self.working_directory = working_directory
49
+ self.timeout = timeout
50
+ self._session_options = dict(session_options)
51
+
52
+ self._event_handlers: list[Callable[[Any], None]] = []
53
+ self.event_errors: list[BaseException] = []
54
+ self._event_lock = Lock()
55
+ if on_event is not None:
56
+ self._event_handlers.append(on_event)
57
+
58
+ self._client: Any | None = None
59
+ self._session: Any | None = None
60
+ self._send_lock: asyncio.Lock | None = None
61
+ self._active = False
62
+ self._sync_active = False
63
+
64
+ self._loop: asyncio.AbstractEventLoop | None = None
65
+ self._thread: Thread | None = None
66
+ self._thread_ready = Event()
67
+ self._handler_lock = Lock()
68
+
69
+ async def __aenter__(self) -> Self:
70
+ """Open the Copilot client and session in the current event loop."""
71
+ if self._active:
72
+ raise RuntimeError("CopilotAgentSession is already active")
73
+ await self._open_async()
74
+ self._sync_active = False
75
+ return self
76
+
77
+ async def __aexit__(
78
+ self,
79
+ exc_type: type[BaseException] | None,
80
+ exc: BaseException | None,
81
+ traceback: object | None,
82
+ ) -> None:
83
+ """Close the Copilot session and client."""
84
+ await self._close_async(exc_type, exc, traceback)
85
+
86
+ def __enter__(self) -> Self:
87
+ """Open the Copilot client and session on a background event loop."""
88
+ if self._active:
89
+ raise RuntimeError("CopilotAgentSession is already active")
90
+ self._start_loop_thread()
91
+ try:
92
+ self._run_on_loop(self._open_async())
93
+ except BaseException:
94
+ self._stop_loop_thread()
95
+ raise
96
+ self._sync_active = True
97
+ return self
98
+
99
+ def __exit__(
100
+ self,
101
+ exc_type: type[BaseException] | None,
102
+ exc: BaseException | None,
103
+ traceback: object | None,
104
+ ) -> None:
105
+ """Close the Copilot session/client and stop the background loop."""
106
+ try:
107
+ if self._loop is not None:
108
+ self._run_on_loop(self._close_async(exc_type, exc, traceback))
109
+ finally:
110
+ self._sync_active = False
111
+ self._stop_loop_thread()
112
+
113
+ async def send_and_wait(
114
+ self,
115
+ prompt: str,
116
+ *,
117
+ timeout: float | None = None,
118
+ ) -> str:
119
+ """Send ``prompt`` through the shared session and return assistant text."""
120
+ if not self._active or self._session is None:
121
+ raise RuntimeError("CopilotAgentSession is not active")
122
+ if not isinstance(prompt, str):
123
+ raise TypeError("prompt must be a str")
124
+ if timeout is not None and timeout <= 0:
125
+ raise ValueError("timeout must be greater than 0")
126
+
127
+ effective_timeout = self.timeout if timeout is None else timeout
128
+ if self._send_lock is None:
129
+ self._send_lock = asyncio.Lock()
130
+ async with self._send_lock:
131
+ response = await self._session.send_and_wait(
132
+ prompt,
133
+ timeout=effective_timeout,
134
+ )
135
+ return self._response_text(response)
136
+
137
+ def on(self, handler: Callable[[Any], None]) -> Callable[[], None]:
138
+ """Subscribe to Copilot session events.
139
+
140
+ The returned callable unsubscribes ``handler``. Handlers may be called
141
+ from the session's event-loop thread when using the synchronous context
142
+ manager.
143
+ """
144
+ if not callable(handler):
145
+ raise TypeError("handler must be callable")
146
+ with self._event_lock:
147
+ self._event_handlers.append(handler)
148
+
149
+ def unsubscribe() -> None:
150
+ with self._event_lock:
151
+ if handler in self._event_handlers:
152
+ self._event_handlers.remove(handler)
153
+
154
+ return unsubscribe
155
+
156
+ def handler(self) -> TaskHandler:
157
+ """Return a synchronous AGENT task handler backed by this session."""
158
+
159
+ def run(context: TaskContext) -> str:
160
+ context.raise_if_cancelled()
161
+ if not self._sync_active or self._loop is None:
162
+ raise RuntimeError(
163
+ "CopilotAgentSession.handler() requires an active sync context",
164
+ )
165
+ with self._handler_lock:
166
+ context.raise_if_cancelled()
167
+ future = asyncio.run_coroutine_threadsafe(
168
+ self.send_and_wait(context.payload, timeout=context.timeout),
169
+ self._loop,
170
+ )
171
+ while True:
172
+ try:
173
+ result = future.result(timeout=0.05)
174
+ except FutureTimeoutError:
175
+ if context.cancelled:
176
+ future.cancel()
177
+ self._abort_active()
178
+ raise TaskCancelled(
179
+ f"Task {context.id!r} was cancelled",
180
+ ) from None
181
+ continue
182
+ except FutureCancelledError as error:
183
+ raise TaskCancelled(
184
+ f"Task {context.id!r} was cancelled",
185
+ ) from error
186
+ context.raise_if_cancelled()
187
+ return result
188
+
189
+ return run
190
+
191
+ async def _open_async(self) -> None:
192
+ """Create the SDK client/session pair."""
193
+ from copilot import CopilotClient
194
+ from copilot.session import PermissionHandler
195
+
196
+ session_options = dict(self._session_options)
197
+ session_options.setdefault(
198
+ "on_permission_request",
199
+ PermissionHandler.approve_all,
200
+ )
201
+ session_options["model"] = self.model
202
+ if self.reasoning_effort is not None:
203
+ session_options["reasoning_effort"] = self.reasoning_effort
204
+ if self.working_directory is not None:
205
+ session_options["working_directory"] = self.working_directory
206
+ session_options["on_event"] = self._dispatch_event
207
+
208
+ client = CopilotClient()
209
+ entered_client = await client.__aenter__()
210
+ try:
211
+ session = await entered_client.create_session(**session_options)
212
+ entered_session = await session.__aenter__()
213
+ except BaseException:
214
+ await entered_client.__aexit__(None, None, None)
215
+ raise
216
+
217
+ self._client = entered_client
218
+ self._session = entered_session
219
+ self._send_lock = asyncio.Lock()
220
+ self._active = True
221
+
222
+ async def _close_async(
223
+ self,
224
+ exc_type: type[BaseException] | None = None,
225
+ exc: BaseException | None = None,
226
+ traceback: object | None = None,
227
+ ) -> None:
228
+ """Close active SDK resources in reverse creation order."""
229
+ session = self._session
230
+ client = self._client
231
+ self._session = None
232
+ self._client = None
233
+ self._send_lock = None
234
+ self._active = False
235
+
236
+ close_error: BaseException | None = None
237
+ if session is not None:
238
+ try:
239
+ await session.__aexit__(exc_type, exc, traceback)
240
+ except BaseException as error:
241
+ close_error = error
242
+ if client is not None:
243
+ try:
244
+ await client.__aexit__(exc_type, exc, traceback)
245
+ except BaseException as error:
246
+ if close_error is None:
247
+ close_error = error
248
+ if close_error is not None:
249
+ raise close_error
250
+
251
+ async def _abort_active_async(self) -> None:
252
+ """Abort the active Copilot turn if the SDK session supports it."""
253
+ session = self._session
254
+ abort = getattr(session, "abort", None)
255
+ if callable(abort):
256
+ await abort()
257
+
258
+ def _abort_active(self) -> None:
259
+ """Best-effort abort for a cancelled synchronous handler call."""
260
+ if self._loop is None:
261
+ return
262
+ future = asyncio.run_coroutine_threadsafe(
263
+ self._abort_active_async(),
264
+ self._loop,
265
+ )
266
+ future.result(timeout=5)
267
+
268
+ @staticmethod
269
+ def _response_text(response: Any) -> str:
270
+ """Normalize a Copilot SDK response event to assistant text."""
271
+ from copilot.generated.session_events import AssistantMessageData
272
+
273
+ if response is None or not isinstance(response.data, AssistantMessageData):
274
+ return ""
275
+ return response.data.content or ""
276
+
277
+ def _dispatch_event(self, event: Any) -> None:
278
+ """Fan out one SDK event to registered session subscribers."""
279
+ with self._event_lock:
280
+ handlers = list(self._event_handlers)
281
+ for handler in handlers:
282
+ try:
283
+ handler(event)
284
+ except BaseException as error:
285
+ self.event_errors.append(error)
286
+
287
+ def _start_loop_thread(self) -> None:
288
+ """Start the background event loop used by the sync API."""
289
+ self._thread_ready.clear()
290
+
291
+ def run_loop() -> None:
292
+ loop = asyncio.new_event_loop()
293
+ asyncio.set_event_loop(loop)
294
+ self._loop = loop
295
+ self._thread_ready.set()
296
+ try:
297
+ loop.run_forever()
298
+ finally:
299
+ loop.close()
300
+ self._loop = None
301
+
302
+ self._thread = Thread(
303
+ target=run_loop,
304
+ name="ttasks-copilot-agent-session",
305
+ daemon=True,
306
+ )
307
+ self._thread.start()
308
+ self._thread_ready.wait()
309
+
310
+ def _stop_loop_thread(self) -> None:
311
+ """Stop and join the background event-loop thread."""
312
+ loop = self._loop
313
+ thread = self._thread
314
+ if loop is not None:
315
+ loop.call_soon_threadsafe(loop.stop)
316
+ if thread is not None:
317
+ thread.join(timeout=5)
318
+ self._thread = None
319
+ self._thread_ready.clear()
320
+
321
+ def _run_on_loop(self, coroutine: Any) -> Any:
322
+ """Run ``coroutine`` on the sync background loop and return its result."""
323
+ if self._loop is None:
324
+ raise RuntimeError("CopilotAgentSession event loop is not running")
325
+ future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
326
+ return future.result()
327
+
328
+
329
+ __all__ = ["CopilotAgentSession"]