diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 42370c5dad..c04f198e4a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 23755696a9..cd08b56f1e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index cd2e19a2ef..ef641292dd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -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 文本。只有当前会话已经持久化时,退出提示才会出现。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5b3f7c4afb..f0ee05d705 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -983,7 +983,7 @@ isLive(id: SessionId): Promise Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:55`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/module-graph.md b/docs/module-graph.md index 7e2ee76934..212fd723a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -674,11 +674,13 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_goal pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_session_persistence + pkg_tui --> pkg_session_query pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title pkg_tui --> pkg_skill @@ -898,7 +900,7 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 4aaf30f45a..86a8c0f1d6 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -69,6 +69,6 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **Lease scope is local-host advisory ownership** — PID liveness prevents two ordinary local Harness processes from resuming the same id, but it is not a distributed lease for shared network filesystems or hostile principals. +- **Lease scope is local-host advisory ownership** — PID plus same-process nonce checks prevent two ordinary local Harness processes from resuming the same id, but foreign PID reuse remains fail-closed and this is not a distributed lease for shared network filesystems or hostile principals. - **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a3ed611a4c..8d96a17681 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -314,7 +314,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error const current = await this.readLiveLease(path) if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break - if (current === undefined || sessionLeaseProcessIsLive(current.pid)) { + if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } const reclaimPath = `${path}.reclaim` @@ -335,7 +335,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (latest === undefined) { if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`) } else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) { - if (sessionLeaseProcessIsLive(latest.pid)) { + if (sessionLeaseOwnerIsLive(latest, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } await rm(path, { force: true }) @@ -356,13 +356,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** Report one non-stale process lease and clean up a crashed owner's record. */ + /** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */ async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { const path = this.liveLeasePath(id) const current = await this.readLiveLease(path) if (current === undefined) return await this.exists(path) if (current.pid === owner.pid && current.nonce === owner.nonce) return true - if (sessionLeaseProcessIsLive(current.pid)) return true + if (sessionLeaseOwnerIsLive(current, owner)) return true return false } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 517cf7bb04..269d56f886 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -249,6 +249,13 @@ describe('SessionPersistenceJsonl: cross-process live leases', () => { const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited) await inheritedClaim.release() + const reusedPid = SessionId('reused-pid') + const reusedPidPath = join(liveDir, `${encodeSegment(reusedPid)}.lock`) + await writeFile(reusedPidPath, JSON.stringify({ pid: process.pid, nonce: 'prior-incarnation' })) + await expect(ctx.sessionPersistence.isLive(reusedPid)).resolves.toBe(false) + const reusedPidClaim = await ctx.sessionPersistence.claimLive(reusedPid) + await reusedPidClaim.release() + await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300)))) .rejects.toThrow() diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 374da93faa..42421b2e81 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -57,3 +57,4 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. - **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). +- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4399ee9c90..091880e87e 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,7 +15,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -295,7 +295,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current !== undefined && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { - if (sessionLeaseProcessIsLive(current.pid)) { + if (sessionLeaseOwnerIsLive(current, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id) @@ -323,7 +323,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current === undefined) return false if ((current.pid === owner.pid && current.nonce === owner.nonce) - || sessionLeaseProcessIsLive(current.pid)) return true + || sessionLeaseOwnerIsLive(current, owner)) return true this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?') .run(id, current.pid, current.nonce) return false diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a3aba22323..b670520a5c 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -13,7 +13,10 @@ import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../sessi import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] -afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +afterEach(async () => { + vi.restoreAllMocks() + for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) +}) async function expectFlushError(promise: Promise, message: RegExp): Promise { try { @@ -465,9 +468,16 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b.ctx.sessionPersistence.list() const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite const owner = sessionLiveOwner() + const occupiedPid = process.pid + 1 + const originalKill = process.kill.bind(process) + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === occupiedPid) return true + return originalKill(pid, signal) + }) const db = openDatabase(path, 'wal') const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') - insert.run('occupied-lease', process.pid, 'another-owner') + insert.run('occupied-lease', occupiedPid, 'another-owner') + insert.run('reused-pid', process.pid, 'prior-incarnation') insert.run('stale-claim', 2_147_483_647, 'dead-owner') insert.run('stale-inspect', 2_147_483_647, 'dead-owner') insert.run('owned-inspect', owner.pid, owner.nonce) @@ -475,11 +485,13 @@ describe('SessionPersistenceSqlite: edge cases', () => { await expect(concrete.acquireLive(SessionId('occupied-lease'), owner)) .rejects.toThrow('occupied by another live process') + const reused = await concrete.acquireLive(SessionId('reused-pid'), owner) const claim = await concrete.acquireLive(SessionId('stale-claim'), owner) expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true) expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false) expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false) await claim() + await reused() await b.dispose() const memory = new Context() diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 2bfa3a31b1..0aec3a4bd5 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -85,3 +85,4 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov - **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance. - **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale. - **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it. +- **Foreign PID reuse is fail-closed** — a claimant with the reused PID detects its different nonce and reclaims safely, but another process cannot observe that foreign process's private nonce and treats the PID as live until it exits or an operator verifies and removes the stale lease. diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 3aa602c8c0..d6bbee0616 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -13,7 +13,12 @@ import type { SessionLiveLease } from './lease.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' -export { sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease } from './lease.ts' +export { + sessionLeaseOwnerIsLive, + sessionLeaseProcessIsLive, + sessionLiveOwner, + shareSessionLiveLease, +} from './lease.ts' export type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** Lightweight immutable source identity returned without loading a full log. */ diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts index 5cc51117ab..148d8e117f 100644 --- a/packages/session-persistence/session-persistence/src/lease.ts +++ b/packages/session-persistence/session-persistence/src/lease.ts @@ -8,7 +8,7 @@ const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' export interface SessionLiveOwner { /** Operating-system process id; retained across an `execve` handoff. */ readonly pid: number - /** Per-process-start nonce that distinguishes PID reuse. */ + /** Exec-stable process-start nonce used when the observer has the same PID. */ readonly nonce: string } @@ -42,9 +42,26 @@ export function sessionLeaseProcessIsLive(pid: number): boolean { } } +/** + * Whether a recorded owner still names this process incarnation or another live PID. + * A same-PID nonce mismatch proves reuse and is stale; an unrelated live PID is + * fail-closed because its private nonce is not observable across processes. + * @param recorded - owner stored in the backend lease. + * @param observer - identity of the process inspecting or claiming the lease. + * @returns whether the recorded owner must still be treated as live. + */ +export function sessionLeaseOwnerIsLive( + recorded: SessionLiveOwner, + observer: SessionLiveOwner, +): boolean { + if (recorded.pid === observer.pid) return recorded.nonce === observer.nonce + return sessionLeaseProcessIsLive(recorded.pid) +} + interface SharedLeaseEntry { refs: number readonly acquired: Promise<() => Promise> + finalizing?: Promise } const sharedLeases = new Map() @@ -59,40 +76,48 @@ export async function shareSessionLiveLease( key: string, acquire: () => Promise<() => Promise>, ): Promise<() => Promise> { - let entry = sharedLeases.get(key) - if (entry === undefined) { - entry = { refs: 0, acquired: acquire() } - sharedLeases.set(key, entry) - void entry.acquired.catch(() => { - /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - }) - } - entry.refs += 1 - try { - await entry.acquired - } catch (error) { - entry.refs -= 1 - throw error - } - let releaseTask: Promise | undefined - return () => { - if (releaseTask !== undefined) return releaseTask - const task = (async () => { + for (;;) { + let entry = sharedLeases.get(key) + if (entry?.finalizing !== undefined) { + await entry.finalizing + continue + } + if (entry === undefined) { + entry = { refs: 0, acquired: acquire() } + sharedLeases.set(key, entry) + void entry.acquired.catch(() => { + /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + }) + } + entry.refs += 1 + try { + await entry.acquired + } catch (error) { entry.refs -= 1 - if (entry.refs > 0 || sharedLeases.get(key) !== entry) return - const release = await entry.acquired - await release() - /* v8 ignore next -- the entry remains installed until this exact final release succeeds */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - })() - const wrapped = task.catch((error: unknown) => { - entry.refs += 1 - /* v8 ignore next -- this closure is the sole writer of its releaseTask until settlement */ - if (releaseTask === wrapped) releaseTask = undefined throw error - }) - releaseTask = wrapped - return wrapped + } + let releaseTask: Promise | undefined + return () => { + if (releaseTask !== undefined) return releaseTask + const task = (async () => { + entry.refs -= 1 + if (entry.refs > 0 || sharedLeases.get(key) !== entry) return + const release = await entry.acquired + await release() + /* v8 ignore next -- claims wait for finalization before they can replace this exact entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + })() + const wrapped = task.catch((error: unknown) => { + entry.refs += 1 + /* v8 ignore next -- this closure is the sole writer of its release state until settlement */ + if (entry.finalizing === wrapped) delete entry.finalizing + releaseTask = undefined + throw error + }) + if (entry.refs === 0 && sharedLeases.get(key) === entry) entry.finalizing = wrapped + releaseTask = wrapped + return wrapped + } } } diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts index e35bba9311..8c38aac874 100644 --- a/packages/session-persistence/session-persistence/tests/lease.spec.ts +++ b/packages/session-persistence/session-persistence/tests/lease.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { randomUUID } from 'node:crypto' import { + sessionLeaseOwnerIsLive, sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease, @@ -21,10 +22,14 @@ describe('process live-session lease helpers', () => { expect(first.pid).toBe(process.pid) expect(typeof first.nonce).toBe('string') expect(sessionLiveOwner()).toEqual(first) + expect(sessionLeaseOwnerIsLive(first, first)).toBe(true) + expect(sessionLeaseOwnerIsLive({ ...first, nonce: 'reused-pid' }, first)).toBe(false) expect(sessionLeaseProcessIsLive(process.pid)).toBe(true) const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) + expect(sessionLeaseOwnerIsLive({ pid: 999_999, nonce: 'gone' }, first)).toBe(false) + vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) expect(sessionLeaseProcessIsLive(999_999)).toBe(false) const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied }) @@ -59,4 +64,29 @@ describe('process live-session lease helpers', () => { await expect(release()).resolves.toBeUndefined() expect(releases).toBe(2) }) + + it('waits for a final physical release before reacquiring the same key', async () => { + const key = `finalizing-${randomUUID()}` + const releaseGate = Promise.withResolvers() + const firstPhysicalRelease = vi.fn(() => releaseGate.promise) + const secondPhysicalRelease = vi.fn(() => Promise.resolve()) + const releases: Array<() => Promise> = [firstPhysicalRelease, secondPhysicalRelease] + let acquisitions = 0 + const acquire = vi.fn<() => Promise<() => Promise>>((): Promise<() => Promise> => { + const release = releases[acquisitions++] + if (release === undefined) throw new Error('unexpected physical acquisition') + return Promise.resolve(release) + }) + const first = await shareSessionLiveLease(key, acquire) + const finalizing = first() + const reacquiring = shareSessionLiveLease(key, acquire) + await Promise.resolve() + expect(acquire).toHaveBeenCalledTimes(1) + releaseGate.resolve(undefined) + await finalizing + const second = await reacquiring + expect(acquire).toHaveBeenCalledTimes(2) + await second() + expect(secondPhysicalRelease).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index ee3e787c3f..db13d8733e 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, flushes it, stops the terminal UI, and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, and claims the target live lease before flushing the current session; a lost claim race or later recoverable failure leaves the current TUI running and releases any acquired reservation. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process while retaining the reservation, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 76e9c9c7aa..3242f5a2a3 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -53,9 +53,6 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, - "@deepseek-ai/dsh-goal": { - "optional": true - }, "@deepseek-ai/dsh-skill": { "optional": true } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 7317f3000b..90297fc478 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -77,9 +77,9 @@ import type { SessionLogSnapshot, SessionRecord, } from '@deepseek-ai/dsh-session-query' -// Side-effect type import: declaration-merges the optional `sessionPersistence` +// Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. -import type {} from '@deepseek-ai/dsh-session-persistence' +import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, @@ -1841,6 +1841,8 @@ export function createTuiChat( let modelOverlay: TuiOverlaySession | undefined let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false + let resumeReservation: SessionLiveLease | undefined + let resumeReservationCommitted = false let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } @@ -1853,6 +1855,12 @@ export function createTuiChat( const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed + const releaseResumeReservation = async (): Promise => { + const reservation = resumeReservation + if (reservation === undefined) return + await reservation.release() + resumeReservation = undefined + } // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2436,6 +2444,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() + /* v8 ignore else -- the committed branch is the non-returning exec handoff covered by the keyless PTY test */ + if (!resumeReservationCommitted) await releaseResumeReservation() contextResolution = undefined clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) @@ -2871,6 +2881,7 @@ export function createTuiChat( const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { if (resumeInFlight) return resumeInFlight = true + let terminalReleased = false try { const checked = await preflightResume(candidate.record.header.id) const hostHandoff = runtime.handoffResume @@ -2884,30 +2895,52 @@ export function createTuiChat( : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } + if (persistence === undefined) { + throw new Error('Resume is unavailable: session persistence is not mounted.') + } + resumeReservation = await persistence.claimLive(checked.record.header.id) + if (disposed) { + await releaseResumeReservation() + return + } await ctx.sessions.flush(agent.session) + // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (disposed) return if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) await overlay.close() resumeOverlay = undefined await runtime.terminal.drainInput(100, 20) + // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (disposed) return ui.stop() - try { - await hostHandoff(checked.record.header.id) - throw new Error('resume host returned without replacing the process') - } catch (error: unknown) { - /* v8 ignore next -- a committed host disposes this TUI and never returns; pre-commit rejection keeps it live */ - if (!disposed) { + terminalReleased = true + resumeReservationCommitted = true + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + /* v8 ignore next -- a committed host disposes this TUI and never returns; recoverable rejection keeps it live */ + if (!disposed) { + resumeReservationCommitted = false + let reported = error + try { + await releaseResumeReservation() + } catch (releaseError: unknown) { + reported = new Error( + `${errorChain(error)}; target reservation release failed: ${errorChain(releaseError)}`, + ) + } + if (terminalReleased) { ui.start() ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error') + } else { + await overlay.close() + resumeOverlay = undefined + appendNotice(`Resume failed: ${errorChain(reported)}`, 'error') } } - } catch (error: unknown) { - /* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */ - if (!disposed) { - await overlay.close() - resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(error)}`, 'error') - } } finally { resumeInFlight = false } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6b9bd143d4..22692026e5 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -10,6 +10,7 @@ import AgentRegistry, { import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -53,6 +54,7 @@ export interface TuiHarnessOptions { list(): Promise load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> isLive?(id: ReturnType): Promise + claimLive?(id: ReturnType): Promise } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ @@ -138,7 +140,9 @@ export async function createTuiTestHarness) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType) => persistence.load!(id), - claimLive: () => Promise.resolve({ release: () => Promise.resolve() }), + claimLive: persistence.claimLive === undefined + ? () => Promise.resolve({ release: () => Promise.resolve() }) + : (id: ReturnType) => persistence.claimLive!(id), isLive: persistence.isLive === undefined ? () => Promise.resolve(false) : (id: ReturnType) => persistence.isLive!(id), diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a6f8d30341..635446b041 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -562,6 +562,8 @@ describe('resume command and /resume', () => { it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { const target = header('target-session', 10, '/workspace') + const releaseReservation = vi.fn(() => Promise.resolve()) + const claimLive = vi.fn(async () => ({ release: releaseReservation })) const handoff = vi.fn>(() => Promise.reject(new Error('test host retained process'))) const result = await setup({ cwd: '/workspace', @@ -569,6 +571,7 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Target session') }), + claimLive, }, }) result.terminal.send('/resume') @@ -579,6 +582,8 @@ describe('resume command and /resume', () => { await tick(); await tick() expect(handoff).toHaveBeenCalledTimes(1) expect(handoff).toHaveBeenCalledWith(target.id) + expect(claimLive).toHaveBeenCalledWith(target.id) + expect(releaseReservation).toHaveBeenCalledTimes(1) expect(result.terminal.stopped).toBeGreaterThan(0) expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') await dispose(result) @@ -630,6 +635,183 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('keeps the current TUI when the target reservation loses the preflight race', async () => { + const target = header('reservation-race', 10, '/workspace') + const handoff = vi.fn>() + const flush = vi.fn() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', flush) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Reservation race') }), + claimLive: () => Promise.reject(new Error('occupied after preflight')), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Reservation race') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume failed: occupied after preflight') + expect(flush).not.toHaveBeenCalled() + expect(handoff).not.toHaveBeenCalled() + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('refuses host handoff when a query backend has no persistence lease service', async () => { + const target = header('query-without-persistence', 10, '/workspace') + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: false, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Query without persistence'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Query without persistence') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('session persistence is not mounted') + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + + it('releases a reservation that resolves after TUI disposal', async () => { + const target = header('late-reservation', 10, '/workspace') + const claiming = Promise.withResolvers<{ release(): Promise }>() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Late reservation') }), + claimLive: () => claiming.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Late reservation') + result.terminal.send('\r') + await tick() + await dispose(result) + claiming.resolve({ release }) + await tick() + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('does not hand off after disposal begins during the current-session flush', async () => { + const target = header('dispose-during-flush', 10, '/workspace') + const flushing = Promise.withResolvers() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', () => flushing.promise) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during flush') + result.terminal.send('\r') + await tick() + const disposing = dispose(result) + await tick() + flushing.resolve(undefined) + await disposing + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('does not hand off after disposal begins while terminal input drains', async () => { + const target = header('dispose-during-drain', 10, '/workspace') + const draining = Promise.withResolvers() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.drainInput.mockImplementationOnce(() => draining.promise) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during drain') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.drainInput).toHaveBeenCalled() }) + await dispose(result) + draining.resolve(undefined) + await tick() + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('reports a target reservation release failure after a recoverable host rejection', async () => { + const target = header('release-failure', 10, '/workspace') + let releases = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: () => Promise.reject(new Error('host rejected')), + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Release failure') }), + claimLive: async () => ({ + release: () => ++releases === 1 + ? Promise.reject(new Error('lock unavailable')) + : Promise.resolve(), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Release failure') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('target reservation release failed') + expect(result.terminal.output).toContain('release failed: lock') + await dispose(result) + expect(releases).toBe(2) + }) + it('rejects a candidate whose cwd changes between listing and preflight', async () => { const target = header('moving-workspace', 10, '/workspace') let listings = 0