diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index f5632ecb178..238bf4b8b8a 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -29,6 +29,17 @@ An issue has at most one assignee. This is a hard invariant. Paperclip is single-assignee by design. +A legacy agent run can hand its own task to a human without cancelling itself. +The REST request must use the run's signed token and match the current agent, +company, task, and active run. The assignment and requested status persist while +the current turn finishes normally. This avoids losing the handoff when workspace +restoration takes longer than the stop timeout. The former agent no longer owns +the task. Its later task mutations are rejected, including under the update +transaction's task and run locks. Read and comment access follows normal policy. + +Board-initiated reassignments, agent API keys, native executions, and handoffs to +another agent still require the outgoing execution to stop before ownership changes. + ## 3. Status Semantics Paperclip issue statuses are not just UI labels. They imply different expectations about ownership and execution. diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index d0ccdbc978c..b3272d30a74 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -71,7 +71,12 @@ Agent access: `partial` — Native set_task_title/reassign_task/set_dependencies for permitted non-lifecycle fields; REST. Policy: Company/task visibility, assignment/change policy, active-run checkout ownership -and lifecycle rules. +and lifecycle rules. A legacy run can assign its own task to a human without +cancelling itself only with its signed run token and matching active run, agent, +company and task. The outgoing run cannot mutate its human-owned task afterward, +including through a late update already admitted before the handoff. Agent keys, +native runs, board reassignment and transfers to +another agent retain verified-stop requirements. Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the reviewed commit above. @@ -79,7 +84,10 @@ reviewed commit above. Verification: `not-run` — exercise a listed permitted operation with actual agent credentials/run context; inspect its result and applicable persistence/attribution. Assert the relevant denied actor, owner, company or mode from Policy. Review the -remaining operations before qualifying the whole capability. +remaining operations before qualifying the whole capability. Focused route +regressions in [ownership tests](../../server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts) +cover a human handoff and stop enforcement for other actors, runtimes and targets; +they use mocked services and do not claim live execution proof. Gaps: Native fallback blocks status/assignment/dependency policy fields. Dedicated tools differ in scope and supported fields. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 19eb631af3c..6ed9fd7f353 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -55,6 +55,13 @@ Manual: Filter by status and assignee, change grouping/sort, open a task, and re Automated: [task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts. +Manual: For a disposable task owned by a legacy agent, have its current run +assign the task to a human with status In review. Verify that assignment and +status persist, that the current run finishes without `issue_reassigned`, and +that slow workspace restoration does not turn the task Blocked. Try a later edit +from the outgoing run and verify rejection. Repeat a board +reassignment and confirm that the outgoing run still stops. + Manual: Add a comment and edit authorized properties. Reload and verify history, actor, and values. Complete a disposable task and inspect its deliverable; reopen through an available action and confirm the next execution follows current policy. ## Gotchas diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 5054ca63d49..6aa18723cba 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -325,6 +325,7 @@ function createRunContextDb( runAgentOrRows: string | Record[] = ownerAgentId, runId: string = ownerRunId, chatBindings: Array<{ id: string; companyId: string; issueId: string; state: string }> = [], + emptyWakeQueue = false, ) { const chatBindingQueries: ReturnType[] = []; const runRows = Array.isArray(runAgentOrRows) @@ -382,6 +383,9 @@ function createRunContextDb( transaction: async (callback: (tx: typeof dbStub) => Promise) => callback(dbStub), select: vi.fn((selection: Record = {}) => ({ from: vi.fn((table: Parameters[0]) => { + if (emptyWakeQueue && getTableName(table) === "agent_wakeup_requests") { + return buildQuery({}, false, true); + } if (getTableName(table) === "issue_thread_interactions") { return { where: vi.fn(() => ({ limit: vi.fn(async () => []) })) }; } @@ -2688,6 +2692,149 @@ describe("agent issue mutation checkout ownership", () => { expect(mockIssueService.update).not.toHaveBeenCalled(); }); + describe("human self-handoff", () => { + const runningRun = { + id: ownerRunId, + companyId, + agentId: ownerAgentId, + runtimeMode: "legacy", + status: "running", + contextSnapshot: { issueId }, + }; + const handoff = { + assigneeAgentId: null, + assigneeUserId: "board-user", + status: "in_review", + }; + const handoffApp = (actor: Record) => createApp(actor, + createRunContextDb({}, ownerAgentId, ownerRunId, [], true)); + + beforeEach(() => { + mockIssueService.getById.mockResolvedValue(makeIssue({ + status: "in_review", + executionRunId: ownerRunId, + })); + mockHeartbeatService.getRun.mockResolvedValue(runningRun); + mockHeartbeatService.cancelRun.mockResolvedValue({ ...runningRun, status: "cancelled" }); + mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, id: string) => ({ + ambiguous: false, + agent: await mockAgentService.getById(id), + })); + }); + + it.each(["in_progress", "in_review"])("persists a human handoff from %s without waiting for its own slow shutdown", async status => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status, executionRunId: ownerRunId })); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified.")); + + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send(handoff); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject(handoff); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({ + ...handoff, + actorRunId: ownerRunId, + actorRunStopId: null, + }); + expect(mockLogActivity.mock.calls.find(call => call[1].action === "issue.updated")?.[1]).toMatchObject({ + action: "issue.updated", + runId: ownerRunId, + details: handoff, + }); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("recognizes the caller when the active run is resolved without an issue execution lock", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review" })); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(runningRun); + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send(handoff); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject(handoff); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it.each(["edit", "complete", "reclaim"])("rejects the outgoing run's later %s after handing the task to a human", async action => { + const app = handoffApp({ ...ownerActor(), source: "agent_jwt" }); + const handedOff = await request(app).patch(`/api/issues/${issueId}`).send(handoff); + expect(handedOff.status, JSON.stringify(handedOff.body)).toBe(200); + mockIssueService.getById.mockResolvedValue(makeIssue(handoff)); + mockIssueService.update.mockClear(); + + const res = await request(app).patch(`/api/issues/${issueId}`).send( + action === "edit" ? { title: "Late outgoing edit" } : + action === "complete" ? { status: "done" } : + { assigneeAgentId: ownerAgentId, assigneeUserId: null }, + ); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.details).toMatchObject({ code: "issue_handed_off" }); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it("keeps a queued human response with its current agent", async () => { + const res = await request(createApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send(handoff); + expect(res.status, JSON.stringify(res.body)).toBe(409); + expect(res.body.code).toBe("interaction_response_queued"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it("retains assignment permission checks for a different human recipient", async () => { + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ + allowed: input.action !== "tasks:assign", + action: input.action, + reason: "deny_policy_restricted", + explanation: "Assignment requires approval.", + })); + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send({ ...handoff, assigneeUserId: "another-user" }); + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it.each(["board", "agent_key", "other_run", "native", "other_issue", "other_agent", "other_company"])( + "still requires a verified stop for %s reassignment", async scenario => { + let actor: Record = { ...ownerActor(), source: "agent_jwt" }; + if (scenario === "board") actor = boardActor(); + if (scenario === "agent_key") actor = ownerActor(); + const run = { + ...runningRun, + ...(scenario === "other_run" ? { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}), + ...(scenario === "native" ? { runtimeMode: "native" } : {}), + ...(scenario === "other_issue" ? { contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } } : {}), + ...(scenario === "other_agent" ? { agentId: peerAgentId } : {}), + ...(scenario === "other_company" ? { companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}), + }; + mockHeartbeatService.getRun.mockResolvedValue(run); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified.")); + + const res = await request(handoffApp(actor)).patch(`/api/issues/${issueId}`).send(handoff); + + expect(res.status, JSON.stringify(res.body)).toBe(500); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(run.id, "Cancelled before issue reassignment", + expect.objectContaining({ errorCode: "issue_reassigned" })); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }, + ); + + it.each(["agent", "unassigned"])("still stops the outgoing run before an %s handoff", async target => { + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send({ + assigneeAgentId: target === "agent" ? peerAgentId : null, + assigneeUserId: null, + }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.cancelRun.mock.invocationCallOrder[0]) + .toBeLessThan(mockIssueService.update.mock.invocationCallOrder[0]!); + }); + }); + describe("task watchdog scope grants", () => { const watchdogRunId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab"; const watchdogReportIssueId = "cccccccc-cccc-4ccc-8ccc-cccccccccccd"; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 4475e075122..5f3f8c58ae7 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -21,6 +21,7 @@ import { issueAccessGrants, issuePlanDecompositions, issueReadStates, + issueRecoveryActions, issueRelations, issueThreadInteractions, issueWorkProducts, @@ -46,6 +47,8 @@ import { readIssueCommentRunLogText, } from "../services/issues.ts"; import { getRunLogStore } from "../services/run-log-store.js"; +import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js"; +import { settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js"; import { WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, @@ -7048,6 +7051,7 @@ describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adopti await db.delete(goals); await db.delete(agents); await db.delete(instanceSettings); + await db.delete(companyMemberships); await db.delete(companies); }); @@ -7136,6 +7140,67 @@ describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adopti return { issueId, assigneeAgentId, actorAgentId, staleRunId, actorRunId }; } + it.each(["succeeded", "failed"])("preserves a human handoff when the outgoing legacy run later %s", async status => { + const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" }); + const original = await svc.getById(seeded.issueId); + await db.insert(companyMemberships).values({ + companyId: original!.companyId, + principalType: "user", + principalId: "handoff-reviewer", + status: "active", + }); + await db.update(heartbeatRuns).set({ + contextSnapshot: { issueId: seeded.issueId }, + }).where(eq(heartbeatRuns.id, seeded.actorRunId)); + await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId); + + const handoff = await svc.update(seeded.issueId, { + assigneeAgentId: null, + assigneeUserId: "handoff-reviewer", + status: "in_review", + actorAgentId: seeded.actorAgentId, + actorRunId: seeded.actorRunId, + }); + expect(handoff).toMatchObject({ + assigneeAgentId: null, + assigneeUserId: "handoff-reviewer", + status: "in_review", + checkoutRunId: null, + executionRunId: null, + }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.actorRunId)); + expect(run!.status).toBe("running"); + await expect(svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId)) + .rejects.toMatchObject({ status: 409 }); + await expect(svc.update(seeded.issueId, { + title: "Late outgoing edit", + actorAgentId: seeded.actorAgentId, + actorRunId: seeded.actorRunId, + })).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } }); + + await terminalizeLegacyExecution({ + db, + run: run!, + status, + fromStatuses: ["running"], + reconcileIfNeeded: true, + patch: { finishedAt: new Date(), ...(status === "failed" ? { errorCode: "process_lost" } : {}) }, + }); + await settleUnrecoverableExecutions(db); + await expect(svc.update(seeded.issueId, { + status: "done", + actorAgentId: seeded.actorAgentId, + actorRunId: seeded.actorRunId, + })).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } }); + expect(await svc.getById(seeded.issueId)).toMatchObject({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "handoff-reviewer", + }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId))) + .toHaveLength(0); + }); + it("lets the current assignee adopt a stale terminal checkout owner", async () => { const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" }); diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index bafcdfce5ac..8c952f63708 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -977,14 +977,19 @@ describeEmbeddedPostgres( const handoff = await request(directedAgent).patch(`/api/issues/${created.body.id}`) .send({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user", comment: "Ready for human review" }); expect(handoff.status, JSON.stringify(handoff.body)).toBe(200); - const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, directRun.id)); - expect(stopped).toMatchObject({ status: "cancelled", errorCode: "issue_reassigned" }); + expect(handoff.body).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user" }); + const [outgoing] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, directRun.id)); + expect(outgoing).toMatchObject({ status: "running", errorCode: null }); + const lateEdit = await request(directedAgent).patch(`/api/issues/${created.body.id}`) + .send({ description: "Late outgoing edit" }); + expect([403, 404]).toContain(lateEdit.status); const returned = await request(board).patch(`/api/issues/${created.body.id}`) .send({ status: "backlog", assigneeAgentId: fixture.agents.lowTrust.id, assigneeUserId: null }); expect(returned.status, JSON.stringify(returned.body)).toBe(200); - // Reassignment back does not revive the cancelled run's human request. - const staleRead = await request(directedAgent).get(`/api/issues/${created.body.id}`); - expect([403, 404]).toContain(staleRead.status); + const [humanRequest] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id)); + expect(humanRequest.status).toBe("cancelled"); + const reassignedRead = await request(directedAgent).get(`/api/issues/${created.body.id}`); + expect(reassignedRead.status, JSON.stringify(reassignedRead.body)).toBe(200); }); diff --git a/server/src/agent-run-cancellation.ts b/server/src/agent-run-cancellation.ts index ea38644de28..7ae25021fa5 100644 --- a/server/src/agent-run-cancellation.ts +++ b/server/src/agent-run-cancellation.ts @@ -19,7 +19,9 @@ export async function assertAgentRunWriteAllowed(tx: Db, companyId: string, acto stopId?: string | null; }) { if (!actor.agentId || !actor.runId) return; - const [run] = await tx.select({ status: heartbeatRuns.status, resultJson: heartbeatRuns.resultJson }) + const [run] = await tx.select({ status: heartbeatRuns.status, resultJson: heartbeatRuns.resultJson, + runtimeMode: heartbeatRuns.runtimeMode, companyId: heartbeatRuns.companyId, + contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actor.runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, actor.agentId))) .for("share"); @@ -28,4 +30,5 @@ export async function assertAgentRunWriteAllowed(tx: Db, companyId: string, acto if (agentRunWritesRevoked(run) && !stoppedForThisMutation) { throw forbidden("This run was cancelled", { code: "agent_run_cancelled" }); } + return run; } diff --git a/server/src/legacy-run-task-ownership.ts b/server/src/legacy-run-task-ownership.ts new file mode 100644 index 00000000000..2c9bfbf5b6a --- /dev/null +++ b/server/src/legacy-run-task-ownership.ts @@ -0,0 +1,24 @@ +import { forbidden } from "./errors.js"; + +export function assertLegacyRunTaskOwnership( + run: { + companyId: string; + runtimeMode: string; + contextSnapshot?: Record | null; + } | null | undefined, + issue: { + id: string; + companyId: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + }, +) { + if (run?.runtimeMode === "legacy" && run.companyId === issue.companyId && + run.contextSnapshot?.issueId === issue.id && + issue.assigneeAgentId === null && issue.assigneeUserId) { + throw forbidden("This task is assigned to a human; the outgoing run can no longer change it", { + code: "issue_handed_off", + issueId: issue.id, + }); + } +} diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3ecbbdab36f..fba3795594f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,4 +1,5 @@ import { parseObject } from "../adapters/utils.js"; +import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; import { enqueueFastResponse } from "../services/fast-responses.js"; import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js"; import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../services/native-runtime/native-workspace-finalization-recovery-state.js"; @@ -5632,6 +5633,11 @@ export function issueRoutes( res.status(403).json({ error: "Agent authentication required" }); return false; } + if (req.actor.source === "agent_jwt" && req.actor.runId && + issue.assigneeAgentId === null && issue.assigneeUserId) { + const run = await heartbeat.getRun(req.actor.runId); + if (run?.agentId === actorAgentId) assertLegacyRunTaskOwnership(run, issue); + } // Task-watchdog runs receive a scoped *grant* to mutate issues inside the // watched subtree. This must be evaluated before the base assignee-ownership // boundary below: that boundary denies an agent mutating an issue owned by a @@ -14241,7 +14247,18 @@ export function issueRoutes( agentId: existing.assigneeAgentId, }); const runToStopForReassignment = await resolveActiveIssueRun(existing); - if (runToStopForReassignment) { + const isHumanSelfHandoff = + req.actor.type === "agent" && + req.actor.source === "agent_jwt" && + nextAssigneeAgentId === null && + typeof nextAssigneeUserId === "string" && + req.actor.agentId === existing.assigneeAgentId && + runToStopForReassignment?.runtimeMode === "legacy" && + runToStopForReassignment.id === req.actor.runId && + runToStopForReassignment.agentId === req.actor.agentId && + runToStopForReassignment.companyId === existing.companyId && + runToStopForReassignment.contextSnapshot?.issueId === existing.id; + if (runToStopForReassignment && !isHumanSelfHandoff) { const cancelled = await heartbeat.cancelRun( runToStopForReassignment.id, "Cancelled before issue reassignment", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e34121a99fa..3149a777035 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -7,6 +7,7 @@ import { notifyChatPublicationWork } from "./chat-work-notifications.js"; import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js"; import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; +import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; import { retryIdempotentDatabaseOperation } from "../database-retry.js"; import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackConversation } from "./slack-conversation-state.js"; import { documentService } from "./documents.js"; @@ -11194,9 +11195,10 @@ export function issueService(db: Db) { if (actorAgentId && actorRunId) { // Recheck under a run lock: a request admitted before Stop must not // commit a late Done after cancellation revoked its credentials. - await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, { + const actorRun = await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, { agentId: actorAgentId, runId: actorRunId, stopId: actorRunStopId, }); + assertLegacyRunTaskOwnership(actorRun, receiptExisting); } if (actorAgentId && patch.status === "done") { const [review] = await tx.select({ id: toolActionRequests.id }).from(toolActionRequests).where(and(eq(toolActionRequests.companyId, existing.companyId), eq(toolActionRequests.issueId, id), inArray(toolActionRequests.status, ["pending", "approved", "executing"]))).limit(1);