fix(issues): attribute agent-authored comments instead of rendering them as "Board" (#8833)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Task/issue threads render each comment as a chat bubble; the author
determines whether it shows as a left-aligned agent bubble (name + icon)
or a right-aligned "Board" bubble
> - Comments posted by an agent from a local execution environment are
written with a non-human author id (`local-board`/system), so they were
mis-rendered as blue "Board" bubbles instead of being attributed to the
authoring agent
> - This misattribution is confusing (it looks like the human board said
something an agent actually said) and it can drive false
wake/reconciliation behavior on the affected threads
> - This pull request adds server-side attribution derivation (lossless
run-id join first, then an explicit run-log post marker), persists the
derived agent so the read path stops re-scanning run logs, and stops the
client from labeling agent-derived comments "Board"
> - The benefit is agent comments render as the correct agent, genuine
human board comments are never reattributed, and reads get cheaper after
a one-time persistence

## Linked Issues or Issue Description

<!-- No public GitHub issue — describing the problem in-PR (bug report
shape). -->

**What happened?**

In a task/issue comment thread, comments authored by an agent from a
local execution environment are stored with a non-human author id
(`local-board`/system). The UI renders these as right-aligned blue
"Board" bubbles, implying a human board member authored them. The
mislabeling is also a wake/reconciliation hazard: an agent comment that
reads as "Board" can look like human board input.

**Expected behavior**

Such comments should render as the authoring agent (left-aligned bubble
with agent name + icon). Genuine human/board comments must continue to
render as "Board" and must never be reattributed to an agent.

**Steps to reproduce**

1. Have an agent post a comment on an issue from a local execution
environment (author id `local-board`).
2. Open the issue comment thread in the UI.
3. Observe the agent's comment rendered as a right-aligned blue "Board"
bubble instead of the authoring agent.

**Root cause**

The read path did not resolve the authoring agent for these comments,
and the client fell back to a "Board" label for the `local-board`
author.

## What Changed

- **Server derivation (`server/src/services/issues.ts`):**
- Resolve the authoring agent from the comment's run id first
(`createdByRunId`/`derivedCreatedByRunId` → `heartbeatRuns.agentId`) —
lossless when present.
- Second tier `run_log_comment_post`: read the run log lazily (only for
still-unresolved comments) to match the explicit `comment id:` post
marker.
- **Guard:** never reattribute a comment whose author maps to a genuine
user profile. Only the non-human sentinel (`local-board`, which is
itself a `user` row) and authors absent from the `user` table are
eligible.
- Pure timing-overlap tiers are intentionally **not** used (Option A) —
an agent comment and a human board comment posted during the same run
are indistinguishable rows, so any timing guess risks mislabeling a real
human comment.
- **Persistence
(`packages/db/src/migrations/0126_issue_comment_derived_attribution.sql`,
`packages/db/src/schema/issue_comments.ts`):** add stored `derived_*`
attribution columns and write the resolved agent back with a single bulk
`UPDATE ... FROM (VALUES ...)`, so reads stop recomputing from run logs.
Migration is additive (new nullable columns) with a batched, idempotent
backfill of the lossless run-id tier over historical rows.
- **Types (`packages/shared/src/types/issue.ts`):** expose the persisted
attribution fields and the `IssueCommentDerivedAuthorSource` union.
- **Client (`ui/src/lib/issue-chat-messages.test.ts`):** the message
builder already prefers a resolved agent id (`authorAgentId ??
runAgentId ?? derivedAuthorAgentId`), so once the server persists the
derived agent the bubble renders as the agent automatically — no client
code change needed. Adds a regression guard confirming a genuine board
comment with no derived agent is still rendered as "Board".
- **Tests:** derivation + message-building tests, including assertions
that genuine board/user comments are **not** reattributed.

## Verification

- `cd server && npx vitest run issues-service` — 94 tests pass: run-id
resolution, no-attribution on timing overlap alone (Option A), multi-run
ambiguity, same-agent multi-run, and the genuine-user guard. Exercises
the real persistence path (bulk UPDATE) against the test DB.
- `cd ui && npx vitest run issue-chat-messages` — 27 tests pass; client
no longer labels agent-derived comments "Board", and a genuine board
comment with no derived agent is not re-labeled.
- `cd server && npm run typecheck` — passes (exit 0).
- Manual: on a thread containing old agent-authored comments, the blue
"Board" bubbles render as the authoring agent; a genuine board comment
on the same thread still renders as "Board".

## Risks

- **Mis-reattributing a genuine board comment made during an agent run**
→ mitigated by the human-profile guard (only `local-board`/system
authors are eligible) and by dropping pure timing tiers (Option A): only
the lossless run-id join and the explicit run-log post marker attribute
history.
- **Backfill volume / run-log reads** → the migration backfill is
batched (5000 rows/loop) and results are persisted so reads stop
recomputing; the read-path persistence is a single bulk UPDATE rather
than per-comment round-trips. Migration adds only nullable columns (no
destructive change).
- The persistence/backfill has **not** been run against any production
database as part of opening this PR.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, via Claude Code
with tool use.

## 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 searched GitHub for duplicate or related PRs — related open
PRs (#6006 narrow attribution run scan, #4729 attribution roll-up, #7014
reaped-run attribution) address different attribution paths; none fix
the `local-board` "Board" bubble rendering this PR targets. Supersedes
#8832 (same change; branch renamed to drop an internal ticket id per
CONTRIBUTING → Branch Naming)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-07-01 17:45:30 -07:00 committed by GitHub
parent de837e2683
commit fb2b760915
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 501 additions and 57 deletions

View File

@ -0,0 +1,69 @@
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_source" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_author_agent_id_agents_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_author_agent_id_agents_id_fk" FOREIGN KEY ("derived_author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("derived_created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
-- Backfill agent attribution for historical non-human ("Board") comments so old
-- threads stop rendering as blue board bubbles and the read path stops
-- re-scanning run logs. Two SQL-computable tiers, both guarded to NEVER touch a
-- comment whose author maps to a genuine user profile. The log-marker tier is
-- handled lazily on read (it needs object-storage log bodies).
--
-- Tier `run_id`: the comment's own authoring run resolves to an agent (lossless).
-- Batched to keep lock/WAL footprint bounded on large histories.
DO $$
DECLARE
affected integer;
BEGIN
LOOP
WITH batch AS (
SELECT c.id AS comment_id, hr.agent_id, hr.id AS run_id
FROM issue_comments c
JOIN heartbeat_runs hr ON hr.id = c.created_by_run_id
WHERE c.author_agent_id IS NULL
AND c.derived_author_agent_id IS NULL
AND c.author_user_id IS NOT NULL
-- Only the non-human board sentinel or non-`user` authors are
-- eligible. `local-board` IS a row in "user" (the implicit board
-- admin), so it must be allowed explicitly; genuine signups are not.
AND (c.author_user_id = 'local-board'
OR NOT EXISTS (SELECT 1 FROM "user" u WHERE u.id = c.author_user_id))
LIMIT 5000
)
UPDATE issue_comments c
SET derived_author_agent_id = b.agent_id,
derived_created_by_run_id = b.run_id,
derived_author_source = 'run_id'
FROM batch b
WHERE c.id = b.comment_id;
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
END LOOP;
END $$;--> statement-breakpoint
-- Option A: the pure run-window TIMING tiers are intentionally NOT applied.
-- Because agents post through the `local-board` subprocess, an agent comment and
-- a genuine human board comment are indistinguishable rows, so any timing-overlap
-- guess mis-attributes human board comments that merely coincided with an agent
-- run (e.g. a human board reply typed while an agent run was in flight).
-- Only the lossless `run_id` backfill above (and the read-path `run_log_comment_post`
-- tier) attribute history; everything else stays "Board".
--
-- This statement also reverts any attribution a PRIOR revision of this migration
-- persisted via the timing tiers, so re-applying / upgrading is idempotent.
UPDATE issue_comments
SET derived_author_agent_id = NULL,
derived_created_by_run_id = NULL,
derived_author_source = NULL
WHERE derived_author_source IN ('run_window_unique', 'run_window_agent_unique');

View File

@ -883,6 +883,13 @@
"when": 1782440100000,
"tag": "0125_environment_custom_image_templates",
"breakpoints": true
},
{
"idx": 126,
"version": "7",
"when": 1782526400000,
"tag": "0126_issue_comment_derived_attribution",
"breakpoints": true
}
]
}

View File

@ -1,5 +1,6 @@
import type {
IssueCommentAuthorType,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentPresentation,
SourceTrustMetadata,
@ -20,6 +21,13 @@ export const issueComments = pgTable(
authorUserId: text("author_user_id"),
authorType: text("author_type").$type<IssueCommentAuthorType>(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
// Persisted result of best-effort agent-attribution derivation for comments
// authored by a non-human sentinel (e.g. `local-board`). Populated once by a
// backfill migration and lazily on read so the load path stops re-scanning
// run logs.
derivedAuthorAgentId: uuid("derived_author_agent_id").references(() => agents.id, { onDelete: "set null" }),
derivedCreatedByRunId: uuid("derived_created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
derivedAuthorSource: text("derived_author_source").$type<IssueCommentDerivedAuthorSource>(),
body: text("body").notNull(),
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),

View File

@ -659,6 +659,7 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,

View File

@ -324,6 +324,7 @@ export type {
IssueReviewRequest,
IssueExecutionDecision,
IssueComment,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,

View File

@ -603,6 +603,22 @@ export interface Issue {
updatedAt: Date;
}
/**
* Where a comment's derived (non-stored-author) agent attribution came from,
* in descending confidence:
* - `run_id`: comment carries a `createdByRunId`/`derivedCreatedByRunId` whose
* run resolves directly to an agent (lossless).
* - `run_log_comment_post`: a run log within the comment window contains the
* `comment id: {id}` post marker (lossless: the run recorded posting it).
*
* Only lossless signals are used. Pure run-window timing overlap is NOT a
* source it cannot distinguish an agent comment from a human board comment
* that coincided with a run (Option A).
*/
export type IssueCommentDerivedAuthorSource =
| "run_id"
| "run_log_comment_post";
export interface IssueComment {
id: string;
companyId: string;
@ -613,7 +629,7 @@ export interface IssueComment {
createdByRunId?: string | null;
derivedAuthorAgentId?: string | null;
derivedCreatedByRunId?: string | null;
derivedAuthorSource?: "run_log_comment_post" | null;
derivedAuthorSource?: IssueCommentDerivedAuthorSource | null;
body: string;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;

View File

@ -94,14 +94,81 @@ describe("deriveIssueCommentRunLogAttribution", () => {
});
});
it("does not rewrite comments without exact run-log proof", () => {
it("resolves directly from the comment's own run id without reading logs", () => {
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: runId,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId,
agentId,
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "",
},
],
);
expect(derived.get(commentId)).toEqual({
derivedAuthorAgentId: agentId,
derivedCreatedByRunId: runId,
derivedAuthorSource: "run_id",
});
});
it("does NOT attribute on run-window overlap alone — timing is not a lossless signal (option A)", () => {
// A human board comment can land inside an agent's run window; since both are
// stored as `local-board`, a timing-only guess would mis-attribute it. So a
// single overlapping run with no run-id and no log marker stays unresolved.
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId,
agentId,
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "posted results without echoing the comment id",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("does not guess when multiple agent runs overlap and no log proves the author", () => {
const commentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "user-1",
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
@ -113,7 +180,81 @@ describe("deriveIssueCommentRunLogAttribution", () => {
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "posted results without echoing the comment id",
logContent: "no comment id here",
},
{
runId: randomUUID(),
agentId: randomUUID(),
createdAt: new Date("2026-05-11T18:54:00.000Z"),
startedAt: new Date("2026-05-11T18:54:00.000Z"),
finishedAt: new Date("2026-05-11T18:56:00.000Z"),
logContent: "also nothing",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("does NOT attribute on same-agent run-window overlap alone (option A)", () => {
// Even when every overlapping run is the same agent, timing alone cannot
// prove the comment was the agent's vs a human board comment during the run.
const commentId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-06-29T17:41:59.916Z"),
},
],
[
{
runId: randomUUID(),
agentId,
createdAt: new Date("2026-06-29T17:41:26.116Z"),
startedAt: new Date("2026-06-29T17:41:26.116Z"),
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
logContent: "no comment id here",
},
{
runId: randomUUID(),
agentId,
createdAt: new Date("2026-06-29T17:40:09.531Z"),
startedAt: new Date("2026-06-29T17:40:09.531Z"),
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
logContent: "also nothing",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("never reattributes a comment that already has a stored agent author", () => {
const commentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: randomUUID(),
authorUserId: null,
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId: randomUUID(),
agentId: randomUUID(),
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "",
},
],
);

View File

@ -6,6 +6,7 @@ import {
activityLog,
agentWakeupRequests,
agents,
authUsers,
approvals,
assets,
companies,
@ -37,6 +38,7 @@ import type {
AcceptedPlanDecomposition,
IssueComment,
IssueCommentAuthorType,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentPresentation,
IssueBlockerAttention,
@ -101,6 +103,11 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
// Non-human author sentinels that agents post under. These ARE eligible for
// agent-attribution derivation even though `local-board` is also materialized
// as a row in the `user` table (it is the implicit board admin). Genuine human
// users — real signups with their own ids — are never reattributed.
const NON_HUMAN_SENTINEL_AUTHOR_USER_IDS = new Set<string>(["local-board"]);
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
@ -169,32 +176,65 @@ type IssueCommentRunLogAttributionRun = {
createdAt: Date | string;
startedAt?: Date | string | null;
finishedAt?: Date | string | null;
// Best-effort run log text. May be empty when logs were not read for a tier
// that does not need them (run-id / run-window-unique); only the
// `run_log_comment_post` tier consults this.
logContent: string;
};
type DerivedIssueCommentAttribution = {
derivedAuthorAgentId: string;
derivedCreatedByRunId: string;
derivedAuthorSource: IssueCommentDerivedAuthorSource;
};
/**
* Best-effort agent attribution for comments whose stored author is a non-human
* sentinel (e.g. `local-board`). Callers MUST pre-filter `comments` to drop any
* comment whose `authorUserId` maps to a genuine user profile so a real board /
* user comment is never reattributed.
*
* Only LOSSLESS signals are used a comment is reattributed solely when a run
* provably authored it. Pure run-window timing overlap is intentionally NOT a
* signal: because agents post through the `local-board` subprocess, an agent
* comment and a genuine human board comment are indistinguishable rows, so any
* timing-based guess mis-attributes human board comments that merely coincided
* with an agent run (Option A).
*
* Tiers, in descending confidence (first match wins per comment):
* 1. `run_id` the comment's own `createdByRunId` resolves to an agent run
* (lossless: that run authored the comment).
* 2. `run_log_comment_post` an overlapping run log contains the explicit
* `comment id: {id}` post marker (lossless: the run recorded posting it).
*/
export function deriveIssueCommentRunLogAttribution(
comments: readonly IssueCommentRunLogAttributionCandidate[],
runs: readonly IssueCommentRunLogAttributionRun[],
) {
const derivedByCommentId = new Map<string, {
derivedAuthorAgentId: string;
derivedCreatedByRunId: string;
derivedAuthorSource: "run_log_comment_post";
}>();
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>();
const runById = new Map(runs.map((run) => [run.runId, run] as const));
for (const comment of comments) {
if (comment.authorAgentId || !comment.authorUserId || comment.createdByRunId) continue;
if (comment.authorAgentId || !comment.authorUserId) continue;
// Tier 1: the comment carries the run that authored it. Lossless even when
// the author was recorded as the `local-board` sentinel.
if (comment.createdByRunId) {
const ownRun = runById.get(comment.createdByRunId);
if (ownRun?.agentId) {
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: ownRun.agentId,
derivedCreatedByRunId: ownRun.runId,
derivedAuthorSource: "run_id",
});
continue;
}
}
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
if (commentCreatedAtMs === null) continue;
let bestMatch:
| {
runId: string;
agentId: string;
distanceMs: number;
}
| null = null;
const overlappingRuns: Array<{ run: IssueCommentRunLogAttributionRun; runEndMs: number }> = [];
for (const run of runs) {
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
@ -205,24 +245,30 @@ export function deriveIssueCommentRunLogAttribution(
) {
continue;
}
if (!run.logContent.includes(`comment id: ${comment.id}`)) continue;
const distanceMs = Math.abs(runEndMs - commentCreatedAtMs);
if (!bestMatch || distanceMs < bestMatch.distanceMs) {
bestMatch = {
runId: run.runId,
agentId: run.agentId,
distanceMs,
};
}
overlappingRuns.push({ run, runEndMs });
}
if (!bestMatch) continue;
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: bestMatch.agentId,
derivedCreatedByRunId: bestMatch.runId,
derivedAuthorSource: "run_log_comment_post",
});
// Tier 2: an overlapping run log explicitly recorded posting this comment.
let bestLogMatch: { runId: string; agentId: string; distanceMs: number } | null = null;
for (const { run, runEndMs } of overlappingRuns) {
if (!run.logContent.includes(`comment id: ${comment.id}`)) continue;
const distanceMs = Math.abs(runEndMs - commentCreatedAtMs);
if (!bestLogMatch || distanceMs < bestLogMatch.distanceMs) {
bestLogMatch = { runId: run.runId, agentId: run.agentId, distanceMs };
}
}
if (bestLogMatch) {
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: bestLogMatch.agentId,
derivedCreatedByRunId: bestLogMatch.runId,
derivedAuthorSource: "run_log_comment_post",
});
continue;
}
// No lossless signal — leave unresolved. A pure run-window timing overlap is
// deliberately NOT enough to reattribute (it cannot tell an agent comment
// from a human board comment that happened during the run).
}
return derivedByCommentId;
@ -3442,6 +3488,39 @@ export function issueService(db: Db) {
return content;
}
// Persist a resolved attribution so subsequent reads stop re-scanning run
// logs (and old "Board" threads stay fixed durably). Best-effort: a write
// failure must never break the read path. The `IS NULL` guard keeps this
// idempotent and avoids clobbering a value another reader just stored.
async function persistDerivedIssueCommentAttribution(
derivedByCommentId: ReadonlyMap<string, DerivedIssueCommentAttribution>,
) {
if (derivedByCommentId.size === 0) return;
// One bulk `UPDATE ... FROM (VALUES ...)` so the read path is never blocked
// on N sequential round-trips for a large legacy thread. The `IS NULL` guard
// keeps this idempotent and avoids clobbering a value another reader just
// stored. Best-effort: a write failure must never break the read path.
const rows = [...derivedByCommentId].map(
([commentId, derived]) =>
sql`(${commentId}::uuid, ${derived.derivedAuthorAgentId}::uuid, ${derived.derivedCreatedByRunId}::uuid, ${derived.derivedAuthorSource}::text)`,
);
try {
await db.execute(sql`
UPDATE ${issueComments} AS c
SET derived_author_agent_id = v.agent_id,
derived_created_by_run_id = v.run_id,
derived_author_source = v.source
FROM (VALUES ${sql.join(rows, sql`, `)}) AS v(comment_id, agent_id, run_id, source)
WHERE c.id = v.comment_id AND c.derived_author_agent_id IS NULL
`);
} catch (err) {
logger.warn(
{ err, commentIds: [...derivedByCommentId.keys()] },
"failed to persist derived issue-comment attribution",
);
}
}
async function enrichCommentsWithDerivedAgentAttribution<
T extends {
id: string;
@ -3450,20 +3529,55 @@ export function issueService(db: Db) {
authorAgentId?: string | null;
authorUserId?: string | null;
createdByRunId?: string | null;
derivedAuthorAgentId?: string | null;
createdAt: Date | string;
},
>(comments: readonly T[]) {
const candidates = comments.filter((comment) =>
// Candidates: a non-human author, no stored agent, and not already resolved
// by a previous read / the backfill migration.
const preliminary = comments.filter((comment) =>
!comment.authorAgentId
&& !!comment.authorUserId
&& !comment.createdByRunId,
&& !comment.derivedAuthorAgentId,
);
if (candidates.length === 0) return comments;
if (preliminary.length === 0) return comments;
const companyId = comments[0]?.companyId ?? null;
const issueId = comments[0]?.issueId ?? null;
if (!companyId || !issueId) return comments;
// Guard: never reattribute a comment whose author maps to a genuine user
// profile. Only the non-human sentinels agents post under (e.g.
// `local-board`) are eligible — even though `local-board` is itself a row in
// the `user` table, so a plain "exists in user table" check would wrongly
// exclude every mis-attributed agent comment.
const nonSentinelAuthorUserIds = [
...new Set(
preliminary
.map((comment) => comment.authorUserId)
.filter((id): id is string => !!id && !NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(id)),
),
];
const genuineUserIds = nonSentinelAuthorUserIds.length
? new Set(
(
await db
.select({ id: authUsers.id })
.from(authUsers)
.where(inArray(authUsers.id, nonSentinelAuthorUserIds))
).map((row) => row.id),
)
: new Set<string>();
// `preliminary` already guarantees a truthy `authorUserId`, so only the two
// "not a genuine user" arms are live: the explicit non-human sentinel, or an
// author id absent from the `user` table.
const candidates = preliminary.filter(
(comment) =>
NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(comment.authorUserId!)
|| !genuineUserIds.has(comment.authorUserId!),
);
if (candidates.length === 0) return comments;
const minCommentCreatedAtMs = candidates.reduce<number | null>((min, comment) => {
const timestamp = toTimestampMs(comment.createdAt);
if (timestamp === null) return min;
@ -3481,6 +3595,13 @@ export function issueService(db: Db) {
maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS,
).toISOString();
// The runs the comments' own `createdByRunId` point at — fetched
// unconditionally so the lossless run-id tier resolves even when a run is
// not otherwise associated with the issue.
const ownRunIds = [
...new Set(candidates.map((comment) => comment.createdByRunId).filter((id): id is string => !!id)),
];
const runs = await db
.select({
runId: heartbeatRuns.id,
@ -3497,36 +3618,82 @@ export function issueService(db: Db) {
and(
eq(heartbeatRuns.companyId, companyId),
or(
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
sql`exists (
select 1
from ${activityLog}
where ${activityLog.companyId} = ${companyId}
and ${activityLog.entityType} = 'issue'
and ${activityLog.entityId} = ${issueId}
and ${activityLog.runId} = ${heartbeatRuns.id}
)`,
and(
or(
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
sql`exists (
select 1
from ${activityLog}
where ${activityLog.companyId} = ${companyId}
and ${activityLog.entityType} = 'issue'
and ${activityLog.entityId} = ${issueId}
and ${activityLog.runId} = ${heartbeatRuns.id}
)`,
),
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`,
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`,
),
ownRunIds.length > 0 ? inArray(heartbeatRuns.id, ownRunIds) : sql`false`,
),
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`,
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`,
),
)
.orderBy(desc(heartbeatRuns.createdAt));
if (runs.length === 0) return comments;
const runsWithLogs: Array<(typeof runs)[number] & { logContent: string }> = [];
for (let index = 0; index < runs.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) {
const batch = runs.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS);
const batchWithLogs = await Promise.all(batch.map(async (run) => ({
...run,
logContent: await readRunLogText(run),
})));
runsWithLogs.push(...batchWithLogs);
// Pass 1: resolve the run-id tier, which never reads log bodies. Most
// comments resolve here, so we avoid object-storage reads entirely.
const runsWithoutLogs = runs.map((run) => ({ ...run, logContent: "" }));
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>(
deriveIssueCommentRunLogAttribution(candidates, runsWithoutLogs),
);
// Pass 2: for comments still unresolved after the run-id tier, read the logs
// of any run whose window overlaps such a comment, to look for the explicit
// `comment id:` post marker. The marker is a lossless signal regardless of
// how many runs overlap, so we do not short-circuit on the single-run case.
const unresolved = candidates.filter((comment) => !derivedByCommentId.has(comment.id));
if (unresolved.length > 0) {
const runIdsToRead = new Set<string>();
for (const run of runs) {
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
if (runStartMs === null || runEndMs === null) continue;
for (const comment of unresolved) {
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
if (commentCreatedAtMs === null) continue;
if (
commentCreatedAtMs >= runStartMs
&& commentCreatedAtMs <= runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS
) {
runIdsToRead.add(run.runId);
break;
}
}
}
if (runIdsToRead.size > 0) {
const runsToRead = runs.filter((run) => runIdsToRead.has(run.runId));
const logByRunId = new Map<string, string>();
for (let index = 0; index < runsToRead.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) {
const batch = runsToRead.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS);
await Promise.all(
batch.map(async (run) => {
logByRunId.set(run.runId, await readRunLogText(run));
}),
);
}
const runsWithLogs = runs.map((run) => ({ ...run, logContent: logByRunId.get(run.runId) ?? "" }));
for (const [commentId, derived] of deriveIssueCommentRunLogAttribution(unresolved, runsWithLogs)) {
derivedByCommentId.set(commentId, derived);
}
}
}
const derivedByCommentId = deriveIssueCommentRunLogAttribution(candidates, runsWithLogs);
if (derivedByCommentId.size === 0) return comments;
await persistDerivedIssueCommentAttribution(derivedByCommentId);
return comments.map((comment) => {
const derived = derivedByCommentId.get(comment.id);
return derived ? { ...comment, ...derived } : comment;

View File

@ -462,6 +462,40 @@ describe("buildIssueChatMessages", () => {
});
});
it("does not reattribute a genuine board/user comment that has no derived agent", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const messages = buildIssueChatMessages({
comments: [
createComment({
authorUserId: "local-board",
authorType: "user",
// No agent ever resolved for this comment — a real board action.
derivedAuthorAgentId: null,
derivedCreatedByRunId: null,
runId: null,
runAgentId: null,
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
agentMap,
currentUserId: "user-1",
userLabelMap: new Map([["local-board", "Board"]]),
});
expect(messages[0]).toMatchObject({
role: "user",
metadata: {
custom: {
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
},
},
});
});
it("renders a comment as agent-authored when runAgentId is set from activity log", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const messages = buildIssueChatMessages({