Skip to content

Commit 3cfbda0

Browse files
authored
fix(#87): prevent session data loss on abnormal exit / 修复异常退出时会话数据丢失 (#108)
* feat(console): 引入会话心跳机制防止数据丢失 - 新增功能: 实现后台心跳线程以定期持久化会话时长 * 在 `src/console/main.py` 中新增 `_session_heartbeat` 函数,通过 `threading.Thread` 启动守护进程 * 引入环境变量 `SESSION_UPDATE_INTERVAL` (默认 30 秒) 控制心跳频率 * 调用 `db.update_session_duration(session_id)` 异步更新数据库中的 `sessions` 表 - 修复问题: 优化异常终止场景下的会话数据完整性 * 在 `_cleanup` 函数中添加逻辑,设置 `threading.Event` (`_session_heartbeat_stop`) 以安全停止心跳线程 * 在 `src/console/commands/systems/quit_cmd.py` 的 `execute` 方法中显式调用 `db.close_session`,确保 `/quit` 命令触发时立即保存状态 - 重构优化: 扩展数据库管理器支持增量更新 * 在 `src/core/database_manager.py` 中新增 `update_session_duration` 方法,仅计算并更新持续时间而不关闭会话连接 - 文档更新: 补充环境配置说明 * 在 `.env.example` 文件中添加 `SESSION_UPDATE_INTERVAL` 配置项及其注释 * docs(readme): 新增会话持久化间隔环境变量 - 文档更新: 补充新的配置项说明 * 在 `README_ZH.md` 和 `README.md` 的环境变量表中添加 `SESSION_UPDATE_INTERVAL` * 说明该变量用于控制会话持续时间持久化的时间间隔,默认值为 30 秒
1 parent 7000309 commit 3cfbda0

6 files changed

Lines changed: 75 additions & 16 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ RESULT_CLEANUP_MINUTES=15
2525
# 是否自动复制结果(支持 true/false/yes/no/on/off)
2626
MANUALAID_AUTO_COPY=true
2727

28+
# 会话持续时间持久化间隔(秒)
29+
# 应用会每隔 N 秒将当前会话耗时写入数据库,
30+
SESSION_UPDATE_INTERVAL=30
31+

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,15 @@ also require audit approval before execution.
167167

168168
Copy `.env.example` to `.env` and adjust as needed:
169169

170-
| Variable | Default | Description |
171-
| ------------------------------ | ------- | ----------------------------------- |
172-
| `TOOL_MAX_RESULT_LENGTH` | 30000 | Max characters in tool output |
173-
| `TOOL_LIST_TRUNCATE_THRESHOLD` | 100 | Max items in list results |
174-
| `TOOL_DICT_TRUNCATE_THRESHOLD` | 100 | Max key-value pairs in dict results |
175-
| `MANUALAID_AUTO_COPY` | true | Auto-copy results to clipboard |
176-
| `MANUALAID_AUTO_VIEW` | true | Auto-view results in viewer |
177-
| `RESULT_EXPIRE_MINUTES` | 5 | Result cache expiration (minutes) |
170+
| Variable | Default | Description |
171+
| ------------------------------ | ------- | ----------------------------------------- |
172+
| `TOOL_MAX_RESULT_LENGTH` | 30000 | Max characters in tool output |
173+
| `TOOL_LIST_TRUNCATE_THRESHOLD` | 100 | Max items in list results |
174+
| `TOOL_DICT_TRUNCATE_THRESHOLD` | 100 | Max key-value pairs in dict results |
175+
| `MANUALAID_AUTO_COPY` | true | Auto-copy results to clipboard |
176+
| `MANUALAID_AUTO_VIEW` | true | Auto-view results in viewer |
177+
| `RESULT_EXPIRE_MINUTES` | 5 | Result cache expiration (minutes) |
178+
| `SESSION_UPDATE_INTERVAL` | 30 | Session duration persistence interval (s) |
178179

179180
---
180181

README_ZH.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,15 @@ ManualAid 注册了 12 个工具供 LLM 通过 XML 函数调用使用:
152152

153153
复制 `.env.example``.env` 并根据需要调整:
154154

155-
| 变量 | 默认值 | 描述 |
156-
| ------------------------------ | ------ | ---------------------- |
157-
| `TOOL_MAX_RESULT_LENGTH` | 30000 | 工具输出的最大字符数 |
158-
| `TOOL_LIST_TRUNCATE_THRESHOLD` | 100 | 列表结果的最大条目数 |
159-
| `TOOL_DICT_TRUNCATE_THRESHOLD` | 100 | 字典结果的最大键值对数 |
160-
| `MANUALAID_AUTO_COPY` | true | 自动将结果复制到剪贴板 |
161-
| `MANUALAID_AUTO_VIEW` | true | 自动在查看器中显示结果 |
162-
| `RESULT_EXPIRE_MINUTES` | 5 | 结果缓存过期时间(分钟) |
155+
| 变量 | 默认值 | 描述 |
156+
| ------------------------------ | ------ | -------------------------- |
157+
| `TOOL_MAX_RESULT_LENGTH` | 30000 | 工具输出的最大字符数 |
158+
| `TOOL_LIST_TRUNCATE_THRESHOLD` | 100 | 列表结果的最大条目数 |
159+
| `TOOL_DICT_TRUNCATE_THRESHOLD` | 100 | 字典结果的最大键值对数 |
160+
| `MANUALAID_AUTO_COPY` | true | 自动将结果复制到剪贴板 |
161+
| `MANUALAID_AUTO_VIEW` | true | 自动在查看器中显示结果 |
162+
| `RESULT_EXPIRE_MINUTES` | 5 | 结果缓存过期时间(分钟) |
163+
| `SESSION_UPDATE_INTERVAL` | 30 | 会话持续时间持久化间隔(秒) |
163164

164165
---
165166

src/console/commands/systems/quit_cmd.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@ def __init__(self):
1414
self.usage = "/quit or /q or /exit"
1515

1616
def execute(self, context: CommandContext) -> CommandResult:
17+
# Close session explicitly before exit (atexit handler is a fallback,
18+
# but explicit is more reliable when sys.exit triggers early shutdown).
19+
session_id = getattr(context.tool_registry, "_current_session_id", None)
20+
if session_id is not None and hasattr(context.workspace, "db"):
21+
context.workspace.db.close_session(session_id)
1722
context.console.print("[bold]Goodbye![/bold]")
1823
sys.exit(0)

src/console/main.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import argparse
44
import atexit
5+
import contextlib
6+
import os
57
import sys
8+
import threading
69
import time
710
from pathlib import Path
811

@@ -16,6 +19,22 @@
1619

1720
tool_registry = ToolRegistry()
1821

22+
# Session heartbeat: periodically persists session duration so that abnormal
23+
# termination (window close, Ctrl+C, SIGKILL) loses minimal data.
24+
_session_heartbeat_stop: threading.Event | None = None
25+
26+
27+
def _session_heartbeat(
28+
db,
29+
session_id: int,
30+
stop_event: threading.Event,
31+
interval: float,
32+
) -> None:
33+
"""Periodically persist session duration during normal operation."""
34+
while not stop_event.wait(interval):
35+
with contextlib.suppress(Exception):
36+
db.update_session_duration(session_id)
37+
1938

2039
def init_workspace(start_path: str | None = None) -> Workspace | None:
2140
"""初始化工作区"""
@@ -42,12 +61,26 @@ def init_workspace(start_path: str | None = None) -> Workspace | None:
4261
tool_registry.set_session_id(session_id)
4362
workspace._current_session_id = session_id
4463

64+
# Start a daemon heartbeat thread to periodically persist session duration
65+
global _session_heartbeat_stop
66+
_session_heartbeat_stop = threading.Event()
67+
interval = int(os.getenv("SESSION_UPDATE_INTERVAL", "30"))
68+
thread = threading.Thread(
69+
target=_session_heartbeat,
70+
args=(workspace.db, session_id, _session_heartbeat_stop, interval),
71+
daemon=True,
72+
)
73+
thread.start()
74+
4575
atexit.register(_cleanup, workspace, session_id)
4676

4777
return workspace
4878

4979

5080
def _cleanup(workspace: Workspace, session_id: int) -> None:
81+
global _session_heartbeat_stop
82+
if _session_heartbeat_stop is not None:
83+
_session_heartbeat_stop.set()
5184
if session_id and hasattr(workspace, "db"):
5285
workspace.db.close_session(session_id)
5386
workspace.db.close()

src/core/database_manager.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,21 @@ def close_session(self, session_id: int) -> None:
216216
(duration, session_id),
217217
)
218218

219+
def update_session_duration(self, session_id: int) -> None:
220+
"""Persist current elapsed duration without closing the session.
221+
222+
Used by the periodic heartbeat so that abnormal termination (window
223+
close, Ctrl+C, SIGKILL) loses at most the heartbeat interval's worth
224+
of session duration data.
225+
"""
226+
row = self.fetchone("SELECT created_at FROM sessions WHERE id = ?", (session_id,))
227+
if row:
228+
duration = time.time() - row[0]
229+
self.execute(
230+
"UPDATE sessions SET duration = ? WHERE id = ?",
231+
(duration, session_id),
232+
)
233+
219234
# -- Tool call logging --
220235

221236
def log_tool_call(

0 commit comments

Comments
 (0)