diff --git a/server/src/__tests__/cross-issue-influence-limit.test.ts b/server/src/__tests__/cross-issue-influence-limit.test.ts index 551957c32ac..c4fff2fd10a 100644 --- a/server/src/__tests__/cross-issue-influence-limit.test.ts +++ b/server/src/__tests__/cross-issue-influence-limit.test.ts @@ -198,7 +198,11 @@ describe("cross-issue influence limit rollout", () => { expect(fake.inserted).toEqual([]); }); - it("fails closed when the persisted run has no source issue", async () => { + it("counts writes from an unscoped run instead of rejecting them", async () => { + // A timer heartbeat with no checked-out issue has no source issue: every + // write it makes is cross-issue by definition. The persisted run row is + // still the attribution anchor, so the write spends the cap rather than + // failing as run-less. const fake = counterDb(0, { contextSnapshot: {} }); await expect(observeCrossIssueInfluence(fake.db as never, { @@ -207,10 +211,31 @@ describe("cross-issue influence limit rollout", () => { agentId: "33333333-3333-4333-8333-333333333333", targetIssueId: "55555555-5555-4555-8555-555555555555", kind: "update", - })).rejects.toMatchObject({ - status: 403, - details: { code: "cross_issue_influence_run_context_required" }, + })).resolves.toMatchObject({ count: 1, allowed: true }); + expect(fake.inserted).toEqual([ + expect.objectContaining({ + action: "issue.cross_issue_influence_observed", + details: expect.objectContaining({ sourceIssueId: null }), + }), + ]); + }); + + it("still caps an unscoped run once its budget is spent", async () => { + const fake = counterDb(CROSS_ISSUE_INFLUENCE_LIMIT, { contextSnapshot: {} }); + + await expect(observeCrossIssueInfluence(fake.db as never, { + companyId: "22222222-2222-4222-8222-222222222222", + runId: "11111111-1111-4111-8111-111111111111", + agentId: "33333333-3333-4333-8333-333333333333", + targetIssueId: "55555555-5555-4555-8555-555555555555", + kind: "comment", + now: CROSS_ISSUE_INFLUENCE_ENFORCE_AT, + })).resolves.toMatchObject({ + allowed: false, + count: CROSS_ISSUE_INFLUENCE_LIMIT + 1, }); - expect(fake.inserted).toEqual([]); + expect(fake.inserted).toEqual([ + expect.objectContaining({ action: "issue.cross_issue_influence_cap_rejected" }), + ]); }); }); diff --git a/server/src/services/cross-issue-influence-limit.ts b/server/src/services/cross-issue-influence-limit.ts index c2c4319a73b..e1ec2c00244 100644 --- a/server/src/services/cross-issue-influence-limit.ts +++ b/server/src/services/cross-issue-influence-limit.ts @@ -109,11 +109,16 @@ export async function observeCrossIssueInfluence( throw crossIssueInfluenceRunContextError(); } + // An unscoped run (timer heartbeat with no checked-out issue) has no source + // issue, so every issue write it makes is cross-issue by definition and + // spends the cap. The locked run row above is the attribution anchor; + // rejecting these writes as run-less left live heartbeat runs unable to + // comment or update any task at all. const sourceIssueId = readRunSourceIssueId(run.contextSnapshot); - if (!sourceIssueId) throw crossIssueInfluenceRunContextError(); if ( - sourceIssueId === input.targetIssueId || - (input.targetIssueIdentifier && sourceIssueId.toUpperCase() === input.targetIssueIdentifier.toUpperCase()) + sourceIssueId !== null && + (sourceIssueId === input.targetIssueId || + (input.targetIssueIdentifier && sourceIssueId.toUpperCase() === input.targetIssueIdentifier.toUpperCase())) ) { return null; }