diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 657100a560..b407afc370 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -3140,6 +3140,132 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(rows[0]?.status).toBe("pending"); }); + it("does not supersede request confirmations for board-token automation comments", async () => { + // Automation that authenticates with a board/user token has no run context, so + // createdByRunId is null and the comment is indistinguishable from a human's unless + // the caller marks it. Both machine markers must be honoured. + const { companyId, issueId } = await seedConfirmationIssue("Board-token automation supersede exclusion"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + }, + }, { + userId: "local-board", + }); + const afterCreated = new Date(new Date(created.createdAt).getTime() + 1_000); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: afterCreated, + authorUserId: "local-board", + createdByRunId: null, + presentation: { + kind: "system_notice", + tone: "info", + detailsDefaultOpen: false, + }, + }, { + userId: "local-board", + })).resolves.toHaveLength(0); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: afterCreated, + authorUserId: "local-board", + authorType: "system", + createdByRunId: null, + }, { + userId: "local-board", + })).resolves.toHaveLength(0); + + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + + it("does not repair historical confirmations from system-notice comments, but still repairs from human ones", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Historical system-notice exclusion"); + const humanCommentId = randomUUID(); + const createdAt = new Date("2026-05-18T12:00:00.000Z"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + }, + }, { + userId: "local-board", + }); + await db + .update(issueThreadInteractions) + .set({ createdAt, updatedAt: createdAt }) + .where(eq(issueThreadInteractions.id, created.id)); + + await db.insert(issueComments).values({ + id: randomUUID(), + companyId, + issueId, + authorUserId: "local-board", + authorType: "user", + presentation: { + kind: "system_notice", + tone: "info", + detailsDefaultOpen: false, + }, + body: "Automated pipeline check: no open PR for this card yet.", + createdAt: new Date("2026-05-18T12:01:00.000Z"), + updatedAt: new Date("2026-05-18T12:01:00.000Z"), + }); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + })).resolves.toEqual([]); + + // The sweep is filtered, not disabled: a real human comment still supersedes. + await db.insert(issueComments).values({ + id: humanCommentId, + companyId, + issueId, + authorUserId: "local-board", + authorType: "user", + body: "Please revise this first.", + createdAt: new Date("2026-05-18T12:02:00.000Z"), + updatedAt: new Date("2026-05-18T12:02:00.000Z"), + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + }); + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId: humanCommentId, + }, + }); + }); + it("repairs historical request confirmations superseded by later user comments idempotently", async () => { const { companyId, issueId } = await seedConfirmationIssue("Historical comment supersede"); const commentId = randomUUID(); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 8e2fea0a81..78af6d61f7 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -39,6 +39,8 @@ import type { ConnectionIntentInteraction, CreateIssueThreadInteraction, InteractionResolverGovernance, + IssueCommentAuthorType, + IssueCommentPresentation, IssueReviewPolicy, IssueThreadInteraction, IssueThreadInteractionCanonicalResolverPolicy, @@ -816,6 +818,23 @@ function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSuperse return interaction.payload.supersedeOnUserComment === true; } +// `createdByRunId` catches machine comments that come from a heartbeat run, but not +// every machine posts from one. Local automation — pipeline monitors, cron scripts, +// gateway hooks — authenticates with a board/user token and carries no run context, so +// its comments are byte-identical to a human's at this decision point. A comment the +// caller marked as a system notice is machine-authored by construction and must never +// stand in for a human answer: otherwise an automated "no PR yet, your move" nudge +// expires the very question the work is waiting on, and the next nudge asks again. +function isMachineAuthoredComment(comment: { + authorType?: IssueCommentAuthorType | null; + createdByRunId?: string | null; + presentation?: IssueCommentPresentation | null; +}) { + if (comment.createdByRunId) return true; + if (comment.authorType === "system") return true; + return comment.presentation?.kind === "system_notice"; +} + function normalizeCreateInteractionInput( input: CreateIssueThreadInteraction, ): CreateIssueThreadInteraction { @@ -4109,14 +4128,16 @@ export function issueThreadInteractionService( id: string; createdAt: Date | string; authorUserId?: string | null; + authorType?: IssueCommentAuthorType | null; createdByRunId?: string | null; + presentation?: IssueCommentPresentation | null; }, actor: InteractionActor, ) => { if (!comment.authorUserId) return []; - // Local-CLI adapters post under user auth, so authorUserId can't tell a human from a - // machine; createdByRunId can. Only genuine human comments (no run context) supersede. - if (comment.createdByRunId) return []; + // Local-CLI adapters and board-token automation both post under user auth, so + // authorUserId can't tell a human from a machine. Only genuine human comments do. + if (isMachineAuthoredComment(comment)) return []; const rows = await db .select() @@ -4218,14 +4239,18 @@ export function issueThreadInteractionService( eq(issueComments.companyId, issue.companyId), eq(issueComments.issueId, issue.id), isNotNull(issueComments.authorUserId), - // Only genuine human comments supersede; machine-originated ones carry createdByRunId. + // Cheap SQL prefilter for the common machine case; the remaining + // machine-authored shapes live in jsonb and are filtered below. isNull(issueComments.createdByRunId), ), ) .orderBy(asc(issueComments.createdAt)), ]); - if (rows.length === 0 || comments.length === 0) return []; + // Only genuine human comments supersede a card that is waiting on a human. + const humanComments = comments.filter((comment) => !isMachineAuthoredComment(comment)); + + if (rows.length === 0 || humanComments.length === 0) return []; const now = new Date(); const expired: IssueThreadInteraction[] = []; @@ -4243,7 +4268,7 @@ export function issueThreadInteractionService( ) as UserCommentSupersedableInteraction; if (!shouldSupersedeInteractionOnUserComment(interaction)) continue; - const supersedingComment = comments.find((comment) => + const supersedingComment = humanComments.find((comment) => isCommentAtOrAfterInteraction({ commentCreatedAt: comment.createdAt, interactionCreatedAt: row.createdAt,