bazaar-compute-node 0.1.6__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/PKG-INFO +7 -3
  2. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/README.md +6 -2
  3. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/pyproject.toml +2 -2
  4. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/pyproject.toml.orig +2 -2
  5. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/application.py +92 -31
  6. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/command.py +29 -9
  7. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/config.py +10 -0
  8. bazaar_compute_node-0.1.8/src/bazaar_compute_node/app/reminder_dispatch.py +411 -0
  9. bazaar_compute_node-0.1.8/src/bazaar_compute_node/bcc.py +913 -0
  10. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/cli.py +2 -0
  11. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/__init__.py +26 -14
  12. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/approval.py +25 -21
  13. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/client.py +127 -26
  14. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/events.py +9 -9
  15. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/plugin.py +2 -2
  16. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/process.py +23 -0
  17. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/protocol.py +2 -2
  18. {bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/codex}/runtime.py +159 -36
  19. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/__init__.py +2 -1
  20. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/codec.py +0 -40
  21. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/migrations.py +14 -0
  22. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/plugin.py +1 -1
  23. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_codec.py +63 -0
  24. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_database.py +21 -0
  25. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_migration.py +85 -0
  26. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_repository.py +420 -0
  27. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/repository.py +0 -90
  28. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/command.py +54 -0
  29. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/instruction.py +46 -10
  30. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/__init__.py +7 -0
  31. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/models/reminder.py +282 -0
  32. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/states.py +13 -0
  33. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/__init__.py +2 -1
  34. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/orchestration/reminder.py +250 -0
  35. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/orchestration/reminder_command.py +362 -0
  36. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/services.py +8 -0
  37. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/orchestration/session.py +1671 -0
  38. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/turn.py +91 -20
  39. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/reminder.py +342 -0
  40. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/reminder_schedule.py +330 -0
  41. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/runtime.py +36 -7
  42. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/storage.py +88 -13
  43. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/timerwheel.py +316 -0
  44. bazaar_compute_node-0.1.6/src/bazaar_compute_node/bcc.py +0 -529
  45. bazaar_compute_node-0.1.6/src/bazaar_compute_node/core/orchestration/session.py +0 -997
  46. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/LICENSE +0 -0
  47. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/__init__.py +0 -0
  48. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/__init__.py +0 -0
  49. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/attachments.py +0 -0
  50. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/registry.py +0 -0
  51. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/transport.py +0 -0
  52. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/windows_pipe.py +0 -0
  53. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/wrapper.py +0 -0
  54. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/__init__.py +0 -0
  55. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
  56. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
  57. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
  58. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/database.py +0 -0
  59. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
  60. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/channel.py +0 -0
  61. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
  62. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/outbound.py +0 -0
  63. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/plugin.py +0 -0
  64. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/__init__.py +0 -0
  65. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/approval.py +0 -0
  66. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/audit.py +0 -0
  67. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/channel.py +0 -0
  68. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/client.py +0 -0
  69. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/concurrency.py +0 -0
  70. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/correlation.py +0 -0
  71. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/lifecycle.py +0 -0
  72. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/entities.py +0 -0
  73. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/observability.py +0 -0
  74. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/command.py +0 -0
  75. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/outcomes.py +0 -0
  76. {bazaar_compute_node-0.1.6 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazaar-compute-node
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Runtime-agnostic computer node daemon for agents and channels.
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -24,6 +24,7 @@ Harness 工具。
24
24
  - **自由组合**:根据使用场景分别选择 Channel 与 Runtime,并可独立替换和扩展。
25
25
  - **持续会话**:每个用户和对话拥有独立上下文,节点重启后仍可继续之前的任务。
26
26
  - **真实工作区**:Runtime 可以在独立工作区中分析、创建和修改文件,而不局限于文本回答。
27
+ - **双向附件**:Channel 收到的媒体会进入会话工作区,Runtime 也可以通过 Channel 交付工作区中的文件。
27
28
  - **可靠交付**:自动处理长结果的分批发送并记录交付状态,避免任务结果静默丢失。
28
29
  - **安全边界**:复用所选 Harness 的权限机制,按任务需要控制文件与网络访问。
29
30
  - **跨平台运行**:支持 Linux、macOS 和 Windows,并以后台进程长期运行。
@@ -57,7 +58,6 @@ bcn 将沿着可组合的 Channel、Runtime 与节点通用能力继续扩展。
57
58
 
58
59
  ### 节点能力
59
60
 
60
- - **附件发送**:让 Agent 可以通过当前 Channel 交付生成的文件;
61
61
  - **定时任务**:创建一次性或周期性任务,由节点按计划自动执行并返回结果。
62
62
 
63
63
  ## 安装
@@ -91,7 +91,8 @@ Channel 即可。
91
91
  ### WeCom
92
92
 
93
93
  WeCom Channel 对接企业微信智能机器人,支持单聊、群聊、文本与媒体输入,以及 Markdown
94
- 长消息分批返回。群聊中需要 @机器人,确保消息能够进入节点。
94
+ 长消息分批和附件返回。收到的媒体会以工作区相对路径提供给 Runtime;Runtime 可以把工作区内的
95
+ 普通文件作为附件发送,附件路径不能包含符号链接。群聊中需要 @机器人,确保消息能够进入节点。
95
96
 
96
97
  在 `~/.bcn/config.toml` 中配置 Bot ID:
97
98
 
@@ -128,9 +129,12 @@ Runtime 即可。
128
129
  [runtime]
129
130
  sandbox_mode = "workspace-write"
130
131
  network_access = true
132
+ idle_timeout = 0
131
133
  ```
132
134
 
133
135
  如需覆盖 Codex 的默认模型或推理强度,可以在 `[runtime]` 中增加 `model` 和 `effort`。
136
+ `idle_timeout` 以秒为单位并支持小数;默认值 `0` 与所有 `<= 0` 的值表示 runtime session
137
+ 常驻。设置为正值后,每条新收到的消息都会刷新空闲回收期限。
134
138
  除非你明确需要使用运行用户已有的主机权限,否则不要将 `sandbox_mode` 改为
135
139
  `danger-full-access`。
136
140
 
@@ -11,6 +11,7 @@ Harness 工具。
11
11
  - **自由组合**:根据使用场景分别选择 Channel 与 Runtime,并可独立替换和扩展。
12
12
  - **持续会话**:每个用户和对话拥有独立上下文,节点重启后仍可继续之前的任务。
13
13
  - **真实工作区**:Runtime 可以在独立工作区中分析、创建和修改文件,而不局限于文本回答。
14
+ - **双向附件**:Channel 收到的媒体会进入会话工作区,Runtime 也可以通过 Channel 交付工作区中的文件。
14
15
  - **可靠交付**:自动处理长结果的分批发送并记录交付状态,避免任务结果静默丢失。
15
16
  - **安全边界**:复用所选 Harness 的权限机制,按任务需要控制文件与网络访问。
16
17
  - **跨平台运行**:支持 Linux、macOS 和 Windows,并以后台进程长期运行。
@@ -44,7 +45,6 @@ bcn 将沿着可组合的 Channel、Runtime 与节点通用能力继续扩展。
44
45
 
45
46
  ### 节点能力
46
47
 
47
- - **附件发送**:让 Agent 可以通过当前 Channel 交付生成的文件;
48
48
  - **定时任务**:创建一次性或周期性任务,由节点按计划自动执行并返回结果。
49
49
 
50
50
  ## 安装
@@ -78,7 +78,8 @@ Channel 即可。
78
78
  ### WeCom
79
79
 
80
80
  WeCom Channel 对接企业微信智能机器人,支持单聊、群聊、文本与媒体输入,以及 Markdown
81
- 长消息分批返回。群聊中需要 @机器人,确保消息能够进入节点。
81
+ 长消息分批和附件返回。收到的媒体会以工作区相对路径提供给 Runtime;Runtime 可以把工作区内的
82
+ 普通文件作为附件发送,附件路径不能包含符号链接。群聊中需要 @机器人,确保消息能够进入节点。
82
83
 
83
84
  在 `~/.bcn/config.toml` 中配置 Bot ID:
84
85
 
@@ -115,9 +116,12 @@ Runtime 即可。
115
116
  [runtime]
116
117
  sandbox_mode = "workspace-write"
117
118
  network_access = true
119
+ idle_timeout = 0
118
120
  ```
119
121
 
120
122
  如需覆盖 Codex 的默认模型或推理强度,可以在 `[runtime]` 中增加 `model` 和 `effort`。
123
+ `idle_timeout` 以秒为单位并支持小数;默认值 `0` 与所有 `<= 0` 的值表示 runtime session
124
+ 常驻。设置为正值后,每条新收到的消息都会刷新空闲回收期限。
121
125
  除非你明确需要使用运行用户已有的主机权限,否则不要将 `sandbox_mode` 改为
122
126
  `danger-full-access`。
123
127
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.6"
3
+ version = "0.1.8"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -19,7 +19,7 @@ Repository = "https://github.com/yuchanns/bazaar-compute-node"
19
19
  bcn = "bazaar_compute_node.cli:main"
20
20
 
21
21
  [project.entry-points."bazaar_compute_node.runtimes"]
22
- codex = "bazaar_compute_node.contrib.codex_app_server.plugin:create_runtime"
22
+ codex = "bazaar_compute_node.contrib.codex.plugin:create_runtime"
23
23
 
24
24
  [project.entry-points."bazaar_compute_node.channels"]
25
25
  wecom = "bazaar_compute_node.contrib.wecom.plugin:create_channel"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.6"
3
+ version = "0.1.8"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -19,7 +19,7 @@ Repository = "https://github.com/yuchanns/bazaar-compute-node"
19
19
  bcn = "bazaar_compute_node.cli:main"
20
20
 
21
21
  [project.entry-points."bazaar_compute_node.runtimes"]
22
- codex = "bazaar_compute_node.contrib.codex_app_server.plugin:create_runtime"
22
+ codex = "bazaar_compute_node.contrib.codex.plugin:create_runtime"
23
23
 
24
24
  [project.entry-points."bazaar_compute_node.channels"]
25
25
  wecom = "bazaar_compute_node.contrib.wecom.plugin:create_channel"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import hmac
5
+ import math
5
6
  import os
6
7
  import re
7
8
  import secrets
@@ -10,19 +11,20 @@ from collections.abc import Mapping, Sequence
10
11
  from pathlib import Path
11
12
 
12
13
  from ..core.channel import ChannelContext, IChannel
14
+ from ..core.concurrency import SessionLockRegistry
13
15
  from ..core.lifecycle import TimeoutBudget
14
16
  from ..core.models import RuntimeSession
15
17
  from ..core.observability import IAudit
16
- from ..core.orchestration import SessionOrchestrator
18
+ from ..core.orchestration import ReminderScheduler, SessionOrchestrator
19
+ from ..core.orchestration.reminder_command import ReminderCommandService
17
20
  from ..core.paths import resolve_data_dir, resolve_workspace_dir
18
21
  from ..core.runtime import IRuntime, RuntimeCommandContext, RuntimeSandboxMode
19
22
  from ..core.storage import IStorage, NodeIdentity
23
+ from ..core.timerwheel import TimerWheel
20
24
  from .attachments import AttachmentMaterializer
21
- from .command import (
22
- CommandDispatcher,
23
- CommandDispatchError,
24
- )
25
+ from .command import CommandDispatchError
25
26
  from .registry import AdapterFactories
27
+ from .reminder_dispatch import CommandDispatcher
26
28
  from .transport import LocalCommandServer
27
29
  from .wrapper import install_bcc_wrapper, remove_bcc_wrapper
28
30
 
@@ -64,6 +66,7 @@ class NodeApplication:
64
66
  runtime_options: Mapping[str, str] | None = None,
65
67
  runtime_sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE,
66
68
  runtime_network_access: bool = True,
69
+ runtime_idle_timeout_seconds: float = 0,
67
70
  channel_options: Mapping[str, object] | None = None,
68
71
  runtime_environment_include: Sequence[str] = (),
69
72
  timeout_budget: TimeoutBudget | None = None,
@@ -81,8 +84,8 @@ class NodeApplication:
81
84
  self.command_log: list[CommandRecord] = []
82
85
  self._wrapper_path: Path | None = None
83
86
  self._identity: NodeIdentity | None = None
84
- self._session_capabilities: dict[str, str] = {}
85
- self._runtime_session_ids: dict[str, str] = {}
87
+ self._session_capabilities: dict[str, tuple[str, str]] = {}
88
+ self._concurrency = SessionLockRegistry()
86
89
  self._started = False
87
90
  self._stopped = asyncio.Event()
88
91
  self._runtime_environment_include = tuple(runtime_environment_include)
@@ -106,6 +109,23 @@ class NodeApplication:
106
109
  startup_timeout_seconds=self.timeout_budget.startup_seconds,
107
110
  )
108
111
  self.runtime: IRuntime = factories.runtime(self._runtime_context)
112
+ if (
113
+ isinstance(runtime_idle_timeout_seconds, bool)
114
+ or not isinstance(runtime_idle_timeout_seconds, int | float)
115
+ or not math.isfinite(runtime_idle_timeout_seconds)
116
+ ):
117
+ raise ValueError("runtime_idle_timeout_seconds must be a finite number")
118
+ self.timer_wheel = TimerWheel()
119
+ if (
120
+ runtime_idle_timeout_seconds > 0
121
+ and runtime_idle_timeout_seconds * 1_000 > self.timer_wheel.maximum_delay_ms
122
+ ):
123
+ raise ValueError("runtime_idle_timeout_seconds exceeds the timer horizon")
124
+ runtime_idle_timeout_ms = (
125
+ math.ceil(runtime_idle_timeout_seconds * 1_000)
126
+ if runtime_idle_timeout_seconds > 0
127
+ else 0
128
+ )
109
129
  self.orchestrator = SessionOrchestrator(
110
130
  node_id=node_id,
111
131
  workspace_id=workspace_id,
@@ -114,15 +134,30 @@ class NodeApplication:
114
134
  storage=self.storage,
115
135
  audit=self.audit,
116
136
  timeout_budget=self.timeout_budget,
137
+ timer_wheel=self.timer_wheel,
138
+ runtime_idle_timeout_ms=runtime_idle_timeout_ms,
117
139
  workspace=self._workspace_path,
140
+ concurrency=self._concurrency,
118
141
  on_node_initialized=self._ensure_workspace,
119
142
  )
143
+ self.reminder_scheduler = ReminderScheduler(
144
+ storage=self.storage,
145
+ timer_wheel=self.timer_wheel,
146
+ concurrency=self._concurrency,
147
+ publish_wake=self.orchestrator.publish_reminder_wake,
148
+ )
149
+ self.reminder_service = ReminderCommandService(
150
+ storage=self.storage,
151
+ concurrency=self._concurrency,
152
+ poke=self.reminder_scheduler.poke,
153
+ )
120
154
  self.command_service = self.orchestrator.command_service
121
155
  control_handler = None
122
156
  if factories.control is not None:
123
157
  control_handler = factories.control(self._adapter_context())
124
158
  self.command_dispatcher = CommandDispatcher(
125
159
  self.command_service,
160
+ reminder_service=self.reminder_service,
126
161
  timeout_budget=self.timeout_budget,
127
162
  control_handler=self._handle_control,
128
163
  session_binding_validator=self._validate_session_binding,
@@ -147,28 +182,43 @@ class NodeApplication:
147
182
  install_bcc_wrapper, self.data_dir / "bin"
148
183
  )
149
184
  try:
185
+ await self.timer_wheel.start()
150
186
  await self.command_server.start()
151
187
  await self.orchestrator.start(
152
188
  timeout=self.timeout_budget.startup_seconds,
153
189
  )
190
+ self._started = True
191
+ await self.reminder_scheduler.start(
192
+ timeout=self.timeout_budget.startup_seconds,
193
+ )
154
194
  except BaseException:
155
- await self.stop()
195
+ try:
196
+ await self.stop()
197
+ finally:
198
+ await self.timer_wheel.close()
156
199
  raise
157
- self._started = True
158
200
  self._stopped.clear()
159
201
  self.command_dispatcher.start_accepting()
160
202
 
161
203
  async def stop(self) -> None:
162
204
  if not self._started:
163
205
  try:
164
- await self.command_dispatcher.drain(
206
+ await self.reminder_scheduler.stop(
165
207
  timeout=self.timeout_budget.shutdown_seconds,
166
208
  )
167
209
  finally:
168
210
  try:
169
- await self.command_server.stop()
211
+ await self.command_dispatcher.drain(
212
+ timeout=self.timeout_budget.shutdown_seconds,
213
+ )
170
214
  finally:
171
- await self._cleanup_bcc_wrapper()
215
+ try:
216
+ await self.command_server.stop()
217
+ finally:
218
+ try:
219
+ await self._cleanup_bcc_wrapper()
220
+ finally:
221
+ await self.timer_wheel.close()
172
222
  return
173
223
  self._started = False
174
224
  self.command_dispatcher.stop_accepting()
@@ -178,17 +228,25 @@ class NodeApplication:
178
228
  )
179
229
  finally:
180
230
  try:
181
- await self.orchestrator.stop(
231
+ await self.reminder_scheduler.stop(
182
232
  timeout=self.timeout_budget.shutdown_seconds,
183
233
  )
184
234
  finally:
185
235
  try:
186
- await self.command_server.stop()
236
+ await self.orchestrator.stop(
237
+ timeout=self.timeout_budget.shutdown_seconds,
238
+ )
187
239
  finally:
188
240
  try:
189
- await self._cleanup_bcc_wrapper()
241
+ await self.command_server.stop()
190
242
  finally:
191
- self._stopped.set()
243
+ try:
244
+ await self._cleanup_bcc_wrapper()
245
+ finally:
246
+ try:
247
+ await self.timer_wheel.close()
248
+ finally:
249
+ self._stopped.set()
192
250
 
193
251
  async def _cleanup_bcc_wrapper(self) -> None:
194
252
  wrapper_path = self._wrapper_path
@@ -278,18 +336,19 @@ class NodeApplication:
278
336
  "SESSION_NOT_FOUND",
279
337
  f"unknown bcn session: {session_id}",
280
338
  )
281
- runtime_session = await transaction.find_runtime_session(session_id)
282
- if runtime_session is None or runtime_session_id != (runtime_session.id):
339
+ runtime_session = self.orchestrator.runtime_session(session_id)
340
+ if runtime_session is None or runtime_session_id != runtime_session.id:
283
341
  raise CommandDispatchError(
284
342
  "SESSION_BINDING_FAILED",
285
343
  "runtime session binding is invalid",
286
344
  )
287
- expected_capability = self._session_capabilities.get(session_id)
345
+ capability_binding = self._session_capabilities.get(session_id)
288
346
  if (
289
- expected_capability is None
347
+ capability_binding is None
348
+ or capability_binding[0] != runtime_session.id
290
349
  or not isinstance(session_capability, str)
291
350
  or not hmac.compare_digest(
292
- session_capability.encode(), expected_capability.encode()
351
+ session_capability.encode(), capability_binding[1].encode()
293
352
  )
294
353
  ):
295
354
  raise CommandDispatchError(
@@ -298,7 +357,9 @@ class NodeApplication:
298
357
  )
299
358
 
300
359
  def _runtime_environment(self, session: RuntimeSession) -> Mapping[str, str]:
301
- self._runtime_session_ids[session.bcn_session_id] = session.id
360
+ runtime_session = self.orchestrator.runtime_session(session.bcn_session_id)
361
+ if runtime_session is None or runtime_session.id != session.id:
362
+ raise RuntimeError("runtime session is not the current live binding")
302
363
  return self._build_command_environment(
303
364
  session.bcn_session_id,
304
365
  session.id,
@@ -316,10 +377,11 @@ class NodeApplication:
316
377
  wrapper_path = self._wrapper_path
317
378
  if wrapper_path is None:
318
379
  raise RuntimeError("bcc wrapper is not installed")
319
- session_capability = self._session_capabilities.setdefault(
320
- session_id,
321
- secrets.token_urlsafe(32),
322
- )
380
+ capability_binding = self._session_capabilities.get(session_id)
381
+ if capability_binding is None or capability_binding[0] != runtime_session_id:
382
+ capability_binding = (runtime_session_id, secrets.token_urlsafe(32))
383
+ self._session_capabilities[session_id] = capability_binding
384
+ session_capability = capability_binding[1]
323
385
  wrapper_directory = str(wrapper_path.parent)
324
386
  allowed = set(_PLATFORM_ENVIRONMENT)
325
387
  for name in self.runtime.environment_variable_names():
@@ -368,13 +430,12 @@ class NodeApplication:
368
430
  if wrapper_path is None:
369
431
  raise RuntimeError("bcc wrapper is not installed")
370
432
  self.command_log.append((session_id, tuple(arguments)))
371
- runtime_session_id = self._runtime_session_ids.get(
372
- session_id,
373
- f"runtime-{session_id}",
374
- )
433
+ runtime_session = self.orchestrator.runtime_session(session_id)
434
+ if runtime_session is None:
435
+ raise RuntimeError("runtime session is not live")
375
436
  environment = self._build_command_environment(
376
437
  session_id,
377
- runtime_session_id,
438
+ runtime_session.id,
378
439
  )
379
440
  process = await asyncio.create_subprocess_exec(
380
441
  str(wrapper_path),
@@ -100,7 +100,7 @@ SessionBindingValidator = Callable[[str, Mapping[str, object]], Awaitable[None]]
100
100
 
101
101
 
102
102
  class CommandDispatcher:
103
- """Translate local JSON requests into core command results."""
103
+ """Translate resource-scoped local JSON requests into core command results."""
104
104
 
105
105
  def __init__(
106
106
  self,
@@ -222,20 +222,40 @@ class CommandDispatcher:
222
222
  async def _dispatch_command(
223
223
  self, request: Mapping[str, object]
224
224
  ) -> Mapping[str, object]:
225
- session_id = request.get("session_id")
226
- if not isinstance(session_id, str) or not session_id:
225
+ resource = request.get("resource")
226
+ if not isinstance(resource, str) or not resource:
227
227
  raise CommandDispatchError(
228
- "SESSION_REQUIRED", "session_id must be a non-empty string"
228
+ "RESOURCE_REQUIRED", "resource must be a non-empty string"
229
229
  )
230
230
  command = request.get("command")
231
231
  if not isinstance(command, str) or not command:
232
232
  raise CommandDispatchError(
233
233
  "COMMAND_REQUIRED", "command must be a non-empty string"
234
234
  )
235
+ if resource == "message":
236
+ if command not in {"check", "read", "send"}:
237
+ raise CommandDispatchError(
238
+ "UNKNOWN_COMMAND", f"unsupported message command: {command}"
239
+ )
240
+ elif resource == "thread":
241
+ if command != "unfollow":
242
+ raise CommandDispatchError(
243
+ "UNKNOWN_COMMAND", f"unsupported thread command: {command}"
244
+ )
245
+ else:
246
+ raise CommandDispatchError(
247
+ "UNKNOWN_RESOURCE", f"unsupported command resource: {resource}"
248
+ )
249
+
250
+ session_id = request.get("session_id")
251
+ if not isinstance(session_id, str) or not session_id:
252
+ raise CommandDispatchError(
253
+ "SESSION_REQUIRED", "session_id must be a non-empty string"
254
+ )
235
255
  if self._session_binding_validator is not None:
236
256
  await self._session_binding_validator(session_id, request)
237
257
 
238
- if command == "check":
258
+ if resource == "message" and command == "check":
239
259
  result = await self._service.check(session_id)
240
260
  return {
241
261
  "ok": True,
@@ -252,7 +272,7 @@ class CommandDispatcher:
252
272
  },
253
273
  }
254
274
 
255
- if command == "read":
275
+ if resource == "message" and command == "read":
256
276
  target = request.get("target")
257
277
  if not isinstance(target, str) or not target:
258
278
  raise CommandDispatchError(
@@ -290,7 +310,7 @@ class CommandDispatcher:
290
310
  },
291
311
  }
292
312
 
293
- if command == "send":
313
+ if resource == "message" and command == "send":
294
314
  target = request.get("target")
295
315
  body = request.get("body")
296
316
  command_id = request.get("command_id")
@@ -343,7 +363,7 @@ class CommandDispatcher:
343
363
  "result": {"outbound": serialize_outbound(result)},
344
364
  }
345
365
 
346
- if command == "unfollow":
366
+ if resource == "thread" and command == "unfollow":
347
367
  target = request.get("target")
348
368
  if not isinstance(target, str) or not target:
349
369
  raise CommandDispatchError(
@@ -352,7 +372,7 @@ class CommandDispatcher:
352
372
  changed = await self._service.unfollow(session_id, target=target)
353
373
  return {"ok": True, "result": {"changed": changed}}
354
374
 
355
- raise CommandDispatchError("UNKNOWN_COMMAND", f"unsupported command: {command}")
375
+ raise AssertionError("validated command route has no handler")
356
376
 
357
377
 
358
378
  def format_message_time(timestamp_ms: int) -> str:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
3
4
  import os
4
5
  import tomllib
5
6
  from dataclasses import dataclass
@@ -25,6 +26,7 @@ class NodeConfiguration:
25
26
  effort: str | None = None
26
27
  sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE
27
28
  network_access: bool = True
29
+ runtime_idle_timeout_seconds: float = 0
28
30
  runtime_env_include: tuple[str, ...] = ()
29
31
  wecom_bot_id: str | None = None
30
32
  wecom_websocket_url: str | None = None
@@ -81,6 +83,13 @@ def load_node_configuration(path: Path | None = None) -> NodeConfiguration:
81
83
  network_access = runtime.get("network_access", True)
82
84
  if not isinstance(network_access, bool):
83
85
  raise ConfigurationError("runtime.network_access must be a boolean")
86
+ idle_timeout = runtime.get("idle_timeout", 0)
87
+ if (
88
+ isinstance(idle_timeout, bool)
89
+ or not isinstance(idle_timeout, int | float)
90
+ or not math.isfinite(idle_timeout)
91
+ ):
92
+ raise ConfigurationError("runtime.idle_timeout must be a finite number")
84
93
  return NodeConfiguration(
85
94
  channel=_optional_text(node.get("channel"), "node.channel"),
86
95
  runtime=_optional_text(node.get("runtime"), "node.runtime"),
@@ -92,6 +101,7 @@ def load_node_configuration(path: Path | None = None) -> NodeConfiguration:
92
101
  effort=_optional_text(runtime.get("effort"), "runtime.effort"),
93
102
  sandbox_mode=parsed_sandbox_mode,
94
103
  network_access=network_access,
104
+ runtime_idle_timeout_seconds=float(idle_timeout),
95
105
  runtime_env_include=_text_list(
96
106
  runtime_env.get("include", []), "runtime.env.include"
97
107
  ),