mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Fix gate-confirmation mirror blocker handling
Persist the server-owned gate confirmation mirror opt-in on child issues and mirror completed gate confirmations back to the parent issue before dependency wakeups. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
4 files changed
+242
No files matched your search
@@ -11,6 +11,7 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
mirrorGateConfirmationToParent: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
}));
|
||||
@@ -176,6 +177,7 @@ describe("issue activity event routes", () => {
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.mirrorGateConfirmationToParent.mockResolvedValue(null);
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
@@ -383,6 +385,10 @@ describe("issue activity event routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockIssueService.mirrorGateConfirmationToParent).toHaveBeenCalledWith(issue.id);
|
||||
expect(mockIssueService.mirrorGateConfirmationToParent.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockIssueService.listWakeableBlockedDependents.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -2898,6 +2898,106 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
|
||||
expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
|
||||
it("persists server-owned gate-confirmation mirror state and mirrors a done child once", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Ship gate confirmations",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
goalId,
|
||||
name: "Gate project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
const { issue: child } = await svc.createChild(parentIssueId, {
|
||||
title: "Security gate",
|
||||
status: "todo",
|
||||
gateConfirmationMirror: true,
|
||||
});
|
||||
|
||||
expect(child.executionState).toMatchObject({
|
||||
gateConfirmationMirror: {
|
||||
version: 1,
|
||||
parentIssueId,
|
||||
},
|
||||
});
|
||||
|
||||
await svc.addComment(child.id, "Approved. The implementation keeps the system write bounded.", {});
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, child.id));
|
||||
|
||||
const mirrored = await svc.mirrorGateConfirmationToParent(child.id);
|
||||
expect(mirrored).toMatchObject({
|
||||
issueId: parentIssueId,
|
||||
authorType: "system",
|
||||
});
|
||||
expect(mirrored?.body).toContain("## Gate Confirmation");
|
||||
expect(mirrored?.body).toContain(child.identifier);
|
||||
expect(mirrored?.body).toContain("Approved. The implementation keeps the system write bounded.");
|
||||
|
||||
await expect(svc.mirrorGateConfirmationToParent(child.id)).resolves.toBeNull();
|
||||
const parentComments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, parentIssueId));
|
||||
expect(parentComments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not mirror unflagged done children", async () => {
|
||||
const companyId = randomUUID();
|
||||
const parentIssueId = 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: parentIssueId,
|
||||
companyId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
const { issue: child } = await svc.createChild(parentIssueId, {
|
||||
title: "Normal child",
|
||||
status: "done",
|
||||
});
|
||||
|
||||
await expect(svc.mirrorGateConfirmationToParent(child.id)).resolves.toBeNull();
|
||||
const parentComments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, parentIssueId));
|
||||
expect(parentComments).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
|
||||
|
||||
@@ -6465,6 +6465,11 @@ export function issueRoutes(
|
||||
|
||||
const becameDone = existing.status !== "done" && issue.status === "done";
|
||||
if (becameDone) {
|
||||
try {
|
||||
await svc.mirrorGateConfirmationToParent(issue.id);
|
||||
} catch (err) {
|
||||
logger.warn({ err, issueId: issue.id }, "failed to mirror gate confirmation to parent");
|
||||
}
|
||||
const dependents = await svc.listWakeableBlockedDependents(issue.id);
|
||||
for (const dependent of dependents) {
|
||||
addWakeup(dependent.assigneeAgentId, {
|
||||
@@ -7855,6 +7860,11 @@ export function issueRoutes(
|
||||
|
||||
const becameDone = issueBeforeCommentDecision.status !== "done" && currentIssue.status === "done";
|
||||
if (becameDone) {
|
||||
try {
|
||||
await svc.mirrorGateConfirmationToParent(currentIssue.id);
|
||||
} catch (err) {
|
||||
logger.warn({ err, issueId: currentIssue.id }, "failed to mirror gate confirmation to parent");
|
||||
}
|
||||
const dependents = await svc.listWakeableBlockedDependents(currentIssue.id);
|
||||
for (const dependent of dependents) {
|
||||
addWakeup(dependent.assigneeAgentId, {
|
||||
|
||||
@@ -101,6 +101,7 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
|
||||
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
|
||||
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
|
||||
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
|
||||
const GATE_CONFIRMATION_MIRROR_SUMMARY_MAX_CHARS = 1200;
|
||||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
|
||||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
|
||||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
|
||||
@@ -370,6 +371,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
||||
type IssueChildCreateInput = IssueCreateInput & {
|
||||
acceptanceCriteria?: string[];
|
||||
blockParentUntilDone?: boolean;
|
||||
gateConfirmationMirror?: boolean;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
};
|
||||
@@ -409,6 +411,12 @@ export type ChildIssueCompletionSummary = {
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
type GateConfirmationMirrorState = {
|
||||
version: 1;
|
||||
parentIssueId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||
if (actorRunId) return checkoutRunId === actorRunId;
|
||||
return checkoutRunId == null;
|
||||
@@ -440,6 +448,62 @@ function truncateInlineSummary(value: string | null | undefined, maxChars = CHIL
|
||||
return normalized.length > maxChars ? `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()} [truncated]` : normalized;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function buildGateConfirmationMirrorState(
|
||||
executionState: unknown,
|
||||
parentIssueId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(readRecord(executionState) ?? {}),
|
||||
gateConfirmationMirror: {
|
||||
version: 1,
|
||||
parentIssueId,
|
||||
createdAt: new Date().toISOString(),
|
||||
} satisfies GateConfirmationMirrorState,
|
||||
};
|
||||
}
|
||||
|
||||
function readGateConfirmationMirrorState(executionState: unknown): GateConfirmationMirrorState | null {
|
||||
const mirror = readRecord(readRecord(executionState)?.gateConfirmationMirror);
|
||||
if (!mirror) return null;
|
||||
if (mirror.version !== 1) return null;
|
||||
if (typeof mirror.parentIssueId !== "string" || !isUuidLike(mirror.parentIssueId)) return null;
|
||||
if (typeof mirror.createdAt !== "string") return null;
|
||||
return {
|
||||
version: 1,
|
||||
parentIssueId: mirror.parentIssueId,
|
||||
createdAt: mirror.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGateConfirmationMirrorMarker(childIssueId: string) {
|
||||
return `<!-- gate-confirmation-mirror:${childIssueId} -->`;
|
||||
}
|
||||
|
||||
function buildGateConfirmationMirrorComment(input: {
|
||||
childIssueId: string;
|
||||
childIdentifier: string | null;
|
||||
childTitle: string;
|
||||
childSummary: string | null;
|
||||
}) {
|
||||
const prefix = input.childIdentifier?.match(/^([A-Z][A-Z0-9]*)-\d+$/)?.[1] ?? null;
|
||||
const childLabel = input.childIdentifier
|
||||
? `[${input.childIdentifier}](${prefix ? `/${prefix}/issues/${input.childIdentifier}` : "#"})`
|
||||
: `child issue ${input.childIssueId}`;
|
||||
const summary = input.childSummary ? `\n\nLatest child update:\n\n${input.childSummary}` : "";
|
||||
return [
|
||||
"## Gate Confirmation",
|
||||
"",
|
||||
`${childLabel} completed the delegated gate-confirmation task: ${input.childTitle}.`,
|
||||
summary,
|
||||
"",
|
||||
buildGateConfirmationMirrorMarker(input.childIssueId),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function truncateByCodePoint(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return Array.from(value).slice(0, maxChars).join("");
|
||||
@@ -4564,6 +4628,64 @@ export function issueService(db: Db) {
|
||||
};
|
||||
},
|
||||
|
||||
mirrorGateConfirmationToParent: async (childIssueId: string) => {
|
||||
const child = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
parentId: issues.parentId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionState: issues.executionState,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, childIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!child || child.status !== "done" || !child.parentId) return null;
|
||||
|
||||
const mirror = readGateConfirmationMirrorState(child.executionState);
|
||||
if (!mirror || mirror.parentIssueId !== child.parentId) return null;
|
||||
|
||||
const marker = buildGateConfirmationMirrorMarker(child.id);
|
||||
const existingMirror = await db
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.companyId, child.companyId),
|
||||
eq(issueComments.issueId, child.parentId),
|
||||
like(issueComments.body, `%${marker}%`),
|
||||
isNull(issueComments.deletedAt),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existingMirror) return null;
|
||||
|
||||
const latestChildComment = await db
|
||||
.select({ body: issueComments.body })
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.companyId, child.companyId),
|
||||
eq(issueComments.issueId, child.id),
|
||||
isNull(issueComments.deletedAt),
|
||||
))
|
||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return issueService(db).addComment(
|
||||
child.parentId,
|
||||
buildGateConfirmationMirrorComment({
|
||||
childIssueId: child.id,
|
||||
childIdentifier: child.identifier,
|
||||
childTitle: child.title,
|
||||
childSummary: truncateInlineSummary(latestChildComment?.body, GATE_CONFIRMATION_MIRROR_SUMMARY_MAX_CHARS),
|
||||
}),
|
||||
{},
|
||||
{ authorType: "system" },
|
||||
);
|
||||
},
|
||||
|
||||
createChild: async (
|
||||
parentIssueId: string,
|
||||
data: IssueChildCreateInput,
|
||||
@@ -4586,6 +4708,7 @@ export function issueService(db: Db) {
|
||||
const {
|
||||
acceptanceCriteria,
|
||||
blockParentUntilDone,
|
||||
gateConfirmationMirror,
|
||||
actorAgentId,
|
||||
actorUserId,
|
||||
...issueData
|
||||
@@ -4599,6 +4722,9 @@ export function issueService(db: Db) {
|
||||
Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0),
|
||||
),
|
||||
description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria),
|
||||
...(gateConfirmationMirror
|
||||
? { executionState: buildGateConfirmationMirrorState(issueData.executionState, parent.id) }
|
||||
: {}),
|
||||
inheritExecutionWorkspaceFromIssueId: parent.id,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user