From 7b09e3da73fdc7db0d78c8c70b8e52f0627bf1a6 Mon Sep 17 00:00:00 2001 From: Dylan Roy Date: Thu, 15 Oct 2026 17:34:23 -0400 Subject: [PATCH] fix: count writes from unscoped heartbeat runs against the cross-issue cap instead of rejecting them (#12621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents act inside heartbeat runs, and the server attributes every issue comment and task update to a run so the per-run cross-issue influence cap can be counted > - A timer heartbeat that wakes an agent with no checked-out issue stores no `issueId` in the run context snapshot > - The influence gate treats that missing source issue as "no valid run" and rejects every comment and PATCH from the run with `cross_issue_influence_run_context_required`, even though the run row exists and fully attributes the write > - Issue creation is not gated, so an unscoped run can open an issue it then cannot describe, update, or comment on > - This pull request counts writes from unscoped runs against the cross-issue cap instead of rejecting them > - The benefit is that unscoped heartbeat runs keep their write channels while the anti-spray cap and the audit trail stay fully intact ## Linked Issues or Issue Description No public issue exists. Description follows the bug report template. **What happened?** An agent woken by a plain heartbeat timer (no assigned issue) received a 403 with code `cross_issue_influence_run_context_required` on every `POST /api/issues/{id}/comments` and `PATCH /api/issues/{id}` request. The agent JWT was fresh and its `run_id` matched a live `heartbeat_runs` row. The error text said the request "arrived without a valid run", but the run was valid. `POST /api/companies/{companyId}/issues` succeeded with the same token, so the run created an issue it could not then update. **Expected behavior** A live, authenticated run must be able to comment and update issues. A run with no source issue has no same-issue exemption, so each of its writes must spend the per-run cross-issue cap. The `cross_issue_influence_run_context_required` error must fire only for malformed, unknown, or mismatched run ids. **Steps to reproduce** 1. Let an agent wake from the heartbeat timer with no checked-out issue. 2. From that run, call `PATCH /api/issues/{id}` or `POST /api/issues/{id}/comments` with the agent JWT. 3. The server returns 403 `cross_issue_influence_run_context_required`, because `observeCrossIssueInfluence` throws when `readRunSourceIssueId` returns null. **Paperclip version or commit** master (c7ebc089c); also reproduced on the current published npm package. **Deployment mode** Local single instance (embedded Postgres). **Installation method** npx paperclipai ## What Changed - `observeCrossIssueInfluence` no longer throws when the persisted run has no source issue. It counts the write as cross-issue influence and records `sourceIssueId: null` in the activity log entry. - The same-issue exemption applies only when the run has a source issue. - Replaced the test that pinned the old rejection with two tests: an unscoped run's write is counted and allowed, and an unscoped run is still capped once its budget is spent. ## Verification - `cd server && pnpm vitest run src/__tests__/cross-issue-influence-limit.test.ts` — 11 tests pass. - `cd server && pnpm vitest run src/__tests__/cross-issue-influence-limit-postgres.test.ts src/__tests__/interaction-resolution-cross-issue-cap-postgres.test.ts` — 8 tests pass. - Manual: wake an agent from a plain heartbeat timer, then PATCH any issue with the run's JWT. Before the fix the server returns 403 `cross_issue_influence_run_context_required`; after the fix the write succeeds and an `issue.cross_issue_influence_observed` activity row appears with `sourceIssueId: null`. ## Risks - Behavioral shift: writes from unscoped runs were rejected and are now allowed up to the existing per-run cap of 20. This is the conservative direction — with no source issue there is no same-issue exemption to abuse, and every write is still attributed to a locked run row and logged. - No schema or migration changes. The activity log `details.sourceIssueId` field can now be null; it was previously always set on these rows. ## Model Used - Claude Fable 5 (Anthropic, model id `claude-fable-5`, 1M context, extended thinking, tool use enabled) authored the change and tests, driven through Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 --- .../cross-issue-influence-limit.test.ts | 35 ++++++++++++++++--- .../services/cross-issue-influence-limit.ts | 11 ++++-- 2 files changed, 38 insertions(+), 8 deletions(-) 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; }