Files
multica/server/pkg/db/queries/comment.sql
Multica Eve c3f5df8bf4 MUL-5492: fix timeline cap dropping newest entries + stop double-broadcasting descriptions (#6175)
* fix(timeline): cap the issue timeline at the newest end and report the clamp

The per-issue timeline cap was applied with ORDER BY created_at ASC LIMIT
2000, so once an issue accumulated more than 2000 comments or activities
the cap discarded the NEWEST rows. The timeline appeared to stop at some
point in the past and every later event was invisible, with nothing in
the response indicating anything was missing.

Activity is machine-paced — description autosave, every agent run, status
and assignee changes all write rows — so this was reachable in normal use,
not only on pathological issues.

Take the window with the keyset ordering (created_at DESC, id DESC) in a
subquery and re-sort ascending in the outer query. This keeps the
chronological contract for every existing caller, including the comment
list endpoint that shares ListCommentsForIssue, and is served as an
index-only scan by the idx_*_keyset indexes already added in migration
068 — no new migration, no call-site changes.

Two things beyond the ordering flip:

- Clamp both lists to a shared window floor. The two caps are applied
  independently, so each list has its own floor. Merging windows with
  different floors produces a timeline that looks continuous but, below
  the higher floor, contains only one of the two kinds — e.g. comments
  with no interleaved activity. That is worse than a timeline that
  visibly stops, because nothing about it looks wrong. Both lists are
  now clamped to the newest floor, so the result is a contiguous,
  correctly interleaved slice.

- Stop truncating silently. The unpaginated response is a bare JSON
  array with nowhere to put a flag, so the clamp is reported via
  X-Timeline-Truncated and X-Timeline-Window-From, added to
  ExposedHeaders because a custom response header is otherwise
  unreadable from browser JS. The legacy wrapped shape's has_more_before
  is now truthful instead of hardcoded false.

Queries read one row past the cap so "hit the cap" is distinguishable
from "holds exactly 2000 rows", which would otherwise report a complete
timeline as truncated and drag the other list's window down with it.

Regression tests cover all four properties and were confirmed to fail
against both the original query and a floor-less DESC flip.

Co-authored-by: multica-agent <github@multica.ai>

* perf(realtime): stop broadcasting two full descriptions on issue:updated

issue:updated carried prev_description alongside the new description in
the issue object, and the WS forwarder reuses the producer's payload map
verbatim. Every debounced description autosave therefore pushed two full
copies of the description to every connection in the workspace, including
users who did not have the issue open. The DB write is O(1); the fanout
was O(connections x description size), and it repeats on every pause in
an editing session.

prev_description and prev_title exist only for in-process listeners —
subscriber_listeners adds newly @mentioned users, notification_listeners
builds mention notifications, activity_listeners records the title
change. No client reads them: IssueUpdatedPayload in
packages/core/types/events.ts does not declare either field.

Project the payload on the way out. The bus dispatches bus.Subscribe
handlers before the SubscribeAll forwarder, so the in-process consumers
are unaffected, and projecting at the forwarder covers both the single-
node Hub and the Redis relays since that is where the frame is
serialized. The producer's map is copied rather than mutated.

The removed keys are listed in a table rather than an if on one event
type. The bug was structural, not a typo: the next large field added to
a published payload inherits the same cost silently, and a declarative
list puts the internal/external payload boundary in one reviewable
place.

issue.description itself is deliberately kept — clients apply it to
their cache, so stripping it would trade fanout bytes for N refetches.
Cutting the remaining fanout needs the per-issue scope routing already
scaffolded server-side for MUL-1138, which is blocked on the client
sending subscribe frames.

Tests assert both halves: the keys are absent from the serialized frame,
and the in-process listener still receives them.

Co-authored-by: multica-agent <github@multica.ai>

* fix(timeline): keep comment threads whole under the newest-N cap

Review found that the newest-N window can orphan a reply, and an orphaned
reply is invisible rather than merely mis-nested: the timeline builds its
top level from "activities + comments with no parent_id" and renders
replies by looking them up under their parent, so an orphan sits in the
map with no card to render it. MUL-1847 / #2263 was exactly this shape —
1 root + 29 replies, root dropped, all 29 vanished from the UI while the
API returned them.

Root cause of the regression: capping with the OLDEST n could never
orphan anything, because a reply is always newer than its parent, so a
prefix of the timeline is closed under "parent of". A newest-n window is
a suffix and has no such property. Flipping which end the cap bites
silently invalidated a structural property the comment tree relies on.

Two changes.

Drop the cross-kind clamp. The previous revision trimmed both lists to a
shared floor so the window was provably contiguous. That was the wrong
trade and it was also the dominant source of orphans. Comments are
human-paced (p99 ~30, max ever observed ~1.1k) and essentially never
reach the cap, while activity is machine-paced and reaches it routinely
— so the shared floor was almost always the activity floor deleting
comments that had been fetched successfully and would have rendered
fine. On an issue with thirty comments it was pure loss. Each list now
reports its own truncation and X-Timeline-Truncated names which kinds
were affected. Not clamping costs only activity density in the older
part of the range, which is metadata rather than content, and it is
reported rather than hidden.

Complete parent chains for the case that remains — comments themselves
exceeding the cap. ListMissingAncestorComments walks parent_id upward via
a recursive CTE and returns the ancestors not already held; the handler
merges them and restores the ascending order. This only ever ADDS rows,
so unlike clamping it cannot hide anything the caller would have seen,
and it is bounded by the number of distinct missing ancestors. Whole-
thread windowing was considered and rejected: a single thread can exceed
any row budget, so its degradation is not definable.

Applied to the shared query's default list path too, not just the
timeline. foldResolvedThreads documents a COMPLETE-thread set as its
precondition and comment.go asserts the default list mode satisfies it;
a half thread made that assertion false and a resolved thread whose root
was cut stopped folding correctly.

Also drops X-Timeline-Window-From. It was second-precision RFC3339 while
the real ordering key is (created_at, id) at full precision, so it could
not resume a read without skipping or repeating rows inside a shared
second. A resumable cursor should be opaque and carry both halves; worth
designing when there is a consumer rather than shipping as a lossy
approximation.

Tests: the reviewer's exact scenario, plus a no-orphaned-replies
invariant on both endpoints, the fold-still-works case, and a guard that
activity truncation does not delete comments. Each was confirmed to fail
with the fix disabled. TestListTimeline_JointWindowHasNoOneSidedRegion
was rewritten rather than deleted — it pinned the clamp behaviour being
abandoned here, so leaving it would lock in the wrong contract and
deleting it would drop the coverage.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): bound parent-chain completion and stop folding partial threads

Second review round on MUL-5492. Four must-fixes, all stemming from one
conflation: parent-chain closure, a newest-N window, and a complete
thread are three different things. Closure makes a reply renderable; it
does not license thread-level derivations.

Do not fold a truncated read. fetchCommentsForList closes parent chains,
but older siblings and descendants of a retained reply stay outside the
window, so the set holds partial threads. Folding them produced wrong
answers rather than incomplete ones: a resolution reply outside the
window made a resolved thread look unresolved, and folded_count reported
a total derived only from retained replies. The previous revision claimed
closure restored foldResolvedThreads' COMPLETE-thread precondition; it
did not, and that claim is removed. --recent and untailed --thread still
return whole threads and still fold.

Bound the walk. The recursive CTE climbed to the root with no depth
limit, so a deep chain could drag its entire ancestry back and defeat the
row cap it was meant to preserve. Depth is genuinely unbounded in stored
data: the general write path stores the exact comment being replied to
(only the agent path collapses to the thread root), so chains can run far
deeper than the two levels the UI renders. Replaced with a layered walk
under explicit budgets — 2000 extra rows, 64 levels — making a response
provably bounded by 4000 comments, or 6000 timeline entries with
activities.

Scope every level to the tenant. The CTE's recursive branch matched on
parent_id alone. parent_id carries a foreign key to comment(id) but not
to a matching issue, so a stray cross-issue parent reference is
representable, and the walk would have followed it into another issue's
comments. The replacement filters issue_id and workspace_id on every
level. A negative test confirms the leak: with the filter removed it
reports "a comment from another issue leaked into this issue's response".

Degrade by pruning, not by orphaning. When a budget is exhausted, a
parent row is missing, or a parent is out of scope, keepRootConnected
drops the affected comments instead of returning replies the UI cannot
render. Dropping a node also drops its descendants, since their chains
run through it. Returning fewer new replies is conservative and already
signalled as a truncated read; leaking another tenant's data, returning
an unbounded response, or emitting invisible orphans are all worse.

Also: probe read on the comment list so exactly-2000 is not misreported
as truncated, which would needlessly suppress the fold; CommentsTruncated
is carried on fetchCommentsResult rather than inferred from the result
length, which is meaningless once completion adds rows. The new query
returns db.Comment directly instead of a hand-copied row, which is how
quick_action_id came to be dropped after the rebase — a backfilled
quick-action root would have rendered as a raw prompt.

Corrected three inaccurate comments: the "index-only scan" claim (the
index avoids the sort but does not cover SELECT *), the "write path
collapses replies to root" claim, and a test header still describing the
abandoned contiguous-window behaviour.

Tests cover exactly-at-cap still folding, truncated reads not folding
(both reply-resolved and root-resolved), depth beyond budget pruned not
orphaned, shared ancestors fetched once, cross-issue parents never
crossing the boundary, and quick_action_id surviving backfill. Each was
confirmed to fail with its specific fix disabled; the reply-resolved fold
test was reshaped after the first version passed for the wrong reason.

Co-authored-by: multica-agent <github@multica.ai>

* fix(timeline): preserve complete threads under comment cap

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): preserve newest bounded views

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 14:40:16 +08:00

532 lines
23 KiB
SQL

-- name: ListCommentsForIssue :many
-- The NEWEST $3 comments for an issue, returned in chronological order.
--
-- Same shape and same reason as ListActivitiesForIssue: the inner query takes
-- the window with the keyset ordering so the cap discards the OLDEST rows, and
-- the outer query restores the ascending contract callers rely on. The ordering
-- of the inner window is satisfied by idx_comment_issue_keyset (migration 068);
-- the outer query sorts that bounded window back into chronological order.
--
-- A newest-N window is a suffix of the timeline, and unlike a prefix it is NOT
-- closed under "parent of": a reply is always newer than its parent, so an old
-- thread root can fall outside the window while a fresh reply to it stays
-- inside. Callers that render threads must close the parent chains afterwards —
-- see completeCommentThreads (MUL-5492).
--
-- The cap is still purely defensive here — issue p99 is ~30 comments and the max
-- ever observed in prod is ~1.1k — but "defensive" is not a reason to drop the
-- newest rows when it does fire (MUL-5492).
SELECT * FROM (
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at DESC, id DESC
LIMIT $3
) AS recent
ORDER BY created_at ASC, id ASC;
-- name: ListCommentsByIDsForIssue :many
-- The subset of @ids that exists within this issue and workspace.
--
-- Used to walk parent chains one level at a time (see completeCommentThreads).
-- Deliberately NOT a recursive CTE: an earlier revision walked parent_id upward
-- in SQL, which had no depth bound — a deep chain could pull tens of thousands of
-- ancestors back and defeat the whole point of the row cap — and its recursive
-- branch matched on parent_id alone, so a stray cross-workspace parent reference
-- would have dragged another tenant's comments into the response. Both tenant
-- columns are required here on every level, and the caller owns the budget.
SELECT * FROM comment
WHERE id = ANY(@ids::uuid[])
AND issue_id = @issue_id
AND workspace_id = @workspace_id
ORDER BY created_at ASC, id ASC;
-- name: ListChildCommentsForParents :many
-- Fetch one descendant level for a bounded set of parent comments. Each parent
-- maps back to its thread root in the Go breadth-first walk, avoiding one query
-- per root.
--
-- Both tenant predicates apply at every level. row_limit is always a probe
-- limit owned by completeCommentThreads; it prevents a wide level from defeating
-- the response budget. through_at/through_id pin the walk to the newest row in
-- the original window, so replies created concurrently are left to realtime
-- delivery instead of making the multi-query snapshot internally inconsistent.
SELECT * FROM comment
WHERE parent_id = ANY(@parent_ids::uuid[])
AND issue_id = @issue_id
AND workspace_id = @workspace_id
AND (created_at, id) <= (@through_at::timestamptz, @through_id::uuid)
ORDER BY parent_id ASC, created_at ASC, id ASC
LIMIT @row_limit;
-- name: ListCommentsSinceForIssue :many
-- Comments created strictly after $3 in chronological order, capped at $4.
-- Powers the CLI's `--since` agent-polling flow.
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC, id ASC
LIMIT $4;
-- name: ListRootCommentsForIssue :many
-- Top-level comments only, in issue chronological order, each annotated with
-- per-thread orientation stats: reply_count (number of descendants) and
-- last_activity_at (MAX(created_at) over the whole subtree). This powers
-- `comment list --roots-only` so agents can not only orient around the global
-- discussion but also triage which thread to drill into (biggest / most
-- recently active) before fetching any specific reply thread.
--
-- `selected_roots` takes the newest @row_limit roots. The final SELECT restores
-- chronological order, so the defensive cap drops the oldest roots without
-- changing the wire order. The recursive `membership` walk only expands those
-- selected threads rather than every thread in the issue, and labels every
-- descendant with its root so the stats stay correct at any reply depth.
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
AND c.parent_id IS NULL
ORDER BY c.created_at DESC, c.id DESC
LIMIT @row_limit
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC;
-- name: ListRootCommentsSinceForIssue :many
-- Top-level comments created strictly after @since, each annotated with the
-- same reply_count / last_activity_at stats as ListRootCommentsForIssue. The
-- @since filter narrows which roots are returned; the stats are still computed
-- over each selected thread's full subtree (so a freshly created root with no
-- replies reports reply_count 0 and last_activity_at = its own created_at).
-- selected_roots applies the @since + @row_limit cut up front so the recursive
-- membership walk only touches the subtrees of the roots we actually return.
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
AND c.parent_id IS NULL
AND c.created_at > @since
ORDER BY c.created_at ASC, c.id ASC
LIMIT @row_limit
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC;
-- name: ListThreadCommentsForIssuePaged :many
-- Resolves @anchor_id to its thread root, recursively expands every descendant,
-- and returns the root + only the @reply_limit most recent replies (per the
-- (created_at, id) composite key). When @has_cursor=TRUE only replies with
-- (created_at, id) < (@before_at, @before_id) are eligible — that is the
-- cursor for scrolling *within* a thread.
--
-- Root is unconditional: it is included regardless of @reply_limit (even 0)
-- and regardless of the cursor. A reader landing on a long thread needs the
-- root for the "what is this thread about" context, even if every reply has
-- been paginated past.
--
-- Reply selection happens DESC (newest replies first) so the cursor walks
-- toward older replies; the outer SELECT then re-sorts the combined output
-- ASC so the body stays chronological (oldest → newest), matching every
-- other comment list path.
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = @anchor_id AND c.issue_id = @issue_id AND c.workspace_id = @workspace_id
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
WHERE p.issue_id = @issue_id AND p.workspace_id = @workspace_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = @issue_id AND c.workspace_id = @workspace_id
),
reply_page AS (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id,
d.source_task_id, d.quick_action_id
FROM descendants d
WHERE d.id NOT IN (SELECT id FROM thread_root)
AND (
@has_cursor::boolean = FALSE
OR (d.created_at, d.id) < (@before_at::timestamptz, @before_id::uuid)
)
ORDER BY d.created_at DESC, d.id DESC
LIMIT @reply_limit
)
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id,
source_task_id, quick_action_id
FROM (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id,
d.source_task_id, d.quick_action_id
FROM descendants d
JOIN thread_root tr ON d.id = tr.id
UNION ALL
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id,
source_task_id, quick_action_id
FROM reply_page
) combined
ORDER BY created_at ASC, id ASC;
-- name: ListRecentThreadCommentsForIssue :many
-- Returns the N most recently active threads (root + every descendant) rather
-- than the N most recent rows. A thread's "last activity" is MAX(created_at)
-- over its whole subtree; threads are ranked by (last_activity_at DESC,
-- root_id DESC) and the top N are expanded.
--
-- Why thread-grouped instead of row-recent: with row-recent the newest 20
-- comments can come from 8 different threads — the agent sees 8 unrelated
-- tails. With thread-grouped the agent sees N complete conversational arcs,
-- which matches how a human reads an issue (#2340).
--
-- Response ordering:
-- threads: (thread_last_activity_at ASC, root_id ASC)
-- in-thread: (created_at ASC, id ASC)
-- So the oldest-active thread appears first and the most recently-active
-- thread is at the tail, closest to "now" in an agent prompt.
--
-- Cursor scrolls back through threads. When @has_cursor=TRUE only threads
-- with (last_activity_at, root_id) < (@before_at, @before_id) are eligible.
-- The cursor is a THREAD cursor — both values identify a thread (its last
-- activity timestamp and its root comment id), not a single row.
--
-- The recursive `membership` CTE labels each comment with its thread root by
-- walking down from every root. It does not assume any maximum nesting depth,
-- which preserves correctness even if the schema ever allows reply-of-reply
-- (the agent path in TaskService.createAgentComment collapses to root today,
-- but the user-facing CreateComment handler does not enforce it).
WITH RECURSIVE membership(id, root_id, comment_created_at) AS (
-- Each root maps to itself.
SELECT c.id, c.id AS root_id, c.created_at
FROM comment c
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
AND c.parent_id IS NULL
UNION ALL
-- Each descendant inherits its parent's root_id.
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
),
thread_stats AS (
SELECT root_id, MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
),
picked AS (
SELECT ts.root_id, ts.last_activity_at
FROM thread_stats ts
WHERE (
@has_cursor::boolean = FALSE
OR (ts.last_activity_at, ts.root_id) < (@before_at::timestamptz, @before_id::uuid)
)
ORDER BY ts.last_activity_at DESC, ts.root_id DESC
LIMIT @thread_limit
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id,
p.root_id AS thread_root_id,
p.last_activity_at AS thread_last_activity_at
FROM picked p
JOIN membership m ON m.root_id = p.root_id
JOIN comment c ON c.id = m.id
ORDER BY p.last_activity_at ASC, p.root_id ASC, c.created_at ASC, c.id ASC;
-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2;
-- name: CountNewCommentsSince :one
-- Counts comments on an issue created strictly after @since, ACROSS THE WHOLE
-- ISSUE (every thread, not just the triggering one). Excludes the triggering
-- comment itself (@anchor_id — its body is already injected into the prompt)
-- and any authored by the given agent (@author_id), so a chatty agent does not
-- inflate its own new-comment count. The agent is steered to read the
-- triggering thread first (see BuildNewCommentsHint), but the count is
-- issue-wide so it knows the full catch-up volume. Feeds the daemon claim
-- response without shipping comment bodies.
SELECT count(*) FROM comment
WHERE issue_id = @issue_id
AND workspace_id = @workspace_id
AND created_at > @since
AND id <> @anchor_id
AND NOT (author_type = 'agent' AND author_id = @author_id);
-- name: GetLatestMemberCommentForIssueSince :one
-- MUL-4195 completion reconciliation: the newest MEMBER-authored comment on an
-- issue created strictly after @since (a run's started_at). Used when a task
-- completes to detect deliberate user input that landed while the agent was
-- busy — or that was merged into the running task after its context was
-- already built — so a single follow-up run can be scheduled for it. Restricted
-- to author_type = 'member' on purpose: only human input earns the guaranteed
-- follow-up, which preserves the existing anti-loop guarantees (agent replies,
-- acknowledgements, and self-triggers never qualify). Returns pgx.ErrNoRows
-- when nothing newer exists, i.e. the run already covered the latest input.
SELECT * FROM comment
WHERE issue_id = @issue_id
AND author_type = 'member'
AND created_at > @since
ORDER BY created_at DESC
LIMIT 1;
-- name: ListReconcilableCommentsForIssueSince :many
-- MUL-4195 / MUL-4304 completion reconciliation: every MEMBER- or AGENT-authored
-- comment on an issue created strictly after @since (the completing run's
-- created_at anchor), plus every id in its planned trigger/coalesced batch.
-- Planned ids matter for retry children because their input comments predate
-- the child's created_at; if one could not be embedded at claim time it still
-- needs reconciliation. The handler excludes only delivered_comment_ids, then
-- replays the remainder through the normal trigger pipeline oldest first.
--
-- Author-type scope (MUL-4304): originally restricted to author_type = 'member'.
-- That left a gap — an explicit agent→agent @mention (agent A comments
-- `@agent B`) that landed while B already had a DISPATCHED task was dropped by
-- the create-time enqueue path (merge only folds into a QUEUED task, so a
-- dispatched target hits the merge-miss + active-task continue) and then never
-- compensated here, because agent-authored comments were excluded. We now also
-- return 'agent' comments so those explicit mentions can be replayed.
--
-- This does NOT reopen the anti-loop guarantees the member-only filter was
-- protecting. The reconcile pass runs each returned comment through
-- computeCommentAgentTriggers under its OWN author_type, and for an agent author
-- it then keeps ONLY explicit @agent/@squad mention triggers
-- (keepExplicitMentionTriggers) — the assigned-squad-leader fallback and all
-- other conversational routing are dropped, so a plain agent reply /
-- acknowledgement yields nothing regardless of issue assignment. The reconcile
-- pass further keeps only triggers routing to the agent that just completed, so
-- an agent comment can never fan out to an unrelated agent. Ordered ASC so
-- replaying in order lets later comments coalesce onto the follow-up created by
-- the first.
SELECT * FROM comment
WHERE issue_id = @issue_id
AND author_type IN ('member', 'agent')
AND (
created_at > @since
OR id = ANY(@planned_comment_ids::uuid[])
)
ORDER BY created_at ASC, id ASC;
-- name: GetComment :one
SELECT * FROM comment
WHERE id = $1;
-- name: GetCommentInWorkspace :one
SELECT * FROM comment
WHERE id = $1 AND workspace_id = $2;
-- name: GetThreadRoot :one
-- Returns the thread-root comment for @comment_id by walking parent_id up to
-- the row whose parent_id IS NULL. For a root comment it returns that comment
-- itself. Used when callers need thread-level behavior while parent_id remains
-- the exact direct parent of a reply. Cycle-safe under the PK constraint (a
-- comment cannot be its own ancestor).
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = @comment_id AND c.workspace_id = @workspace_id
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
)
SELECT c.* FROM comment c
WHERE c.id = (SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1);
-- name: CreateComment :one
-- A new comment counts as activity on its issue, so the same statement bumps
-- the parent issue's updated_at. The touch is a leading data-modifying CTE and
-- the INSERT selects the issue/workspace back out of it, which makes the two
-- inseparable and gives two query-level guarantees:
-- * atomicity — the insert and the timestamp bump commit or roll back
-- together, so an issue is never left with a stale updated_at after a
-- comment persists; and
-- * tenant integrity — the comment can only be created against an issue that
-- actually exists in the given workspace. A mismatched (issue, workspace)
-- pair matches 0 rows in the CTE, the dependent INSERT then selects nothing,
-- and the :one query returns pgx.ErrNoRows. A wrong workspace can therefore
-- never leave a mis-attributed comment or a silently un-touched issue.
-- Centralizing this here means every comment entrypoint inherits both
-- guarantees regardless of what a caller passes. The "Updated date" sort and
-- the daemon GC TTL both read updated_at, so this consistency is load-bearing.
WITH touched_issue AS (
UPDATE issue SET updated_at = now()
WHERE issue.id = sqlc.arg(issue_id) AND issue.workspace_id = sqlc.arg(workspace_id)
RETURNING issue.id, issue.workspace_id
)
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id, source_task_id, quick_action_id)
SELECT ti.id, ti.workspace_id, sqlc.arg(author_type), sqlc.arg(author_id), sqlc.arg(content), sqlc.arg(type), sqlc.narg(parent_id), sqlc.narg(source_task_id), sqlc.narg(quick_action_id)
FROM touched_issue ti
RETURNING *;
-- name: UpdateComment :one
UPDATE comment SET
content = $2,
source_task_id = sqlc.narg(source_task_id),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: HasAgentCommentedSince :one
SELECT EXISTS (
SELECT 1 FROM comment
WHERE issue_id = @issue_id
AND author_type = 'agent'
AND author_id = @author_id
AND created_at >= @since
) AS commented;
-- name: HasAgentRepliedInThread :one
-- Returns true if the given agent has posted a reply in the thread rooted at
-- the specified parent comment. Used to detect agent participation in a
-- member-started thread so that follow-up member replies still trigger the agent.
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = @parent_id AND author_type = 'agent' AND author_id = @agent_id;
-- name: DeleteComment :exec
-- Defense-in-depth: workspace_id is a SQL-layer tenant guard. See DeleteIssue.
DELETE FROM comment WHERE id = $1 AND workspace_id = $2;
-- name: ResolveComment :one
-- Idempotent: re-resolving keeps the original resolved_at + resolver. Always
-- returns the row so the handler can surface the canonical state.
UPDATE comment SET
resolved_at = COALESCE(resolved_at, now()),
resolved_by_type = COALESCE(resolved_by_type, $2),
resolved_by_id = COALESCE(resolved_by_id, $3),
updated_at = CASE WHEN resolved_at IS NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING *;
-- name: ClearOtherThreadResolutions :many
-- Single-resolution invariant: a thread has at most one resolved comment.
-- Resolving @target_id makes it the sole resolution, so this clears resolved_at
-- on every OTHER currently-resolved comment in the same thread (the root of
-- @target_id plus every descendant). The handler runs this in the SAME tx as
-- ResolveComment so the replace is atomic — a crash can never leave two
-- resolutions or zero. Scope is the thread only (id IN descendants AND
-- id <> @target_id), never the whole issue. Returns each cleared row so the
-- handler can emit a comment:unresolved event per row; granular realtime
-- consumers replace a single comment in place and would otherwise keep
-- displaying the stale resolution.
WITH RECURSIVE root_of AS (
-- Walk up from the target to its thread root.
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = @target_id AND c.issue_id = @issue_id AND c.workspace_id = @workspace_id
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
-- Expand back down from the root over the whole subtree. Cycle-safe under
-- the PK constraint (a comment cannot be its own ancestor).
SELECT c.id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = @issue_id AND c.workspace_id = @workspace_id
)
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
updated_at = now()
WHERE comment.id IN (SELECT id FROM descendants)
AND comment.id <> @target_id
AND comment.resolved_at IS NOT NULL
RETURNING *;
-- name: UnresolveComment :one
-- Idempotent: a no-op clear (already unresolved) just returns the row.
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
updated_at = CASE WHEN resolved_at IS NOT NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING *;