[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:
@@ -1,5 +1,5 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
documents,
|
||||
@@ -158,6 +158,20 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) {
|
||||
return interaction.payload.supersedeOnUserComment === true;
|
||||
}
|
||||
|
||||
function isCommentAtOrAfterInteraction(args: {
|
||||
commentCreatedAt: Date | string;
|
||||
interactionCreatedAt: Date | string;
|
||||
}) {
|
||||
const commentCreatedAtMs = new Date(args.commentCreatedAt).getTime();
|
||||
const interactionCreatedAtMs = new Date(args.interactionCreatedAt).getTime();
|
||||
if (!Number.isFinite(commentCreatedAtMs) || !Number.isFinite(interactionCreatedAtMs)) return false;
|
||||
return commentCreatedAtMs >= interactionCreatedAtMs;
|
||||
}
|
||||
|
||||
function buildTaskCreationOrder(tasks: ReadonlyArray<SuggestTasksInteraction["payload"]["tasks"][number]>) {
|
||||
const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const));
|
||||
const ordered: Array<SuggestTasksInteraction["payload"]["tasks"][number]> = [];
|
||||
@@ -967,7 +981,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
|
||||
expireRequestConfirmationsSupersededByComment: async (
|
||||
issue: { id: string; companyId: string },
|
||||
comment: { id: string; authorUserId?: string | null },
|
||||
comment: { id: string; createdAt: Date | string; authorUserId?: string | null },
|
||||
actor: InteractionActor,
|
||||
) => {
|
||||
if (!comment.authorUserId) return [];
|
||||
@@ -984,7 +998,13 @@ export function issueThreadInteractionService(db: Db) {
|
||||
|
||||
const superseded = rows.filter((row) => {
|
||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
||||
return interaction.payload.supersedeOnUserComment === true;
|
||||
return (
|
||||
shouldSupersedeRequestConfirmationOnUserComment(interaction)
|
||||
&& isCommentAtOrAfterInteraction({
|
||||
commentCreatedAt: comment.createdAt,
|
||||
interactionCreatedAt: row.createdAt,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (superseded.length === 0) return [];
|
||||
@@ -1020,6 +1040,91 @@ export function issueThreadInteractionService(db: Db) {
|
||||
return expired;
|
||||
},
|
||||
|
||||
expireRequestConfirmationsSupersededByHistoricalComments: async (
|
||||
issue: { id: string; companyId: string },
|
||||
) => {
|
||||
const [rows, comments] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
)),
|
||||
db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.companyId, issue.companyId),
|
||||
eq(issueComments.issueId, issue.id),
|
||||
isNotNull(issueComments.authorUserId),
|
||||
))
|
||||
.orderBy(asc(issueComments.createdAt)),
|
||||
]);
|
||||
|
||||
if (rows.length === 0 || comments.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
const supersededByComment = new Map<
|
||||
string,
|
||||
{
|
||||
comment: (typeof comments)[number];
|
||||
rowIds: string[];
|
||||
}
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
||||
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
|
||||
|
||||
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
|
||||
commentCreatedAt: comment.createdAt,
|
||||
interactionCreatedAt: row.createdAt,
|
||||
}));
|
||||
if (!supersedingComment) continue;
|
||||
|
||||
const group = supersededByComment.get(supersedingComment.id);
|
||||
if (group) {
|
||||
group.rowIds.push(row.id);
|
||||
} else {
|
||||
supersededByComment.set(supersedingComment.id, {
|
||||
comment: supersedingComment,
|
||||
rowIds: [row.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { comment, rowIds } of supersededByComment.values()) {
|
||||
const updatedRows = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: comment.id,
|
||||
},
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: comment.authorUserId,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
inArray(issueThreadInteractions.id, rowIds),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
expired.push(...updatedRows.map(hydrateInteraction));
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
await touchIssue(db, issue.id);
|
||||
}
|
||||
return expired;
|
||||
},
|
||||
|
||||
expireStaleRequestConfirmationsForIssueDocument: async (
|
||||
issue: { id: string; companyId: string },
|
||||
document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null,
|
||||
|
||||
Reference in New Issue
Block a user