diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 9b6daa189b1..d6fa8286cea 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -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({ diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 44e63a74ad5..8c3ca65eee6 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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", () => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index fbce62ccd74..5d8ec7e8441 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 43c86e7b3e9..5575aa156f5 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 & { 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 | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function buildGateConfirmationMirrorState( + executionState: unknown, + parentIssueId: string, +): Record { + 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 ``; +} + +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, });