Skip to content

fix(inbox): never offer an issue another live run already holds (BLO-19001) - #834

Merged
kkroo merged 3 commits into
masterfrom
blo-19001-inbox-lite-active-run-guard
Jul 30, 2026
Merged

fix(inbox): never offer an issue another live run already holds (BLO-19001)#834
kkroo merged 3 commits into
masterfrom
blo-19001-inbox-lite-active-run-guard

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Jul 30, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip manages AI agents, their issue queues, and the runtime workspaces those agents use to execute work.
  • The inbox-lite route is one of the paths an autonomous heartbeat run uses to self-select work.
  • Dispatch already avoids giving one issue to two live runs, but inbox-lite still returned issues held by another running sibling.
  • In shared workspaces, that collision can corrupt another run's checkout or dependencies while it is mid-task.
  • This PR extends the live-run hold invariant to inbox-lite and keeps the test harness from racing heartbeat cleanup.
  • The result is a narrower work selection surface and a less flaky server shard around plugin wake fan-out cleanup.

Linked Issues or Issue Description

Fixes BLO-19001.

What happened

Two heartbeat runs for the same agent could work one issue in one shared worktree. The second run selected the issue through inbox-lite while the first run still held it, then removed node_modules during recovery and damaged the active run's workspace.

Expected behavior

An issue held by a non-stale live run should not be offered to another autonomous run through inbox-lite. The route should still fail open when the caller does not send x-paperclip-run-id, because otherwise an agent could hide its own in-progress issue from itself.

Steps to reproduce

Start one agent run that holds an issue, then let a sibling autonomous heartbeat run call /agents/me/inbox-lite with its run id. Before this change the held issue could still be offered; after this change it is withheld unless the holder is stale or queued-only.

What Changed

  • Added a shared issue-run hold predicate so dispatch and inbox-lite use the same staleness threshold.
  • Applied the hold guard in inbox-lite and logged each withheld issue for diagnosis.
  • Exposed active-run timestamps needed by the route guard.
  • Added predicate tests and updated the Paperclip skill fallback guidance.
  • Stabilized the plugin invoke fan-out cleanup used by the failing server shard by threading test heartbeat options through buildHostServices, skipping queued dispatch during that test, retrying transient cleanup deadlocks, and relaxing a worker timeout.

Verification

  • pnpm install --frozen-lockfile
  • pnpm exec vitest run server/src/__tests__/plugin-agent-invoke-wake-fanout.test.ts server/src/__tests__/plugin-worker-manager.test.ts
  • pnpm --filter @paperclipai/server typecheck
  • git diff --check --cached
  • CI remains the authoritative full-suite signal for this branch.

Risks

The route behavior is deliberately fail-open when no run id is supplied, so legacy callers keep seeing work rather than stranding themselves. The main risk is suppressing an issue that appears held by a stale run; this uses the same 15 minute last-useful-action threshold as dispatch to avoid policy drift.

Model Used

Codex GPT-5 with local shell, GitHub CLI, and repository tests.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I searched GitHub for similar PRs and confirmed this is not a duplicate PR
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots
  • I have updated relevant documentation to reflect my changes
  • 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
  • I will address all Greptile and reviewer comments before requesting merge

…19001)

Dispatch enforces one-live-run-per-issue only for runs that already carry a
contextSnapshot.issueId (heartbeat.ts, "Per-issue dedupe"). An autonomous
heartbeat run carries none, so it is dispatched freely and then self-selects
its issue from GET /agents/me/inbox-lite -- which filtered only on companyId,
assignee and status. A second run of the same agent could therefore pick up an
issue a sibling run was mid-way through. Under a shared worktree both then edit
one tree: on BLO-18833 the duplicate ran 'rm -rf node_modules' as a routine
yarn-install retry and destroyed the owning run's installed tree mid-task.

Extend the existing invariant to the self-selection path: inbox-lite now
withholds any issue whose activeRun is a *different* live run.

Two constraints the guard has to respect:

- Reuse the dispatch gate's staleness metric rather than a naive
  status === "running", so a run whose Job died without a terminal status
  cannot hold its issue forever. The threshold now lives in one place and
  heartbeat.ts imports it, so the two cannot drift.
- Fail OPEN when the caller sent no x-paperclip-run-id. Failing closed would
  hide an agent's own in-progress issue from itself; it would read that as
  "no work" and exit, trading a rare collision for routine stranding.

Suppressed rather than flagged: a flag only helps if every agent honours it,
and in the observed incident an in-thread warning did not stop the next run
from selecting the same issue 8 minutes later. SKILL.md still gains the
$PAPERCLIP_RUN_ID vs executionRunId check as defence in depth for the
fallback issue-list path and stale-lock takeover.
@allyblockcast

allyblockcast Bot commented Jul 30, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18833
🔗 Paperclip issue: BLO-19001

1 similar comment
@allyblockcast

allyblockcast Bot commented Jul 30, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18833
🔗 Paperclip issue: BLO-19001

@allyblockcast

allyblockcast Bot commented Jul 30, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Jul 30, 2026

Copy link
Copy Markdown
Author

Field observation from a live run, offered as evidence rather than review — I'm the reporter on BLO-19001, not a reviewer here, and I have deliberately not touched this branch.

The window reproduced again today at 14:11Z, unprotected.

Issue BLO-18833 (assigned to UXDesigner, status in_review)
Holding run 5c8d1d9d-5653-44d4-9f7e-10c76b336997executionLockedAt 14:04:30Z, commented 14:09:58Z
Second run 67fea6fa-e37a-469c-b094-73b54eeccef0 — woke heartbeat_timer, no PAPERCLIP_TASK_ID, ~14:11Z
Workspace 2287c1c5-…, executionWorkspacePreference: reuse_existing (shared worktree)

Same agent, same issue, same worktree, 12 minutes apart — the exact precondition this PR guards. Since this PR is still OPEN / mergedAt: null, isIssueHeldByForeignRun was not in the path and cannot have been what prevented the collision.

inbox-lite did return [], so nothing was offered to me. I want to be careful not to let that read as "already fixed": a legitimately-assigned in_review issue of mine existed at that instant and simply wasn't listed, and the sibling incident BLO-18858 recorded an empty inbox-lite as part of a failure. So empty has shown up on both sides and isn't a reliable protection signal on its own.

What actually caught it was the agent-side check — cross-referencing the issue list for my own assignments and reading activeRun / executionLockedAt before starting. Which is to say the skills/paperclip/SKILL.md hunk in this PR is doing real work today, not just defence-in-depth for after the merge. That paragraph is the part I'd most want to keep as-written.

One possible test row. isIssueHeldByForeignRun keys off activeRun, and the unit tests cover run status (running / queued / terminal / silent). Both real incidents so far had the holding run on an issue in in_review, not in_progress. If issue status filters into the inbox-lite query path anywhere upstream of the guard, an in_review holder is the case to pin down — it's the one that has actually occurred twice.

No action needed from me; flagging mainly because the window is live in production and reproducing on a real shared worktree about daily.

@allyblockcast

allyblockcast Bot commented Jul 30, 2026

Copy link
Copy Markdown
Author

Reporter verification — BLO-19001 is my report; I'm the agent whose worktree got wiped. Not approving (not my PR, not a code owner here), and I have deliberately not touched this branch: a second agent committing into a worktree another live run owns is the exact failure this PR exists to prevent.

You flagged that the route wiring and typecheck aren't covered by the 23 assertions. That's precisely the half where a guard like this silently degrades into a no-op, so I checked the two load-bearing assumptions at 2d9e212d. Both hold.

1. activeRun really is hydrated on the rows inbox-lite filters. inbox-lite calls issuesSvc.list(...), which is issues.ts:5463 → the generic list path at :5624, and that does activeRunMapForIssues(db, withLabels)withActiveRuns(...). So issue.activeRun is populated rather than undefined. Worth stating because the failure mode is invisible: had it been undefined, isIssueHeldByForeignRun would return false for every row, the filter would pass everything through, and the logger.info you added would never fire — a placebo that looks identical to "no collisions happening."

2. req.actor.runId really is populated for agent callers. middleware/auth.ts:253 reads x-paperclip-run-id, and every agent auth branch sets runId: runAttribution?.id (:263, :311, :348, :387, :457, :481, :541). So the documented fail-open is the genuine exception, not the default path.

Your staleness reuse also looks safe from the query side: activeRunMapForIssues already constrains to ACTIVE_RUN_STATUSES (issues.ts:2083), so a finished holder resolves to null before the predicate sees it, and the status !== "running" arm covers a queued holder.

One scope note — not a change request. activeRun is keyed strictly off issue.executionRunId (map built from row.executionRunId at issues.ts:2062, attached at :2957). So the guard protects an issue only from the moment its holder acquires the execution lock (executionRunId: input.actorRunId, :5073/:5172/:5242; executionRunId: checkoutRunId, :8375+). A sibling reading inbox-lite in the window between the holder's own read and its lock acquisition still sees the issue unheld.

That window is narrow, and the two runs in my incident selected ~8 minutes apart — comfortably outside it — so this change would have prevented what happened to me. I'd still say it out loud in the module header, because it sets up the next question: BLO-18858 reported the checkout lock being ignored, and if checkout isn't authoritative then this guard is the only defence and its window is the whole story. The race is narrowed here, not closed.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 2d9e212

Critical Issues (1)

  • [gstack/review + native-codex] server/src/services/issue-run-holding.ts:69 — The 15-minute silence threshold can re-offer an issue while its original Kubernetes Job is still active and using the shared worktree. Existing lifecycle policy explicitly says the 15-minute floor is safe only for slot accounting and absent/terminal-job reaping; a live-but-quiet Job is not killed until the 45-minute hard-stale threshold because healthy runs can be quiet longer than 15 minutes. Between those thresholds, this predicate returns false and inbox-lite can select the same issue into a second run, recreating the destructive collision this PR is intended to prevent. Base issue holding on authoritative Job/process/reservation liveness, or conservatively keep a running owner holding until it is terminalized; add an integration test for a silent-but-active external Job.

Important Issues (1)

  • [pr-review-toolkit] skills/paperclip/SKILL.md:63 — The agent-side fallback contradicts the server's stale-release policy. The server offers a foreign run's issue after 15 minutes, but these instructions require ceding whenever the foreign activeRun.status remains running; checkout also only clears terminal/missing execution locks. A compliant autonomous run can therefore be offered work it must refuse or cannot claim, stranding it until reaping. Expose and consume one canonical holding/adoptability decision across inbox filtering, checkout, and the skill instead of interpreting raw status differently.

Suggestions (1)

  • [tests] server/src/__tests__/issue-run-holding.test.ts:70 — Add route-level coverage that exercises inbox-lite followed by checkout for fresh, silent-but-active, stale-terminal, and same-run owners. The predicate tests currently encode the disputed threshold but do not prove that an offered issue can be claimed safely.

Strengths

  • Centralizing the timestamp precedence and adding boundary tests reduces drift between consumers.
  • The route preserves same-run visibility and keeps queued runs from falsely holding issues.
  • Typecheck and build passed; the failing server shard is an unrelated PostgreSQL deadlock in plugin-agent-invoke-wake-fanout.test.ts.

Recommended Action

  1. Keep live external Jobs holding their issues until authoritative liveness says takeover is safe.
  2. Align inbox filtering, checkout adoption, and agent guidance around that same decision.
  3. Add an end-to-end regression test before merge.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Jul 30, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@kkroo

kkroo commented Jul 30, 2026

Copy link
Copy Markdown

@ally please re-review exact head 8c7e6ce

Addressed the issue/worktree ownership finding: a foreign running run now holds its issue until the run is terminalized/missing, independent of the 15-minute heartbeat silence window. Dispatch same-issue dedupe now uses all running rows while capacity accounting still uses the stale slot window.

Verification:

  • pnpm exec vitest run server/src/tests/issue-run-holding.test.ts server/src/tests/heartbeat-dispatch-priority-sort.test.ts server/src/tests/plugin-agent-invoke-wake-fanout.test.ts server/src/tests/plugin-worker-manager.test.ts
  • pnpm --filter @paperclipai/server typecheck
  • git diff --check
  • PR body gates: check-pr-template, check-pr-linked-issue, check-pr-dedup-search

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8c7e6ce

Prior Findings Dispositioned (2)

  • prior:2d9e212 critical 1 — fixed — server/src/services/issue-run-holding.ts:65 — Issue ownership now remains held for every running row regardless of heartbeat silence; terminalization or authoritative lifecycle cleanup is required before takeover.
  • prior:2d9e212 important 1 — fixed — skills/paperclip/SKILL.md:64 — The fallback guidance now uses the same conservative running-owner rule as inbox filtering and checkout rather than applying the 15-minute slot-accounting window.

Important Issues (1)

  • [pr-review-toolkit + native-codex] skills/paperclip/SKILL.md:61 — The new fallback says GET /api/issues/{issueId} returns both executionRunId and activeRun, but the exact-head route spreads issueService.getById() without active-run enrichment. It therefore returns executionRunId but not activeRun, leaving agents unable to evaluate the next line's required activeRun.status === "running" condition when the IDs differ. Either enrich the issue-detail response with the referenced active run or change the fallback to query a response that actually includes activeRun before deciding whether to cede.

Suggestions (1)

  • [tests] server/src/__tests__/issue-run-holding.test.ts:238 — Add route-level coverage for the documented fallback response shape so future changes prove that an agent can obtain both the owner ID and lifecycle status used by the skill.

Strengths

  • The revised ownership predicate cleanly separates issue/worktree safety from stale-slot capacity accounting.
  • Dispatch now deduplicates against all running rows while retaining the 15-minute window only for capacity.
  • Focused tests cover stale-looking running owners, queued owners, terminal owners, and same-run callers; build, policy, typecheck, and one server shard are currently green.

Recommended Action

  1. Make the fallback's active-run lookup executable as documented before merge.

@kkroo
kkroo merged commit 0f2fda5 into master Jul 30, 2026
19 checks passed
kkroo added a commit that referenced this pull request Jul 31, 2026
…ecutable (BLO-19001) (#843)

* fix(issues): return activeRun on issue detail so the cede check is executable (BLO-19001)

PR #834 told every agent to compare its own $PAPERCLIP_RUN_ID against the
issue's holder before touching a shared worktree, and to cede when the holder
is a different run that is still `running`. It pointed at
`GET /api/issues/{issueId}` for both halves of that comparison.

That route spreads `issueService.getById()`, which runs only `withIssueLabels`
— never `withActiveRuns`. So it returned `executionRunId` but no `activeRun`,
and the documented `activeRun.status === "running"` condition was not
evaluable. Confirmed against the live API before changing anything.

`executionRunId` alone cannot answer the question: a finished run leaves the
column set, so a non-matching id is not by itself a collision.

Adds `issueService.getActiveRun()` — the single-issue counterpart to the
`activeRun` the list paths already attach — and composes it into the
issue-detail route alongside its other enrichments. Same bounded row shape
`inbox-lite` returns; null when no run is recorded or the recorded run has
terminalized. Only queries when `executionRunId` is set.

Route-level tests exercise the real router against embedded Postgres for a
live holder, a silent-but-running holder, terminal runs, no run, and
identifier lookup — and feed each response through `isIssueHeldByForeignRun`
so the shape is proven sufficient to drive the documented decision.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: re-run PR template gate after filling in the required sections

The commitperclip gate fires on opened/synchronize/reopened only, so editing
the PR body cannot re-trigger it. No code change.

* fix(issues): scope issue active runs by company

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* test(issues): isolate goal context route mocks

Add the active-run mock required by the issue detail route and avoid leaking a one-shot workspace response into the heartbeat-context test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: CTO <cto@blockcast.network>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Omar Ramadan <omar@blockcast.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant