mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## Thinking Path > - Paperclip manages AI agents and their tasks. > - A task can outlive a provider process or a server restart. > - Legacy recovery treated unknown tool outcomes as a permanent execution hold. > - That hold could also reject a later user message. > - A conversation turn can use prior history without replaying prior tool calls. > - This pull request lets supported conversation adapters continue within the existing retry budget. > - Users can send a new message after automatic attempts stop. ## Linked Issues or Issue Description **What happened?** A server restart could interrupt a local ACP run and leave its task behind a permanent recovery hold. A later user message could be cancelled before the provider answered. The immediate recovery path could also create a successor outside the durable failure counter. **Expected behavior** Continue with a bounded new conversation turn. Preserve a compatible provider session or use full task context when it is unavailable. Do not replay recorded tools. When automatic attempts stop, allow a new user request through the normal execution gates. **Steps to reproduce** 1. Start a task with a local conversation adapter. 2. Restart the server while the provider is working. 3. Let the previous run become interrupted. 4. Send a follow-up message and observe the recovery hold on the old behavior. Related work: Refs #13075 for durable task recovery. Refs #12946 for retry-limit and checkout-lock handling. This change routes conversation recovery through the existing bounded scheduler. ## What Changed - Mark supported local conversation failures for continuation. Keep native-runner and non-conversation recovery rules. - Carry an interruption notice into the next turn. Retain stopped ACP session history even when a write outcome is unknown. - Clear unavailable ACP sessions so the next bounded attempt can use full task context. - Route immediate failure recovery through the same durable scheduler as process-loss recovery. Release only the predecessor checkout when its retry takes ownership. - Retire obsolete conversation holds using immutable run evidence, in bounded batches with an activity record. Preserve outcome evidence and do not wake historical tasks. - Block actual admission and Resume while a predecessor process or environment lease is still active. Keep the original interruption notice after a rejected wake. Preserve the upstream blocked-wake waiting contract: bounded retry planning can happen during cleanup, while deferred messages and execution remain gated. - Add subprocess and database regression tests. Update the execution contract. - Add the current thread-status field to the native recovery provider fixture so its damaged-journal test reaches the intended boundary. Tolerate an already-exited fixture process during test cleanup while still asserting both processes terminate. ## Verification - Workspace typecheck passed: `pnpm -r typecheck`. - Build passed: `pnpm build`. - Module boundaries passed: `pnpm check:module-boundaries`. - Focused tests passed: 293 recovery/session/dispatch tests, 66 retry and response-gate tests, and 37 native-session tests. Some suites overlap. - Tests cover interrupted writes, missing sessions, concurrent retries, restart persistence, pending questions and approvals, execution gates, and historical holds. - Built the Rust test executables with `pnpm --filter @paperclipai/paperclip-runner build:rust` for native-runner verification. - Full Vitest coverage verified locally using the repository’s general and serialized shards, with focused reruns for failures and files not reached after a shard stopped. The ownership-gate regression is fixed and the complete affected server shard passes (1,390 tests). Local parallel runs also hit temporary-directory, resource, and timing failures; those suites pass with canonical temporary paths and sequential reruns. No test timeouts were increased. - Final merged-branch regression run: 577 tests pass across process recovery, retry scheduling, liveness, durable chat, wake-queue application/adapter, dispatch, continuation, native sessions, and task chat. Earlier focused verification also passed 19 native control tests. Token gates and whitespace validation pass. - Browser verification passed all three ACP Stop/continue/pause scenarios, including a rerun after merging the upstream waiting behavior: `PAPERCLIP_E2E_PORT=3397 pnpm test:e2e tests/e2e/acp-stop-continuation.spec.ts`. The interrupted-write case verifies that follow-up completes without a repeated write. - Final-head [CI run 34625037394](https://github.com/paperclipai/paperclip/actions/runs/34625037394) passed on `06ac4bd9d150f8b209a96e5fd609c696958794a0`: all 31 reported checks are green, including server/workspace suites, all browser shards, native runner verification, build, typecheck, release dry run, and aggregate gates. The two conditional Storybook checks were skipped. Greptile reviewed this exact commit at 5/5; all review threads are resolved. ## Risks - A new model turn can choose to repeat an action. Paperclip does not replay recorded tool calls and does not certify unknown action outcomes. - Conversation adapters now stop after their retry budget instead of requiring action reconciliation. Explicit Stop, pause, dependency, approval, budget, and ownership gates remain in force. - No schema migration or dependency changes. Historical holds are folded without changing task status or waking work. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and test execution. The session does not expose a more specific model build ID or context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import express from "express";
|
|
import { PgDialect } from "drizzle-orm/pg-core";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockTreeControlService = vi.hoisted(() => ({
|
|
preview: vi.fn(),
|
|
createHold: vi.fn(),
|
|
cancelIssueStatusesForHold: vi.fn(),
|
|
restoreIssueStatusesForHold: vi.fn(),
|
|
getHold: vi.fn(),
|
|
releaseHold: vi.fn(),
|
|
cancelUnclaimedWakeupsForTree: vi.fn(),
|
|
}));
|
|
|
|
const mockReplayBlocks = vi.hoisted(() => vi.fn());
|
|
const mockReplayWhere = vi.hoisted(() => vi.fn());
|
|
const mockExecutionBlocker = vi.hoisted(() => vi.fn());
|
|
vi.mock("../services/execution-blocker.js", () => ({ getExecutionBlocker: mockExecutionBlocker }));
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
const mockHeartbeatService = vi.hoisted(() => ({
|
|
cancelRun: vi.fn(),
|
|
wakeup: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
heartbeatService: () => mockHeartbeatService,
|
|
issueService: () => mockIssueService,
|
|
issueTreeControlService: () => mockTreeControlService,
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
async function createApp(actor: Record<string, unknown>) {
|
|
const [{ errorHandler }, { issueTreeControlRoutes }] = await Promise.all([
|
|
import("../middleware/index.js"),
|
|
import("../routes/issue-tree-control.js"),
|
|
]);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = actor;
|
|
next();
|
|
});
|
|
const query = {
|
|
from: () => query,
|
|
innerJoin: () => query,
|
|
where: (predicate: unknown) => { mockReplayWhere(predicate); return mockReplayBlocks(); },
|
|
limit: mockReplayBlocks,
|
|
};
|
|
app.use("/api", issueTreeControlRoutes({ select: () => query } as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describe("issue tree control routes", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockReplayBlocks.mockResolvedValue([]);
|
|
mockExecutionBlocker.mockResolvedValue(null);
|
|
mockTreeControlService.getHold.mockResolvedValue(null);
|
|
mockIssueService.getById.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-2",
|
|
});
|
|
mockTreeControlService.cancelUnclaimedWakeupsForTree.mockResolvedValue([]);
|
|
mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({
|
|
updatedIssueIds: [],
|
|
updatedIssues: [],
|
|
});
|
|
mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({
|
|
updatedIssueIds: [],
|
|
updatedIssues: [],
|
|
releasedCancelHoldIds: [],
|
|
restoreHold: null,
|
|
});
|
|
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
|
mockHeartbeatService.wakeup.mockResolvedValue(null);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"only wakes eligible current assignees when resume requests wakeAgents=%s",
|
|
async (wakeAgents) => {
|
|
const rootId = "11111111-1111-4111-8111-111111111111";
|
|
const holdId = "33333333-3333-4333-8333-333333333333";
|
|
const root = {
|
|
id: rootId,
|
|
companyId: "company-2",
|
|
status: "todo",
|
|
assigneeAgentId: "agent-parent",
|
|
};
|
|
const issues = [
|
|
root,
|
|
{
|
|
id: "child",
|
|
companyId: "company-2",
|
|
status: "in_progress",
|
|
assigneeAgentId: "agent-child",
|
|
},
|
|
{
|
|
id: "backlog-task",
|
|
companyId: "company-2",
|
|
status: "backlog",
|
|
assigneeAgentId: "parked-agent",
|
|
},
|
|
{
|
|
id: "blocked-task",
|
|
companyId: "company-2",
|
|
status: "blocked",
|
|
assigneeAgentId: "blocked-agent",
|
|
},
|
|
{
|
|
id: "done",
|
|
companyId: "company-2",
|
|
status: "done",
|
|
assigneeAgentId: "agent-done",
|
|
},
|
|
{
|
|
id: "cancelled",
|
|
companyId: "company-2",
|
|
status: "cancelled",
|
|
assigneeAgentId: "agent-cancelled",
|
|
},
|
|
{
|
|
id: "foreign",
|
|
companyId: "company-3",
|
|
status: "todo",
|
|
assigneeAgentId: "agent-foreign",
|
|
},
|
|
];
|
|
mockIssueService.getById.mockImplementation(async (id: string) =>
|
|
issues.find((issue) => issue.id === id),
|
|
);
|
|
mockTreeControlService.releaseHold.mockResolvedValue({
|
|
id: holdId,
|
|
mode: "pause",
|
|
status: "released",
|
|
members: issues.map((issue) => ({ issueId: issue.id })),
|
|
});
|
|
mockTreeControlService.getHold.mockResolvedValue({
|
|
id: holdId,
|
|
rootIssueId: rootId,
|
|
mode: "pause",
|
|
members: issues.map((issue) => ({ issueId: issue.id })),
|
|
});
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
const response = await request(app)
|
|
.post(`/api/issues/${rootId}/tree-holds/${holdId}/release`)
|
|
.send({ metadata: { wakeAgents } });
|
|
expect(response.status).toBe(200);
|
|
expect(mockHeartbeatService.wakeup.mock.calls.map(([id]) => id)).toEqual(
|
|
wakeAgents ? ["agent-parent", "agent-child"] : [],
|
|
);
|
|
if (wakeAgents) {
|
|
const { params } = new PgDialect().sqlToQuery(mockReplayWhere.mock.calls[0]![0]);
|
|
expect(params).toEqual(expect.arrayContaining(["todo", "in_progress", "in_review"]));
|
|
expect(params).not.toContain("blocked");
|
|
expect(params).not.toContain("backlog");
|
|
}
|
|
if (wakeAgents)
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
"agent-child",
|
|
expect.objectContaining({
|
|
reason: "issue_tree_resumed",
|
|
contextSnapshot: expect.objectContaining({
|
|
issueId: "child",
|
|
holdId,
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ action: "issue.tree_hold_released" }),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("reports wake failures without undoing release or skipping other assignees", async () => {
|
|
const rootId = "11111111-1111-4111-8111-111111111111";
|
|
const holdId = "33333333-3333-4333-8333-333333333333";
|
|
mockTreeControlService.releaseHold.mockResolvedValue({
|
|
id: holdId,
|
|
mode: "pause",
|
|
status: "released",
|
|
members: [{ issueId: rootId }, { issueId: "child" }],
|
|
});
|
|
mockIssueService.getById.mockImplementation(async (id) => ({
|
|
id,
|
|
companyId: "company-2",
|
|
status: "todo",
|
|
assigneeAgentId: id,
|
|
}));
|
|
mockHeartbeatService.wakeup.mockRejectedValueOnce(
|
|
new Error("Agent unavailable"),
|
|
);
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
const response = await request(app)
|
|
.post(`/api/issues/${rootId}/tree-holds/${holdId}/release`)
|
|
.send({ metadata: { wakeAgents: true } });
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
status: "released",
|
|
wakeFailures: [{ issueId: rootId, message: "Agent unavailable" }],
|
|
});
|
|
expect(
|
|
mockHeartbeatService.wakeup.mock.calls.map(([agent]) => agent),
|
|
).toEqual([rootId, "child"]);
|
|
});
|
|
|
|
it("keeps the hold active when resume requests an unsafe execution replay", async () => {
|
|
const rootId = "11111111-1111-4111-8111-111111111111";
|
|
const holdId = "33333333-3333-4333-8333-333333333333";
|
|
mockTreeControlService.getHold.mockResolvedValue({
|
|
id: holdId,
|
|
rootIssueId: rootId,
|
|
mode: "pause",
|
|
members: [{ issueId: rootId }],
|
|
});
|
|
mockReplayBlocks.mockResolvedValue([{ id: rootId, identifier: "TEST-1" }]);
|
|
mockExecutionBlocker.mockResolvedValue({ nextAction: "Resume without waking agents until the previous execution stops.",
|
|
recoveryActionId: null, cause: "execution_owner_active", runId: "previous-run", agentId: "agent-1" });
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
const response = await request(app)
|
|
.post(`/api/issues/${rootId}/tree-holds/${holdId}/release`)
|
|
.send({ metadata: { wakeAgents: true } });
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.error).toContain("Resume without waking agents");
|
|
expect(mockTreeControlService.releaseHold).not.toHaveBeenCalled();
|
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects cross-company preview requests with a uniform 404 before calling the preview service", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-1"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-control/preview")
|
|
.send({ mode: "pause" });
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(mockTreeControlService.preview).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires board access for hold creation", async () => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "22222222-2222-4222-8222-222222222222",
|
|
companyId: "company-2",
|
|
runId: null,
|
|
source: "api_key",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
|
.send({ mode: "pause" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockIssueService.getById).not.toHaveBeenCalled();
|
|
expect(mockTreeControlService.createHold).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects malformed tree hold IDs before querying the hold service", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const getRes = await request(app)
|
|
.get("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds/null");
|
|
const releaseRes = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds/null/release")
|
|
.send({ reason: "bad hold id" });
|
|
|
|
expect(getRes.status).toBe(400);
|
|
expect(releaseRes.status).toBe(400);
|
|
expect(mockTreeControlService.getHold).not.toHaveBeenCalled();
|
|
expect(mockTreeControlService.releaseHold).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("cancels active descendant runs when creating a pause hold", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
mockTreeControlService.createHold.mockResolvedValue({
|
|
hold: {
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
mode: "pause",
|
|
reason: "pause subtree",
|
|
},
|
|
preview: {
|
|
mode: "pause",
|
|
totals: { affectedIssues: 1 },
|
|
warnings: [],
|
|
activeRuns: [
|
|
{
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
issueId: "11111111-1111-4111-8111-111111111111",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
|
.send({ mode: "pause", reason: "pause subtree" });
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("44444444-4444-4444-8444-444444444444");
|
|
expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).toHaveBeenCalledWith(
|
|
"company-2",
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"Cancelled because an active subtree pause hold was created",
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "issue.tree_hold_run_interrupted",
|
|
entityId: "44444444-4444-4444-8444-444444444444",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("marks affected issues cancelled when creating a cancel hold", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
mockTreeControlService.createHold.mockResolvedValue({
|
|
hold: {
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
mode: "cancel",
|
|
reason: "cancel subtree",
|
|
},
|
|
preview: {
|
|
mode: "cancel",
|
|
totals: { affectedIssues: 2 },
|
|
warnings: [],
|
|
activeRuns: [],
|
|
},
|
|
});
|
|
mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({
|
|
updatedIssueIds: [
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"55555555-5555-4555-8555-555555555555",
|
|
],
|
|
updatedIssues: [],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
|
.send({ mode: "cancel", reason: "cancel subtree" });
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockTreeControlService.cancelIssueStatusesForHold).toHaveBeenCalledWith(
|
|
"company-2",
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"33333333-3333-4333-8333-333333333333",
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "issue.tree_cancel_status_updated",
|
|
details: expect.objectContaining({ cancelledIssueCount: 2 }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("still marks affected issues cancelled when run interruption fails", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
mockTreeControlService.createHold.mockResolvedValue({
|
|
hold: {
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
mode: "cancel",
|
|
reason: "cancel subtree",
|
|
},
|
|
preview: {
|
|
mode: "cancel",
|
|
totals: { affectedIssues: 1 },
|
|
warnings: [],
|
|
activeRuns: [
|
|
{
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
issueId: "11111111-1111-4111-8111-111111111111",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({
|
|
updatedIssueIds: ["11111111-1111-4111-8111-111111111111"],
|
|
updatedIssues: [],
|
|
});
|
|
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("adapter process did not exit"));
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
|
.send({ mode: "cancel", reason: "cancel subtree" });
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("44444444-4444-4444-8444-444444444444");
|
|
expect(mockTreeControlService.cancelIssueStatusesForHold).toHaveBeenCalledWith(
|
|
"company-2",
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"33333333-3333-4333-8333-333333333333",
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "issue.tree_hold_run_interrupt_failed",
|
|
entityId: "44444444-4444-4444-8444-444444444444",
|
|
details: expect.objectContaining({
|
|
error: "adapter process did not exit",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("restores affected issues and can request explicit wakeups", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
mockTreeControlService.createHold.mockResolvedValue({
|
|
hold: {
|
|
id: "66666666-6666-4666-8666-666666666666",
|
|
mode: "restore",
|
|
reason: "restore subtree",
|
|
},
|
|
preview: {
|
|
mode: "restore",
|
|
totals: { affectedIssues: 1 },
|
|
warnings: [],
|
|
activeRuns: [],
|
|
},
|
|
});
|
|
mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({
|
|
updatedIssueIds: ["55555555-5555-4555-8555-555555555555"],
|
|
updatedIssues: [
|
|
{
|
|
id: "55555555-5555-4555-8555-555555555555",
|
|
status: "todo",
|
|
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
|
},
|
|
],
|
|
releasedCancelHoldIds: ["33333333-3333-4333-8333-333333333333"],
|
|
restoreHold: {
|
|
id: "66666666-6666-4666-8666-666666666666",
|
|
mode: "restore",
|
|
status: "released",
|
|
},
|
|
});
|
|
mockHeartbeatService.wakeup.mockResolvedValue({
|
|
id: "77777777-7777-4777-8777-777777777777",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
|
.send({ mode: "restore", reason: "restore subtree", metadata: { wakeAgents: true } });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockTreeControlService.restoreIssueStatusesForHold).toHaveBeenCalledWith(
|
|
"company-2",
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"66666666-6666-4666-8666-666666666666",
|
|
expect.objectContaining({ reason: "restore subtree" }),
|
|
);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
|
"22222222-2222-4222-8222-222222222222",
|
|
expect.objectContaining({
|
|
reason: "issue_tree_restored",
|
|
payload: expect.objectContaining({ issueId: "55555555-5555-4555-8555-555555555555" }),
|
|
}),
|
|
);
|
|
expect(res.body.hold.status).toBe("released");
|
|
});
|
|
|
|
it("releases a restore hold if the restore application fails", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
mockTreeControlService.createHold.mockResolvedValue({
|
|
hold: {
|
|
id: "66666666-6666-4666-8666-666666666666",
|
|
mode: "restore",
|
|
reason: "restore subtree",
|
|
},
|
|
preview: {
|
|
mode: "restore",
|
|
totals: { affectedIssues: 1 },
|
|
warnings: [],
|
|
activeRuns: [],
|
|
},
|
|
});
|
|
mockTreeControlService.restoreIssueStatusesForHold.mockRejectedValue(new Error("restore failed"));
|
|
mockTreeControlService.releaseHold.mockResolvedValue({
|
|
id: "66666666-6666-4666-8666-666666666666",
|
|
mode: "restore",
|
|
status: "released",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
|
.send({ mode: "restore", reason: "restore subtree" });
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(mockTreeControlService.releaseHold).toHaveBeenCalledWith(
|
|
"company-2",
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"66666666-6666-4666-8666-666666666666",
|
|
expect.objectContaining({
|
|
reason: "Restore operation failed before subtree status updates completed",
|
|
metadata: { cleanup: "restore_failed_before_apply" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns resume operations as released holds and avoids cancellation side effects", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
companyIds: ["company-2"],
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
});
|
|
mockTreeControlService.createHold.mockResolvedValue({
|
|
hold: {
|
|
id: "77777777-7777-4777-8777-777777777777",
|
|
mode: "resume",
|
|
status: "released",
|
|
reason: "resume subtree",
|
|
},
|
|
preview: {
|
|
mode: "resume",
|
|
totals: {
|
|
affectedIssues: 1,
|
|
},
|
|
warnings: [],
|
|
activeRuns: [],
|
|
},
|
|
resumedPauseHoldIds: ["33333333-3333-4333-8333-333333333333"],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
|
.send({ mode: "resume", reason: "resume subtree" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.hold.mode).toBe("resume");
|
|
expect(res.body.hold.status).toBe("released");
|
|
expect(res.body.resumedPauseHoldIds).toEqual(["33333333-3333-4333-8333-333333333333"]);
|
|
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
|
expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).not.toHaveBeenCalled();
|
|
expect(mockTreeControlService.cancelIssueStatusesForHold).not.toHaveBeenCalled();
|
|
expect(mockTreeControlService.restoreIssueStatusesForHold).not.toHaveBeenCalled();
|
|
});
|
|
});
|