Skip to content

fix: allow agents to clear orphaned execution locks - #8792

Open
dmndbrp-oss wants to merge 10 commits into
paperclipai:masterfrom
dmndbrp-oss:fix/orphaned-execution-lock-release
Open

fix: allow agents to clear orphaned execution locks#8792
dmndbrp-oss wants to merge 10 commits into
paperclipai:masterfrom
dmndbrp-oss:fix/orphaned-execution-lock-release

Conversation

@dmndbrp-oss

Copy link
Copy Markdown
Contributor

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work.
  • Agent execution is coordinated through issue checkout and execution locks backed by heartbeat runs.
  • A worker process can die while its heartbeat run remains non-terminal, leaving the issue locked forever.
  • That stale lock blocks checkout, release, reassignment, and normal agent progress even when the owner is no longer alive.
  • This pull request adds TTL-aware dead-owner detection and schedules lock reaping when execution locks are acquired.
  • It also gives the current assignee a guarded force-release path that only clears provably dead owners.
  • The benefit is that orphaned execution locks can self-heal without allowing active live runs to be stolen.

Linked Issues or Issue Description

Refs #2533
Refs #6007
Refs #6399
Refs #7314
Refs #8502

Bug fix: issue execution locks can remain stuck after an agent run dies without writing a terminal heartbeat status.

What happened:

  • Issue mutation and release paths treated any non-terminal heartbeat run row as live forever.
  • Lock acquisition stamped executionLockedAt but did not consistently schedule the issue monitor to revisit the lock.
  • Assignee release could not clear a dead non-terminal owner, so operators needed a board/admin override.

Expected behavior:

  • Missing, terminal, or TTL-stale heartbeat owners should be treated as dead for stale-lock adoption and cleanup.
  • Fresh non-terminal owners should still be treated as live and return conflict responses.
  • The assigned agent should be able to request a guarded force release only when all lock owners are provably dead.

What Changed

  • Added a centralized execution lock acquisition helper that stamps executionRunId, executionLockedAt, monitorNextCheckAt, and monitorWakeRequestedAt together.
  • Made heartbeat-run liveness checks TTL-aware using heartbeatRuns.updatedAt for checkout adoption, release, and stale-lock cleanup.
  • Added a force:true release path for assigned agents and assignee managers that clears only missing, terminal, or TTL-stale lock owners.
  • Preserved the existing board-only admin force-release route as the unconditional override.
  • Updated heartbeat lock acquisition sites to use the central helper.
  • Updated stale issue lock recovery sweep logic to consider TTL-stale running heartbeat rows dead.
  • Added regression tests for reaper scheduling, stale owner force release, stale owner adoption, and fresh live-owner conflict behavior.

Verification

  • pnpm vitest run server/src/tests/issue-monitor-scheduler.test.ts server/src/tests/issue-stale-execution-lock-routes.test.ts server/src/tests/execution-lock-orphan-cleanup.test.ts
    • 3 files passed, 22 tests passed
  • pnpm --filter @paperclipai/server typecheck
    • passed
  • git diff --check
    • passed
  • pnpm vitest run server/src
    • 320 files passed, 2958 tests passed, 1 skipped

QA review requested:

  • QA Unit Tests: please verify the focused stale-lock route/recovery coverage and the full server suite result.
  • QA Integration: please verify the guarded release semantics around live vs TTL-stale owners.
  • CTO/design review: please review the control-plane lock/reaper behavior and the preservation of the board-only unconditional override.

Risks

  • Medium control-plane risk because this changes execution lock liveness behavior for running heartbeat rows that stop updating.
  • The live-owner safety invariant is covered: fresh non-terminal owners still return conflict and are not force-released or adopted away.
  • Existing explicit issue-monitor schedules are preserved by the helper instead of overwritten.
  • No migrations, dependency changes, production deploys, or shared infrastructure changes are included.

Model Used

OpenAI Codex GPT-5.5 through the Paperclip local agent harness, with repository file editing, shell command execution, and local verification tools enabled.

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 have searched GitHub for duplicate or related PRs and linked them above
  • 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (e.g. docs/..., fix/...) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • 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

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds TTL-aware dead-owner detection to the issue execution lock system and wires it into checkout acquisition, stale-lock adoption, the recovery sweeper, and a new guarded force-release route for the assigned agent. The approach is well-structured: a single executionLockAcquisitionFields helper centralizes the stamp, heartbeatRunIsDead centralizes the liveness decision, and lockHeartbeatRuns ensures the FOR UPDATE ordering is consistent.

  • The sweeper is the most structurally significant change — it moves from an optimistic bulk-read to per-issue serialized transactions with both issue-row and heartbeat-row FOR UPDATE locking, eliminating the TOCTOU window that was previously flagged.
  • The force-release route correctly gates on authorization boundary, assignee identity, and run-row FOR UPDATE before the TTL check, so a heartbeat refresh that arrives while the release is blocked cannot flip a live owner to appear dead.
  • Test coverage is solid: the new concurrent-refresh tests use a real outer transaction to hold the FOR UPDATE lock and assert the correct conflict outcome.

Confidence Score: 4/5

Safe to merge with the two minor comments addressed; the locking and TTL logic is correct and well-tested.

The sweeper's inline TTL check diverges from heartbeatRunIsDead without the defensive Date coercion — a minor inconsistency that would only surface if the DB driver returned a non-Date for updatedAt. All three previously-flagged concerns (monitor-scheduling CASE condition, sweeper TOCTOU, force-release TOCTOU) are either addressed in this PR or were already noted in earlier review threads.

server/src/services/recovery/service.ts (inline isCleanable diverges from heartbeatRunIsDead) and server/src/__tests__/execution-lock-acquisition-helper.test.ts (overly broad file exclusion in the enforcement regex).

Important Files Changed

Filename Overview
server/src/services/issues.ts Core lock logic: adds executionLockAcquisitionFields helper, heartbeatRunIsDead TTL check, lockHeartbeatRuns FOR UPDATE helper, and the guarded force release path. Logic is sound; one minor inconsistency noted in the sweeper (addressed in a separate file).
server/src/services/recovery/service.ts Sweeper moved into per-issue transactions with proper FOR UPDATE locking on both the issue row and heartbeat run rows. TTL-staleness detection reimplements the logic from heartbeatRunIsDead inline without the defensive Date coercion, creating a divergence from the canonical function.
server/src/routes/issues.ts Adds the force:true release route branch with proper agent-identity and authorization boundary guards before delegating to the service layer.
server/src/services/heartbeat.ts All eight execution lock acquisition sites replaced with executionLockAcquisitionFields(...) spread; mechanical and correct, also fixes a double-new Date() inconsistency in the legacy path.
server/src/tests/execution-lock-acquisition-helper.test.ts New structural-compliance test; the !file.endsWith("issues.ts") exclusion is broader than intended, silently skipping routes/issues.ts as well as the canonical helper file.
server/src/tests/issue-stale-execution-lock-routes.test.ts Good new route-level tests covering TTL-stale force-release, live-owner rejection, concurrent heartbeat-refresh protection, and TTL-stale checkout adoption.
server/src/tests/execution-lock-orphan-cleanup.test.ts Adds monitor-scheduling assertion for the checkout acquisition path; straightforward and correct.
server/src/tests/recovery-stale-issue-lock-sweep.test.ts New sweeper test correctly verifies the concurrent-heartbeat-refresh scenario using an outer transaction to hold the FOR UPDATE lock while the sweeper waits.
server/src/tests/heartbeat-dependency-scheduling.test.ts Minor teardown fix: adds issueComments delete to afterEach to avoid FK violations from comment seeds added by the new test cases.

Reviews (6): Last reviewed commit: "Merge master into orphaned execution loc..." | Re-trigger Greptile

Comment thread server/src/services/issues.ts Outdated
Comment on lines +431 to +440
monitorNextCheckAt: sql<Date | null>`case
when ${issues.monitorNextCheckAt} is null
and ${issues.monitorLastTriggeredAt} is null
and ${issues.monitorAttemptCount} = 0
then ${nextCheckAt.toISOString()}::timestamptz
else ${issues.monitorNextCheckAt}
end`,
monitorWakeRequestedAt: null,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Monitor check not re-scheduled on subsequent lock acquisitions

The CASE condition only sets monitorNextCheckAt when ALL three guards are true: monitorNextCheckAt IS NULL AND monitorLastTriggeredAt IS NULL AND monitorAttemptCount = 0. Since release() never clears the monitor fields (monitorLastTriggeredAt, monitorAttemptCount), any issue that has been through even one monitoring cycle will fall into the ELSE branch — preserving the current monitorNextCheckAt, which may be NULL after the previous monitor run completed and cleared it. The result is that lock acquisition on any previously-monitored issue silently skips scheduling the TTL reaper, leaving orphaned locks on that issue class with no monitor-driven cleanup trigger.

Concrete case: issue is assigned → monitor fires → lock is released → issue is reassigned and the worker dies → monitorLastTriggeredAt is non-null from cycle 1 → CASE falls to ELSE → monitorNextCheckAt remains NULL → monitor is never scheduled for the new orphaned lock.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/issues.ts
Line: 431-440

Comment:
**Monitor check not re-scheduled on subsequent lock acquisitions**

The CASE condition only sets `monitorNextCheckAt` when ALL three guards are true: `monitorNextCheckAt IS NULL AND monitorLastTriggeredAt IS NULL AND monitorAttemptCount = 0`. Since `release()` never clears the monitor fields (`monitorLastTriggeredAt`, `monitorAttemptCount`), any issue that has been through even one monitoring cycle will fall into the `ELSE` branch — preserving the current `monitorNextCheckAt`, which may be `NULL` after the previous monitor run completed and cleared it. The result is that lock acquisition on any previously-monitored issue silently skips scheduling the TTL reaper, leaving orphaned locks on that issue class with no monitor-driven cleanup trigger.

Concrete case: issue is assigned → monitor fires → lock is released → issue is reassigned and the worker dies → `monitorLastTriggeredAt` is non-null from cycle 1 → CASE falls to ELSE → `monitorNextCheckAt` remains `NULL` → monitor is never scheduled for the new orphaned lock.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread server/src/services/recovery/service.ts Outdated
@dmndbrp-oss
dmndbrp-oss force-pushed the fix/orphaned-execution-lock-release branch from c2485ee to b338e90 Compare July 3, 2026 13:12
Comment on lines +6218 to +6233
if (options.force && (existing.checkoutRunId || existing.executionRunId)) {
const [checkoutDead, executionDead] = await Promise.all([
existing.checkoutRunId ? isTerminalOrMissingHeartbeatRun(existing.checkoutRunId, tx) : Promise.resolve(true),
existing.executionRunId ? isTerminalOrMissingHeartbeatRun(existing.executionRunId, tx) : Promise.resolve(true),
]);
if (!checkoutDead || !executionDead) {
throw conflict("Issue run ownership conflict", {
issueId: existing.id,
status: existing.status,
assigneeAgentId: existing.assigneeAgentId,
checkoutRunId: existing.checkoutRunId,
executionRunId: existing.executionRunId,
actorAgentId: actorAgentId ?? null,
actorRunId: actorRunId ?? null,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Force-release TOCTOU: heartbeat run rows not locked before TTL read

The force-release path reads checkoutRunId/executionRunId liveness via isTerminalOrMissingHeartbeatRun without first acquiring a SELECT … FOR UPDATE lock on those heartbeat run rows. Every other liveness-gated path in this file (clearExecutionRunIfTerminal, clearCheckoutRunIfTerminal, adoptStaleCheckoutRun) locks the run row before reading it. Without that lock, a concurrent heartbeat posting a fresh updatedAt between the TTL check and the issue UPDATE can make a "running but stale-looking" owner appear dead: the force-release proceeds and clears its lock even though the run is now demonstrably live. Terminal status is monotonic so there is no race there, but TTL-staleness is ephemeral and makes the window meaningful. Add await tx.execute(sql\select … for update`)for each run ID before callingisTerminalOrMissingHeartbeatRun, mirroring the pattern used in clearCheckoutRunIfTerminal` (lines 4349–4361).

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/issues.ts
Line: 6218-6233

Comment:
**Force-release TOCTOU: heartbeat run rows not locked before TTL read**

The force-release path reads `checkoutRunId`/`executionRunId` liveness via `isTerminalOrMissingHeartbeatRun` without first acquiring a `SELECT … FOR UPDATE` lock on those heartbeat run rows. Every other liveness-gated path in this file (`clearExecutionRunIfTerminal`, `clearCheckoutRunIfTerminal`, `adoptStaleCheckoutRun`) locks the run row before reading it. Without that lock, a concurrent heartbeat posting a fresh `updatedAt` between the TTL check and the issue UPDATE can make a "running but stale-looking" owner appear dead: the force-release proceeds and clears its lock even though the run is now demonstrably live. Terminal status is monotonic so there is no race there, but TTL-staleness is ephemeral and makes the window meaningful. Add `await tx.execute(sql\`select … for update\`)` for each run ID before calling `isTerminalOrMissingHeartbeatRun`, mirroring the pattern used in `clearCheckoutRunIfTerminal` (lines 4349–4361).

How can I resolve this? If you propose a fix, please make it concise.

@dmndbrp-oss
dmndbrp-oss force-pushed the fix/orphaned-execution-lock-release branch from b338e90 to 50f9477 Compare July 3, 2026 13:21
@@ -6182,6 +6215,23 @@ export function issueService(db: Db) {
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing FOR UPDATE lock on heartbeat runs in force-release path

Force-release checks run liveness without SELECT … FOR UPDATE, unlike all other liveness-gated paths in the file.

Acquire row-level locks on heartbeat run rows before the TTL liveness check, mirroring clearCheckoutRunIfTerminal.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="server/src/services/issues.ts">
<violation number="1" location="server/src/services/issues.ts:6215">
<priority>P2</priority>
<title>Missing FOR UPDATE lock on heartbeat runs in force-release path</title>
<evidence>The new options.force block in release() calls isTerminalOrMissingHeartbeatRun for checkoutRunId and executionRunId without first locking the referenced heartbeat run rows via SELECT ... FOR UPDATE. Every other liveness-gated path in the same file acquires such a lock before reading run status. Because updatedAt-based TTL staleness is ephemeral, a concurrent heartbeat update between the liveness check and the issue UPDATE can make an actively-running owner appear dead, allowing the force-release to clear its lock.</evidence>
<recommendation>Add a SELECT ... FOR UPDATE lock on each referenced heartbeat run row before calling isTerminalOrMissingHeartbeatRun, matching the pattern used in clearCheckoutRunIfTerminal.</recommendation>
</violation>
</file>

@superagent-security superagent-security Bot added the pr:flagged Superagent flagged for security review label Jul 3, 2026
Co-Authored-By: Paperclip <noreply@paperclip.ing>
@dmndbrp-oss
dmndbrp-oss force-pushed the fix/orphaned-execution-lock-release branch from 50f9477 to 35dfd06 Compare July 4, 2026 07:23

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Superagent found 1 security concern(s).

Comment thread server/src/services/recovery/service.ts Outdated
const run = runById.get(runId);
if (!run) return true; // missing run row → no real claim
if (TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return true;
return run.updatedAt.getTime() <= Date.now() - ISSUE_EXECUTION_LOCK_TTL_MS;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 TOCTOU race in recovery sweeper TTL-stale lock detection

Sweeper uses stale heartbeat snapshot for TTL check, creating a race window where live runs can be incorrectly cleared.

Re-verify heartbeat updatedAt inside the update transaction or add a freshness guard to the WHERE clause.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="server/src/services/recovery/service.ts">
<violation number="1" location="server/src/services/recovery/service.ts:4421">
<priority>P2</priority>
<title>TOCTOU race in recovery sweeper TTL-stale lock detection</title>
<evidence>The recovery sweeper reads heartbeat run updatedAt values in a bulk query outside the per-issue update loop. It then uses this stale snapshot to decide if a run is TTL-stale via `run.updatedAt.getTime() &lt;= Date.now() - ISSUE_EXECUTION_LOCK_TTL_MS`. A run that posts a fresh heartbeat after the bulk read but before its issue row is updated will incorrectly pass the TTL check and have its lock swept, even though it is now demonstrably live. The UPDATE&apos;s WHERE clause only guards on issue-side columns (checkoutRunId, executionRunId) and does not verify the heartbeat run is still TTL-stale.</evidence>
<recommendation>Re-verify the heartbeat run&apos;s updatedAt inside the per-issue update transaction or add an updatedAt freshness guard to the UPDATE WHERE clause.</recommendation>
</violation>
</file>

@superagent-security superagent-security Bot removed the pr:flagged Superagent flagged for security review label Jul 18, 2026
Paperclip-Paperclip and others added 7 commits August 4, 2026 21:35
Pass `-q -` to hermes so the CLI reads the query from stdin instead of
receiving it as a command-line argument.  The previous invocation

  hermes chat -q "<full_prompt>"

exposed the entire rendered prompt — task body, wake context, issue
comments, agent instructions, and API guidance — to any local process that
can read /proc/<pid>/cmdline or run `ps aux` on the same host.

The fix passes the sentinel `-` and delivers the actual prompt via the
`stdin` option of runChildProcess, matching the pattern already used by
the claude-local adapter.

Adds execute.argv-security.test.ts with 5 focused regression tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ion-lock-release

# Conflicts:
#	server/src/services/heartbeat.ts
#	server/src/services/issues.ts
#	server/src/services/recovery/service.ts
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.

2 participants