Avoid accepted-plan workspace branch freeze

Keep accepted-plan child issues on unresolved execution workspace strategy until their own first realization, and cover base-ref movement through heartbeat execution.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-07-08 16:38:11 +00:00
1 parent e8f2db3a2c
commit 4ef3419db2
2 files changed
+368 -8

No files matched your search

@@ -23,6 +23,7 @@ import {
issueComments,
issueDocuments,
issuePlanDecompositions,
issueThreadInteractions,
issues,
projects,
projectWorkspaces,
@@ -34,6 +35,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { issueService } from "../services/issues.ts";
const execFileAsync = promisify(execFile);
@@ -72,17 +74,33 @@ if (!embeddedPostgresSupport.supported) {
);
}
async function runGit(cwd: string, args: string[]) {
const result = await execFileAsync("git", args, { cwd });
return result.stdout.trim();
}
async function createGitRepo() {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-repo-"));
await execFileAsync("git", ["init"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.email", "paperclip-test@example.com"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.name", "Paperclip Test"], { cwd: repoRoot });
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["checkout", "-B", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip-test@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await writeFile(path.join(repoRoot, "README.md"), "accepted plan workspace refresh\n");
await execFileAsync("git", ["add", "README.md"], { cwd: repoRoot });
await execFileAsync("git", ["commit", "-m", "initial"], { cwd: repoRoot });
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "initial"]);
return repoRoot;
}
async function createGitRepoWithOrigin() {
const repoRoot = await createGitRepo();
const originRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-origin-"));
await runGit(originRoot, ["init", "--bare"]);
await runGit(repoRoot, ["remote", "add", "origin", originRoot]);
await runGit(repoRoot, ["push", "-u", "origin", "master"]);
await runGit(repoRoot, ["fetch", "origin", "master"]);
return { repoRoot, originRoot };
}
describeEmbeddedPostgres("accepted plan workspace refresh", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@@ -114,6 +132,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
await db.delete(issuePlanDecompositions);
await db.delete(issueThreadInteractions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
@@ -198,6 +217,73 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
});
}
async function seedAcceptedPlanAcceptance(args: {
companyId: string;
issueId: string;
ownerAgentId: string;
}) {
const documentId = randomUUID();
const revisionId = randomUUID();
const interactionId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId: args.companyId,
title: "Plan",
format: "markdown",
latestBody: "Plan body",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: args.ownerAgentId,
updatedByAgentId: args.ownerAgentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId: args.companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "Plan body",
createdByAgentId: args.ownerAgentId,
});
await db.insert(issueDocuments).values({
companyId: args.companyId,
issueId: args.issueId,
documentId,
key: "plan",
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId: args.companyId,
issueId: args.issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
issueId: args.issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
result: {
version: 1,
outcome: "accepted",
},
resolvedAt: new Date(),
createdByUserId: "local-board",
resolvedByUserId: "local-board",
});
return revisionId;
}
it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@@ -373,6 +459,232 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
expect(isolatedRows[0]?.cwd).not.toBe(repoRoot);
}, 20_000);
it("keeps accepted-plan children strategy-only until first realization after the base ref moves", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const sourceIssueId = randomUUID();
const agentId = randomUUID();
const { repoRoot, originRoot } = await createGitRepoWithOrigin();
tempRoots.push(repoRoot, originRoot);
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: true,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Accepted Plan Branch Freshness",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Planning source issue",
status: "in_progress",
workMode: "planning",
priority: "medium",
responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-1584",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
createdAt: new Date(),
updatedAt: new Date(),
});
const heartbeat = heartbeatService(db);
adapterExecute.mockImplementationOnce(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "planning-source-session" },
sessionDisplayId: "planning-source-session",
summary: "Realized the planning source workspace.",
provider: "test",
model: "test-model",
}));
const sourceRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
contextSnapshot: {
issueId: sourceIssueId,
taskId: sourceIssueId,
wakeReason: "issue_commented",
skipIssueComment: true,
},
});
expect(sourceRun).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(sourceRun!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
const sourceWorkspace = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.sourceIssueId, sourceIssueId))
.then((rows) => rows[0] ?? null);
expect(sourceWorkspace).toMatchObject({
mode: "isolated_workspace",
strategyType: "git_worktree",
baseRef: "origin/master",
});
expect(sourceWorkspace?.branchName).toBeTruthy();
await writeFile(path.join(repoRoot, "base-moved.txt"), "base moved after planning\n");
await runGit(repoRoot, ["add", "base-moved.txt"]);
await runGit(repoRoot, ["commit", "-m", "Move base after planning"]);
const movedBaseSha = await runGit(repoRoot, ["rev-parse", "HEAD"]);
await runGit(repoRoot, ["push", "origin", "HEAD:master"]);
await runGit(repoRoot, ["fetch", "origin", "master"]);
const acceptedPlanRevisionId = await seedAcceptedPlanAcceptance({
companyId,
issueId: sourceIssueId,
ownerAgentId: agentId,
});
const decomposition = await issueService(db).decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children: [
{
title: "Implement approved child after base move",
status: "todo",
workMode: "standard",
priority: "medium",
assigneeAgentId: agentId,
},
],
actorAgentId: agentId,
});
const childIssueId = decomposition.childIssueIds[0];
expect(childIssueId).toBeTruthy();
const childBeforeRun = await db
.select({
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, childIssueId!))
.then((rows) => rows[0] ?? null);
expect(childBeforeRun?.executionWorkspaceId).toBeNull();
expect(childBeforeRun?.executionWorkspacePreference).toBeNull();
expect(childBeforeRun?.executionWorkspaceSettings).toMatchObject({
mode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
});
let childRunWorkspace:
| { cwd: string; branchName: string; executionWorkspaceId: string }
| null = null;
adapterExecute.mockImplementationOnce(async (input) => {
const context = (input as { context?: Record<string, unknown> }).context ?? {};
const workspace = context.paperclipWorkspace as Record<string, unknown> | undefined;
const cwd = typeof workspace?.cwd === "string" ? workspace.cwd : null;
const branchName = typeof workspace?.branchName === "string" ? workspace.branchName : null;
const executionWorkspaceId =
typeof context.executionWorkspaceId === "string" ? context.executionWorkspaceId : null;
if (!cwd || !branchName || !executionWorkspaceId) {
throw new Error("Accepted-plan child run did not receive a realized workspace");
}
childRunWorkspace = { cwd, branchName, executionWorkspaceId };
expect(branchName).not.toBe(sourceWorkspace?.branchName);
await expect(runGit(cwd, ["rev-parse", "HEAD"])).resolves.toBe(movedBaseSha);
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, childIssueId!));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "child-session" },
sessionDisplayId: "child-session",
summary: "Child realized from the moved base.",
provider: "test",
model: "test-model",
};
});
const childRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_assigned",
contextSnapshot: {
issueId: childIssueId,
taskId: childIssueId,
wakeReason: "issue_assigned",
skipIssueComment: true,
},
});
expect(childRun).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(childRun!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(childRunWorkspace).not.toBeNull();
expect(childRunWorkspace?.executionWorkspaceId).not.toBe(sourceWorkspace?.id);
const childAfterRun = await db
.select({ executionWorkspaceId: issues.executionWorkspaceId })
.from(issues)
.where(eq(issues.id, childIssueId!))
.then((rows) => rows[0] ?? null);
expect(childAfterRun?.executionWorkspaceId).toBe(childRunWorkspace?.executionWorkspaceId);
}, 20_000);
it("forces a fresh session and suppresses accepted-plan continuation when another issue owns the in-flight claim", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
+51 -3
View File
@@ -238,6 +238,28 @@ function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
};
}
// Accepted-plan children are not realized yet, so carry only unresolved
// workspace intent and let the first child run render/persist its own branch.
function buildPreRealizationExecutionWorkspaceSettings(raw: unknown): Record<string, unknown> | null {
const settings = parseIssueExecutionWorkspaceSettings(raw);
if (!settings) return null;
const mode =
settings.mode && settings.mode !== "inherit" && settings.mode !== "reuse_existing"
? settings.mode
: null;
const next: Record<string, unknown> = {};
if (mode) next.mode = mode;
if (settings.workspaceStrategy) {
next.workspaceStrategy = {
type: settings.workspaceStrategy.type,
...(settings.workspaceStrategy.baseRef ? { baseRef: settings.workspaceStrategy.baseRef } : {}),
...(settings.workspaceStrategy.branchTemplate ? { branchTemplate: settings.workspaceStrategy.branchTemplate } : {}),
...(settings.workspaceStrategy.worktreeParentDir ? { worktreeParentDir: settings.workspaceStrategy.worktreeParentDir } : {}),
};
}
return Object.keys(next).length > 0 ? next : null;
}
function toTimestampMs(value: Date | string | null | undefined) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
@@ -494,6 +516,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
labelIds?: string[];
blockedByIssueIds?: string[];
inheritExecutionWorkspaceFromIssueId?: string | null;
skipExecutionWorkspaceInheritance?: boolean;
watchdog?: { agentId: string; instructions?: string | null } | null;
watchdogActorRunId?: string | null;
actorRunId?: string | null;
@@ -503,6 +526,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
type IssueChildCreateInput = IssueCreateInput & {
acceptanceCriteria?: string[];
blockParentUntilDone?: boolean;
executionWorkspaceInheritanceMode?: "linkage" | "strategy_only";
actorAgentId?: string | null;
actorUserId?: string | null;
};
@@ -693,6 +717,8 @@ const ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS = new Set([
"updatedByUserId",
"actorAgentId",
"actorUserId",
"executionWorkspaceInheritanceMode",
"skipExecutionWorkspaceInheritance",
]);
function normalizeAcceptedPlanDecompositionFingerprintChild(child: IssueChildCreateInput) {
@@ -5558,14 +5584,25 @@ export function issueService(db: Db) {
const {
acceptanceCriteria,
blockParentUntilDone,
executionWorkspaceInheritanceMode = "linkage",
actorAgentId,
actorUserId,
...issueData
} = data;
const inheritStrategyOnly = executionWorkspaceInheritanceMode === "strategy_only";
const hasExplicitExecutionWorkspaceOverride =
issueData.executionWorkspaceId !== undefined ||
issueData.executionWorkspacePreference !== undefined ||
issueData.executionWorkspaceSettings !== undefined;
const inheritedPreRealizationWorkspaceSettings =
inheritStrategyOnly && !hasExplicitExecutionWorkspaceOverride
? buildPreRealizationExecutionWorkspaceSettings(parent.executionWorkspaceSettings)
: null;
let child = await issueService(db).create(parent.companyId, {
...issueData,
parentId: parent.id,
projectId: issueData.projectId ?? parent.projectId,
projectWorkspaceId: issueData.projectWorkspaceId ?? (inheritStrategyOnly ? parent.projectWorkspaceId : undefined),
goalId: issueData.goalId ?? parent.goalId,
actorResponsibleUserId: issueData.actorResponsibleUserId ?? null,
trustExplicitResponsibleUserId: issueData.trustExplicitResponsibleUserId === true,
@@ -5573,7 +5610,12 @@ export function issueService(db: Db) {
Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0),
),
description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria),
inheritExecutionWorkspaceFromIssueId: parent.id,
...(inheritedPreRealizationWorkspaceSettings
? { executionWorkspaceSettings: inheritedPreRealizationWorkspaceSettings }
: {}),
...(inheritStrategyOnly
? { skipExecutionWorkspaceInheritance: true }
: { inheritExecutionWorkspaceFromIssueId: parent.id }),
});
if (blockParentUntilDone) {
@@ -5748,7 +5790,10 @@ export function issueService(db: Db) {
throw new Error("Accepted-plan decomposition child cursor moved past the requested children");
}
const createdChild = await issueService(tx as unknown as Db).createChild(sourceIssue.id, nextChildInput);
const createdChild = await issueService(tx as unknown as Db).createChild(sourceIssue.id, {
...nextChildInput,
executionWorkspaceInheritanceMode: "strategy_only",
});
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
const now = new Date();
const nextStatus = nextIds.length === data.children.length ? "completed" : "in_flight";
@@ -5876,6 +5921,7 @@ export function issueService(db: Db) {
labelIds: inputLabelIds,
blockedByIssueIds,
inheritExecutionWorkspaceFromIssueId,
skipExecutionWorkspaceInheritance,
watchdog,
watchdogActorRunId,
actorRunId,
@@ -5908,7 +5954,9 @@ export function issueService(db: Db) {
let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null;
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null;
const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance
? null
: inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null;
const hasExplicitExecutionWorkspaceOverride =
issueData.executionWorkspaceId !== undefined ||
issueData.executionWorkspacePreference !== undefined ||