diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index d7a5fb7b866..2e4cdf6ddc0 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -1314,7 +1314,7 @@ export function registerIssueCommands(program: Command): void { addCommonClientOptions( issue .command("release") - .description("Release issue back to todo and clear assignee") + .description("Release issue execution locks; clear the assignee only for unfinished issues") .argument("", "Issue ID") .action(async (issueId: string, opts: BaseClientOptions) => { try { diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 12e000fea51..60105e3361e 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1097,6 +1097,12 @@ Server behavior: 2. if updated row count is 0, return `409` with current owner/status 3. successful checkout sets `assignee_agent_id`, `status = in_progress`, and `started_at` +`POST /issues/:issueId/release` clears checkout and execution locks. For terminal +issues (`done` or `cancelled`), it preserves the assignee, status, and disposition +timestamps, including on repeated release. For unfinished issues it clears the +agent assignee; only `in_progress` changes to `todo`. Existing company access, +assignee/run ownership checks, and the `issue.released` activity event still apply. + `POST /issues/:issueId/admin/force-release` is an operator recovery endpoint for stale harness locks. It requires board access to the issue company, clears checkout and execution run lock fields, and may clear the agent assignee when `clearAssignee=true` is passed. The route must write an `issue.admin_force_release` activity log entry containing the previous checkout and execution run IDs. ## 10.5 Projects diff --git a/doc/SPEC.md b/doc/SPEC.md index e69a6f1b8b0..8840bff89ea 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -414,6 +414,11 @@ Tasks use **single assignment** (one agent per task) with **atomic checkout**: No optimistic locking or CRDTs needed. The single-assignment model + atomic checkout prevents conflicts at the design level. +Releasing a terminal task clears execution locks while preserving its assigned +owner and final status. Assignment remains part of the work history after Done +or Cancelled. Releasing unfinished work still relinquishes the agent assignment; +only an active `in_progress` task returns to `todo`. + ### Human in the Loop Agents can create tasks assigned to humans. The board member (or any human with access) can complete these tasks through the UI. 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 ed2e39ed79a..bfb9c8c6092 100644 --- a/server/src/__tests__/issue-stale-execution-lock-routes.test.ts +++ b/server/src/__tests__/issue-stale-execution-lock-routes.test.ts @@ -175,13 +175,16 @@ 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: "done" as const, title: "Done release preserves owner", completedAt: new Date(), retainAssignee: true }, + { status: "cancelled" as const, title: "Cancelled release preserves owner", cancelledAt: new Date(), retainAssignee: true }, { status: "in_review" as const, title: "In review release preserves status" }, { status: "blocked" as const, title: "Blocked release preserves status" }, + { status: "backlog" as const, title: "Backlog release relinquishes ownership" }, + { status: "todo" as const, title: "Todo release relinquishes ownership" }, + { status: "in_progress" as const, title: "Active release requeues work", expectedStatus: "todo" }, ])( - "preserves $status when releasing a non-in_progress issue", - async ({ status, title, completedAt, cancelledAt }) => { + "$title and clears execution locks", + async ({ status, title, completedAt, cancelledAt, retainAssignee, expectedStatus = status }) => { const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns(); const issueId = randomUUID(); await db.insert(issues).values({ @@ -204,7 +207,8 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => { .send(); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(res.body.status).toBe(status); + const expectedAssignee = retainAssignee ? agentId : null; + expect(res.body).toMatchObject({ status: expectedStatus, assigneeAgentId: expectedAssignee }); const row = await db .select({ @@ -212,18 +216,40 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => { assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, executionLockedAt: issues.executionLockedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0]); expect(row).toEqual({ - status, - assigneeAgentId: null, + status: expectedStatus, + assigneeAgentId: expectedAssignee, checkoutRunId: null, executionRunId: null, + executionAgentNameKey: null, executionLockedAt: null, + completedAt: completedAt ?? null, + cancelledAt: cancelledAt ?? null, }); + + const events = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)); + expect(events).toContainEqual(expect.objectContaining({ + action: "issue.released", companyId, agentId, runId: currentRunId, + })); + + if (retainAssignee) { + // Repeated cleanup, including by the board, must not erase attribution. + const retried = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/release`) + .send(); + expect(retried.status, JSON.stringify(retried.body)).toBe(200); + expect(retried.body).toMatchObject({ status, assigneeAgentId: agentId, checkoutRunId: null, executionRunId: null }); + const [saved] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(saved).toMatchObject(row); + } }, ); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ceb13379f74..edaa977e16e 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -11787,14 +11787,16 @@ export function issueService(db: Db) { } } - // Release clears checkout/assignee locks; only in_progress work re-queues to todo. + // Terminal assignment records who owned the work, not a live execution + // claim. Cleanup must preserve it; unfinished release still relinquishes it. + const isTerminal = existing.status === "done" || existing.status === "cancelled"; const releaseStatus = existing.status === "in_progress" ? "todo" : existing.status; const updated = await tx .update(issues) .set({ status: releaseStatus, - assigneeAgentId: null, + assigneeAgentId: isTerminal ? existing.assigneeAgentId : null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 80ba78e3cf3..1641d0ebee1 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -1355,9 +1355,9 @@ Terminal states: `done`, `cancelled` - `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout. - `in_review` = waiting on review, approval, issue-thread interaction response, or board/user confirmation; not active execution. - `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker. -- `done` = completed. -- `cancelled` = intentionally abandoned. -- `in_progress` requires an assignee (use checkout). +- `done` = completed. Release clears execution locks but preserves the assignee and `completedAt`. +- `cancelled` = intentionally abandoned. Release clears execution locks but preserves the assignee and `cancelledAt`. +- `in_progress` requires an assignee (use checkout). Release returns it to `todo` and clears the agent assignee. - `started_at` is auto-set on `in_progress`. - `completed_at` is auto-set on `done`. - One assignee per task at a time. @@ -1418,7 +1418,7 @@ Terminal states: `done`, `cancelled` | POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) | | PATCH | `/api/issues/:issueId` | Update issue; response is authoritative and includes `changes` + `comment` (`Prefer: return=minimal` supported); `blockedByIssueIds` replaces blocker set | | POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. | -| POST | `/api/issues/:issueId/release` | Release task ownership | +| POST | `/api/issues/:issueId/release` | Release execution locks; preserve terminal task ownership | | GET | `/api/issues/:issueId/comments` | List comments | | GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | | POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |