fix(tui): close resume handoff races

This commit is contained in:
NI0317
2026-07-24 12:58:53 +08:00
committed by ZiyaZhang
parent 2ae9f4fdf3
commit 54d986ed87
20 changed files with 375 additions and 76 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-tui-resume-command.md: 23755696a9b7b379f0341c472769684839b37211
2026-07-21-tui-resume-command.zh.md: cd2e19a2ef95409e8e11199f08afa996e8b07414
2026-07-21-tui-resume-command.md: cd08b56f1e887473fd1df9f5f6055cb7c5e0a9b4
2026-07-21-tui-resume-command.zh.md: ef641292dd178e19f94b6f79d3ab8607da27ee6d

View File

@@ -14,9 +14,9 @@ The original `/resume` printed shell commands. It did not let a keyboard user in
`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly.
First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID, and release only after the exact session lifecycle drains. `AgentLoop.resume()` claims before load, closing the preflight/start race.
First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID or a same-PID different-incarnation owner, and release only after the exact session lifecycle drains. A final process-local release excludes reacquisition until the physical lease settles. `AgentLoop.resume()` claims before load, closing the preflight/start race.
After preflight, the TUI flushes the current session and stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
After preflight, the TUI claims the target's exec-stable live lease before flushing the current session. A lost claim race remains in the current TUI; any later recoverable failure releases the reservation. The TUI then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process while retaining the target reservation rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable.

View File

@@ -14,9 +14,9 @@ Status: implemented
`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。
第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 的租约,并且仅在对应会话生命周期完全停稳后释放租约。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。
第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 或 PID 相同但进程代际不同的租约,并且仅在对应会话生命周期完全停稳后释放租约。进程内最后一个引用开始释放后,新的领取操作必须等待物理租约完成释放再重新获取。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。
预检通过后,TUI 先刷写当前会话并停止终端,再调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
预检通过后,TUI 会先领取目标会话在进程替换前后保持稳定的活跃租约,再刷写当前会话。如果目标在预检后被其他进程抢占,当前 TUI 会继续运行;之后任何可恢复失败也会释放该预留租约。随后 TUI 停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,同时保留目标预留租约,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。