mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
[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:
parent
f257530537
commit
c91a062326
26 changed files with 1363 additions and 130 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue