fix: Compare native finaliser lease expiry at millisecond precision when releasing an expired owner (#17214)

> [!NOTE]
> Opened by Prospector's automated issue-fix pipeline. A maintainer
reviews and decides; nothing here merges on its own.

## Thinking Path

- Issue #16715 reports a defect in paperclipai/paperclip.
- Root cause, as the fixing agent read it: Lease renewal writes
'lease_expires_at' from the database clock ('now() + interval'), so the
stored value has microseconds. 'agentExecutionsHaveStopped' reads that
value into a millisecond JS 'Date' and then releases the row with an
exact 'eq' on it. The update never matches, so the stop check returns
false on every tick and the agent stays in 'pausing'.
- This pull request: Compare native finaliser lease expiry at
millisecond precision when releasing an expired owner

## What Changed

- `server/src/services/agent-execution-stop.ts`: The release update now
truncates the stored lease expiry to milliseconds before comparing it
with the value it read, using the same 'date_trunc('milliseconds', ...)
= ...::timestamptz' pattern as 'chat-channels/activity.ts'. The other
guards stay the same: owner, generation, phase and the null result. This
was the only exact-match comparison on a lease expiry in 'server/src'.

## Verification

- Reproducing test(s), added in this PR:
`server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts`
- They fail on the base commit and pass with the fix, each twice in a
fresh sandbox.
- 3 independent attempts wrote 3 reproductions; this fix passes every
one of them.
- The repository's compile command passes with the fix applied.
- The full test suite passes except for the 10 file(s) that already fail
on the base commit.

## Risks

- Written by an automated pipeline, not a person: review it as you would
an outside contribution.
- Risk tier of the touched paths: 2.
- The scope reviewer found it also changes, beyond the report:
agentExecutionsHaveStopped for an expired finaliser row whose
lease_expires_at is already millisecond-precise (written from a JS Date)
- The scope reviewer found it also changes, beyond the report: Finaliser
row whose lease was renewed or taken over between the select and the
update
- The scope reviewer found it also changes, beyond the report: Rows with
null leaseExpiresAt

## Model Used

- codex:gpt-6.1-sol, opus, sonnet (Anthropic Claude, via Claude Code;
OpenAI, via the Codex CLI), writing and checking the change in an
isolated sandbox.
- opus (Anthropic Claude, via Claude Code), reviewing it independently.

## Linked Issues

Fixes #16715

Other pull requests that name #16715:
- #17204 (open): fix(heartbeat): stop loading full run rows and stale
pids when pausing an agent
- #16745 (open): fix(agent-lifecycle): release an expired native
finalizer lease at millisecond precision

## 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)
- [ ] 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)
- [ ] 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
- [ ] 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge

<!-- prospector:issue-fix v1 issue=16715 base=c7ea5c0216b8
report=1a4e13ecd7c6cdb6 -->

Co-authored-by: commitperclip[bot] <284812584+commitperclip[bot]@users.noreply.github.com>
This commit is contained in:
commitperclip[bot] authored and GitHub committed 2026-11-02 15:01:52 -08:00
1 parent 554a770c6f
commit fd0d61b8e3
2 files changed
+16 -1

No files matched your search

@@ -414,6 +414,21 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)))[0]).toMatchObject({ leaseOwner: null, leaseExpiresAt: null });
});
it("releases an expired native owner whose renewed lease expiry has microsecond precision", async () => {
const agent = await hire();
const [issue] = await db.insert(issues).values({ companyId, title: "Renewed native run" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, nativeIssueId: issue.id,
status: "cancelled", invocationSource: "on_demand", runtimeMode: "native",
startedAt: new Date(), finishedAt: new Date(), processPid: 2_000_000_000 }).returning();
await db.insert(nativeRunFinalizations).values({ companyId, issueId: issue.id, runId: run.id,
phase: "executing", leaseOwner: "stopped-server", leaseExpiresAt: new Date(0) });
await db.update(nativeRunFinalizations)
.set({ leaseExpiresAt: sql`'2020-01-01T00:00:00.123456Z'::timestamptz` })
.where(eq(nativeRunFinalizations.runId, run.id));
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(true);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)))[0]).toMatchObject({ leaseOwner: null, leaseExpiresAt: null });
});
it("does not advance termination while a cancelled process still runs", async () => {
const agent = await hire();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id,
+1 -1
View File
@@ -64,7 +64,7 @@ export async function agentExecutionsHaveStopped(db: Db, agentIds: string[]) {
const [cleared] = await db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null, updatedAt: now })
.where(and(eq(nativeRunFinalizations.runId, coordinator.runId),
eq(nativeRunFinalizations.leaseOwner, coordinator.leaseOwner!),
eq(nativeRunFinalizations.leaseExpiresAt, coordinator.leaseExpiresAt!),
sql`date_trunc('milliseconds', ${nativeRunFinalizations.leaseExpiresAt}) = ${coordinator.leaseExpiresAt!.toISOString()}::timestamptz`,
eq(nativeRunFinalizations.controllerGeneration, coordinator.controllerGeneration),
eq(nativeRunFinalizations.phase, coordinator.phase), isNull(nativeRunFinalizations.resultId)))
.returning({ runId: nativeRunFinalizations.runId });