magpie-gemini-web 0.3.2 → 0.4.0

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.
@@ -0,0 +1,806 @@
1
+ """State management, persistence, session snapshots, and ticket lifecycle."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import secrets
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .config import RuntimeConfig, atomic_write_file
14
+ from .contract import BridgeError, CompletionRequest, SESSION, canonical, invalid, json_loads
15
+ from .protocol import MODEL_ID
16
+ from .transport import NativeTask
17
+
18
+
19
+ def make_tool_response(call_id: str, name: str, args: dict, created: int) -> dict:
20
+ return {
21
+ "id": f"chatcmpl-{secrets.token_hex(12)}",
22
+ "object": "chat.completion",
23
+ "created": created,
24
+ "model": MODEL_ID,
25
+ "choices": [{
26
+ "index": 0,
27
+ "message": {
28
+ "role": "assistant",
29
+ "content": None,
30
+ "tool_calls": [{
31
+ "id": call_id,
32
+ "type": "function",
33
+ "function": {
34
+ "name": name,
35
+ "arguments": canonical(args),
36
+ },
37
+ }],
38
+ },
39
+ "finish_reason": "tool_calls",
40
+ }],
41
+ }
42
+
43
+
44
+ def make_final_response(text: str, created: int) -> dict:
45
+ return {
46
+ "id": f"chatcmpl-{secrets.token_hex(12)}",
47
+ "object": "chat.completion",
48
+ "created": created,
49
+ "model": MODEL_ID,
50
+ "choices": [{
51
+ "index": 0,
52
+ "message": {
53
+ "role": "assistant",
54
+ "content": text,
55
+ },
56
+ "finish_reason": "stop",
57
+ }],
58
+ }
59
+
60
+
61
+ class Ticket:
62
+ def __init__(self, task_id: str):
63
+ self.task_id = task_id
64
+ self.event = threading.Event()
65
+ self.response: dict | None = None
66
+ self.error: BridgeError | None = None
67
+
68
+ def result(self, timeout: float | None = None) -> dict:
69
+ if not self.event.wait(timeout):
70
+ raise TimeoutError("Ticket timed out waiting for result.")
71
+ if self.error is not None:
72
+ raise self.error
73
+ if self.response is None:
74
+ raise BridgeError("empty_response", "Ticket finished without a response.", 409)
75
+ return self.response
76
+
77
+
78
+ class _TaskSink:
79
+ def __init__(self, manager: Manager, session_id: str, task_id: str):
80
+ self.manager = manager
81
+ self.session_id = session_id
82
+ self.task_id = task_id
83
+
84
+ def tool(self, name: str, arguments: dict) -> str:
85
+ return self.manager._on_sink_tool(self.session_id, self.task_id, name, arguments)
86
+
87
+ def complete(self, text: str) -> None:
88
+ self.manager._on_sink_complete(self.session_id, self.task_id, text)
89
+
90
+ def fail(self, error: BridgeError) -> None:
91
+ self.manager._on_sink_fail(self.session_id, self.task_id, error)
92
+
93
+
94
+ class Manager:
95
+ """Manages Task sessions, persistent snapshots, and ticket dispatch."""
96
+
97
+ def __init__(self, config: RuntimeConfig, transport_factory=NativeTask):
98
+ self.config = config
99
+ self.transport_factory = transport_factory
100
+ self.sessions_dir = self.config.data_dir / "sessions"
101
+ self.sessions_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
102
+
103
+ self._lock = threading.RLock()
104
+ self._storage_failed = False
105
+ self._closed = False
106
+
107
+ self._active_task_id: str | None = None
108
+ self._active_session_id: str | None = None
109
+ self._active_digest: str | None = None
110
+ self._active_request: CompletionRequest | None = None
111
+ self._active_transport: Any = None
112
+ self._active_ticket: Ticket | None = None
113
+ self._task_start_time: float | None = None
114
+ self._pending_deadline: float | None = None
115
+
116
+ self._stop_watchdog = threading.Event()
117
+ self._watchdog_thread = threading.Thread(target=self._watchdog_loop, daemon=True, name="ManagerWatchdog")
118
+ self._recover_and_start()
119
+
120
+ def _snapshot_path(self, session_id: str) -> Path:
121
+ key = canonical([self.config.instance_id, session_id])
122
+ h = hashlib.sha256(key.encode()).hexdigest()
123
+ return self.sessions_dir / f"{h}.json"
124
+
125
+ def _clear_active(self):
126
+ self._active_task_id = None
127
+ self._active_session_id = None
128
+ self._active_digest = None
129
+ self._active_request = None
130
+ self._active_ticket = None
131
+ self._task_start_time = None
132
+ self._pending_deadline = None
133
+ self._active_transport = None
134
+
135
+ def _storage_fault(self, error: BridgeError) -> BridgeError:
136
+ self._storage_failed = True
137
+ if self._active_transport is not None:
138
+ try:
139
+ self._active_transport.stop()
140
+ except Exception:
141
+ pass
142
+ ticket = self._active_ticket
143
+ self._clear_active()
144
+ if ticket and not ticket.event.is_set():
145
+ ticket.error = error
146
+ ticket.event.set()
147
+ return error
148
+
149
+ def _validate_snapshot(self, data: Any, path: Path, expected_session: str | None = None) -> dict:
150
+ def need(condition):
151
+ if not condition:
152
+ raise BridgeError("state_corrupt", "Session journal failed validation.", 409)
153
+
154
+ def ident(value):
155
+ return isinstance(value, str) and SESSION.fullmatch(value) is not None
156
+
157
+ def error_record(value):
158
+ if value is not None:
159
+ need(isinstance(value, dict))
160
+ need(isinstance(value.get("code"), str) and bool(value["code"]))
161
+ need(isinstance(value.get("message"), str))
162
+ need(type(value.get("status")) is int and 400 <= value["status"] <= 499)
163
+
164
+ def tool_call(value):
165
+ need(isinstance(value, dict) and ident(value.get("id")) and value.get("type") == "function")
166
+ fn = value.get("function")
167
+ need(isinstance(fn, dict) and ident(fn.get("name")) and isinstance(fn.get("arguments"), str))
168
+ args = json_loads(fn["arguments"])
169
+ need(isinstance(args, dict))
170
+ return value["id"], fn["name"], args
171
+
172
+ need(isinstance(data, dict) and type(data.get("version")) is int and data["version"] == 1)
173
+ need(data.get("instance_id") == self.config.instance_id and ident(data.get("session_id")))
174
+ session = data["session_id"]
175
+ need(expected_session in (None, session) and self._snapshot_path(session) == path)
176
+ need(ident(data.get("task_id")) and ident(data.get("signature")))
177
+ need(type(data.get("calls")) is int and 0 <= data["calls"] <= self.config.max_tool_calls)
178
+ need(type(data.get("updated_at")) in (int, float) and math.isfinite(data["updated_at"]))
179
+ need(isinstance(data.get("history"), list) and bool(data["history"]))
180
+ for message in data["history"]:
181
+ need(isinstance(message, dict) and message.get("role") in {"system", "developer", "user", "assistant", "tool"})
182
+ need(isinstance(message.get("content"), str))
183
+ if message.get("role") == "tool":
184
+ need(ident(message.get("tool_call_id")))
185
+ if "tool_calls" in message:
186
+ need(message["role"] == "assistant" and isinstance(message["tool_calls"], list) and bool(message["tool_calls"]))
187
+ for call in message["tool_calls"]:
188
+ tool_call(call)
189
+
190
+ need(isinstance(data.get("requests"), dict) and 1 <= len(data["requests"]) <= 256)
191
+ current_records = []
192
+ current_tools = []
193
+ for request_id, record in data["requests"].items():
194
+ need(ident(request_id) and isinstance(record, dict) and ident(record.get("task_id")))
195
+ need("response" in record and "error" in record)
196
+ response, error = record["response"], record["error"]
197
+ need(response is None or error is None)
198
+ error_record(error)
199
+ if record["task_id"] == data["task_id"]:
200
+ current_records.append(record)
201
+ if response is None:
202
+ continue
203
+ need(isinstance(response, dict) and ident(response.get("id")) and response.get("object") == "chat.completion")
204
+ choices = response.get("choices")
205
+ need(isinstance(choices, list) and len(choices) == 1 and isinstance(choices[0], dict))
206
+ choice = choices[0]
207
+ need(type(choice.get("index")) is int and choice["index"] == 0)
208
+ message = choice.get("message")
209
+ need(isinstance(message, dict) and message.get("role") == "assistant")
210
+ if choice.get("finish_reason") == "tool_calls":
211
+ calls = message.get("tool_calls")
212
+ need(isinstance(calls, list) and len(calls) == 1)
213
+ details = tool_call(calls[0])
214
+ if record["task_id"] == data["task_id"]:
215
+ current_tools.append(details)
216
+ else:
217
+ need(choice.get("finish_reason") == "stop" and isinstance(message.get("content"), str) and bool(message["content"].strip()))
218
+ need(bool(current_records))
219
+ need("error" in data and "pending" in data)
220
+ error_record(data["error"])
221
+ status = data.get("status")
222
+ need(status in {"running", "waiting_for_tool", "complete", "failed", "abandoned", "interrupted"})
223
+ if status == "waiting_for_tool":
224
+ pending = data["pending"]
225
+ need(isinstance(pending, dict) and ident(pending.get("id")) and ident(pending.get("name")))
226
+ need(isinstance(pending.get("arguments"), dict))
227
+ need(type(pending.get("received_at")) in (int, float) and math.isfinite(pending["received_at"]))
228
+ last = data["history"][-1]
229
+ need(last.get("role") == "assistant" and len(last.get("tool_calls", [])) == 1)
230
+ details = (pending["id"], pending["name"], pending["arguments"])
231
+ need(tool_call(last["tool_calls"][0]) == details and details in current_tools)
232
+ else:
233
+ need(data["pending"] is None)
234
+ if status == "running":
235
+ need(any(r["response"] is None and r["error"] is None for r in current_records))
236
+ if status in {"running", "waiting_for_tool", "complete"}:
237
+ need(data["error"] is None)
238
+ else:
239
+ need(data["error"] is not None)
240
+ return data
241
+
242
+ def _load_snapshot(self, session_id: str) -> dict | None:
243
+ path = self._snapshot_path(session_id)
244
+ if not path.exists():
245
+ if self._active_session_id == session_id:
246
+ raise self._storage_fault(BridgeError("state_corrupt", "Active session snapshot disappeared.", 409))
247
+ return None
248
+
249
+ try:
250
+ raw_text = path.read_text(encoding="utf-8")
251
+ data = json_loads(raw_text)
252
+ return self._validate_snapshot(data, path, expected_session=session_id)
253
+ except BridgeError as be:
254
+ raise self._storage_fault(be)
255
+ except Exception as e:
256
+ raise self._storage_fault(BridgeError("state_corrupt", "Session journal cannot be read or validated.", 409)) from e
257
+
258
+ def _save_snapshot(self, snapshot: dict):
259
+ if self._storage_failed:
260
+ raise BridgeError("state_storage_failed", "Storage is in a failed state.", 409)
261
+
262
+ session_id = snapshot["session_id"]
263
+ path = self._snapshot_path(session_id)
264
+ data = json.dumps(snapshot, indent=2).encode()
265
+ try:
266
+ atomic_write_file(path, data, mode=0o600)
267
+ except Exception as e:
268
+ raise self._storage_fault(BridgeError("state_storage_failed", "Session journal could not be persisted.", 409)) from e
269
+
270
+ def _record_terminal_error(self, snapshot: dict, error: BridgeError, status: str):
271
+ snapshot["status"] = status
272
+ snapshot["pending"] = None
273
+ snapshot["error"] = {"code": error.code, "message": error.message, "status": error.status}
274
+ snapshot["updated_at"] = time.time()
275
+ for record in snapshot.get("requests", {}).values():
276
+ if record.get("task_id") == snapshot.get("task_id") and record.get("response") is None and record.get("error") is None:
277
+ record["error"] = dict(snapshot["error"])
278
+
279
+ def _recover_and_start(self):
280
+ with self._lock:
281
+ # 1. Validate all files and filename bindings first
282
+ validated = []
283
+ for p in self.sessions_dir.glob("*.json"):
284
+ try:
285
+ raw = p.read_text(encoding="utf-8")
286
+ data = json_loads(raw)
287
+ snap = self._validate_snapshot(data, p)
288
+ validated.append((p, snap))
289
+ except Exception as e:
290
+ self._storage_failed = True
291
+ raise BridgeError("state_corrupt", f"Corrupt snapshot found on startup: {p}", 409) from e
292
+
293
+ # 2. Record interruption on any active snapshot
294
+ for p, snap in validated:
295
+ if snap.get("status") in ("running", "waiting_for_tool"):
296
+ interr_err = BridgeError("interrupted", "Task interrupted on daemon restart.", 409)
297
+ self._record_terminal_error(snap, interr_err, "interrupted")
298
+ atomic_write_file(p, json.dumps(snap, indent=2).encode(), mode=0o600)
299
+
300
+ self._watchdog_thread.start()
301
+
302
+ def submit(self, session_id: str, request: CompletionRequest) -> Ticket:
303
+ if not isinstance(session_id, str) or not SESSION.fullmatch(session_id):
304
+ raise invalid("invalid_session_id", "Session ID must be 1-256 non-whitespace ASCII characters.")
305
+
306
+ with self._lock:
307
+ if self._closed:
308
+ raise BridgeError("service_stopped", "Bridge service is closed.", 409)
309
+ if self._storage_failed:
310
+ raise BridgeError("state_storage_failed", "Storage is in a failed state.", 409)
311
+
312
+ # Enforce total deadline of existing active task if any
313
+ now = time.monotonic()
314
+ if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
315
+ if self._active_session_id and self._active_task_id:
316
+ err = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
317
+ self._on_sink_fail(self._active_session_id, self._active_task_id, err)
318
+
319
+ snapshot = self._load_snapshot(session_id)
320
+
321
+ if snapshot is None:
322
+ # New session
323
+ if request.messages[-1]["role"] == "tool":
324
+ raise BridgeError("unknown_task", "Cannot submit a tool result to an unknown session.", 409)
325
+ if request.messages[-1]["role"] != "user":
326
+ raise invalid("invalid_initial_message", "A new session must end in a user message.")
327
+
328
+ existing_count = len(list(self.sessions_dir.glob("*.json")))
329
+ if existing_count >= 128:
330
+ raise BridgeError("session_capacity", "Maximum stored sessions reached (128).", 409)
331
+
332
+ if self._active_task_id is not None:
333
+ raise BridgeError("concurrency_limit", "Another native task is currently active.", 409)
334
+
335
+ task_id = "task_" + secrets.token_hex(12)
336
+ snapshot = {
337
+ "version": 1,
338
+ "session_id": session_id,
339
+ "instance_id": self.config.instance_id,
340
+ "task_id": task_id,
341
+ "status": "running",
342
+ "signature": request.signature,
343
+ "history": request.messages,
344
+ "pending": None,
345
+ "calls": 0,
346
+ "error": None,
347
+ "requests": {
348
+ request.digest: {
349
+ "task_id": task_id,
350
+ "response": None,
351
+ "error": None,
352
+ }
353
+ },
354
+ "updated_at": time.time(),
355
+ }
356
+ self._save_snapshot(snapshot)
357
+
358
+ ticket = Ticket(task_id)
359
+ self._active_task_id = task_id
360
+ self._active_session_id = session_id
361
+ self._active_digest = request.digest
362
+ self._active_request = request
363
+ self._active_ticket = ticket
364
+ self._task_start_time = time.monotonic()
365
+ self._pending_deadline = None
366
+
367
+ sink = _TaskSink(self, session_id, task_id)
368
+ try:
369
+ self._active_transport = self.transport_factory(self.config, request, sink)
370
+ self._active_transport.start()
371
+ except Exception as e:
372
+ err = BridgeError("startup_failed", "Failed to start native transport.", 409)
373
+ self._on_sink_fail(session_id, task_id, err)
374
+ raise err from e
375
+
376
+ return ticket
377
+
378
+ # Existing session
379
+ if len(snapshot.get("requests", {})) >= 256 and request.digest not in snapshot.get("requests", {}):
380
+ raise BridgeError("session_capacity", "Session request capacity reached (256). Open a new session.", 409)
381
+
382
+ # Check cached request
383
+ req_record = snapshot.get("requests", {}).get(request.digest)
384
+ if req_record is not None:
385
+ if req_record.get("response") is not None:
386
+ cached_resp = req_record["response"]
387
+ finish_reason = cached_resp["choices"][0]["finish_reason"]
388
+ if finish_reason == "tool_calls":
389
+ call_id = cached_resp["choices"][0]["message"]["tool_calls"][0]["id"]
390
+ # Enforce deadlines before returning cached tool call
391
+ if self._pending_deadline is not None and time.monotonic() >= self._pending_deadline:
392
+ err = BridgeError("tool_timeout", "Tool deadline expired.", 409)
393
+ self._on_sink_fail(session_id, snapshot["task_id"], err)
394
+ raise err
395
+
396
+ if (
397
+ snapshot["status"] == "waiting_for_tool"
398
+ and snapshot["pending"]
399
+ and snapshot["pending"]["id"] == call_id
400
+ ):
401
+ t = Ticket(req_record["task_id"])
402
+ t.response = cached_resp
403
+ t.event.set()
404
+ return t
405
+ else:
406
+ raise BridgeError(
407
+ "stale_tool_phase",
408
+ "Cached tool-call response cannot be replayed after the tool phase has advanced or ended.",
409
+ 410,
410
+ )
411
+ else:
412
+ t = Ticket(req_record["task_id"])
413
+ t.response = cached_resp
414
+ t.event.set()
415
+ return t
416
+
417
+ elif req_record.get("error") is not None:
418
+ err = req_record["error"]
419
+ raise BridgeError(err["code"], err["message"], err.get("status", 409))
420
+ else:
421
+ raise BridgeError("request_in_progress", "An identical request is already in progress.", 409)
422
+
423
+ # Check if this is a tool result
424
+ if request.messages[-1]["role"] == "tool":
425
+ if snapshot["status"] != "waiting_for_tool" or not snapshot["pending"]:
426
+ raise BridgeError("unexpected_tool_result", "Session is not waiting for a tool result.", 409)
427
+
428
+ # Deadline check before accepting tool result
429
+ if self._pending_deadline is not None and time.monotonic() >= self._pending_deadline:
430
+ err = BridgeError("tool_timeout", f"Tool response exceeded deadline ({self.config.tool_timeout}s).", 409)
431
+ self._on_sink_fail(session_id, snapshot["task_id"], err)
432
+ raise err
433
+
434
+ if request.signature != snapshot["signature"]:
435
+ raise BridgeError("signature_mismatch", "Request signature changed during active task.", 409)
436
+ if request.messages[:-1] != snapshot["history"]:
437
+ raise BridgeError("history_mismatch", "History prefix does not match active task history.", 409)
438
+
439
+ tool_msg = request.messages[-1]
440
+ if tool_msg["tool_call_id"] != snapshot["pending"]["id"]:
441
+ raise BridgeError(
442
+ "tool_call_id_mismatch",
443
+ "Submitted tool result ID does not match the pending call.",
444
+ 409,
445
+ )
446
+
447
+ if (
448
+ self._active_task_id != snapshot["task_id"]
449
+ or self._active_session_id != session_id
450
+ or self._active_transport is None
451
+ ):
452
+ err = BridgeError("interrupted", "Task was interrupted and cannot be resumed.", 409)
453
+ self._on_sink_fail(session_id, snapshot["task_id"], err)
454
+ raise err
455
+
456
+ pending_call_id = snapshot["pending"]["id"]
457
+ tool_text = tool_msg["content"]
458
+
459
+ snapshot["history"].append(tool_msg)
460
+ snapshot["pending"] = None
461
+ snapshot["status"] = "running"
462
+ snapshot["updated_at"] = time.time()
463
+ snapshot["requests"][request.digest] = {
464
+ "task_id": snapshot["task_id"],
465
+ "response": None,
466
+ "error": None,
467
+ }
468
+ self._save_snapshot(snapshot)
469
+
470
+ ticket = Ticket(snapshot["task_id"])
471
+ self._active_ticket = ticket
472
+ self._active_digest = request.digest
473
+ self._pending_deadline = None
474
+
475
+ try:
476
+ self._active_transport.submit_result(pending_call_id, tool_text)
477
+ except Exception as e:
478
+ err = BridgeError("transport_failure", "Failed to submit tool result to transport.", 409)
479
+ self._on_sink_fail(session_id, snapshot["task_id"], err)
480
+ raise err from e
481
+
482
+ return ticket
483
+
484
+ else:
485
+ # New user turn
486
+ if snapshot["status"] in ("running", "waiting_for_tool"):
487
+ raise BridgeError("session_busy", "Session is currently busy with an active task.", 409)
488
+
489
+ stored_history = snapshot["history"]
490
+ incoming_history = request.messages
491
+ if len(incoming_history) <= len(stored_history) or incoming_history[:len(stored_history)] != stored_history:
492
+ raise BridgeError("history_diverged", "Incoming history does not match stored history prefix; open a new session.", 409)
493
+ if incoming_history[-1]["role"] != "user":
494
+ raise invalid("invalid_continuation", "Appended continuation message must be a user turn.")
495
+
496
+ if self._active_task_id is not None:
497
+ raise BridgeError("concurrency_limit", "Another native task is currently active.", 409)
498
+
499
+ new_task_id = "task_" + secrets.token_hex(12)
500
+ snapshot["task_id"] = new_task_id
501
+ snapshot["status"] = "running"
502
+ snapshot["signature"] = request.signature
503
+ snapshot["history"] = request.messages
504
+ snapshot["pending"] = None
505
+ snapshot["error"] = None
506
+ snapshot["calls"] = 0
507
+ snapshot["updated_at"] = time.time()
508
+ snapshot["requests"][request.digest] = {
509
+ "task_id": new_task_id,
510
+ "response": None,
511
+ "error": None,
512
+ }
513
+ self._save_snapshot(snapshot)
514
+
515
+ ticket = Ticket(new_task_id)
516
+ self._active_task_id = new_task_id
517
+ self._active_session_id = session_id
518
+ self._active_digest = request.digest
519
+ self._active_request = request
520
+ self._active_ticket = ticket
521
+ self._task_start_time = time.monotonic()
522
+ self._pending_deadline = None
523
+
524
+ sink = _TaskSink(self, session_id, new_task_id)
525
+ try:
526
+ self._active_transport = self.transport_factory(self.config, request, sink)
527
+ self._active_transport.start()
528
+ except Exception as e:
529
+ err = BridgeError("startup_failed", "Failed to start native transport.", 409)
530
+ self._on_sink_fail(session_id, new_task_id, err)
531
+ raise err from e
532
+
533
+ return ticket
534
+
535
+ def _on_sink_tool(self, session_id: str, task_id: str, name: str, arguments: dict) -> str:
536
+ with self._lock:
537
+ # 1. Stale callback check
538
+ if self._closed or task_id != self._active_task_id or session_id != self._active_session_id:
539
+ return ""
540
+
541
+ snapshot = self._load_snapshot(session_id)
542
+ if snapshot is None or snapshot["task_id"] != task_id:
543
+ return ""
544
+
545
+ # Check deadline before tool publication
546
+ now = time.monotonic()
547
+ if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
548
+ err = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
549
+ self._on_sink_fail(session_id, task_id, err)
550
+ raise err
551
+
552
+ # 2. Status check
553
+ if snapshot["status"] != "running" or snapshot.get("pending") is not None:
554
+ error = BridgeError("unsupported_parallel_call", "A tool is already pending.", 409)
555
+ self._on_sink_fail(session_id, task_id, error)
556
+ raise error
557
+
558
+ # 3. Active catalog validation
559
+ if self._active_request is None:
560
+ error = BridgeError("missing_request", "Active request missing.", 409)
561
+ self._on_sink_fail(session_id, task_id, error)
562
+ raise error
563
+
564
+ try:
565
+ self._active_request.active_catalog.validate(name, arguments)
566
+ except Exception:
567
+ err = BridgeError("invalid_upstream_arguments", "The upstream tool arguments do not satisfy the supplied JSON Schema.", 409)
568
+ self._on_sink_fail(session_id, task_id, err)
569
+ raise err
570
+
571
+ # 4. Capacity check
572
+ if len(snapshot.get("requests", {})) >= 256:
573
+ error = BridgeError("session_capacity", "Open a new session before another tool phase.", 409)
574
+ self._on_sink_fail(session_id, task_id, error)
575
+ raise error
576
+
577
+ snapshot["calls"] += 1
578
+ if snapshot["calls"] > self.config.max_tool_calls:
579
+ err = BridgeError("max_tool_calls_exceeded", f"Exceeded maximum tool calls ({self.config.max_tool_calls}).", 409)
580
+ self._on_sink_fail(session_id, task_id, err)
581
+ raise err
582
+
583
+ call_id = "call_" + secrets.token_hex(12)
584
+ resp = make_tool_response(call_id, name, arguments, int(time.time()))
585
+
586
+ snapshot["history"].append({
587
+ "role": "assistant",
588
+ "content": "",
589
+ "tool_calls": resp["choices"][0]["message"]["tool_calls"],
590
+ })
591
+ snapshot["pending"] = {
592
+ "id": call_id,
593
+ "name": name,
594
+ "arguments": arguments,
595
+ "received_at": time.time(),
596
+ }
597
+ snapshot["status"] = "waiting_for_tool"
598
+ snapshot["updated_at"] = time.time()
599
+
600
+ digest = self._active_digest
601
+ if digest and digest in snapshot["requests"]:
602
+ snapshot["requests"][digest]["response"] = resp
603
+
604
+ # Save state BEFORE publishing ticket!
605
+ self._save_snapshot(snapshot)
606
+
607
+ self._pending_deadline = time.monotonic() + self.config.tool_timeout
608
+
609
+ ticket = self._active_ticket
610
+ if ticket and not ticket.event.is_set():
611
+ ticket.response = resp
612
+ ticket.event.set()
613
+
614
+ return call_id
615
+
616
+ def _on_sink_complete(self, session_id: str, task_id: str, text: str) -> None:
617
+ with self._lock:
618
+ if self._closed or task_id != self._active_task_id or session_id != self._active_session_id:
619
+ return
620
+
621
+ snapshot = self._load_snapshot(session_id)
622
+ if snapshot is None or snapshot["task_id"] != task_id:
623
+ return
624
+
625
+ now = time.monotonic()
626
+ if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
627
+ error = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
628
+ self._on_sink_fail(session_id, task_id, error)
629
+ return
630
+
631
+ if snapshot["status"] != "running" or snapshot.get("pending") is not None or not text or not text.strip():
632
+ error = BridgeError("incomplete_task", "Invalid completion state.", 409)
633
+ self._on_sink_fail(session_id, task_id, error)
634
+ return
635
+
636
+ resp = make_final_response(text, int(time.time()))
637
+ snapshot["history"].append(resp["choices"][0]["message"])
638
+ snapshot["status"] = "complete"
639
+ snapshot["pending"] = None
640
+ snapshot["error"] = None
641
+ snapshot["updated_at"] = time.time()
642
+
643
+ digest = self._active_digest
644
+ if digest and digest in snapshot["requests"]:
645
+ snapshot["requests"][digest]["response"] = resp
646
+
647
+ # Save BEFORE publishing
648
+ self._save_snapshot(snapshot)
649
+
650
+ ticket = self._active_ticket
651
+ self._clear_active()
652
+
653
+ if ticket and not ticket.event.is_set():
654
+ ticket.response = resp
655
+ ticket.event.set()
656
+
657
+ def _on_sink_fail(self, session_id: str, task_id: str, error: BridgeError) -> None:
658
+ with self._lock:
659
+ if task_id != self._active_task_id or session_id != self._active_session_id:
660
+ return
661
+
662
+ snapshot = None
663
+ try:
664
+ snapshot = self._load_snapshot(session_id)
665
+ except Exception:
666
+ pass
667
+
668
+ if snapshot is not None and snapshot["task_id"] == task_id:
669
+ if snapshot["status"] != "complete":
670
+ self._record_terminal_error(snapshot, error, "failed")
671
+ try:
672
+ self._save_snapshot(snapshot)
673
+ except Exception:
674
+ pass
675
+
676
+ if self._active_transport is not None:
677
+ try:
678
+ self._active_transport.stop()
679
+ except Exception:
680
+ pass
681
+
682
+ ticket = self._active_ticket
683
+ self._clear_active()
684
+
685
+ if ticket and not ticket.event.is_set():
686
+ ticket.error = error
687
+ ticket.event.set()
688
+
689
+ def abandon_delivery(self, session_id: str, task_id: str, request_digest: str):
690
+ """Conditionally abandon active delivery if session, task, and digest match current request."""
691
+ with self._lock:
692
+ if (
693
+ self._active_session_id == session_id
694
+ and self._active_task_id == task_id
695
+ and self._active_digest == request_digest
696
+ ):
697
+ self.cancel(session_id)
698
+
699
+ def status(self, session_id: str) -> dict:
700
+ if not isinstance(session_id, str) or not SESSION.fullmatch(session_id):
701
+ raise invalid("invalid_session_id", "Session ID must be 1-256 non-whitespace ASCII characters.")
702
+
703
+ with self._lock:
704
+ snap = self._load_snapshot(session_id)
705
+ if snap is None:
706
+ raise BridgeError("session_not_found", "Session not found.", 404)
707
+ return {
708
+ "session_id": snap["session_id"],
709
+ "instance_id": self.config.instance_id,
710
+ "status": snap["status"],
711
+ "task_id": snap["task_id"],
712
+ "calls": snap.get("calls", 0),
713
+ "pending_tool": snap["pending"]["id"] if snap.get("pending") else None,
714
+ "pending_tool_name": snap["pending"]["name"] if snap.get("pending") else None,
715
+ }
716
+
717
+ def cancel(self, session_id: str) -> dict:
718
+ if not isinstance(session_id, str) or not SESSION.fullmatch(session_id):
719
+ raise invalid("invalid_session_id", "Session ID must be 1-256 non-whitespace ASCII characters.")
720
+
721
+ with self._lock:
722
+ snap = self._load_snapshot(session_id)
723
+ if snap is None:
724
+ raise BridgeError("session_not_found", "Session not found.", 404)
725
+
726
+ task_id = snap["task_id"]
727
+ if snap["status"] in ("complete", "failed", "abandoned", "interrupted"):
728
+ return {"status": snap["status"], "native_cancel_confirmed": False}
729
+
730
+ cancel_err = BridgeError("cancelled", "Task locally abandoned.", 409)
731
+ ticket = self._active_ticket if self._active_task_id == task_id else None
732
+ transport = self._active_transport if self._active_task_id == task_id else None
733
+ self._record_terminal_error(snap, cancel_err, "abandoned")
734
+ try:
735
+ self._save_snapshot(snap)
736
+ finally:
737
+ if transport is not None:
738
+ transport.stop()
739
+ if self._active_task_id == task_id:
740
+ self._clear_active()
741
+ if ticket and not ticket.event.is_set():
742
+ ticket.error = cancel_err
743
+ ticket.event.set()
744
+ return {"status": "abandoned", "native_cancel_confirmed": False}
745
+
746
+ def close(self) -> None:
747
+ with self._lock:
748
+ self._closed = True
749
+
750
+ self._stop_watchdog.set()
751
+ if self._watchdog_thread.is_alive():
752
+ self._watchdog_thread.join(timeout=2.0)
753
+
754
+ with self._lock:
755
+ if self._active_transport is not None:
756
+ try:
757
+ self._active_transport.stop()
758
+ except Exception:
759
+ pass
760
+
761
+ if self._active_session_id and self._active_task_id:
762
+ snap = None
763
+ try:
764
+ snap = self._load_snapshot(self._active_session_id)
765
+ except Exception:
766
+ pass
767
+ if snap and snap["status"] in ("running", "waiting_for_tool"):
768
+ close_err = BridgeError("service_stopped", "Bridge service closed.", 409)
769
+ self._record_terminal_error(snap, close_err, "abandoned")
770
+ try:
771
+ self._save_snapshot(snap)
772
+ except Exception:
773
+ pass
774
+
775
+ ticket = self._active_ticket
776
+ self._clear_active()
777
+
778
+ if ticket and not ticket.event.is_set():
779
+ ticket.error = BridgeError("service_stopped", "Bridge service closed.", 409)
780
+ ticket.event.set()
781
+
782
+ def _watchdog_loop(self):
783
+ while not self._stop_watchdog.wait(0.2):
784
+ with self._lock:
785
+ if self._closed or self._storage_failed:
786
+ continue
787
+ if self._active_task_id is None:
788
+ continue
789
+ task_id = self._active_task_id
790
+ session_id = self._active_session_id
791
+ if not session_id:
792
+ continue
793
+
794
+ now = time.monotonic()
795
+ if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
796
+ err = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
797
+ self._on_sink_fail(session_id, task_id, err)
798
+ continue
799
+
800
+ if self._pending_deadline is not None and now >= self._pending_deadline:
801
+ err = BridgeError(
802
+ "tool_timeout",
803
+ f"Tool response exceeded deadline ({self.config.tool_timeout}s).",
804
+ 409,
805
+ )
806
+ self._on_sink_fail(session_id, task_id, err)