Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/dor-tool.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/src/components/wall/tool-reaper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
6 changes: 4 additions & 2 deletions lib/src/components/wall/tool-reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function waitFor(id: string, done: () => boolean, timeoutMs: number): Promise<vo
*/
export async function stopTool(lath: LathWallEngine, id: string): Promise<boolean> {
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);
Expand All @@ -107,8 +108,9 @@ export async function stopTool(lath: LathWallEngine, id: string): Promise<boolea
} finally {
payload = endToolStop(id);
}
// Closed meanwhile: its closure owns the teardown.
if (!registry.has(id) || !lath.getMeta(id)) return false;
// Closed meanwhile: its closure owns the teardown, and a Session reusing
// the id is not the one this stop interrupted.
if (registry.get(id) !== entry || !lath.getMeta(id)) return false;
const alert = getLivePersistedAlertState(id);
reapTerminal(id);
retireToolRun(lath, id);
Expand Down
Loading