soothe-client-python 0.9.4__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.
- soothe_client/__init__.py +171 -0
- soothe_client/appkit/__init__.py +138 -0
- soothe_client/appkit/attachments.py +118 -0
- soothe_client/appkit/broadcaster.py +116 -0
- soothe_client/appkit/chunk_filter.py +99 -0
- soothe_client/appkit/classifier.py +498 -0
- soothe_client/appkit/daemon_session.py +657 -0
- soothe_client/appkit/events.py +63 -0
- soothe_client/appkit/managed_client.py +163 -0
- soothe_client/appkit/observability.py +29 -0
- soothe_client/appkit/pool.py +258 -0
- soothe_client/appkit/query_gate.py +93 -0
- soothe_client/appkit/session_store.py +100 -0
- soothe_client/appkit/thinking_step.py +147 -0
- soothe_client/appkit/turn.py +362 -0
- soothe_client/appkit/turn_runner.py +565 -0
- soothe_client/errors.py +65 -0
- soothe_client/helpers.py +404 -0
- soothe_client/intent_hints.py +43 -0
- soothe_client/protocol_params.py +604 -0
- soothe_client/schemas.py +55 -0
- soothe_client/session.py +199 -0
- soothe_client/websocket.py +1626 -0
- soothe_client/ws_command_client.py +633 -0
- soothe_client_python-0.9.4.dist-info/METADATA +131 -0
- soothe_client_python-0.9.4.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/WHEEL +4 -0
- soothe_client_python-0.9.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Protocol-1 streaming frame helpers for application loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def unwrap_next(event: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
9
|
+
"""Unwrap a protocol-1 ``next`` envelope to its inner streaming frame.
|
|
10
|
+
|
|
11
|
+
Under protocol-1 the daemon wraps free-form streaming frames
|
|
12
|
+
(``event`` / ``command_response`` / card replay) in a
|
|
13
|
+
``{proto, type:"next", payload:{namespace, mode, data}}`` envelope. This
|
|
14
|
+
helper returns the inner ``data`` dict (the legacy frame) so turn loops
|
|
15
|
+
can branch on the same fields as before the migration.
|
|
16
|
+
|
|
17
|
+
``status`` / ``error`` / ``response`` / ``complete`` are sent raw and pass
|
|
18
|
+
through unchanged.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
event: A raw wire frame as returned by ``client.read_event()``.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The inner ``payload.data`` dict for ``next`` envelopes, the original
|
|
25
|
+
frame otherwise, or ``None`` if ``event`` is ``None``.
|
|
26
|
+
"""
|
|
27
|
+
if not isinstance(event, dict):
|
|
28
|
+
return event
|
|
29
|
+
if event.get("type") != "next":
|
|
30
|
+
return event
|
|
31
|
+
payload = event.get("payload")
|
|
32
|
+
if not isinstance(payload, dict):
|
|
33
|
+
return event
|
|
34
|
+
data = payload.get("data")
|
|
35
|
+
return data if isinstance(data, dict) else event
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_loop_scoped_event(event: dict[str, Any], *, active_loop_id: str) -> bool:
|
|
39
|
+
"""Return whether a daemon frame belongs to the active StrangeLoop session.
|
|
40
|
+
|
|
41
|
+
Unwraps protocol-1 ``next`` envelopes first, then checks ``loop_id`` on the
|
|
42
|
+
inner streaming frame. Non-scoped types (``response``, ``error``,
|
|
43
|
+
``complete``, etc.) are always considered in-scope.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
event: Raw or already-unwrapped wire frame.
|
|
47
|
+
active_loop_id: Loop id for the in-flight session.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True when the frame should be processed for ``active_loop_id``.
|
|
51
|
+
"""
|
|
52
|
+
event_type = event.get("type", "")
|
|
53
|
+
if event_type == "next":
|
|
54
|
+
inner = unwrap_next(event)
|
|
55
|
+
if isinstance(inner, dict):
|
|
56
|
+
event_type = inner.get("type", "")
|
|
57
|
+
return event_type not in {"status", "event"} or (inner.get("loop_id") == active_loop_id)
|
|
58
|
+
if event_type not in {"status", "event"}:
|
|
59
|
+
return True
|
|
60
|
+
return event.get("loop_id") == active_loop_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["is_loop_scoped_event", "unwrap_next"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""ManagedClient seam for appkit ConnectionPool / TurnRunner (RFC-629).
|
|
2
|
+
|
|
3
|
+
The concrete ``WebSocketClient`` satisfies it via ``WebSocketManagedClient``;
|
|
4
|
+
tests supply fakes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from soothe_client.errors import DisconnectCause
|
|
13
|
+
from soothe_client.websocket import WebSocketClient
|
|
14
|
+
|
|
15
|
+
ClientFactory = Callable[[str], "ManagedClient"]
|
|
16
|
+
BootstrapFunc = Callable[
|
|
17
|
+
["ManagedClient", str, str],
|
|
18
|
+
Awaitable[str],
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class ManagedClient(Protocol):
|
|
24
|
+
"""Subset of Layer 0 that ConnectionPool and TurnRunner depend on."""
|
|
25
|
+
|
|
26
|
+
async def connect(self) -> None:
|
|
27
|
+
"""Dial and handshake."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
async def reconnect(self) -> None:
|
|
31
|
+
"""Re-dial after a drop."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
async def reattach_and_probe(self, loop_id: str) -> None:
|
|
35
|
+
"""Resume a loop by id and probe liveness."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def send_message(self, msg: Any) -> None:
|
|
39
|
+
"""Send a fire-and-forget JSON frame (legacy flat or wire envelope)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def receive_messages(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
cancel_event: Any | None = None,
|
|
46
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
47
|
+
"""Yield decoded daemon events until EOF or cancel."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def is_disconnected(self) -> bool:
|
|
51
|
+
"""Return whether the disconnect signal has fired."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def disconnect_cause(self) -> DisconnectCause | None:
|
|
55
|
+
"""Return the disconnect cause, or None if still connected."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def is_connected(self) -> bool:
|
|
59
|
+
"""Report connection liveness."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
"""Tear down the connection."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WebSocketManagedClient:
|
|
68
|
+
"""Adapter wrapping ``WebSocketClient`` as a ``ManagedClient``."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, client: WebSocketClient) -> None:
|
|
71
|
+
self._client = client
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def underlying(self) -> WebSocketClient:
|
|
75
|
+
"""Return the wrapped Layer 0 client."""
|
|
76
|
+
return self._client
|
|
77
|
+
|
|
78
|
+
async def connect(self) -> None:
|
|
79
|
+
await self._client.connect()
|
|
80
|
+
await self._client.request_connection_init()
|
|
81
|
+
await self._client.wait_for_connection_ack()
|
|
82
|
+
|
|
83
|
+
async def reconnect(self) -> None:
|
|
84
|
+
await self._client.reconnect()
|
|
85
|
+
|
|
86
|
+
async def reattach_and_probe(self, loop_id: str) -> None:
|
|
87
|
+
await self._client.reattach_and_probe(loop_id)
|
|
88
|
+
|
|
89
|
+
async def send_message(self, msg: Any) -> None:
|
|
90
|
+
if not isinstance(msg, dict):
|
|
91
|
+
raise TypeError("send_message expects a dict payload")
|
|
92
|
+
await self._client.send(msg)
|
|
93
|
+
|
|
94
|
+
async def receive_messages(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
cancel_event: Any | None = None,
|
|
98
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
99
|
+
while True:
|
|
100
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
101
|
+
return
|
|
102
|
+
event = await self._client.read_event()
|
|
103
|
+
if event is None:
|
|
104
|
+
return
|
|
105
|
+
yield event
|
|
106
|
+
|
|
107
|
+
def is_disconnected(self) -> bool:
|
|
108
|
+
return self._client.is_disconnected()
|
|
109
|
+
|
|
110
|
+
def disconnect_cause(self) -> DisconnectCause | None:
|
|
111
|
+
return self._client.disconnect_cause()
|
|
112
|
+
|
|
113
|
+
def is_connected(self) -> bool:
|
|
114
|
+
alive = getattr(self._client, "is_connection_alive", None)
|
|
115
|
+
if callable(alive):
|
|
116
|
+
return bool(alive()) and not self._client.is_disconnected()
|
|
117
|
+
return bool(self._client.is_connected) and not self._client.is_disconnected()
|
|
118
|
+
|
|
119
|
+
async def close(self) -> None:
|
|
120
|
+
await self._client.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def default_client_factory() -> ClientFactory:
|
|
124
|
+
"""Return a factory that builds ``WebSocketManagedClient`` instances."""
|
|
125
|
+
|
|
126
|
+
def _factory(url: str) -> ManagedClient:
|
|
127
|
+
return WebSocketManagedClient(WebSocketClient(url=url))
|
|
128
|
+
|
|
129
|
+
return _factory
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def default_bootstrap_func() -> BootstrapFunc:
|
|
133
|
+
"""Default bootstrap: ``loop_new`` + ``subscribe(loop_events)``."""
|
|
134
|
+
|
|
135
|
+
async def _bootstrap(client: ManagedClient, workspace_id: str, user_id: str) -> str:
|
|
136
|
+
from soothe_client.session import bootstrap_loop_session
|
|
137
|
+
|
|
138
|
+
underlying = getattr(client, "underlying", client)
|
|
139
|
+
status = await bootstrap_loop_session(
|
|
140
|
+
underlying,
|
|
141
|
+
resume_loop_id=None,
|
|
142
|
+
user_id=user_id or None,
|
|
143
|
+
client_workspace_id=workspace_id or None,
|
|
144
|
+
workspace=workspace_id or None,
|
|
145
|
+
)
|
|
146
|
+
if status.get("type") == "error":
|
|
147
|
+
raise RuntimeError(str(status.get("message", "daemon bootstrap failed")))
|
|
148
|
+
loop_id = str(status.get("loop_id") or "").strip()
|
|
149
|
+
if not loop_id:
|
|
150
|
+
raise RuntimeError("bootstrap_loop_session returned no loop_id")
|
|
151
|
+
return loop_id
|
|
152
|
+
|
|
153
|
+
return _bootstrap
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = [
|
|
157
|
+
"BootstrapFunc",
|
|
158
|
+
"ClientFactory",
|
|
159
|
+
"ManagedClient",
|
|
160
|
+
"WebSocketManagedClient",
|
|
161
|
+
"default_bootstrap_func",
|
|
162
|
+
"default_client_factory",
|
|
163
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Per-turn observability counters for daemon stream consumption."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class TurnEventStats:
|
|
10
|
+
"""Event counts accumulated over a single daemon turn.
|
|
11
|
+
|
|
12
|
+
Apps may subclass or replace this via ``DaemonSession``'s stats factory.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
total: int = 0
|
|
16
|
+
messages: int = 0
|
|
17
|
+
updates: int = 0
|
|
18
|
+
custom: int = 0
|
|
19
|
+
skipped: int = 0
|
|
20
|
+
filtered_early: int = 0
|
|
21
|
+
tool_calls: int = 0
|
|
22
|
+
tool_results: int = 0
|
|
23
|
+
text_chunks: int = 0
|
|
24
|
+
heartbeats_dropped: int = 0
|
|
25
|
+
post_idle_drained: int = 0
|
|
26
|
+
inbound_dropped: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["TurnEventStats"]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Per-session connection pool for appkit (RFC-629 Layer 1).
|
|
2
|
+
|
|
3
|
+
Manages a pool of daemon connections, one active per session. Reuses an
|
|
4
|
+
active connection when still live, otherwise bootstraps a fresh loop or
|
|
5
|
+
reattaches an existing one. Persistence of session↔loop mappings is
|
|
6
|
+
abstracted behind ``SessionStore``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from soothe_client.appkit.managed_client import (
|
|
19
|
+
BootstrapFunc,
|
|
20
|
+
ClientFactory,
|
|
21
|
+
ManagedClient,
|
|
22
|
+
default_bootstrap_func,
|
|
23
|
+
default_client_factory,
|
|
24
|
+
)
|
|
25
|
+
from soothe_client.appkit.session_store import SessionStore
|
|
26
|
+
from soothe_client.errors import StaleLoopError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ErrPoolExhausted(Exception): # noqa: N818 — match Go/TS name
|
|
30
|
+
"""Raised when no free connection slot is available."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
super().__init__("appkit: connection pool exhausted")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class PoolConfig:
|
|
38
|
+
"""Configures a ``ConnectionPool``. Zero / negative sizes use defaults."""
|
|
39
|
+
|
|
40
|
+
pool_size: int = 1000
|
|
41
|
+
query_timeout_s: float = 30 * 60
|
|
42
|
+
connection_timeout_s: float = 30.0
|
|
43
|
+
max_idle_time_s: float = 10 * 60
|
|
44
|
+
health_check_interval_s: float = 30.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_pool_config() -> PoolConfig:
|
|
48
|
+
"""Return conservative pool defaults suitable for most apps."""
|
|
49
|
+
return PoolConfig()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PooledConn:
|
|
53
|
+
"""One connection slot in the pool."""
|
|
54
|
+
|
|
55
|
+
__slots__ = (
|
|
56
|
+
"slot_id",
|
|
57
|
+
"client",
|
|
58
|
+
"event_stream",
|
|
59
|
+
"stream_cancel",
|
|
60
|
+
"session_id",
|
|
61
|
+
"loop_id",
|
|
62
|
+
"workspace_id",
|
|
63
|
+
"last_used",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def __init__(self, slot_id: int, client: ManagedClient) -> None:
|
|
67
|
+
self.slot_id = slot_id
|
|
68
|
+
self.client = client
|
|
69
|
+
self.event_stream: AsyncIterator[dict[str, Any]] | None = None
|
|
70
|
+
self.stream_cancel: asyncio.Event | None = None
|
|
71
|
+
self.session_id = ""
|
|
72
|
+
self.loop_id = ""
|
|
73
|
+
self.workspace_id = ""
|
|
74
|
+
self.last_used = 0.0
|
|
75
|
+
|
|
76
|
+
def is_disconnected(self) -> bool:
|
|
77
|
+
"""Report whether the underlying client signalled a drop."""
|
|
78
|
+
return self.client.is_disconnected()
|
|
79
|
+
|
|
80
|
+
def is_connected(self) -> bool:
|
|
81
|
+
"""Report whether the slot is still usable."""
|
|
82
|
+
return self.client.is_connected() and not self.is_disconnected()
|
|
83
|
+
|
|
84
|
+
def get_loop_id(self) -> str:
|
|
85
|
+
"""Return the active loop id for this slot."""
|
|
86
|
+
return self.loop_id
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ConnectionPool:
|
|
90
|
+
"""Manage a pool of daemon connections, one active per session."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
url: str,
|
|
95
|
+
store: SessionStore,
|
|
96
|
+
cfg: PoolConfig | None = None,
|
|
97
|
+
factory: ClientFactory | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
self._cfg = cfg or default_pool_config()
|
|
100
|
+
if self._cfg.pool_size <= 0:
|
|
101
|
+
self._cfg = PoolConfig(
|
|
102
|
+
pool_size=default_pool_config().pool_size,
|
|
103
|
+
query_timeout_s=self._cfg.query_timeout_s,
|
|
104
|
+
connection_timeout_s=self._cfg.connection_timeout_s,
|
|
105
|
+
max_idle_time_s=self._cfg.max_idle_time_s,
|
|
106
|
+
health_check_interval_s=self._cfg.health_check_interval_s,
|
|
107
|
+
)
|
|
108
|
+
self._factory = factory or default_client_factory()
|
|
109
|
+
self._bootstrap: BootstrapFunc = default_bootstrap_func()
|
|
110
|
+
self._store = store
|
|
111
|
+
self._url = url
|
|
112
|
+
self._pool: list[PooledConn] = []
|
|
113
|
+
self._active: dict[str, PooledConn] = {}
|
|
114
|
+
self._next_slot_id = 1
|
|
115
|
+
self._lock = asyncio.Lock()
|
|
116
|
+
for _ in range(self._cfg.pool_size):
|
|
117
|
+
self._pool.append(self._new_slot())
|
|
118
|
+
|
|
119
|
+
def with_bootstrap(self, bootstrap: BootstrapFunc) -> ConnectionPool:
|
|
120
|
+
"""Override the loop bootstrap function (useful for test fakes)."""
|
|
121
|
+
self._bootstrap = bootstrap
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def with_client_factory(self, factory: ClientFactory) -> ConnectionPool:
|
|
125
|
+
"""Override the client factory (useful for test fakes)."""
|
|
126
|
+
self._factory = factory
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def stats(self) -> dict[str, int]:
|
|
130
|
+
"""Return a snapshot of active and idle slot counts."""
|
|
131
|
+
return {"active": len(self._active), "idle": len(self._pool)}
|
|
132
|
+
|
|
133
|
+
def _new_slot(self) -> PooledConn:
|
|
134
|
+
slot_id = self._next_slot_id
|
|
135
|
+
self._next_slot_id += 1
|
|
136
|
+
return PooledConn(slot_id, self._factory(self._url))
|
|
137
|
+
|
|
138
|
+
async def acquire(
|
|
139
|
+
self,
|
|
140
|
+
session_id: str,
|
|
141
|
+
workspace_id: str,
|
|
142
|
+
user_id: str,
|
|
143
|
+
) -> PooledConn:
|
|
144
|
+
"""Return a live connection for ``session_id``.
|
|
145
|
+
|
|
146
|
+
Caller must ``release`` when done (turn completes or session reset).
|
|
147
|
+
"""
|
|
148
|
+
async with self._lock:
|
|
149
|
+
existing = self._active.get(session_id)
|
|
150
|
+
if existing is not None:
|
|
151
|
+
if existing.is_disconnected() or not existing.is_connected():
|
|
152
|
+
await self._release_unlocked(session_id)
|
|
153
|
+
else:
|
|
154
|
+
existing.last_used = time.monotonic()
|
|
155
|
+
with contextlib.suppress(Exception):
|
|
156
|
+
await self._store.update_last_used(session_id)
|
|
157
|
+
return existing
|
|
158
|
+
|
|
159
|
+
if not self._pool:
|
|
160
|
+
raise ErrPoolExhausted()
|
|
161
|
+
conn = self._pool.pop()
|
|
162
|
+
self._active[session_id] = conn
|
|
163
|
+
|
|
164
|
+
loop_id = ""
|
|
165
|
+
ok = False
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
loop_id, ok = await self._store.get_loop_id_for_session(session_id)
|
|
168
|
+
|
|
169
|
+
final_loop_id = ""
|
|
170
|
+
try:
|
|
171
|
+
if not ok or not loop_id:
|
|
172
|
+
await conn.client.connect()
|
|
173
|
+
final_loop_id = await self._bootstrap_new(conn, workspace_id, user_id)
|
|
174
|
+
with contextlib.suppress(Exception):
|
|
175
|
+
await self._store.create_session(workspace_id, session_id, final_loop_id, "")
|
|
176
|
+
else:
|
|
177
|
+
try:
|
|
178
|
+
await self._resume_and_reattach(conn, loop_id)
|
|
179
|
+
final_loop_id = loop_id
|
|
180
|
+
except Exception:
|
|
181
|
+
await conn.client.connect()
|
|
182
|
+
final_loop_id = await self._bootstrap_new(conn, workspace_id, user_id)
|
|
183
|
+
with contextlib.suppress(Exception):
|
|
184
|
+
await self._store.create_session(
|
|
185
|
+
workspace_id, session_id, final_loop_id, ""
|
|
186
|
+
)
|
|
187
|
+
except Exception:
|
|
188
|
+
await self.release(session_id)
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
conn.session_id = session_id
|
|
192
|
+
conn.loop_id = final_loop_id
|
|
193
|
+
conn.workspace_id = workspace_id
|
|
194
|
+
conn.last_used = time.monotonic()
|
|
195
|
+
with contextlib.suppress(Exception):
|
|
196
|
+
await self._store.update_last_used(session_id)
|
|
197
|
+
return conn
|
|
198
|
+
|
|
199
|
+
async def release(self, session_id: str) -> None:
|
|
200
|
+
"""Tear down the connection for ``session_id`` and return a fresh slot."""
|
|
201
|
+
async with self._lock:
|
|
202
|
+
await self._release_unlocked(session_id)
|
|
203
|
+
|
|
204
|
+
async def _release_unlocked(self, session_id: str) -> None:
|
|
205
|
+
conn = self._active.pop(session_id, None)
|
|
206
|
+
if conn is None:
|
|
207
|
+
return
|
|
208
|
+
if conn.stream_cancel is not None:
|
|
209
|
+
conn.stream_cancel.set()
|
|
210
|
+
conn.stream_cancel = None
|
|
211
|
+
with contextlib.suppress(Exception):
|
|
212
|
+
await conn.client.close()
|
|
213
|
+
conn.session_id = ""
|
|
214
|
+
conn.loop_id = ""
|
|
215
|
+
conn.event_stream = None
|
|
216
|
+
self._pool.append(self._new_slot())
|
|
217
|
+
|
|
218
|
+
async def reset_session(self, session_id: str) -> None:
|
|
219
|
+
"""Tear down so the next acquire bootstraps fresh."""
|
|
220
|
+
await self.release(session_id)
|
|
221
|
+
|
|
222
|
+
async def stop(self) -> None:
|
|
223
|
+
"""Gracefully shut down all active connections."""
|
|
224
|
+
async with self._lock:
|
|
225
|
+
for sid in list(self._active):
|
|
226
|
+
await self._release_unlocked(sid)
|
|
227
|
+
|
|
228
|
+
async def _bootstrap_new(
|
|
229
|
+
self,
|
|
230
|
+
conn: PooledConn,
|
|
231
|
+
workspace_id: str,
|
|
232
|
+
user_id: str,
|
|
233
|
+
) -> str:
|
|
234
|
+
loop_id = await self._bootstrap(conn.client, workspace_id, user_id)
|
|
235
|
+
self._start_reader(conn)
|
|
236
|
+
return loop_id
|
|
237
|
+
|
|
238
|
+
async def _resume_and_reattach(self, conn: PooledConn, loop_id: str) -> None:
|
|
239
|
+
await conn.client.connect()
|
|
240
|
+
try:
|
|
241
|
+
await conn.client.reattach_and_probe(loop_id)
|
|
242
|
+
except StaleLoopError:
|
|
243
|
+
raise
|
|
244
|
+
self._start_reader(conn)
|
|
245
|
+
|
|
246
|
+
def _start_reader(self, conn: PooledConn) -> None:
|
|
247
|
+
cancel = asyncio.Event()
|
|
248
|
+
conn.stream_cancel = cancel
|
|
249
|
+
conn.event_stream = conn.client.receive_messages(cancel_event=cancel)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = [
|
|
253
|
+
"ConnectionPool",
|
|
254
|
+
"ErrPoolExhausted",
|
|
255
|
+
"PoolConfig",
|
|
256
|
+
"PooledConn",
|
|
257
|
+
"default_pool_config",
|
|
258
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Single-flight query gate (RFC-629 Layer 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
SendCancelFn = Callable[[], Awaitable[None] | None]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ErrQueryBusy(Exception): # noqa: N818 — match Go/TS `ErrQueryBusy` name
|
|
13
|
+
"""Raised when a session already has an in-flight query."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__("appkit: query already in progress for session")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QueryGate:
|
|
20
|
+
"""Enforce single-flight query execution per session id.
|
|
21
|
+
|
|
22
|
+
Cancel ordering: daemon cancel (detached timeout) runs before the local
|
|
23
|
+
cancel callback, matching Go/TS appkit semantics.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, cancel_timeout_s: float = 10.0) -> None:
|
|
27
|
+
self._lock = asyncio.Lock()
|
|
28
|
+
self._cancels: dict[str, Callable[[], None]] = {}
|
|
29
|
+
self._send_cancels: dict[str, SendCancelFn] = {}
|
|
30
|
+
self._cancel_timeout_s = cancel_timeout_s
|
|
31
|
+
|
|
32
|
+
async def acquire(
|
|
33
|
+
self,
|
|
34
|
+
session_id: str,
|
|
35
|
+
cancel: Callable[[], None],
|
|
36
|
+
send_cancel: SendCancelFn | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Reserve ``session_id`` for one agent turn.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
session_id: Application session key (often the loop id).
|
|
42
|
+
cancel: Local cancel callback (e.g. set an Event / cancel a task).
|
|
43
|
+
send_cancel: Optional async daemon-cancel sender.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ErrQueryBusy: A query is already in flight for ``session_id``.
|
|
47
|
+
"""
|
|
48
|
+
async with self._lock:
|
|
49
|
+
if session_id in self._cancels:
|
|
50
|
+
raise ErrQueryBusy()
|
|
51
|
+
self._cancels[session_id] = cancel
|
|
52
|
+
if send_cancel is not None:
|
|
53
|
+
self._send_cancels[session_id] = send_cancel
|
|
54
|
+
|
|
55
|
+
async def cancel(self, session_id: str) -> None:
|
|
56
|
+
"""Stop an in-flight query: daemon cancel first, then local cancel."""
|
|
57
|
+
async with self._lock:
|
|
58
|
+
cancel = self._cancels.pop(session_id, None)
|
|
59
|
+
send_cancel = self._send_cancels.pop(session_id, None)
|
|
60
|
+
|
|
61
|
+
if cancel is None and send_cancel is None:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if send_cancel is not None:
|
|
65
|
+
try:
|
|
66
|
+
await asyncio.wait_for(
|
|
67
|
+
_await_maybe(send_cancel()),
|
|
68
|
+
timeout=self._cancel_timeout_s,
|
|
69
|
+
)
|
|
70
|
+
except Exception: # noqa: BLE001 — proceed to local cancel
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
if cancel is not None:
|
|
74
|
+
cancel()
|
|
75
|
+
|
|
76
|
+
async def release(self, session_id: str) -> None:
|
|
77
|
+
"""Clear the gate without sending a daemon cancel."""
|
|
78
|
+
async with self._lock:
|
|
79
|
+
self._cancels.pop(session_id, None)
|
|
80
|
+
self._send_cancels.pop(session_id, None)
|
|
81
|
+
|
|
82
|
+
async def is_active(self, session_id: str) -> bool:
|
|
83
|
+
"""Return whether a query is in flight for ``session_id``."""
|
|
84
|
+
async with self._lock:
|
|
85
|
+
return session_id in self._cancels
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _await_maybe(result: Any) -> None:
|
|
89
|
+
if asyncio.iscoroutine(result) or isinstance(result, Awaitable):
|
|
90
|
+
await result # type: ignore[misc]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = ["ErrQueryBusy", "QueryGate"]
|