From 1ba79d82a5bc966492c7883719e0cddb66d24c88 Mon Sep 17 00:00:00 2001 From: Harsh Kotak Date: Tue, 21 Jul 2026 10:07:59 -0700 Subject: [PATCH] fix(server): preserve terminal status on issue release (#7524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4206 ## Thinking Path > - Paperclip orchestrates AI agents on issues with checkout/release semantics for execution locks > - `POST /api/issues/:id/release` clears checkout and execution locks when a heartbeat ends without finishing the issue > - `issues.release()` unconditionally set `status: "todo"`, undoing terminal and waiting states (`done`, `cancelled`, `in_review`, `blocked`) set during the session > - Agents reported status drift after release (e.g. `in_review` → `todo`, `done` → `todo`), forcing manual PATCH recovery and risking silent stalls > - This pull request gates the `todo` re-queue to `in_progress` issues only and preserves all other statuses on release > - The benefit is lock cleanup without destroying workflow state agents already recorded ## Linked Issues or Issue Description - Fixes #4206 — `issues.release()` must not downgrade terminal/waiting statuses - Related internal incident: AIT-114 status drift on terminal issue release (AI Trading Council) ## What Changed - `server/src/services/issues.ts` — `releaseStatus` is `todo` only when `existing.status === "in_progress"`; otherwise preserves `existing.status` - `server/src/__tests__/issue-stale-execution-lock-routes.test.ts` — regression tests: release preserves done, cancelled, in_review, blocked keeps `done` and clears lock fields - `server/package.json` — patch bump `0.3.1` → `0.3.2` - `server/CHANGELOG.md` — documents the fix ## Verification ```sh pnpm --filter @paperclipai/server test issue-stale-execution-lock-routes ``` - 7/7 tests pass (parametrized done, cancelled, in_review, blocked) (includes new `preserves terminal status when releasing a done issue` and existing `in_progress` → `todo` on release) - CI: Build, Typecheck, serialized server suites, e2e, Canary Dry Run green on latest head `f31b55f` ## Risks Low risk. Behaviour change is intentional: non-`in_progress` releases no longer force `todo`. Agents that relied on release to re-queue `in_review`/`blocked` work must PATCH status explicitly (documented in agent lifecycle guidance). Rollback: revert this commit and redeploy `@paperclipai/server` 0.3.1. ## Model Used Anthropic Claude Opus 4.6 (extended thinking mode) — 200K context window, tool use enabled. Assisted implementation and PR packaging for AI Trading Council upstream port from local hotfix. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (N/A) - [x] I have updated relevant documentation to reflect my changes (CHANGELOG) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (re-review requested on head `f31b55f`) - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Cursor Co-authored-by: brandon --- .../issue-stale-execution-lock-routes.test.ts | 53 +++++++++++++++++++ server/src/services/issues.ts | 4 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/issue-stale-execution-lock-routes.test.ts b/server/src/__tests__/issue-stale-execution-lock-routes.test.ts index 6f1a8ce4d7..f5a12a1f65 100644 --- a/server/src/__tests__/issue-stale-execution-lock-routes.test.ts +++ b/server/src/__tests__/issue-stale-execution-lock-routes.test.ts @@ -171,6 +171,59 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => { }); }); + it.each([ + { status: "done" as const, title: "Done release preserves status", completedAt: new Date() }, + { status: "cancelled" as const, title: "Cancelled release preserves status", cancelledAt: new Date() }, + { status: "in_review" as const, title: "In review release preserves status" }, + { status: "blocked" as const, title: "Blocked release preserves status" }, + ])( + "preserves $status when releasing a non-in_progress issue", + async ({ status, title, completedAt, cancelledAt }) => { + const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns(); + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, + companyId, + title, + status, + priority: "medium", + assigneeAgentId: agentId, + checkoutRunId: currentRunId, + executionRunId: currentRunId, + executionAgentNameKey: "codexcoder", + executionLockedAt: new Date(), + ...(completedAt ? { completedAt } : {}), + ...(cancelledAt ? { cancelledAt } : {}), + }); + + const res = await request(createApp(agentActor(companyId, agentId, currentRunId))) + .post(`/api/issues/${issueId}/release`) + .send(); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.status).toBe(status); + + const row = await db + .select({ + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + executionLockedAt: issues.executionLockedAt, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0]); + expect(row).toEqual({ + status, + assigneeAgentId: null, + checkoutRunId: null, + executionRunId: null, + executionLockedAt: null, + }); + }, + ); + it("allows the rightful assignee to release after the owning run failed", async () => { const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns(); const issueId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 1510608dee..e1281028a7 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -7158,10 +7158,12 @@ export function issueService(db: Db) { } } + // Release clears checkout/assignee locks; only in_progress work re-queues to todo. + const releaseStatus = existing.status === "in_progress" ? "todo" : existing.status; const updated = await tx .update(issues) .set({ - status: "todo", + status: releaseStatus, assigneeAgentId: null, checkoutRunId: null, executionRunId: null,