diff --git a/CHANGELOG.md b/CHANGELOG.md index 315ae8e..cbc9253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Notable changes to Swarmail. Versions follow [semantic versioning](https://semver.org). +## Unreleased + +- A prompt no longer retries a pending registration whose edit checkout has been deleted. The server refuses a + worktree that is gone, so the hook warned on every prompt until the session ended. The pending entry is dropped; + the session's next edit registers it again. + ## 0.5.1 - 2026-10-09 - A session whose usual name is already held by another session in a project diff --git a/src/registry.ts b/src/registry.ts index 0cf6937..2fe085d 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -5,6 +5,7 @@ import { renameOver } from "./files.ts"; import { stateHome, t3Home } from "./paths.ts"; import { closeSync, + existsSync, mkdirSync, openSync, readdirSync, @@ -412,10 +413,14 @@ export function openRegistry( if (endedSince(state, since)) { return; } - pending = state.pending; - if (state.ended) { + // A retry for an edit checkout that has since been deleted can never succeed: the server refuses a + // worktree that is gone, and the hook would warn on every prompt. A later edit registers again. + const stale = state.pending?.worktree !== undefined && !existsSync(state.pending.worktree); + pending = stale ? undefined : state.pending; + if (stale || state.ended) { const { ended, ...rest } = state; - writeState(file, rest as S); + const { pending: dropped, ...withoutPending } = rest; + writeState(file, (stale ? withoutPending : rest) as S); } }); return pending; diff --git a/test/registry.test.js b/test/registry.test.js index d6d3c32..fddd5e8 100644 --- a/test/registry.test.js +++ b/test/registry.test.js @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { openRegistry } from "../src/registry.ts"; @@ -92,7 +92,11 @@ test("readers skip unreadable or foreign state and leftover temporary files", () }); test("a changed edit checkout refreshes registration and survives a failed retry", () => { - withRegistry((registry) => { + withRegistry((registry, dir) => { + const a = join(dir, "a"); + const b = join(dir, "b"); + mkdirSync(a); + mkdirSync(b); let up = true; const calls = []; const register = (project, name) => { @@ -107,24 +111,24 @@ test("a changed edit checkout refreshes registration and survives a failed retry worktree, register, }); - settle("/w/a"); - settle("/w/a"); + settle(a); + settle(a); expect(calls).toHaveLength(1); up = false; - expect(settle("/w/b").after.worktrees["/r"]).toBe("/w/a"); + expect(settle(b).after.worktrees["/r"]).toBe(a); expect(registry.resume("edit", 0)).toEqual({ project: "/r", tag: "[claude:edit cwd:/launch]", - worktree: "/w/b", + worktree: b, }); expect(settle(undefined).after.pending).toEqual({ project: "/r", tag: "[claude:edit cwd:/launch]", - worktree: "/w/b", + worktree: b, }); up = true; const pending = registry.resume("edit", 0); - expect(settle(pending.worktree).after.worktrees["/r"]).toBe("/w/b"); + expect(settle(pending.worktree).after.worktrees["/r"]).toBe(b); expect(registry.read("edit").pending).toBeUndefined(); expect(calls).toEqual([ ["/r", null], @@ -134,6 +138,26 @@ test("a changed edit checkout refreshes registration and survives a failed retry }); }); +test("a prompt drops a pending registration whose edit checkout was deleted", () => { + withRegistry((registry, dir) => { + const gone = join(dir, "gone"); + mkdirSync(gone); + const settle = (worktree) => + registry.settle("s1", { + since: 0, + project: "/r", + tag: "[t1]", + worktree, + register: () => null, + }); + expect(settle(gone).after.pending).toEqual({ project: "/r", tag: "[t1]", worktree: gone }); + expect(registry.resume("s1", 0)).toEqual({ project: "/r", tag: "[t1]", worktree: gone }); + rmSync(gone, { recursive: true }); + expect(registry.resume("s1", 0)).toBeUndefined(); + expect(registry.read("s1").pending).toBeUndefined(); + }); +}); + test("registration orderings preserve pending edit locations across supervisor calls", () => { withRegistry((registry, dir) => { const events = [