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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/src/app/docs/guides/herdr-spaces/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<prompt>"` does not start a second one
beside it. Whenever Herdr starts an agent:
Expand Down
2 changes: 1 addition & 1 deletion docs/src/lib/site-meta.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
75 changes: 75 additions & 0 deletions src/integrations/herdr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});

/**
Expand Down
64 changes: 49 additions & 15 deletions src/integrations/herdr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -463,21 +482,36 @@ export async function startHerdrAgent({
paneId,
args = [],
}: HerdrAgentStartOptions): Promise<void> {
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),
);
}
}

/**
Expand Down
Loading