From 92c018ed42139066c4c9b3b20511526fa53642df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Wed, 14 Oct 2026 17:45:00 +0000 Subject: [PATCH 1/4] fix(herdr): retry the agent start while the pane is busy Herdr refuses `agent start` with agent_pane_busy until the new pane's shell reaches its prompt, which can still be starting when `worktree open` returns. Retry that refusal up to 5 times, 500 ms apart (2 s worst case); every other error is still one-shot. Refs #102 --- .changeset/herdr-pane-busy-retry.md | 5 ++ context/notes.md | 15 ++++++ src/integrations/herdr.test.ts | 75 +++++++++++++++++++++++++++++ src/integrations/herdr.ts | 64 ++++++++++++++++++------ 4 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 .changeset/herdr-pane-busy-retry.md create mode 100644 context/notes.md diff --git a/.changeset/herdr-pane-busy-retry.md b/.changeset/herdr-pane-busy-retry.md new file mode 100644 index 0000000..b25ade3 --- /dev/null +++ b/.changeset/herdr-pane-busy-retry.md @@ -0,0 +1,5 @@ +--- +"@northguild/worktree": patch +--- + +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. diff --git a/context/notes.md b/context/notes.md new file mode 100644 index 0000000..563cebf --- /dev/null +++ b/context/notes.md @@ -0,0 +1,15 @@ +# Notes + +Non-blocking gate observations for this branch. Read by nothing; `/feature-close` deletes this file. + +## From #102 Phase 2 (Gate 2, PASS WITH NOTES) + +- The plan put `.changeset/herdr-pane-busy-retry.md` in Phase 2, while `context/release.md` says the note is + once per feature and written by `/feature-close`. `/feature-close` should update this file, not add a + second one. Its name is descriptive, not random as `release.md` prefers. That is harmless here. +- `herdr.test.ts` "rejects with the last HerdrError" uses one identical fixture per attempt, so it cannot + tell the last refusal from the first. Distinct messages would make it assert what its name says. +- The 500 ms delay is proven to exist but its length is not pinned (e.g. `advanceTimersByTimeAsync(499)` + → 1 call, `(1)` → 2). +- `base-command.test.ts` was on Phase 2's Files line but needed no change. An optional seam test (busy, then + success, then the brief delivered) would add coverage. 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), + ); + } } /** From 2088762c7b347945289a4645138fe2b2ff6a0ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Wed, 14 Oct 2026 17:46:22 +0000 Subject: [PATCH 2/4] docs(herdr): say a busy agent pane is retried before it warns Refs #102 --- docs/src/app/docs/guides/herdr-spaces/page.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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: From 481ad1896ae8d2bcc0c93872c5089be6c78d4fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Wed, 14 Oct 2026 19:50:50 +0000 Subject: [PATCH 3/4] chore(context): retire #102 and drop its branch notes --- context/notes.md | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 context/notes.md diff --git a/context/notes.md b/context/notes.md deleted file mode 100644 index 563cebf..0000000 --- a/context/notes.md +++ /dev/null @@ -1,15 +0,0 @@ -# Notes - -Non-blocking gate observations for this branch. Read by nothing; `/feature-close` deletes this file. - -## From #102 Phase 2 (Gate 2, PASS WITH NOTES) - -- The plan put `.changeset/herdr-pane-busy-retry.md` in Phase 2, while `context/release.md` says the note is - once per feature and written by `/feature-close`. `/feature-close` should update this file, not add a - second one. Its name is descriptive, not random as `release.md` prefers. That is harmless here. -- `herdr.test.ts` "rejects with the last HerdrError" uses one identical fixture per attempt, so it cannot - tell the last refusal from the first. Distinct messages would make it assert what its name says. -- The 500 ms delay is proven to exist but its length is not pinned (e.g. `advanceTimersByTimeAsync(499)` - → 1 call, `(1)` → 2). -- `base-command.test.ts` was on Phase 2's Files line but needed no change. An optional seam test (busy, then - success, then the brief delivered) would add coverage. From d2003aa434e2f598de1d42bbc1604e15effffdbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Wed, 14 Oct 2026 19:53:49 +0000 Subject: [PATCH 4/4] chore(release): 2.1.2 --- .changeset/herdr-pane-busy-retry.md | 5 ----- CHANGELOG.md | 6 ++++++ docs/src/lib/site-meta.ts | 2 +- package.json | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/herdr-pane-busy-retry.md diff --git a/.changeset/herdr-pane-busy-retry.md b/.changeset/herdr-pane-busy-retry.md deleted file mode 100644 index b25ade3..0000000 --- a/.changeset/herdr-pane-busy-retry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@northguild/worktree": patch ---- - -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. 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/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",