fix(interactions): do not supersede decision cards on board-token automation comments

Automation that authenticates with a board or user token has no run context.
Its comments carry a non-null authorUserId and a null createdByRunId, so the
supersede guard treats them as a human answer and expires a pending card.

Honour the two machine markers the schema already has: authorType "system"
and presentation.kind "system_notice". The live and the historical supersede
paths share one helper.
This commit is contained in:
anatol 2026-09-10 03:05:42 +03:00 committed by paperclip-agent
parent 0d8bbf7cf4
commit d524e9a1c2
2 changed files with 157 additions and 6 deletions

View File

@ -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();

View File

@ -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,