Fix subtree resume pause release

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 13:23:56 -05:00
1 parent ec2c5537fc
commit 9465d04755
5 files changed
+289 -59

No files matched your search

@@ -672,7 +672,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const issueChain = Array.from({ length: 17 }, () => randomUUID());
const deepDescendantIssueId = issueChain.at(-1)!;
await db.insert(companies).values({
id: companyId,
@@ -705,15 +706,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
priority: "medium",
assigneeAgentId: agentId,
},
{
id: childIssueId,
...issueChain.map((issueId, index) => ({
id: issueId,
companyId,
parentId: rootIssueId,
title: "Paused child",
parentId: index === 0 ? rootIssueId : issueChain[index - 1],
title: `Paused desc ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
})),
]);
const [hold] = await db
.insert(issueTreeHolds)
@@ -731,8 +732,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: { issueId: childIssueId },
contextSnapshot: { issueId: childIssueId, wakeReason: "issue_blockers_resolved" },
payload: { issueId: deepDescendantIssueId },
contextSnapshot: { issueId: deepDescendantIssueId, wakeReason: "issue_blockers_resolved" },
});
expect(blockedWake).toBeNull();
@@ -742,7 +743,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
reason: agentWakeupRequests.reason,
})
.from(agentWakeupRequests)
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${childIssueId}`)
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${deepDescendantIssueId}`)
.then((rows) => rows[0] ?? null);
expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" });
@@ -750,7 +751,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await db.insert(issueComments).values({
id: childCommentId,
companyId,
issueId: childIssueId,
issueId: deepDescendantIssueId,
authorUserId: "board-user",
body: "Please respond while this hold is active.",
});
@@ -759,7 +760,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
source: "on_demand",
triggerDetail: "manual",
reason: "issue_commented",
payload: { issueId: childIssueId, commentId: childCommentId },
payload: { issueId: deepDescendantIssueId, commentId: childCommentId },
requestedByActorType: "agent",
requestedByActorId: agentId,
});
@@ -769,11 +770,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId: childIssueId, commentId: childCommentId },
payload: { issueId: deepDescendantIssueId, commentId: childCommentId },
requestedByActorType: "user",
requestedByActorId: "board-user",
contextSnapshot: {
issueId: childIssueId,
issueId: deepDescendantIssueId,
commentId: childCommentId,
wakeCommentId: childCommentId,
wakeReason: "issue_commented",
@@ -355,4 +355,44 @@ describe("issue tree control routes", () => {
}),
);
});
it("returns resume operations as released holds and avoids cancellation side effects", async () => {
const app = await createApp({
type: "board",
userId: "user-1",
companyIds: ["company-2"],
source: "session",
isInstanceAdmin: false,
});
mockTreeControlService.createHold.mockResolvedValue({
hold: {
id: "77777777-7777-4777-8777-777777777777",
mode: "resume",
status: "released",
reason: "resume subtree",
},
preview: {
mode: "resume",
totals: {
affectedIssues: 1,
},
warnings: [],
activeRuns: [],
},
resumedPauseHoldIds: ["33333333-3333-4333-8333-333333333333"],
});
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
.send({ mode: "resume", reason: "resume subtree" });
expect(res.status).toBe(200);
expect(res.body.hold.mode).toBe("resume");
expect(res.body.hold.status).toBe("released");
expect(res.body.resumedPauseHoldIds).toEqual(["33333333-3333-4333-8333-333333333333"]);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).not.toHaveBeenCalled();
expect(mockTreeControlService.cancelIssueStatusesForHold).not.toHaveBeenCalled();
expect(mockTreeControlService.restoreIssueStatusesForHold).not.toHaveBeenCalled();
});
});
@@ -337,19 +337,20 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
});
});
it("blocks normal checkout but allows comment interaction checkout under a pause hold", async () => {
it("walks pause-hold ancestry beyond 15 levels for checkout and interaction waives", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const issuePath = Array.from({ length: 17 }, () => randomUUID());
const rootIssueId = issuePath[0];
const deepDescendantIssueId = issuePath.at(-1)!;
const rootRunId = randomUUID();
const childRunId = randomUUID();
const deepDescendantRunId = randomUUID();
const forgedRunId = randomUUID();
const rootWakeupRequestId = randomUUID();
const childWakeupRequestId = randomUUID();
const deepDescendantWakeupRequestId = randomUUID();
const forgedWakeupRequestId = randomUUID();
const rootCommentId = randomUUID();
const childCommentId = randomUUID();
const deepDescendantCommentId = randomUUID();
await db.insert(companies).values({
id: companyId,
@@ -368,25 +369,17 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: rootIssueId,
await db.insert(issues).values(
issuePath.map((issueId, index) => ({
id: issueId,
companyId,
title: "Paused root",
parentId: index > 0 ? issuePath[index - 1] : null,
title: `Issue ${index}`,
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Paused child",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
]);
})),
);
await db.insert(issueComments).values([
{
id: rootCommentId,
@@ -396,11 +389,11 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
body: "Please answer this root issue question.",
},
{
id: childCommentId,
id: deepDescendantCommentId,
companyId,
issueId: childIssueId,
issueId: deepDescendantIssueId,
authorUserId: "board-user",
body: "Please answer this child issue question.",
body: "Please answer this deep descendant issue question.",
},
]);
await db.insert(agentWakeupRequests).values([
@@ -424,24 +417,24 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
source: "on_demand",
triggerDetail: "manual",
reason: "issue_commented",
payload: { issueId: childIssueId, commentId: childCommentId },
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
status: "queued",
requestedByActorType: "agent",
requestedByActorId: agentId,
runId: forgedRunId,
},
{
id: childWakeupRequestId,
id: deepDescendantWakeupRequestId,
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId: childIssueId, commentId: childCommentId },
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
status: "queued",
requestedByActorType: "user",
requestedByActorId: "board-user",
runId: childRunId,
runId: deepDescendantRunId,
},
]);
await db.insert(heartbeatRuns).values([
@@ -470,25 +463,25 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
status: "queued",
wakeupRequestId: forgedWakeupRequestId,
contextSnapshot: {
issueId: childIssueId,
issueId: deepDescendantIssueId,
wakeReason: "issue_commented",
commentId: childCommentId,
wakeCommentId: childCommentId,
commentId: deepDescendantCommentId,
wakeCommentId: deepDescendantCommentId,
},
},
{
id: childRunId,
id: deepDescendantRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: childWakeupRequestId,
wakeupRequestId: deepDescendantWakeupRequestId,
contextSnapshot: {
issueId: childIssueId,
issueId: deepDescendantIssueId,
wakeReason: "issue_commented",
commentId: childCommentId,
wakeCommentId: childCommentId,
commentId: deepDescendantCommentId,
wakeCommentId: deepDescendantCommentId,
source: "issue.comment",
},
},
@@ -500,16 +493,28 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
reason: "operator requested pause",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const deepDescendantGate = await treeSvc.getActivePauseHoldGate(companyId, deepDescendantIssueId);
expect(deepDescendantGate).toMatchObject({
holdId: expect.any(String),
rootIssueId,
issueId: deepDescendantIssueId,
isRoot: false,
mode: "pause",
});
const issueSvc = issueService(db);
await expect(issueSvc.checkout(childIssueId, agentId, ["todo"], randomUUID())).rejects.toMatchObject({
await expect(
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], randomUUID()),
).rejects.toMatchObject({
status: 409,
details: expect.objectContaining({
rootIssueId,
mode: "pause",
}),
});
await expect(issueSvc.checkout(childIssueId, agentId, ["todo"], forgedRunId)).rejects.toMatchObject({
await expect(
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], forgedRunId),
).rejects.toMatchObject({
status: 409,
details: expect.objectContaining({
rootIssueId,
@@ -517,9 +522,9 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
}),
});
const checkedOutChild = await issueSvc.checkout(childIssueId, agentId, ["todo"], childRunId);
const checkedOutChild = await issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], deepDescendantRunId);
expect(checkedOutChild.status).toBe("in_progress");
expect(checkedOutChild.checkoutRunId).toBe(childRunId);
expect(checkedOutChild.checkoutRunId).toBe(deepDescendantRunId);
const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
expect(checkedOutRoot.status).toBe("in_progress");
@@ -552,4 +557,86 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress");
expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId);
});
it("resumes subtree pauses by releasing matching pause holds", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const nonSubtreeIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "todo",
priority: "medium",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child",
status: "todo",
priority: "medium",
},
{
id: nonSubtreeIssueId,
companyId,
title: "Unrelated",
status: "todo",
priority: "medium",
},
]);
const treeSvc = issueTreeControlService(db);
const subtreePause = await treeSvc.createHold(companyId, childIssueId, {
mode: "pause",
reason: "pause child only",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const nonSubtreePause = await treeSvc.createHold(companyId, nonSubtreeIssueId, {
mode: "pause",
reason: "pause unrelated issue",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const resumed = await treeSvc.createHold(companyId, rootIssueId, {
mode: "resume",
reason: "resume subtree",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(resumed.hold.mode).toBe("resume");
expect(resumed.hold.status).toBe("released");
expect(resumed.resumedPauseHoldIds).toEqual([subtreePause.hold.id]);
const rows = await db
.select({ id: issueTreeHolds.id, status: issueTreeHolds.status, releaseMetadata: issueTreeHolds.releaseMetadata })
.from(issueTreeHolds)
.where(eq(issueTreeHolds.companyId, companyId));
const byId = new Map(rows.map((row) => [row.id, row] as const));
expect(byId.get(subtreePause.hold.id)?.status).toBe("released");
expect(byId.get(nonSubtreePause.hold.id)?.status).toBe("active");
expect(byId.get(resumed.hold.id)?.status).toBe("released");
const releaseMetadata = byId.get(subtreePause.hold.id)?.releaseMetadata as
| Record<string, unknown>
| null;
expect(releaseMetadata).toMatchObject({
resumedByResumeHoldId: resumed.hold.id,
resumeHoldMode: "tree_resume",
resumedPauseHoldId: subtreePause.hold.id,
});
expect((byId.get(resumed.hold.id)?.releaseMetadata as Record<string, unknown> | null)).toMatchObject({
resumedPauseHoldIds: [subtreePause.hold.id],
resumeMode: "subtree",
});
});
});
+3 -1
View File
@@ -291,7 +291,9 @@ export function issueTreeControlRoutes(db: Db) {
}
}
res.status(result.hold.mode === "restore" ? 200 : 201).json(result);
res
.status(result.hold.mode === "restore" || result.hold.mode === "resume" ? 200 : 201)
.json(result);
});
router.get("/issues/:id/tree-control/state", async (req, res) => {
+105 -5
View File
@@ -71,7 +71,6 @@ type RestoreTreeStatusResult = TreeStatusUpdateResult & {
const TERMINAL_ISSUE_STATUSES = new Set<IssueStatus>(["done", "cancelled"]);
const ACTIVE_RUN_STATUSES = ["queued", "running"] as const;
const DEFAULT_RELEASE_POLICY: IssueTreeHoldReleasePolicy = { strategy: "manual" };
const MAX_PAUSE_HOLD_GATE_DEPTH = 15;
export const ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS: ReadonlySet<string> = new Set([
"issue_commented",
"issue_reopened_via_comment",
@@ -548,6 +547,22 @@ export function issueTreeControlService(db: Db) {
return byIssueId;
}
async function activePauseHoldsForIssueIds(companyId: string, issueIds: string[]) {
if (issueIds.length === 0) return [];
return db
.select()
.from(issueTreeHolds)
.where(
and(
eq(issueTreeHolds.companyId, companyId),
eq(issueTreeHolds.status, "active"),
eq(issueTreeHolds.mode, "pause"),
inArray(issueTreeHolds.rootIssueId, issueIds),
),
)
.orderBy(asc(issueTreeHolds.createdAt), asc(issueTreeHolds.id));
}
async function getActivePauseHoldGate(
companyId: string,
issueId: string,
@@ -573,9 +588,8 @@ export function issueTreeControlService(db: Db) {
const holdByRootIssueId = new Map(activePauseHolds.map((hold) => [hold.rootIssueId, hold]));
let currentIssueId: string | null = issueId;
const visited = new Set<string>();
let depth = 0;
while (currentIssueId && !visited.has(currentIssueId) && depth < MAX_PAUSE_HOLD_GATE_DEPTH) {
while (currentIssueId && !visited.has(currentIssueId)) {
visited.add(currentIssueId);
const hold = holdByRootIssueId.get(currentIssueId);
if (hold) {
@@ -596,7 +610,6 @@ export function issueTreeControlService(db: Db) {
.where(and(eq(issues.id, currentIssueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
currentIssueId = parent?.parentId ?? null;
depth += 1;
}
return null;
@@ -690,13 +703,100 @@ export function issueTreeControlService(db: Db) {
releasePolicy?: IssueTreeHoldReleasePolicy | null;
actor: ActorInput;
},
) {
): Promise<{
hold: IssueTreeHold;
preview: IssueTreeControlPreview;
resumedPauseHoldIds?: string[];
}> {
const holdReleasePolicy = normalizeReleasePolicy(input.releasePolicy);
const holdPreview = await preview(companyId, rootIssueId, {
mode: input.mode,
releasePolicy: holdReleasePolicy,
});
if (input.mode === "resume") {
const issueIds = [...new Set(holdPreview.issues.map((issue) => issue.id))];
const activePauseHolds = await activePauseHoldsForIssueIds(companyId, issueIds);
const releaseReason = input.reason ?? "Subtree resume applied.";
const { hold: resumeHold } = await db.transaction(async (tx) => {
const [createdHold] = await tx
.insert(issueTreeHolds)
.values({
companyId,
rootIssueId,
mode: input.mode,
status: "active",
reason: input.reason ?? null,
releasePolicy: holdReleasePolicy as unknown as Record<string, unknown>,
createdByActorType: input.actor.actorType,
createdByAgentId: input.actor.agentId ?? null,
createdByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null),
createdByRunId: input.actor.runId ?? null,
})
.returning();
const memberRows = holdPreview.issues.map((issue) => ({
companyId,
holdId: createdHold.id,
issueId: issue.id,
parentIssueId: issue.parentId,
depth: issue.depth,
issueIdentifier: issue.identifier,
issueTitle: issue.title,
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
activeRunId: issue.activeRun?.id ?? null,
activeRunStatus: issue.activeRun?.status ?? null,
skipped: issue.skipped,
skipReason: issue.skipReason,
}));
const createdMembers = memberRows.length > 0
? await tx
.insert(issueTreeHoldMembers)
.values(memberRows)
.returning()
: [];
return { hold: toHold(createdHold, createdMembers) };
});
const resumedPauseHoldIds = activePauseHolds.map((hold) => hold.id);
if (resumedPauseHoldIds.length > 0) {
await Promise.all(
activePauseHolds.map((pauseHold) =>
releaseHold(companyId, pauseHold.rootIssueId, pauseHold.id, {
reason: releaseReason,
metadata: {
resumedByResumeHoldId: resumeHold.id,
resumeHoldMode: "tree_resume",
resumedPauseHoldId: pauseHold.id,
},
actor: input.actor,
}),
),
);
}
const releasedResumeHold = await releaseHold(companyId, rootIssueId, resumeHold.id, {
reason: releaseReason,
metadata: {
resumedPauseHoldIds,
resumeMode: "subtree",
...(input.releasePolicy ? { releasePolicy: holdReleasePolicy } : {}),
},
actor: input.actor,
});
return {
hold: releasedResumeHold,
preview: holdPreview,
resumedPauseHoldIds,
};
}
const { hold, members } = await db.transaction(async (tx) => {
const [createdHold] = await tx
.insert(issueTreeHolds)