diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd6f88..944da28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @northguild/worktree +## 2.1.2 + +### Patch Changes + +- 92c018e: Opening a worktree with a Herdr agent now retries the agent start for a couple of seconds when the pane's shell is not ready yet, before warning `agent_pane_busy` and leaving the agent unstarted. + ## 2.1.1 ### Patch Changes diff --git a/docs/src/app/docs/guides/herdr-spaces/page.mdx b/docs/src/app/docs/guides/herdr-spaces/page.mdx index fb57ff9..8bebd8d 100644 --- a/docs/src/app/docs/guides/herdr-spaces/page.mdx +++ b/docs/src/app/docs/guides/herdr-spaces/page.mdx @@ -78,8 +78,11 @@ The agent starts only for a space this command just opened. Re-opening a worktre second agent, because the one you left running is still in that pane. Herdr's name for the agent is derived from the branch and coerced to what Herdr accepts, which includes -a 32-character cap. If the start fails — most often a name already taken by a live agent from another -repository — you get a warning, and the space is still open and correct. +a 32-character cap. A new pane's shell can still be starting when the space opens, and Herdr refuses an +agent until the shell reaches its prompt (`agent_pane_busy`). That refusal is retried for up to about two +seconds before it counts as a failure. If the start fails — most often a name already taken by a live +agent from another repository, or a pane still busy after those retries — you get a warning, and the +space is still open and correct. This is **the** agent for the worktree: `worktree branch --agent ""` does not start a second one beside it. Whenever Herdr starts an agent: diff --git a/docs/src/lib/site-meta.ts b/docs/src/lib/site-meta.ts index f6d0164..46ce48f 100644 --- a/docs/src/lib/site-meta.ts +++ b/docs/src/lib/site-meta.ts @@ -1,7 +1,7 @@ // Generated file. Do not edit directly. import type { Metadata } from "next"; -export const cliVersion = "2.1.1"; +export const cliVersion = "2.1.2"; export const projectName = "Worktree"; export const projectDescription = "A CLI tool for managing git worktrees with enhanced workflow features"; diff --git a/package.json b/package.json index 88c630b..43e07e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@northguild/worktree", - "version": "2.1.1", + "version": "2.1.2", "description": "A CLI tool for managing git worktrees with enhanced workflow features", "main": "./bin/run.js", "type": "module", diff --git a/src/integrations/herdr.test.ts b/src/integrations/herdr.test.ts index 7cae6ca..3524f04 100644 --- a/src/integrations/herdr.test.ts +++ b/src/integrations/herdr.test.ts @@ -444,6 +444,81 @@ describe("startHerdrAgent", () => { expect(error).toBeInstanceOf(HerdrError); expect(error).toMatchObject({ code: "agent_name_in_use" }); }); + + describe("when the pane is busy", () => { + const busy = { + stdout: "", + stderr: makeErrorEnvelope( + "agent_pane_busy", + "agent target pane w61:p1 is not an available shell", + ), + exitCode: 1, + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries and resolves once the pane accepts the start", async () => { + mockRunCapturing + .mockResolvedValueOnce(busy) + .mockResolvedValueOnce(busy) + .mockResolvedValueOnce({ + stdout: makeAgentStartedEnvelope(), + stderr: "", + exitCode: 0, + }); + + const started = startAgent(); + await vi.runAllTimersAsync(); + + await expect(started).resolves.toBeUndefined(); + expect(mockRunCapturing).toHaveBeenCalledTimes(3); + }); + + it("rejects with the last HerdrError after exactly the attempt count", async () => { + mockRunCapturing.mockResolvedValue(busy); + + const started = startAgent().catch((thrown: unknown) => thrown); + await vi.runAllTimersAsync(); + const error = await started; + + expect(error).toBeInstanceOf(HerdrError); + expect(error).toMatchObject({ code: "agent_pane_busy" }); + expect(mockRunCapturing).toHaveBeenCalledTimes(5); + }); + + it("waits between attempts rather than retrying at once", async () => { + mockRunCapturing.mockResolvedValue(busy); + + const started = startAgent().catch((thrown: unknown) => thrown); + await vi.advanceTimersByTimeAsync(0); + + expect(mockRunCapturing).toHaveBeenCalledTimes(1); + + await vi.runAllTimersAsync(); + await started; + }); + + it("does not retry any other error code", async () => { + mockRunCapturing.mockResolvedValue({ + stdout: "", + stderr: makeErrorEnvelope("agent_name_in_use", "already running"), + exitCode: 1, + }); + + const started = startAgent().catch((thrown: unknown) => thrown); + await vi.runAllTimersAsync(); + const error = await started; + + expect(error).toMatchObject({ code: "agent_name_in_use" }); + expect(mockRunCapturing).toHaveBeenCalledTimes(1); + }); + }); }); /** diff --git a/src/integrations/herdr.ts b/src/integrations/herdr.ts index 73ccfc4..aedcf8d 100644 --- a/src/integrations/herdr.ts +++ b/src/integrations/herdr.ts @@ -19,6 +19,22 @@ const AGENT_NAME_MAX_LENGTH = 32; */ const AGENT_START_TIMEOUT_MS = 15_000; +/** + * How many times `agent start` is tried while Herdr answers `agent_pane_busy`, + * and how long to wait between tries. The worst case is + * (AGENT_START_ATTEMPTS - 1) * AGENT_START_RETRY_DELAY_MS = 2 s of waiting, + * plus the refusals themselves, which take about 40 ms each. + * + * The code means the pane's shell is not at a prompt yet. Observed on 0.9.0 + * (#102): straight after the open the refusal arrives before anything is + * launched, so a retry cannot collide on the agent name, and the pane accepts a + * start within about 0.3 s. Half a second apart leaves room for a slow rc file + * or prompt theme. The call is awaited, so the worst case is kept small; a + * pane that never settles ends in the same warning as before. + */ +const AGENT_START_ATTEMPTS = 5; +const AGENT_START_RETRY_DELAY_MS = 500; + /** * How long this process waits for `herdr` to answer one request before killing * it and reporting the failure. @@ -454,6 +470,9 @@ export function toHerdrAgentName(branchName: string): string { * otherwise — the caller decides how loud that is, and the seam treats it as a * warning because the space is already open by then (§5). * + * An `agent_pane_busy` refusal is retried, up to AGENT_START_ATTEMPTS tries in + * all; any other error, and the last attempt's, rejects unchanged. + * * Nothing is read from the response: `agent_started` answers with the pane's * `AgentInfo` and the `argv` it ran, and this feature needs neither. */ @@ -463,21 +482,36 @@ export async function startHerdrAgent({ paneId, args = [], }: HerdrAgentStartOptions): Promise { - await runHerdrRequest( - [ - "agent", - "start", - name, - "--kind", - kind, - "--pane", - paneId, - "--timeout", - String(AGENT_START_TIMEOUT_MS), - ...(args.length > 0 ? ["--", ...args] : []), - ], - AGENT_START_REQUEST_TIMEOUT_MS, - ); + const startArgs = [ + "agent", + "start", + name, + "--kind", + kind, + "--pane", + paneId, + "--timeout", + String(AGENT_START_TIMEOUT_MS), + ...(args.length > 0 ? ["--", ...args] : []), + ]; + + for (let attempt = 1; ; attempt++) { + try { + await runHerdrRequest(startArgs, AGENT_START_REQUEST_TIMEOUT_MS); + return; + } catch (error) { + const paneBusy = + error instanceof HerdrError && error.code === "agent_pane_busy"; + + if (!paneBusy || attempt >= AGENT_START_ATTEMPTS) { + throw error; + } + } + + await new Promise((resolve) => + setTimeout(resolve, AGENT_START_RETRY_DELAY_MS), + ); + } } /**