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:
Nicky LeachandPaperclip committed 2026-06-20 01:29:32 +00:00
1 parent 713b3b08f0
commit aa3f1fb942
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({
+100
View File
@@ -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", () => {
+10
View File
@@ -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, {
+126
View File
@@ -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,
});