Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 37 additions & 13 deletions server/src/routes/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,18 @@ export function issueRoutes(
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;

const actor = getActorInfo(req);

// Recover true agent when bearer token was omitted but x-paperclip-run-id was sent.
let resolvedActorAgentId: string | null = actor.agentId;
let resolvedActorIsAgent = actor.actorType === "agent";
if (actor.actorType !== "agent" && actor.runId) {
const run = await heartbeat.getRun(actor.runId);
if (run?.agentId && run.companyId === existing.companyId) {
resolvedActorAgentId = run.agentId;
resolvedActorIsAgent = true;
}
}

const isClosed = isClosedIssueStatus(existing.status);
const isBlocked = existing.status === "blocked";
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
Expand Down Expand Up @@ -1801,8 +1813,8 @@ export function issueRoutes(
shouldImplicitlyMoveCommentedIssueToTodoForAgent({
issueStatus: existing.status,
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
actorType: resolvedActorIsAgent ? "agent" : actor.actorType,
actorId: resolvedActorAgentId ?? actor.actorId,
}));
const updateReferenceSummaryBefore = titleOrDescriptionChanged
? await issueReferencesSvc.listIssueReferenceSummary(existing.id)
Expand Down Expand Up @@ -2160,8 +2172,8 @@ export function issueRoutes(
const commentReferenceSummaryBefore = updateReferenceSummaryAfter
?? await issueReferencesSvc.listIssueReferenceSummary(issue.id);
comment = await svc.addComment(id, commentBody, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
agentId: resolvedActorAgentId ?? undefined,
userId: resolvedActorIsAgent ? undefined : (actor.actorType === "user" ? actor.actorId : undefined),
runId: actor.runId,
});
await issueReferencesSvc.syncComment(comment.id);
Expand Down Expand Up @@ -2313,8 +2325,7 @@ export function issueRoutes(

if (commentBody && comment) {
const assigneeId = issue.assigneeAgentId;
const actorIsAgent = actor.actorType === "agent";
const selfComment = actorIsAgent && actor.actorId === assigneeId;
const selfComment = resolvedActorIsAgent && resolvedActorAgentId === assigneeId;
const skipAssigneeCommentWake = selfComment || isClosed;

if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) {
Expand Down Expand Up @@ -3114,6 +3125,20 @@ export function issueRoutes(
}

const actor = getActorInfo(req);

// When an agent omits its bearer token but correctly sends x-paperclip-run-id,
// the server resolves the actor as board. Recover the true agent from the run
// record so attribution and self-wake suppression work correctly.
let resolvedActorAgentId: string | null = actor.agentId;
let resolvedActorIsAgent = actor.actorType === "agent";
if (actor.actorType !== "agent" && actor.runId) {
const run = await heartbeat.getRun(actor.runId);
if (run?.agentId && run.companyId === issue.companyId) {
resolvedActorAgentId = run.agentId;
resolvedActorIsAgent = true;
}
}

const reopenRequested = req.body.reopen === true;
const interruptRequested = req.body.interrupt === true;
const isClosed = isClosedIssueStatus(issue.status);
Expand All @@ -3123,8 +3148,8 @@ export function issueRoutes(
shouldImplicitlyMoveCommentedIssueToTodoForAgent({
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
actorType: resolvedActorIsAgent ? "agent" : actor.actorType,
actorId: resolvedActorAgentId ?? actor.actorId,
});
const hasUnresolvedFirstClassBlockers =
isBlocked && effectiveMoveToTodoRequested
Expand Down Expand Up @@ -3192,8 +3217,8 @@ export function issueRoutes(
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
agentId: resolvedActorAgentId ?? undefined,
userId: resolvedActorIsAgent ? undefined : (actor.actorType === "user" ? actor.actorId : undefined),
runId: actor.runId,
});
await issueReferencesSvc.syncComment(comment.id);
Expand Down Expand Up @@ -3251,8 +3276,7 @@ export function issueRoutes(
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
const assigneeId = currentIssue.assigneeAgentId;
const actorIsAgent = actor.actorType === "agent";
const selfComment = actorIsAgent && actor.actorId === assigneeId;
const selfComment = resolvedActorIsAgent && resolvedActorAgentId === assigneeId;
const skipWake = selfComment || isClosed;
if (assigneeId && (reopened || !skipWake)) {
if (reopened) {
Expand Down Expand Up @@ -3315,7 +3339,7 @@ export function issueRoutes(

for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
if (actorIsAgent && actor.actorId === mentionedId) continue;
if (resolvedActorIsAgent && resolvedActorAgentId === mentionedId) continue;
wakeups.set(mentionedId, {
source: "automation",
triggerDetail: "system",
Expand Down
23 changes: 23 additions & 0 deletions server/src/services/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6654,6 +6654,29 @@ export function heartbeatService(db: Db) {
return null;
}

// Suppress automation wakes triggered by the agent itself. The common path
// is caught in route handlers; this is belt-and-suspenders for paths that lack
// per-caller self-checks (e.g. execution-stage triggers).
if (opts.requestedByActorType === "agent" && opts.requestedByActorId === agentId && source === "automation") {
await writeSkippedRequest("self_event_suppression");
return null;
}

// Secondary path: agent omitted bearer token so actor resolved as board,
// but the triggering comment's createdByRunId belongs to this agent.
if (opts.requestedByActorType !== "agent" && wakeCommentId && source === "automation") {
const commentRun = await db
.select({ agentId: heartbeatRuns.agentId })
.from(issueComments)
.innerJoin(heartbeatRuns, eq(issueComments.createdByRunId, heartbeatRuns.id))
.where(eq(issueComments.id, wakeCommentId))
.then((rows) => rows[0] ?? null);
if (commentRun?.agentId === agentId) {
await writeSkippedRequest("self_event_suppression_via_run");
return null;
}
}

if (issueId) {
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(agent.companyId, issueId);
if (activePauseHold) {
Expand Down
Loading