mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* 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>
532 lines
23 KiB
SQL
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 *;
|