mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A task that an agent run executes holds a checkout lock and an execution lock (`checkoutRunId`, `executionRunId`) > - A status change away from `in_progress` clears these locks. It does not stop the run that holds them > - Agents cannot stop another agent's run: run cancel and the task-tree pause are board-only > - But an agent that can edit a task (for example a lead with the manage-active-checkouts grant over its report) can change its status. The run then keeps running and is no longer bound to its task > - In chat evals, a lead "paused" a launch tree. It moved running subtasks to backlog. The runs kept running, detached > - This pull request refuses an agent's status change when a different queued or running run holds the task's lock. The denial is final, links the task, and names the person's controls > - The benefit is that agents cannot interrupt another agent's run through a status edit, and runs are not left orphaned ## Linked Issues or Issue Description Refs #20261 (chat guidance sends a task-tree pause to the person's Pause subtree control). Refs #19394 (resume restarts the work a subtree pause interrupted). **What happened** An agent changed the status of a task while another agent's run executed it. The request succeeded. The server cleared `checkoutRunId` and `executionRunId`. The run continued, but it was no longer bound to the task. In the chat evals `steering.stop-versus-pause` and `task-coordination.pause-tree`, a lead moved running subtasks to backlog to "pause" a launch. Their runs kept running, detached. **Expected behavior** An agent cannot get the effect of interrupting another agent's run. When a different live run holds the task's lock, the agent's status change is refused before any write. The refusal says it is final. It sends the person to the task and names the controls that really stop or hold the work: the run's Stop or Interrupt, or More task actions, then Pause subtree on the parent task. The lock-holding run, board users, and paths that stop the run themselves continue to work. **Steps to reproduce** 1. Create agent A and agent B, with B reporting to A (A gets `tasks:manage_active_checkouts` through the manager chain). 2. Give B an `in_progress` task. Set `checkoutRunId` and `executionRunId` to a `running` heartbeat run of B. 3. As A (agent JWT with its own run), send `PATCH /api/issues/:id` with `{ "status": "backlog" }`. 4. Before this change: `200`, the status is `backlog`, both lock fields are `null`, and B's run is still `running`. ## What Changed - `server/src/routes/issues.ts`: new `assertAgentStatusChangeKeepsLiveRunBound`. For an agent actor, it refuses a status change when `executionRunId` or `checkoutRunId` names a different run in the same company that is `queued` or `running`. It runs in `PATCH /issues/:id` after the final status and assignee are known (execution-policy transitions included) and before any run stop or write. It also runs in recovery resolution when the source status changes. - The guard applies to any status other than `in_progress`, also to the current status, because the service clears the locks for those too. - The write is conditional on the lock the guard checked. The route re-reads the lock and passes it to `svc.update` as `expectedExecutionLock`. The service compares it under the row lock and returns `409 issue_execution_lock_changed` if a checkout landed in between. - These cases stay allowed: the lock-holding run itself, board users, requests with no status, a change to `in_progress`, `cancelled` (the route cancels the run), reassignment (the route stops the run first), `done` through PATCH while a Runner goal is active (terminalization pauses the goal and stops the run; recovery resolution does not get this exception), and a lock holder that already finished. - `packages/shared/src/issue-write-denial.ts`: new denial code `issue_status_change_live_run` (409). The copy says the refusal is final, links the task as `[identifier](/prefix/issues/identifier)`, and names Stop or Interrupt and More task actions, then Pause subtree. A new optional `issuePath` context field carries the link. The UI notice renders it through the existing generic path. - `doc/SPEC-implementation.md`: documents the rule in the checkout contract section. - Paths checked with no change needed: there is no bulk issue update route; tree holds and run cancel are board-only; `POST /issues/:id/release` already lets only the assignee's checkout run release; API-catalog actions call the same PATCH route; MCP status changes already refuse with `MCP_ACTIVE_EXECUTION`. ## Verification - Red on master, green with this change: - `server/src/__tests__/issue-stale-execution-lock-routes.test.ts` (embedded Postgres, real issue service): a manager moves its report's running task to backlog. On master: `200`, status `backlog`, `checkoutRunId`/`executionRunId` changed to `null`. With the fix: `409 issue_status_change_live_run`, row unchanged, run still `running`. - `server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`: refusals for `backlog`, `todo`, `blocked`, `in_review`, `done`, a queued lock holder, and a repeated current status (red on master). Allowed cases: field edits, the lock-holding run, board, `cancelled` (run is cancelled), and finished lock holders; an allowed change carries `expectedExecutionLock`. - Same embedded-Postgres file: `svc.update` with an `expectedExecutionLock` that differs from the row returns `409 issue_execution_lock_changed` and leaves the row unchanged. - Also passed locally: `issue-closed-workspace-routes`, `issue-comment-reopen-routes`, `issue-recovery-actions`, `issue-scheduled-retry-routes`, `low-trust-red-team-routes`, `issue-update-comment-wakeup-routes`, `external-object-routes`, `packages/shared` `issue-write-denial`, `ui` `IssueWriteDenialNotice`. - `tsc --noEmit` shows no errors in the changed files. Full typecheck, build and test are left to CI. ## Risks - Behavior change for agents: a lead or CEO that used status edits to take over a report's running task now gets a 409. The supported paths (comment, cancel, reassign, or the person's Stop or Pause subtree) still work. - A queued run that holds the lock also blocks the change. This is deliberate: the queued run would otherwise start later with no task bound to it. - Agents can still cancel another agent's run by cancelling its task. That is existing, documented behavior ("task cancellation cancels the intent"), and this PR does not change it. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] 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 - [x] 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 - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
691 lines
23 KiB
TypeScript
691 lines
23 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
authUsers,
|
|
companyMemberships,
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueRelations,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { issueRoutes } from "../routes/issues.js";
|
|
import { issueService } from "../services/issues.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres stale execution lock route tests on this host: ${
|
|
embeddedPostgresSupport.reason ?? "unsupported environment"
|
|
}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("stale issue execution lock routes", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-stale-execution-lock-routes-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
await db.delete(authUsers);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
function createApp(actor: Express.Request["actor"]) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = actor;
|
|
next();
|
|
});
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
async function seedCompanyAgentAndRuns() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
const currentRunId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(authUsers).values({ id: "board-user", name: "Board", email: "board@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user", status: "active", membershipRole: "admin" });
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: failedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date(),
|
|
},
|
|
{
|
|
id: currentRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
return { companyId, agentId, failedRunId, currentRunId };
|
|
}
|
|
|
|
function agentActor(companyId: string, agentId: string, runId: string): Express.Request["actor"] {
|
|
return {
|
|
type: "agent",
|
|
agentId,
|
|
companyId,
|
|
runId,
|
|
source: "agent_jwt",
|
|
};
|
|
}
|
|
|
|
function boardActor(companyId: string): Express.Request["actor"] {
|
|
return {
|
|
type: "board",
|
|
userId: "board-user",
|
|
companyIds: [companyId],
|
|
memberships: [{ companyId, membershipRole: "admin", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
source: "session",
|
|
};
|
|
}
|
|
|
|
it("allows an assigned agent PATCH to recover a terminal stale executionRunId", async () => {
|
|
const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale execution lock",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: null,
|
|
executionRunId: failedRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
await db.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { issueId } })
|
|
.where(eq(heartbeatRuns.id, currentRunId));
|
|
|
|
const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
|
|
.patch(`/api/issues/${issueId}`)
|
|
.send({ title: "Recovered execution lock" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body.title).toBe("Recovered execution lock");
|
|
|
|
const row = await db
|
|
.select({
|
|
title: issues.title,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
title: "Recovered execution lock",
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: currentRunId,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ 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" },
|
|
])(
|
|
"$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({
|
|
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);
|
|
const expectedAssignee = retainAssignee ? agentId : null;
|
|
expect(res.body).toMatchObject({ status: expectedStatus, assigneeAgentId: expectedAssignee });
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
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: 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);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("allows the rightful assignee to release after the owning run failed", async () => {
|
|
const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Failed run release",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: failedRunId,
|
|
executionRunId: failedRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
|
|
const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
|
|
.post(`/api/issues/${issueId}/release`)
|
|
.send();
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
|
|
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: "todo",
|
|
assigneeAgentId: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionLockedAt: null,
|
|
});
|
|
});
|
|
|
|
it("lets the current assignee recover a timed_out stale checkout owner during PATCH", async () => {
|
|
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
|
|
const timedOutRunId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: timedOutRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "timed_out",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date(),
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale checkout lock",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: timedOutRunId,
|
|
executionRunId: timedOutRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
await db.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { issueId } })
|
|
.where(eq(heartbeatRuns.id, currentRunId));
|
|
|
|
const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
|
|
.patch(`/api/issues/${issueId}`)
|
|
.send({ title: "Recovered stale checkout lock" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
const row = await db
|
|
.select({
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: currentRunId,
|
|
});
|
|
});
|
|
|
|
it("still returns 409 when a different live checkout owner is active", async () => {
|
|
const { companyId, agentId, failedRunId } = await seedCompanyAgentAndRuns();
|
|
const liveOwnerRunId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: liveOwnerRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date(),
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Live checkout lock",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: liveOwnerRunId,
|
|
executionRunId: liveOwnerRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
|
|
const res = await request(createApp(agentActor(companyId, agentId, failedRunId)))
|
|
.patch(`/api/issues/${issueId}`)
|
|
.send({ title: "Should fail" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
|
expect(res.body?.error).toBe("Issue run ownership conflict");
|
|
});
|
|
|
|
it("preserves live checkout ownership on checkout conflicts without retry side effects", async () => {
|
|
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
|
|
const contenderRunId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: contenderRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "assignment",
|
|
startedAt: new Date(),
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Live checkout race",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: currentRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
|
|
const res = await request(createApp(agentActor(companyId, agentId, contenderRunId)))
|
|
.post(`/api/issues/${issueId}/checkout`)
|
|
.send({
|
|
agentId,
|
|
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
|
});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
|
expect(res.body).toMatchObject({
|
|
error: "Issue checkout conflict",
|
|
});
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: currentRunId,
|
|
});
|
|
|
|
const checkoutActivity = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.checked_out"));
|
|
expect(checkoutActivity).toHaveLength(0);
|
|
});
|
|
|
|
it("restricts admin force-release to board users with company access and writes an audit event", async () => {
|
|
const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Admin force release",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: failedRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
|
|
await request(createApp(agentActor(companyId, agentId, currentRunId)))
|
|
.post(`/api/issues/${issueId}/admin/force-release`)
|
|
.expect(403);
|
|
await request(createApp({
|
|
type: "board",
|
|
userId: "outside-user",
|
|
companyIds: [],
|
|
memberships: [],
|
|
isInstanceAdmin: false,
|
|
source: "session",
|
|
}))
|
|
.post(`/api/issues/${issueId}/admin/force-release`)
|
|
.expect(404);
|
|
|
|
const res = await request(createApp(boardActor(companyId)))
|
|
.post(`/api/issues/${issueId}/admin/force-release?clearAssignee=true`)
|
|
.send();
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body.issue).toMatchObject({
|
|
id: issueId,
|
|
assigneeAgentId: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionLockedAt: null,
|
|
});
|
|
expect(res.body.previous).toEqual({
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: failedRunId,
|
|
});
|
|
|
|
const audit = await db
|
|
.select({
|
|
action: activityLog.action,
|
|
actorType: activityLog.actorType,
|
|
actorId: activityLog.actorId,
|
|
details: activityLog.details,
|
|
})
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.admin_force_release"))
|
|
.then((rows) => rows[0]);
|
|
expect(audit).toMatchObject({
|
|
action: "issue.admin_force_release",
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
details: {
|
|
issueId,
|
|
actorUserId: "board-user",
|
|
prevCheckoutRunId: currentRunId,
|
|
prevExecutionRunId: failedRunId,
|
|
clearAssignee: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("self-heals a stale checkoutRunId via clearCheckoutRunIfTerminal on checkout (Fix B path)", async () => {
|
|
// Reproduces the recurrence pattern: prior owning run died, executionRunId
|
|
// was cleared by releaseIssueExecutionAndPromote, but checkoutRunId stayed
|
|
// pinned to the dead run. The new agent's POST /checkout would 409 forever
|
|
// without the clearCheckoutRunIfTerminal helper in svc.checkout.
|
|
const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns();
|
|
const issueId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: otherAgentId,
|
|
companyId,
|
|
name: "OtherAgent",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale checkout lock after reassignment",
|
|
// Status off in_progress + checkoutRunId still set — adoptStaleCheckoutRun
|
|
// cannot recover from this; only clearCheckoutRunIfTerminal can.
|
|
status: "todo",
|
|
priority: "high",
|
|
assigneeAgentId: otherAgentId,
|
|
checkoutRunId: failedRunId,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
});
|
|
|
|
const res = await request(createApp(agentActor(companyId, otherAgentId, currentRunId)))
|
|
.post(`/api/issues/${issueId}/checkout`)
|
|
.send({
|
|
agentId: otherAgentId,
|
|
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
|
});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
status: "in_progress",
|
|
assigneeAgentId: otherAgentId,
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: currentRunId,
|
|
});
|
|
});
|
|
|
|
it("keeps a report's live run bound when its manager tries to move the running task to backlog", async () => {
|
|
// A lead "pausing" work by editing statuses: before this guard the status
|
|
// change released the execution lock and left the report's run running,
|
|
// no longer bound to its task.
|
|
const { companyId, agentId: reportAgentId, currentRunId: reportRunId } = await seedCompanyAgentAndRuns();
|
|
const leadAgentId = randomUUID();
|
|
const leadRunId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: leadAgentId,
|
|
companyId,
|
|
name: "LaunchLead",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.update(agents).set({ reportsTo: leadAgentId }).where(eq(agents.id, reportAgentId));
|
|
await db.insert(heartbeatRuns).values({
|
|
id: leadRunId,
|
|
companyId,
|
|
agentId: leadAgentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date(),
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Build the launch website",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: reportAgentId,
|
|
checkoutRunId: reportRunId,
|
|
executionRunId: reportRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
await db.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { issueId } })
|
|
.where(eq(heartbeatRuns.id, reportRunId));
|
|
|
|
const res = await request(createApp(agentActor(companyId, leadAgentId, leadRunId)))
|
|
.patch(`/api/issues/${issueId}`)
|
|
.send({ status: "backlog" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
|
expect(res.body.details).toMatchObject({ code: "issue_status_change_live_run", runId: reportRunId });
|
|
expect(res.body.error).toContain("More task actions, then Pause subtree");
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
status: "in_progress",
|
|
assigneeAgentId: reportAgentId,
|
|
checkoutRunId: reportRunId,
|
|
executionRunId: reportRunId,
|
|
});
|
|
const run = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, reportRunId)).then((rows) => rows[0]);
|
|
expect(run?.status).toBe("running");
|
|
});
|
|
|
|
it("rejects a status change whose checked execution lock changed before the write", async () => {
|
|
// The route checks the lock, then a checkout lands before the write. The
|
|
// write must not release the newly acquired lock.
|
|
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Checked out after the check",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: currentRunId,
|
|
executionRunId: currentRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date(),
|
|
});
|
|
|
|
await expect(issueService(db).update(issueId, {
|
|
status: "backlog",
|
|
expectedExecutionLock: { executionRunId: null, checkoutRunId: null },
|
|
})).rejects.toMatchObject({ status: 409, details: { code: "issue_execution_lock_changed" } });
|
|
|
|
const row = await db
|
|
.select({ status: issues.status, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({ status: "in_progress", checkoutRunId: currentRunId, executionRunId: currentRunId });
|
|
|
|
// A lock that was released after the check (the route's own confirmed Stop)
|
|
// does not block the write.
|
|
await db.update(issues).set({ checkoutRunId: null, executionRunId: null }).where(eq(issues.id, issueId));
|
|
const updated = await issueService(db).update(issueId, {
|
|
status: "backlog",
|
|
expectedExecutionLock: { executionRunId: currentRunId, checkoutRunId: currentRunId },
|
|
});
|
|
expect(updated?.status).toBe("backlog");
|
|
});
|
|
});
|