fix worktree archive terminal cleanup and worktree setup

This commit is contained in:
Mohamed Boudra
2026-02-16 11:55:16 +07:00
parent 04a06197de
commit 90a81462cb
5 changed files with 178 additions and 22 deletions
@@ -665,5 +665,98 @@ describe("daemon E2E", () => {
);
});
describe("archivePaseoWorktree", () => {
test(
"archives worktree by running destroy commands and shutting down worktree terminals",
async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt");
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
destroy: [
`echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`,
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", {
cwd: repoRoot,
stdio: "pipe",
});
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Worktree Archive Cleanup Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "archive-cleanup-test",
worktreeSlug: "archive-cleanup-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
timeoutMs: 15000,
label: "worktree terminal marker",
});
const beforeArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(beforeArchiveDirectories).toContain(agent.cwd);
const archive = await ctx.client.archivePaseoWorktree({
worktreePath: agent.cwd,
});
expect(archive.error).toBeNull();
expect(archive.success).toBe(true);
expect(archive.removedAgents).toContain(agent.id);
expect(existsSync(agent.cwd)).toBe(false);
expect(existsSync(destroyMarkerPath)).toBe(true);
expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd);
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(afterArchiveDirectories).not.toContain(agent.cwd);
rmSync(repoRoot, { recursive: true, force: true });
},
60000
);
});
});
+58 -18
View File
@@ -4484,11 +4484,13 @@ export class Session {
}
}
await deletePaseoWorktree({
cwd: repoRoot,
worktreePath: targetPath,
paseoHome: this.paseoHome,
});
await this.killTerminalsUnderPath(targetPath);
await deletePaseoWorktree({
cwd: repoRoot,
worktreePath: targetPath,
paseoHome: this.paseoHome,
});
for (const agentId of removedAgents) {
this.emit({
@@ -6428,6 +6430,56 @@ export class Session {
session.send(msg.message);
}
private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void {
const unsubscribe = this.terminalSubscriptions.get(terminalId);
if (unsubscribe) {
unsubscribe();
this.terminalSubscriptions.delete(terminalId);
}
const streamId = this.terminalStreamByTerminalId.get(terminalId);
if (typeof streamId === "number") {
this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true });
}
this.terminalManager?.killTerminal(terminalId);
}
private async killTerminalsUnderPath(rootPath: string): Promise<void> {
if (!this.terminalManager) {
return;
}
const cleanupErrors: Array<{ cwd: string; message: string }> = [];
const terminalDirectories = [...this.terminalManager.listDirectories()];
for (const terminalCwd of terminalDirectories) {
if (!this.isPathWithinRoot(rootPath, terminalCwd)) {
continue;
}
try {
const terminals = await this.terminalManager.getTerminals(terminalCwd);
for (const terminal of [...terminals]) {
this.killTrackedTerminal(terminal.id, { emitExit: true });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
cleanupErrors.push({ cwd: terminalCwd, message });
this.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to clean up worktree terminals during archive"
);
}
}
if (cleanupErrors.length > 0) {
const details = cleanupErrors
.map((entry) => `${entry.cwd}: ${entry.message}`)
.join("; ");
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
}
}
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
@@ -6441,19 +6493,7 @@ export class Session {
return;
}
// Unsubscribe first
const unsubscribe = this.terminalSubscriptions.get(msg.terminalId);
if (unsubscribe) {
unsubscribe();
this.terminalSubscriptions.delete(msg.terminalId);
}
const streamId = this.terminalStreamByTerminalId.get(msg.terminalId);
if (typeof streamId === "number") {
this.detachTerminalStream(streamId, { emitExit: true });
}
this.terminalManager.killTerminal(msg.terminalId);
this.killTrackedTerminal(msg.terminalId, { emitExit: true });
this.emit({
type: "kill_terminal_response",
payload: {
@@ -93,6 +93,20 @@ describe("createWorktree", () => {
rmSync(varTempDir, { recursive: true, force: true });
});
it("reports repoRoot as the repository root for paseo-owned worktrees", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "repo-root-check",
paseoHome,
});
const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(ownership.repoRoot).toBe(repoDir);
});
it("creates a worktree with a new branch", async () => {
const result = await createWorktree({
branchName: "feature-branch",
+10 -2
View File
@@ -667,11 +667,19 @@ function normalizePathForOwnership(input: string): string {
}
}
function resolveRepoRootFromGitCommonDir(commonDir: string): string {
const normalizedCommonDir = normalizePathForOwnership(commonDir);
return basename(normalizedCommonDir) === ".git"
? dirname(normalizedCommonDir)
: normalizedCommonDir;
}
export async function isPaseoOwnedWorktreeCwd(
cwd: string,
options?: { paseoHome?: string }
): Promise<PaseoWorktreeOwnership> {
const gitCommonDir = await getGitCommonDir(cwd);
const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
const resolvedCwd = normalizePathForOwnership(cwd);
@@ -679,7 +687,7 @@ export async function isPaseoOwnedWorktreeCwd(
if (!resolvedCwd.startsWith(resolvedRoot)) {
return {
allowed: false,
repoRoot: gitCommonDir,
repoRoot,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
@@ -692,7 +700,7 @@ export async function isPaseoOwnedWorktreeCwd(
});
return {
allowed,
repoRoot: gitCommonDir,
repoRoot,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
+3 -2
View File
@@ -2,12 +2,13 @@
"worktree": {
"setup": [
"npm ci",
"npm run build --workspace=@getpaseo/relay",
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
],
"terminals": [
{
"name": "anti-andran dev",
"command": "npm run dev"
"name": "dev",
"command": "SESSION_NAME=\"${PWD##*/}\"; if ! tmux has-session -t \"$SESSION_NAME\" 2>/dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\"; tmux send-keys -t \"$SESSION_NAME\":0.0 \"npm run dev\" Enter; fi"
}
]
}