fix(cli): quarantine worktree seed execution state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-25 19:21:29 +00:00
1 parent 9964b034bb
commit 65d22a7006
3 files changed
+224 -9

No files matched your search

+118 -1
View File
@@ -4,9 +4,10 @@ import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createServer } from "node:net";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
agentWakeupRequests,
agents,
authAccounts,
authUsers,
@@ -14,7 +15,9 @@ import {
companyMemberships,
createDb,
executionWorkspaces,
heartbeatRuns,
inspectMigrations,
instanceSettings,
issueComments,
issues,
projectWorkspaces,
@@ -35,6 +38,7 @@ import {
pauseSeededScheduledRoutines,
quarantineSeededWorktreeExecutionState,
readWorktreeSeedManifest,
resetSeededWorktreeRunExecutionActivation,
readSourceAttachmentBody,
rebindWorkspaceCwd,
requiresWorktreeSeedCredentialAccount,
@@ -1170,6 +1174,11 @@ describe("worktree helpers", () => {
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
const wakeupId = randomUUID();
const completedWakeupId = randomUUID();
const queuedRunId = randomUUID();
const runningRunId = randomUUID();
const succeededRunId = randomUUID();
try {
await db.insert(companies).values({
@@ -1275,6 +1284,11 @@ describe("worktree helpers", () => {
identifier: "WTQ-1",
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-04-18T00:00:00.000Z"),
monitorNextCheckAt: new Date("2026-04-18T00:01:00.000Z"),
monitorWakeRequestedAt: new Date("2026-04-18T00:00:30.000Z"),
monitorLastTriggeredAt: new Date("2026-04-18T00:00:00.000Z"),
monitorAttemptCount: 2,
monitorNotes: "copied monitor state",
},
{
id: todoIssueId,
@@ -1307,8 +1321,53 @@ describe("worktree helpers", () => {
identifier: "WTQ-4",
},
]);
await db.insert(agentWakeupRequests).values([
{
id: wakeupId,
companyId,
agentId,
source: "system",
status: "queued",
},
{
id: completedWakeupId,
companyId,
agentId,
source: "system",
status: "completed",
},
]);
await db.insert(heartbeatRuns).values([
{
id: queuedRunId,
companyId,
agentId,
status: "queued",
wakeupRequestId: wakeupId,
processPid: 1234,
processGroupId: 1234,
},
{
id: runningRunId,
companyId,
agentId,
status: "running",
processPid: 5678,
processGroupId: 5678,
},
{
id: succeededRunId,
companyId,
agentId,
status: "succeeded",
wakeupRequestId: completedWakeupId,
},
]);
await expect(quarantineSeededWorktreeExecutionState(tempDb.connectionString)).resolves.toEqual({
cancelledHeartbeatRuns: 2,
deletedPendingWakeups: 1,
clearedIssueMonitors: 1,
disabledTimerHeartbeats: 1,
resetRunningAgents: 1,
quarantinedInProgressIssues: 1,
@@ -1331,6 +1390,32 @@ describe("worktree helpers", () => {
expect(inProgressIssue?.assigneeAgentId).toBeNull();
expect(inProgressIssue?.executionAgentNameKey).toBeNull();
expect(inProgressIssue?.executionLockedAt).toBeNull();
expect(inProgressIssue?.monitorNextCheckAt).toBeNull();
expect(inProgressIssue?.monitorWakeRequestedAt).toBeNull();
expect(inProgressIssue?.monitorLastTriggeredAt).toBeNull();
expect(inProgressIssue?.monitorAttemptCount).toBe(0);
expect(inProgressIssue?.monitorNotes).toBeNull();
const quarantinedRuns = await db
.select()
.from(heartbeatRuns)
.where(inArray(heartbeatRuns.id, [queuedRunId, runningRunId]));
expect(quarantinedRuns).toHaveLength(2);
for (const run of quarantinedRuns) {
expect(run.status).toBe("cancelled");
expect(run.errorCode).toBe("worktree_seed_quarantine");
expect(run.processPid).toBeNull();
expect(run.processGroupId).toBeNull();
expect(run.finishedAt).toBeInstanceOf(Date);
}
const [succeededRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, succeededRunId));
expect(succeededRun?.status).toBe("succeeded");
expect(succeededRun?.wakeupRequestId).toBe(completedWakeupId);
const wakeups = await db.select().from(agentWakeupRequests);
expect(wakeups).toHaveLength(1);
expect(wakeups[0]?.id).toBe(completedWakeupId);
const [todoIssue] = await db.select().from(issues).where(eq(issues.id, todoIssueId));
expect(todoIssue?.status).toBe("todo");
@@ -1394,6 +1479,38 @@ describe("worktree helpers", () => {
}
}, 20_000);
itEmbeddedPostgres("always disarms copied worktree run execution settings", async () => {
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-activation-reset-");
const db = createDb(tempDb.connectionString);
try {
await db
.update(instanceSettings)
.set({
experimental: {
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-16T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "source-instance",
enableSmokeLab: true,
},
})
.where(eq(instanceSettings.singletonKey, "default"));
await expect(resetSeededWorktreeRunExecutionActivation(tempDb.connectionString)).resolves.toBe(1);
const [settings] = await db.select().from(instanceSettings);
expect(settings?.experimental).toMatchObject({
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
enableSmokeLab: true,
});
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
await tempDb.cleanup();
}
}, 20_000);
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
+101 -7
View File
@@ -33,6 +33,7 @@ import {
} from "@paperclipai/shared/worktree-port-registry";
import {
applyPendingMigrations,
agentWakeupRequests,
agents,
authAccounts,
authUsers,
@@ -47,6 +48,7 @@ import {
formatDatabaseBackupResult,
goals,
heartbeatRuns,
instanceSettings,
inspectMigrations,
issueAttachments,
issueComments,
@@ -261,6 +263,9 @@ export type LegacyWorktreeDatabaseEvidence = {
};
export type SeededWorktreeExecutionQuarantineSummary = {
cancelledHeartbeatRuns: number;
deletedPendingWakeups: number;
clearedIssueMonitors: number;
disabledTimerHeartbeats: number;
resetRunningAgents: number;
quarantinedInProgressIssues: number;
@@ -287,6 +292,9 @@ function formatSeededWorktreeExecutionQuarantineSummary(
summary: SeededWorktreeExecutionQuarantineSummary,
): string {
return [
`cancelled heartbeat runs: ${summary.cancelledHeartbeatRuns}`,
`deleted pending wakeups: ${summary.deletedPendingWakeups}`,
`cleared issue monitors: ${summary.clearedIssueMonitors}`,
`disabled timer heartbeats: ${summary.disabledTimerHeartbeats}`,
`reset running agents: ${summary.resetRunningAgents}`,
`quarantined in-progress issues: ${summary.quarantinedInProgressIssues}`,
@@ -1247,6 +1255,9 @@ export async function pauseSeededScheduledRoutines(connectionString: string): Pr
}
const EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY: SeededWorktreeExecutionQuarantineSummary = {
cancelledHeartbeatRuns: 0,
deletedPendingWakeups: 0,
clearedIssueMonitors: 0,
disabledTimerHeartbeats: 0,
resetRunningAgents: 0,
quarantinedInProgressIssues: 0,
@@ -1323,6 +1334,63 @@ export async function quarantineSeededWorktreeExecutionState(
const summary = { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY };
try {
await db.transaction(async (tx) => {
const now = new Date();
const pendingWakeups = await tx
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]));
const pendingWakeupIds = pendingWakeups.map((row) => row.id);
if (pendingWakeupIds.length > 0) {
await tx
.update(heartbeatRuns)
.set({ wakeupRequestId: null, updatedAt: now })
.where(inArray(heartbeatRuns.wakeupRequestId, pendingWakeupIds));
}
const cancelledRuns = await tx
.update(heartbeatRuns)
.set({
status: "cancelled",
errorCode: "worktree_seed_quarantine",
processPid: null,
processGroupId: null,
scheduledRetryAt: null,
finishedAt: now,
updatedAt: now,
})
.where(inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]))
.returning({ id: heartbeatRuns.id });
summary.cancelledHeartbeatRuns = cancelledRuns.length;
if (pendingWakeupIds.length > 0) {
const deletedWakeups = await tx
.delete(agentWakeupRequests)
.where(inArray(agentWakeupRequests.id, pendingWakeupIds))
.returning({ id: agentWakeupRequests.id });
summary.deletedPendingWakeups = deletedWakeups.length;
}
const clearedMonitors = await tx
.update(issues)
.set({
monitorNextCheckAt: null,
monitorWakeRequestedAt: null,
monitorLastTriggeredAt: null,
monitorAttemptCount: 0,
monitorNotes: null,
updatedAt: now,
})
.where(sql`
${issues.monitorNextCheckAt} is not null
or ${issues.monitorWakeRequestedAt} is not null
or ${issues.monitorLastTriggeredAt} is not null
or ${issues.monitorAttemptCount} <> 0
or ${issues.monitorNotes} is not null
`)
.returning({ id: issues.id });
summary.clearedIssueMonitors = clearedMonitors.length;
const seededAgents = await tx
.select({
id: agents.id,
@@ -1346,7 +1414,7 @@ export async function quarantineSeededWorktreeExecutionState(
.set({
runtimeConfig: normalized.runtimeConfig,
status: nextStatus,
updatedAt: new Date(),
updatedAt: now,
})
.where(eq(agents.id, agent.id));
}
@@ -1379,7 +1447,7 @@ export async function quarantineSeededWorktreeExecutionState(
executionAgentNameKey: null,
executionLockedAt: null,
executionWorkspaceId: null,
updatedAt: new Date(),
updatedAt: now,
})
.where(eq(issues.id, issue.id));
@@ -1677,6 +1745,31 @@ async function inspectVerifiedSeedDatabase(
}
}
export async function resetSeededWorktreeRunExecutionActivation(connectionString: string): Promise<number> {
const db = createDb(connectionString);
try {
const rows = await db.select({ id: instanceSettings.id, experimental: instanceSettings.experimental }).from(instanceSettings);
let resetCount = 0;
for (const row of rows) {
const experimental = isRecord(row.experimental) ? row.experimental : {};
const nextExperimental = {
...experimental,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
};
await db
.update(instanceSettings)
.set({ experimental: nextExperimental, updatedAt: new Date() })
.where(eq(instanceSettings.id, row.id));
resetCount += 1;
}
return resetCount;
} finally {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
async function seedWorktreeDatabase(input: {
sourceConfigPath: string;
sourceConfig: PaperclipConfig;
@@ -1762,6 +1855,7 @@ async function seedWorktreeDatabase(input: {
await applyPendingMigrations(targetConnectionString);
input.onPhase?.("migrations", "succeeded");
input.onPhase?.("execution_quarantine", "started");
await resetSeededWorktreeRunExecutionActivation(targetConnectionString);
const executionQuarantine = input.preserveLiveWork
? { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY }
: await quarantineSeededWorktreeExecutionState(targetConnectionString);
@@ -4273,7 +4367,7 @@ async function backupWorktreeReseedTarget(input: {
}
async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
const seedMode = opts.seedMode ?? "full";
const seedMode = opts.seedMode ?? "minimal";
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
@@ -4488,7 +4582,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--preserve-live-work", "Preserve copied runs, wakeups, monitors, agent work, and workspace runtime services", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
@@ -4505,7 +4599,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--preserve-live-work", "Preserve copied runs, wakeups, monitors, agent work, and workspace runtime services", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeInitCommand);
@@ -4555,7 +4649,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config")
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--preserve-live-work", "Preserve copied runs, wakeups, monitors, agent work, and workspace runtime services", false)
.option("--yes", "Skip the destructive confirmation prompt", false)
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.option("--backup-target", "Retain a recoverable full backup of the isolated target DB before reseeding", false)
@@ -4570,7 +4664,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config (default: default)")
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--preserve-live-work", "Preserve copied runs, wakeups, monitors, agent work, and workspace runtime services", false)
.option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false)
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
.action(worktreeRepairCommand);