bazaar-compute-node 0.1.3__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.
Files changed (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,891 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass, replace
8
+ from time import time_ns
9
+
10
+ from ..audit import ErrorKind
11
+ from ..channel import IChannel
12
+ from ..concurrency import ISessionConcurrency, SessionLockRegistry
13
+ from ..correlation import CorrelationContext
14
+ from ..lifecycle import IAsyncLifecycle, TimeoutBudget
15
+ from ..models import (
16
+ AgentSignal,
17
+ AgentState,
18
+ AgentTick,
19
+ AgentTickSource,
20
+ BcnSession,
21
+ ChannelSession,
22
+ ChannelTargetKind,
23
+ ConsumerCursor,
24
+ InboundMessage,
25
+ RuntimeAttempt,
26
+ RuntimeEventState,
27
+ RuntimeSession,
28
+ RuntimeTurn,
29
+ RuntimeTurnState,
30
+ )
31
+ from ..observability import IAudit
32
+ from ..outcomes import ProviderCallStatus
33
+ from ..runtime import IRuntime, RuntimeSessionUnavailable
34
+ from ..storage import IStorage, NodeIdentity
35
+ from .command import SessionCommandService
36
+ from .services import SessionAuditRecorder, SessionStateWriter
37
+ from .turn import SessionContext, SessionTurnCoordinator
38
+
39
+
40
+ def _current_time_ms() -> int:
41
+ return time_ns() // 1_000_000
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class _IngressItem:
46
+ message: InboundMessage
47
+ completion: asyncio.Future[RuntimeTurn | None]
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class _RuntimeNotification:
52
+ message: InboundMessage
53
+ context: SessionContext
54
+ completion: asyncio.Future[RuntimeTurn | None]
55
+
56
+
57
+ class SessionOrchestrator(IAsyncLifecycle):
58
+ """Route one Channel composition through provider-neutral core contracts."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ node_id: str | None = None,
64
+ workspace_id: str | None = None,
65
+ channel: IChannel,
66
+ runtime: IRuntime,
67
+ storage: IStorage,
68
+ audit: IAudit,
69
+ timeout_budget: TimeoutBudget,
70
+ concurrency: ISessionConcurrency | None = None,
71
+ clock: Callable[[], int] | None = None,
72
+ on_node_initialized: Callable[[NodeIdentity], Awaitable[None]] | None = None,
73
+ ) -> None:
74
+ for value, field_name in (
75
+ (node_id, "node_id"),
76
+ (runtime.name, "runtime.name"),
77
+ ):
78
+ if value is not None and (not isinstance(value, str) or not value):
79
+ raise ValueError(f"{field_name} must be a non-empty string")
80
+ if workspace_id is not None and (
81
+ not isinstance(workspace_id, str) or not workspace_id
82
+ ):
83
+ raise ValueError("workspace_id must be a non-empty string")
84
+ self._node_id = node_id
85
+ self._workspace_id = workspace_id
86
+ self._on_node_initialized = on_node_initialized
87
+ self._channel = channel
88
+ self._runtime = runtime
89
+ self._storage = storage
90
+ self._timeout_budget = timeout_budget
91
+ self._concurrency = concurrency or SessionLockRegistry()
92
+ self._clock = clock or _current_time_ms
93
+ self._runtime_sessions: dict[str, RuntimeSession] = {}
94
+ self._runtime_turns: dict[str, RuntimeTurn] = {}
95
+ self._agent_states: dict[str, AgentState] = {}
96
+ self._logger = logging.getLogger("bazaar_compute_node.orchestration.session")
97
+ if not self._logger.handlers:
98
+ self._logger.addHandler(logging.StreamHandler())
99
+ self._logger.setLevel(logging.INFO)
100
+ self._logger.propagate = False
101
+ self._audit = SessionAuditRecorder(
102
+ sink=audit,
103
+ timeout_budget=timeout_budget,
104
+ clock=self._clock,
105
+ )
106
+ self._state_writer = SessionStateWriter(
107
+ storage=storage,
108
+ concurrency=self._concurrency,
109
+ states=self._agent_states,
110
+ )
111
+ self._command_service = SessionCommandService(
112
+ channel=channel,
113
+ storage=storage,
114
+ audit=self._audit,
115
+ provider_call_timeout=timeout_budget.provider_call_seconds,
116
+ concurrency=self._concurrency,
117
+ node_id=lambda: self.node_id,
118
+ clock=self._clock,
119
+ )
120
+ self._turns = SessionTurnCoordinator(
121
+ channel=channel,
122
+ runtime=runtime,
123
+ storage=storage,
124
+ audit=self._audit,
125
+ state_writer=self._state_writer,
126
+ timeout_budget=timeout_budget,
127
+ concurrency=self._concurrency,
128
+ turns=self._runtime_turns,
129
+ node_id=lambda: self.node_id,
130
+ clock=self._clock,
131
+ )
132
+ self._active_tasks: set[asyncio.Task[RuntimeTurn | None]] = set()
133
+ self._ingress_queues: dict[tuple[str, str], asyncio.Queue[_IngressItem]] = {}
134
+ self._ingress_workers: dict[tuple[str, str], asyncio.Task[None]] = {}
135
+ self._runtime_queues: dict[str, asyncio.Queue[_RuntimeNotification]] = {}
136
+ self._runtime_workers: dict[str, asyncio.Task[None]] = {}
137
+ self._receive_task: asyncio.Task[None] | None = None
138
+ self._started = False
139
+ self._stopping = False
140
+ self._shutdown_errors: list[str] = []
141
+
142
+ @property
143
+ def node_id(self) -> str:
144
+ if self._node_id is None:
145
+ raise RuntimeError("node identity has not been initialized")
146
+ return self._node_id
147
+
148
+ @property
149
+ def workspace_id(self) -> str:
150
+ if self._workspace_id is None:
151
+ raise RuntimeError("node identity has not been initialized")
152
+ return self._workspace_id
153
+
154
+ @property
155
+ def command_service(self) -> SessionCommandService:
156
+ return self._command_service
157
+
158
+ def agent_state(self, session_id: str) -> AgentState | None:
159
+ """Return the process-local lifecycle state for one active Agent."""
160
+
161
+ return self._agent_states.get(session_id)
162
+
163
+ async def start(self, *, timeout: float) -> None:
164
+ if self._started:
165
+ return
166
+ if self._stopping:
167
+ raise RuntimeError("session orchestrator is stopping")
168
+ await self._storage.start(timeout=timeout)
169
+ try:
170
+ identity = await self._storage.initialize(
171
+ node_id=self._node_id,
172
+ workspace_id=self._workspace_id,
173
+ )
174
+ self._node_id = identity.node_id
175
+ self._workspace_id = identity.workspace_id
176
+ if self._on_node_initialized is not None:
177
+ await self._on_node_initialized(identity)
178
+ await self._runtime.start(timeout=timeout)
179
+ await self._channel.start(timeout=timeout)
180
+ except BaseException:
181
+ await self._runtime.stop(timeout=timeout)
182
+ await self._storage.stop(timeout=timeout)
183
+ raise
184
+ self._started = True
185
+ self._receive_task = asyncio.create_task(
186
+ self._receive_loop(), name="bcn-channel-receive"
187
+ )
188
+
189
+ async def stop(self, *, timeout: float) -> None:
190
+ if self._stopping:
191
+ return
192
+ self._stopping = True
193
+
194
+ try:
195
+ await self._channel.stop(timeout=timeout)
196
+ except asyncio.CancelledError:
197
+ raise
198
+ except Exception as error: # noqa: BLE001
199
+ self._shutdown_errors.append(f"channel.stop: {type(error).__name__}")
200
+
201
+ receive_task = self._receive_task
202
+ if receive_task is not None and not receive_task.done():
203
+ receive_task.cancel()
204
+ try:
205
+ await asyncio.wait_for(receive_task, timeout=timeout)
206
+ except TimeoutError, asyncio.CancelledError:
207
+ self._shutdown_errors.append("channel.receive: shutdown timeout")
208
+ self._receive_task = None
209
+
210
+ active_tasks = tuple(self._active_tasks)
211
+ for task in active_tasks:
212
+ task.cancel()
213
+ if active_tasks:
214
+ try:
215
+ await asyncio.wait_for(
216
+ asyncio.gather(*active_tasks, return_exceptions=True),
217
+ timeout=timeout,
218
+ )
219
+ except TimeoutError:
220
+ self._shutdown_errors.append("inbound tasks: shutdown timeout")
221
+
222
+ workers = (*self._ingress_workers.values(), *self._runtime_workers.values())
223
+ for worker in workers:
224
+ worker.cancel()
225
+ if workers:
226
+ try:
227
+ await asyncio.wait_for(
228
+ asyncio.gather(*workers, return_exceptions=True),
229
+ timeout=timeout,
230
+ )
231
+ except TimeoutError:
232
+ self._shutdown_errors.append("session workers: shutdown timeout")
233
+ self._ingress_queues.clear()
234
+ self._ingress_workers.clear()
235
+ self._runtime_queues.clear()
236
+ self._runtime_workers.clear()
237
+
238
+ for runtime_session in tuple(self._runtime_sessions.values()):
239
+ await self._stop_runtime_session(runtime_session, timeout=timeout)
240
+
241
+ try:
242
+ await self._runtime.stop(timeout=timeout)
243
+ except asyncio.CancelledError:
244
+ raise
245
+ except Exception as error: # noqa: BLE001
246
+ self._shutdown_errors.append(f"runtime.stop: {type(error).__name__}")
247
+ try:
248
+ await self._storage.stop(timeout=timeout)
249
+ except asyncio.CancelledError:
250
+ raise
251
+ except Exception as error: # noqa: BLE001
252
+ self._shutdown_errors.append(f"storage.stop: {type(error).__name__}")
253
+ self._started = False
254
+ self._agent_states.clear()
255
+ self._runtime_sessions.clear()
256
+ self._runtime_turns.clear()
257
+
258
+ def dispatch_inbound(
259
+ self, message: InboundMessage
260
+ ) -> asyncio.Task[RuntimeTurn | None]:
261
+ """Schedule one inbound message while retaining its task for shutdown."""
262
+
263
+ if self._stopping:
264
+ raise RuntimeError("session orchestrator is stopping")
265
+ task = asyncio.create_task(
266
+ self.handle_inbound(message),
267
+ name=f"bcn-inbound-{message.message_id}",
268
+ )
269
+ self._active_tasks.add(task)
270
+ task.add_done_callback(self._forget_task)
271
+ return task
272
+
273
+ async def tick(self, session_id: str, tick: AgentTick) -> AgentState:
274
+ """Apply one serialized lifecycle observation to a bcn session."""
275
+
276
+ if not session_id:
277
+ raise ValueError("session_id must be a non-empty string")
278
+ return await self._state_writer.apply(session_id, tick)
279
+
280
+ async def handle_inbound(self, message: InboundMessage) -> RuntimeTurn | None:
281
+ """Queue one inbound message without waiting for Runtime I/O at ingress."""
282
+
283
+ loop = asyncio.get_running_loop()
284
+ completion: asyncio.Future[RuntimeTurn | None] = loop.create_future()
285
+ conversation_key = (message.channel, message.provider_thread_id)
286
+ ingress_queue = self._ingress_queues.get(conversation_key)
287
+ if ingress_queue is None:
288
+ ingress_queue = asyncio.Queue()
289
+ self._ingress_queues[conversation_key] = ingress_queue
290
+ self._ingress_workers[conversation_key] = asyncio.create_task(
291
+ self._ingress_loop(ingress_queue),
292
+ name=f"bcn-ingress-{message.channel_session_id}",
293
+ )
294
+ ingress_queue.put_nowait(_IngressItem(message, completion))
295
+ return await completion
296
+
297
+ async def _ingress_loop(
298
+ self,
299
+ queue: asyncio.Queue[_IngressItem],
300
+ ) -> None:
301
+ while True:
302
+ item = await queue.get()
303
+ try:
304
+ context, message, created = await self._record_inbound(item.message)
305
+ if not created or context is None:
306
+ if not item.completion.done():
307
+ item.completion.set_result(None)
308
+ continue
309
+ session_id = context.bcn_session.id
310
+ runtime_queue = self._runtime_queues.get(session_id)
311
+ if runtime_queue is None:
312
+ runtime_queue = asyncio.Queue()
313
+ self._runtime_queues[session_id] = runtime_queue
314
+ self._runtime_workers[session_id] = asyncio.create_task(
315
+ self._runtime_loop(runtime_queue),
316
+ name=f"bcn-runtime-{session_id}",
317
+ )
318
+ runtime_queue.put_nowait(
319
+ _RuntimeNotification(message, context, item.completion)
320
+ )
321
+ except asyncio.CancelledError:
322
+ if not item.completion.done():
323
+ item.completion.cancel()
324
+ raise
325
+ except Exception as error: # noqa: BLE001
326
+ if not item.completion.done():
327
+ item.completion.set_exception(error)
328
+ finally:
329
+ queue.task_done()
330
+
331
+ async def _runtime_loop(
332
+ self,
333
+ queue: asyncio.Queue[_RuntimeNotification],
334
+ ) -> None:
335
+ while True:
336
+ batch = [await queue.get()]
337
+ while True:
338
+ try:
339
+ batch.append(queue.get_nowait())
340
+ except asyncio.QueueEmpty:
341
+ break
342
+ try:
343
+ result = await self._run_notification(batch[0])
344
+ for notification in batch:
345
+ if not notification.completion.done():
346
+ notification.completion.set_result(result)
347
+ except asyncio.CancelledError:
348
+ for notification in batch:
349
+ if not notification.completion.done():
350
+ notification.completion.cancel()
351
+ raise
352
+ except Exception as error: # noqa: BLE001
353
+ for notification in batch:
354
+ if not notification.completion.done():
355
+ notification.completion.set_exception(error)
356
+ finally:
357
+ for _notification in batch:
358
+ queue.task_done()
359
+
360
+ async def _receive_loop(self) -> None:
361
+ async for message in self._channel.receive():
362
+ if self._stopping:
363
+ break
364
+ self.dispatch_inbound(message)
365
+
366
+ def _forget_task(self, task: asyncio.Task[RuntimeTurn | None]) -> None:
367
+ self._active_tasks.discard(task)
368
+ if task.cancelled():
369
+ return
370
+ error = task.exception()
371
+ if error is None:
372
+ return
373
+ self._logger.error(
374
+ "%s",
375
+ json.dumps(
376
+ {
377
+ "event_name": "channel.inbound.failed",
378
+ "created_at_ms": self._clock(),
379
+ "metadata": {
380
+ "task_name": task.get_name(),
381
+ "error_type": type(error).__name__,
382
+ "error_message": str(error),
383
+ },
384
+ },
385
+ separators=(",", ":"),
386
+ sort_keys=True,
387
+ ),
388
+ exc_info=(type(error), error, error.__traceback__),
389
+ )
390
+
391
+ async def _record_inbound(
392
+ self, message: InboundMessage
393
+ ) -> tuple[SessionContext | None, InboundMessage, bool]:
394
+ context: SessionContext | None = None
395
+ channel_session_created = False
396
+ bcn_session_created = False
397
+ runtime_session_created = False
398
+ async with self._storage.transaction() as transaction:
399
+ existing_message = await transaction.find_inbound_message(
400
+ message.channel,
401
+ message.provider_thread_id,
402
+ message.provider_message_id,
403
+ )
404
+ if existing_message is not None:
405
+ message = existing_message
406
+ channel_session = await transaction.find_channel_session(
407
+ channel=message.channel,
408
+ provider_thread_id=message.provider_thread_id,
409
+ )
410
+ now_ms = self._clock()
411
+ if channel_session is None:
412
+ channel_session_created = True
413
+ channel_session = ChannelSession(
414
+ id=message.channel_session_id,
415
+ channel=message.channel,
416
+ provider_thread_id=message.provider_thread_id,
417
+ created_at_ms=now_ms,
418
+ updated_at_ms=now_ms,
419
+ target_kind=message.target_kind,
420
+ following=message.target_kind is ChannelTargetKind.DM
421
+ or message.mentions_agent,
422
+ )
423
+ await transaction.save_channel_session(channel_session)
424
+ elif (
425
+ existing_message is None
426
+ and message.mentions_agent
427
+ and not channel_session.following
428
+ ):
429
+ channel_session = replace(
430
+ channel_session,
431
+ following=True,
432
+ updated_at_ms=now_ms,
433
+ )
434
+ await transaction.save_channel_session(channel_session)
435
+
436
+ bcn_session = await transaction.find_bcn_session(channel_session.id)
437
+ if bcn_session is None:
438
+ bcn_session_created = True
439
+ bcn_session = BcnSession(
440
+ id=message.session_id,
441
+ channel_session_id=channel_session.id,
442
+ workspace_id=self.workspace_id,
443
+ created_at_ms=now_ms,
444
+ updated_at_ms=now_ms,
445
+ )
446
+ await transaction.save_bcn_session(bcn_session)
447
+
448
+ if existing_message is None:
449
+ notifies_runtime = message.notifies_runtime and (
450
+ message.target_kind is ChannelTargetKind.DM
451
+ or channel_session.following
452
+ or message.mentions_agent
453
+ )
454
+ canonical_target = message.canonical_target
455
+ if channel_session.id != message.channel_session_id:
456
+ canonical_target = (
457
+ f"{channel_session.target_kind.value}:{channel_session.id}"
458
+ )
459
+ message = replace(
460
+ message,
461
+ session_id=bcn_session.id,
462
+ channel_session_id=channel_session.id,
463
+ canonical_target=canonical_target,
464
+ notifies_runtime=notifies_runtime,
465
+ )
466
+
467
+ cursor = await transaction.get_consumer_cursor(bcn_session.id)
468
+ if cursor is None:
469
+ await transaction.save_consumer_cursor(
470
+ ConsumerCursor(session_id=bcn_session.id)
471
+ )
472
+
473
+ if existing_message is None:
474
+ message = await transaction.append_inbound_message(message)
475
+ channel_session = replace(
476
+ channel_session,
477
+ last_inbound_at_ms=message.received_at_ms,
478
+ updated_at_ms=now_ms,
479
+ )
480
+ bcn_session = replace(
481
+ bcn_session,
482
+ last_activity_at_ms=message.received_at_ms,
483
+ updated_at_ms=now_ms,
484
+ )
485
+ await transaction.save_channel_session(channel_session)
486
+ await transaction.save_bcn_session(bcn_session)
487
+
488
+ runtime_session: RuntimeSession | None = None
489
+ if message.notifies_runtime:
490
+ runtime_session = await transaction.find_runtime_session(bcn_session.id)
491
+ if runtime_session is None:
492
+ runtime_session_created = True
493
+ runtime_session = RuntimeSession(
494
+ id=f"runtime-{bcn_session.id}",
495
+ bcn_session_id=bcn_session.id,
496
+ channel_session_id=channel_session.id,
497
+ runtime=self._runtime.name,
498
+ workspace_id=self.workspace_id,
499
+ created_at_ms=now_ms,
500
+ updated_at_ms=now_ms,
501
+ )
502
+ await transaction.save_runtime_session(runtime_session)
503
+ context = SessionContext(channel_session, bcn_session, runtime_session)
504
+
505
+ if existing_message is None:
506
+ await self._audit.append(
507
+ event_name="channel.inbound.persisted",
508
+ state=RuntimeEventState.COMPLETED,
509
+ correlation=CorrelationContext(
510
+ node_id=self.node_id,
511
+ channel=message.channel,
512
+ channel_session_id=message.channel_session_id,
513
+ bcn_session_id=message.session_id,
514
+ runtime_session_id=(
515
+ runtime_session.id if runtime_session is not None else None
516
+ ),
517
+ provider_thread_id=message.provider_thread_id,
518
+ inbound_seq=message.seq,
519
+ ),
520
+ metadata={
521
+ "notifies_runtime": message.notifies_runtime,
522
+ "channel_session_mapping": (
523
+ "created" if channel_session_created else "reused"
524
+ ),
525
+ "bcn_session_mapping": (
526
+ "created" if bcn_session_created else "reused"
527
+ ),
528
+ "runtime_session_mapping": (
529
+ "created"
530
+ if runtime_session_created
531
+ else "reused"
532
+ if runtime_session is not None
533
+ else "not_requested"
534
+ ),
535
+ },
536
+ )
537
+ return context, message, existing_message is None
538
+
539
+ async def _run_notification(
540
+ self, notification: _RuntimeNotification
541
+ ) -> RuntimeTurn | None:
542
+ message = notification.message
543
+ context = notification.context
544
+ async with self._storage.transaction() as transaction:
545
+ runtime_session = await transaction.find_runtime_session(
546
+ context.bcn_session.id
547
+ )
548
+ if runtime_session is None:
549
+ raise RuntimeError("notifying inbound has no runtime session")
550
+ context = SessionContext(
551
+ context.channel_session,
552
+ context.bcn_session,
553
+ runtime_session,
554
+ )
555
+ cursor = await transaction.get_consumer_cursor(context.bcn_session.id)
556
+ delivered_through_seq = (
557
+ cursor.delivered_through_seq if cursor is not None else 0
558
+ )
559
+ unread = await transaction.list_inbound_messages(
560
+ context.bcn_session.id,
561
+ after_seq=delivered_through_seq,
562
+ notifying_only=True,
563
+ )
564
+ if not unread:
565
+ return None
566
+ turn_id = f"turn-{message.message_id}"
567
+ if await transaction.get_runtime_attempt(turn_id) is not None:
568
+ return self._runtime_turns.get(turn_id)
569
+ turn = RuntimeTurn(
570
+ turn_id=turn_id,
571
+ session_id=context.runtime_session.id,
572
+ state=RuntimeTurnState.STARTING,
573
+ started_at_ms=self._clock(),
574
+ client_user_message_id=message.message_id,
575
+ )
576
+ await transaction.save_runtime_attempt(
577
+ RuntimeAttempt(
578
+ turn_id=turn.turn_id,
579
+ session_id=turn.session_id,
580
+ client_user_message_id=message.message_id,
581
+ started_at_ms=turn.started_at_ms,
582
+ )
583
+ )
584
+ self._runtime_turns[turn.turn_id] = turn
585
+
586
+ for attempt in range(2):
587
+ context = await self._ensure_runtime_session(context)
588
+ agent_state = self._state_writer.get(context.bcn_session.id)
589
+ if agent_state is not AgentState.IDLE:
590
+ finish_state = (
591
+ RuntimeTurnState.UNKNOWN
592
+ if agent_state is AgentState.UNKNOWN
593
+ else RuntimeTurnState.FAILED
594
+ )
595
+ return await self._turns.finish_turn(
596
+ turn,
597
+ finish_state,
598
+ error_kind=(
599
+ ErrorKind.PROVIDER_UNKNOWN
600
+ if finish_state is RuntimeTurnState.UNKNOWN
601
+ else ErrorKind.PROVIDER_FAILED
602
+ ),
603
+ error_message=(
604
+ "runtime session start outcome is unknown"
605
+ if finish_state is RuntimeTurnState.UNKNOWN
606
+ else "runtime session failed to start"
607
+ ),
608
+ correlation=self._turns.turn_correlation(message, context, turn),
609
+ session_id=context.bcn_session.id,
610
+ )
611
+ self._state_writer.apply_observation(
612
+ context.bcn_session.id,
613
+ AgentTick(
614
+ source=AgentTickSource.CHANNEL,
615
+ signal=AgentSignal.TURN_STARTED,
616
+ observed_at_ms=self._clock(),
617
+ ),
618
+ )
619
+ try:
620
+ return await self._turns.run_turn(
621
+ message,
622
+ context,
623
+ turn,
624
+ unread_count=len(unread),
625
+ )
626
+ except RuntimeSessionUnavailable as error:
627
+ self._state_writer.apply_observation(
628
+ context.bcn_session.id,
629
+ AgentTick(
630
+ source=AgentTickSource.RUNTIME,
631
+ signal=AgentSignal.FAILED,
632
+ observed_at_ms=self._clock(),
633
+ error_kind=ErrorKind.PROVIDER_FAILED.value,
634
+ error_message=str(error),
635
+ ),
636
+ )
637
+ if attempt == 1:
638
+ return await self._turns.finish_turn(
639
+ turn,
640
+ RuntimeTurnState.FAILED,
641
+ error_kind=ErrorKind.PROVIDER_FAILED,
642
+ error_message=str(error),
643
+ correlation=self._turns.turn_correlation(
644
+ message, context, turn
645
+ ),
646
+ session_id=context.bcn_session.id,
647
+ )
648
+ raise AssertionError("runtime pre-start retry loop did not return")
649
+
650
+ async def _ensure_runtime_session(self, context: SessionContext) -> SessionContext:
651
+ runtime_session = context.runtime_session
652
+ agent_state = self._state_writer.get(context.bcn_session.id)
653
+ if agent_state in {
654
+ AgentState.IDLE,
655
+ AgentState.WORKING,
656
+ AgentState.COMPACTION_STARTING,
657
+ AgentState.COMPACTING,
658
+ AgentState.COMPACTION_COMPLETED,
659
+ AgentState.STOPPING,
660
+ }:
661
+ return context
662
+
663
+ if agent_state in {AgentState.CREATED, AgentState.FAILED}:
664
+ agent_state = self._state_writer.apply_observation(
665
+ context.bcn_session.id,
666
+ AgentTick(
667
+ source=AgentTickSource.SESSION,
668
+ signal=AgentSignal.START_REQUESTED,
669
+ observed_at_ms=self._clock(),
670
+ ),
671
+ )
672
+ elif agent_state is AgentState.UNKNOWN:
673
+ agent_state = self._state_writer.apply_observation(
674
+ context.bcn_session.id,
675
+ AgentTick(
676
+ source=AgentTickSource.RECOVERY,
677
+ signal=AgentSignal.RECONCILE_REQUESTED,
678
+ observed_at_ms=self._clock(),
679
+ ),
680
+ )
681
+ if agent_state not in {AgentState.STARTING, AgentState.RECONCILING}:
682
+ return context
683
+
684
+ process_operation = (
685
+ "start" if runtime_session.provider_thread_id is None else "resume"
686
+ )
687
+ process_correlation = CorrelationContext(
688
+ node_id=self.node_id,
689
+ channel=context.channel_session.channel,
690
+ channel_session_id=context.channel_session.id,
691
+ bcn_session_id=context.bcn_session.id,
692
+ runtime_session_id=runtime_session.id,
693
+ provider_thread_id=runtime_session.provider_thread_id,
694
+ )
695
+ await self._audit.append(
696
+ event_name=f"runtime.process.{process_operation}.requested",
697
+ state=RuntimeEventState.STARTED,
698
+ correlation=process_correlation,
699
+ metadata={
700
+ "runtime": runtime_session.runtime,
701
+ "workspace_id": runtime_session.workspace_id,
702
+ },
703
+ )
704
+
705
+ if process_operation == "start":
706
+ provider_result = await self._runtime.start_session(
707
+ runtime_session,
708
+ timeout=self._timeout_budget.provider_call_seconds,
709
+ )
710
+ else:
711
+ provider_result = await self._runtime.resume_session(
712
+ runtime_session,
713
+ timeout=self._timeout_budget.startup_seconds,
714
+ )
715
+
716
+ now_ms = self._clock()
717
+ if provider_result.status is ProviderCallStatus.CONFIRMED:
718
+ updated_runtime = provider_result.value
719
+ if updated_runtime is None:
720
+ raise ValueError("confirmed runtime start has no session")
721
+ if (
722
+ updated_runtime.bcn_session_id != context.bcn_session.id
723
+ or updated_runtime.channel_session_id != context.channel_session.id
724
+ or updated_runtime.workspace_id != self.workspace_id
725
+ ):
726
+ raise ValueError("runtime provider returned a mismatched session")
727
+ runtime_session = updated_runtime
728
+ async with self._storage.transaction() as transaction:
729
+ await transaction.save_runtime_session(runtime_session)
730
+ current_state = self._state_writer.get(context.bcn_session.id)
731
+ if current_state is AgentState.STARTING:
732
+ self._state_writer.apply_observation(
733
+ context.bcn_session.id,
734
+ AgentTick(
735
+ source=AgentTickSource.RUNTIME,
736
+ signal=AgentSignal.START_CONFIRMED,
737
+ observed_at_ms=now_ms,
738
+ ),
739
+ )
740
+ elif current_state is AgentState.RECONCILING:
741
+ self._state_writer.apply_observation(
742
+ context.bcn_session.id,
743
+ AgentTick(
744
+ source=AgentTickSource.RECOVERY,
745
+ signal=AgentSignal.RECONCILE_CONFIRMED,
746
+ observed_at_ms=now_ms,
747
+ ),
748
+ )
749
+ await self._audit.append(
750
+ event_name=(
751
+ "runtime.process.started"
752
+ if process_operation == "start"
753
+ else "runtime.process.resumed"
754
+ ),
755
+ state=RuntimeEventState.COMPLETED,
756
+ correlation=replace(
757
+ process_correlation,
758
+ provider_thread_id=runtime_session.provider_thread_id,
759
+ ),
760
+ metadata={
761
+ "runtime": runtime_session.runtime,
762
+ "workspace_id": runtime_session.workspace_id,
763
+ },
764
+ )
765
+ else:
766
+ signal = (
767
+ AgentSignal.FAILED
768
+ if provider_result.status is ProviderCallStatus.FAILED
769
+ else AgentSignal.UNKNOWN
770
+ )
771
+ self._state_writer.apply_observation(
772
+ context.bcn_session.id,
773
+ AgentTick(
774
+ source=AgentTickSource.RUNTIME,
775
+ signal=signal,
776
+ observed_at_ms=now_ms,
777
+ error_kind=provider_result.error_kind,
778
+ error_message=provider_result.error_message,
779
+ ),
780
+ )
781
+ await self._audit.append(
782
+ event_name=f"runtime.process.{signal.value}",
783
+ state=(
784
+ RuntimeEventState.FAILED
785
+ if signal is AgentSignal.FAILED
786
+ else RuntimeEventState.UNKNOWN
787
+ ),
788
+ correlation=process_correlation,
789
+ error_kind=(
790
+ ErrorKind(provider_result.error_kind)
791
+ if provider_result.error_kind in ErrorKind._value2member_map_
792
+ else ErrorKind.INTERNAL
793
+ ),
794
+ error_message=provider_result.error_message,
795
+ metadata={
796
+ "operation": process_operation,
797
+ "runtime": runtime_session.runtime,
798
+ "workspace_id": runtime_session.workspace_id,
799
+ },
800
+ )
801
+
802
+ self._runtime_sessions[runtime_session.id] = runtime_session
803
+ return SessionContext(
804
+ context.channel_session,
805
+ context.bcn_session,
806
+ runtime_session,
807
+ )
808
+
809
+ async def _stop_runtime_session(
810
+ self, runtime_session: RuntimeSession, *, timeout: float
811
+ ) -> None:
812
+ async with self._concurrency.for_session(runtime_session.bcn_session_id):
813
+ await self._stop_runtime_session_locked(runtime_session, timeout=timeout)
814
+
815
+ async def _stop_runtime_session_locked(
816
+ self, runtime_session: RuntimeSession, *, timeout: float
817
+ ) -> None:
818
+ process_correlation = CorrelationContext(
819
+ node_id=self.node_id,
820
+ channel_session_id=runtime_session.channel_session_id,
821
+ bcn_session_id=runtime_session.bcn_session_id,
822
+ runtime_session_id=runtime_session.id,
823
+ provider_thread_id=runtime_session.provider_thread_id,
824
+ )
825
+ await self._audit.append(
826
+ event_name="runtime.process.stop.requested",
827
+ state=RuntimeEventState.STARTED,
828
+ correlation=process_correlation,
829
+ metadata={
830
+ "runtime": runtime_session.runtime,
831
+ "workspace_id": runtime_session.workspace_id,
832
+ },
833
+ )
834
+ await self._state_writer.apply_locked(
835
+ runtime_session.bcn_session_id,
836
+ AgentTick(
837
+ source=AgentTickSource.SESSION,
838
+ signal=AgentSignal.STOP_REQUESTED,
839
+ observed_at_ms=self._clock(),
840
+ ),
841
+ )
842
+ try:
843
+ result = await self._runtime.stop_session(runtime_session, timeout=timeout)
844
+ except Exception as error: # noqa: BLE001
845
+ result = None
846
+ stop_error = error
847
+ else:
848
+ stop_error = None
849
+ confirmed = result is not None and result.status is ProviderCallStatus.CONFIRMED
850
+ unknown = result is None or result.status in {
851
+ ProviderCallStatus.UNKNOWN,
852
+ ProviderCallStatus.QUEUED,
853
+ }
854
+ error_kind = (
855
+ result.error_kind
856
+ if result is not None
857
+ else ErrorKind.PROVIDER_UNKNOWN.value
858
+ )
859
+ error_message = result.error_message if result is not None else str(stop_error)
860
+ self._runtime_sessions.pop(runtime_session.id, None)
861
+ await self._audit.append(
862
+ event_name=(
863
+ "runtime.process.stop.completed"
864
+ if confirmed
865
+ else "runtime.process.stop.unknown"
866
+ if unknown
867
+ else "runtime.process.stop.failed"
868
+ ),
869
+ state=(
870
+ RuntimeEventState.COMPLETED
871
+ if confirmed
872
+ else RuntimeEventState.UNKNOWN
873
+ if unknown
874
+ else RuntimeEventState.FAILED
875
+ ),
876
+ correlation=process_correlation,
877
+ error_kind=(
878
+ ErrorKind(error_kind)
879
+ if error_kind in ErrorKind._value2member_map_
880
+ else ErrorKind.INTERNAL
881
+ if error_message
882
+ else None
883
+ )
884
+ if not confirmed
885
+ else None,
886
+ error_message=error_message if not confirmed else None,
887
+ metadata={
888
+ "runtime": runtime_session.runtime,
889
+ "workspace_id": runtime_session.workspace_id,
890
+ },
891
+ )