diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 43a78e3d2..06025c1cd 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -139,6 +139,8 @@ A reap stops an idle Tool's Session, shell included; rehydrate starts it again i 2. Keep the last `dehydrate` payload the run emits from then until the kill; none earlier counts. 3. Kill the PTY at the prompt or after a grace, whichever comes first, so a hung Tool blocks nothing. +**Never abandon a stop once its Ctrl+C is written**: only a close or a replacement Session cancels the kill, never input, a dirty report, or being shown, which rehydrates once the stop ends (rationale). + **Must rehydrate a reaped Tool when it becomes visible, or when `dor tool` or `dor open` matches it** (reporting `adopted`), never into a closing or transferring Workspace: a fresh shell in the run's directory, the saved command typed as cold restore types it ([Persistence and hosts](#persistence-and-hosts)). It degrades in tiers: | Tier | When | diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md index dab44e2cf..418092ad2 100644 --- a/docs/specs/dor-tool.rationale.md +++ b/docs/specs/dor-tool.rationale.md @@ -64,6 +64,8 @@ The ladder has no retype (decided 2026-10-03, PR #961 review): no shipped Tool n The stop silences the run's command-exit watch first. Observed in the innerdogfood harness (2026-10-03): a folder viewer launched while engaged had its exit seen, so the reap's Ctrl+C rang `COMMAND_EXIT` while it sat Doored, and the rehydrate carried that ring back as a TODO; a push or spoken alarm would have announced the host's own housekeeping. +The stop commits at the interrupt (decided 2026-10-21, PR #1286 review). By the time the grace ends the run has usually exited, so skipping the kill restores nothing: it lapses the host hold, turns the Surface into a plain terminal, and drops the payload just captured, where finishing leaves a Tool that rehydrates from it. The window is a few seconds on a Tool already out of sight for the idle threshold. + A preview slot is excluded because its retarget interrupts and retypes in place (Preview slot), which a Session with no shell cannot take. A Tool with an auxiliary helper keeps a second shell alive anyway, and killing the parent would orphan it. ## Naming diff --git a/lib/src/components/wall/tool-reaper.test.ts b/lib/src/components/wall/tool-reaper.test.ts index 962fd7f1a..fddc89190 100644 --- a/lib/src/components/wall/tool-reaper.test.ts +++ b/lib/src/components/wall/tool-reaper.test.ts @@ -195,6 +195,20 @@ describe('stopTool', () => { expect(isToolReaped(ID)).toBe(false); }); + it('never kills a replacement Session that reused the id mid-stop', async () => { + const lath = servingTool(); + const stopped = stopTool(lath, ID); + disposeSession(ID); + getOrCreateTerminal(ID); + startRun(); + kills.length = 0; + await finishStop(); + expect(await stopped).toBe(false); + expect(kills).toEqual([]); + expect(registry.get(ID)?.exited).not.toBe(true); + expect(isToolReaped(ID)).toBe(false); + }); + it('stops nothing that is not eligible', async () => { const lath = servingTool(); announce({ dehydrate: false }); diff --git a/lib/src/components/wall/tool-reaper.ts b/lib/src/components/wall/tool-reaper.ts index a717a4612..a9c5dc765 100644 --- a/lib/src/components/wall/tool-reaper.ts +++ b/lib/src/components/wall/tool-reaper.ts @@ -94,6 +94,7 @@ function waitFor(id: string, done: () => boolean, timeoutMs: number): Promise { if (toolReapBlocker(id, lath.getMeta(id)?.params) !== null) return false; + const entry = registry.get(id); const state = getTerminalPaneState(id); const cwd = state.currentCommand?.cwdAtStart?.path ?? state.cwd?.path ?? null; beginToolStop(id); @@ -107,8 +108,9 @@ export async function stopTool(lath: LathWallEngine, id: string): Promise