From 75ac8b4dbb29055b5a3ca370979420bd45f316de Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 1 Aug 2026 19:02:06 +0000 Subject: [PATCH] fix: inherit run scope for scheduled retries Co-Authored-By: Paperclip --- server/src/__tests__/activity-service.test.ts | 19 +++++++++++++++++++ .../heartbeat-retry-scheduling.test.ts | 14 ++++++++++++++ server/src/services/heartbeat.ts | 7 +++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts index df7d841b9aa..642a699c773 100644 --- a/server/src/__tests__/activity-service.test.ts +++ b/server/src/__tests__/activity-service.test.ts @@ -142,10 +142,21 @@ describeEmbeddedPostgres("activity service", () => { permissions: {}, }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Summarize a completed run", + status: "done", + priority: "medium", + assigneeAgentId: agentId, + }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", contextSnapshot: { issueId }, @@ -256,6 +267,8 @@ describeEmbeddedPostgres("activity service", () => { id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:00:00.000Z"), @@ -350,6 +363,8 @@ describeEmbeddedPostgres("activity service", () => { id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:00:00.000Z"), @@ -365,6 +380,8 @@ describeEmbeddedPostgres("activity service", () => { id: otherRunId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:05:00.000Z"), @@ -472,6 +489,8 @@ describeEmbeddedPostgres("activity service", () => { id: runId, companyId, agentId, + scopeKind: "issue", + issueId, invocationSource: "assignment", status: "succeeded", startedAt: new Date("2026-04-18T20:10:00.000Z"), diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index bc04d27db27..798d3711905 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -380,6 +380,11 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { identifier: `${issuePrefix}-1`, }); + // Complete the circular issue/run fixture after both FK targets exist. + await db.update(heartbeatRuns) + .set({ scopeKind: "issue", issueId }) + .where(eq(heartbeatRuns.id, runId)); + return { companyId, agentId, issueId, runId, now }; } @@ -1755,6 +1760,9 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { issueNumber: 1, identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-2`, }); + await db.update(heartbeatRuns) + .set({ scopeKind: "issue", issueId }) + .where(eq(heartbeatRuns.id, sourceRunId)); const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { now, @@ -1856,6 +1864,9 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { issueNumber: 1, identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-3`, }); + await db.update(heartbeatRuns) + .set({ scopeKind: "issue", issueId }) + .where(eq(heartbeatRuns.id, sourceRunId)); const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { now, @@ -1966,6 +1977,9 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { issueNumber: 1, identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-3`, }); + await db.update(heartbeatRuns) + .set({ scopeKind: "issue", issueId }) + .where(eq(heartbeatRuns.id, sourceRunId)); const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { now, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 50fe918fb9f..870c6c1be8a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10511,7 +10511,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : null; const transientRetryNotBefore = transientRecovery?.retryNotBefore ?? null; const contextSnapshot = parseObject(run.contextSnapshot); - const issueId = readNonEmptyString(contextSnapshot.issueId); + // A retry inherits the durable authorization scope of its source run. Do + // not promote an untrusted or legacy contextSnapshot.issueId into a new + // issue binding: that could either violate the FK or misclassify history. + const issueId = run.scopeKind === "issue" ? run.issueId : null; if (!baseSchedule) { await appendRunEvent(run, await nextRunEventSeq(run.id), { @@ -10901,7 +10904,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: run.companyId, agentId: run.agentId, - scopeKind: issueId ? "issue" : "company", + scopeKind: run.scopeKind, issueId, invocationSource: "automation", triggerDetail: "system",