{"record":{"id":"0ecb1a772adc90f4","repo":"gastownhall/beads","slug":"cannot-unclaim-closed-issue-s","errorCode":null,"errorMessage":"cannot unclaim closed issue %s","messagePattern":"cannot unclaim closed issue (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"internal/storage/issueops/unclaim.go","lineNumber":48,"sourceCode":"//   - Issue has no assignee (nothing to unclaim)\n//   - Issue is claimed by a different actor and force is false (ErrNotOwner)\n//\n//nolint:gosec // G201: table names come from WispTableRouting (hardcoded constants)\nfunc UnclaimIssueInTx(ctx context.Context, tx DBTX, id string, actor string, force bool) error {\n\t// Route to the correct table (issues/wisps) automatically, matching\n\t// ClaimIssueInTx — a wisp claim lives in the wisp tables, so its release\n\t// must update them too rather than no-op against the permanent issues table.\n\tisWisp := IsActiveWispInTx(ctx, tx, id)\n\tissueTable, _, eventTable, _ := WispTableRouting(isWisp)\n\n\toldIssue, err := GetIssueInTx(ctx, tx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get issue for unclaim: %w\", err)\n\t}\n\n\t// Validate: cannot unclaim closed issues\n\tif oldIssue.Status == types.StatusClosed {\n\t\treturn fmt.Errorf(\"cannot unclaim closed issue %s\", id)\n\t}\n\n\t// Validate: must have an assignee to unclaim\n\tif oldIssue.Assignee == \"\" {\n\t\treturn fmt.Errorf(\"issue %s is not assigned\", id)\n\t}\n\n\t// Validate ownership unless the caller forced the release. Without force, a\n\t// process may only release its own claim. Compared under actorMatches, not\n\t// verbatim, so a caller naming its own identity under a different layer's\n\t// spelling (ga-5ksp5) is not refused as a stranger.\n\tif !force && !actorMatches(oldIssue.Assignee, actor) {\n\t\treturn fmt.Errorf(\"%w: %s is held by %s; coordinate with the holder — pass --force only if their claim is abandoned (crashed agent, expired lease)\",\n\t\t\tstorage.ErrNotOwner, id, oldIssue.Assignee)\n\t}\n\n\tnow := time.Now().UTC()\n","sourceCodeStart":30,"sourceCodeEnd":66,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/unclaim.go#L30-L66","documentation":"UnclaimIssueInTx refuses to release an issue whose status is closed, returning this plain error. The library throws it because unclaiming is defined as clearing the assignee and resetting status to open; doing that to a closed issue would mutate finished work. Claim release and issue closure are separate lifecycle steps.","triggerScenarios":"Calling ReleaseIssueInTx/UnclaimIssueInTx on an issue with Status == types.StatusClosed, e.g. an agent that closed the issue on completion and then attempts to release its claim during cleanup.","commonSituations":"Double cleanup: close-on-completion plus a shutdown hook that releases claims; stale batch scripts releasing claims for already-closed issues.","solutions":["Skip the release when the issue is already closed — nothing needs unclaiming.","Check status first via GetIssue and only call Unclaim for open/in_progress issues.","If the claim must be cleared on a closed issue, deliberately reopen it, then unclaim.","Treat this error as benign/no-op in cleanup paths rather than failing shutdown."],"exampleFix":"// before\nif err := store.ReleaseIssue(ctx, id); err != nil { return err }\n// after\niss, err := store.GetIssue(ctx, id)\nif err != nil { return err }\nif iss.Status != types.StatusClosed {\n\tif err := store.ReleaseIssue(ctx, id); err != nil { return err }\n}","handlingStrategy":"validation","validationCode":"iss, err := store.GetIssue(ctx, id)\nif err != nil { return err }\nif iss.Status == types.StatusClosed {\n\treturn nil // nothing to unclaim\n}","typeGuard":null,"tryCatchPattern":"if err := store.ReleaseIssue(ctx, id); err != nil {\n\tif strings.Contains(err.Error(), \"cannot unclaim closed issue\") {\n\t\treturn nil // already closed; expected during cleanup\n\t}\n\treturn err\n}","preventionTips":["In agent shutdown hooks, check issue status before releasing claims.","Order cleanup: unclaim first, close last.","Make release paths idempotent and tolerant of already-closed issues."],"tags":["state-conflict","claim-management","lifecycle"],"backgroundTag":"invalid-state-transition","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}