Gateway Startup Locking Spec
Date: 2026-06-27 Status: Draft
Problem
当初始进程被中断,或在第一个进程仍处于 schema 迁移过程中又启动了第二个 gateway 进程时,桌面端的首次启动可能会导致捆绑的 gateway 无法启动。
观察到的失败模式如下:
- 全新的桌面状态能够成功启动到足以初始化 agent workspace 的程度。
- 第一个捆绑的 gateway 进程未能到达
gateway.started。 - 之后的 gateway 进程在
apply_pending()中因 yoyo 的LockTimeout而失败。 - yoyo 错误在每次重试时都报告同一个较早的进程 id。
直接的失败原因是桌面端 sessions.db 的 yoyo 迁移锁表中存在一条陈旧或仍处于活动状态的记录。
一旦出现这种状态,桌面端反复重试无法自行恢复。
Goals
- 防止 Electron shell 为同一个桌面 profile 启动重复的 gateway 进程。
- 在整个 server 生命周期内保持 gateway 的 pid 锁有效,并在优雅关闭时释放它。
- 仅当记录的 pid 被证实已死亡时,才从陈旧的 yoyo 迁移锁中恢复。
- 当另一个进程可能仍在迁移时,保留 yoyo 的安全保证。
- 呈现可操作的启动错误,而不是难以理解的 PyInstaller traceback。
Non-Goals
- 不要禁用 yoyo 迁移锁。
- 不要在每次迁移锁超时时盲目运行
break-lock。 - 不要在此修复中更改 session 数据库 schema。
- 不要让桌面端 gateway 与 Electron 共享进程。
- 不要放宽 gateway 的绑定或 auth 行为。
Existing Surfaces
- Electron 在
desktop/electron/src/main.ts中通过spawn(...)启动捆绑的 gateway。 - Electron 在报告 gateway 未变为健康状态之前,最多等待 45 秒的
/healthz。 - gateway 在
build_services()之前以及 session 数据库迁移之前获取GatewayPidLock。 build_services()在打开SessionStorage之前运行apply_pending(session_db_path, migrations_dir)。apply_pending()通过with backend.lock():委托加锁。- yoyo 将迁移锁实现为
yoyo_lock中的一行(以 pid 为键),并在finally代码块中将其移除。进程的硬退出可能会残留该行记录。
Design
实现三层防御。每一层针对不同的失败模式,并且应当能够独立测试。
Layer 1: Electron Single Instance Guard
在 Electron 应用启动附近、任何 gateway 启动工作能够运行之前,添加
app.requestSingleInstanceLock()。
预期行为:
- 如果无法获取锁,则新的 Electron 进程立即退出。
- 在
second-instance时,已有的主窗口被恢复并获得焦点。 bootDesktopApp()不得在第二个 Electron 进程中运行。- 已有进程仍然是唯一被允许 spawn gateway 的所有者。
这能减少因双击应用、从 Finder 重新打开,或在首次运行仍在进行时从 DMG 启动而导致的 重复 gateway 启动。
Layer 2: Gateway PID Lock Lifetime
将获取到的 GatewayPidLock 存储在返回的 GatewayServer 对象上,并在
GatewayServer.close() 中释放它。
预期行为:
start_gateway_server()像现在一样,在数据库迁移之前获取锁。- 锁对象在整个 server 生命周期内保持强引用。
GatewayServer.close()在关闭工作完成到新 gateway 可以安全启动的程度之后, 恰好调用一次release()。release()保持幂等。- 现有的
atexit和信号清理仍作为尽力而为的回退路径保留。
这使得正常的 gateway 关闭变得显式,而不是仅依赖进程退出行为。
Layer 3: Conservative Yoyo Stale Lock Recovery
在 apply_pending() 内部包装 yoyo 的 LockTimeout 处理。
当 yoyo 报告锁超时时:
- 检查锁表中记录的 pid。
- 如果任何记录的 pid 仍存活,则在不清除锁的情况下失败。
- 如果记录的每个 pid 都已死亡或无效,则删除 yoyo 的锁记录行。
- 重试一次迁移。
- 如果重试失败,则在不进入另一个恢复循环的情况下呈现第二次失败。
存活性检查应使用与平台相适应的进程探测方式:
- POSIX:
os.kill(pid, 0)。 - Windows:
OpenProcess或等效的现有 helper 行为。
锁恢复路径必须记录结构化事件:
migrator.lock_timeoutmigrator.lock_held_by_live_processmigrator.stale_lock_clearedmigrator.stale_lock_retry_failed
面向操作者的错误应说明 gateway 是仍在启动中、另一个 gateway 正在运行,还是有一个 陈旧的迁移锁无法恢复。
Safety Rules
- 永远不要清除由存活 pid 持有的 yoyo 锁。
- 如果无法安全地检查数据库,永远不要清除锁。
- 在清除陈旧锁后,永远不要将迁移重试超过一次。
- 当 schema 状态不确定时,保持迁移失败的明显性。
- 宁可出现 false negative,也不要 false positive:让启动失败比破坏一次迁移更安全。
Implementation Notes
Electron:
- 在
desktop/electron/src/main.ts中添加单实例处理。 - 为已有进程保留当前的
startupInProgressguard。 - 在第二个实例上,如果
mainWindow存在,则恢复并聚焦它。
Gateway:
- 为
GatewayServer扩展一个可选的 pid 锁字段。 - 在
GatewayPidLock.acquire()成功后赋值获取到的锁。 - 在
GatewayServer.close()中以finally安全的路径释放锁。
Migrator:
- 显式导入 yoyo 的
LockTimeout。 - 添加一个小型 helper,通过 yoyo backend 或对同一本地数据库的独立 SQLite 连接来查询
yoyo_lock行。 - 添加一个小型 helper 来清除 yoyo 锁表。
- 保持
:memory:行为不变。 - 保持正常的无待处理迁移行为不变。
Test Plan
Unit Tests
- 当第一个锁仍被持有时,
GatewayPidLock拒绝对同一 state dir 的第二次锁获取。 GatewayServer.close()释放存储的 gateway pid 锁。apply_pending()不会为存活 pid 清除 yoyo 锁。apply_pending()为已死亡 pid 清除 yoyo 锁并重试一次。- 在陈旧锁恢复失败后,
apply_pending()不会进入无界的重试循环。 apply_pending()保持正常迁移成功和 no-op 行为。
Electron Tests
- 第二个 Electron 实例不会调用
bootDesktopApp()。 - 第二个 Electron 实例聚焦已有窗口。
- 当启动已在进行中时,桌面端重试不会 spawn 新的 gateway。
Integration Tests
- 创建一个临时桌面状态,其
sessions.db含有一条陈旧的yoyo_lock行。gateway 启动 将其清除并完成迁移。 - 创建一个临时桌面状态,在
yoyo_lock中记录一个存活的 helper 进程。gateway 启动以 清晰、无破坏性的错误失败。 - 针对同一 state dir 启动两个 gateway 进程。第二个进程在数据库迁移之前失败。
Manual DMG Smoke
- 将 DMG 安装到
/Applications。 - 从干净的桌面用户数据目录开始。
- 在首次运行启动期间反复双击应用。
- 确认只启动了一个 gateway 进程。
- 确认应用变为就绪,或呈现一个清晰的单次启动错误。
- 在首次运行迁移期间强制退出,重新启动,并确认当记录的 pid 不再存活时陈旧锁恢复有效。
Acceptance Criteria
- 全新 DMG 的首次运行无法从两个 Electron 实例 spawn 两个被拥有的 gateway 进程。
- 一个 pid 已死亡的陈旧 yoyo 迁移锁会被自动清除,且 gateway 启动成功。
- 一个 pid 存活的 yoyo 迁移锁永远不会被自动清除。
- 使用同一桌面状态的第二个 gateway 在迁移工作之前失败。
- 优雅的 gateway 关闭会移除
gateway.pid并释放gateway.pid.lock。 - 失败信息区分活动启动、已运行的 gateway 和不可恢复的迁移锁状态。
- CI 覆盖陈旧锁恢复和存活锁不恢复。
Rollout
- 以正常启动行为发布,没有面向用户的设置项。
- 在桌面端 gateway 日志中保留结构化日志,用于事后诊断。
- 在该行为实现之后,添加一条简短的排障条目。
Open Questions
- yoyo 锁检查应仅使用 yoyo backend API,还是对本地文件数据库使用直接 SQLite。
- 桌面端启动在检测到 schema 迁移处于活动状态后,是否应延长首次运行的健康超时。
- 启动 splash 是否应显示一个独立的 “Preparing database” 阶段。