Add Linear issue worktree workflow
This commit is contained in:
parent
bbd082947e
commit
bc5bf77963
9 changed files with 581 additions and 0 deletions
62
config/pi/extensions/worktree-agent/guard.ts
Normal file
62
config/pi/extensions/worktree-agent/guard.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
|
||||
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function canonicalizePotentialPath(inputPath: string, cwd: string): string {
|
||||
let normalized = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
|
||||
if (normalized === "~") normalized = os.homedir();
|
||||
else if (normalized.startsWith(`~${path.sep}`)) normalized = path.join(os.homedir(), normalized.slice(2));
|
||||
|
||||
const resolved = path.resolve(cwd, normalized);
|
||||
const missingSegments: string[] = [];
|
||||
let existing = resolved;
|
||||
|
||||
while (!fs.existsSync(existing)) {
|
||||
const parent = path.dirname(existing);
|
||||
if (parent === existing) break;
|
||||
missingSegments.unshift(path.basename(existing));
|
||||
existing = parent;
|
||||
}
|
||||
|
||||
const canonicalBase = fs.existsSync(existing) ? fs.realpathSync(existing) : existing;
|
||||
return path.join(canonicalBase, ...missingSegments);
|
||||
}
|
||||
|
||||
export function registerWorkerGuard(pi: ExtensionAPI, configuredRoot: string): void {
|
||||
const root = fs.realpathSync(configuredRoot);
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (!PATH_TOOLS.has(event.toolName)) return;
|
||||
|
||||
const input = event.input as { path?: unknown };
|
||||
if (input.path === undefined && (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls")) {
|
||||
return;
|
||||
}
|
||||
if (typeof input.path !== "string") {
|
||||
return { block: true, reason: `${event.toolName} requires a path inside the assigned worktree.` };
|
||||
}
|
||||
|
||||
const target = canonicalizePotentialPath(input.path, root);
|
||||
if (!isWithin(root, target)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Worker file tools are restricted to ${root}. Attempted path: ${input.path}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event) => ({
|
||||
systemPrompt:
|
||||
event.systemPrompt +
|
||||
`\n\nYou are running as a worktree worker. Your assigned repository root is ${root}. ` +
|
||||
"Keep all file mutations and shell work inside this worktree. Do not access or modify sibling worktrees.",
|
||||
}));
|
||||
}
|
||||
75
config/pi/extensions/worktree-agent/index.ts
Normal file
75
config/pi/extensions/worktree-agent/index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { registerWorkerGuard } from "./guard.ts";
|
||||
import { WorktreeAgentParameters, type ProgressReporter } from "./types.ts";
|
||||
import { runWorker } from "./worker.ts";
|
||||
import { isParentClean, prepareWorktree } from "./worktree.ts";
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
export default function worktreeAgentExtension(pi: ExtensionAPI) {
|
||||
const workerRoot = process.env.PI_WORKTREE_ROOT;
|
||||
if (process.env.PI_WORKTREE_AGENT === "1" && workerRoot) {
|
||||
registerWorkerGuard(pi, workerRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
pi.registerTool({
|
||||
name: "run_issue_in_worktree",
|
||||
label: "Run issue in worktree",
|
||||
description:
|
||||
"Create an isolated Git worktree for a retrieved Linear issue and run a child Pi agent there. " +
|
||||
"Call this only after retrieving and understanding the Linear issue. The call remains active until the child finishes.",
|
||||
promptSnippet: "Run a retrieved Linear issue in an isolated Git worktree using a child Pi agent",
|
||||
promptGuidelines: [
|
||||
"Use run_issue_in_worktree only after retrieving the Linear issue, then let the child agent perform all repository modifications.",
|
||||
"When run_issue_in_worktree is active, do not modify files in the parent checkout.",
|
||||
],
|
||||
parameters: WorktreeAgentParameters,
|
||||
|
||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
||||
const report: ProgressReporter = (message, details) => {
|
||||
onUpdate?.({ content: [{ type: "text", text: message }], details });
|
||||
};
|
||||
|
||||
const worktree = await prepareWorktree(pi, ctx.cwd, params, report);
|
||||
const workerReport = await runWorker({
|
||||
input: params,
|
||||
worktree,
|
||||
model: ctx.model,
|
||||
thinkingLevel: pi.getThinkingLevel(),
|
||||
signal,
|
||||
report,
|
||||
});
|
||||
|
||||
const parentClean = await isParentClean(pi, worktree.repoRoot);
|
||||
const resumeCommand =
|
||||
`cd ${shellQuote(worktree.worktreePath)} && ` +
|
||||
'PI_WORKTREE_AGENT=1 PI_WORKTREE_ROOT="$PWD" pi -c';
|
||||
const parentWarning = parentClean
|
||||
? ""
|
||||
: "\n\nWARNING: The parent working tree is no longer clean; inspect it before continuing.";
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Child Pi finished in ${worktree.worktreePath}.\n` +
|
||||
`Branch: ${worktree.branchName}\n` +
|
||||
`Resume: ${resumeCommand}\n\n${workerReport}${parentWarning}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
phase: "complete",
|
||||
branchName: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
parentClean,
|
||||
resumeCommand,
|
||||
workerReport,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
25
config/pi/extensions/worktree-agent/types.ts
Normal file
25
config/pi/extensions/worktree-agent/types.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Type, type Static } from "typebox";
|
||||
|
||||
export const WorktreeAgentParameters = Type.Object({
|
||||
issueId: Type.String({ description: "Linear issue identifier, for example ENG-123" }),
|
||||
title: Type.String({ description: "Linear issue title" }),
|
||||
description: Type.String({ description: "Linear issue description and relevant context" }),
|
||||
acceptanceCriteria: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Explicit or inferred acceptance criteria" }),
|
||||
),
|
||||
issueUrl: Type.Optional(Type.String({ description: "Linear issue URL" })),
|
||||
baseRef: Type.Optional(
|
||||
Type.String({ description: "Git ref from which to create the branch. Defaults to the current HEAD." }),
|
||||
),
|
||||
});
|
||||
|
||||
export type WorktreeAgentInput = Static<typeof WorktreeAgentParameters>;
|
||||
|
||||
export interface WorktreeContext {
|
||||
repoRoot: string;
|
||||
branchName: string;
|
||||
worktreePath: string;
|
||||
baseRef: string;
|
||||
}
|
||||
|
||||
export type ProgressReporter = (message: string, details: Record<string, unknown>) => void;
|
||||
175
config/pi/extensions/worktree-agent/worker.ts
Normal file
175
config/pi/extensions/worktree-agent/worker.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts";
|
||||
|
||||
const MAX_CAPTURED_STDERR = 50 * 1024;
|
||||
|
||||
interface WorkerModel {
|
||||
provider: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface RunWorkerOptions {
|
||||
input: WorktreeAgentInput;
|
||||
worktree: WorktreeContext;
|
||||
model?: WorkerModel;
|
||||
thinkingLevel: string;
|
||||
signal?: AbortSignal;
|
||||
report: ProgressReporter;
|
||||
}
|
||||
|
||||
function assistantText(message: unknown): string {
|
||||
if (!message || typeof message !== "object") return "";
|
||||
const candidate = message as {
|
||||
role?: string;
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
};
|
||||
if (candidate.role !== "assistant" || !Array.isArray(candidate.content)) return "";
|
||||
return candidate.content
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function appendCaptured(current: string, addition: string): string {
|
||||
const combined = current + addition;
|
||||
return combined.length <= MAX_CAPTURED_STDERR
|
||||
? combined
|
||||
: combined.slice(combined.length - MAX_CAPTURED_STDERR);
|
||||
}
|
||||
|
||||
function buildWorkerPrompt(input: WorktreeAgentInput, worktree: WorktreeContext): string {
|
||||
const criteria = input.acceptanceCriteria?.length
|
||||
? input.acceptanceCriteria.map((criterion) => `- ${criterion}`).join("\n")
|
||||
: "- Derive acceptance criteria from the issue description and state any uncertainty.";
|
||||
|
||||
return [
|
||||
"/skill:linear-issue-worker",
|
||||
"",
|
||||
`Issue ID: ${input.issueId.trim().toUpperCase()}`,
|
||||
`Title: ${input.title.trim()}`,
|
||||
input.issueUrl ? `URL: ${input.issueUrl}` : undefined,
|
||||
"",
|
||||
"Description:",
|
||||
input.description.trim() || "(No description supplied.)",
|
||||
"",
|
||||
"Acceptance criteria:",
|
||||
criteria,
|
||||
"",
|
||||
`Assigned branch: ${worktree.branchName}`,
|
||||
`Assigned worktree: ${worktree.worktreePath}`,
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export async function runWorker(options: RunWorkerOptions): Promise<string> {
|
||||
const { input, worktree, model, thinkingLevel, signal, report } = options;
|
||||
const sessionName = `${input.issueId.trim().toUpperCase()} worker`;
|
||||
const childArgs = ["--mode", "json", "-p", "--approve", "--name", sessionName];
|
||||
if (model) childArgs.push("--model", `${model.provider}/${model.id}`);
|
||||
childArgs.push("--thinking", thinkingLevel, buildWorkerPrompt(input, worktree));
|
||||
|
||||
report(`Starting child Pi in ${worktree.worktreePath}`, {
|
||||
phase: "starting-worker",
|
||||
branchName: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
});
|
||||
|
||||
let finalText = "";
|
||||
let stderr = "";
|
||||
let workerStopReason: string | undefined;
|
||||
let workerError: string | undefined;
|
||||
|
||||
let exitCode: number;
|
||||
try {
|
||||
exitCode = await new Promise<number>((resolve, reject) => {
|
||||
const child = spawn("pi", childArgs, {
|
||||
cwd: worktree.worktreePath,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_WORKTREE_AGENT: "1",
|
||||
PI_WORKTREE_ROOT: worktree.worktreePath,
|
||||
},
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let completed = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const finish = (callback: () => void) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
signal?.removeEventListener("abort", abortChild);
|
||||
callback();
|
||||
};
|
||||
|
||||
const abortChild = () => {
|
||||
child.kill("SIGTERM");
|
||||
killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
||||
};
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "message_end") {
|
||||
const text = assistantText(event.message);
|
||||
if (text) finalText = text;
|
||||
if (event.message?.role === "assistant") {
|
||||
workerStopReason = event.message.stopReason;
|
||||
workerError = event.message.errorMessage;
|
||||
}
|
||||
} else if (event.type === "tool_execution_start") {
|
||||
report(`Worker running: ${event.toolName}`, {
|
||||
phase: "worker-running",
|
||||
branchName: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
toolName: event.toolName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
stdoutBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr = appendCaptured(stderr, chunk.toString());
|
||||
});
|
||||
child.on("error", (error) => finish(() => reject(error)));
|
||||
child.on("close", (code) =>
|
||||
finish(() => {
|
||||
if (stdoutBuffer.trim()) processLine(stdoutBuffer);
|
||||
resolve(code ?? 1);
|
||||
}),
|
||||
);
|
||||
|
||||
if (signal?.aborted) abortChild();
|
||||
else signal?.addEventListener("abort", abortChild, { once: true });
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to run child Pi: ${detail}\nWorktree retained at ${worktree.worktreePath}`);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error(`Worker aborted. Worktree retained at ${worktree.worktreePath}`);
|
||||
}
|
||||
if (exitCode !== 0 || workerStopReason === "error" || workerStopReason === "aborted") {
|
||||
const detail = workerError || stderr.trim() || finalText || `exit code ${exitCode}`;
|
||||
throw new Error(`Worker failed: ${detail}\nWorktree retained at ${worktree.worktreePath}`);
|
||||
}
|
||||
|
||||
return finalText || "Worker exited successfully without a final text report.";
|
||||
}
|
||||
93
config/pi/extensions/worktree-agent/worktree.ts
Normal file
93
config/pi/extensions/worktree-agent/worktree.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts";
|
||||
|
||||
type ExecResult = { stdout: string; stderr: string; code: number | null };
|
||||
|
||||
async function git(
|
||||
pi: ExtensionAPI,
|
||||
cwd: string,
|
||||
args: string[],
|
||||
options: { allowFailure?: boolean; timeout?: number } = {},
|
||||
): Promise<ExecResult> {
|
||||
const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: options.timeout ?? 30_000 });
|
||||
if (!options.allowFailure && result.code !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim();
|
||||
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function slugify(title: string): string {
|
||||
const slug = title
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/g, "");
|
||||
|
||||
return slug || "work";
|
||||
}
|
||||
|
||||
export async function prepareWorktree(
|
||||
pi: ExtensionAPI,
|
||||
cwd: string,
|
||||
input: WorktreeAgentInput,
|
||||
report: ProgressReporter,
|
||||
): Promise<WorktreeContext> {
|
||||
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(input.issueId.trim())) {
|
||||
throw new Error(`Invalid Linear issue identifier: ${input.issueId}`);
|
||||
}
|
||||
|
||||
const repoResult = await git(pi, cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const repoRoot = fs.realpathSync(repoResult.stdout.trim());
|
||||
const statusBefore = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
||||
if (statusBefore.stdout.trim()) {
|
||||
throw new Error("The parent working tree is not clean. Commit, stash, or remove local changes first.");
|
||||
}
|
||||
|
||||
const issueId = input.issueId.trim().toLowerCase();
|
||||
const branchName = `issue/${issueId}-${slugify(input.title)}`;
|
||||
const worktreePath = path.resolve(repoRoot, "..", "worktrees", branchName);
|
||||
const baseRef = input.baseRef?.trim() || "HEAD";
|
||||
|
||||
const branchExists = await git(
|
||||
pi,
|
||||
repoRoot,
|
||||
["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
|
||||
{ allowFailure: true },
|
||||
);
|
||||
if (branchExists.code === 0) {
|
||||
throw new Error(`Branch already exists: ${branchName}`);
|
||||
}
|
||||
if (fs.existsSync(worktreePath)) {
|
||||
throw new Error(`Worktree path already exists: ${worktreePath}`);
|
||||
}
|
||||
|
||||
const baseExists = await git(pi, repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`], {
|
||||
allowFailure: true,
|
||||
});
|
||||
if (baseExists.code !== 0) {
|
||||
throw new Error(`Base ref does not resolve to a commit: ${baseRef}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||
report(`Creating ${branchName} at ${worktreePath}`, {
|
||||
phase: "creating-worktree",
|
||||
branchName,
|
||||
worktreePath,
|
||||
});
|
||||
await git(pi, repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseRef], {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
return { repoRoot, branchName, worktreePath, baseRef };
|
||||
}
|
||||
|
||||
export async function isParentClean(pi: ExtensionAPI, repoRoot: string): Promise<boolean> {
|
||||
const status = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
||||
return !status.stdout.trim();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue