[codex] Runtime control-plane fixes (#6380)

## Thinking Path

> - Paperclip orchestrates AI agents through a server-side control plane
> - That control plane depends on reliable issue state transitions,
plugin lifecycle behavior, import limits, and startup/shutdown handling
> - Several small runtime fixes had accumulated on the working branch
and were mixed with larger feature work
> - Keeping them separate makes the correctness fixes reviewable and
mergeable without waiting for cloud-sync UI work
> - This pull request groups the server/runtime control-plane fixes into
one standalone branch
> - The benefit is a tighter, safer runtime baseline for retries,
imports, plugin migrations, feedback flushing, and trusted cloud import
handling

## What Changed

- Fixed updated issue list pagination sorting and scheduled retry
comment handling.
- Re-applied pending plugin migrations during hot reload and fixed
plugin-schema worktree seed restore.
- Hardened public tenant DB startup, portable import body limits,
trusted cloud import errors, and trusted cloud tenant import mutation
access.
- Expired stale request confirmations after user comments.
- Added feedback export shutdown hardening so database-unavailable flush
loops stop cleanly.
- Guarded plugin worker `error` event emission when no listener is
registered.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `npm run install --prefix
node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts
server/src/__tests__/plugin-lifecycle-restart.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/body-limits.test.ts
server/src/__tests__/feedback-flush-controller.test.ts
server/src/__tests__/error-handler.test.ts
server/src/__tests__/board-mutation-guard.test.ts
packages/db/src/backup-lib.test.ts` initially exposed local setup issues
and two 5s test timeouts.
- Rerun after local prereq build: `pnpm exec vitest run --testTimeout
15000 server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/feedback-flush-controller.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` passed.
- Some embedded Postgres-backed tests skipped on this host because local
Postgres init was unavailable.

## Risks

- Runtime-touching branch: startup/shutdown and issue interaction
behavior should be reviewed carefully.
- The feedback export change disables repeated flush attempts only for
database connection-refused failures; other upload failures still log
normally.
- The plugin worker error guard avoids process crashes from unhandled
EventEmitter errors but may hide errors from code paths that expected an
emitted listener.

> 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, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## 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
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] 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-05-20 10:37:11 -05:00 committed by GitHub
parent f257530537
commit c91a062326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1363 additions and 130 deletions

View file

@ -25,6 +25,7 @@ import {
} from "../services/index.js";
import type { StorageService } from "../storage/types.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js";
export function companyRoutes(db: Db, storage?: StorageService) {
const router = Router();
@ -176,7 +177,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.json(preview);
});
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
router.post(COMPANY_IMPORT_ROUTE_PATH, validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req);
assertImportTargetAccess(req, req.body.target);
const actor = getActorInfo(req);

View file

@ -0,0 +1,2 @@
export const COMPANY_IMPORT_ROUTE_PATH = "/import";
export const COMPANY_IMPORT_API_PATH = `/api/companies${COMPANY_IMPORT_ROUTE_PATH}`;

View file

@ -627,6 +627,18 @@ function shouldImplicitlyMoveCommentedIssueToTodo(input: {
return true;
}
function shouldHumanCommentResumeInProgressScheduledRetry(input: {
hasComment: boolean;
issueStatus: string | null | undefined;
assigneeAgentId: string | null | undefined;
actorType: "agent" | "user";
}) {
if (!input.hasComment) return false;
if (input.actorType !== "user") return false;
if (input.issueStatus !== "in_progress") return false;
return typeof input.assigneeAgentId === "string" && input.assigneeAgentId.length > 0;
}
function isExplicitResumeCapableStatus(status: string | null | undefined) {
return status === "done" || status === "blocked" || status === "todo" || status === "in_progress";
}
@ -873,6 +885,41 @@ export function issueRoutes(
const feedbackExportService = opts?.feedbackExportService;
const environmentsSvc = environmentService(db);
async function cancelScheduledRetrySupersededByComment(input: {
scheduledRetryRunId: string | null | undefined;
issue: { id: string; companyId: string };
actor: ReturnType<typeof getActorInfo>;
}) {
const scheduledRetryRunId = readNonEmptyString(input.scheduledRetryRunId);
if (!scheduledRetryRunId) return null;
try {
const cancelled = await heartbeat.cancelRun(scheduledRetryRunId);
const cancelledRunId = cancelled?.id ?? scheduledRetryRunId;
await logActivity(db, {
companyId: input.issue.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelledRunId,
details: {
source: "issue_comment_scheduled_retry_superseded",
issueId: input.issue.id,
},
});
return cancelledRunId;
} catch (err) {
logger.error(
{ err, issueId: input.issue.id, runId: scheduledRetryRunId },
"failed to cancel scheduled retry superseded by issue comment",
);
throw err;
}
}
async function classifySourceRecoveryRevalidation(input: {
issue: IssueRouteSnapshot;
trigger: RecoveryRevalidationTrigger;
@ -1762,6 +1809,8 @@ export function issueRoutes(
? Number.parseInt(rawOffset, 10)
: null;
const attention = req.query.attention as string | undefined;
const sortField = req.query.sortField as string | undefined;
const sortDir = req.query.sortDir as string | undefined;
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
@ -1791,6 +1840,14 @@ export function issueRoutes(
res.status(400).json({ error: "offset must be a non-negative integer" });
return;
}
if (sortField !== undefined && sortField !== "updated") {
res.status(400).json({ error: "sortField must be 'updated' when provided" });
return;
}
if (sortDir !== undefined && sortDir !== "asc" && sortDir !== "desc") {
res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" });
return;
}
const offset = parsedOffset ?? 0;
const result = await svc.list(companyId, {
@ -1823,6 +1880,8 @@ export function issueRoutes(
q: req.query.q as string | undefined,
limit,
offset,
sortField: sortField === "updated" ? "updated" : undefined,
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined,
});
const issueIds = result.map((issue) => issue.id);
const [handoffStates, recoveryActionByIssue] = await Promise.all([
@ -3387,6 +3446,18 @@ export function issueRoutes(
) {
return;
}
const scheduledRetryForHumanComment =
shouldHumanCommentResumeInProgressScheduledRetry({
hasComment: !!commentBody,
issueStatus: existing.status,
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
})
? await svc.getCurrentScheduledRetry(existing.id)
: null;
const shouldResumeInProgressScheduledRetry =
!!scheduledRetryForHumanComment &&
scheduledRetryForHumanComment.agentId === requestedAssigneeAgentId;
const effectiveMoveToTodoRequested =
explicitMoveToTodoRequested ||
(!!commentBody &&
@ -3395,7 +3466,8 @@ export function issueRoutes(
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
}));
})) ||
shouldResumeInProgressScheduledRetry;
const updateReferenceSummaryBefore = titleOrDescriptionChanged
? await issueReferencesSvc.listIssueReferenceSummary(existing.id)
: null;
@ -3457,11 +3529,23 @@ export function issueRoutes(
if (
commentBody &&
effectiveMoveToTodoRequested &&
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) &&
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry) &&
updateFields.status === undefined
) {
updateFields.status = "todo";
}
let cancelledScheduledRetryRunId: string | null = null;
if (
commentBody &&
shouldResumeInProgressScheduledRetry &&
updateFields.status === "todo"
) {
cancelledScheduledRetryRunId = await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue: existing,
actor,
});
}
if (req.body.executionPolicy !== undefined) {
updateFields.executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
@ -3715,6 +3799,11 @@ export function issueRoutes(
previous.status !== undefined &&
issue.status === "todo";
const reopenFromStatus = reopened ? existing.status : null;
const scheduledRetrySupersededByComment =
shouldResumeInProgressScheduledRetry &&
previous.status !== undefined &&
existing.status === "in_progress" &&
issue.status === "todo";
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
@ -3756,6 +3845,13 @@ export function issueRoutes(
...(commentBody ? { source: "comment" } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(cancelledStatusRunId ? { cancelledStatusRunId } : {}),
...(workspaceChange ? { workspaceChange } : {}),
@ -3973,6 +4069,13 @@ export function issueRoutes(
issueTitle: issue.title,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(hasFieldChanges ? { updated: true } : {}),
...summarizeIssueReferenceActivityDetails({
@ -4470,7 +4573,17 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const interactions = await issueThreadInteractionService(db).listForIssue(id);
const actor = getActorInfo(req);
const interactionSvc = issueThreadInteractionService(db);
const expiredInteractions = await interactionSvc.expireRequestConfirmationsSupersededByHistoricalComments(issue);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.interactions.catchup_superseded_by_comment",
});
const interactions = await interactionSvc.listForIssue(id);
res.json(interactions);
});
@ -4976,6 +5089,18 @@ export function issueRoutes(
const isClosed = isClosedIssueStatus(issue.status);
const isBlocked = issue.status === "blocked";
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
const scheduledRetryForHumanComment =
shouldHumanCommentResumeInProgressScheduledRetry({
hasComment: true,
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
})
? await svc.getCurrentScheduledRetry(issue.id)
: null;
const shouldResumeInProgressScheduledRetry =
!!scheduledRetryForHumanComment &&
scheduledRetryForHumanComment.agentId === issue.assigneeAgentId;
const effectiveMoveToTodoRequested =
explicitMoveToTodoRequested ||
shouldImplicitlyMoveCommentedIssueToTodo({
@ -4983,7 +5108,8 @@ export function issueRoutes(
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
});
}) ||
shouldResumeInProgressScheduledRetry;
const hasUnresolvedFirstClassBlockers =
isBlocked && effectiveMoveToTodoRequested
? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0
@ -4998,14 +5124,27 @@ export function issueRoutes(
let currentIssue = issue;
const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
if (effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers))) {
let scheduledRetrySupersededByComment = false;
let cancelledScheduledRetryRunId: string | null = null;
if (
effectiveMoveToTodoRequested &&
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry)
) {
scheduledRetrySupersededByComment = shouldResumeInProgressScheduledRetry && issue.status === "in_progress";
cancelledScheduledRetryRunId = scheduledRetrySupersededByComment
? await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue,
actor,
})
: null;
const reopenedIssue = await svc.update(id, { status: "todo" });
if (!reopenedIssue) {
res.status(404).json({ error: "Issue not found" });
return;
}
reopened = true;
reopenFromStatus = issue.status;
reopened = isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers);
reopenFromStatus = reopened ? issue.status : null;
currentIssue = reopenedIssue;
await logActivity(db, {
@ -5019,8 +5158,14 @@ export function issueRoutes(
entityId: currentIssue.id,
details: {
status: "todo",
reopened: true,
reopenedFrom: reopenFromStatus,
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
source: "comment",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
identifier: currentIssue.identifier,
@ -5091,6 +5236,13 @@ export function issueRoutes(
issueTitle: currentIssue.title,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...summarizeIssueReferenceActivityDetails({
addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
@ -5119,7 +5271,7 @@ export function issueRoutes(
issue: currentIssue,
trigger: "comment",
actor,
statusChanged: reopened,
statusChanged: reopened || scheduledRetrySupersededByComment,
resumeRequested: resumeRequested === true,
reopened,
blockedToTodoRecovery: reopened && reopenFromStatus === "blocked" && currentIssue.status === "todo",