Files
multica/server/internal/handler/comment.go
Yushan 3551e72e76 MUL-7232 feat(daemon): add dsh desktop(self-hosted) support (#8237)
* feat(daemon): discover the DSH CLI bundled in DeepSeek Harness Desktop

DeepSeek Harness Desktop ships its CLI inside the .app and never installs
`dsh` onto PATH, so a GUI-launched daemon missed it on exec.LookPath — and the
login-shell sweep could not rescue it either, because no rc file has a reason
to know that path. Only a hand-made symlink made dsh visible at all.

Add the app-bundle fallback the Codex provider has had since #5205. The
candidate is a Node script rather than a native binary, so it counts only while
it is executable: an entry discovered without the executable bit would be
advertised as a healthy runtime and then fail on every spawn.

The Multica runtime profile gate still applies — a bundled CLI without the
profile is not a runtime.

* fix(daemon): report and install the missing DSH runtime profile

probeAgentCLIs is documented as pure discovery, but dsh's runtime-profile gate
lived inside it — one layer above the verdict machinery that turns a drop into
a /health skipped_agents reason and a daemon-log line. A dsh binary without the
profile therefore vanished from the runtime list with nothing anywhere saying
why, which is the shape the DSH reports in #7721 and #6936 run into.

Move the gate down to probeBuiltinRuntime and give it its own verdict: dsh is
now discovered like any other CLI, and failing to use it is a reported,
actionable drop. The verdict is deterministic, so it demotes immediately rather
than waiting out a confirmation window — it is a fact about the CLI's
environment, not about bytes mid-write. The executable is checked first,
because probeDshMulticaProfile cannot tell "the profile refused" from "there
was nothing to run", and would otherwise blame a missing profile for a path
that vanished under us.

Reporting is only half of it: DSH is the one built-in whose CLI is not enough
on its own, and no other provider needs anything installed into the agent's own
home. MULTICA_DSH_PROFILE_BUNDLE now names what to install — an npm spec, a
directory or a tarball, candidates tried in order. Unset stays the default and
means "do not touch the user's DSH installation"; there is nothing to point it
at yet either, because Multica's own bridge is unpublished (#6936).

DSH Desktop ships the pnpm that `dsh plugin` forwards to inside its own
runtime-commands directory and injects it only for the processes it spawns, so
the install prepends that directory to PATH. Without it the command fails with
"pnpm not found on PATH" on exactly the machines this is for. The install runs
once per daemon, and the discovery loop is kicked when it finishes so dsh comes
online immediately instead of at the next tick.

* fix(daemon): keep a registered dsh runtime in step with its runtime profile

Two states have to agree: the Multica runtime profile is installed, and a dsh
runtime is registered. Neither disagreement corrects itself, and both leave the
server routing work into a CLI that cannot start.

When the profile is removed, dsh stays discovered and keeps its runtime, so
nothing else in the discovery loop looks at it again. Observed in the field: a
profile removed a minute after startup stayed live for three minutes and a chat
task failed against it. When a profile is installed by hand, the runtime is
missing but the converge backoff has already doubled to agentConvergeMaxBackoff
(30m) over the rounds that could not register it, so the fix stays invisible
for up to half an hour.

Compare the two states on every tick and force a round on disagreement, in
either direction. A round forced for a registered provider has nothing missing
to register, so convergeRuntimeRegistrations now consumes the verdict map it
used to discard — safe because demoteUnusableRuntimes owns the claim barrier
and the seq-stamped hold, and stays the single owner of demotion.

The profile's presence is a stat of the manifest DSH itself looks for, so the
check costs one syscall per tick; the authoritative --probe stays inside the
probe round the check forces.

* docs(runtime): document the DSH runtime-profile variables

The two variables this branch adds were undocumented, and the install guide
still told readers to run `dsh plugin --profile multica add <bundle>` without
ever saying what <bundle> is — the hole #6936 was filed against.

Document MULTICA_DSH_PROFILE_BUNDLE and MULTICA_DSH_PLUGIN_PATH next to the
other MULTICA_DSH_* variables, and name the bundle in the DSH setup step: what
implements the protocol, where the bridge lives, and that the daemon reports
the missing profile on /health rather than registering a runtime that cannot
start. All four locale files, so the translations do not drift.

* refactor(agent): export the DSH stdio protocol version

The daemon's runtime-profile probe accepts or rejects a profile on this exact
number: one answering `--probe` with a protocol this backend does not drive
must not be registered. That decision was about to be made against a second
copy of the constant in internal/daemon, and a second copy is one that can
drift from the code actually speaking the protocol.

Export it so the probe reads the number from its only honest source. No
behaviour change: the value is still 1.

* fix(redact): mask credentials carried in URL userinfo

The connection-string rule covers postgres, mysql, mongodb, redis and amqp
only, so `https://user:token@registry/...` passed through untouched. That shape
is not hypothetical for agent output: npm and pnpm accept an authenticated
registry URL as a package spec, and both the package manager and the registry
echo the request URL back in their error text, which is how it reaches a log
line without anyone logging the spec itself.

Add a scheme-agnostic userinfo rule. The scheme and host survive — which
registry refused the request is the part an operator reads, and it is not the
secret — and userinfo requires both a colon and an `@` before the next path
separator, so `https://host:8443/p`, `ssh://git@host/p` and a bare `a:b` in a
path are all left alone. Tests cover both directions.

* fix(daemon): decide the DSH runtime-profile verdict on evidence

probeDshMulticaProfile returned a bare bool, so a timeout, a non-zero exit,
unparseable output, a future protocol version and a genuinely absent manifest
all collapsed into "the profile is not installed" — a verdict that demotes a
live runtime and, with a bundle configured, installs over the user's DSH home.
This rebuilds the verdict layer around what was actually observed.

Classification. The probe now returns one of OK / missing / incompatible /
unavailable. The frame decides, not the exit status: a profile that printed a
valid answer has answered, whatever the process did on its way out — which also
stops a process-tree lifecycle error from being read as a protocol failure.
With no usable frame, a cancelled ROUND stays transient (the daemon is shutting
down; that says nothing about the profile) and otherwise the manifest decides.
A manifest that IS present keeps every failure transient, which is what
protects a working install during an upgrade.

Timeouts are not transient by themselves. Asked for a profile it does not have,
DSH does not refuse `--probe` — it hangs, printing nothing, until something
kills it. Measured: still running after 10s, zero bytes on both streams. So a
timeout is the ordinary way a missing profile presents, and treating it as
transient made the profile undetectable on exactly the host the automatic
install exists for. The manifest is the same file DSH's own loadProfile
consults before refusing to boot, so a confirmed-absent one is evidence.

Order. The profile probe now runs only after version detection succeeds.
exec.LookPath is far too weak a proxy for "this CLI works": on Windows the CLI
is a .cmd shim it resolves happily and which exits 9009 — cmd.exe's "command
not found" — when what it forwards to is missing. Probing the profile first
reported such a machine as missing its runtime profile, a repair for a problem
it did not have.

Demotion. An incompatible profile is reported and never demoted: this daemon
can be the stale side of a protocol skew, and tearing down a live runtime
because THIS build is behind is a destructive answer to a version question.
The confirmation window that gated the not-executable verdict now gates every
condemnable verdict, keyed per verdict so a provider moving between states
starts a fresh clock rather than inheriting one earned by another problem.

Convergence. A registered dsh whose profile was removed forces a round on every
tick, because condemning it takes two of them and nothing else schedules the
second: with dsh still registered nothing is missing a runtime, so an unforced
tick returns without probing at all. The mirror direction — a profile with no
runtime — stays throttled to one forced round, since no round is guaranteed to
resolve it and forcing would bypass agentConvergeMaxBackoff forever.

Offline cause. A missing profile reaches the server as a structured
`dsh_profile` code rather than a bare offline, and carries whether the daemon
is installing the profile right now — the one case where queueing is right.
That claim is withdrawn if the install gives up: the runtime row is written
once, by the demotion that also removes the runtime from the index, so nothing
later would correct it and the work would queue forever behind an install that
already stopped.

Provisioning. Runs under the daemon's lifetime through processtree, so the
pnpm/node descendants cannot outlive a shutdown and keep writing into the DSH
home. Success is the manifest on disk, not the package manager's exit status,
and a candidate that exits 0 without producing a profile is a failed candidate
so the next one still gets its turn. The bundle spec no longer reaches a log
line — an npm URL can carry credentials and a path a username — and captured
output is redacted and bounded on a rune boundary. The PATH merge matches the
variable case-insensitively, because Windows spells it `Path` and an exact
`PATH=` miss left the package manager with only the plugin directory under Go's
case-insensitive de-duplication.

Startup. A daemon that registered nothing continues when it has just begun
installing the profile itself. On a host whose only provider is a dsh without
one, failing here is a deadlock rather than a fail-fast: the probe starts the
install, the error kills the process milliseconds later, the install's process
tree dies with it, and the next start repeats all of it. Nothing else is
forgiven. Because such a daemon tracks no workspace, a finished install picks
the workspace up before converging.

Repair. The reported repair names the missing package and carries no command:
the install needs a bundle only the operator can choose, and Repair.Command is
rendered to the user inside a shell fence as the line to run, so a placeholder
there is a paste-and-fail instruction.

Tests. The classification matrix, "manifest present + timeout / non-zero exit /
malformed output / future protocol ⇒ no demotion, no provisioning", the 9009
shim, the forced-round sequence, the withdrawal, the startup exception, the
empty install, and the case-insensitive PATH merge. Two existing tests read the
developer's real ~/.dsh and one fake dsh could not answer `--version` at all —
both now pinned to the test's own directory and to the shape the feature is
actually about.

* feat(daemon): discover the CLI shims DSH Desktop generates

The macOS fallback pointed at one path inside the app bundle, and Windows had
none at all — every candidate there was a /Applications path that cannot exist,
so a Windows host running DSH Desktop reported no dsh whatsoever. Replace it
with one layout that serves both platforms.

The CLI is not in the install directory. DSH Desktop generates shims under its
own per-user data directory — ~/Library/Application Support/DSH Desktop on
macOS, %APPDATA%\DSH Desktop on Windows — and on Windows that is roaming
application data even though the app installs to Program Files or
%LOCALAPPDATA%\Programs. One lookup therefore covers both install modes, since
a per-machine install still generates these shims per user.

Payload directories are content hashes (and, under host-commands, a hash plus a
generation uuid), so they are enumerated rather than composed. Only directories
holding a bin/ are accepted, so a stray file or half-extracted download is
skipped; modification time orders them so an upgrade's new payload wins over an
abandoned one, and the name breaks ties because ReadDir order is not a promise
and the daemon must not pick a different CLI on different rounds.

host-commands outranks cli, and that is load-bearing: nothing here executes a
candidate, so a broken-but-present shim is indistinguishable from a working one
at this layer — and on the install that prompted this, the cli shim was exactly
that, resolvable by exec.LookPath and exiting 9009. Ranking is the only lever.

Generated shims also outrank the macOS app-bundle script, which is entered
through `#!/usr/bin/env node` and so only works where node happens to be
installed; a shim runs the Desktop app's own runtime by absolute path and needs
nothing on PATH. That gap was latent on macOS too.

executableCandidate had to learn the platform difference for any of this to
work: Go synthesizes a regular file's mode from the read-only attribute and
never sets an executable bit on Windows, so the Unix test rejected every
candidate and the whole fallback would have been dead code there. Windows uses
exec.LookPath, the same check the launcher makes, so a candidate accepted here
is one that can actually start — which is also why the Node script has no
Windows counterpart and .js never appears in that list.

Only default locations are covered, and nothing reads an install receipt or the
registry, so a relocated install still needs MULTICA_DSH_PATH.

The path builders take goos/env/home as arguments rather than reading
runtime.GOOS and os.Getenv, so the Windows layout is asserted from any host —
the alternative leaves it testable only on Windows, which in this package has
meant untested.

* fix(server): refuse a missing DSH runtime profile under its own reason

A runtime taken offline for a missing profile reached the server as a bare
offline, so runtimeVerdict classified it as waitable/runtime_offline and
assignments and @mentions bound to that agent queued forever — even though,
with no automatic install configured, that wait never resolves on its own.
That contradicts the semantics settled in MUL-6164: when a human has to
intervene, refuse the trigger and say how to fix it.

Blocked, but not as runtime_unusable. The two causes have opposite repairs: an
unrunnable CLI is reinstalled, while a CLI missing its runtime profile is
working perfectly and reinstalling it changes nothing. Sharing one code meant
every client told DSH users to reinstall a CLI that was never the problem — the
chip and toast said "reinstall it there", and the durable system comment left
on the issue opened with "its CLI is installed but cannot be executed on that
machine", blamed a blocked npm postinstall, and rendered `dsh plugin --profile
multica add <bundle>` inside a bash fence as the line to run on that machine. A
placeholder presented as a command is the shape of instruction people paste
verbatim and then report as broken.

So: a new runtime_profile_missing reason code, carried through readiness,
dispatch and the localized copy in all four languages. The notice branches on
the reason rather than on whether a repair command happened to be present, and
the profile text names what is missing, says the CLI is fine, and points at the
two ways to supply a real bundle without printing a command to copy.

An install the daemon is running right now is the exception: that wait does end
by itself, so the work queues instead of being refused.

RuntimeBlockedNeedsNotice replaces three inline comparisons, because a code
added to one admission path and not the others is a trigger that vanishes with
no trace on exactly the surfaces that have no response for the user to read.

* docs(runtime): document DSH profile provisioning and shim discovery

Automatic provisioning is a new, persistent feature surface, and it had no
product surface: an admin had no documented way to configure it, and the one
paragraph that existed attached the install semantics to the wrong variable —
the sentences describing what MULTICA_DSH_PROFILE_BUNDLE does followed the
MULTICA_DSH_PLUGIN_PATH sentence, so "the variable is unset by default and the
install it enables is bounded" read as being about a variable that enables no
install, and PLUGIN_PATH was then described twice.

Environment variables, in all four languages: the install semantics move onto
the variable they belong to, and now state the bounds — at most once per daemon
lifetime, only on a confirmed-absent profile, failures logged with the package
manager's own output and retried only after a restart, a timeout or an
unsupported protocol version reported and never installed over. Plus how to
diagnose (search the daemon log for `DSH runtime profile`) and how to roll back
(delete $DSH_HOME/profiles/multica). MULTICA_DSH_PLUGIN_PATH is described once,
and Windows and Linux are named as the platforms with no bundled pnpm
directory, where the daemon uses whatever is on PATH unless this points at one.

Install guide, in all four languages: what to actually put in the variable.
Multica's bridge is not on a public npm registry yet (multica#6936), so a
self-hosted team builds it from the repository and points at the build
directory, or at the tarball npm pack produces when the daemon host is not the
build host; a package name becomes the simplest option once it is published.
With a warning that belongs on this value specifically — it is installed into
every daemon host's DSH home without further confirmation, so an unvetted
third-party package is a supply-chain decision, not a convenience.

Also documents where the daemon looks for a Desktop-managed CLI, including the
detail that trips people on Windows: it is roaming application data, not the
install directory. And the one exception to "the daemon must detect at least
one built-in CLI before it will start" — a daemon that has just begun installing
the profile itself starts with no runtimes, because otherwise a host whose only
tool is a DSH needing that profile could never finish the install.

* fix(daemon): find DSH Desktop's bundled pnpm on Windows too

The automatic install shells out to `dsh plugin`, which forwards to pnpm, and
the daemon inherits no path to the one DSH Desktop ships. The lookup composed a
single macOS path and was commented as macOS-only, so on Windows the install
failed with "pnpm not found on PATH" unless the operator set
MULTICA_DSH_PLUGIN_PATH by hand — on exactly the hosts the automatic install
exists for.

Both platforms bundle it under runtime-commands in the app's own per-user data
directory, but not in the same shape: macOS 2.0.5 keeps a flat
runtime-commands/bin, while a Windows install keeps
runtime-commands/generations/<id>/bin. Composing either path leaves the other
broken, so the lookup now shares the enumerator with CLI-shim discovery and
searches both shapes at both levels. A Desktop version that moves between them
is then a version this daemon keeps working with rather than one it silently
stops supporting.

Sorting is pooled per tier rather than per directory. Doing it per directory
made the answer depend on the order ReadDir returned DSH profile names, which
is the same non-determinism the name tie-break exists to remove: the newest
generation has to win even when it belongs to a profile that sorts later. An
existing test caught this while the enumerator was being extracted.

An explicit MULTICA_DSH_PLUGIN_PATH now replaces the search instead of merely
being tried first. An operator who pinned a directory is bypassing the bundled
pnpm on purpose, and quietly falling back to it would defeat that.

Documented in all four languages, where the previous text stated the opposite —
that Windows has no bundled directory.

* fix(server): bound the in-flight DSH install claim instead of trusting it

`installing` tells the server to queue triggers rather than refuse them, so a
claim that outlives its install recreates exactly the failure the structured
reason was added to prevent: work queued behind something that already stopped.

The withdraw cannot be made to guarantee that. Three paths leave the claim on
the row. The verdict reads dshInstallInFlight when it is built, but the wait is
recorded later, when the demotion knows the runtime ids — an install that fails
in between finds an empty list, and the `installing` written afterwards is never
withdrawn. The corrective Deregister is best-effort, and during shutdown it runs
on an already-cancelled lifecycle context. And a daemon restarted mid-install
does not track the row at all.

Bound the claim instead. SetAgentRuntimeOfflineWithReason stamps updated_at, and
the install it describes is itself bounded by the daemon's provisioning timeout,
so honouring `installing` only inside a window comfortably longer than that
covers a real install while capping every way the claim can be left behind. A
row with no usable timestamp cannot be bounded and is not honoured: refusing
during a real install costs one retry the notice already asks for, while
honouring a stale claim costs work that queues until it expires.

The withdraw stays as the fast path — this is the floor under it.

* refactor(daemon): scrub URL credentials in provisioning output, not globally

The rule was added to pkg/redact's shared pattern list, where redact.Text runs
over every agent's task messages, comments and error text — so a fix aimed at
one package manager's output changed what every provider emits. The gap is real
and still worth closing in that list, but widening a shared filter is its own
change with its own blast radius, and it does not belong in a PR about DSH.

Move it to dshProvisionOutput, which is the only place this PR needs it: a
bundle spec can be an authenticated npm URL, and both the package manager and
the registry echo the request URL back in their error text. The shared redactor
still runs after it for the token shapes it already knows.

Behaviour for the provisioning log line is unchanged; behaviour for every other
provider returns to main's. Same assertions as before, moved next to the code
they now cover, plus the false-positive cases — a port, an scp-style git remote,
a bare colon in a path — and one asserting the host survives, since which
registry refused the request is what an operator reads and is not the secret.

* fix(daemon): let a converge round condemn dsh only

convergeRuntimeRegistrations started consuming the verdict map it used to
discard, so that a round forced by a DSH profile mismatch could reach demotion —
a registered dsh whose profile was removed leaves nothing missing a runtime, so
the round has no registration left to do and would otherwise return having done
nothing.

But it acted on every provider's verdict, not just dsh. On main, demotion is
reached from refreshAgentVersions alone, on its own ten-minute cadence, and that
is the schedule every provider's verdict was tuned against. Claude Code, Codex
and the rest would have moved onto a different one as a side effect of a DSH
fix — a behaviour change for providers this PR has no reason to touch.

Narrow it to dsh. It is the only built-in with a precondition that can change
without any version changing, which is why it is the only one that needs a round
it can be forced into; every other provider keeps main's behaviour exactly.
2026-09-12 11:25:56 +08:00

3794 lines
156 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/dbid"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type CommentResponse struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Revision int64 `json:"revision"`
IssueRevision int64 `json:"issue_revision,omitempty"`
ResolvedAt *string `json:"resolved_at"`
ResolvedByType *string `json:"resolved_by_type"`
ResolvedByID *string `json:"resolved_by_id"`
SourceTaskID *string `json:"source_task_id,omitempty"`
// QuickActionID marks a comment produced by a quick action run (MUL-5465).
// The timeline renders those as a collapsed one-line card instead of the
// raw prompt body. It is NOT settable through this endpoint — there is no
// request field for it — which is exactly why the card keys off this id
// rather than a `type` value the client controls.
QuickActionID *string `json:"quick_action_id,omitempty"`
Reactions []ReactionResponse `json:"reactions"`
Attachments []AttachmentResponse `json:"attachments"`
// Orientation stats — populated only on the roots_only path and omitted in
// every other mode, so the default response shape stays byte-identical for
// existing callers. ReplyCount is the number of descendants in the thread;
// LastActivityAt is the MAX(created_at) across the whole subtree. Together
// they let an agent triage which thread to drill into without fetching any
// replies.
ReplyCount *int `json:"reply_count,omitempty"`
LastActivityAt *string `json:"last_activity_at,omitempty"`
// ContentTruncated is set only under summary=true: true when Content was
// clipped to the summary budget, false when it fit. nil (omitted) means the
// caller did not request a summary projection, so Content is verbatim.
ContentTruncated *bool `json:"content_truncated,omitempty"`
// Fold projection fields — populated only under fold=true, and only on the
// thread ROOT of a resolved thread (mirrors the human timeline fold; see
// foldResolvedThreads). Both are nil/omitted everywhere else: every comment
// under an unresolved thread, and every non-root comment. ThreadResolved
// marks a thread whose discussion was collapsed to its conclusion;
// FoldedCount is how many comments in that thread were dropped from the
// response (0 when the thread was already at root + conclusion). The reader
// pulls the folded comments back with `comment list --full`.
ThreadResolved *bool `json:"thread_resolved,omitempty"`
FoldedCount *int `json:"folded_count,omitempty"`
// TriggerOutcomes is the per-target result of every EXPLICIT @agent / @squad
// mention in this comment (MUL-4525 §2). It is additive and populated only on
// create/edit responses: old clients ignore it. A saved comment whose mention
// was blocked (no invoke permission, target unavailable, runtime offline) now
// reports that here instead of silently dropping the trigger, so the client
// can show "comment posted, but N targets were not triggered".
TriggerOutcomes []CommentTriggerOutcome `json:"trigger_outcomes,omitempty"`
}
// CommentTriggerOutcome is the per-target result of an explicit @agent / @squad
// mention (MUL-4525 §2). target_id is the id the user mentioned — the agent id,
// or the SQUAD id for a squad mention — so the client correlates it back to the
// mention it rendered without the server echoing a private target's name/owner.
// reason_code is the stable, enumeration-safe admission reason.
type CommentTriggerOutcome struct {
TargetType string `json:"target_type"` // "agent" | "squad"
TargetID string `json:"target_id"`
Status DispatchStatus `json:"status"` // queued | coalesced | deferred | blocked
ReasonCode DispatchReasonCode `json:"reason_code"`
}
func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse {
if reactions == nil {
reactions = []ReactionResponse{}
}
if attachments == nil {
attachments = []AttachmentResponse{}
}
return CommentResponse{
ID: uuidToString(c.ID),
IssueID: uuidToString(c.IssueID),
AuthorType: c.AuthorType,
AuthorID: uuidToString(c.AuthorID),
Content: c.Content,
Type: c.Type,
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: timestampToString(c.UpdatedAt),
Revision: c.Revision,
ResolvedAt: timestampToPtr(c.ResolvedAt),
ResolvedByType: textToPtr(c.ResolvedByType),
ResolvedByID: uuidToPtr(c.ResolvedByID),
SourceTaskID: uuidToPtr(c.SourceTaskID),
QuickActionID: uuidToPtr(c.QuickActionID),
Reactions: reactions,
Attachments: attachments,
}
}
// summaryContentRunes bounds comment content under summary=true. 200 runes is
// enough to tell what a comment is about (its opening) while cutting the bulk
// of a long body out of an agent's context budget. Counted in runes, not bytes,
// so multi-byte (e.g. CJK) content is clipped on a character boundary.
const summaryContentRunes = 200
// summarizeContent clips content to summaryContentRunes for the summary
// projection. Returns the (possibly clipped) content and whether it was
// truncated. An ellipsis marks a clip so the reader knows more text exists.
//
// It scans by rune and stops at the (budget+1)th rune rather than allocating a
// full []rune for the whole body — so a pathologically long comment costs only
// the budget, not its full length, under summary mode.
func summarizeContent(content string) (string, bool) {
count := 0
for byteOffset := range content { // range over a string yields rune start offsets
if count == summaryContentRunes {
return content[:byteOffset] + "…", true
}
count++
}
return content, false
}
// foldStat is the per-thread fold annotation attached to a resolved thread's
// root under fold=true. FoldedCount is how many comments in the thread were
// dropped from the response. See CommentResponse.ThreadResolved / FoldedCount.
type foldStat struct {
FoldedCount int
}
// foldResolvedThreads collapses every resolved thread in a COMPLETE-thread
// comment set down to the comments a reader actually needs, mirroring the human
// timeline fold (deriveThreadResolution in
// packages/views/issues/components/thread-utils.ts) so agents see what humans
// see:
//
// - unresolved thread → every comment kept, unchanged, no annotation.
// - reply-resolved thread → keep the thread root + the resolution reply (the
// conclusion), drop every other reply. Root + conclusion, never conclusion
// alone: a conclusion is often referential ("go with what we said above")
// and unreadable without the root question.
// - root-resolved thread → keep only the root, drop every reply (the whole
// discussion was a settled dead-end; the root states the topic).
//
// The thread root of each resolved thread is annotated (foldStat keyed by the
// root's id string) so the response can mark it thread_resolved and report how
// many comments were folded; a reader that needs the dropped discussion pulls
// it back with `comment list --full`.
//
// Resolution is derived exactly as the UI does: the root wins if resolved;
// otherwise the reply with the latest resolved_at is THE resolution. The single
// resolved-per-thread invariant (ClearOtherThreadResolutions) normally makes
// this unambiguous; the latest-wins tiebreak keeps the projection total if an
// older/concurrent write ever left two.
//
// Callers MUST invoke this only on a set containing COMPLETE threads (the
// default list, --recent, and untailed --thread reads). Partial-thread reads
// (--since, --tail) are rejected upstream because a fold computed over a partial
// thread could drop a resolution that was never fetched. As defense in depth,
// any comment whose thread root is absent from the set is treated as its own
// root and kept verbatim, so a partial thread degrades to "unchanged" rather
// than to silent data loss.
func foldResolvedThreads(comments []db.Comment) ([]db.Comment, map[string]foldStat) {
if len(comments) == 0 {
return comments, nil
}
byID := make(map[string]db.Comment, len(comments))
for _, c := range comments {
byID[uuidToString(c.ID)] = c
}
// rootOf walks parent_id up to the thread root within this set. It stops at
// the first comment whose parent is absent (in a complete-thread set that is
// the real root; in a partial set it is the highest ancestor we hold). The
// loop is bounded by len(comments) so an unexpected cycle cannot hang it —
// the PK forbids real cycles, but never trust a graph walk over stored data.
rootOf := func(c db.Comment) db.Comment {
cur := c
for i := 0; i < len(comments); i++ {
if !cur.ParentID.Valid {
return cur
}
parent, ok := byID[uuidToString(cur.ParentID)]
if !ok {
return cur
}
cur = parent
}
return cur
}
type thread struct {
root db.Comment
replies []db.Comment // non-root, in input (chronological) order
}
threads := map[string]*thread{}
for _, c := range comments {
root := rootOf(c)
rid := uuidToString(root.ID)
th := threads[rid]
if th == nil {
th = &thread{root: root}
threads[rid] = th
}
if uuidToString(c.ID) != rid {
th.replies = append(th.replies, c)
}
}
keep := make(map[string]bool, len(comments))
stats := map[string]foldStat{}
for rid, th := range threads {
// Root-resolved: keep only the root.
if th.root.ResolvedAt.Valid {
keep[rid] = true
stats[rid] = foldStat{FoldedCount: len(th.replies)}
continue
}
// Reply-resolved: the latest-resolved reply is the conclusion.
var resolution *db.Comment
for i := range th.replies {
r := &th.replies[i]
if !r.ResolvedAt.Valid {
continue
}
if resolution == nil || r.ResolvedAt.Time.After(resolution.ResolvedAt.Time) {
resolution = r
}
}
if resolution == nil {
// Unresolved thread: keep everything, no annotation.
keep[rid] = true
for _, r := range th.replies {
keep[uuidToString(r.ID)] = true
}
continue
}
keep[rid] = true
keep[uuidToString(resolution.ID)] = true
// Every reply except the conclusion is folded away.
stats[rid] = foldStat{FoldedCount: len(th.replies) - 1}
}
out := make([]db.Comment, 0, len(comments))
for _, c := range comments {
if keep[uuidToString(c.ID)] {
out = append(out, c)
}
}
return out, stats
}
// commentHardCap bounds the comments returned per issue. Sized as a defensive
// safety net rather than a UX paging window: prod p99 is ~30 comments and
// the all-time max observed is ~1.1k, so 2000 leaves ~2x headroom while still
// preventing a runaway response if some user manages to accumulate a wild
// number of rows on a single issue.
const commentHardCap = 2000
// HeaderCommentsTruncated tells browser and CLI callers that a defensive
// comment-list cap omitted rows. It is deliberately separate from the timeline
// header: /comments has several projections (roots, one thread, since) whose
// truncation cannot be described as a timeline activity kind.
const HeaderCommentsTruncated = "X-Comments-Truncated"
// commentProbeLimit reads one row past the cap so a truncated read can be told
// apart from an issue holding exactly commentHardCap comments. The difference
// matters: exact-cap reads are complete and must not advertise data loss.
const commentProbeLimit = commentHardCap + 1
// Budgets for thread completion (completeCommentThreads).
//
// commentThreadContextBudget caps ALL context added outside the newest window:
// ancestors first, then missing siblings/descendants for any thread whose root
// had to be restored. A response is therefore bounded by commentHardCap +
// commentThreadContextBudget comments regardless of thread shape.
// commentThreadMaxDepth bounds each direction of the walk.
//
// Both are needed because reply depth is genuinely unbounded in stored data: the
// general write path saves the exact comment being replied to, so chains can run
// far deeper than the two levels the UI usually renders. Without a budget the
// completion pass would defeat the row cap it is meant to preserve.
const (
commentThreadContextBudget = 2000
commentThreadMaxDepth = 64
)
// ListComments returns comments for an issue. The default path returns the
// newest commentHardCap comments in chronological order. If that defensive cap
// fires, completeCommentThreads restores each affected thread within a bounded
// context budget (or drops that thread as a unit), and the response sets
// HeaderCommentsTruncated. Optional query params give agent-style readers
// bounded views that scale to long issues without dragging every prior reply
// into context:
//
// - roots_only=true — return only top-level comments (parent_id IS NULL),
// each annotated with reply_count + last_activity_at so the caller can
// triage which thread to drill into. May combine with since for incremental
// polling of newly created roots, but is exclusive with thread/recent/tail/
// cursor modes because those have their own grouping or pagination semantics.
//
// - summary=true — orthogonal content projection. Clips each returned
// comment's content to a fixed budget and sets content_truncated, so an
// agent can scan a list cheaply before pulling a full body. Composes with
// every mode (default, since, thread, recent, roots_only).
//
// - fold=true — resolve-aware thread projection. Collapses each resolved
// thread to root + conclusion (reply-resolved) or root only (root-resolved),
// reusing the human timeline fold so an agent does not pay tokens for
// settled discussion. The resolved thread's root carries thread_resolved +
// folded_count; `--full` (no fold param) brings the dropped comments back.
// Needs whole threads to compute a resolution, so it is rejected with since,
// tail, and roots_only (partial-thread / reply-less reads) and composes with
// the default list, recent, untailed thread, and summary.
//
// - thread=<comment-uuid> — return the root of the thread containing this
// comment plus every descendant. The anchor may be a root or any reply;
// the server walks up to the root via a recursive CTE, so callers do not
// need to know whether the id they have is a root.
//
// - tail=<N> — only valid with thread. Cap the reply count at the N most
// recent replies (per (created_at, id)). The thread root is always
// returned, even when N=0, so the reader keeps the "what is this thread
// about" context. Without tail, thread returns the entire thread (the
// pre-MUL-2421 behavior).
//
// - recent=<N> — return the N most recently active threads (root + every
// descendant per thread). A thread's recency is MAX(created_at) across
// the whole subtree, so a stale-but-recently-replied thread ranks ahead
// of an active-but-quiet one. Row-based "newest N comments" is
// deliberately NOT exposed — it surfaces unrelated thread tails and
// hides relevant history (#2340).
//
// - before=<RFC3339> + before-id=<uuid> — cursor. The pair's meaning is
// context-dependent so the flag surface stays small:
//
// - with recent: a *thread* cursor — (last_activity_at, root_id) — and
// the next page returns threads strictly less recent.
//
// - with thread + tail: a *reply* cursor — (created_at, id) — and the
// next page returns replies in the same thread strictly older than
// that reply.
//
// Both values must be set together so the cursor can tie-break entries
// landing in the same microsecond. The cursor for the next page is
// emitted via the X-Multica-Next-Before / X-Multica-Next-Before-Id
// response headers.
//
// Combination rules (kept narrow on purpose — Elon flagged the matrix risk):
//
// - roots_only is exclusive with thread, recent, tail, and before/before-id.
// It may combine with since. This keeps "list issue roots" separate from
// "read a specific thread" and "read recently active threads".
// - thread is exclusive with recent. Asking for "the most recent N within
// thread X" mixes two different navigation models and is rejected.
// - thread + before/before-id requires tail. Without tail, thread returns
// the entire thread and a cursor would be ignored — reject loudly so
// the documented "cursor scrolls within a tailed window" rule holds.
// - tail requires thread (it is a thread-scoped limit; outside of thread
// it has no defined behavior).
// - thread may combine with since (incremental polling of one thread),
// and the since filter is applied after the tail/cursor cut so the
// thread root is still emitted but stale rows drop out.
// - recent may combine with before/before-id (scroll older threads) and
// with since (recent activity in a window).
//
// The response body is always chronological (oldest → newest); under recent
// that means threads are listed oldest-active first and the freshest thread
// sits at the tail, closest to "now" in an agent prompt.
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
q := r.URL.Query()
var sinceTime pgtype.Timestamptz
if v := q.Get("since"); v != "" {
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
// Fall back to RFC3339 for backwards-compat with the original CLI.
t, err = time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
return
}
}
sinceTime = pgtype.Timestamptz{Time: t, Valid: true}
}
threadStr := q.Get("thread")
recentStr := q.Get("recent")
tailStr := q.Get("tail")
beforeTimeStr := q.Get("before")
beforeIDStr := q.Get("before_id")
if beforeIDStr == "" {
// Accept hyphenated alias to match CLI flag convention.
beforeIDStr = q.Get("before-id")
}
rootsOnlyStr := q.Get("roots_only")
if rootsOnlyStr == "" {
// Accept hyphenated alias to match CLI flag convention.
rootsOnlyStr = q.Get("roots-only")
}
rootsOnly := false
if rootsOnlyStr != "" {
switch rootsOnlyStr {
case "true":
rootsOnly = true
case "false":
default:
writeError(w, http.StatusBadRequest, "invalid roots_only parameter; expected boolean")
return
}
}
// summary=true is an orthogonal content projection: it clips each comment's
// content to a fixed budget so an agent can scan a list without pulling full
// bodies into context. It is intentionally NOT mutually exclusive with any
// mode — it composes with the default list, since, thread, recent, and
// roots_only alike.
summary := false
if summaryStr := q.Get("summary"); summaryStr != "" {
switch summaryStr {
case "true":
summary = true
case "false":
default:
writeError(w, http.StatusBadRequest, "invalid summary parameter; expected boolean")
return
}
}
// fold=true is a thread-level projection: it collapses every resolved thread
// in the result to root + conclusion (reply-resolved) or root only
// (root-resolved), reusing the human timeline's fold semantics so an agent
// reading a long issue doesn't pay tokens for settled discussion. Unlike
// summary (a per-comment content clip that composes with everything), fold
// needs WHOLE threads to compute a resolution, so it is rejected on the
// partial-thread reads (since / tail) and on roots_only (which carries no
// replies to fold). It composes with summary, the default list, recent, and
// untailed thread reads.
fold := false
if foldStr := q.Get("fold"); foldStr != "" {
switch foldStr {
case "true":
fold = true
case "false":
default:
writeError(w, http.StatusBadRequest, "invalid fold parameter; expected boolean")
return
}
}
// --- combination validation ----------------------------------------
if fold && sinceTime.Valid {
writeError(w, http.StatusBadRequest, "fold and since are mutually exclusive: since returns a partial thread, and a fold over a partial thread could hide a resolution that was not fetched")
return
}
if fold && tailStr != "" {
writeError(w, http.StatusBadRequest, "fold and tail are mutually exclusive: tail returns a partial thread, which cannot be folded safely")
return
}
if fold && rootsOnly {
writeError(w, http.StatusBadRequest, "fold and roots_only are mutually exclusive: roots_only returns no replies to fold")
return
}
if rootsOnly && threadStr != "" {
writeError(w, http.StatusBadRequest, "roots_only and thread are mutually exclusive")
return
}
if rootsOnly && recentStr != "" {
writeError(w, http.StatusBadRequest, "roots_only and recent are mutually exclusive")
return
}
if rootsOnly && tailStr != "" {
writeError(w, http.StatusBadRequest, "roots_only and tail are mutually exclusive")
return
}
if rootsOnly && (beforeTimeStr != "" || beforeIDStr != "") {
writeError(w, http.StatusBadRequest, "roots_only does not support before / before_id")
return
}
if threadStr != "" && recentStr != "" {
writeError(w, http.StatusBadRequest, "thread and recent are mutually exclusive")
return
}
if tailStr != "" && threadStr == "" {
writeError(w, http.StatusBadRequest, "tail requires thread (it is a thread-scoped limit)")
return
}
if (beforeTimeStr == "") != (beforeIDStr == "") {
writeError(w, http.StatusBadRequest, "before and before_id must be set together (composite cursor)")
return
}
// Cursor needs either a recent window (thread cursor) or a tailed thread
// (reply cursor). A bare cursor would otherwise fall through to the
// default / since path — returning a full timeline that the caller did
// not ask for. Reject loudly so the API surface matches the documented
// semantics.
if beforeTimeStr != "" && recentStr == "" && (threadStr == "" || tailStr == "") {
writeError(w, http.StatusBadRequest, "before / before_id require recent (thread cursor) or thread + tail (reply cursor)")
return
}
// --- parse cursor / recent ----------------------------------------
var beforeCursor pgtype.Timestamptz
var beforeUUID pgtype.UUID
hasCursor := false
if beforeTimeStr != "" {
t, err := time.Parse(time.RFC3339Nano, beforeTimeStr)
if err != nil {
t, err = time.Parse(time.RFC3339, beforeTimeStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid before parameter; expected RFC3339 format")
return
}
}
beforeCursor = pgtype.Timestamptz{Time: t, Valid: true}
uuid, perr := util.ParseUUID(beforeIDStr)
if perr != nil {
writeError(w, http.StatusBadRequest, "invalid before_id parameter; expected UUID")
return
}
beforeUUID = uuid
hasCursor = true
}
recentN := 0
if recentStr != "" {
n, err := strconv.Atoi(recentStr)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "invalid recent parameter; expected positive integer")
return
}
if n > commentHardCap {
n = commentHardCap
}
recentN = n
}
// tail=0 is allowed (returns root only — useful for "what is this thread
// about" lookups without dragging any replies into context). Negative
// values are rejected because they'd round-trip to LIMIT -N which
// PostgreSQL flags as a syntax error.
threadTail := -1
threadTailSet := false
if tailStr != "" {
n, err := strconv.Atoi(tailStr)
if err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "invalid tail parameter; expected non-negative integer")
return
}
if n > commentHardCap {
n = commentHardCap
}
threadTail = n
threadTailSet = true
}
result, err := h.fetchCommentsForList(r.Context(), fetchCommentsArgs{
Issue: issue,
Since: sinceTime,
ThreadAnchor: threadStr,
ThreadTail: threadTail,
ThreadTailSet: threadTailSet,
RecentN: recentN,
HasCursor: hasCursor,
BeforeAt: beforeCursor,
BeforeID: beforeUUID,
RootsOnly: rootsOnly,
})
if err != nil {
switch err {
case errCommentThreadNotFound:
writeError(w, http.StatusNotFound, "thread anchor not found in this issue")
return
case errCommentThreadBadID:
writeError(w, http.StatusBadRequest, "invalid thread parameter; expected UUID")
return
default:
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
}
// Apply the resolve-aware fold before anything keys off the comment set
// (reaction/attachment grouping, the response array): folding drops comments,
// and the dropped ones should not pay a reactions/attachments round-trip or
// appear in the response.
//
// fetchCommentsForList marks FoldUnsafe only when a returned thread is known
// to be partial. The default capped path is safe: completeCommentThreads
// either restores every affected thread or removes it as one unit. An
// oversized untailed --thread read, however, intentionally keeps only the
// newest replies, so folding that partial thread would invent resolution
// state and folded_count.
var foldInfo map[string]foldStat
if fold && !result.FoldUnsafe {
result.Comments, foldInfo = foldResolvedThreads(result.Comments)
}
commentIDs := make([]pgtype.UUID, len(result.Comments))
for i, c := range result.Comments {
commentIDs[i] = c.ID
}
grouped := h.groupReactions(r, commentIDs)
groupedAtt := h.groupAttachments(r, commentIDs)
resp := make([]CommentResponse, len(result.Comments))
for i, c := range result.Comments {
cid := uuidToString(c.ID)
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
// Attach roots_only orientation stats when present (nil map elsewhere).
if st, ok := result.RootStats[cid]; ok {
rc := st.ReplyCount
resp[i].ReplyCount = &rc
if st.LastActivityAt.Valid {
la := timestampToString(st.LastActivityAt)
resp[i].LastActivityAt = &la
}
}
// Attach fold annotations on a resolved thread's root (nil map elsewhere;
// keyed by root id, so only root comments match).
if st, ok := foldInfo[cid]; ok {
resolved := true
resp[i].ThreadResolved = &resolved
fc := st.FoldedCount
resp[i].FoldedCount = &fc
}
// Apply the summary projection last so it clips whatever content the
// chosen read mode produced, uniformly across every mode.
if summary {
clipped, truncated := summarizeContent(resp[i].Content)
resp[i].Content = clipped
resp[i].ContentTruncated = &truncated
}
}
// Emit the next cursor as response headers when the page is likely not
// the last one. The cursor's meaning is context-dependent: under recent
// it points at the oldest thread in the page (next page = older threads);
// under thread + tail it points at the oldest reply in the page (next
// page = older replies in the same thread). Headers stay out of the JSON
// body so the default flat-array response shape — which the desktop UI
// and existing callers depend on — is unchanged.
if result.NextBefore != "" && result.NextBeforeID != "" {
w.Header().Set("X-Multica-Next-Before", result.NextBefore)
w.Header().Set("X-Multica-Next-Before-Id", result.NextBeforeID)
}
if result.CommentsTruncated {
w.Header().Set(HeaderCommentsTruncated, "true")
}
writeJSON(w, http.StatusOK, resp)
}
// fetchCommentsArgs bundles the parsed query params so fetchCommentsForList
// stays readable. Sentinel errors below let the caller turn DB-layer outcomes
// into the right HTTP status without leaking SQL details.
//
// ThreadTail is split into a value + a "set" flag because tail=0 is a
// meaningful caller intent (return just the root). A bare int would collapse
// "user did not pass --tail" and "user passed --tail 0" into the same state,
// which would silently downgrade the latter to the full-thread path.
type fetchCommentsArgs struct {
Issue db.Issue
Since pgtype.Timestamptz
RootsOnly bool
ThreadAnchor string
ThreadTail int
ThreadTailSet bool
RecentN int
HasCursor bool
BeforeAt pgtype.Timestamptz
BeforeID pgtype.UUID
}
// fetchCommentsResult carries both the materialised comments and (for the
// recent/thread-grouped path) the cursor to use for the next page. Cursor
// fields are empty strings when there is no next page or the path does not
// support cursors.
type fetchCommentsResult struct {
Comments []db.Comment
NextBefore string
NextBeforeID string
// RootStats carries per-root orientation stats keyed by comment id string.
// Populated only on the roots_only path; nil for every other mode.
RootStats map[string]rootStat
// CommentsTruncated reports that a defensive cap omitted comments. Derived
// from a probe read, never from the final response length.
CommentsTruncated bool
// FoldUnsafe reports that the response contains a known-partial thread.
// Truncation alone does not imply this: completeCommentThreads makes the
// default newest window safe to fold by restoring or dropping whole threads.
FoldUnsafe bool
}
// rootStat is the per-thread orientation metadata attached to each root comment
// on the roots_only path. See CommentResponse.ReplyCount / LastActivityAt.
type rootStat struct {
ReplyCount int
LastActivityAt pgtype.Timestamptz
}
var (
errCommentThreadNotFound = &commentFetchError{"thread anchor not found"}
errCommentThreadBadID = &commentFetchError{"invalid thread anchor id"}
)
type commentFetchError struct{ msg string }
func (e *commentFetchError) Error() string { return e.msg }
func (h *Handler) fetchCommentsForList(ctx context.Context, args fetchCommentsArgs) (fetchCommentsResult, error) {
issue := args.Issue
// Thread-scoped read. Server resolves the anchor → root via recursive
// CTE, so we don't have to assume two-layer flat threads here.
if args.ThreadAnchor != "" {
anchor, err := util.ParseUUID(args.ThreadAnchor)
if err != nil {
return fetchCommentsResult{}, errCommentThreadBadID
}
// Tailed path: paged query that returns root + the @reply_limit
// most recent replies (per (created_at, id)). The thread root is
// always returned, so a reader can land on a long thread without
// dragging hundreds of replies into context. The reply-internal
// cursor (--before / --before-id under --thread + --tail) scrolls
// to older replies inside the same thread.
if args.ThreadTailSet {
// Probe for has-more by asking the SQL for one extra reply
// beyond what the caller wants. If we get back >tail replies
// there is at least one older reply still on disk; if we get
// back ≤tail the page is the tail of the thread and there is
// nothing older to scroll to (so we must NOT emit a cursor —
// otherwise the next page is wasted round-trip that returns
// just the root). This is the exact-boundary fix called out
// in the MUL-2421 review.
rows, err := h.Queries.ListThreadCommentsForIssuePaged(ctx, db.ListThreadCommentsForIssuePagedParams{
AnchorID: anchor,
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
HasCursor: args.HasCursor,
BeforeAt: args.BeforeAt,
BeforeID: args.BeforeID,
ReplyLimit: int32(args.ThreadTail) + 1,
})
if err != nil {
return fetchCommentsResult{}, err
}
if len(rows) == 0 {
return fetchCommentsResult{}, errCommentThreadNotFound
}
// Split the result into root + replies (ASC order preserved).
// Root is identified by parent_id IS NULL and is always
// present in the SQL output; we keep it out of the cursor /
// tail-trim logic so the user always sees thread context.
var rootComment *db.Comment
replies := make([]db.Comment, 0, len(rows))
for _, r := range rows {
c := db.Comment{
ID: r.ID,
IssueID: r.IssueID,
AuthorType: r.AuthorType,
AuthorID: r.AuthorID,
Content: r.Content,
Type: r.Type,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ParentID: r.ParentID,
WorkspaceID: r.WorkspaceID,
ResolvedAt: r.ResolvedAt,
ResolvedByType: r.ResolvedByType,
ResolvedByID: r.ResolvedByID,
SourceTaskID: r.SourceTaskID,
QuickActionID: r.QuickActionID,
Revision: r.Revision,
}
if !r.ParentID.Valid {
root := c
rootComment = &root
continue
}
replies = append(replies, c)
}
// Trim the probe overflow back to the caller's tail. The SQL
// emits ASC, so the extra row is the oldest reply — dropping
// it from the head is what aligns "newest N" with the user's
// request.
hasMore := len(replies) > args.ThreadTail
if hasMore {
replies = replies[1:]
}
out := make([]db.Comment, 0, len(replies)+1)
if rootComment != nil {
out = append(out, *rootComment)
}
for _, r := range replies {
// since drops stale rows AFTER the tail / cursor cut.
// The root is exempt (already appended above): a reader
// who set --since to skip already-seen replies still
// needs the root context if the page only contained
// the root.
if args.Since.Valid && !r.CreatedAt.Time.After(args.Since.Time) {
continue
}
out = append(out, r)
}
// Emit a reply cursor only when we proved an older reply
// exists (hasMore). On an exact-boundary page (replyCount
// == tail with no overflow) hasMore is false and the cursor
// stays empty.
//
// Additionally suppress the cursor when `since` is set and
// the oldest retained reply on this page is already <= since.
// The next page walks replies strictly older than that one,
// so every older reply has created_at strictly less — if the
// cursor target itself can't satisfy `> since`, no older
// reply can either, and continuing to paginate would only
// return root-only pages until the agent walks the entire
// pre-`since` history. This mirrors the head-thread guard on
// the recent + since path. Flagged by Elon's second review on
// MUL-2421.
res := fetchCommentsResult{Comments: out}
emitCursor := hasMore && len(replies) > 0
if emitCursor && args.Since.Valid && !replies[0].CreatedAt.Time.After(args.Since.Time) {
emitCursor = false
}
if emitCursor {
oldest := replies[0]
res.NextBefore = oldest.CreatedAt.Time.UTC().Format(time.RFC3339Nano)
res.NextBeforeID = uuidToString(oldest.ID)
}
return res, nil
}
// Untailed reads use the same newest-reply query as the paged path.
// Probe with commentHardCap replies: because the root is unconditional,
// 2000 replies proves the total thread exceeds the 2000-row response cap.
// We then drop the oldest reply and retain root + newest 1999 replies.
rows, err := h.Queries.ListThreadCommentsForIssuePaged(ctx, db.ListThreadCommentsForIssuePagedParams{
AnchorID: anchor,
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
ReplyLimit: commentHardCap,
})
if err != nil {
return fetchCommentsResult{}, err
}
if len(rows) == 0 {
return fetchCommentsResult{}, errCommentThreadNotFound
}
var rootComment *db.Comment
replies := make([]db.Comment, 0, len(rows))
for _, r := range rows {
c := db.Comment{
ID: r.ID,
IssueID: r.IssueID,
AuthorType: r.AuthorType,
AuthorID: r.AuthorID,
Content: r.Content,
Type: r.Type,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ParentID: r.ParentID,
WorkspaceID: r.WorkspaceID,
ResolvedAt: r.ResolvedAt,
ResolvedByType: r.ResolvedByType,
ResolvedByID: r.ResolvedByID,
SourceTaskID: r.SourceTaskID,
QuickActionID: r.QuickActionID,
Revision: r.Revision,
}
if !r.ParentID.Valid {
root := c
rootComment = &root
continue
}
replies = append(replies, c)
}
truncated := len(replies) >= commentHardCap
if truncated {
replies = replies[1:]
}
out := make([]db.Comment, 0, len(replies)+1)
if rootComment != nil && (!args.Since.Valid || rootComment.CreatedAt.Time.After(args.Since.Time)) {
out = append(out, *rootComment)
}
for _, reply := range replies {
if args.Since.Valid && !reply.CreatedAt.Time.After(args.Since.Time) {
continue
}
out = append(out, reply)
}
return fetchCommentsResult{
Comments: out,
CommentsTruncated: truncated,
FoldUnsafe: truncated,
}, nil
}
// Thread-grouped recent read: N most recently active threads.
if args.RecentN > 0 {
rows, err := h.Queries.ListRecentThreadCommentsForIssue(ctx, db.ListRecentThreadCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
HasCursor: args.HasCursor,
BeforeAt: args.BeforeAt,
BeforeID: args.BeforeID,
ThreadLimit: int32(args.RecentN),
})
if err != nil {
return fetchCommentsResult{}, err
}
// The SQL already orders rows by (last_activity_at ASC, root_id ASC,
// created_at ASC, id ASC), so the OLDEST-active thread sits at the
// head and the FRESHEST thread at the tail. Walk the rows once to:
// 1. Strip the thread-metadata columns down to db.Comment for the
// caller (uniform shape across paths).
// 2. Count distinct threads in the page so we know whether a "next
// older page" is likely to exist.
// 3. Capture the head thread's (last_activity_at, root_id) — that
// is the cursor for the next page (next page = threads strictly
// less recent than this one).
comments := make([]db.Comment, 0, len(rows))
var headRoot pgtype.UUID
var headLast pgtype.Timestamptz
seenRoot := map[string]struct{}{}
for _, r := range rows {
if !headRoot.Valid {
headRoot = r.ThreadRootID
headLast = r.ThreadLastActivityAt
}
seenRoot[uuidToString(r.ThreadRootID)] = struct{}{}
// Since filter on the recent path: drop comments older than
// `since`. Done in-memory so we keep the thread-grouped
// semantics from the query (don't pre-filter rows before the
// MAX(created_at) ranking — that would silently downgrade a
// thread whose most recent activity falls inside the window).
if args.Since.Valid && !r.CreatedAt.Time.After(args.Since.Time) {
continue
}
comments = append(comments, db.Comment{
ID: r.ID,
IssueID: r.IssueID,
AuthorType: r.AuthorType,
AuthorID: r.AuthorID,
Content: r.Content,
Type: r.Type,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ParentID: r.ParentID,
WorkspaceID: r.WorkspaceID,
ResolvedAt: r.ResolvedAt,
ResolvedByType: r.ResolvedByType,
ResolvedByID: r.ResolvedByID,
SourceTaskID: r.SourceTaskID,
QuickActionID: r.QuickActionID,
Revision: r.Revision,
})
}
// Only emit a cursor when the page is full. Fewer threads than
// requested ⇒ the SELECT exhausted matching threads, so there is
// no older page to scroll to.
//
// Additionally suppress the cursor when `since` is set and the head
// thread's last_activity_at is already <= since. The pagination
// walks threads in strictly decreasing last_activity_at, so every
// older page has last_activity_at strictly less than the head's —
// if the head itself can't satisfy `> since`, no older thread can
// either. Predicating on the head (not on whether `comments` is
// empty) also catches the mixed case where this page keeps rows
// from fresher threads but the head thread is already past `since`.
// Flagged by Elon in #2787's second review (MUL-2340 nit).
out := fetchCommentsResult{Comments: comments}
emitCursor := len(seenRoot) >= args.RecentN && headRoot.Valid && headLast.Valid
if emitCursor && args.Since.Valid && !headLast.Time.After(args.Since.Time) {
emitCursor = false
}
if emitCursor {
out.NextBefore = headLast.Time.UTC().Format(time.RFC3339Nano)
out.NextBeforeID = uuidToString(headRoot)
}
return out, nil
}
if args.RootsOnly {
// Root-only read for issue-level orientation. This intentionally
// stays separate from thread/recent modes: callers get the global
// top-level discussion first, then fetch a specific thread only when
// they need reply context. Each root carries reply_count +
// last_activity_at so the reader can triage which thread to drill into.
stats := map[string]rootStat{}
if args.Since.Valid {
rows, err := h.Queries.ListRootCommentsSinceForIssue(ctx, db.ListRootCommentsSinceForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Since: args.Since,
RowLimit: commentProbeLimit,
})
if err != nil {
return fetchCommentsResult{}, err
}
truncated := len(rows) > commentHardCap
if truncated {
// Since is an incremental stream: keep the first page after the
// cursor so callers can advance without skipping a gap.
rows = rows[:commentHardCap]
}
comments := make([]db.Comment, len(rows))
for i, r := range rows {
comments[i] = db.Comment{
ID: r.ID, IssueID: r.IssueID, AuthorType: r.AuthorType, AuthorID: r.AuthorID,
Content: r.Content, Type: r.Type, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
ParentID: r.ParentID, WorkspaceID: r.WorkspaceID, ResolvedAt: r.ResolvedAt,
ResolvedByType: r.ResolvedByType, ResolvedByID: r.ResolvedByID,
SourceTaskID: r.SourceTaskID, QuickActionID: r.QuickActionID, Revision: r.Revision,
}
stats[uuidToString(r.ID)] = rootStat{ReplyCount: int(r.ReplyCount), LastActivityAt: r.LastActivityAt}
}
return fetchCommentsResult{
Comments: comments, RootStats: stats, CommentsTruncated: truncated,
}, nil
}
rows, err := h.Queries.ListRootCommentsForIssue(ctx, db.ListRootCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
RowLimit: commentProbeLimit,
})
if err != nil {
return fetchCommentsResult{}, err
}
truncated := len(rows) > commentHardCap
if truncated {
// The SQL returns selected roots chronologically after taking the
// newest probe window, so the overflow row is the oldest.
rows = rows[1:]
}
comments := make([]db.Comment, len(rows))
for i, r := range rows {
comments[i] = db.Comment{
ID: r.ID, IssueID: r.IssueID, AuthorType: r.AuthorType, AuthorID: r.AuthorID,
Content: r.Content, Type: r.Type, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
ParentID: r.ParentID, WorkspaceID: r.WorkspaceID, ResolvedAt: r.ResolvedAt,
ResolvedByType: r.ResolvedByType, ResolvedByID: r.ResolvedByID,
SourceTaskID: r.SourceTaskID, QuickActionID: r.QuickActionID, Revision: r.Revision,
}
stats[uuidToString(r.ID)] = rootStat{ReplyCount: int(r.ReplyCount), LastActivityAt: r.LastActivityAt}
}
return fetchCommentsResult{
Comments: comments, RootStats: stats, CommentsTruncated: truncated,
}, nil
}
// Since reads keep their chronological page shape. A probe makes the cap
// visible without skipping the next page of the incremental stream.
if args.Since.Valid {
comments, err := h.Queries.ListCommentsSinceForIssue(ctx, db.ListCommentsSinceForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: args.Since,
Limit: commentProbeLimit,
})
if err != nil {
return fetchCommentsResult{}, err
}
truncated := len(comments) > commentHardCap
if truncated {
comments = comments[:commentHardCap]
}
return fetchCommentsResult{Comments: comments, CommentsTruncated: truncated}, nil
}
comments, err := h.Queries.ListCommentsForIssue(ctx, db.ListCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: commentProbeLimit,
})
if err != nil {
return fetchCommentsResult{}, err
}
// Probe read: an issue holding exactly commentHardCap comments is complete,
// and calling that truncated would needlessly suppress the thread fold below.
truncated := len(comments) > commentHardCap
if truncated {
comments = comments[len(comments)-commentHardCap:]
// The cap keeps the NEWEST comments (MUL-5492), so a thread can be cut
// in half: an old root or sibling outside the window, a fresh reply
// inside it. Complete every affected thread within the shared context
// budget. If a thread cannot be completed, it is dropped as one unit.
//
comments, err = h.completeCommentThreads(ctx, issue.ID, issue.WorkspaceID, comments)
if err != nil {
return fetchCommentsResult{}, err
}
}
return fetchCommentsResult{Comments: comments, CommentsTruncated: truncated}, nil
}
// completeCommentThreads restores complete renderable threads around a newest-N
// comment window.
//
// Why this is needed at all: capping with the OLDEST n comments could never
// orphan a reply, because a reply is always newer than its parent and so a
// prefix of the timeline is closed under "parent of". A newest-n window is a
// suffix and has no such property — an old thread root falls outside while a
// fresh reply to it stays inside (MUL-5492).
//
// Parent closure comes first because an orphaned reply is not merely mis-nested,
// it is invisible.
// 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 — the exact shape of MUL-1847
// (1 root + 29 replies, root dropped, all 29 vanished from the UI while the API
// returned them).
//
// Parent closure alone is insufficient for the human timeline. Web and Desktop
// derive resolution bars and folded counts from the comments they receive, so a
// backfilled root plus only its newest replies would make a partial thread look
// complete. After reaching roots, this function walks DOWN from every restored
// root and adds all missing siblings/descendants. If the shared budget or depth
// bound cannot prove a whole affected thread complete, that thread is removed
// as one unit; old installed clients can therefore never fold partial data.
//
// Both walks share commentThreadContextBudget, so the returned set remains
// provably bounded by commentHardCap + commentThreadContextBudget. Descendants
// are fetched one level at a time for every affected root in one query, avoiding
// an N+1 query per thread. Each query carries issue + workspace predicates and a
// probe limit.
//
// When a budget is exhausted, a parent row is missing, or a parent belongs to
// another issue or workspace, keepRootConnected drops the affected replies. A
// descendant overflow drops every affected thread rather than guessing which
// root is complete. Returning fewer new replies is a conservative degradation
// that the caller already signals as truncated; leaking another tenant's
// comments, returning an unbounded response, or emitting partial threads are all
// worse.
func (h *Handler) completeCommentThreads(ctx context.Context, issueID, workspaceID pgtype.UUID, window []db.Comment) ([]db.Comment, error) {
if len(window) == 0 {
return window, nil
}
initialIDs := make(map[string]struct{}, len(window))
byID := make(map[string]db.Comment, len(window)+commentThreadContextBudget/8)
through := window[0]
for _, c := range window {
id := uuidToString(c.ID)
initialIDs[id] = struct{}{}
byID[id] = c
if c.CreatedAt.Time.After(through.CreatedAt.Time) ||
(c.CreatedAt.Time.Equal(through.CreatedAt.Time) &&
bytes.Compare(c.ID.Bytes[:], through.ID.Bytes[:]) > 0) {
through = c
}
}
added := 0
for depth := 0; depth < commentThreadMaxDepth; depth++ {
// Distinct parents referenced by the current set but not in it. Dedup is
// what keeps many replies sharing one ancestor from fetching it twice.
var missing []pgtype.UUID
seen := make(map[string]struct{})
for _, c := range byID {
if !c.ParentID.Valid {
continue
}
pid := uuidToString(c.ParentID)
if _, have := byID[pid]; have {
continue
}
if _, dup := seen[pid]; dup {
continue
}
seen[pid] = struct{}{}
missing = append(missing, c.ParentID)
}
if len(missing) == 0 {
break
}
if added+len(missing) > commentThreadContextBudget {
break
}
// Tenant-scoped: a parent in another issue or workspace simply does not
// come back, and keepRootConnected then prunes the reply that pointed at
// it. The filter is on every level, not just the first.
rows, err := h.Queries.ListCommentsByIDsForIssue(ctx, db.ListCommentsByIDsForIssueParams{
Ids: missing,
IssueID: issueID,
WorkspaceID: workspaceID,
})
if err != nil {
return nil, err
}
if len(rows) == 0 {
break
}
for _, r := range rows {
id := uuidToString(r.ID)
if _, have := byID[id]; have {
continue
}
byID[id] = r
added++
}
}
// Remove every chain that did not reach a same-tenant root within the
// ancestor budget/depth. Rebuild the map so the descendant phase cannot
// accidentally revive a disconnected chain.
connected := keepRootConnected(byID)
byID = make(map[string]db.Comment, len(connected)+commentThreadContextBudget-added)
for _, c := range connected {
byID[uuidToString(c.ID)] = c
}
// A restored root identifies a thread that the newest window cut. Threads
// whose roots were already in the suffix are complete under the normal
// parent-before-reply timestamp contract and need no extra query.
affectedRoots := make(map[string]struct{})
for id, c := range byID {
if c.ParentID.Valid {
continue
}
if _, wasInWindow := initialIDs[id]; !wasInWindow {
affectedRoots[id] = struct{}{}
}
}
if len(affectedRoots) > 0 {
complete, err := h.addMissingCommentDescendants(
ctx,
issueID,
workspaceID,
byID,
affectedRoots,
through,
&added,
)
if err != nil {
return nil, err
}
if !complete {
dropCommentThreads(byID, affectedRoots)
}
}
out := make([]db.Comment, 0, len(byID))
for _, c := range byID {
out = append(out, c)
}
sortCommentsChronologically(out)
return out, nil
}
// addMissingCommentDescendants walks every affected thread breadth-first. It
// returns false when the remaining context budget or depth bound cannot prove
// all affected threads complete; callers then drop those threads as one unit.
func (h *Handler) addMissingCommentDescendants(
ctx context.Context,
issueID, workspaceID pgtype.UUID,
byID map[string]db.Comment,
affectedRoots map[string]struct{},
through db.Comment,
added *int,
) (bool, error) {
frontier := make(map[string]string, len(affectedRoots)) // comment id -> root id
for rootID := range affectedRoots {
frontier[rootID] = rootID
}
// Every returned child is unique in a tree walk. The scan budget includes
// rows already present in the window plus the remaining add budget, so all
// descendant queries together inspect at most the response bound (+1 probe).
scanBudget := len(byID) + commentThreadContextBudget - *added
for depth := 0; depth <= commentThreadMaxDepth; depth++ {
parentKeys := make([]string, 0, len(frontier))
for id := range frontier {
parentKeys = append(parentKeys, id)
}
sort.Strings(parentKeys)
parentIDs := make([]pgtype.UUID, len(parentKeys))
for i, id := range parentKeys {
parentIDs[i] = byID[id].ID
}
rows, err := h.Queries.ListChildCommentsForParents(ctx, db.ListChildCommentsForParentsParams{
ParentIds: parentIDs,
IssueID: issueID,
WorkspaceID: workspaceID,
ThroughAt: through.CreatedAt,
ThroughID: through.ID,
RowLimit: int32(scanBudget + 1),
})
if err != nil {
return false, err
}
if len(rows) == 0 {
return true, nil
}
if len(rows) > scanBudget || depth == commentThreadMaxDepth {
return false, nil
}
scanBudget -= len(rows)
next := make(map[string]string, len(rows))
for _, row := range rows {
parentID := uuidToString(row.ParentID)
rootID, ok := frontier[parentID]
if !ok {
// The query is constrained to frontier parent ids, so this is
// defensive fail-closed handling for malformed query output.
return false, nil
}
id := uuidToString(row.ID)
next[id] = rootID
if _, exists := byID[id]; exists {
continue
}
if *added >= commentThreadContextBudget {
return false, nil
}
byID[id] = row
*added = *added + 1
}
frontier = next
}
return false, nil
}
// dropCommentThreads removes every comment whose root is in roots. It is used
// only after a bounded descendant walk could not prove the affected threads
// complete.
func dropCommentThreads(byID map[string]db.Comment, roots map[string]struct{}) {
for id := range byID {
rootID, ok := commentRootID(id, byID)
if !ok {
delete(byID, id)
continue
}
if _, drop := roots[rootID]; drop {
delete(byID, id)
}
}
}
func commentRootID(id string, byID map[string]db.Comment) (string, bool) {
seen := make(map[string]struct{})
for current := id; ; {
if _, loop := seen[current]; loop {
return "", false
}
seen[current] = struct{}{}
comment, ok := byID[current]
if !ok {
return "", false
}
if !comment.ParentID.Valid {
return current, true
}
current = uuidToString(comment.ParentID)
}
}
func sortCommentsChronologically(comments []db.Comment) {
sort.Slice(comments, func(i, j int) bool {
if !comments[i].CreatedAt.Time.Equal(comments[j].CreatedAt.Time) {
return comments[i].CreatedAt.Time.Before(comments[j].CreatedAt.Time)
}
return bytes.Compare(comments[i].ID.Bytes[:], comments[j].ID.Bytes[:]) < 0
})
}
// keepRootConnected returns only the comments whose parent chain terminates at a
// root (parent_id IS NULL) inside the set.
//
// A comment whose chain leaves the set, loops, or dead-ends is unrenderable: the
// UI needs a top-level card to hang the thread from. Dropping such a comment also
// drops its descendants, because their chains run through it and therefore fail
// the same test — no separate descendant pass is needed.
//
// The walk is bounded by the visited set, so a cycle in stored data terminates
// instead of hanging. Real cycles are impossible via the write path, but a graph
// walk over stored data should never assume that.
func keepRootConnected(byID map[string]db.Comment) []db.Comment {
connected := make(map[string]bool, len(byID))
for id := range byID {
if _, decided := connected[id]; decided {
continue
}
var path []string
visited := make(map[string]struct{})
verdict := false
for cur := id; ; {
if v, decided := connected[cur]; decided {
verdict = v
break
}
if _, loop := visited[cur]; loop {
break // cycle: nothing on this path reaches a root
}
c, present := byID[cur]
if !present {
break // chain leaves the set
}
visited[cur] = struct{}{}
path = append(path, cur)
if !c.ParentID.Valid {
verdict = true // a real root, inside the set
break
}
cur = uuidToString(c.ParentID)
}
for _, p := range path {
connected[p] = verdict
}
}
out := make([]db.Comment, 0, len(byID))
for id, c := range byID {
if connected[id] {
out = append(out, c)
}
}
return out
}
type CreateCommentRequest struct {
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
AttachmentIDs []string `json:"attachment_ids"`
SuppressAgentIDs []string `json:"suppress_agent_ids"`
}
type CommentTriggerPreviewRequest struct {
Content string `json:"content"`
ParentID *string `json:"parent_id"`
EditingCommentID *string `json:"editing_comment_id"`
}
type CommentTriggerPreviewResponse struct {
Agents []CommentTriggerAgentResponse `json:"agents"`
// Blocked lists explicit @agent / @squad mentions that will NOT trigger if
// this comment is posted as-is (MUL-4525 §2). Additive: old clients ignore
// it. It lets the composer warn before sending instead of the user only
// discovering the silent no-op afterwards.
Blocked []CommentTriggerOutcome `json:"blocked,omitempty"`
}
type CommentTriggerAgentResponse struct {
ID string `json:"id"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
Source string `json:"source"`
Reason string `json:"reason"`
}
type commentAgentTriggerSource string
const (
commentTriggerSourceIssueAssignee commentAgentTriggerSource = "issue_assignee"
commentTriggerSourceMentionAgent commentAgentTriggerSource = "mention_agent"
commentTriggerSourceMentionSquadLeader commentAgentTriggerSource = "mention_squad_leader"
commentTriggerSourceThreadParent commentAgentTriggerSource = "thread_parent"
commentTriggerSourceConversation commentAgentTriggerSource = "conversation_continuation"
)
type commentAgentTrigger struct {
Agent db.Agent
Source commentAgentTriggerSource
Squad *db.Squad
AlreadyPending bool
// NonLeaderAgentReply marks an agent comment, not authored by the leader,
// that the assigned-squad-leader fallback routes to that leader. The author
// need not be a squad member. Completion may replay it only if creation
// recorded it as a planned input.
NonLeaderAgentReply bool
}
type commentTriggerComputeOptions struct {
ThreadCommentID pgtype.UUID
ExcludeTriggerCommentID pgtype.UUID
// OriginatorUserID is the top-of-chain human user id for this trigger
// (MUL-3963). Only consulted for AGENT actors — canInvokeAgent judges A2A
// by the originator, not the immediate agent principal. Members are their
// own originator so this may be empty for member-authored triggers.
OriginatorUserID string
}
func commentAgentTriggerReason(trigger commentAgentTrigger) string {
switch trigger.Source {
case commentTriggerSourceIssueAssignee:
return "Current issue assignment will trigger this agent."
case commentTriggerSourceMentionAgent:
return "This agent was mentioned in the comment."
case commentTriggerSourceMentionSquadLeader:
return "A mentioned squad will trigger its leader."
case commentTriggerSourceThreadParent:
return "This reply will trigger the parent comment's author."
case commentTriggerSourceConversation:
return "This follow-up will continue the recent agent conversation."
default:
return "This comment will trigger this agent."
}
}
func (h *Handler) commentAgentTriggerToResponse(trigger commentAgentTrigger) CommentTriggerAgentResponse {
return CommentTriggerAgentResponse{
ID: uuidToString(trigger.Agent.ID),
Name: trigger.Agent.Name,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(trigger.Agent.AvatarUrl)),
Source: string(trigger.Source),
Reason: commentAgentTriggerReason(trigger),
}
}
func (h *Handler) PreviewCommentTriggers(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CommentTriggerPreviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var editingComment *db.Comment
var opts commentTriggerComputeOptions
if req.EditingCommentID != nil {
editingID, ok := parseUUIDOrBadRequest(w, *req.EditingCommentID, "editing_comment_id")
if !ok {
return
}
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: editingID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || uuidToString(comment.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "invalid editing comment")
return
}
editingComment = &comment
opts.ExcludeTriggerCommentID = editingID
}
var parentID pgtype.UUID
if req.ParentID != nil {
parentID, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
if !ok {
return
}
if editingComment != nil && uuidToString(parentID) != uuidToString(editingComment.ParentID) {
writeError(w, http.StatusBadRequest, "parent_id does not match editing comment")
return
}
} else if editingComment != nil && editingComment.ParentID.Valid {
parentID = editingComment.ParentID
}
var parentComment *db.Comment
if parentID.Valid {
parent, err := h.Queries.GetComment(r.Context(), parentID)
if err != nil || uuidToString(parent.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "invalid parent comment")
return
}
parentComment = &parent
}
// Normalize with the SAME entry point CreateComment/UpdateComment apply
// before persisting (sanitizeNullBytes), so the preview computes triggers on
// the exact content that will be stored and enqueued on submit. Otherwise a
// mention hidden behind a byte the DB strips (e.g. a NUL inside
// mention://agent/<uuid>) reads as inert in preview but enqueues the agent on
// submit — a preview/side-effect divergence (GH #5388 review).
content := sanitizeNullBytes(req.Content)
if content == "" {
writeJSON(w, http.StatusOK, CommentTriggerPreviewResponse{Agents: []CommentTriggerAgentResponse{}})
return
}
actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
opts.OriginatorUserID = h.invokeOriginatorFromRequest(r, actorType, actorID)
triggers, targets := h.computeCommentAgentTriggers(r.Context(), issue, content, parentComment, actorType, actorID, opts)
resp := CommentTriggerPreviewResponse{
Agents: make([]CommentTriggerAgentResponse, 0, len(triggers)),
Blocked: commentBlockedTargetOutcomes(targets),
}
for _, trigger := range triggers {
resp.Agents = append(resp.Agents, h.commentAgentTriggerToResponse(trigger))
}
writeJSON(w, http.StatusOK, resp)
}
// taskCoversReplyParent reports whether parentID is a comment this task is
// authorized to reply under. A comment-triggered task may reply to its trigger
// comment OR to any earlier comment that was folded into the same run while it
// was still queued (coalesced_comment_ids). A coalesced run answers each root
// thread it covered inside that thread, so its replies legitimately target
// those threads' comments — not just the trigger (MUL-4348 per-thread fan-out).
//
// Every other parent on the task's own issue is still rejected: this is the
// defense against resumed-session --parent drift and cross-thread misplacement.
// The allow-list is exactly the set the run was given to answer, so it cannot
// reach arbitrary comments.
func taskCoversReplyParent(task db.AgentTaskQueue, parentID pgtype.UUID) bool {
if !parentID.Valid {
return false
}
target := uuidToString(parentID)
if task.TriggerCommentID.Valid && uuidToString(task.TriggerCommentID) == target {
return true
}
for _, id := range task.CoalescedCommentIds {
if id.Valid && uuidToString(id) == target {
return true
}
}
return false
}
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateCommentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Strip bytes PostgreSQL's TEXT column rejects before the empty check. The
// case reachable over the JSON API is an embedded NUL (0x00, SQLSTATE
// 22021) that survives a JSON round trip; sanitizeNullBytes also drops
// invalid UTF-8 as defense-in-depth. A stray such byte in agent-written
// content (notably via --content-file) otherwise fails the INSERT with an
// opaque 500 the CLI renders as "server unavailable" and retries forever —
// the plausible cause of GH #5388. Mirrors the skill-import sanitization;
// normalizing first means all-NUL content is correctly treated as empty.
req.Content = sanitizeNullBytes(req.Content)
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if req.Type == "" {
req.Type = "comment"
}
// Validate rather than letting the DB CHECK reject it: an unknown type
// previously surfaced as a 500 on a constraint violation, which reads as a
// server fault for what is plainly bad input. This is also the explicit
// refusal of a client trying to author a machine-written kind (MUL-5465
// review): a member must not be able to post a comment that renders as
// something the system generated.
if !isClientAuthorableCommentType(req.Type) {
writeError(w, http.StatusBadRequest, "invalid comment type")
return
}
var parentID pgtype.UUID
var parentComment *db.Comment
if req.ParentID != nil {
var parsed pgtype.UUID
parsed, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
if !ok {
return
}
parentID = parsed
parent, err := h.Queries.GetComment(r.Context(), parentID)
if err != nil || uuidToString(parent.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "invalid parent comment")
return
}
parentComment = &parent
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
suppressAgentIDs, ok := parseUUIDSliceOrBadRequest(w, req.SuppressAgentIDs, "suppress_agent_ids")
if !ok {
return
}
// Determine author identity: agent (via X-Agent-ID header) or member.
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
// sourceTaskID captures the agent's currently-executing task when it posts
// via the CLI (X-Task-ID header). Stamping it on the comment row keeps the
// originator inheritance chain (resolveOriginatorFromTriggerComment →
// comment.source_task_id → parent task's originator_user_id) intact across
// an agent→agent hop. Without this stamp, a private squad leader's
// worker-agent whose completion wakes the leader via
// routeAssignedSquadLeaderFallback can't pass canInvokeAgent — the worker's
// task originator is unattributed, effectiveUser resolves to "", and the
// private-agent gate denies the wake (MUL-4015).
//
// The header is NOT client-chosen: the auth middleware deletes any
// client-supplied X-Agent-ID / X-Task-ID and re-stamps both from the mat_
// token's own row (MUL-3428). So the stamp always records the authoring
// agent's own run.
//
// It is deliberately NOT scoped to that run's own issue (MUL-6490 / GH
// #7328). A run that legitimately comments on ANOTHER issue — the ordinary
// "agent creates issue Y, then coordinates there" flow — used to persist a
// NULL lineage, so the run that comment woke resolved as unattributed and
// every @mention / assign / sub-issue inside it hit invocation_not_allowed,
// even though the SAME delegation had just succeeded on the originating
// issue. Propagating the chain is monotonic: it can only carry the human
// this run ALREADY acts for, and every hop still re-runs canInvokeAgent
// against that same human, so the admissible set stays a subset of what that
// human could trigger directly. What must never happen is SUBSTITUTING a
// human — an owner fallback, or adopting the target issue's own originator —
// because that borrows a different person's authority; those stay rejected.
//
// Consumers that genuinely need "the authoring run is working on THIS issue"
// keep that check next to their own rule instead: the reply-parent /
// no_action guards below, and autopilotDelegationAuthority's lineage
// verification (MUL-4857).
var sourceTaskID pgtype.UUID
if authorType == "agent" {
if task, ok := h.taskFromRequestHeader(r); ok {
// Defense against resumed-session drift: when an agent posts from
// inside a comment-triggered task AND the comment is being posted on
// that same issue, the parent_id must exactly match the task's
// trigger comment. Resumed Claude sessions otherwise carry forward a
// previous turn's --parent UUID and silently misplace the reply.
//
// The task.IssueID scope is important: the CLI stamps X-Task-ID on
// every request, so an agent legitimately commenting on a different
// issue must not be blocked by its current task's trigger.
// Assignment-triggered tasks (no TriggerCommentID) are also
// unaffected.
if task.IssueID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
if task.TriggerCommentID.Valid {
if !taskCoversReplyParent(task, parentID) {
// Keep this error actionable for agents (MUL-4417 / GH #5266).
// The two rejections need different copy. A resumed
// session carrying a previous turn's --parent forward
// (GH #6264) did NOT ask for a top-level comment, and
// telling it that it did sends it looking for a
// new-thread opt-in instead of simply correcting the
// parent it already passed.
fix := "set parent_id (--parent) to " + uuidToString(task.TriggerCommentID) + " or a coalesced comment id"
msg := "comment-triggered tasks cannot create top-level comments; " + fix
if parentID.Valid {
msg = "parent_id " + uuidToString(parentID) + " is not a comment this task may reply under; " + fix
}
writeError(w, http.StatusConflict, msg)
return
}
}
noAction, checkErr := service.HasSquadLeaderNoActionEvaluationForTask(r.Context(), h.Queries, task)
if checkErr != nil {
slog.Warn("checking squad leader no_action evaluation failed", append(logger.RequestAttrs(r),
"error", checkErr,
"task_id", uuidToString(task.ID),
"issue_id", issueID,
)...)
} else if noAction {
writeError(w, http.StatusConflict, "squad leader recorded no_action; comments are not allowed for this task")
return
}
}
sourceTaskID = task.ID
}
}
// NOTE: Comment content is stored as Markdown source. XSS is handled at the
// rendering layer (rehype-sanitize) and at the editor layer
// (@tiptap/markdown with html:false). Running an HTML sanitizer here would
// entity-encode Markdown syntax characters (>, ", &, <) and corrupt the
// source. See issue #1303 / discussion in MUL-1119, MUL-1125.
// parent_id stores the exact comment being replied to. Thread-level behavior
// (for example auto-unresolving a resolved thread) resolves the root
// separately so storing a reply-to-reply does not destroy the direct-parent
// signal used by trigger decisions.
var rootComment *db.Comment
if parentID.Valid {
if root, err := h.Queries.GetThreadRoot(r.Context(), db.GetThreadRootParams{
CommentID: parentID,
WorkspaceID: issue.WorkspaceID,
}); err == nil {
rootComment = &root
}
}
created, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
ID: dbid.NewV7(),
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
AuthorType: authorType,
AuthorID: parseUUID(authorID),
Content: req.Content,
Type: req.Type,
ParentID: parentID,
SourceTaskID: sourceTaskID,
})
if err != nil {
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
return
}
comment := created.Comment()
// Link uploaded attachments to this comment.
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, attachmentIDs)
}
// Fetch linked attachments so the response includes them.
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
resp := commentToResponse(comment, nil, groupedAtt[uuidToString(comment.ID)])
resp.IssueRevision = created.IssueRevision
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
"comment": resp,
"issue_title": issue.Title,
"issue_assignee_type": textToPtr(issue.AssigneeType),
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
"issue_status": issue.Status,
"issue_revision": created.IssueRevision,
})
// A reply in a resolved thread re-opens it. Done after CreateComment commits
// so the reply is visible regardless of the unresolve outcome. Shared with
// the agent task path (TaskService.createAgentComment) — both reply paths
// must keep the resolved root in sync.
h.TaskService.AutoUnresolveThreadOnReply(r.Context(), rootComment, uuidToString(issue.WorkspaceID), authorType, authorID)
originatorUserID := h.invokeOriginatorFromRequest(r, authorType, authorID)
// The comment is already saved; a blocked mention must not fail the whole
// request. Surface the per-target outcomes so the client can show partial
// success instead of a silent no-op (MUL-4525 §2).
resp.TriggerOutcomes = h.triggerTasksForComment(r.Context(), issue, comment, parentComment, authorType, authorID, originatorUserID, suppressAgentIDs)
writeJSON(w, http.StatusCreated, resp)
}
// clientAuthorableCommentTypes is what POST /comments accepts. `status_change`
// and `system` are platform-generated narration, so a client claiming them
// would be forging system output; they are deliberately absent.
var clientAuthorableCommentTypes = map[string]struct{}{
"comment": {},
"progress_update": {},
}
func isClientAuthorableCommentType(t string) bool {
_, ok := clientAuthorableCommentTypes[t]
return ok
}
// noteCommentPrefix marks a comment as a human-only note. A comment whose first
// whitespace-delimited token is this prefix (case-insensitive) is stored like
// any other comment but never triggers an agent.
const noteCommentPrefix = "/note"
// isNoteComment reports whether content opts out of agent triggering via the
// reserved /note prefix. The prefix must be the comment's first token, so
// "/note check expiry", " /NOTE", and "/note" all match, while "/notes",
// "/ note", and "see foo/note" do not.
func isNoteComment(content string) bool {
trimmed := strings.TrimLeft(content, " \t\r\n")
firstToken := trimmed
if i := strings.IndexFunc(trimmed, unicode.IsSpace); i >= 0 {
firstToken = trimmed[:i]
}
return strings.EqualFold(firstToken, noteCommentPrefix)
}
// triggerTasksForComment resolves and enqueues the comment's agent triggers and
// returns the per-target outcomes for explicit @agent / @squad mentions
// (MUL-4525 §2): blocked mentions from resolution plus queued / coalesced /
// deferred / blocked from enqueue. UI-suppressed triggers (the user unchecked
// them) are removed before enqueue and produce no outcome.
func (h *Handler) triggerTasksForComment(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, actorType, actorID, originatorUserID string, suppressAgentIDs []pgtype.UUID) []CommentTriggerOutcome {
if isNoteComment(comment.Content) {
return nil
}
triggers, targets := h.computeCommentAgentTriggers(ctx, issue, comment.Content, parentComment, actorType, actorID, commentTriggerComputeOptions{
ExcludeTriggerCommentID: comment.ID,
OriginatorUserID: originatorUserID,
})
triggers = filterSuppressedCommentAgentTriggers(triggers, suppressAgentIDs)
h.noteBlockedRuntimeTargets(ctx, issue, targets)
enqueued := h.enqueueCommentAgentTriggers(ctx, issue, comment.ID, triggers)
return commentTriggerOutcomes(targets, enqueued)
}
// noteBlockedRuntimeTargets leaves one system comment per agent refused for an
// unusable runtime, whoever authored the trigger — see noteRuntimeUnusable for
// why a human author gets one too. Deduped by agent so a comment naming both
// @Agent and the squad it leads produces a single notice.
//
// Called from the trigger path only. The resolver that produced these targets
// also runs for the composer preview, where writing a comment would be a side
// effect of typing.
func (h *Handler) noteBlockedRuntimeTargets(ctx context.Context, issue db.Issue, targets []commentMentionTarget) {
var noted map[string]struct{}
for _, t := range targets {
if t.unusable == nil {
continue
}
id := uuidToString(t.unusable.agent.ID)
if _, done := noted[id]; done {
continue
}
if noted == nil {
noted = make(map[string]struct{}, 1)
}
noted[id] = struct{}{}
h.noteRuntimeUnusable(ctx, issue, t.unusable.agent, t.unusable.verdict)
}
}
func filterSuppressedCommentAgentTriggers(triggers []commentAgentTrigger, suppressAgentIDs []pgtype.UUID) []commentAgentTrigger {
if len(triggers) == 0 || len(suppressAgentIDs) == 0 {
return triggers
}
suppressed := make(map[string]struct{}, len(suppressAgentIDs))
for _, id := range suppressAgentIDs {
if id.Valid {
suppressed[uuidToString(id)] = struct{}{}
}
}
if len(suppressed) == 0 {
return triggers
}
filtered := make([]commentAgentTrigger, 0, len(triggers))
for _, trigger := range triggers {
if _, ok := suppressed[uuidToString(trigger.Agent.ID)]; ok {
continue
}
filtered = append(filtered, trigger)
}
return filtered
}
// commentEnqueueResult is the domain outcome of enqueuing ONE executing agent.
// execSquadID is the squad whose leader context the run actually carries (set
// only for a squad-leader execution), so a DIFFERENT squad that shares this
// leader can be reported honestly as coalesced rather than as if its own leader
// context ran.
type commentEnqueueResult struct {
status DispatchStatus
reason DispatchReasonCode
execSquadID string
}
// enqueueCommentAgentTriggers enqueues each resolved trigger (already deduped by
// executing agent) and returns the result keyed by executing agent id
// (MUL-4525 §2). Outcomes are later fanned from these to every explicit mention
// target that resolved to the agent, so coalescing a run never drops a named
// target's outcome. queued / coalesced / deferred are success-shaped (the run
// was handled, no duplicate task); only a real enqueue failure is blocked.
func (h *Handler) enqueueCommentAgentTriggers(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, triggers []commentAgentTrigger) map[string]commentEnqueueResult {
results := make(map[string]commentEnqueueResult, len(triggers))
record := func(trigger commentAgentTrigger, status DispatchStatus, reason DispatchReasonCode) {
execSquadID := ""
if trigger.Squad != nil {
execSquadID = uuidToString(trigger.Squad.ID)
}
results[uuidToString(trigger.Agent.ID)] = commentEnqueueResult{status: status, reason: reason, execSquadID: execSquadID}
}
for _, trigger := range triggers {
status, reason := h.resolveCommentTriggerEnqueue(ctx, issue, trigger, triggerCommentID)
record(trigger, status, reason)
}
return results
}
// resolveCommentTriggerEnqueue resolves ONE trigger into its final dispatch
// outcome, folding the comment into an existing pending task rather than
// dropping it (MUL-4195). It handles the two ways the (issue, agent) pair can
// already hold a pending task:
//
// - resolution pre-flagged AlreadyPending, or
// - a fresh enqueue LOST the race to a concurrent sibling and the
// idx_one_pending_task_per_issue_agent unique index rejected the insert
// (service.ErrDuplicatePendingTask, #5914).
//
// Both mean "a task now exists". INVARIANT: a success-shaped outcome (coalesced /
// deferred / queued) is returned only when the comment is provably attached to a
// run that will address it:
//
// - coalesced — an atomic head-scoped merge folded it into a queued task;
// - queued — a fresh task was created for it;
// - deferred — an active task's completion reconcile will replay it, either
// because a planned-id write landed on a claim-receipt task
// (worker reply, or lost enqueue race where the comment may predate the task) or
// because the comment is newer than that task and therefore
// inside reconcile's `created_at > since` window (AlreadyPending
// path for member/explicit-mention routes, where the task existed first).
//
// The deferral is not a one-shot prediction: if a replay is later blocked by a
// task that cannot cover the comment, reconcileCommentsOnCompletion hands the
// obligation on (propagateUncoveredCommentObligation) instead of discarding it,
// and that hand-off retries across the blocker's state changes (#5914, Elon
// rounds 8–9). It is best-effort in exactly one shape — a DIFFERENT-head QUEUED
// blocker, which can neither cover the comment nor accept it without violating
// TEN-356 — and that case is logged at error rather than hidden; closing it needs
// a durable obligation record, which is deliberately out of scope here.
// It never defers off a mere classification of the current active tasks — a
// snapshot cannot prove anything about state after it is read (round 7).
// A duplicate that cannot yet be resolved re-loops (bounded by maxAttempts); on
// genuine non-convergence it returns a truthful internal_error, never a fabricated
// deferred that would silently drop the comment.
func (h *Handler) resolveCommentTriggerEnqueue(ctx context.Context, issue db.Issue, trigger commentAgentTrigger, triggerCommentID pgtype.UUID) (DispatchStatus, DispatchReasonCode) {
pending := trigger.AlreadyPending
lostRace := false
// Resolve the reviewed HEAD lazily and at most once — the common
// non-pending path enqueues without ever needing it, so it stays off the
// hot path. It keys both the head-scoped merge and the planned-comment
// registration, matching the AlreadyPending pre-check's head (TEN-356).
var headSha pgtype.Text
headShaLoaded := false
getHeadSha := func() pgtype.Text {
if !headShaLoaded {
headSha = h.TaskService.ResolveIssueReviewSHAParam(ctx, issue.ID)
headShaLoaded = true
}
return headSha
}
// Bounded retry (#5914, Elon round 3). INVARIANT: never return a
// success-shaped outcome (coalesced / deferred / queued) without a COMPLETED
// merge, planned-id registration, fresh enqueue, or a confirmed different-head
// deferral. A duplicate that cannot be durably resolved re-loops; on genuine
// non-convergence we return a truthful internal_error below, never a
// fabricated deferred that would silently drop the comment. maxAttempts is a
// concurrent-churn backstop — each duplicate is followed by a merge/register
// attempt, so the loop only spins while a sibling keeps flipping state.
const maxAttempts = 4
for attempt := 0; attempt < maxAttempts; attempt++ {
if pending {
// (a) Fold into a same-head QUEUED task. This is the ONLY path that
// ATOMICALLY re-attributes the run (trigger / originator / accountable
// / overlay / connected apps) to the folded comment, so a queued
// winner must always come through here, never a bare planned append
// (MUL-4302). HEAD-scoped so a DIFFERENT-head task is never folded
// (TEN-356) — the merge misses and this falls through. The merge
// reports HOW it resolved: coalesced on success; blocked on a
// fail-closed / failed merge — never mislabeled as success (MUL-4525 §2).
if status, reason, terminal := commentMergeTerminalOutcome(
h.mergeCommentIntoPendingTask(ctx, issue, trigger, triggerCommentID, getHeadSha()),
); terminal {
return status, reason
}
// No same-head QUEUED row to fold into (merge missed). The two paths
// resolve differently.
if !lostRace && !trigger.NonLeaderAgentReply {
// (b) AlreadyPending path: this comment arrived AFTER its task, so
// it is newer than the task and completion reconcile covers it by
// timestamp; MUL-4195 leaves the claimed task untouched. Defer to
// the active task, or enqueue fresh if it has since finished.
active, activeErr := h.hasActiveTaskForIssueAndAgent(ctx, issue.ID, trigger.Agent.ID, triggerCommentID)
if status, reason, enqueueFresh := decidePostMergeMiss(active, activeErr); !enqueueFresh {
return status, reason
}
// enqueueFresh → fall through to the enqueue below.
} else {
// (c) A worker reply needs a recorded obligation: unlike explicit
// mentions, timestamp-only agent replies are not replayed (loop
// safety). The same registration also covers a lost INSERT race,
// where the losing comment can PREDATE the winner,
// which completion reconcile's `created_at > since` window cannot
// see. Register it as a planned (undelivered) input on a same-head
// CLAIM-RECEIPT task (dispatched/running/waiting; queued is excluded
// — it must fold through the atomic merge above), turning the drop
// into a bounded follow-up (#5914, Elon round 2).
registered, err := h.registerPlannedCommentForActiveTask(ctx, issue, trigger.Agent.ID, triggerCommentID, getHeadSha())
if err != nil {
slog.Warn("register planned comment failed",
"issue_id", uuidToString(issue.ID), "agent_id", uuidToString(trigger.Agent.ID), "error", err)
return DispatchBlocked, ReasonInternalError
}
if registered {
return DispatchDeferred, ReasonDeferred
}
// (d) No same-head QUEUED task to fold (merge missed) and no
// same-head CLAIM-RECEIPT task to register on. Do NOT try to defer
// off a classification of the current active tasks: a snapshot only
// proves state NOW, and a DIFFERENT-head task's reconcile covering
// this comment can be defeated by a newer blocker that appears after
// the snapshot but before that task completes (its replay collides
// with the blocker and completion reconcile discards the failure —
// Elon round 7). Every success-shaped outcome must be backed by a
// COMPLETED write, never a prediction. So just fall through to a
// fresh enqueue: if the unique slot is still occupied — necessarily
// by a different-head task we can neither fold into nor durably cover
// — the insert re-raises ErrDuplicatePendingTask and we re-loop,
// converging to a truthful non-success; if the slot is free, the
// enqueue creates the covering task. (The occupant's own completion
// reconcile still replays this comment best-effort in the common
// no-blocker case; we simply never PROMISE it.)
}
}
if err := h.enqueueSingleCommentTrigger(ctx, issue, triggerCommentID, trigger); err != nil {
// Lost the enqueue race: a sibling task for this (issue, agent) now
// exists. Re-resolve as pending so the next attempt folds this
// comment into that sibling (queued) or durably registers it
// (dispatched) instead of dropping it (#5914).
if errors.Is(err, service.ErrDuplicatePendingTask) {
pending = true
lostRace = true
continue
}
return DispatchBlocked, commentEnqueueFailureReason(err)
}
return DispatchQueued, ReasonQueued
}
// Non-convergence after the bounded retries (only reachable under sustained
// concurrent churn where a sibling keeps flipping state under us): surface a
// truthful non-success — NEVER a fabricated deferred/coalesced that would
// report the comment covered when no task provably covers it (#5914, Elon
// round 3). The comment row persists; a later trigger/edit can still cover it.
slog.Warn("comment trigger enqueue did not converge; reporting non-success",
"issue_id", uuidToString(issue.ID), "agent_id", uuidToString(trigger.Agent.ID))
return DispatchBlocked, ReasonInternalError
}
// commentTriggerOutcomes maps each explicit mention target to its final outcome
// (MUL-4525 §2): a target that resolved to an executing agent takes that agent's
// enqueue status, so several mentions coalescing into one run each still get
// their own outcome; a terminal target (blocked / self-suppressed) carries its
// own status. A target whose executing agent has no enqueue result — because the
// composer suppressed (unchecked) it — yields no outcome, since the user opted
// out deliberately.
func commentTriggerOutcomes(targets []commentMentionTarget, enqueued map[string]commentEnqueueResult) []CommentTriggerOutcome {
if len(targets) == 0 {
return nil
}
outcomes := make([]CommentTriggerOutcome, 0, len(targets))
for _, t := range targets {
if t.ExecAgentID != "" {
res, ok := enqueued[t.ExecAgentID]
if !ok {
continue
}
status, reason := res.status, res.reason
// A @squad whose shared leader ran, but under a DIFFERENT squad's
// context, did not get its own leader briefing injected — the single
// leader run (one task per issue+agent) merely folds it in. Report
// coalesced, not queued, so we never claim this squad's leader
// context executed (MUL-4525, Elon round 3).
if t.TargetType == "squad" && res.execSquadID != "" && res.execSquadID != t.TargetID && status == DispatchQueued {
status, reason = DispatchCoalesced, ReasonCoalesced
}
outcomes = append(outcomes, CommentTriggerOutcome{TargetType: t.TargetType, TargetID: t.TargetID, Status: status, ReasonCode: reason})
continue
}
outcomes = append(outcomes, CommentTriggerOutcome{TargetType: t.TargetType, TargetID: t.TargetID, Status: t.Status, ReasonCode: t.ReasonCode})
}
return outcomes
}
// commentBlockedTargetOutcomes is the composer-preview projection: the explicit
// mentions that will NOT trigger if the comment is posted as-is (MUL-4525 §2). A
// resolvable/executing target instead appears in the preview `agents` list, so
// only terminal blocked targets surface here.
func commentBlockedTargetOutcomes(targets []commentMentionTarget) []CommentTriggerOutcome {
var blocked []CommentTriggerOutcome
for _, t := range targets {
if t.Status == DispatchBlocked {
blocked = append(blocked, CommentTriggerOutcome{TargetType: t.TargetType, TargetID: t.TargetID, Status: t.Status, ReasonCode: t.ReasonCode})
}
}
return blocked
}
// commentEnqueueFailureReason types an enqueue error that reached the response
// (MUL-4525 §2). The admission gate (canInvokeAgent / archived / runtime) already
// ran during resolution, so a failure here is either a fail-closed attribution
// refusal (attribution_blocked, typed via errors.Is) or a rare race /
// infrastructure error that stays an unclassified internal error rather than
// leaking the raw message.
func commentEnqueueFailureReason(err error) DispatchReasonCode {
if errors.Is(err, service.ErrAttributionFailClosed) {
return ReasonAttributionBlocked
}
return ReasonInternalError
}
// hasActiveTaskForIssueAndAgent reports whether the (issue, agent) pair has any
// non-terminal task whose completion will drive completion reconciliation. It
// returns the query error rather than swallowing it (MUL-4525, Elon round 4):
// callers must fail closed on error (never enqueue a possibly-colliding
// duplicate) AND must not report a success — "cannot confirm whether a run is
// active" is never the same as "a run is active". See decidePostMergeMiss /
// decideSuppressedLeaderOutcome for the two decisions.
func (h *Handler) hasActiveTaskForIssueAndAgent(ctx context.Context, issueID, agentID, threadCommentID pgtype.UUID) (bool, error) {
active, err := h.Queries.HasActiveTaskForIssueAndAgentInThread(ctx, db.HasActiveTaskForIssueAndAgentInThreadParams{
ThreadCommentID: threadCommentID,
IssueID: issueID,
AgentID: agentID,
})
if err != nil {
slog.Warn("has active task for issue+agent check failed",
"issue_id", uuidToString(issueID), "agent_id", uuidToString(agentID), "error", err)
return false, err
}
return active, nil
}
// decidePostMergeMiss decides what to do after a comment merge missed on a
// target that had a pending task (MUL-4525, Elon round 4). On a query failure
// (activeErr != nil) it FAILS CLOSED: never enqueue a fresh task — a duplicate
// concurrent run risk — and report a non-success internal_error, since we cannot
// confirm a run is active. A confirmed active task defers to that run's
// reconcile; only a confirmed-none enqueues a fresh follow-up.
func decidePostMergeMiss(active bool, activeErr error) (status DispatchStatus, reason DispatchReasonCode, enqueueFresh bool) {
switch {
case activeErr != nil:
return DispatchBlocked, ReasonInternalError, false
case active:
return DispatchDeferred, ReasonDeferred, false
default:
return "", "", true
}
}
// decideSuppressedLeaderOutcome maps the self-trigger-suppressed squad leader's
// active-task check to an honest outcome (MUL-4525, Elon round 4). A query
// failure is never success — it is internal_error, not a fabricated deferred.
// A confirmed active run defers (its reconcile covers the comment); otherwise
// nothing runs and the outcome is self_trigger_suppressed.
func decideSuppressedLeaderOutcome(active bool, activeErr error) (DispatchStatus, DispatchReasonCode) {
switch {
case activeErr != nil:
return DispatchBlocked, ReasonInternalError
case active:
return DispatchDeferred, ReasonAlreadyActive
default:
return DispatchBlocked, ReasonSelfTriggerSuppressed
}
}
// commentMergeResult distinguishes how a pending-task merge attempt resolved so
// the caller can report an HONEST outcome (MUL-4525, Elon round 5). A real merge
// is coalesced, but a REFUSED or FAILED merge — even when we correctly fail
// closed by keeping the original task and not enqueuing a duplicate — must NOT
// be reported as a success-shaped coalesced.
type commentMergeResult int
const (
// commentMergeSucceeded: the comment folded into the queued task → coalesced.
commentMergeSucceeded commentMergeResult = iota
// commentMergeNoPendingTask: no queued task to merge into anymore (it was
// claimed/started between the dedup check and now). The caller runs the
// active-task decision (defer vs fresh enqueue).
commentMergeNoPendingTask
// commentMergeAttributionBlocked: fail-closed attribution refused re-stamping
// the merge. The original task is kept and no fresh task is enqueued, but the
// re-attribution did NOT happen → outcome attribution_blocked, not success.
commentMergeAttributionBlocked
// commentMergeError: an unknown attribution/DB error. Fail closed (keep the
// task, no duplicate enqueue), but the merge did not complete → outcome
// internal_error, not success.
commentMergeError
)
// commentMergeTerminalOutcome maps a merge result that carries its own final
// outcome (everything except commentMergeNoPendingTask, which needs the
// active-task decision) to the reported (status, reason). terminal=false only
// for commentMergeNoPendingTask.
func commentMergeTerminalOutcome(result commentMergeResult) (status DispatchStatus, reason DispatchReasonCode, terminal bool) {
switch result {
case commentMergeSucceeded:
return DispatchCoalesced, ReasonCoalesced, true
case commentMergeAttributionBlocked:
return DispatchBlocked, ReasonAttributionBlocked, true
case commentMergeError:
return DispatchBlocked, ReasonInternalError, true
default: // commentMergeNoPendingTask
return "", "", false
}
}
// mergeCommentIntoPendingTask folds a newly-arrived comment into the existing
// pre-claim task for (issue, agent) instead of dropping it
// (MUL-4195). It reports HOW it resolved via commentMergeResult so the caller
// never mislabels a refused/failed merge as success (MUL-4525 §2). No path here
// enqueues a duplicate: on any failure the original task is kept intact, so the
// comment is still read by that run and its instruction is not lost — only the
// re-attribution / merge bookkeeping is declined, and that is surfaced honestly.
//
// Recompute-on-merge (MUL-4195 review must-fix #1): on success the run's
// originator_user_id, runtime_mcp_overlay and runtime_connected_apps are
// re-stamped to the NEW trigger comment's originator, and trigger_summary is
// refreshed — so a different member's comment safely folds into a task another
// member created, the coalescing run carrying the latest instruction's
// originator and matching connected-app overlay.
func (h *Handler) mergeCommentIntoPendingTask(ctx context.Context, issue db.Issue, trigger commentAgentTrigger, newTriggerCommentID pgtype.UUID, headSha pgtype.Text) commentMergeResult {
// Re-attribute the coalescing run to the new comment's human atomically: the
// whole attribution snapshot moves, not just the person columns (MUL-4302). An
// issue-assignee reaction is comment_source; a mention / thread-parent /
// conversation hop is delegation.
isMention := trigger.Source != commentTriggerSourceIssueAssignee
attr, err := h.TaskService.AttributionForMergedComment(ctx, issue.WorkspaceID, newTriggerCommentID, isMention, trigger.Agent)
if err != nil {
// The new comment cannot be re-attributed. REFUSE the merge — keep the
// existing queued task on its original (precise) snapshot rather than
// re-stamp it to a degraded owner_fallback, and never spawn a duplicate.
// A fail-closed refusal is a distinct, honest outcome (attribution_blocked);
// any other error is unclassified (internal_error) — neither is success.
slog.Warn("refused comment merge: attribution failed, keeping original task snapshot",
"issue_id", uuidToString(issue.ID),
"agent_id", uuidToString(trigger.Agent.ID),
"new_trigger_comment_id", uuidToString(newTriggerCommentID),
"error", err)
if errors.Is(err, service.ErrAttributionFailClosed) {
return commentMergeAttributionBlocked
}
return commentMergeError
}
overlay, connectedApps := h.TaskService.BuildRuntimeMCPOverlayForMerge(ctx, attr.UserID, trigger.Agent)
row, err := h.Queries.MergeCommentIntoPendingTask(ctx, db.MergeCommentIntoPendingTaskParams{
IssueID: issue.ID,
AgentID: trigger.Agent.ID,
NewTriggerCommentID: newTriggerCommentID,
NewOriginatorUserID: attr.UserID,
NewAccountableUserID: attr.AccountableUserID,
NewOriginatorSource: pgtype.Text{String: attr.Source.String(), Valid: true},
NewDelegatedFromTaskID: attr.DelegatedFromTaskID,
NewRuleVersionID: attr.RuleVersionID,
NewTriggerEvidenceKind: pgtype.Text{String: string(attr.EvidenceKind), Valid: attr.EvidenceKind != ""},
NewTriggerEvidenceRefID: attr.EvidenceRefID,
NewTriggerSummary: h.TaskService.BuildCommentTriggerSummary(ctx, issue.WorkspaceID, newTriggerCommentID),
NewRuntimeMcpOverlay: overlay,
NewRuntimeConnectedApps: connectedApps,
HeadSha: headSha,
})
if err != nil {
if isNotFound(err) {
// No pre-claim (queued/deferred) task to merge into. The caller
// defers to completion reconcile when an active task exists, or
// enqueues fresh when none does.
return commentMergeNoPendingTask
}
// Unknown error: the pending task most likely still exists, so do NOT
// risk enqueuing a duplicate — but the merge did not happen, so this is
// not a success.
slog.Warn("merge comment into pending task failed",
"issue_id", uuidToString(issue.ID),
"agent_id", uuidToString(trigger.Agent.ID),
"error", err)
return commentMergeError
}
slog.Info("merged comment into pending task",
"task_id", uuidToString(row.ID),
"issue_id", uuidToString(issue.ID),
"agent_id", uuidToString(trigger.Agent.ID),
"new_trigger_comment_id", uuidToString(newTriggerCommentID),
"coalesced_count", len(row.CoalescedCommentIds))
return commentMergeSucceeded
}
// registerPlannedCommentForActiveTask durably folds an accepted comment into the
// same-head active task's planned (coalesced) set when the queued merge could no
// longer target it (the winner was already claimed → dispatched/running). It
// returns true when a same-head active task absorbed the comment; (false, nil)
// means no same-head active task exists (a DIFFERENT-head task holds the slot, or
// the task just terminated), so the caller falls back to the active-task
// decision. Delivered ids are untouched, so completion reconcile replays the
// comment as a single bounded follow-up (#5914). See the query doc.
func (h *Handler) registerPlannedCommentForActiveTask(ctx context.Context, issue db.Issue, agentID, commentID pgtype.UUID, headSha pgtype.Text) (bool, error) {
row, err := h.Queries.RegisterPlannedCommentForActiveTask(ctx, db.RegisterPlannedCommentForActiveTaskParams{
CommentID: commentID,
IssueID: issue.ID,
AgentID: agentID,
HeadSha: headSha,
})
if err != nil {
if isNotFound(err) {
return false, nil
}
return false, err
}
slog.Info("registered comment as planned follow-up input",
"task_id", uuidToString(row.ID),
"issue_id", uuidToString(issue.ID),
"agent_id", uuidToString(agentID),
"comment_id", uuidToString(commentID),
"coalesced_count", len(row.CoalescedCommentIds))
return true, nil
}
// propagateUncoveredCommentObligation hands an UNCOVERED comment's replay
// obligation to whichever active task now occupies the (issue, agent) slot, so a
// blocked completion replay is never silently dropped (#5914, Elon round 8).
//
// Without this, a deferral is only ever consumed ONCE: task A's completion
// reconcile replays comment C, the enqueue collides with a newer blocker B, the
// failure is discarded, and B never sees C (C predates B and is not in its
// planned ids) — so C is lost even though the original response promised it would
// be handled. Propagating turns "consumed once" into "handed along" until some
// task provably covers C.
//
// Each attempt tries, in order, the three writes that can durably cover the
// comment, and RETRIES when the slot's occupant changes state under us — the
// hand-off is not one atomic statement, so a blocker that completes or gets
// claimed between two steps must be re-observed rather than lost (Elon round 9):
//
// 1. a CLAIM-RECEIPT task (dispatched/running/waiting), ANY head: a planned id
// added after the claim is never delivered to that run, so it cannot consume
// the comment under the wrong head — it purely schedules the comment for that
// task's own completion reconcile, which re-enqueues at the head current then;
// 2. a SAME-HEAD queued task: the atomic merge, which re-stamps trigger /
// originator / overlay (MUL-4302). This is HEAD-SCOPED on purpose: merging
// into a different-head queued task would make the comment that run's trigger
// and mark it delivered at claim time, so an old-head run would consume a
// new-head request and no new-head follow-up would ever be created — the
// TEN-356 violation (Elon round 9, must-fix 2);
// 3. a fresh enqueue: correct precisely when the blocker has finished and freed
// the unique slot, which is the state a mid-hand-off completion leaves behind.
//
// A duplicate-key error from (3) means the slot is still held, so the loop
// re-observes and tries again. Returns false only when every attempt found the
// slot held by a task that can neither cover nor accept the comment (a
// different-head QUEUED blocker); the caller logs that loudly — it is the one
// shape today's schema has no safe place to park, and closing it needs a durable
// obligation record rather than an in-request hand-off.
func (h *Handler) propagateUncoveredCommentObligation(ctx context.Context, issue db.Issue, trigger commentAgentTrigger, commentID pgtype.UUID, headSha pgtype.Text) bool {
const maxAttempts = 3
for attempt := 0; attempt < maxAttempts; attempt++ {
registered, err := h.registerPlannedCommentForActiveTask(ctx, issue, trigger.Agent.ID, commentID, pgtype.Text{})
if err != nil {
slog.Warn("propagate comment obligation: register on active task failed",
"issue_id", uuidToString(issue.ID), "agent_id", uuidToString(trigger.Agent.ID),
"comment_id", uuidToString(commentID), "error", err)
} else if registered {
return true
}
if h.mergeCommentIntoPendingTask(ctx, issue, trigger, commentID, headSha) == commentMergeSucceeded {
return true
}
err = h.enqueueSingleCommentTrigger(ctx, issue, commentID, trigger)
if err == nil {
return true
}
if !errors.Is(err, service.ErrDuplicatePendingTask) {
return false
}
// The slot is still occupied; its occupant may have changed state between
// the steps above, so re-observe and try again.
}
return false
}
// logCommentEnqueueFailure logs a failed comment-trigger enqueue. A benign
// duplicate-pending-task race (service.ErrDuplicatePendingTask) is not a fault —
// resolveCommentTriggerEnqueue folds the comment into the winning task — so it
// is logged at debug and never surfaces as a warning, keeping the raw Postgres
// constraint name out of warn/error logs (#5914, Elon review). Any other error
// stays a warning with the error attached.
func logCommentEnqueueFailure(msg string, err error, attrs ...any) {
if errors.Is(err, service.ErrDuplicatePendingTask) {
slog.Debug(msg+": duplicate pending task, coalescing into the sibling run", attrs...)
return
}
slog.Warn(msg, append(attrs, "error", err)...)
}
// enqueueSingleCommentTrigger creates a fresh task for one computed trigger.
// Split out of enqueueCommentAgentTriggers so the merge-not-drop path
// (MUL-4195) can fall back to it when a pending task vanished mid-flight.
// enqueueSingleCommentTrigger enqueues one resolved trigger and returns the
// enqueue error (nil on success) so the caller can surface a
// trigger_outcome (MUL-4525 §2).
func (h *Handler) enqueueSingleCommentTrigger(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, trigger commentAgentTrigger) error {
switch trigger.Source {
case commentTriggerSourceIssueAssignee:
if trigger.Squad != nil {
if _, err := h.TaskService.EnqueueTaskForSquadLeader(ctx, issue, trigger.Agent.ID, trigger.Squad.ID, triggerCommentID); err != nil {
logCommentEnqueueFailure("enqueue squad leader task failed", err,
"issue_id", uuidToString(issue.ID),
"squad_id", uuidToString(trigger.Squad.ID),
"leader_id", uuidToString(trigger.Agent.ID))
return err
}
return nil
}
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue, triggerCommentID); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", uuidToString(issue.ID), "error", err)
return err
}
case commentTriggerSourceMentionSquadLeader:
if _, err := h.TaskService.EnqueueTaskForSquadLeader(ctx, issue, trigger.Agent.ID, trigger.Squad.ID, triggerCommentID); err != nil {
logCommentEnqueueFailure("enqueue squad leader mention task failed", err,
"issue_id", uuidToString(issue.ID),
"agent_id", uuidToString(trigger.Agent.ID))
return err
}
case commentTriggerSourceMentionAgent:
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, trigger.Agent.ID, triggerCommentID); err != nil {
logCommentEnqueueFailure("enqueue mention agent task failed", err,
"issue_id", uuidToString(issue.ID),
"agent_id", uuidToString(trigger.Agent.ID))
return err
}
case commentTriggerSourceThreadParent, commentTriggerSourceConversation:
var err error
// Squad is set on these two sources only when the routing already
// proved a leader role to continue: the thread root's prior task for
// the conversation path, the replied-to comment's own authoring task
// for the thread-parent path. Gating on the source as well would keep
// the thread-parent path demoted for no reason (MUL-7006).
if trigger.Squad != nil {
_, err = h.TaskService.EnqueueTaskForSquadLeader(ctx, issue, trigger.Agent.ID, trigger.Squad.ID, triggerCommentID)
} else {
_, err = h.TaskService.EnqueueTaskForThreadParent(ctx, issue, trigger.Agent.ID, triggerCommentID)
}
if err != nil {
logCommentEnqueueFailure("enqueue routed comment agent task failed", err,
"issue_id", uuidToString(issue.ID),
"agent_id", uuidToString(trigger.Agent.ID),
"source", trigger.Source)
return err
}
}
return nil
}
// computeCommentAgentTriggers resolves which agents a comment triggers (deduped
// by executing agent), plus the per-target list for every EXPLICIT @agent /
// @squad mention (MUL-4525 §2). Targets come only from the explicit-mention path
// — the implicit routing fallbacks (assignee, thread parent, conversation) were
// never named by the user, so a no-route there is not a silent no-op.
func (h *Handler) computeCommentAgentTriggers(ctx context.Context, issue db.Issue, content string, parentComment *db.Comment, actorType, actorID string, opts commentTriggerComputeOptions) ([]commentAgentTrigger, []commentMentionTarget) {
// A persisted comment determines its thread; previews use the parent.
// A new top-level preview has no thread yet and cannot merge with a queue.
opts.ThreadCommentID = opts.ExcludeTriggerCommentID
if !opts.ThreadCommentID.Valid && parentComment != nil {
opts.ThreadCommentID = parentComment.ID
}
if isNoteComment(content) {
return nil, nil
}
// Autopilot delegation authority (MUL-4857) is applied by the gate via
// opts.OriginatorUserID: when a run carried no human originator, the gate
// falls back to opts.AutopilotDelegationAuthorityUserID, which the caller has
// already resolved from a server-trusted, lineage-verified speaking task (see
// autopilotDelegationAuthority). Nothing is re-derived from issue provenance
// here, so an unrelated unattributed run cannot borrow a stranger autopilot
// creator's authority by commenting on that autopilot's issue.
mentions := util.ParseMentions(content)
// EXPLICIT @agent / @squad mentions are a direct request and win over the
// @all broadcast (MUL-5411). @all only suppresses the IMPLICIT routing
// fallbacks (assignee / thread parent / conversation) below — it must not
// swallow a target the author named by hand. Before this ordering, a
// comment carrying both `@all` and `@Preflight` enqueued nothing at all.
// `all` is neither "agent" nor "squad", so it is skipped inside
// resolveMentionedAgentCommentTriggers and never enqueues a run of its own.
if hasAgentOrSquadMention(mentions) {
return h.resolveMentionedAgentCommentTriggers(ctx, issue, mentions, actorType, actorID, opts)
}
if util.HasMentionAll(mentions) {
return nil, nil
}
if hasMemberMention(mentions) {
return nil, nil
}
if actorType != "member" {
// Agent-authored comments do not participate in the member-driven
// conversation routing (parent-author / thread-root continuation) or
// the member assignee fallback. They retain one narrow path restored
// after MUL-3794 (MUL-3879): a worker-agent result comment on a
// squad-assigned issue can still wake the assigned squad leader, so
// the leader→worker→leader coordination loop stays closed. The leader
// self-trigger guard lives in
// routeAssignedSquadLeaderFallback. Explicit @agent / @squad mentions
// are already handled above, so this never double-enqueues a mentioned
// target alongside the assigned leader.
if issue.AssigneeType.Valid && issue.AssigneeType.String == "squad" {
if trigger, ok := h.routeAssignedSquadLeaderFallback(ctx, issue, actorType, actorID, opts); ok {
trigger.NonLeaderAgentReply = actorType == "agent" && actorID != uuidToString(trigger.Agent.ID)
return []commentAgentTrigger{trigger}, nil
}
}
return nil, nil
}
if parentComment != nil && parentComment.AuthorType == "agent" {
trigger, ok := h.routeReplyToParentAuthor(ctx, issue, parentComment, actorType, actorID, opts)
if !ok {
return nil, nil
}
return []commentAgentTrigger{trigger}, nil
}
if parentComment != nil {
triggers, handled := h.routeThreadRootOwners(ctx, issue, parentComment, actorID, opts)
if handled {
if len(triggers) == 0 {
return nil, nil
}
return triggers, nil
}
// A plain member-to-member reply must not start the issue assignee just
// because the thread has no agent owner. Explicit mentions and existing
// conversation owners were already resolved above.
if parentComment.AuthorType == "member" {
return nil, nil
}
}
if trigger, ok := h.routeAssigneeFallback(ctx, issue, actorType, actorID, opts); ok {
return []commentAgentTrigger{trigger}, nil
}
return nil, nil
}
func hasAgentOrSquadMention(mentions []util.Mention) bool {
for _, m := range mentions {
if m.Type == "agent" || m.Type == "squad" {
return true
}
}
return false
}
func hasMemberMention(mentions []util.Mention) bool {
for _, m := range mentions {
if m.Type == "member" {
return true
}
}
return false
}
func (h *Handler) routeReplyToParentAuthor(ctx context.Context, issue db.Issue, parent *db.Comment, authorType, authorID string, opts commentTriggerComputeOptions) (commentAgentTrigger, bool) {
if parent == nil || parent.AuthorType != "agent" || !parent.AuthorID.Valid {
return commentAgentTrigger{}, false
}
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: parent.AuthorID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
return commentAgentTrigger{}, false
}
if !h.canInvokeAgent(ctx, agent, authorType, authorID, opts.OriginatorUserID, uuidToString(issue.WorkspaceID)) {
return commentAgentTrigger{}, false
}
hasPending, err := h.hasPendingTaskForIssueAndAgent(ctx, issue.ID, parent.AuthorID, opts)
if err != nil {
return commentAgentTrigger{}, false
}
trigger := commentAgentTrigger{Agent: agent, Source: commentTriggerSourceThreadParent, AlreadyPending: hasPending}
if squad, ok := h.squadLeaderRoleOfAuthoringTask(ctx, issue, *parent, agent); ok {
trigger.Squad = squad
}
return trigger, true
}
// squadLeaderRoleOfAuthoringTask reports the squad a reply should continue
// under, when the comment being replied to was itself written by a run of that
// squad's leader acting IN the leader role.
//
// Without it, replying directly to a leader's comment demoted the next run to
// a generic direct-agent task (MUL-4024's thread-parent gap): no squad
// briefing at claim time, `multica squad activity` rejected, and — on a
// project with a local_directory resource — the coordinator dragged into the
// user's own directory, queued behind its path mutex and stripped of the
// prior session it was still holding a workdir for (MUL-7006). Replying to a
// MEMBER comment in the same thread already restores the role this way
// (routeConversationOwnersForRoot); this closes the asymmetry.
//
// The role is read from the replied-to comment's OWN authoring run
// (source_task_id), not from the agent's latest task on the issue. An agent
// that is both leader and worker of the same squad posts in both roles, and a
// reply continues the role of the comment it answers — the latest-task read
// would hand a reply to a worker comment the leader's coordinator role.
//
// Fails closed to today's direct-agent routing on every uncertainty: a comment
// with no recorded authoring run (pre-migration-120 rows, or an agent write
// without the X-Task-ID header), a task that did not run as leader, a squad
// since deleted, or leadership that has moved to another agent. The claim
// handler downgrades the wire role again if the briefing cannot be injected,
// so an is_leader_task row whose squad went missing later still cannot deliver
// a leader run.
//
// Deleting a squad ARCHIVES it (DeleteSquad soft-archives after transferring
// its issues to the leader agent), so its rows and the leader's old comments
// both outlive the deletion. GetSquadInWorkspace does not filter archived_at —
// reviving a deleted squad from a stale comment would be a role this issue no
// longer has any assignment for — so the archived check belongs here.
func (h *Handler) squadLeaderRoleOfAuthoringTask(ctx context.Context, issue db.Issue, parent db.Comment, agent db.Agent) (*db.Squad, bool) {
if !parent.SourceTaskID.Valid {
return nil, false
}
task, err := h.Queries.GetAgentTask(ctx, parent.SourceTaskID)
if err != nil || !task.IsLeaderTask || !task.SquadID.Valid {
return nil, false
}
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: task.SquadID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || squad.ArchivedAt.Valid || uuidToString(squad.LeaderID) != uuidToString(agent.ID) {
return nil, false
}
return &squad, true
}
type conversationRoutedAgentInfo struct {
SquadID pgtype.UUID
}
func (h *Handler) routeThreadRootOwners(ctx context.Context, issue db.Issue, parent *db.Comment, memberID string, opts commentTriggerComputeOptions) ([]commentAgentTrigger, bool) {
if parent == nil || !parent.ID.Valid {
return nil, false
}
root, err := h.Queries.GetThreadRoot(ctx, db.GetThreadRootParams{
CommentID: parent.ID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || !root.ID.Valid || root.AuthorType != "member" {
return nil, false
}
return h.routeConversationOwnersForRoot(ctx, issue, root, memberID, opts)
}
func (h *Handler) routeConversationOwnersForRoot(ctx context.Context, issue db.Issue, root db.Comment, memberID string, opts commentTriggerComputeOptions) ([]commentAgentTrigger, bool) {
if !root.ID.Valid || root.AuthorType != "member" {
return nil, false
}
if trigger, hasExplicitOwner, ok := h.routeFirstExplicitRootMentionOwner(ctx, issue, root, memberID, opts); hasExplicitOwner {
if !ok {
return nil, true
}
return []commentAgentTrigger{trigger}, true
}
rootID := uuidToString(root.ID)
excludedID := uuidToString(opts.ExcludeTriggerCommentID)
tasks, err := h.Queries.ListTasksByIssue(ctx, issue.ID)
if err != nil {
return nil, false
}
routedAgents := make(map[string]conversationRoutedAgentInfo)
for _, task := range tasks {
if !task.TriggerCommentID.Valid || !task.AgentID.Valid {
continue
}
if excludedID != "" && uuidToString(task.TriggerCommentID) == excludedID {
continue
}
if uuidToString(task.TriggerCommentID) != rootID {
continue
}
agentID := uuidToString(task.AgentID)
info := routedAgents[agentID]
if !info.SquadID.Valid {
info.SquadID = task.SquadID
}
routedAgents[agentID] = info
}
if len(routedAgents) == 0 {
return nil, false
}
triggers := make([]commentAgentTrigger, 0, len(routedAgents))
for agentID, info := range routedAgents {
trigger, ok := h.routeConversationContinuationToAgent(ctx, issue, parseUUID(agentID), info.SquadID, memberID, opts)
if ok {
triggers = append(triggers, trigger)
}
}
return triggers, true
}
func (h *Handler) routeFirstExplicitRootMentionOwner(ctx context.Context, issue db.Issue, root db.Comment, memberID string, opts commentTriggerComputeOptions) (commentAgentTrigger, bool, bool) {
for _, mention := range util.ParseMentions(root.Content) {
switch mention.Type {
case "agent":
agentID, err := util.ParseUUID(mention.ID)
if err != nil {
return commentAgentTrigger{}, true, false
}
trigger, ok := h.routeConversationContinuationToAgent(ctx, issue, agentID, pgtype.UUID{}, memberID, opts)
return trigger, true, ok
case "squad":
squadID, err := util.ParseUUID(mention.ID)
if err != nil {
return commentAgentTrigger{}, true, false
}
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: squadID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
return commentAgentTrigger{}, true, false
}
trigger, ok := h.routeConversationContinuationToAgent(ctx, issue, squad.LeaderID, squadID, memberID, opts)
return trigger, true, ok
}
}
return commentAgentTrigger{}, false, false
}
func (h *Handler) routeConversationContinuationToAgent(ctx context.Context, issue db.Issue, agentID, squadID pgtype.UUID, memberID string, opts commentTriggerComputeOptions) (commentAgentTrigger, bool) {
if !agentID.Valid {
return commentAgentTrigger{}, false
}
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: agentID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
return commentAgentTrigger{}, false
}
if !h.canInvokeAgent(ctx, agent, "member", memberID, memberID, uuidToString(issue.WorkspaceID)) {
return commentAgentTrigger{}, false
}
hasPending, err := h.hasPendingTaskForIssueAndAgent(ctx, issue.ID, agentID, opts)
if err != nil {
return commentAgentTrigger{}, false
}
trigger := commentAgentTrigger{Agent: agent, Source: commentTriggerSourceConversation, AlreadyPending: hasPending}
if squadID.Valid {
if squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: squadID,
WorkspaceID: issue.WorkspaceID,
}); err == nil {
trigger.Squad = &squad
}
}
return trigger, true
}
func (h *Handler) routeAssigneeFallback(ctx context.Context, issue db.Issue, authorType, authorID string, opts commentTriggerComputeOptions) (commentAgentTrigger, bool) {
if !issue.AssigneeType.Valid || !issue.AssigneeID.Valid {
return commentAgentTrigger{}, false
}
switch issue.AssigneeType.String {
case "agent":
agent, hasPending, ok := h.assigneeFallbackAgent(ctx, issue, authorType, authorID, opts)
if !ok {
return commentAgentTrigger{}, false
}
return commentAgentTrigger{Agent: agent, Source: commentTriggerSourceIssueAssignee, AlreadyPending: hasPending}, true
case "squad":
return h.routeAssignedSquadLeaderFallback(ctx, issue, authorType, authorID, opts)
default:
return commentAgentTrigger{}, false
}
}
func (h *Handler) routeAssignedSquadLeaderFallback(ctx context.Context, issue db.Issue, authorType, authorID string, opts commentTriggerComputeOptions) (commentAgentTrigger, bool) {
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: issue.AssigneeID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
return commentAgentTrigger{}, false
}
if authorType == "agent" && authorID == uuidToString(squad.LeaderID) &&
h.shouldSuppressSquadLeaderSelfTrigger(ctx, issue.ID, squad.LeaderID, squad.ID) {
return commentAgentTrigger{}, false
}
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: squad.LeaderID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
return commentAgentTrigger{}, false
}
if !h.canInvokeAgent(ctx, agent, authorType, authorID, opts.OriginatorUserID, uuidToString(issue.WorkspaceID)) {
return commentAgentTrigger{}, false
}
hasPending, err := h.hasPendingTaskForIssueAndAgent(ctx, issue.ID, squad.LeaderID, opts)
if err != nil {
return commentAgentTrigger{}, false
}
return commentAgentTrigger{Agent: agent, Source: commentTriggerSourceIssueAssignee, Squad: &squad, AlreadyPending: hasPending}, true
}
func (h *Handler) hasPendingTaskForIssueAndAgent(ctx context.Context, issueID, agentID pgtype.UUID, opts commentTriggerComputeOptions) (bool, error) {
if !opts.ThreadCommentID.Valid {
return false, nil
}
// Key dedup on the reviewed head so re-pushing to the PR mid-review
// invalidates dedup and a fresh run enqueues against the new HEAD (TEN-356).
headSha := h.TaskService.ResolveIssueReviewSHAParam(ctx, issueID)
if opts.ExcludeTriggerCommentID.Valid {
return h.Queries.HasPendingTaskForIssueAndAgentExcludingTriggerCommentInThread(ctx, db.HasPendingTaskForIssueAndAgentExcludingTriggerCommentInThreadParams{
ThreadCommentID: opts.ThreadCommentID,
IssueID: issueID,
AgentID: agentID,
ExcludeTriggerCommentID: opts.ExcludeTriggerCommentID,
HeadSha: headSha,
})
}
return h.Queries.HasPendingTaskForIssueAndAgentInThread(ctx, db.HasPendingTaskForIssueAndAgentInThreadParams{
ThreadCommentID: opts.ThreadCommentID,
IssueID: issueID,
AgentID: agentID,
HeadSha: headSha,
})
}
// resolveMentionedAgentCommentTriggers parses explicit @agent and @squad
// mentions from the current comment and returns the runnable agent recipients.
// Skips agents with on_mention trigger disabled, and private agents mentioned
// by non-owner members (only the agent owner or workspace admin/owner can
// mention a private agent). Self-mentions are intentionally allowed so an
// agent running in one issue can explicitly enqueue itself on another (e.g.
// a child-issue run notifying the parent issue whose assignee is the same
// agent); runaway loops are prevented by HasPendingTaskForIssueAndAgent
// dedupe and the natural queued/dispatched coalescing of the task queue.
// Note: no issue status gate here — @mention is an explicit action and should
// work even on done/cancelled issues (the agent can reopen the issue if needed).
// commentMentionTarget is one EXPLICIT @agent / @squad mention and how it
// resolved (MUL-4525 §2). This is tracked separately from the execution
// triggers: several mentions can resolve to the same executing agent (e.g.
// @Agent A and @Squad S whose leader is A), and each still needs its own
// outcome even though the run is coalesced. Exactly one of the resolution
// fields is set:
// - ExecAgentID non-empty → the mention runs via that executing agent; its
// outcome mirrors the agent's enqueue status (queued/coalesced/deferred).
// - Status set (with ReasonCode) → a terminal, non-executing outcome
// (blocked, or an A2A self-suppressed squad leader that is deferred).
type commentMentionTarget struct {
TargetType string // "agent" | "squad"
TargetID string
ExecAgentID string
Status DispatchStatus
ReasonCode DispatchReasonCode
// unusable carries the refused agent and its verdict for the one reason
// that needs a durable trace (runtime_unusable). Internal to the handler:
// the resolver runs for the composer PREVIEW as well, so it only records
// what happened — writing the notice is the trigger path's job.
unusable *blockedRuntimeNotice
}
// blockedRuntimeNotice is a refusal worth leaving on the issue: which agent, and
// the verdict that names the repair.
type blockedRuntimeNotice struct {
agent db.Agent
verdict service.AgentVerdict
}
func (h *Handler) resolveMentionedAgentCommentTriggers(ctx context.Context, issue db.Issue, mentions []util.Mention, authorType, authorID string, opts commentTriggerComputeOptions) ([]commentAgentTrigger, []commentMentionTarget) {
wsID := uuidToString(issue.WorkspaceID)
triggers := make([]commentAgentTrigger, 0, len(mentions))
// seen dedups EXECUTION by resolved agent id: two mentions resolving to the
// same agent enqueue only one task. Mapping to the trigger's index lets a
// squad-leader mention UPGRADE an already-added plain @agent trigger for the
// same agent — the leader task is a strict superset (it sets is_leader_task
// and squad_id so the daemon injects the squad briefing), so the merged run
// must carry that role regardless of mention order.
seen := make(map[string]int, len(mentions))
add := func(trigger commentAgentTrigger) {
id := uuidToString(trigger.Agent.ID)
if idx, ok := seen[id]; ok {
if triggers[idx].Source != commentTriggerSourceMentionSquadLeader &&
trigger.Source == commentTriggerSourceMentionSquadLeader {
triggers[idx] = trigger
}
return
}
seen[id] = len(triggers)
triggers = append(triggers, trigger)
}
// targets record one outcome per EXPLICIT mention — deduped by the target
// the user named (type:id), NOT by executing agent — so no explicitly-named
// target is silently dropped even when its run coalesces with another's.
var targets []commentMentionTarget
targetSeen := make(map[string]struct{}, len(mentions))
addTarget := func(t commentMentionTarget) {
key := t.TargetType + ":" + t.TargetID
if _, ok := targetSeen[key]; ok {
return
}
targetSeen[key] = struct{}{}
targets = append(targets, t)
}
// blockTarget records a mention that will not fire. The invoke gate is
// evaluated BEFORE any archived/runtime state is read, so a caller who
// cannot invoke a private target learns only the generic
// invocation_not_allowed and can never enumerate the target's existence or
// state from the reason code.
blockTarget := func(targetType, targetID string, reason DispatchReasonCode) {
addTarget(commentMentionTarget{TargetType: targetType, TargetID: targetID, Status: DispatchBlocked, ReasonCode: reason})
}
// blockUnusableTarget is blockTarget for the one verdict that also needs a
// durable trace. Every author gets it, including a human: the chip and toast
// carry the reason code but not the repair command, and an agent-authored
// mention has nobody watching a response at all.
blockUnusableTarget := func(targetType, targetID string, agent db.Agent, verdict service.AgentVerdict) {
notice := &blockedRuntimeNotice{agent: agent, verdict: verdict}
if !service.RuntimeBlockedNeedsNotice(verdict.Reason) {
notice = nil
}
addTarget(commentMentionTarget{
TargetType: targetType, TargetID: targetID,
Status: DispatchBlocked, ReasonCode: verdict.Reason, unusable: notice,
})
}
for _, m := range mentions {
if m.Type == "squad" {
// @squad mention → trigger the squad's leader agent.
// The mention id comes from untrusted comment text and MentionRe
// accepts any hex/dash run (`mention://squad/-`), so it is parsed
// with the error-returning ParseUUID — the Must* variant would
// panic the request, and on the create path the comment row is
// already committed by then. A malformed id is indistinguishable
// from a well-formed id that owns no squad: same target_unavailable
// outcome, no run.
squadUUID, err := util.ParseUUID(m.ID)
if err != nil {
blockTarget("squad", m.ID, ReasonTargetUnavailable)
continue
}
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: squadUUID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
blockTarget("squad", m.ID, ReasonTargetUnavailable)
continue
}
leaderID := squad.LeaderID
// A2A self-suppression: the author IS this squad's leader and its
// most recent task on this issue was a leader/generic role (NOT a
// fresh same-squad worker→leader handoff), so we do not re-fire the
// leader from its own @mention. The outcome must reflect reality, not
// assume success (MUL-4525): `deferred` only when a real non-terminal
// task is still active (its reconcile covers this comment); a query
// failure is a non-success internal_error, never a fabricated
// deferred; otherwise nothing runs → self_trigger_suppressed.
if authorType == "agent" && authorID == uuidToString(leaderID) &&
h.shouldSuppressSquadLeaderSelfTrigger(ctx, issue.ID, leaderID, squad.ID) {
active, activeErr := h.hasActiveTaskForIssueAndAgent(ctx, issue.ID, leaderID, opts.ThreadCommentID)
status, reason := decideSuppressedLeaderOutcome(active, activeErr)
addTarget(commentMentionTarget{TargetType: "squad", TargetID: m.ID, Status: status, ReasonCode: reason})
continue
}
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: leaderID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
blockTarget("squad", m.ID, ReasonTargetUnavailable)
continue
}
// Private-leader gate first (enumeration-safe: a caller who cannot
// invoke the leader never learns its archived/runtime state).
if !h.canInvokeAgent(ctx, agent, authorType, authorID, opts.OriginatorUserID, wsID) {
blockTarget("squad", m.ID, ReasonInvocationNotAllowed)
continue
}
if agent.ArchivedAt.Valid {
blockTarget("squad", m.ID, ReasonTargetUnavailable)
continue
}
// Same shared verdict as the direct-agent branch below.
if verdict, err := service.AgentReadiness(ctx, h.runtimeLookup(obsmetrics.RuntimeLookupSourceComment), agent); err == nil && verdict.Blocked() {
blockUnusableTarget("squad", m.ID, agent, verdict)
continue
}
hasPending, err := h.hasPendingTaskForIssueAndAgent(ctx, issue.ID, leaderID, opts)
if err != nil {
blockTarget("squad", m.ID, ReasonInternalError)
continue
}
add(commentAgentTrigger{Agent: agent, Source: commentTriggerSourceMentionSquadLeader, Squad: &squad, AlreadyPending: hasPending})
addTarget(commentMentionTarget{TargetType: "squad", TargetID: m.ID, ExecAgentID: uuidToString(leaderID)})
continue
}
if m.Type != "agent" {
continue
}
agentUUID, err := util.ParseUUID(m.ID)
if err != nil {
// Untrusted comment text: MentionRe matches any hex/dash run, so a
// malformed id must not reach the panicking Must* parser. A string
// that is not a UUID at all cannot name an entity in ANY workspace,
// so there is no existence to conceal here and the invoke-permission
// code would be a false cause (MUL-5548): report the same
// target_unavailable the squad path above already uses. Only the
// well-formed-but-unresolved case below stays enumeration-safe.
blockTarget("agent", m.ID, ReasonTargetUnavailable)
continue
}
// Load the agent scoped to the current issue's workspace. Using the
// bare GetAgent here would let a mention resolve to an agent in a
// different workspace, and the visibility check below would then be
// applied against the wrong workspace's roles (a workspace owner in
// THIS workspace would pass the gate for a private agent that lives
// in someone else's workspace).
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
// Do not reveal whether the id exists (it may be a private agent in
// another workspace): the generic invocation_not_allowed is returned.
blockTarget("agent", m.ID, ReasonInvocationNotAllowed)
continue
}
// Private-agent gate first, before any archived/runtime state is read.
if !h.canInvokeAgent(ctx, agent, authorType, authorID, opts.OriginatorUserID, wsID) {
blockTarget("agent", m.ID, ReasonInvocationNotAllowed)
continue
}
if agent.ArchivedAt.Valid {
blockTarget("agent", m.ID, ReasonTargetUnavailable)
continue
}
// One readiness verdict for every admission path (service.AgentReadiness).
// Only a BLOCKED verdict refuses the mention: an unbound agent has no
// machine to bring back (MUL-5559), and a machine whose CLI cannot run
// will not start doing so on its own (MUL-6164). A merely offline
// runtime keeps queueing — that wait ends by itself when the machine
// returns, and taking it away would remove a behaviour people rely on.
if verdict, err := service.AgentReadiness(ctx, h.runtimeLookup(obsmetrics.RuntimeLookupSourceComment), agent); err == nil && verdict.Blocked() {
blockUnusableTarget("agent", m.ID, agent, verdict)
continue
}
hasPending, err := h.hasPendingTaskForIssueAndAgent(ctx, issue.ID, agentUUID, opts)
if err != nil {
blockTarget("agent", m.ID, ReasonInternalError)
continue
}
add(commentAgentTrigger{Agent: agent, Source: commentTriggerSourceMentionAgent, AlreadyPending: hasPending})
addTarget(commentMentionTarget{TargetType: "agent", TargetID: m.ID, ExecAgentID: uuidToString(agentUUID)})
}
return triggers, targets
}
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return
}
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
isAuthor := existing.AuthorType == actorType && uuidToString(existing.AuthorID) == actorID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can edit")
return
}
var req struct {
Content string `json:"content"`
ContentBase *string `json:"content_base,omitempty"`
AttachmentIDs *[]string `json:"attachment_ids"`
SuppressAgentIDs []string `json:"suppress_agent_ids"`
ExpectedRevision *int64 `json:"expected_revision,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// See CreateComment: strip NUL / invalid-UTF-8 bytes PostgreSQL's TEXT column
// rejects before the empty check, so an edit that introduces such a byte
// can't 500 (GH #5388).
req.Content = sanitizeNullBytes(req.Content)
if req.ContentBase != nil {
sanitized := sanitizeNullBytes(*req.ContentBase)
req.ContentBase = &sanitized
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if req.ExpectedRevision != nil {
if *req.ExpectedRevision < 1 {
writeError(w, http.StatusBadRequest, "expected_revision must be a positive integer")
return
}
if existing.Revision != *req.ExpectedRevision {
writeRevisionConflict(w, "comment", existing.ID, *req.ExpectedRevision, existing.Revision)
return
}
}
strictContentEdit := req.ContentBase != nil || req.ExpectedRevision != nil
var attachmentIDs []pgtype.UUID
replaceAttachments := req.AttachmentIDs != nil
if replaceAttachments {
var ok bool
attachmentIDs, ok = parseUUIDSliceOrBadRequest(w, *req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
}
suppressAgentIDs, ok := parseUUIDSliceOrBadRequest(w, req.SuppressAgentIDs, "suppress_agent_ids")
if !ok {
return
}
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
oldContent := existing.Content
// Preserve the existing authority lineage by default — this path is taken only
// for an UNCHANGED edit (no re-trigger). When the content changes below, the
// lineage is re-derived from the EDIT action itself (MUL-4857), never carried
// over from the comment's original authoring run.
sourceTaskID := existing.SourceTaskID
var triggerIssue *db.Issue
var cancelled []db.AgentTaskQueue
if oldContent != req.Content {
issue, err := h.Queries.GetIssue(r.Context(), existing.IssueID)
if err != nil {
slog.Warn("load issue for edit post-processing failed", "issue_id", uuidToString(existing.IssueID), "error", err)
writeError(w, http.StatusInternalServerError, "failed to load issue")
return
}
triggerIssue = &issue
// A content edit is a NEW action, so its delegation lineage must key on THIS
// edit. Only the AGENT author re-editing its OWN comment carries the lineage
// forward (commentSourceTaskID re-stamps the current editing task) — so
// preview, save, and the deferred completion-reconcile all resolve the
// authority from this one action. Any OTHER editor — a workspace owner/admin
// editing an AGENT's comment, or a member editing their own — CLEARS the
// lineage so the deferred reconcile fails closed instead of resurrecting the
// original autopilot run's creator authority. An admin holds manage rights
// over the comment, not invoke rights over the author's private agents (Elon
// must-fix, round 3).
if actorType == "agent" && isAuthor {
sourceTaskID = h.commentSourceTaskID(r)
} else {
sourceTaskID = pgtype.UUID{}
}
// Legacy clients keep the existing cancel-before-update behavior. Strict
// revision writes defer cancellation until the conditional UPDATE wins,
// so a race that returns 409 cannot mutate the task queue.
if !strictContentEdit {
cancelled, err = h.TaskService.CancelTasksByTriggerComment(r.Context(), existing.ID)
if err != nil {
slog.Warn("cancel tasks for edited comment failed", "comment_id", uuidToString(existing.ID), "error", err)
writeError(w, http.StatusInternalServerError, "failed to prepare comment edit")
return
}
}
}
updateParams := db.UpdateCommentParams{
ID: commentUUID,
Content: req.Content,
SourceTaskID: sourceTaskID,
}
if req.ContentBase != nil {
updateParams.ContentBase = pgtype.Text{String: *req.ContentBase, Valid: true}
}
if req.ExpectedRevision != nil {
updateParams.ExpectedRevision = pgtype.Int8{Int64: *req.ExpectedRevision, Valid: true}
}
var comment db.Comment
var issueRevision int64
transactionalEdit := replaceAttachments || (oldContent != req.Content && strictContentEdit)
if transactionalEdit {
// Strict body edits, attachment-set edits, and cancellation of tasks built
// from the old body are one database outcome. UpdateComment takes the row
// lock before the attachment replacement, so two modern editors cannot
// interleave their CAS check and attachment selection. A body + attachment
// edit is one visible mutation and therefore bumps revision exactly once.
tx, beginErr := h.TxStarter.Begin(r.Context())
if beginErr != nil {
writeError(w, http.StatusInternalServerError, "failed to prepare comment edit")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
var updated db.UpdateCommentRow
updated, err = qtx.UpdateComment(r.Context(), updateParams)
if err == nil {
comment = updated.Comment()
issueRevision = updated.IssueRevision
}
if err == nil && oldContent != req.Content && strictContentEdit {
cancelled, err = qtx.CancelAgentTasksByTriggerComment(r.Context(), existing.ID)
if err == nil {
err = service.SettleDeliveredDelegatedFailureRecoveries(r.Context(), qtx, cancelled...)
}
}
if err == nil && replaceAttachments {
var changed int64
changed, err = qtx.ReplaceCommentAttachments(r.Context(), db.ReplaceCommentAttachmentsParams{
CommentID: comment.ID,
IssueID: existing.IssueID,
AttachmentIds: attachmentIDs,
})
if err == nil && changed > 0 && oldContent == req.Content {
comment, err = qtx.BumpCommentRevision(r.Context(), db.BumpCommentRevisionParams{
ID: comment.ID,
WorkspaceID: existing.WorkspaceID,
})
}
}
if err == nil {
err = tx.Commit(r.Context())
}
if err == nil {
h.TaskService.BroadcastCancelledTasks(r.Context(), uuidToString(existing.WorkspaceID), cancelled)
}
} else {
var updated db.UpdateCommentRow
updated, err = h.Queries.UpdateComment(r.Context(), updateParams)
if err == nil {
comment = updated.Comment()
issueRevision = updated.IssueRevision
}
}
if err != nil {
slog.Warn("update comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
if triggerIssue != nil && !strictContentEdit {
// Cancellation committed but the edit did not. Restore the complete
// original batch, including the still-valid unchanged comment.
h.retriggerCancelledTaskSurvivors(r.Context(), *triggerIssue, cancelled, pgtype.UUID{})
}
if errors.Is(err, pgx.ErrNoRows) && strictContentEdit {
current, reloadErr := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ID: commentUUID, WorkspaceID: wsUUID})
if reloadErr == nil {
if req.ExpectedRevision != nil {
writeRevisionConflict(w, "comment", current.ID, *req.ExpectedRevision, current.Revision)
} else {
writeEditConflict(w, "comment", current.ID)
}
return
}
}
writeError(w, http.StatusInternalServerError, "failed to update comment")
return
}
retriggerEditedComment := func() []CommentTriggerOutcome {
if oldContent == comment.Content {
return nil
}
issue := *triggerIssue
var parentComment *db.Comment
if existing.ParentID.Valid {
parent, err := h.Queries.GetComment(r.Context(), existing.ParentID)
if err == nil {
parentComment = &parent
}
}
h.retriggerCancelledTaskSurvivors(r.Context(), issue, cancelled, existing.ID)
return h.triggerTasksForComment(r.Context(), issue, comment, parentComment, actorType, actorID, h.invokeOriginatorFromRequest(r, actorType, actorID), suppressAgentIDs)
}
// Fetch reactions and attachments for the updated comment.
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
cid := uuidToString(comment.ID)
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
resp.IssueRevision = issueRevision
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
eventPayload := map[string]any{"comment": resp}
if issueRevision > 0 {
eventPayload["issue_revision"] = issueRevision
}
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, eventPayload)
// The broadcast above intentionally omits trigger_outcomes — it is the
// editor's private feedback, not shared timeline state (MUL-4525 §2).
resp.TriggerOutcomes = retriggerEditedComment()
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return
}
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
isAuthor := comment.AuthorType == actorType && uuidToString(comment.AuthorID) == actorID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can delete")
return
}
issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID)
hasIssue := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
slog.Warn("load issue for delete post-processing failed", "issue_id", uuidToString(comment.IssueID), "error", err)
writeError(w, http.StatusInternalServerError, "failed to load issue")
return
}
if errors.Is(err, pgx.ErrNoRows) {
// The issue delete may have won the race after GetCommentInWorkspace.
// Continue so DeleteComment can report whether the comment itself still
// existed; touching issue activity is intentionally best effort.
slog.Info("comment parent issue no longer exists", "issue_id", uuidToString(comment.IssueID), "comment_id", commentId)
}
// Collect attachment URLs before CASCADE delete removes them.
attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), comment.ID)
// Cancel any active task whose planned batch contains this comment so the
// agent does not run with the now-deleted content already embedded. Must
// run before DeleteComment because the FK ON DELETE SET NULL would
// otherwise nullify trigger_comment_id and orphan those tasks in queued.
cancelled, cancelErr := h.TaskService.CancelTasksByTriggerComment(r.Context(), comment.ID)
if cancelErr != nil {
slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", cancelErr, "comment_id", commentId)...)
}
deleted, err := h.Queries.DeleteComment(r.Context(), db.DeleteCommentParams{
ID: comment.ID,
WorkspaceID: comment.WorkspaceID,
})
if err != nil || !deleted.Changed {
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "changed", deleted.Changed, "comment_id", commentId)...)
// Cancellation already committed but deletion did not. If the parent
// issue still exists, rebuild the complete cancelled batch (including
// this trigger) before reporting the storage error or concurrent no-op.
if hasIssue {
h.retriggerCancelledTaskSurvivors(r.Context(), issue, cancelled, pgtype.UUID{})
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete comment")
} else {
writeError(w, http.StatusNotFound, "comment not found")
}
return
}
h.deleteS3Objects(r.Context(), attachmentURLs)
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
eventPayload := map[string]any{
"comment_id": uuidToString(comment.ID),
"issue_id": uuidToString(comment.IssueID),
}
if deleted.IssueRevision > 0 {
eventPayload["issue_revision"] = deleted.IssueRevision
}
h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, eventPayload)
if hasIssue {
h.retriggerCancelledTaskSurvivors(r.Context(), issue, cancelled, comment.ID)
}
w.WriteHeader(http.StatusNoContent)
}
// retriggerCancelledTaskSurvivors repairs the surviving inputs from a
// batch containing a comment that was edited or deleted. Cancellation releases
// the one-pending-task constraint; every other comment is replayed through the
// normal authorization/routing path, scoped to the agent whose task carried
// it. Chronological replay makes those survivors coalesce back into one batch
// and lets the latest real comment restamp originator + connected-app context.
func (h *Handler) retriggerCancelledTaskSurvivors(ctx context.Context, issue db.Issue, cancelled []db.AgentTaskQueue, excludedCommentID pgtype.UUID) {
if len(cancelled) == 0 {
return
}
targetsByComment := make(map[string]map[string]pgtype.UUID)
for _, task := range cancelled {
if !task.AgentID.Valid {
continue
}
plannedCommentIDs := append([]pgtype.UUID{}, task.CoalescedCommentIds...)
if task.TriggerCommentID.Valid {
plannedCommentIDs = append(plannedCommentIDs, task.TriggerCommentID)
}
for _, commentID := range plannedCommentIDs {
if !commentID.Valid || (excludedCommentID.Valid && commentID == excludedCommentID) {
continue
}
commentKey := uuidToString(commentID)
if targetsByComment[commentKey] == nil {
targetsByComment[commentKey] = make(map[string]pgtype.UUID)
}
targetsByComment[commentKey][uuidToString(task.AgentID)] = task.AgentID
}
}
comments := make([]db.Comment, 0, len(targetsByComment))
for commentID := range targetsByComment {
comment, err := h.Queries.GetComment(ctx, parseUUID(commentID))
if err != nil {
slog.Warn("retrigger cancelled comment batch: load survivor failed",
"issue_id", uuidToString(issue.ID), "comment_id", commentID, "error", err)
continue
}
if comment.IssueID != issue.ID {
continue
}
comments = append(comments, comment)
}
sort.Slice(comments, func(i, j int) bool {
if !comments[i].CreatedAt.Time.Equal(comments[j].CreatedAt.Time) {
return comments[i].CreatedAt.Time.Before(comments[j].CreatedAt.Time)
}
return uuidToString(comments[i].ID) < uuidToString(comments[j].ID)
})
for i := range comments {
comment := comments[i]
if isNoteComment(comment.Content) {
continue
}
// Platform recovery comments bypass generic member/agent routing. If
// editing or deleting another comment cancelled the queued batch that
// carried one, replay it through the durable delegated-failure path so
// the cancelled, undelivered task cannot swallow the obligation.
if service.IsDelegatedFailureRecoveryComment(comment) {
if err := h.TaskService.DispatchDelegatedFailureRecoveryComment(ctx, comment, pgtype.UUID{}); err != nil {
slog.Warn("retrigger cancelled comment batch: delegated failure recovery replay failed",
"issue_id", uuidToString(issue.ID),
"comment_id", uuidToString(comment.ID),
"error", err,
)
}
continue
}
var parentComment *db.Comment
if comment.ParentID.Valid {
if parent, err := h.Queries.GetComment(ctx, comment.ParentID); err == nil {
parentComment = &parent
}
}
actorType := comment.AuthorType
actorID := uuidToString(comment.AuthorID)
originatorUserID := actorID
if actorType != "member" {
originatorUserID = uuidToString(h.TaskService.ResolveOriginatorFromTriggerComment(ctx, issue.WorkspaceID, comment.ID))
}
triggers, _ := h.computeCommentAgentTriggers(ctx, issue, comment.Content, parentComment, actorType, actorID, commentTriggerComputeOptions{
ExcludeTriggerCommentID: comment.ID,
OriginatorUserID: originatorUserID,
})
targets := targetsByComment[uuidToString(comment.ID)]
scoped := make([]commentAgentTrigger, 0, len(targets))
for _, trigger := range triggers {
if _, ok := targets[uuidToString(trigger.Agent.ID)]; ok {
scoped = append(scoped, trigger)
}
}
if len(scoped) > 0 {
h.enqueueCommentAgentTriggers(ctx, issue, comment.ID, scoped)
}
}
}
// loadCommentForActor resolves a {commentId} URL param to a comment in the
// caller's workspace. Returns the comment, the workspace UUID, the actor
// identity, and ok. Resolve / unresolve handlers share this scaffolding so the
// workspace membership + tenant guard stay identical. Any comment (root or
// reply) may be resolved: resolving a root collapses the whole thread; resolving
// a reply marks it as the thread's resolution. Which one is the thread's
// resolution is a pure frontend derivation, so the backend stays a plain setter.
func (h *Handler) loadCommentForActor(w http.ResponseWriter, r *http.Request) (db.Comment, string, string, string, bool) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return db.Comment{}, "", "", "", false
}
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
if !ok {
return db.Comment{}, "", "", "", false
}
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return db.Comment{}, "", "", "", false
}
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return db.Comment{}, "", "", "", false
}
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
ID: commentUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return db.Comment{}, "", "", "", false
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
return comment, workspaceID, actorType, actorID, true
}
func (h *Handler) ResolveComment(w http.ResponseWriter, r *http.Request) {
comment, workspaceID, actorType, actorID, ok := h.loadCommentForActor(w, r)
if !ok {
return
}
wasResolved := comment.ResolvedAt.Valid
actorUUID, err := util.ParseUUID(actorID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid actor id")
return
}
// Single-resolution invariant: a thread has at most one resolved comment, so
// resolving this one must clear any other resolution in the same thread. Both
// writes run in one tx — clearing the old resolution and setting the new one
// is atomic, so a crash can never leave two resolutions (or none) visible.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
cleared, err := qtx.ClearOtherThreadResolutions(r.Context(), db.ClearOtherThreadResolutionsParams{
TargetID: comment.ID,
IssueID: comment.IssueID,
WorkspaceID: comment.WorkspaceID,
})
if err != nil {
slog.Warn("clear other thread resolutions failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
return
}
updated, err := qtx.ResolveComment(r.Context(), db.ResolveCommentParams{
ID: comment.ID,
ResolvedByType: pgtype.Text{String: actorType, Valid: true},
ResolvedByID: actorUUID,
})
if err != nil {
slog.Warn("resolve comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
return
}
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("resolve comment commit failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
return
}
// Emit a comment:unresolved per cleared sibling so granular realtime
// consumers (which patch a single comment in place) drop the stale
// resolution instead of showing two. Published after commit so no event ever
// describes an uncommitted state.
for _, c := range cleared {
clearedID := uuidToString(c.ID)
clearedReactions := h.groupReactions(r, []pgtype.UUID{c.ID})
clearedAtt := h.groupAttachments(r, []pgtype.UUID{c.ID})
clearedResp := commentToResponse(c, clearedReactions[clearedID], clearedAtt[clearedID])
slog.Info("comment unresolved (replaced)", append(logger.RequestAttrs(r), "comment_id", clearedID)...)
h.publish(protocol.EventCommentUnresolved, workspaceID, actorType, actorID, map[string]any{"comment": clearedResp})
}
grouped := h.groupReactions(r, []pgtype.UUID{updated.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{updated.ID})
cid := uuidToString(updated.ID)
resp := commentToResponse(updated, grouped[cid], groupedAtt[cid])
// Suppress the target event on a re-resolve no-op so consumers do not
// re-process an unchanged thread (notifications, log spam). Cleared siblings
// still get their own events above — those rows did change.
if !wasResolved {
slog.Info("comment resolved", append(logger.RequestAttrs(r), "comment_id", cid)...)
h.publish(protocol.EventCommentResolved, workspaceID, actorType, actorID, map[string]any{"comment": resp})
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) UnresolveComment(w http.ResponseWriter, r *http.Request) {
comment, workspaceID, actorType, actorID, ok := h.loadCommentForActor(w, r)
if !ok {
return
}
wasResolved := comment.ResolvedAt.Valid
updated, err := h.Queries.UnresolveComment(r.Context(), comment.ID)
if err != nil {
slog.Warn("unresolve comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
writeError(w, http.StatusInternalServerError, "failed to unresolve comment")
return
}
grouped := h.groupReactions(r, []pgtype.UUID{updated.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{updated.ID})
cid := uuidToString(updated.ID)
resp := commentToResponse(updated, grouped[cid], groupedAtt[cid])
if wasResolved {
slog.Info("comment unresolved", append(logger.RequestAttrs(r), "comment_id", cid)...)
h.publish(protocol.EventCommentUnresolved, workspaceID, actorType, actorID, map[string]any{"comment": resp})
}
writeJSON(w, http.StatusOK, resp)
}