Skip to content

Commit 83d919f

Browse files
committed
fix(pi-herdr-rename): harden title synchronization
1 parent 187e33c commit 83d919f

3 files changed

Lines changed: 119 additions & 25 deletions

File tree

packages/pi-herdr-rename/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ Display titles are natural task phrases, preferably three or four words and alwa
3636

3737
In a linked worktree, a detached checkout or Herdr `worktree/...` branch is renamed; an existing non-generated branch stays. A generated workspace label such as `worktree-brave-meadow-4aa8` becomes the display title automatically; `/rename` also replaces a custom workspace name. Enclosing Herdr tab updates only when this pane is tab's only pane. Outside Herdr, only Pi session name changes.
3838

39-
Tries assigned profile primary, then fallback, while honoring configured thinking level. Never substitutes current session model. No viable route leaves titles unchanged. Resuming a session created by this version reapplies saved display title and semantic branch without another model request. Older titles receive no migration.
39+
Tries assigned profile primary, then fallback, while honoring configured thinking level. Never substitutes current session model. No viable route leaves titles unchanged. Resuming a session created by this version reapplies saved display title and semantic branch without another model request. Herdr and Git synchronization failures appear as warnings; cancellation by a newer rename remains silent. Older titles receive no migration.

packages/pi-herdr-rename/extensions/rename.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
type ExtensionAPI,
44
type ExtensionContext,
55
} from "@earendil-works/pi-coding-agent";
6-
import { createHerdrClient } from "@henryqw/pi-herdr";
6+
import { createHerdrClient, withWorktreeLock } from "@henryqw/pi-herdr";
77
import {
88
readTaskModelsConfig,
99
resolveConfiguredTaskRoutes,
@@ -42,15 +42,22 @@ function configuredRenameRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
4242
}
4343
}
4444

45+
function validSubject(subject: string): boolean {
46+
return /^[a-z0-9]+(?: [a-z0-9]+)*$/.test(subject)
47+
&& subject.length <= DISPLAY_MAX_CHARS
48+
&& subject.split(" ").length <= DISPLAY_MAX_WORDS;
49+
}
50+
51+
function isDisplayTitle(value: unknown): value is string {
52+
if (typeof value !== "string" || !value) return false;
53+
const subject = value[0].toLowerCase() + value.slice(1);
54+
return validSubject(subject) && value === subject[0].toUpperCase() + subject.slice(1);
55+
}
56+
4557
function parseGeneratedTitle(title: string): GeneratedTitle | undefined {
46-
const match = /^([a-z][a-z0-9-]*): ([a-z0-9]+(?: [a-z0-9]+)*)$/.exec(title);
47-
if (!match) return undefined;
58+
const match = /^([a-z][a-z0-9-]*): (.+)$/.exec(title);
59+
if (!match || match[1].length > SEMANTIC_TYPE_MAX_CHARS || !validSubject(match[2])) return undefined;
4860
const subject = match[2];
49-
if (
50-
match[1].length > SEMANTIC_TYPE_MAX_CHARS ||
51-
subject.length > DISPLAY_MAX_CHARS ||
52-
subject.split(" ").length > DISPLAY_MAX_WORDS
53-
) return undefined;
5461
return {
5562
display: subject[0].toUpperCase() + subject.slice(1),
5663
branch: `${match[1]}/${subject.replaceAll(" ", "-")}`,
@@ -63,7 +70,7 @@ function savedTitle(ctx: ExtensionContext): GeneratedTitle | undefined {
6370
.find((candidate) => candidate.type === "custom" && candidate.customType === TITLE_STATE_TYPE);
6471
if (entry?.type !== "custom" || !entry.data || typeof entry.data !== "object" || Array.isArray(entry.data)) return undefined;
6572
const { display, branch } = entry.data as { display?: unknown; branch?: unknown };
66-
return typeof display === "string" && typeof branch === "string" && SEMANTIC_BRANCH.test(branch)
73+
return isDisplayTitle(display) && typeof branch === "string" && SEMANTIC_BRANCH.test(branch)
6774
? { display, branch }
6875
: undefined;
6976
}
@@ -269,15 +276,18 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
269276
return result.stdout.trim();
270277
};
271278

272-
const branch = await runGit(["branch", "--show-current"]);
273-
if (!branch || branch.startsWith("worktree/")) {
279+
await withWorktreeLock(checkoutPath, async () => {
274280
if (!isCurrent(request, controller)) return;
275-
const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
276-
.split("\n")
277-
.filter(Boolean);
278-
const semanticBranch = availableBranch(branchCandidate, branches);
279-
await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
280-
}
281+
const branch = await runGit(["branch", "--show-current"]);
282+
if (!branch || branch.startsWith("worktree/")) {
283+
if (!isCurrent(request, controller)) return;
284+
const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
285+
.split("\n")
286+
.filter(Boolean);
287+
const semanticBranch = availableBranch(branchCandidate, branches);
288+
await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
289+
}
290+
});
281291
};
282292

283293
const begin = () => {
@@ -307,7 +317,7 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
307317
await applyHerdr(title.display, title.branch, previousDisplayTitle, manual, request, controller);
308318
return title.display;
309319
} catch (error) {
310-
if (isCurrent(request, controller) && (manual || error instanceof RenameModelError)) {
320+
if (isCurrent(request, controller)) {
311321
ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
312322
}
313323
return undefined;
@@ -346,7 +356,11 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
346356

347357
const { request, controller } = begin();
348358
void applyHerdr(title, saved.branch, saved.display, false, request, controller)
349-
.catch(() => undefined)
359+
.catch((error) => {
360+
if (isCurrent(request, controller)) {
361+
ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
362+
}
363+
})
350364
.finally(() => finish(request, controller));
351365
});
352366

packages/pi-herdr-rename/test/rename.test.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import test from "node:test";
66
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7+
import { withWorktreeLock } from "@henryqw/pi-herdr";
78
import herdrRenameExtension from "../extensions/rename.ts";
89

910
type Handler = (event: any, ctx: ExtensionContext) => unknown;
@@ -213,8 +214,10 @@ test("manual rename disarms a pending automatic rename", async () => {
213214
});
214215

215216
test("saved display titles keep semantic branches, replace generated branches, and preserve custom workspace names", async () => {
216-
await withAgentDir(async () => {
217+
await withAgentDir(async (dir) => {
217218
process.env.HERDR_PANE_ID = "pane-1";
219+
const checkoutPath = join(dir, "worktree");
220+
await mkdir(checkoutPath);
218221
for (const [paneCount, workspaceName, isLinkedWorktree, currentBranch, existingBranches, renameWorkspace, gitMutation] of [
219222
[1, "worktree-brave-meadow-4aa8", true, "fix/title-length", [], true, undefined],
220223
[2, "lucky-field-f694", true, "feat/new-loader", [], true, undefined],
@@ -239,7 +242,7 @@ test("saved display titles keep semantic branches, replace generated branches, a
239242
return success(JSON.stringify({ result: { tab: { pane_count: paneCount } } }));
240243
}
241244
if (args[0] === "workspace" && args[1] === "get") {
242-
return success(JSON.stringify({ result: { workspace: { label: workspaceName, worktree: { checkout_path: "/repo/worktree", is_linked_worktree: isLinkedWorktree } } } }));
245+
return success(JSON.stringify({ result: { workspace: { label: workspaceName, worktree: { checkout_path: checkoutPath, is_linked_worktree: isLinkedWorktree } } } }));
243246
}
244247
return success("{}");
245248
},
@@ -257,7 +260,7 @@ test("saved display titles keep semantic branches, replace generated branches, a
257260
app.execCalls.filter((args) => (args[0] === "branch" && args[1] === "-m") || args[0] === "switch"),
258261
gitMutation ? [gitMutation] : [],
259262
);
260-
assert.deepEqual(gitCwds, Array(isLinkedWorktree ? 1 + Number(Boolean(gitMutation)) * 2 : 0).fill("/repo/worktree"));
263+
assert.deepEqual(gitCwds, Array(isLinkedWorktree ? 1 + Number(Boolean(gitMutation)) * 2 : 0).fill(checkoutPath));
261264
assert.equal(app.execCalls.filter((args) => args[0] === "for-each-ref").length, Number(Boolean(gitMutation)));
262265
if (gitMutation) {
263266
assert.ok(app.execCalls.some((args) => args.join("\0") === "for-each-ref\0--format=%(refname:short)\0refs/heads"));
@@ -271,8 +274,10 @@ test("saved display titles keep semantic branches, replace generated branches, a
271274
});
272275

273276
test("manual rename updates generated and custom workspace titles", async () => {
274-
await withAgentDir(async () => {
277+
await withAgentDir(async (dir) => {
275278
process.env.HERDR_PANE_ID = "pane-1";
279+
const checkoutPath = join(dir, "worktree");
280+
await mkdir(checkoutPath);
276281
for (const workspaceName of ["Saved title", "Custom workspace"]) {
277282
const app = harness({
278283
sessionName: "Saved title",
@@ -290,7 +295,7 @@ test("manual rename updates generated and custom workspace titles", async () =>
290295
return success(JSON.stringify({ result: { tab: { pane_count: 1 } } }));
291296
}
292297
if (args[0] === "workspace" && args[1] === "get") {
293-
return success(JSON.stringify({ result: { workspace: { label: workspaceName, worktree: { checkout_path: "/repo/worktree", is_linked_worktree: true } } } }));
298+
return success(JSON.stringify({ result: { workspace: { label: workspaceName, worktree: { checkout_path: checkoutPath, is_linked_worktree: true } } } }));
294299
}
295300
return success("{}");
296301
},
@@ -307,6 +312,47 @@ test("manual rename updates generated and custom workspace titles", async () =>
307312
});
308313
});
309314

315+
test("semantic branch mutation honors the shared worktree lock", async () => {
316+
await withAgentDir(async (dir) => {
317+
process.env.HERDR_PANE_ID = "pane-1";
318+
const checkoutPath = join(dir, "worktree");
319+
await mkdir(checkoutPath);
320+
let releaseLock!: () => void;
321+
const heldLock = withWorktreeLock(checkoutPath, () => new Promise<void>((resolve) => {
322+
releaseLock = resolve;
323+
}));
324+
await eventually(() => Boolean(releaseLock));
325+
try {
326+
const app = harness({
327+
sessionName: "Saved title",
328+
branch: [{ type: "message", message: { role: "user", content: "rename this" } }],
329+
exec: async (args) => {
330+
if (args.join("\0") === "branch\0--show-current") return success("worktree/generated\n");
331+
if (args[0] === "for-each-ref") return success("");
332+
if (args[0] === "pane" && args[1] === "get") {
333+
return success(JSON.stringify({ result: { pane: { tab_id: "tab-1", workspace_id: "workspace-1" } } }));
334+
}
335+
if (args[0] === "tab" && args[1] === "get") {
336+
return success(JSON.stringify({ result: { tab: { pane_count: 1 } } }));
337+
}
338+
if (args[0] === "workspace" && args[1] === "get") {
339+
return success(JSON.stringify({ result: { workspace: { label: "worktree-rapid-meadow-04ae", worktree: { checkout_path: checkoutPath, is_linked_worktree: true } } } }));
340+
}
341+
return success("{}");
342+
},
343+
});
344+
await app.handlers.get("session_start")?.({}, app.ctx);
345+
await app.commands.get("rename")?.("", app.ctx);
346+
347+
assert.equal(app.execCalls.filter((args) => args[0] === "branch" || args[0] === "for-each-ref" || args[0] === "switch").length, 0);
348+
assert.match(app.notifications.at(-1) ?? "", /lock/i);
349+
} finally {
350+
releaseLock();
351+
await heldLock;
352+
}
353+
});
354+
});
355+
310356
test("existing and manually changed titles remain untouched", async () => {
311357
await withAgentDir(async () => {
312358
process.env.HERDR_PANE_ID = "pane-1";
@@ -320,6 +366,40 @@ test("existing and manually changed titles remain untouched", async () => {
320366
});
321367
await changed.handlers.get("session_start")?.({}, changed.ctx);
322368
assert.deepEqual(changed.execCalls, []);
369+
370+
const malformed = harness({
371+
sessionName: "One two three four five",
372+
branch: [{ type: "custom", customType: "pi-herdr-rename/title", data: { display: "One two three four five", branch: "fix/saved-title" } }],
373+
});
374+
await malformed.handlers.get("session_start")?.({}, malformed.ctx);
375+
await new Promise((resolve) => setTimeout(resolve, 0));
376+
assert.deepEqual(malformed.execCalls, []);
377+
});
378+
});
379+
380+
test("automatic and resumed Herdr failures warn", async () => {
381+
await withAgentDir(async () => {
382+
process.env.HERDR_PANE_ID = "pane-1";
383+
const exec = async (args: string[]) =>
384+
args[0] === "pane" && args[1] === "get"
385+
? { stdout: "", stderr: "gone", code: 7, killed: false }
386+
: success("{}");
387+
388+
const automatic = harness({ exec });
389+
await automatic.handlers.get("session_start")?.({}, automatic.ctx);
390+
automatic.handlers.get("input")?.({ source: "interactive", text: "prompt" }, automatic.ctx);
391+
await automatic.handlers.get("before_agent_start")?.({ prompt: "prompt" }, automatic.ctx);
392+
await eventually(() => automatic.notifications.length > 0);
393+
assert.match(automatic.notifications.at(-1) ?? "", /herdr pane get failed/);
394+
395+
const resumed = harness({
396+
sessionName: "Saved title",
397+
branch: [{ type: "custom", customType: "pi-herdr-rename/title", data: { display: "Saved title", branch: "fix/saved-title" } }],
398+
exec,
399+
});
400+
await resumed.handlers.get("session_start")?.({}, resumed.ctx);
401+
await eventually(() => resumed.notifications.length > 0);
402+
assert.match(resumed.notifications.at(-1) ?? "", /herdr pane get failed/);
323403
});
324404
});
325405

0 commit comments

Comments
 (0)