crabcode 0.1.6__py3-none-win_amd64.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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,708 @@
1
+ """Lifecycle tracking for gateway background tasks.
2
+
3
+ HTTP and WebSocket handlers deliberately return before a query/plan finishes.
4
+ This module gives those detached tasks an owner (session and, optionally, a
5
+ WebSocket connection) so lifecycle operations can cancel and await them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextvars
12
+ import logging
13
+ from contextlib import asynccontextmanager
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass
16
+ from typing import Any, TypeVar
17
+
18
+ from crabcode_gateway.session_registry import get_session_lock
19
+
20
+ _TASKS_ATTR = "background_tasks"
21
+ _CLOSING_ATTR = "closing_sessions"
22
+ _GATEWAY_CLOSING_ATTR = "gateway_closing"
23
+ _MAX_CANCEL_ROUNDS = 100
24
+ _LEASES_ATTR = "session_leases"
25
+ _LEASE_EVENTS_ATTR = "session_lease_events"
26
+ _LEASE_TASKS_ATTR = "session_lease_tasks"
27
+ _OPERATION_TASKS_ATTR = "operation_tasks"
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ T = TypeVar("T")
32
+
33
+ # Async generators/stream adapters are sometimes wrapped in ``wait_for`` or
34
+ # another child task. Preserve the logical lease owner across those task
35
+ # boundaries so cleanup does not cancel the parent task that owns the lease.
36
+ _LEASE_OWNER: contextvars.ContextVar[
37
+ tuple[str, asyncio.Task[Any]] | None
38
+ ] = contextvars.ContextVar("crabcode_lease_owner", default=None)
39
+
40
+
41
+ class SessionOperationRejected(RuntimeError):
42
+ """Raised when a request races a session or gateway shutdown."""
43
+
44
+
45
+ class OperationAlreadyRegistered(RuntimeError):
46
+ """Raised when an active operation already owns a session/id pair."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CancelledOperation:
51
+ """A cancelled task whose operation id remains reserved for its terminal."""
52
+
53
+ task: asyncio.Task[Any]
54
+ claim: object
55
+
56
+
57
+ def ensure_task_state(app_state: Any) -> None:
58
+ if not hasattr(app_state, _TASKS_ATTR):
59
+ setattr(app_state, _TASKS_ATTR, {})
60
+ if not hasattr(app_state, _CLOSING_ATTR):
61
+ setattr(app_state, _CLOSING_ATTR, set())
62
+ if not hasattr(app_state, _GATEWAY_CLOSING_ATTR):
63
+ setattr(app_state, _GATEWAY_CLOSING_ATTR, False)
64
+ if not hasattr(app_state, _LEASES_ATTR):
65
+ setattr(app_state, _LEASES_ATTR, {})
66
+ if not hasattr(app_state, _LEASE_EVENTS_ATTR):
67
+ setattr(app_state, _LEASE_EVENTS_ATTR, {})
68
+ if not hasattr(app_state, _LEASE_TASKS_ATTR):
69
+ setattr(app_state, _LEASE_TASKS_ATTR, {})
70
+ if not hasattr(app_state, _OPERATION_TASKS_ATTR):
71
+ setattr(app_state, _OPERATION_TASKS_ATTR, {})
72
+
73
+
74
+ def operation_is_registered(
75
+ app_state: Any,
76
+ session_id: str,
77
+ operation_id: str,
78
+ ) -> bool:
79
+ """Return whether a live task/claim owns an operation id.
80
+
81
+ Callers that use this for admission must hold the session registry lock
82
+ until they register the replacement owner.
83
+ """
84
+ ensure_task_state(app_state)
85
+ operations: dict[tuple[str, str], Any] = getattr(
86
+ app_state,
87
+ _OPERATION_TASKS_ATTR,
88
+ )
89
+ owner = operations.get((session_id, operation_id))
90
+ if isinstance(owner, asyncio.Task) and owner.done():
91
+ if operations.get((session_id, operation_id)) is owner:
92
+ operations.pop((session_id, operation_id), None)
93
+ return False
94
+ return owner is not None
95
+
96
+
97
+ def get_operation_task(
98
+ app_state: Any,
99
+ session_id: str,
100
+ operation_id: str,
101
+ *,
102
+ operation_scope: str | None = None,
103
+ ) -> asyncio.Task[Any] | None:
104
+ """Return the live task owning an operation, if it matches the scope.
105
+
106
+ The operation map also contains short-lived opaque claims while an
107
+ interrupt is publishing its terminal event. Those claims intentionally
108
+ do not resolve to a task: a control request must never attach to an
109
+ operation whose owner has already been cancelled. Callers that need a
110
+ race-free answer should hold ``get_session_lock`` while invoking this
111
+ helper.
112
+ """
113
+ ensure_task_state(app_state)
114
+ operations: dict[tuple[str, str], Any] = getattr(
115
+ app_state,
116
+ _OPERATION_TASKS_ATTR,
117
+ )
118
+ key = (session_id, operation_id)
119
+ owner = operations.get(key)
120
+ if not isinstance(owner, asyncio.Task):
121
+ return None
122
+ if owner.done():
123
+ if operations.get(key) is owner:
124
+ operations.pop(key, None)
125
+ return None
126
+ if operation_scope is not None and getattr(
127
+ owner,
128
+ "_crabcode_operation_scope",
129
+ None,
130
+ ) != operation_scope:
131
+ return None
132
+ return owner
133
+
134
+
135
+ def get_active_operation(
136
+ app_state: Any,
137
+ session_id: str,
138
+ *,
139
+ operation_scope: str | None = None,
140
+ ) -> tuple[str, asyncio.Task[Any]] | None:
141
+ """Return one live operation for a session, optionally by scope.
142
+
143
+ Foreground requests are serialized by ``CoreSession``. Discovering an
144
+ existing task at the Gateway boundary lets callers reject a second request
145
+ instead of queueing another user message behind the active turn.
146
+ Callers should hold ``get_session_lock`` while using this helper.
147
+ """
148
+ ensure_task_state(app_state)
149
+ operations: dict[tuple[str, str], Any] = getattr(
150
+ app_state,
151
+ _OPERATION_TASKS_ATTR,
152
+ )
153
+ for (candidate_session_id, operation_id), owner in list(operations.items()):
154
+ if candidate_session_id != session_id:
155
+ continue
156
+ if not isinstance(owner, asyncio.Task):
157
+ continue
158
+ if owner.done():
159
+ if operations.get((candidate_session_id, operation_id)) is owner:
160
+ operations.pop((candidate_session_id, operation_id), None)
161
+ continue
162
+ if operation_scope is not None and getattr(
163
+ owner,
164
+ "_crabcode_operation_scope",
165
+ None,
166
+ ) != operation_scope:
167
+ continue
168
+ return operation_id, owner
169
+ return None
170
+
171
+
172
+ def claim_operation(
173
+ app_state: Any,
174
+ session_id: str,
175
+ operation_id: str,
176
+ owner: object,
177
+ ) -> None:
178
+ """Reserve an operation id across an awaited admission sequence."""
179
+ if operation_is_registered(app_state, session_id, operation_id):
180
+ raise OperationAlreadyRegistered(
181
+ f"operation already active: {operation_id}"
182
+ )
183
+ operations: dict[tuple[str, str], Any] = getattr(
184
+ app_state,
185
+ _OPERATION_TASKS_ATTR,
186
+ )
187
+ operations[(session_id, operation_id)] = owner
188
+
189
+
190
+ def release_operation_claim(
191
+ app_state: Any,
192
+ session_id: str,
193
+ operation_id: str,
194
+ owner: object,
195
+ ) -> None:
196
+ """Release a reservation only when it is still owned by *owner*."""
197
+ operations: dict[tuple[str, str], Any] = getattr(
198
+ app_state,
199
+ _OPERATION_TASKS_ATTR,
200
+ {},
201
+ )
202
+ key = (session_id, operation_id)
203
+ if operations.get(key) is owner:
204
+ operations.pop(key, None)
205
+
206
+
207
+ def mark_session_closing(app_state: Any, session_id: str) -> None:
208
+ """Fence a session against new background work.
209
+
210
+ Callers should set this while holding the gateway session-registry lock,
211
+ before removing the session from the registry. Keeping the marker until
212
+ the CoreSession has actually closed prevents a concurrent resume/send
213
+ operation from installing or using the same id during cleanup.
214
+ """
215
+ ensure_task_state(app_state)
216
+ getattr(app_state, _CLOSING_ATTR).add(session_id)
217
+
218
+
219
+ def unmark_session_closing(app_state: Any, session_id: str) -> None:
220
+ ensure_task_state(app_state)
221
+ getattr(app_state, _CLOSING_ATTR).discard(session_id)
222
+
223
+
224
+ def track_task(
225
+ app_state: Any,
226
+ session_id: str,
227
+ task: asyncio.Task[Any],
228
+ *,
229
+ owner_tasks: set[asyncio.Task[Any]] | None = None,
230
+ owner_sessions: dict[asyncio.Task[Any], str] | None = None,
231
+ operation_id: str | None = None,
232
+ operation_scope: str | None = None,
233
+ operation_claim: object | None = None,
234
+ ) -> None:
235
+ """Register a detached task and consume its terminal exception."""
236
+ ensure_task_state(app_state)
237
+ if operation_id is not None:
238
+ operations: dict[tuple[str, str], Any] = getattr(
239
+ app_state,
240
+ _OPERATION_TASKS_ATTR,
241
+ )
242
+ key = (session_id, operation_id)
243
+ previous = operations.get(key)
244
+ if previous is not None and previous is not task:
245
+ if operation_claim is None or previous is not operation_claim:
246
+ raise OperationAlreadyRegistered(
247
+ f"operation already active: {operation_id}"
248
+ )
249
+ operations[key] = task
250
+ setattr(task, "_crabcode_operation_id", operation_id)
251
+ setattr(task, "_crabcode_operation_scope", operation_scope)
252
+
253
+ if (
254
+ getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
255
+ or session_id in getattr(app_state, _CLOSING_ATTR)
256
+ ):
257
+ task.cancel()
258
+ else:
259
+ tasks: dict[str, set[asyncio.Task[Any]]] = getattr(app_state, _TASKS_ATTR)
260
+ tasks.setdefault(session_id, set()).add(task)
261
+
262
+ if owner_tasks is not None:
263
+ owner_tasks.add(task)
264
+ if owner_sessions is not None:
265
+ owner_sessions[task] = session_id
266
+
267
+ def _done(done: asyncio.Task[Any]) -> None:
268
+ tasks = getattr(app_state, _TASKS_ATTR, {})
269
+ session_tasks = tasks.get(session_id)
270
+ if session_tasks is not None:
271
+ session_tasks.discard(done)
272
+ if not session_tasks:
273
+ tasks.pop(session_id, None)
274
+ if owner_tasks is not None:
275
+ owner_tasks.discard(done)
276
+ if owner_sessions is not None:
277
+ owner_sessions.pop(done, None)
278
+ if operation_id is not None:
279
+ operations = getattr(app_state, _OPERATION_TASKS_ATTR, {})
280
+ key = (session_id, operation_id)
281
+ if operations.get(key) is done:
282
+ operations.pop(key, None)
283
+ try:
284
+ done.result()
285
+ except asyncio.CancelledError:
286
+ pass
287
+ except Exception:
288
+ # The task's owner is responsible for publishing a user-facing
289
+ # error. Drain the exception here to avoid "Task exception was
290
+ # never retrieved" warnings during shutdown.
291
+ pass
292
+
293
+ task.add_done_callback(_done)
294
+
295
+
296
+ async def cancel_operation_task(
297
+ app_state: Any,
298
+ session_id: str,
299
+ operation_id: str,
300
+ *,
301
+ expected_session: object | None = None,
302
+ ) -> CancelledOperation | None:
303
+ """Cancel one operation and reserve its id until the caller emits terminal.
304
+
305
+ Replacing the task with an opaque claim under the registry lock also makes
306
+ concurrent interrupts single-owner: only the first caller can cancel the
307
+ task and publish its terminal event. Callers must release ``claim`` after
308
+ terminal publication with :func:`release_operation_claim`.
309
+ """
310
+ async with get_session_lock(app_state):
311
+ ensure_task_state(app_state)
312
+ if expected_session is not None and (
313
+ getattr(app_state, "sessions", {}).get(session_id) is not expected_session
314
+ or getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
315
+ or session_id in getattr(app_state, _CLOSING_ATTR, set())
316
+ ):
317
+ return None
318
+ operations: dict[tuple[str, str], Any] = getattr(
319
+ app_state,
320
+ _OPERATION_TASKS_ATTR,
321
+ )
322
+ owner = operations.get((session_id, operation_id))
323
+ if not isinstance(owner, asyncio.Task) or owner.done():
324
+ return None
325
+ claim = object()
326
+ operations[(session_id, operation_id)] = claim
327
+ owner.cancel()
328
+ task = owner
329
+
330
+ # Shield the operation task from cancellation of the interrupt request.
331
+ # The cancelled operation may have asynchronous cleanup of its own, and a
332
+ # second cancellation must not abandon that cleanup or strand the claim.
333
+ caller = asyncio.current_task()
334
+ caller_cancelled = False
335
+ while not task.done():
336
+ try:
337
+ await asyncio.shield(task)
338
+ except asyncio.CancelledError:
339
+ if caller is not None and caller.cancelling():
340
+ caller_cancelled = True
341
+ except Exception:
342
+ # The operation's detached-task callback is responsible for
343
+ # reporting/draining its error. Cancellation still owns the claim.
344
+ break
345
+ try:
346
+ task.result()
347
+ except (asyncio.CancelledError, Exception):
348
+ pass
349
+ if caller_cancelled:
350
+ release_operation_claim(
351
+ app_state,
352
+ session_id,
353
+ operation_id,
354
+ claim,
355
+ )
356
+ raise asyncio.CancelledError
357
+ return CancelledOperation(task=task, claim=claim)
358
+
359
+
360
+ async def cancel_tasks(
361
+ tasks: list[asyncio.Task[Any]] | set[asyncio.Task[Any]],
362
+ *,
363
+ exclude_tasks: set[asyncio.Task[Any]] | None = None,
364
+ ) -> None:
365
+ excluded = set(exclude_tasks or ())
366
+ current = asyncio.current_task()
367
+ if current is not None:
368
+ excluded.add(current)
369
+ pending = [task for task in tasks if task not in excluded and not task.done()]
370
+ for task in pending:
371
+ task.cancel()
372
+ if pending:
373
+ await asyncio.gather(*pending, return_exceptions=True)
374
+
375
+
376
+ async def cancel_session_tasks(
377
+ app_state: Any,
378
+ session_id: str,
379
+ *,
380
+ keep_closing: bool = False,
381
+ protected_tasks: set[asyncio.Task[Any]] | None = None,
382
+ ) -> None:
383
+ """Mark a session closing and await all currently registered tasks."""
384
+ ensure_task_state(app_state)
385
+ closing: set[str] = getattr(app_state, _CLOSING_ATTR)
386
+ was_closing = session_id in closing
387
+ closing.add(session_id)
388
+ tasks_by_session: dict[str, set[asyncio.Task[Any]]] = getattr(
389
+ app_state, _TASKS_ATTR
390
+ )
391
+ protected = set(protected_tasks or ())
392
+ current = asyncio.current_task()
393
+ if current is not None:
394
+ protected.add(current)
395
+ try:
396
+ # A task may create a child in a cancellation/finally callback. Keep
397
+ # draining until the registry is empty; the closing marker makes every
398
+ # such late registration immediately cancellable. The bound is only a
399
+ # guard against a broken task that continuously manufactures children.
400
+ for _ in range(_MAX_CANCEL_ROUNDS):
401
+ tasks = list(tasks_by_session.get(session_id, set()))
402
+ if not tasks:
403
+ break
404
+ if tasks and all(task in protected for task in tasks):
405
+ # A session task may defensively invoke its own cleanup. It
406
+ # cannot cancel itself; leave removal to its done callback and
407
+ # avoid spinning through the cancellation-round guard.
408
+ break
409
+ await cancel_tasks(tasks, exclude_tasks=protected)
410
+ # Give done callbacks a scheduling turn before taking the next
411
+ # snapshot. This is important when a task was already complete
412
+ # when it entered ``cancel_tasks``.
413
+ await asyncio.sleep(0)
414
+ if not tasks_by_session.get(session_id):
415
+ break
416
+ else:
417
+ remaining = len(tasks_by_session.get(session_id, set()))
418
+ if remaining:
419
+ logger.warning(
420
+ "Session %s still has %d background task(s) after %d cancellation rounds",
421
+ session_id,
422
+ remaining,
423
+ _MAX_CANCEL_ROUNDS,
424
+ )
425
+ finally:
426
+ operations: dict[tuple[str, str], Any] = getattr(
427
+ app_state,
428
+ _OPERATION_TASKS_ATTR,
429
+ {},
430
+ )
431
+ for key in [key for key in operations if key[0] == session_id]:
432
+ operations.pop(key, None)
433
+ if not keep_closing and not was_closing:
434
+ closing.discard(session_id)
435
+
436
+
437
+ async def cleanup_session(
438
+ app_state: Any,
439
+ session_id: str,
440
+ session: Any,
441
+ *,
442
+ protected_task: asyncio.Task[Any] | None = None,
443
+ owns_registry: bool = True,
444
+ ) -> None:
445
+ """Cancel owned work, close a CoreSession, then release its fence.
446
+
447
+ The marker intentionally spans both task cancellation and ``session.close``
448
+ so a resume request cannot recreate the same id while the old object's
449
+ resources are still being released. This helper is idempotent with
450
+ respect to CoreSession.close().
451
+ """
452
+ # A loaded resume candidate can lose an install race to an already
453
+ # registered CoreSession with the same id. Such a candidate must be
454
+ # closed without fencing/cancelling the winner's tasks.
455
+ if not owns_registry:
456
+ await session.close()
457
+ return
458
+
459
+ mark_session_closing(app_state, session_id)
460
+ try:
461
+ initiator = protected_task or asyncio.current_task()
462
+ protected = {initiator} if initiator is not None else None
463
+ await cancel_session_tasks(
464
+ app_state,
465
+ session_id,
466
+ keep_closing=True,
467
+ protected_tasks=protected,
468
+ )
469
+ # ``cancel_session_tasks`` deliberately leaves the current task
470
+ # alive. When cleanup is initiated from inside a streaming lease,
471
+ # waiting for all leases here would wait for this task's own finally
472
+ # block, which cannot run until cleanup returns. Every *other* lease
473
+ # is registered as a session task and has already been cancelled and
474
+ # drained above, so only wait when the current task is not the owner.
475
+ if not await _task_owns_session_lease(app_state, session_id, initiator):
476
+ await wait_session_leases(app_state, session_id)
477
+ await session.close()
478
+ finally:
479
+ unmark_session_closing(app_state, session_id)
480
+
481
+
482
+ async def shielded_cleanup_session(
483
+ app_state: Any,
484
+ session_id: str,
485
+ session: Any,
486
+ *,
487
+ owns_registry: bool = True,
488
+ ) -> None:
489
+ """Run session cleanup to completion even if its caller is cancelled."""
490
+ logical_owner = _LEASE_OWNER.get()
491
+ initiator = (
492
+ logical_owner[1]
493
+ if logical_owner is not None and logical_owner[0] == session_id
494
+ else asyncio.current_task()
495
+ )
496
+ cleanup_task = asyncio.create_task(
497
+ cleanup_session(
498
+ app_state,
499
+ session_id,
500
+ session,
501
+ protected_task=initiator,
502
+ owns_registry=owns_registry,
503
+ )
504
+ )
505
+ cancelled = False
506
+ try:
507
+ # ``shield`` keeps the child alive but immediately raises in the
508
+ # caller. A shutdown task can itself receive more than one cancel
509
+ # request while cleanup is draining; keep waiting through every such
510
+ # interruption so the CoreSession and task registry are not abandoned.
511
+ while not cleanup_task.done():
512
+ try:
513
+ await asyncio.shield(cleanup_task)
514
+ except asyncio.CancelledError:
515
+ cancelled = True
516
+
517
+ # Retrieve the child's result/exception after it has reached a
518
+ # terminal state. This also distinguishes an externally cancelled
519
+ # child from cancellation delivered to this waiter.
520
+ await cleanup_task
521
+ except asyncio.CancelledError:
522
+ # If the cleanup child itself was cancelled, preserve that terminal
523
+ # state. Otherwise this is the caller's cancellation, which is
524
+ # re-raised only after the child has been fully drained.
525
+ cancelled = True
526
+ if cancelled:
527
+ raise asyncio.CancelledError
528
+
529
+
530
+ async def cancel_owner_tasks(
531
+ owner_tasks: set[asyncio.Task[Any]],
532
+ ) -> None:
533
+ # Owner tasks can schedule another task from a cancellation callback. A
534
+ # bounded drain keeps WebSocket disconnect cleanup deterministic without
535
+ # allowing an ill-behaved callback to hang shutdown forever.
536
+ for _ in range(_MAX_CANCEL_ROUNDS):
537
+ tasks = list(owner_tasks)
538
+ if not tasks:
539
+ return
540
+ await cancel_tasks(tasks)
541
+ await asyncio.sleep(0)
542
+ if owner_tasks:
543
+ logger.warning(
544
+ "WebSocket owner still has %d background task(s) after cancellation",
545
+ len(owner_tasks),
546
+ )
547
+
548
+
549
+ async def wait_session_leases(app_state: Any, session_id: str) -> None:
550
+ """Wait for in-flight streaming handlers admitted for a session."""
551
+ ensure_task_state(app_state)
552
+ events: dict[str, asyncio.Event] = getattr(app_state, _LEASE_EVENTS_ATTR)
553
+ event = events.get(session_id)
554
+ if event is not None:
555
+ await event.wait()
556
+
557
+
558
+ async def _task_owns_session_lease(
559
+ app_state: Any,
560
+ session_id: str,
561
+ task: asyncio.Task[Any] | None = None,
562
+ ) -> bool:
563
+ """Return whether *task* owns this session's streaming lease."""
564
+ owner = task if task is not None else asyncio.current_task()
565
+ if owner is None:
566
+ return False
567
+ async with get_session_lock(app_state):
568
+ ensure_task_state(app_state)
569
+ owners: dict[str, set[asyncio.Task[Any]]] = getattr(
570
+ app_state,
571
+ _LEASE_TASKS_ATTR,
572
+ {},
573
+ )
574
+ return owner in owners.get(session_id, set())
575
+
576
+
577
+ @asynccontextmanager
578
+ async def session_lease(app_state: Any, session: Any):
579
+ """Admit a long-lived operation until it leaves the session.
580
+
581
+ Unlike ``run_session_operation`` this does not create a child task, so an
582
+ async generator can yield incrementally. Cleanup marks the session as
583
+ closing before waiting on this lease, which closes the resolve/close race.
584
+ """
585
+ session_id = getattr(session, "session_id", None)
586
+ if not session_id:
587
+ raise SessionOperationRejected("session has no id")
588
+ async with get_session_lock(app_state):
589
+ ensure_task_state(app_state)
590
+ if (
591
+ getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
592
+ or session_id in getattr(app_state, _CLOSING_ATTR)
593
+ or getattr(app_state, "sessions", {}).get(session_id) is not session
594
+ ):
595
+ raise SessionOperationRejected("session is closing")
596
+ leases: dict[str, int] = getattr(app_state, _LEASES_ATTR)
597
+ events: dict[str, asyncio.Event] = getattr(app_state, _LEASE_EVENTS_ATTR)
598
+ lease_tasks: dict[str, set[asyncio.Task[Any]]] = getattr(
599
+ app_state,
600
+ _LEASE_TASKS_ATTR,
601
+ )
602
+ leases[session_id] = leases.get(session_id, 0) + 1
603
+ events.setdefault(session_id, asyncio.Event()).clear()
604
+ # Keep the owning request visible to shutdown. A stalled stream must
605
+ # be cancelled before cleanup waits for its lease to drain.
606
+ current = asyncio.current_task()
607
+ owner_token = None
608
+ if current is not None:
609
+ getattr(app_state, _TASKS_ATTR).setdefault(session_id, set()).add(current)
610
+ lease_tasks.setdefault(session_id, set()).add(current)
611
+ owner_token = _LEASE_OWNER.set((session_id, current))
612
+ try:
613
+ yield
614
+ finally:
615
+ if owner_token is not None:
616
+ _LEASE_OWNER.reset(owner_token)
617
+ async with get_session_lock(app_state):
618
+ leases = getattr(app_state, _LEASES_ATTR, {})
619
+ events = getattr(app_state, _LEASE_EVENTS_ATTR, {})
620
+ lease_tasks = getattr(app_state, _LEASE_TASKS_ATTR, {})
621
+ tasks_by_session = getattr(app_state, _TASKS_ATTR, {})
622
+ current = asyncio.current_task()
623
+ if current is not None:
624
+ session_tasks = tasks_by_session.get(session_id)
625
+ if session_tasks is not None:
626
+ session_tasks.discard(current)
627
+ if not session_tasks:
628
+ tasks_by_session.pop(session_id, None)
629
+ if current is not None:
630
+ owners = lease_tasks.get(session_id)
631
+ if owners is not None:
632
+ owners.discard(current)
633
+ if not owners:
634
+ lease_tasks.pop(session_id, None)
635
+ remaining = leases.get(session_id, 0) - 1
636
+ if remaining <= 0:
637
+ leases.pop(session_id, None)
638
+ event = events.pop(session_id, None)
639
+ if event is not None:
640
+ event.set()
641
+ else:
642
+ leases[session_id] = remaining
643
+
644
+
645
+ async def run_session_operation(
646
+ app_state: Any,
647
+ session: Any,
648
+ operation: Callable[[], Awaitable[T]],
649
+ *,
650
+ owner_tasks: set[asyncio.Task[Any]] | None = None,
651
+ owner_sessions: dict[asyncio.Task[Any], str] | None = None,
652
+ ) -> T:
653
+ """Run one session operation with lifecycle admission fencing.
654
+
655
+ Route handlers often resolve a ``CoreSession`` and then await into it. A
656
+ concurrent archive/stop can remove and close that object in the gap. The
657
+ operation is therefore admitted and registered as a session-owned task
658
+ while holding the registry lock. Cleanup marks the session as closing,
659
+ cancels all registered work, and waits for it before closing resources.
660
+
661
+ The caller's cancellation is propagated after the owned operation is
662
+ settled. If cleanup cancelled the operation, expose a stable rejection
663
+ error instead of leaking ``CancelledError`` through an unrelated HTTP/WS
664
+ handler.
665
+ """
666
+ session_id = getattr(session, "session_id", None)
667
+ if not session_id:
668
+ raise SessionOperationRejected("session has no id")
669
+
670
+ async with get_session_lock(app_state):
671
+ ensure_task_state(app_state)
672
+ if (
673
+ getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
674
+ or session_id in getattr(app_state, _CLOSING_ATTR)
675
+ or getattr(app_state, "sessions", {}).get(session_id) is not session
676
+ ):
677
+ raise SessionOperationRejected("session is closing")
678
+ task: asyncio.Task[T] = asyncio.create_task(operation())
679
+ track_task(
680
+ app_state,
681
+ session_id,
682
+ task,
683
+ owner_tasks=owner_tasks,
684
+ owner_sessions=owner_sessions,
685
+ )
686
+
687
+ try:
688
+ # Shield lets us distinguish a shutdown cancellation (the owned task
689
+ # itself is cancelled) from a client disconnect (the waiter is
690
+ # cancelled while the operation remains alive).
691
+ return await asyncio.shield(task)
692
+ except asyncio.CancelledError:
693
+ if task.done() and task.cancelled():
694
+ if (
695
+ getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
696
+ or session_id in getattr(app_state, _CLOSING_ATTR, set())
697
+ or getattr(app_state, "sessions", {}).get(session_id) is not session
698
+ ):
699
+ raise SessionOperationRejected("session is closing")
700
+ raise
701
+
702
+ # The caller was cancelled (for example a disconnected HTTP client).
703
+ # Do not leave an operation running against a session whose owner has
704
+ # gone away; cancel and drain it before propagating cancellation.
705
+ if not task.done():
706
+ task.cancel()
707
+ await asyncio.gather(task, return_exceptions=True)
708
+ raise