[codex] Add issue subtree pause, cancel, and restore controls (#4332)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - This branch extends the issue control-plane so board operators can
pause, cancel, and later restore whole issue subtrees while keeping
descendant execution and wake behavior coherent.
> - That required new hold state in the database, shared contracts,
server routes/services, and issue detail UI controls so subtree actions
are durable and auditable instead of ad hoc.
> - While this branch was in flight, `master` advanced with new
environment lifecycle work, including a new `0065_environments`
migration.
> - Before opening the PR, this branch had to be rebased onto
`paperclipai/paperclip:master` without losing the existing
subtree-control work or leaving conflicting migration numbering behind.
> - This pull request rebases the subtree pause/cancel/restore feature
cleanly onto current `master`, renumbers the hold migration to
`0066_issue_tree_holds`, and preserves the full branch diff in a single
PR.
> - The benefit is that reviewers get one clean, mergeable PR for the
subtree-control feature instead of stale branch history with migration
conflicts.

## What Changed

- Added durable issue subtree hold data structures, shared
API/types/validators, server routes/services, and UI flows for subtree
pause, cancel, and restore operations.
- Added server and UI coverage for subtree previewing, hold
creation/release, dependency-aware scheduling under holds, and issue
detail subtree controls.
- Rebased the branch onto current `paperclipai/paperclip:master` and
renumbered the branch migration from `0065_issue_tree_holds` to
`0066_issue_tree_holds` so it no longer conflicts with upstream
`0065_environments`.
- Added a small follow-up commit that makes restore requests return `200
OK` explicitly while keeping pause/cancel hold creation at `201
Created`, and updated the route test to match that contract.

## Verification

- `pnpm --filter @paperclipai/db typecheck`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `cd server && pnpm exec vitest run
src/__tests__/issue-tree-control-routes.test.ts
src/__tests__/issue-tree-control-service.test.ts
src/__tests__/issue-tree-control-service-unit.test.ts
src/__tests__/heartbeat-dependency-scheduling.test.ts`
- `cd ui && pnpm exec vitest run src/components/IssueChatThread.test.tsx
src/pages/IssueDetail.test.tsx`

## Risks

- This is a broad cross-layer change touching DB/schema, shared
contracts, server orchestration, and UI; regressions are most likely
around subtree status restoration or wake suppression/resume edge cases.
- The migration was renumbered during PR prep to avoid the new upstream
`0065_environments` conflict. Reviewers should confirm the final
`0066_issue_tree_holds` ordering is the only hold-related migration that
lands.
- The issue-tree restore endpoint now responds with `200` instead of
relying on implicit behavior, which is semantically better for a restore
operation but still changes an API detail that clients or tests could
have assumed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent in the Paperclip Codex runtime (GPT-5-class
tool-using coding model; exact deployment ID/context window is not
exposed inside this session).

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-23 14:51:46 -05:00 committed by GitHub
parent 854fa81757
commit f98c348e2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 4753 additions and 22 deletions

View file

@ -4,6 +4,7 @@ export { companySkillRoutes } from "./company-skills.js";
export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js";
export { issueRoutes } from "./issues.js";
export { issueTreeControlRoutes } from "./issue-tree-control.js";
export { routineRoutes } from "./routines.js";
export { goalRoutes } from "./goals.js";
export { approvalRoutes } from "./approvals.js";

View file

@ -0,0 +1,347 @@
import { Router } from "express";
import type { Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createIssueTreeHoldSchema,
previewIssueTreeControlSchema,
releaseIssueTreeHoldSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function issueTreeControlRoutes(db: Db) {
const router = Router();
const issuesSvc = issueService(db);
const treeControlSvc = issueTreeControlService(db);
const heartbeat = heartbeatService(db);
async function resolveRootIssue(req: Request) {
const rootIssueId = req.params.id as string;
const root = await issuesSvc.getById(rootIssueId);
return root;
}
router.post("/issues/:id/tree-control/preview", validate(previewIssueTreeControlSchema), async (req, res) => {
assertBoard(req);
const root = await resolveRootIssue(req);
if (!root) {
res.status(404).json({ error: "Root issue not found" });
return;
}
assertCompanyAccess(req, root.companyId);
const preview = await treeControlSvc.preview(root.companyId, root.id, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_control_previewed",
entityType: "issue",
entityId: root.id,
details: {
mode: preview.mode,
totals: preview.totals,
warningCodes: preview.warnings.map((warning) => warning.code),
},
});
res.json(preview);
});
router.post("/issues/:id/tree-holds", validate(createIssueTreeHoldSchema), async (req, res) => {
assertBoard(req);
const root = await resolveRootIssue(req);
if (!root) {
res.status(404).json({ error: "Root issue not found" });
return;
}
assertCompanyAccess(req, root.companyId);
const actor = getActorInfo(req);
const actorInput = {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
};
let result = await treeControlSvc.createHold(root.companyId, root.id, {
...req.body,
actor: actorInput,
});
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_created",
entityType: "issue",
entityId: root.id,
details: {
holdId: result.hold.id,
mode: result.hold.mode,
reason: result.hold.reason,
totals: result.preview.totals,
warningCodes: result.preview.warnings.map((warning) => warning.code),
},
});
if (result.hold.mode === "pause" || result.hold.mode === "cancel") {
const interruptedRunIds = [...new Set(result.preview.activeRuns.map((run) => run.id))];
for (const runId of interruptedRunIds) {
await heartbeat.cancelRun(runId);
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_run_interrupted",
entityType: "heartbeat_run",
entityId: runId,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation",
},
});
}
const cancelledWakeups = await treeControlSvc.cancelUnclaimedWakeupsForTree(
root.companyId,
root.id,
result.hold.mode === "pause"
? "Cancelled because an active subtree pause hold was created"
: "Cancelled because a subtree cancel operation was applied",
);
for (const wakeup of cancelledWakeups) {
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_wakeup_deferred",
entityType: "agent_wakeup_request",
entityId: wakeup.id,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
agentId: wakeup.agentId,
previousReason: wakeup.reason,
},
});
}
}
if (result.hold.mode === "cancel") {
const statusUpdate = await treeControlSvc.cancelIssueStatusesForHold(root.companyId, root.id, result.hold.id);
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_cancel_status_updated",
entityType: "issue",
entityId: root.id,
details: {
holdId: result.hold.id,
cancelledIssueIds: statusUpdate.updatedIssueIds,
cancelledIssueCount: statusUpdate.updatedIssueIds.length,
},
});
}
if (result.hold.mode === "restore") {
let statusUpdate;
try {
statusUpdate = await treeControlSvc.restoreIssueStatusesForHold(root.companyId, root.id, result.hold.id, {
reason: result.hold.reason,
actor: actorInput,
});
} catch (error) {
await treeControlSvc.releaseHold(root.companyId, root.id, result.hold.id, {
reason: "Restore operation failed before subtree status updates completed",
metadata: {
cleanup: "restore_failed_before_apply",
},
actor: actorInput,
}).catch(() => null);
throw error;
}
if (statusUpdate.restoreHold) {
result = { ...result, hold: statusUpdate.restoreHold };
}
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_restore_status_updated",
entityType: "issue",
entityId: root.id,
details: {
holdId: result.hold.id,
restoredIssueIds: statusUpdate.updatedIssueIds,
restoredIssueCount: statusUpdate.updatedIssueIds.length,
releasedCancelHoldIds: statusUpdate.releasedCancelHoldIds,
},
});
const wakeAgents = typeof req.body.metadata === "object"
&& req.body.metadata !== null
&& (req.body.metadata as Record<string, unknown>).wakeAgents === true;
if (wakeAgents) {
for (const restoredIssue of statusUpdate.updatedIssues) {
if (!restoredIssue.assigneeAgentId) continue;
const wakeRun = await heartbeat
.wakeup(restoredIssue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_tree_restored",
payload: {
issueId: restoredIssue.id,
rootIssueId: root.id,
restoreHoldId: result.hold.id,
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: restoredIssue.id,
taskId: restoredIssue.id,
wakeReason: "issue_tree_restored",
source: "issue.tree_restore",
rootIssueId: root.id,
restoreHoldId: result.hold.id,
},
})
.catch(() => null);
if (!wakeRun) continue;
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_restore_wakeup_requested",
entityType: "heartbeat_run",
entityId: wakeRun.id,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
issueId: restoredIssue.id,
agentId: restoredIssue.assigneeAgentId,
},
});
}
}
}
res.status(result.hold.mode === "restore" ? 200 : 201).json(result);
});
router.get("/issues/:id/tree-control/state", async (req, res) => {
assertBoard(req);
const issueId = req.params.id as string;
const issue = await issuesSvc.getById(issueId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
res.json({ activePauseHold });
});
router.get("/issues/:id/tree-holds", async (req, res) => {
assertBoard(req);
const root = await resolveRootIssue(req);
if (!root) {
res.status(404).json({ error: "Root issue not found" });
return;
}
assertCompanyAccess(req, root.companyId);
const statusParam = typeof req.query.status === "string" ? req.query.status : null;
const modeParam = typeof req.query.mode === "string" ? req.query.mode : null;
const includeMembers = req.query.includeMembers === "true";
const holds = await treeControlSvc.listHolds(root.companyId, root.id, {
status: statusParam === "active" || statusParam === "released" ? statusParam : undefined,
mode:
modeParam === "pause" || modeParam === "resume" || modeParam === "cancel" || modeParam === "restore"
? modeParam
: undefined,
includeMembers,
});
res.json(holds);
});
router.get("/issues/:id/tree-holds/:holdId", async (req, res) => {
assertBoard(req);
const root = await resolveRootIssue(req);
if (!root) {
res.status(404).json({ error: "Root issue not found" });
return;
}
assertCompanyAccess(req, root.companyId);
const hold = await treeControlSvc.getHold(root.companyId, req.params.holdId as string);
if (!hold || hold.rootIssueId !== root.id) {
res.status(404).json({ error: "Issue tree hold not found" });
return;
}
res.json(hold);
});
router.post(
"/issues/:id/tree-holds/:holdId/release",
validate(releaseIssueTreeHoldSchema),
async (req, res) => {
assertBoard(req);
const root = await resolveRootIssue(req);
if (!root) {
res.status(404).json({ error: "Root issue not found" });
return;
}
assertCompanyAccess(req, root.companyId);
const actor = getActorInfo(req);
const hold = await treeControlSvc.releaseHold(root.companyId, root.id, req.params.holdId as string, {
...req.body,
actor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
});
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_released",
entityType: "issue",
entityId: root.id,
details: {
holdId: hold.id,
mode: hold.mode,
reason: hold.releaseReason,
memberCount: hold.members?.length ?? 0,
},
});
res.json(hold);
},
);
return router;
}