From eb452fba30587a3ce0fce0f92456c4b89ea41b8e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 13 May 2026 12:56:51 -0500 Subject: [PATCH] Fix comment date binding regression (#5919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the control plane for autonomous AI companies, and issue comments are the primary durable communication surface between operators and agents. > - Commit `c445e592` (`fix(ui): fix message attribution for agent-posted comments with user author IDs (#5780)`) added server-side derived attribution for historical comments by scanning heartbeat runs near comment timestamps. > - That scan accidentally bound JavaScript `Date` objects directly into postgres-js SQL fragments for the run timestamp window. > - On real Postgres, that can fail while listing issue comments with `ERR_INVALID_ARG_TYPE`, which makes comments disappear from issue pages such as `PAP-9284`. > - This pull request keeps the attribution behavior intact while changing only the broken timestamp binding path. > - The benefit is that comments load again without weakening the conservative attribution recovery introduced by `c445e592`. ## What Changed - Convert the derived-attribution heartbeat-run window bounds to ISO timestamp strings before binding them into SQL, with explicit `::timestamptz` casts. - Add an embedded Postgres regression that inserts a heartbeat run and user-authored comment, then verifies `issueService.listComments()` returns the comment while the attribution scan runs. - Delete `heartbeat_runs` during the issue service test cleanup before deleting agents so the new test data does not leak across cases. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "lists user comments when derived run attribution scans a timestamp window"` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check` ## Risks - Low risk. The change is limited to how timestamp parameters are bound for an existing query. - The derived attribution logic remains conservative and still requires exact run-log proof before relabeling a comment. - The regression uses embedded Postgres so it covers the postgres-js binding path that failed in production-like local runs. > 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 via the Paperclip `codex_local` adapter; GPT-5 coding-agent family with local terminal, file-editing, and git/GitHub CLI tool use. Exact hosted model deployment ID is not exposed by this local 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 (not applicable: server-side comment API bugfix) - [x] I have updated relevant documentation to reflect my changes (not applicable: no documented behavior or command changed) - [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 --- server/src/__tests__/issues-service.test.ts | 63 +++++++++++++++++++++ server/src/services/issues.ts | 9 ++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 17d1a8ef..44ac15e6 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -150,6 +150,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(goals); + await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(instanceSettings); await db.delete(companies); @@ -1159,6 +1160,68 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]); }); + it("lists user comments when derived run attribution scans a timestamp window", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const commentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Comments issue", + status: "todo", + priority: "medium", + }); + + await db.insert(heartbeatRuns).values({ + id: randomUUID(), + companyId, + agentId, + contextSnapshot: { issueId }, + createdAt: new Date("2026-05-12T22:58:00.000Z"), + startedAt: new Date("2026-05-12T22:58:00.000Z"), + finishedAt: new Date("2026-05-12T23:14:00.000Z"), + }); + + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "user-1", + body: "Comment should be visible", + createdAt: new Date("2026-05-12T23:00:00.000Z"), + updatedAt: new Date("2026-05-12T23:00:00.000Z"), + }); + + const comments = await svc.listComments(issueId, { + order: "desc", + limit: 50, + }); + + expect(comments.map((comment) => comment.id)).toEqual([commentId]); + expect(comments[0]?.body).toBe("Comment should be visible"); + }); + it("includes blockedBy summaries on list rows in one batched pass", async () => { const companyId = randomUUID(); const blockerId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 1e8f1f9b..7a708c28 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1943,6 +1943,11 @@ export function issueService(db: Db) { }, null); if (minCommentCreatedAtMs === null || maxCommentCreatedAtMs === null) return comments; + const minCommentCreatedAt = new Date(minCommentCreatedAtMs).toISOString(); + const maxCommentCreatedAt = new Date( + maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS, + ).toISOString(); + const runs = await db .select({ runId: heartbeatRuns.id, @@ -1969,8 +1974,8 @@ export function issueService(db: Db) { and ${activityLog.runId} = ${heartbeatRuns.id} )`, ), - sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${new Date(minCommentCreatedAtMs)}`, - sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${new Date(maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS)}`, + sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`, + sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`, ), ) .orderBy(desc(heartbeatRuns.createdAt));