Commit Graph

29 Commits

Author SHA1 Message Date
Multica Eve
c3f5df8bf4 MUL-5492: fix timeline cap dropping newest entries + stop double-broadcasting descriptions (#6175)
* fix(timeline): cap the issue timeline at the newest end and report the clamp

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

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

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

Two things beyond the ordering flip:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two changes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(comments): preserve newest bounded views

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 14:40:16 +08:00
Jiayuan Zhang
5e3b7a8c37 feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465) (#6132)
* feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465)

Preset "who to call and what to say" once in Settings, then trigger it from
any issue's sidebar with a single click.

Running one is NOT a new dispatch path. The server renders the prompt, posts
a `quick_action` comment carrying the target's mention markup, and hands off
to the existing comment -> mention -> task trigger. Permission
(canInvokeAgent), attribution, squad-leader routing, the execution log, and
pending-task coalescing are inherited rather than reimplemented — the
MUL-3375 lesson about four drifting copies of one trigger decision.

Three things the UI has to be honest about, because the backend already
decided them:

- One pending task per (issue, agent) is a DB invariant
  (idx_one_pending_task_per_issue_agent). A second click against a busy agent
  starts no new run; the comment merges into the pending task. The toast says
  "Added to Lambda's current run", not "Lambda started working".
- An offline target defers rather than fails; the run reuses the existing
  dispatch.ReasonCode vocabulary instead of inventing one.
- Private agents are deny-by-default with no admin bypass. The sidebar filters
  by the caller's own invoke verdict, so a dead button is never rendered, and
  a direct API call still 403s with `invocation_not_allowed`.

Visibility is DERIVED from the bound agent's permission_mode on every request,
never stored — so it cannot drift after someone flips an agent between private
and public_to. Binding a workspace action to a private agent is allowed (the
alternative pressures people into making agents public just to satisfy a
config constraint) but the settings form says so at bind time, and the
catalog badges it. The target's name is withheld from callers who cannot see
it, so the response never discloses a private agent's existence.

Prompt templating is flat substitution over a closed whitelist. No
conditionals, loops, or filters — the agent already reads the whole issue, so
natural language is the control flow. One optional runtime input ({{input}})
keeps a single action from splitting into five near-identical variants; both
directions of the input/{{input}} agreement are rejected at write time so a
typo can never land silently.

Surfaces: sidebar (top 5, rest behind More), the `/` menu in the comment
composer (inserts the server-rendered body to edit before sending), and
Alt-click for the same hand-off from the sidebar.

Migrations 234-236: quick_action table, its listing index (CONCURRENTLY, own
file), and comment.type + comment.quick_action_id.

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

* refactor(issues): simplify quick action permissions to a stored public/private intent (MUL-5465)

Replaces the derived four-value visibility model with a two-value choice made
at creation, and collapses permission handling to a single check.

The old model computed visibility per request from the bound agent's
permission_mode and used it to filter the sidebar. That filtering was the
problem: two people on one issue saw different sidebars with nothing to
explain the difference, which is harder to debug than a button that tells you
why it refused. It also required the list endpoint to run an invocation-target
query per action per request.

Now:
  - `visibility` is stored INTENT — 'public' or 'private' — chosen up front.
  - A public action must bind a target every workspace member can invoke
    (public_to carrying a workspace target), enforced at write time. So a
    public action is runnable by construction and dead buttons are eliminated
    at the source rather than filtered out later.
  - A private action allows any target and is returned only to its creator.
    That scoping is what the field MEANS, not a permission check.
  - Permission is checked in exactly one place: RunQuickAction. A refusal is a
    structured 403 the client renders as one dialog. The dialog does not
    distinguish "no permission" from "the binding drifted" — the person
    reading it takes the same next step either way, and the person who can fix
    it looks at settings.

Removed: can_run, position + manual ordering (settings sorted by usage while
the sidebar sorted by position — one list, two orders), the derived
visibility_broken flag, the runnable_only projection and its second cache
entry, target_name redaction, the alt-click composer hand-off (the `/` menu
covers insert-then-edit and is discoverable), and the sidebar_limit response
field (now a shared constant).

Ordering is use_count DESC everywhere. Settings shows the target's current
reachability as plain metadata ("Nova · private"), so a public action pointing
at a now-private agent reads as visibly wrong without a bespoke error state.
The tradeoff — no active signal when that drift happens — was accepted
deliberately: drift is rare and the failure is loud at click time.

Migration 234 is edited in place rather than layered, since the PR is
unmerged and the table has never been deployed.

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

* refactor(issues): drop quick action variables and runtime input (MUL-5465)

V1 ships a preset prompt sent verbatim, triggered from the sidebar or the `/`
slash command. Two features are removed and one guard is kept.

Runtime input goes because `/` already covers it. Typing `/code review` drops
the rendered body into the composer, where any part of it can be edited before
sending — strictly more flexible than one fixed field, and the field was
specified before `/` was in V1. Two UIs for one need.

Variables go because none of them passed their own test. The rule was that a
variable earns its place only if it changes what the agent ATTENDS TO, not what
it KNOWS. Checked one by one — {{issue.title}}, {{issue.identifier}},
{{issue.url}}, {{user.name}}, {{date}} — the agent already has every one from
the issue context and from the fact that the comment is authored by the person
who triggered it. They were inherited from autopilot's title template rather
than justified.

The REJECTION survives the feature: any `{{...}}` is refused at write time,
naming the offending token. Someone carrying the habit over would otherwise
have `{{issue.title}}` rendered literally into an agent's instructions and
never notice — the exact silent-typo failure the whitelist existed to prevent.
The check is a fraction of the interpolation engine it replaces and keeps the
door open to enabling variables later without touching stored data.

Removed: 4 columns (input_enabled/label/placeholder/required),
renderQuickActionPrompt + the variable whitelist + quickActionIssueURL, the
two-way {{input}} agreement logic, the run/render `input` parameter, the
variable insert chips, the entire "Ask for input on click" block, and the
sidebar's Popover branch — every row is now a plain button. The settings
dialog drops from six field groups to four.

Migration 234 is edited in place rather than layered, since the PR is unmerged
and the table has never been deployed.

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

* refactor(settings): align Quick Actions with the Labels/Properties list, then fix what the UI review found (MUL-5465)

The tab used a bespoke card list while its two siblings — Labels and
Properties — share one table layout. These three are the workspace's catalog
of small named things and should read as one surface, so Quick Actions now
uses the same structure: search + primary action row, bordered card, responsive
column grid that collapses to stacked rows under `md`, and an overflow menu
instead of a row of icon buttons. Columns are Name / Runs as / Who / Used /
Updated. The tab joins the max-w-5xl group for the same reason.

A UI review pass over the result found five things, four of which are fixed
here:

- The visibility chooser communicated selection through border and background
  only, so a screen reader announced both options identically. Added
  aria-pressed.
- The editor dialog was max-w-xl while both siblings use sm:max-w-lg, and the
  unprefixed cap applied at every breakpoint.
- The empty-state hint diverged from the Properties tab it was copied from
  (text-sm and no max width vs mx-auto max-w-sm text-xs).
- Two hardcoded `text-amber-600 dark:text-amber-400` usages replaced with the
  `text-warning` semantic token, per the repo's design-token rule.

Also fixed a signal-quality bug the review surfaced: the usage column
highlighted anything with use_count 0, so an action was flagged the instant it
was created. Staleness now means "has had time to be used and wasn't" — 90
days since last use, or 90 days since creation for one never used.

Not fixed here: the overflow trigger is size-7 (28px), under the 44px touch
floor. Labels and Properties use the identical size, so changing only this tab
would break the consistency this commit exists to create; it needs one pass
across all three.

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

* fix(issues): drop the quick_action comment type, widen the mention guard, harden the slash race (MUL-5465)

Second review round on PR #6132. All four remaining findings.

**Comment type removed entirely (#2 blocker + #3).** Adding a `quick_action`
type meant dropping and re-adding comment_type_check, and re-adding a CHECK
holds ACCESS EXCLUSIVE on `comment` for a full table scan — a read/write stall
on one of the hottest tables in the product, every deploy. It was also
forgeable: `type` is client-supplied on POST /comments, so any member could
post type='quick_action' and have an ordinary comment render as an action
audit record with its body collapsed out of view.

Both go away by not having the type. A quick action now posts an ORDINARY
comment marked with `quick_action_id`, and the collapsed card keys off that id.
There is no request field for it, so the marker cannot be forged, and the
migration is a bare nullable ADD COLUMN — metadata-only and instant. Verified
against a fresh database: comment_type_check is untouched.

The generic comment endpoint now also validates `type` instead of letting the
DB CHECK reject it. An unknown type surfaced as a 500 on a constraint
violation, which reads as a server fault for plainly bad input; it is a 400
now. `status_change` and `system` are excluded from what a client may author —
claiming those would be forging system narration.

**Member mentions rejected too (#1).** The first pass allowed
`mention://member/...` in prompts on the reasoning that it "only renders a
link". That was wrong: notification_listeners.go adds member mentions to the
recipient set and creates an inbox item, so a saved prompt pinged that person
on every single click. Only `mention://issue/...` reaches nobody and stays
allowed.

**Slash race, properly this time (#4).** The previous fix checked only that the
range still started with "/". Rewriting `/review` into `/fix` while the request
was open passed that check, and the stale response overwrote the new command.
The exact original text is now captured and compared; if the command was
edited, moved, or removed, the pick is abandoned rather than inserted
somewhere wrong. Adds the three regression tests the review asked for:
delayed resolve, rejection, and edit-during-flight.

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

* fix(issues): stop the quick action card repeating its own prompt, and insert the `/` body as markdown (MUL-5465)

Two fixes, one reported and one found while verifying it.

**The card printed the prompt twice.** The collapsed header previewed the
prompt's first line, and expanding showed the mention line plus that same
prompt again. The header now identifies WHICH action ran — "Code Review via
Lambda" — which is both non-redundant and something the body never told you:
the prompt text alone does not say which action produced it. This is what the
original design called for; previewing the prompt was the implementation
drifting from it.

When the action cannot be resolved — deleted, or another member's private one
and so absent from this viewer's catalog — the header falls back to the
prompt's opening line, which is the previous behaviour.

**The `/` menu inserted its body as literal text.** insertContentAt was called
with a plain string, so Tiptap treated the server-rendered markdown as text
rather than parsing it. The mention never became a node; it serialised back out
with escaped brackets (`\[@Lambda\](mention://agent/…)`) and rendered as raw
markup in the thread. Passing `contentType: "markdown"` — the same option the
description editor already uses — parses it properly. Found by reading the
comment rows while checking the first fix: one had escaped brackets and no
quick_action_id, which is what a slash-inserted comment looked like.

The existing async test now asserts the contentType, so the option cannot be
dropped again without failing.

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

* docs(issues): correct the stale quick actions sidebar comment (MUL-5465)

The comment still claimed the section renders nothing when no action is
runnable by the member. Permission filtering was removed several rounds
ago -- the list is deliberately unfiltered and a refusal is explained at
run time -- so the comment described behavior that no longer exists.

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

* refactor(settings): cut the quick action dialog's helper copy in half (MUL-5465)

The dialog had five blocks of explanatory prose around four fields, and
three of them wrapped to two lines, so the form read as a paragraph with
inputs in it.

Each helper now earns its line or loses it:

- The header explained the implementation ("keeps the same history,
  permissions, and execution log as an @mention") -- an architecture note
  the person creating an action does not need. Reduced to the one fact
  they do: it posts a comment.
- "Who can use it" is a question, so the hints answer it as noun phrases
  ("Everyone in the workspace" / "Only you") instead of restating the
  verb. Both now fit one line, which also makes the two cards the same
  height -- the shorter one used to sit in dead space.
- The target and prompt hints front-load the constraint rather than
  burying it mid-sentence.

70 words to 32 across the dialog, with no fact dropped. Field spacing
goes 4 -> 5 so the gap between groups beats the gap inside one.

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

* refactor(issues): render a quick action comment as an ordinary comment (MUL-5465)

The card had a collapsed one-line header that expanded to reveal the
prompt, on the theory that repeated runs of the same action would bury
the discussion. That was solving a problem the feature does not have:
prompts are a sentence or two, the header restated what the body already
said, and the disclosure only put a click between the reader and the
text.

A quick action posts a real comment through the real mention path, so
the honest rendering is the one every other comment gets. Drops
QuickActionCommentBody, its query for the action catalog, and the
now-orphaned quick_action_ran_via string in all four locales.

quick_action_id stays on the comment: it is provenance, and it was never
the reason the card looked different -- keying the special rendering off
it is what is going away, not the record itself.

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

* fix(settings): use the faint tone token for the empty-state icon (MUL-5465)

main added apps/web/app/text-contrast.test.ts, a guard that rejects
transparency standing in for a text tone. The empty-state Zap used
text-muted-foreground/60, which is exactly the pattern it forbids: an
alpha-dimmed tone lands at a different contrast on every surface it is
composited over, so it cannot be reasoned about the way a token can.

text-faint-foreground is the token the guard names for icons and glyphs.

The rule arrived on main after this branch's last merge, so local runs
never saw it -- CI tests the merge commit, which is why only CI caught
it. Merged main first so the branch is checked against the same rules.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 21:01:48 +08:00
Bohan Jiang
9961db15ed feat(issues): bump issue updated_at when a comment is added (MUL-5009) (#5667)
* feat(issues): bump issue updated_at when a comment is added (MUL-5009)

A new comment now counts as activity on its issue and advances
updated_at, so the "Updated date" Kanban/list sort surfaces
recently-discussed cards — not only cards whose status changed.

Applies to all three comment-creation paths (user/agent HTTP,
agent task delivery, and the child-done system comment) via a
best-effort TouchIssue query. The bump never fails an already-
persisted comment; it self-heals on the next activity if it errors.

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

* fix(issues): make comment updated_at bump atomic (MUL-5009 review)

Address Elon's review. Move the updated_at bump into CreateComment as a
leading data-modifying CTE so the comment insert and the timestamp bump
commit or roll back together — closing the non-atomic window where a
comment could persist while updated_at stayed stale. That window also
skewed the daemon GC TTL, which reads issue.updated_at to reclaim
done/cancelled workdirs.

Centralizing the bump in the query drops the three per-caller TouchIssue
calls and guarantees any future comment entrypoint inherits it.

Also refresh the now-stale gc.go / gc_test.go comments that asserted
'CreateComment does not bump issue.updated_at'.

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

* fix(issues): make comment/issue workspace match a query-level guarantee (MUL-5009 nit2)

The touch CTE now RETURNING id, workspace_id and the INSERT SELECTs from
it, so the comment insert depends on the issue actually existing in the
passed workspace. A mismatched (issue, workspace) pair matches 0 rows in
the CTE, the dependent INSERT selects nothing, and the :one query returns
pgx.ErrNoRows — no mis-attributed comment is written and the issue is not
touched.

CreateComment is now the single carrier of the 'a comment belongs to an
issue in the same workspace and always bumps it' invariant, so no future
caller can break it by passing the wrong workspace. Signature unchanged;
no migration or foreign key.

Add TestCreateComment_WorkspaceMismatchPersistsNothing (error returned,
no comment persisted, updated_at unchanged).

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 15:38:31 +08:00
Bohan Jiang
69744241e8 fix(comment): restore autopilot @mention delegation authority (MUL-4857) (#5512)
* fix(comment): restore autopilot @mention delegation authority (MUL-4857)

A schedule/webhook autopilot run is unattributed by design (no top-of-chain
human originator, MUL-4302). Since MUL-3963 the A2A invoke gate (canInvokeAgent)
keys on that originator, so a mid-run @agent/@squad delegation on an
autopilot-created issue fails closed for the DEFAULT private agent (and
member-scoped public_to agents): the mention renders but no run is enqueued.
The SAME autopilot's first dispatch is admitted via the autopilot creator
(autopilotAdmitInvoke -> canCreatorInvokeAgent), so first-dispatch and mid-run
delegation disagreed.

Align them: when an unattributed agent/system-authored comment on an
autopilot-origin issue reaches computeCommentAgentTriggers with no originator,
fall back to the autopilot creator as the effective invoking user for the gate.
The gate still runs (no unrestricted agent-to-agent bypass); it is authorization
only -- the enqueued task's originator/attribution stays unattributed. Scoped to
autopilot-origin issues so other unattributed chains stay fail-closed.

Adds a DB-backed regression test covering: creator-owns-target admits, a
non-autopilot unattributed run stays denied, and a creator without invoke rights
stays denied.

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

* fix(comment): bind autopilot @mention authority to verified task lineage (MUL-4857)

Address the review's confused-deputy finding on the P0 fix. The first cut keyed
the invoke-gate fallback on issue provenance + an empty originator alone
(invokeAuthorityForAutopilotIssue took only the issue), so any unattributed run
could borrow a stranger autopilot creator's rights merely by commenting on that
autopilot's issue — and the fallback also leaked past explicit @mention into the
plain-comment squad-leader path and system actors.

Rework it so the autopilot-creator authority is granted ONLY when the SPEAKING
task's lineage is verified against this issue:

  - resolve the authority separately (new AutopilotDelegationAuthorityUserID on
    commentTriggerComputeOptions), never by overwriting OriginatorUserID; the
    gate reads it through opts.effectiveInvoker() only when no human originator
    resolved, so attribution stays untouched;
  - resolve from a server-trusted speaking task — X-Task-ID on create/preview,
    comment.source_task_id on edit/reconcile — via autopilotDelegationAuthority,
    which admits only when author == task agent AND task.issue_id == this issue
    AND the issue is autopilot-origin, then keys on the member autopilot creator;
  - do NOT key on autopilot_run_id: in create_issue mode (the reported case) the
    leader task is enqueued through the ordinary issue-assignment path and has no
    autopilot_run_id — the task.issue_id == issue binding is what proves the run
    is part of this autopilot's work while rejecting foreign-issue runs.

Tests: replace the provenance-only regression with lineage-bound coverage —
verified-lineage-admits, creator-without-rights-denied, non-autopilot-denied,
missing-source-task-denied, cross-issue-source-task-denied, author!=task-agent-
denied — plus an end-to-end CreateComment path asserting the private worker is
enqueued and the delegated run stays unattributed. Verified the fallback is
load-bearing (positive + e2e fail with it disabled) and the full internal/handler
package passes. Skill docs (multica-mentioning) updated to the lineage-bound
contract and new helper names.

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

* fix(comment): make autopilot @mention authority consistent across defer/edit (MUL-4857)

Second review round (Elon) surfaced two must-fixes on top of the lineage binding.

1. Busy-target completion reconcile lost the authority. A delegation to a target
   that is already running is deferred to that target's completion reconcile
   (reconcileCommentsOnCompletion). That path recomputed triggers with only the
   (empty) originator, so an unattributed autopilot delegation's follow-up was
   gate-denied again and silently dropped. It now restores the delegation
   authority from comment.source_task_id, so the follow-up fires once the target
   frees up — still unattributed.

2. Edit could borrow the old authoring run's authority, and preview != save. The
   edit preview keyed authority on the current request task while save keyed it on
   the comment's original source_task_id, so an agent editing its old autopilot
   comment from a task on an UNRELATED issue would fail-closed in preview but reuse
   the old autopilot creator's authority on save (cross-issue confused-deputy, and
   a preview/side-effect divergence). Fix: treat source_task_id as the persisted
   per-action authority lineage and re-stamp it on edit to the CURRENT editing
   task, issue-scoped exactly like CreateComment. A cross-issue edit re-stamps it
   to NULL, so preview, save, AND the deferred reconcile all fail closed
   identically. UpdateComment query gains a source_task_id param (sqlc regen).

Also locks the review-accepted behavior that effectiveInvoker() carries the
autopilot-creator authority into the plain assigned-squad-leader wake (a worker's
result comment on the autopilot issue can still wake the private leader).

Tests: reconcile-restores-authority (owns -> one unattributed follow-up; no rights
-> none); edit re-stamp (same-issue keeps authority and triggers; cross-issue
clears source_task_id and fails closed); worker-result wakes private squad leader.
Verified both fixes are load-bearing (each negative control reproduces the exact
regression Elon described), full internal/handler + internal/service packages pass,
gofmt/vet clean. Skill docs (multica-mentioning) updated.

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

* fix(comment): clear stale task lineage on non-author comment edits (MUL-4857)

An admin editing an autopilot Agent's comment previously preserved the
comment's original source_task_id. The immediate save is judged on the
admin's member identity and correctly fails closed, but the deferred
completion-reconcile routes the comment under its original agent author
and resolved the delegation authority from the stale source_task_id,
resurrecting the autopilot creator's invoke authority once the busy
target freed up — an admin (manage rights) could thereby trigger another
owner's private agent (invoke rights).

Now a content edit re-derives lineage from the edit action: only the
agent author editing its own comment re-stamps source_task_id to the
current editing task; every other editor (member/admin, or any
non-author) clears it, so preview, save, and reconcile all fail closed.

Adds a regression covering the admin-edit + busy-target path and syncs
the multica-mentioning skill docs.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:23:56 +08:00
Multica Eve
bf161f2f9c fix(tasks): preserve merged comment delivery (#5192)
Track actual claim-time delivery, support legacy daemons, and repair comment
batches across claim, retry, edit, and delete races.

MUL-4348

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 14:10:10 +08:00
Multica Eve
4db1abe11d fix(comment): compensate dropped agent→agent @mentions in completion reconcile (MUL-4304) (#5148)
* fix(comment): compensate dropped agent→agent @mentions in completion reconcile (MUL-4304)

When agent A explicitly @mentions agent B while B already has a
dispatched/running task, the create-time enqueue path can only fold the
comment into a QUEUED task; on a merge miss it defers to completion
reconcile. But reconcileCommentsOnCompletion listed only member comments
(ListMemberCommentsForIssueSince, author_type='member'), so A's
agent-authored mention was never replayed and B was silently never woken
— the intermittent 'agent @ agent fails to trigger' bug.

Broaden the reconcile query to member+agent comments and route each under
its own author_type. For an agent author, computeCommentAgentTriggers only
produces triggers for explicit @agent/@squad mentions (plus the narrow
assigned-squad-leader fallback), and reconcile still keeps only triggers
routing to the agent that just ran — so plain agent replies never qualify
and no unrelated agent is re-woken. Agent originator is resolved from the
comment's source task so canInvokeAgent authorizes A2A correctly.

Adds two covering tests: an agent-authored @B mention earns exactly one B
follow-up; a plain agent reply (no mention) earns none.

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

* fix(comment): address MUL-4304 review — exercise real dispatched drop + explicit-mention-only reconcile

Review must-fix 1: the regression test used a 'running' task, which does not
reproduce the drop (running-only is not AlreadyPending, so it takes the normal
fresh-enqueue path). Rewrite it to drive the ACTUAL failure: B has a DISPATCHED
task, agent A's explicit @B mention goes through the real trigger path
(triggerTasksForComment), assert it is dropped at creation (0 queued follow-up),
then complete B's task and assert reconcile recovers exactly 1 follow-up. Correct
the 'dispatched/running' wording in daemon.go and comment.sql to 'dispatched'.

Review must-fix 2: agent-authored comments on a squad-assigned issue can route
to the squad leader via routeAssignedSquadLeaderFallback (a non-mention route),
so 'plain reply yields nothing' was not unconditionally true. Scope reconcile's
agent-comment compensation to EXPLICIT @agent/@squad mentions only
(keepExplicitMentionTriggers, Source in {mention_agent, mention_squad_leader});
the squad-leader/assignee fallback and all other conversational routing are
intentionally not replayed. Add a squad-assigned plain-worker-reply test proving
the leader gets no completion-driven follow-up (verified failing without the
filter). Update doc comments accordingly.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 15:02:58 +08:00
Multica Eve
75695a2e40 fix(comments): guarantee at-least-once processing of user comments (MUL-4195) (#5068)
* fix(comments): guarantee at-least-once processing of user comments (MUL-4195)

Consecutive comments on an issue were silently dropped: a new comment that
arrived while the agent already had a queued/dispatched task was discarded by
the HasPendingTaskForIssueAndAgent dedup, losing the user's follow-up
instruction with no visible trace. Comments — unlike chat — are deliberate,
addressed, persisted input and must never vanish.

This makes comment handling at-least-once while keeping concurrency bounded to
one run per (issue, agent):

- Merge, don't drop (PR1): a comment landing while a not-yet-started task
  exists is folded into that task — the prior trigger becomes a coalesced
  comment and the new one becomes the trigger, so a single run still covers
  every deliberate comment. Falls back to a fresh enqueue if the pending task
  was claimed mid-flight, so nothing is lost in the race.
- Completion reconciliation (PR2): on task completion, a member comment newer
  than the run's started_at schedules exactly one follow-up via the normal
  trigger pipeline. Loop-safe: member-authored only, capped by the existing
  per-(issue,agent) dedup, and terminating.
- Visibility (PR3): coalesced_comment_ids is surfaced on the task API and in
  the run prompt so the covered comments are explicit.

Migration 145 adds agent_task_queue.coalesced_comment_ids UUID[].

Tests: merge-not-drop preserves all three of a rapid burst and repoints the
trigger to the newest; reconciliation query gates on member/since; e2e
CompleteTask enqueues a follow-up for a mid-run member comment and does not for
none.

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

* fix(comments): address review — originator gate, agent-scoped reconcile, cross-thread coalesced prompt (MUL-4195)

Resolves GPT-Boy's Request-changes review on PR #5068.

Must-fix #1 — merge no longer inherits a stale originator/runtime context.
MergeCommentIntoPendingTask now only folds a comment into a pending task
whose originator_user_id IS NOT DISTINCT FROM the new comment's originator.
runtime_mcp_overlay / runtime_connected_apps are a pure function of
(originator, agent) and the agent is fixed, so a matching originator keeps
the stored overlay/attribution valid; a differing originator (e.g. user B
commenting on a task originated by user A) matches no row and the caller
enqueues a fresh follow-up with B's own context instead of reusing A's.
trigger_summary is refreshed to the new trigger comment.

Must-fix #2 — completion reconcile no longer re-wakes unrelated agents.
reconcileCommentsOnCompletion computes the latest member comment's triggers
and keeps ONLY the agent that just completed, instead of fanning the comment
out through the full pipeline. An @-mention of agent B during agent A's run
is triggered once at creation time and is no longer replayed (double-run)
when A completes.

Should-fix #3 — coalesced-comment prompt no longer assumes a single thread.
The claim response now carries each folded comment's thread id / author /
created_at / content (CoalescedCommentData); the prompt embeds them directly
so the agent addresses cross-thread folded comments without the wrong
"they are in the triggering thread" hint. Old servers that ship only ids
fall back to an issue-wide fetch, still without the same-thread assumption.

Tests: TestMergeCommentIntoPendingTask_OriginatorGate (query gate),
TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun (reconcile
scoping), TestBuildCommentPromptCoalescedCrossThread / IDsOnlyFallback
(prompt). Existing MUL-4195 suites still pass.

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

* fix(comments): close unique-index drop + dispatched-window race in comment coalescing (MUL-4195)

Second-round review follow-up on PR #5068.

Must-fix #1 — originator-mismatch no longer drops the comment.
The previous originator gate returned ErrNoRows on a different originator and
the caller fell through to a fresh enqueue, which collided with the
idx_one_pending_task_per_issue_agent unique index (one queued/dispatched task
per (issue, agent)) — silently dropping the second user's comment. Replaced
the gate with recompute-on-merge: MergeCommentIntoPendingTask now re-stamps
originator_user_id, runtime_mcp_overlay, runtime_connected_apps and
trigger_summary to the new comment's originator. A different member's comment
folds into the single coalescing run carrying the latest instruction's own
identity/overlay (no cross-user capability bleed, no drop, no collision).

Must-fix #2 — comment arriving in the claim→StartTask window is no longer lost.
Merge now targets only PRE-CLAIM states ('queued','deferred'); a
dispatched/running task is never a merge target, so a post-claim comment is
never falsely stamped into coalesced_comment_ids as "delivered". Completion
reconcile is re-anchored on dispatched_at (the moment the claim response is
built) instead of started_at, and sweeps ALL undelivered member comments since
that anchor — replaying each through the normal enqueue path so they coalesce
into one bounded, agent-scoped follow-up run. This covers the dispatch→start
window a started_at anchor missed.

Enqueue path: on a merge miss the caller no longer blindly fresh-enqueues
(which could collide with a dispatched sibling); it defers to the active
task's completion reconcile via HasActiveTaskForIssueAndAgent, and only
fresh-enqueues when no active task exists.

Tests: rewrote the query test to
TestMergeCommentIntoPendingTask_RecomputesOriginatorAndSkipsDispatched;
added TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath (full handler
enqueue path, two distinct originators) and
TestCompleteTask_ReconcilesDispatchedWindowComment (claim→start window). All
existing MUL-4195 handler/cmd-server/daemon/service suites still pass.

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

* fix(comments): catch pre-dispatch merge-race comment in completion reconcile (MUL-4195)

Third-round review follow-up on PR #5068.

Race: a member comment is created while the task is still queued, but its
merge loses the race to the daemon claiming the task (queued→dispatched). The
merge then finds no pre-claim row (ErrNoRows), the enqueue path defers to
reconcile — but the comment's created_at is BEFORE dispatched_at, so the
dispatched_at-anchored reconcile skipped it and the comment vanished with no
task coverage.

Fix: anchor completion reconcile on the task's created_at (which always
precedes dispatch) instead of a dispatch/start timestamp, and exclude the
run's DELIVERED SET — trigger_comment_id ∪ coalesced_comment_ids. Because
merges only ever touch pre-claim rows, that set is exactly what the claim
response carried, so any member comment created since the task was made that
is NOT in it was genuinely undelivered and earns a bounded follow-up. This
catches the pre-dispatch merge-race comment and the dispatch→start comment,
while never re-firing a comment that was delivered as a pre-claim coalesced
entry.

Test: TestCompleteTask_ReconcilesPreDispatchMergeRaceComment reproduces the
race (comment created pre-dispatch, task dispatched before merge, plus a
delivered coalesced comment) and asserts exactly one follow-up, triggered by
the race comment, with the delivered coalesced comment excluded. Existing
reconcile fixtures updated to set a realistic created_at (the production
invariant that created_at is the earliest task timestamp).

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

* fix(comments): merge only into the queued task, never a deferred fallback (MUL-4195)

Fourth-round review follow-up on PR #5068.

MergeCommentIntoPendingTask targeted status IN ('queued','deferred') ordered
by created_at DESC. When a (issue, agent) pair had both an older queued task
(the run about to be claimed) and a newer deferred assignee-fallback task, a
new comment merged into the deferred row instead of the queued one — so the
comment missed the imminent run and the deferred fallback could later promote
into a duplicate/conflicting run.

This merge is only ever reached when HasPendingTaskForIssueAndAgent matched a
queued/dispatched task (it never inspects deferred), so the coalescing target
must be the queued row. Restricted the merge target to status = 'queued'
(the unique index guarantees at most one). Deferred fallbacks keep their own
fire_at/promotion escalation lifecycle and are never a merge target.

Test: TestMergeCommentIntoPendingTask_TargetsQueuedNotDeferred seeds an older
queued task + a newer deferred fallback for the same (issue, agent), merges a
new comment, and asserts it lands on the queued task (trigger repointed, old
trigger coalesced) while the deferred fallback is left untouched.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 12:48:57 +08:00
Jiayuan Zhang
63b9b10df5 MUL-3328: add retry button for failed agent comments (#4217)
* MUL-3328: add retry button for failed agent comments

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

* MUL-3328: cover child-done system comments

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

* MUL-3328: restrict retry affordance to failures

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

* MUL-3328: clean migration whitespace

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:44 +02:00
Naiyuan Qing
34c68e1e4c fix(comments): enforce single resolution per thread (#3984)
A thread could hold multiple resolved comments at once: ResolveComment
was a plain per-row setter that never cleared the prior resolution, and
"replacing" one was a display-only illusion (deriveThreadResolution
picks the max resolved_at). The stale rows stayed resolved in the DB and
the optimistic update flashed the new resolution, then reverted.

Make single-resolution-per-thread a write invariant:

- ClearOtherThreadResolutions: thread-scoped clear via a RECURSIVE CTE
  (root + descendants of the target, id <> target), returns each cleared
  row.
- ResolveComment handler runs the clear + set in one tx so the replace
  is atomic. It emits comment:unresolved per cleared sibling (granular
  realtime consumers patch a single comment in place and would otherwise
  keep showing the stale resolution). Target keeps its COALESCE
  idempotency and the re-resolve event suppression.
- Frontend optimistic update mirrors the invariant: resolving clears
  every other resolution in the same thread, so the cache never shows
  two at once. Unresolve still only clears its own row.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 14:20:39 +08:00
Naiyuan Qing
4ae4722ef0 fix(comments): preserve direct parent on replies (#3579)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 08:28:15 +08:00
Naiyuan Qing
973a43923f fix(comments): revert since-delta to issue-wide, steer to parent thread first (#3535)
#3509/#3523 scoped the comment-trigger since-delta count to the triggering
thread, so an agent resuming a busy issue only saw "+N in this thread" and
lost visibility of new comments in other threads. Revert the count to
issue-wide (every thread), keeping the trigger-comment + agent-own
exclusions, and reshape the warm-path hint to:

  - report the issue-wide new-comment volume,
  - steer the agent to read the triggering (parent) thread FIRST
    (`--thread <trigger> --since`, or `--tail 30` for full context),
  - demote the issue-wide `--since` catch-up to an only-if-needed fallback
    ("don't read them all blindly").

Also fixes the now-stale "scoped to the triggering thread" wording in the
resumed-session no-delta hint (it's issue-wide zero now).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 20:13:23 +08:00
Multica Eve
9616d78e47 MUL-2785: optimize resumed comment reads (#3509)
* feat(comments): skip default thread read on resumed comment sessions

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

* fix(comments): scope since delta to trigger thread

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

* chore(comments): address thread delta review nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 14:57:14 +08:00
Bohan Jiang
75b5be3f8e feat(comments): roots-only thread stats + summary projection for comment list (MUL-2809) (#3505)
* feat(comments): roots-only thread stats + summary projection for comment list

Enrich the roots_only read so each root carries reply_count (recursive
descendant count) and last_activity_at (MAX created_at over the subtree),
letting an agent triage which thread to open without fetching any replies.

Add an orthogonal summary=true projection (--summary) that 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. It composes
with every read mode (default, since, thread, recent, roots_only).

New response fields are optional (omitempty) and only populated for the
agent-facing query params, so the default response shape is unchanged for
the desktop/web and existing CLI callers.

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

* test(comments): cover roots_only + summary composition end-to-end

The summary projection composing with roots_only is the spec's headline
"table of contents" read, but it was only exercised at the CLI param-
forwarding level — no handler test asserted that a roots_only response
both clips content AND keeps reply_count / last_activity_at. A refactor
moving the clip into a per-mode branch would silently break that
composition with no failing test.

Add TestListComments_RootsOnlySummaryComposes: a long root + a reply,
read via roots_only=true&summary=true, asserting the root is clipped
(content_truncated=true) while its subtree stats still surface.

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

* refactor(comments): address review nits on roots stats + summary

- ListRootComments[Since]ForIssue: scope the recursive membership walk to a
  selected_roots CTE (the @row_limit page, with the @since cut applied up front)
  so stats are only computed over the subtrees of the roots actually returned,
  instead of every thread in the issue.
- summarizeContent: scan by rune and stop at the budget+1th rune instead of
  allocating a full []rune for the whole body, so a pathologically long comment
  costs only the budget under summary mode. Add a multi-byte (CJK) test to lock
  rune-boundary clipping.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 12:59:53 +08:00
Fangfei
c730e906b9 feat(cli): add roots-only issue comment listing (MUL-2805) (#3288) 2026-05-29 12:03:38 +08:00
Naiyuan Qing
3187bbf90c feat(comments): re-add since-delta + cold-start thread read + parent-root write normalization (#3494)
* feat(comments): since-delta new-comment hint + default-on comment session resume (#3432)

* feat(db): add unresolved comment count + list filter queries

Add CountUnresolvedComments (excludes the agent's own comments) and
ListUnresolvedCommentsForIssue. Both are additive — existing callers stay
on the unfiltered queries — so old clients are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(handler): support unresolved-only comment listing

Wire an additive `unresolved` query param into ListComments. Defaults off
so an old CLI that never sends it gets unchanged behavior; only true/1
enable it. Rejects combining unresolved with thread/recent (whole-issue
filter vs navigation models). Includes filter + count query tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(handler): plumb unresolved count + thread root into claim, gate comment resume

Populate trigger_parent_id (thread root of the trigger comment) and
unresolved_count (excludes the agent's own comments) on comment-triggered
claim responses. Both fields are omitempty so old daemons ignore them.

Gate comment-triggered session resume behind MULTICA_RESUME_COMMENT_SESSION
(default off): resumed comment turns can inherit the prior turn's "Done."
final message, so this stays an explicit rollout switch. The runtime-match
and poisoned-session guards still apply regardless of the flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(daemon): inject unresolved-comments hint + resolve step into agent brief

Add a shared BuildUnresolvedCommentsHint helper rendered on both the
per-turn prompt and the CLAUDE.md workflow (kept in sync per PR #2816). It
ships only the count and the relevant CLI call — never comment bodies — so
the server stays cheap. Thread case points at --thread <root>; issue case
points at --unresolved. Suppressed when the count is 0.

Also add a workflow step telling the agent to `multica comment resolve
<thread-root>` once a thread is fully handled, so the unresolved set
converges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): add comment list --unresolved and comment resolve command

Add an --unresolved filter to `issue comment list` (wired to the server's
unresolved param, rejected when combined with --thread/--recent) and a
top-level `comment resolve <id>` command that POSTs to the existing
/api/comments/{id}/resolve endpoint, letting an agent close threads it has
fully handled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(comments): since-delta new-comment hint + default-on comment resume

Simplifies the comment-triggered agent flow down to what's actually needed:

- New-comment awareness is now a pure time delta: the claim response carries
  new_comment_count + new_comments_since (anchored on the prior run's
  started_at, never completed_at so a long run can't miss comments). The
  per-turn prompt and CLAUDE.md workflow render one line — "N new comment(s)
  since your last run, --since <ts>" — via a shared BuildNewCommentsHint so the
  two surfaces can't drift. Cold start (no prior run) falls back to a plain read.
- Comment-triggered tasks resume the prior session by default (same runtime),
  dropping the MULTICA_RESUME_COMMENT_SESSION rollout gate. The "Focus on THIS
  comment" prompt guard defends against inheriting the prior turn's "Done."
  marker; GetLastTaskSession still excludes poisoned sessions.
- Drops the resolved-based machinery from the first draft: CountUnresolvedComments
  / ListUnresolvedCommentsForIssue queries, the `comment list --unresolved`
  flag, the `multica comment resolve` command, and the resolve workflow step.
- Removes the verbose cursor-pagination paragraph from the comment prompt; the
  --thread/--recent/--since flags stay in the CLI/API, just no longer explained
  inline every turn.

Compatibility: new claim fields are omitempty (old daemons ignore them).
Comment resume is default-on and affects even old daemons, which already
consume prior_session_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(comments): collapse reply parent_id to thread root on write

Comment threads are a 2-level model (root + flat replies, like Linear/Slack),
enforced today only by the UI and the agent path — the CreateComment handler
stored whatever parent_id it was handed, and the agent-side flatten walked just
one level, so a reply-to-a-reply could land at depth 3+. Add GetThreadRoot (a
recursive walk to the parent_id=NULL root) and run both write paths
(handler.CreateComment, service.createAgentComment) through it, so every stored
reply's parent_id IS its thread root. Readers can now treat parent_id as the
thread root without re-walking. The agent-drift guard still compares the raw
parent_id to the trigger comment before normalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(comments): cold-start reads triggering thread, warm keeps --thread pointer

The since-delta rework dropped the thread-first read on the COLD path: a
first-time agent fell back to the flat `comment list` dump (oldest-first, cap
2000), burying the trigger's context in ancient chatter. Point cold start at the
triggering conversation instead via a shared BuildColdCommentsHint
(`--thread <trigger> --tail 30` + a --recent pointer for cross-thread
background). On the WARM path, --since is a pure time delta and can miss the
triggering thread's pre-anchor history, so BuildNewCommentsHint now also emits a
--thread pointer. Both surfaces (per-turn prompt + CLAUDE.md workflow) render via
the shared helpers so they cannot drift (PR #2816 rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:38:37 +08:00
Naiyuan Qing
d90732750f Revert "feat(comments): since-delta new-comment hint + default-on comment ses…" (#3455)
This reverts commit 5e78e5100a.
2026-05-28 17:52:59 +08:00
Naiyuan Qing
5e78e5100a feat(comments): since-delta new-comment hint + default-on comment session resume (#3432)
* feat(db): add unresolved comment count + list filter queries

Add CountUnresolvedComments (excludes the agent's own comments) and
ListUnresolvedCommentsForIssue. Both are additive — existing callers stay
on the unfiltered queries — so old clients are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(handler): support unresolved-only comment listing

Wire an additive `unresolved` query param into ListComments. Defaults off
so an old CLI that never sends it gets unchanged behavior; only true/1
enable it. Rejects combining unresolved with thread/recent (whole-issue
filter vs navigation models). Includes filter + count query tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(handler): plumb unresolved count + thread root into claim, gate comment resume

Populate trigger_parent_id (thread root of the trigger comment) and
unresolved_count (excludes the agent's own comments) on comment-triggered
claim responses. Both fields are omitempty so old daemons ignore them.

Gate comment-triggered session resume behind MULTICA_RESUME_COMMENT_SESSION
(default off): resumed comment turns can inherit the prior turn's "Done."
final message, so this stays an explicit rollout switch. The runtime-match
and poisoned-session guards still apply regardless of the flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(daemon): inject unresolved-comments hint + resolve step into agent brief

Add a shared BuildUnresolvedCommentsHint helper rendered on both the
per-turn prompt and the CLAUDE.md workflow (kept in sync per PR #2816). It
ships only the count and the relevant CLI call — never comment bodies — so
the server stays cheap. Thread case points at --thread <root>; issue case
points at --unresolved. Suppressed when the count is 0.

Also add a workflow step telling the agent to `multica comment resolve
<thread-root>` once a thread is fully handled, so the unresolved set
converges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): add comment list --unresolved and comment resolve command

Add an --unresolved filter to `issue comment list` (wired to the server's
unresolved param, rejected when combined with --thread/--recent) and a
top-level `comment resolve <id>` command that POSTs to the existing
/api/comments/{id}/resolve endpoint, letting an agent close threads it has
fully handled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(comments): since-delta new-comment hint + default-on comment resume

Simplifies the comment-triggered agent flow down to what's actually needed:

- New-comment awareness is now a pure time delta: the claim response carries
  new_comment_count + new_comments_since (anchored on the prior run's
  started_at, never completed_at so a long run can't miss comments). The
  per-turn prompt and CLAUDE.md workflow render one line — "N new comment(s)
  since your last run, --since <ts>" — via a shared BuildNewCommentsHint so the
  two surfaces can't drift. Cold start (no prior run) falls back to a plain read.
- Comment-triggered tasks resume the prior session by default (same runtime),
  dropping the MULTICA_RESUME_COMMENT_SESSION rollout gate. The "Focus on THIS
  comment" prompt guard defends against inheriting the prior turn's "Done."
  marker; GetLastTaskSession still excludes poisoned sessions.
- Drops the resolved-based machinery from the first draft: CountUnresolvedComments
  / ListUnresolvedCommentsForIssue queries, the `comment list --unresolved`
  flag, the `multica comment resolve` command, and the resolve workflow step.
- Removes the verbose cursor-pagination paragraph from the comment prompt; the
  --thread/--recent/--since flags stay in the CLI/API, just no longer explained
  inline every turn.

Compatibility: new claim fields are omitempty (old daemons ignore them).
Comment resume is default-on and affects even old daemons, which already
consume prior_session_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:58:42 +08:00
Tom Qiao
1c91c2a3b2 security(db): scope DELETE/UpdateIssueStatus by workspace_id (defense-in-depth) (#3027)
* fix(security): scope DELETE/UpdateIssueStatus by workspace_id

Add workspace_id to the WHERE clause of DeleteIssue, DeleteComment,
DeleteProject, DeleteSkill, DeleteChatSession, and UpdateIssueStatus
as SQL-layer defense-in-depth.

Handler loaders (loadIssueForUser / loadSkillForUser / etc.) already
enforce workspace membership today, so this is not patching a known
live vuln. But the tenant invariant is currently a handler-layer
guarantee — a future loader bypass or a new caller skipping the
loader would be silently catastrophic. Making workspace_id part of
the SQL identity collapses the trust surface to the schema itself:
forging a sibling-workspace UUID becomes ErrNoRows instead of a
cross-tenant write.

Reference: incident #1661 (util.ParseUUID silent zero UUID returning
204 on a DELETE that matched zero rows) — same class of failure,
prevented at a different layer.

Scope:
- 5 DELETE queries: issue, comment, project, skill, chat_session
- 1 simple UPDATE: UpdateIssueStatus (2 narg, no SET ordering risk)
- All callers updated (handlers, service, runtime sweeper fallback)

Multi-narg UPDATE queries (UpdateIssue, UpdateProject, UpdateSkill,
UpdateComment, UpdateChatSession*) are deferred to a follow-up to
keep this change reviewable: each needs its narg pinning shifted
and per-caller verification.

sqlc was regenerated by hand (no local sqlc toolchain); CI's
backend job is the authoritative compile check.

* test(security): add workspace_scope_guard regression test

Locks in the SQL-layer tenant guard added in this PR. For each of the 6
scoped queries (DeleteIssue, DeleteComment, DeleteProject, DeleteSkill,
DeleteChatSession, UpdateIssueStatus), creates the resource in workspace
A, invokes the query with a foreign workspace UUID, and asserts the row
is untouched (0 rows affected with no error for :exec; pgx.ErrNoRows for
:one). A future refactor that drops the workspace_id arg from any of
these queries will now fail loudly instead of silently regressing.

Includes a sanity sub-test that the in-workspace path still mutates, so
a buggy guard that returns no-op for every call would not pass.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-05-22 14:39:47 +08:00
Bohan Jiang
7f9e4e829d feat(comments): thread-internal --tail pagination + reply cursor (MUL-2421) (#2846)
* feat(comments): thread-internal pagination via --tail + reply cursor (MUL-2421)

Long threads inside a single issue still forced agents to read every reply
once they used --thread, even after MUL-2387 fixed cross-thread noise. This
adds reply-level paging so a 200-reply thread can be navigated tail-first
without dragging the whole conversation into prompt context.

- New SQL query ListThreadCommentsForIssuePaged: same recursive root walk
  as the legacy thread query, but caps reply count and supports an
  (created_at, id) composite cursor. Root is unconditional — even tail=0
  emits it so the reader keeps the "what is this thread about" context.
- Handler ListComments: parses `tail` (non-negative, ThreadTailSet flag
  preserves the tail=0 intent), threads it through to the paged query,
  and re-uses X-Multica-Next-Before / X-Multica-Next-Before-Id for the
  reply cursor. Cursor's meaning is now context-dependent: thread cursor
  under --recent, reply cursor under --thread + --tail.
- CLI: new --tail flag (only valid with --thread; mutually exclusive
  with --recent), reply-cursor semantics for --before / --before-id when
  paired with --thread + --tail, stderr label flips to "Next reply cursor"
  so an operator copy-pasting the cursor knows which scope it scrolls.
- Tests cover the new contract: tail=N keeps newest N + root, tail=0 is
  root-only, anchor on a nested reply still walks up, reply cursor
  scrolls older replies page-by-page, since combined with tail filters
  after the cut, and the negative-flag-combination matrix.

Out of scope: prompt template update to hint at `--thread <id> --tail 30`
on long threads — separate follow-up per the issue.

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

* fix(comments): only emit reply cursor when older reply exists (MUL-2421)

The thread-tail path emitted `X-Multica-Next-Before` whenever the page
filled to exactly the requested reply count, even when there was nothing
older to scroll to. So `--thread <root> --tail 3` on a thread with
exactly 3 replies sent a cursor that, when followed, returned just the
root — a wasted round-trip that surfaced as a phantom "older replies"
affordance in the agent prompt.

Switch to a `reply_limit + 1` probe: ask the SQL for one extra row, trim
the oldest overflow before responding, and only emit the cursor when an
older reply actually existed. The exact-boundary case (replyCount ==
tail with no overflow) now returns no cursor.

Also documents `--thread/--tail/--recent/--before` and the cursor
semantics in CLI_AND_DAEMON.md, which was the second must-fix in the
MUL-2421 review.

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

* fix(comments): suppress reply cursor when --since covers older replies (MUL-2421)

In the thread + tail + since path the server still emitted a reply cursor
whenever there was an older reply on disk, regardless of `since`. If the
oldest retained reply on the page was already `<= since`, every older
reply was guaranteed to be filtered out too, so the next page only ever
returned the root — wasting round-trips until the agent walked the whole
pre-`since` history. Mirror the recent + since suppression: when
`replies[0].CreatedAt <= since`, drop the cursor.

Test covers the exact case from Elon's review: tail=2 overflow, body
keeps a fresher reply, but the cursor target (oldest retained reply) is
already past `since` — header must be empty.

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

* feat(prompt): default comment-trigger reads to --thread --tail 30 (MUL-2421)

Comment-triggered agents previously defaulted the trigger-thread read to
the unbounded `--thread <id> --output json`, which dumps the full thread
into the prompt — exactly the kind of context bloat MUL-2387 fixed at the
cross-thread layer but never bounded inside a single thread.

Use the new `--tail` flag landed earlier in this PR (server + CLI) as the
default for both the per-turn prompt and the runtime-config Workflow:

- `--thread <trigger-id> --tail 30 --output json` is the new default.
  Root is always included so "what is this about" context survives.
- If 30 replies aren't enough, the prompt now spells out the reply
  cursor: re-feed the stderr `Next reply cursor: --before <ts>
  --before-id <reply-id>` pair back to walk older replies.
- `--recent 20` stays as the cross-thread background fallback, with an
  explicit callout that the same `--before` / `--before-id` flags walk
  *threads* (not replies) in that mode.
- Available Commands core line now surfaces `--tail N` and both stderr
  cursor labels so non-workflow callers also discover the flag.
- `--since` callouts reflect the post-MUL-2421 combinable mode names
  (`--thread --tail` / `--recent`).

Tests (`prompt_test.go`, `execenv_test.go`) pin the new defaults and add
a regression guard against the unbounded `--thread` recipe sneaking back
in.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:43:15 +08:00
Bohan Jiang
6f5fbb7813 feat(comments): thread-aware list with composite cursor (MUL-2340) (#2787)
* feat(comments): thread-aware list with composite cursor (MUL-2340)

Adds three optional query params to GET /api/issues/{id}/comments and the
matching `multica issue comment list` flags:

- `thread=<comment-uuid>` resolves the anchor to the thread root via a
  recursive CTE (defends against any future nested replies) and returns
  root + all descendants chronologically. Anchor can be any comment in
  the thread, root or reply.
- `recent=<N>` returns the newest N comments for the issue, ordered
  chronologically in the response.
- `before=<RFC3339>` + `before-id=<uuid>` form a composite cursor for
  stable pagination of `recent`. Both must be set together; a
  timestamp-only cursor is rejected because ties on `created_at` would
  let the existing `(created_at ASC, id ASC)` total order skip or
  duplicate rows across pages.

Flag combination rules: `thread` is exclusive with `recent` and the
cursor; both may combine with `since`. Server and CLI enforce the same
matrix; the CLI fails fast locally so callers don't pay for a 400
round-trip.

Default behaviour (no params) is unchanged — full chronological dump
capped at commentHardCap — so the desktop UI and existing `--since`
polling are untouched. Agent prompt updates land in a follow-up PR so
the new CLI capabilities ship and bake first.

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

* fix(comments): reject cursor without recent and align CLI/server on invalid --recent (MUL-2340)

Elon's PR #2787 second review flagged two gaps in the flag combination
matrix:

- server: GET /comments?before=...&before_id=... without `recent` was
  silently dropped by fetchCommentsForList (RecentN=0 fell through to
  the default / since path), so callers got the full timeline instead
  of the documented "before X" semantics. Now returns 400.
- CLI: --recent 0 / --recent -3 were collapsed with "flag not passed"
  by `recent > 0`, so an explicit invalid value silently fell back to
  the default list. Switched to Flags().Changed("recent") so explicit
  non-positive values fail loudly. Also enforces that --before /
  --before-id only appear with explicit --recent (mirrors the new
  server-side rule).

Tests:
- server flag matrix gains `before + before_id without recent → 400`.
- CLI gains TestRunIssueCommentListFlagGuards covering `--recent 0`,
  `--recent -3`, cursor-without-recent, and the thread/recent
  exclusivity path under the new Changed()-based check. The mock
  server fatals if a request reaches /comments, proving the guards
  fire before any HTTP round-trip.

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

* feat(comments): make `recent` thread-grouped with a thread cursor (MUL-2340)

Bohan pushed back on the row-based `recent=N` shape: comments form a tree,
not a list, and the newest N rows can come from N unrelated threads, giving
the agent N disjoint conversational tails. Replace the row-based query with
a thread-grouped one before #2787 merges so we never ship the wrong shape:

- `recent=N` now returns the N most recently active threads (root + every
  descendant per thread). A thread's recency is MAX(created_at) across its
  whole subtree, so a stale-but-recently-replied thread outranks an old
  quiet one — exactly the property row-recent loses.
- The cursor is now a *thread* cursor: `before` = a thread's
  last_activity_at, `before_id` = its root comment id. The pair walks
  threads strictly less recent than the page's oldest-active thread. The
  cursor surfaces via `X-Multica-Next-Before` / `X-Multica-Next-Before-Id`
  response headers (empty when there are no older threads); the CLI
  forwards the same pair to stderr after listing.
- Row-based `recent` is gone — there is no internal caller and the prompt
  update has not shipped yet, so there is no compat surface to preserve.
- Response body shape unchanged (flat JSON array, chronological). Default
  and `--since` paths untouched. Desktop UI keeps working.

Tests:
- recent=1 returns the freshest-active thread fully; recent=2 returns both
  with the older-active thread first (oldest-active → freshest tail).
- Stale-but-fresh: a thread whose root is older but has a fresh reply
  outranks a thread whose root is newer but quiet.
- Cursor headers emitted only on full pages; empty on the final page.
- Pagination walks threads root2 → root1 → empty, no skips/duplicates.
- Tie-break: three threads sharing last_activity_at paginate one-at-a-time
  using (last_activity_at, root_id) ordering — verifies the timestamp-only
  cursor failure mode is fixed for the thread case too.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 19:28:26 +08:00
Bohan Jiang
3f20999597 refactor(timeline): drop server-side comment + timeline pagination (#2322)
* refactor(timeline): drop server-side comment + timeline pagination (MUL-1929)

The cursor-paginated /timeline and /comments endpoints were sized for a
problem the data shape doesn't have: prod p99 is ~30 comments per issue
and the all-time max is ~1.1k. Time-based pagination also splits reply
threads across page boundaries (orphan replies), which the frontend was
papering over with an "orphan rescue" that promoted disconnected replies
to top-level — confusing UX with no real benefit.

Replace both endpoints with a single full-issue fetch, capped server-side
at 2000 rows as a defensive safety net (never hit in practice).

Server
- /api/issues/:id/timeline now returns a flat ASC TimelineEntry[]
  (matches the legacy desktop contract — older Multica.app builds keep
  working because the wrapped TimelineResponse + cursors are gone, and
  the raw array shape was always what they consumed).
- /api/issues/:id/comments drops limit/offset; only ?since is honoured
  for the CLI agent-polling flow.
- Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest
  and the timelineCursor encoding.
- Replace with ListCommentsForIssue / ListCommentsSinceForIssue /
  ListActivitiesForIssue (capped by argument).

CLI
- multica issue comment list drops --limit / --offset and the X-Total-Count
  reporting; --since is preserved for incremental polling.

Frontend
- Replace useInfiniteQuery with useQuery in useIssueTimeline; drop
  fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount.
- Remove timeline-cache helpers (mapAllEntries / filterAllEntries /
  prependToLatestPage) and the TimelinePage / TimelinePageParam types.
- WS event handlers update the single flat-array cache directly.
- Drop the orphan-reply rescue in issue-detail — every reply's parent
  is now guaranteed to be in the same array.
- Strip the "show older / show newer / jump to latest" buttons and their
  i18n strings.

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

* fix(timeline): address review feedback on pagination removal

Three issues caught in PR #2322 review:

1. /timeline broke for stale clients between #2128 and this PR. They send
   ?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema;
   the new flat-array response was failing schema validation and falling back
   to an empty timeline. Restore the wrapped shape on those query params
   (DESC entries, null cursors, has_more_*=false), keeping the flat ASC array
   for bare requests. Around-mode now also fills target_index from the merged
   slice so legacy clients can still scroll-to-anchor without a follow-up.

2. The agent prompts in runtime_config.go and prompt.go still told agents
   that `multica issue comment list` accepts --limit/--offset and to use
   `--limit 30` on truncated output. With those flags removed in this PR,
   new agent runs would hit "unknown flag" or skip context. Update the
   prompt copy to "returns all comments, capped at 2000; --since for
   incremental polling".

3. useCreateComment's onSuccess was a bare append to the timeline cache
   with no id-dedupe, so a fast comment:created WS event firing before
   onSuccess produced a transient duplicate. Restore the id guard the old
   prependToLatestPage helper used to provide.

Adds two new boundary tests:
- TestListTimeline_LegacyWrappedShape_OnPaginationParams
- TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex

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

* test(handler): fix timeline test assertions for handler-package isolation

The TestListTimeline_* assertions assumed CreateIssue would seed an
"issue_created" activity_log row, but the activity listener that publishes
those rows is registered in cmd/server/main.go — handler-package tests
don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the
test expected ≥6.

Drop the auto-activity assumption: assert exactly 5 entries in
TestListTimeline_MergesCommentsAndActivities, and tighten
TestListTimeline_EmptyIssue to assert a fully-empty timeline.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 16:11:58 +08:00
Jiayuan Zhang
3b3be9d7bd feat(comments): resolve threads with collapsible bar (MUL-1895) (#2300)
* feat(comments): resolve threads with collapsible bar (MUL-1895)

Adds a Linear-style resolve action on comment thread roots. Resolved
threads collapse to a single "N resolved comments from X" bar in the
activity feed; clicking expands the thread inline (per-session, not
persisted). Replying inside a resolved thread auto-unresolves it.

Backend
- migration 069: resolved_at, resolved_by_type, resolved_by_id on comment
- sqlc ResolveComment / UnresolveComment queries (idempotent via COALESCE)
- POST/DELETE /api/comments/{id}/resolve handlers, root-only validation
- CreateComment auto-clears resolved_at when a reply lands in a resolved
  thread, publishing comment:unresolved
- comment:resolved / comment:unresolved events; CommentResponse and
  TimelineEntry both surface the new fields

Frontend
- Comment + TimelineEntry types extended; payloads typed; WS sync wired
- useResolveComment optimistic mutation with rollback
- ResolvedThreadBar component for the collapsed view
- Resolve / Unresolve menu items on root comments; Collapse strip on the
  expanded resolved card
- en + zh-Hans locale strings

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

* fix(comments): cover agent reply path, expand-state hygiene, nested counts (MUL-1895)

Addresses three review issues from Emacs on PR #2300:

1. TaskService.createAgentComment bypasses Handler.CreateComment, so the
   auto-unresolve wired into the handler did not fire when an agent replied
   in a resolved thread (task / mention / on_comment paths). Extracted the
   logic to TaskService.AutoUnresolveThreadOnReply so both reply paths share
   it; rewired Handler.CreateComment to call the new method.

2. Resolving an already-expanded thread no longer collapses it back to the
   bar because expandedResolved still contained the id. Added
   clearResolvedExpand + handleResolveToggle wrapper so resolve / unresolve
   always wipe the session expand entry.

3. ResolvedThreadBar received only direct children, while CommentCard's
   expanded view recurses through descendants. Extracted the recursive
   walk into thread-utils.collectThreadReplies and called from both —
   counts and author lists now match.

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

* test(comments): mock useResolveComment + add zh-Hans plural key

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 05:49:33 +02:00
Naiyuan Qing
ba147708a6 fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968) (#2128)
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)

Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.

This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.

API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
  + ?limit (default 50, max 100); response wraps entries with next/prev
  cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
  pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
  the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
  every row unbounded; the unbounded ListComments / ListActivities sqlc
  queries are deleted.

Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
  fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
  prepends to pages[0] only when the user is reading the live tail;
  otherwise it just bumps a counter so the UI offers a "Jump to latest"
  affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
  helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
  core/issues/timeline-cache.ts) and use setQueriesData so all open
  windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
  during the brief load window plus subtle text-link load-more buttons
  matching the existing Subscribe affordance (no Button chrome). Top
  uses a divider for boundary clarity; bottom shows
  "Jump to latest · N new" weighted slightly heavier when there's
  unread state.
- highlightCommentId now flows into the hook's around parameter so
  Inbox jumps fetch the surrounding 50 entries directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent): default comment list to 50 + prompt hint about long issues

The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.

The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:27:06 +08:00
Bohan Jiang
bf71802451 fix(server): trigger agent on reply in thread where agent already participated (#981)
When a member replies in a member-started thread without @mentioning the
assigned agent, the on_comment trigger was suppressed — even if the agent
had already replied in that thread. This meant the common flow of
"member posts → agent replies → member follows up" would not re-trigger
the agent on the follow-up.

Add HasAgentRepliedInThread SQL query and check it in isReplyToMemberThread
so that agent participation in a thread is treated as an ongoing conversation.
2026-04-14 18:00:29 +08:00
Jiayuan Zhang
b3f98ef95d fix(server): skip auto-comment when agent already posted during task (#712)
* fix(server): skip auto-comment when agent already posted during task

In CompleteTask(), check if the agent already posted a comment on the
issue since the task started. If so, skip the automatic output comment
to avoid duplicates. This preserves the fallback for agents that don't
post comments via CLI.

Closes MUL-609

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(server): use StartedAt instead of CreatedAt for duplicate check

CreatedAt is the enqueue time, not execution start. If a previous task
posted a comment between enqueue and start of the next task, it would
incorrectly suppress the auto-comment for the later task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:27:02 +08:00
Jiayuan
0bbc6bc1c5 feat(comments): add pagination support to comment list API and CLI
Add --limit, --offset, and --since flags to `multica issue comment list`
to prevent context window overflow when issues have many comments.

The API endpoint now accepts limit, offset, and since (RFC3339) query
parameters. When paginating, the response includes an X-Total-Count
header with the total number of comments.
2026-04-03 23:53:00 +08:00
Naiyuan Qing
9ede795c5b feat(api): strict workspace isolation + agent parity fixes
Enforce workspace isolation at every layer:

- Router: move RequireWorkspaceMember middleware to group level so ALL
  workspace-scoped routes (issues, agents, skills, runtimes, inbox,
  comments) require workspace context
- SQL: add GetXxxInWorkspace queries that filter by workspace_id,
  eliminating cross-workspace data access at the query level
- Handlers: loadXForUser functions use workspace-scoped queries,
  no fallback to unscoped queries
- Migration 025: add workspace_id column to comment table with backfill
- ListComments: add workspace_id filter for defense-in-depth

Fix daemon workspace mapping:
- Server returns workspace_id in task claim response (from issue)
- Daemon uses task.WorkspaceID directly instead of unreliable
  workspaceIDForRuntime() local map lookup
- Remove workspaceIDForRuntime function

Fix agent/human parity:
- Comment update/delete: use resolveActor for isAuthor check so agents
  can edit/delete their own comments
- Event attribution: replace hardcoded "member" with resolveActor in
  agent, skill, and subscriber publish calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:49:13 +08:00
Naiyuan Qing
e7fe6ea79b feat(activity): unified activity timeline with comment reply support
Replace the comment-only list with a Linear-style unified timeline that
interleaves field changes and comments chronologically.

Backend:
- activity_listeners.go: records field changes (status, assignee, description,
  task completed/failed) to activity_log table on domain events
- Timeline API: GET /api/issues/{id}/timeline merges activity_log + comments
  sorted by created_at
- Comment reply: parent_id column + handler support for threading

Frontend:
- Unified timeline replaces comment list: activity entries as compact muted
  lines, comments as Card components with reply threading
- Filter toggle (All / Comments / Activity)
- Reply UI: inline editor under comments with Cancel/Reply buttons
- Real-time sync for activity:created + comment events
- 10 new Go tests, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:53:08 +08:00
Jiayuan Zhang
1e61c1974c feat(server): implement full REST API with JWT auth and real-time WebSocket
- Add HTTP handlers for issues, comments, agents, workspaces, inbox, members, and activity
- Implement JWT authentication middleware with Bearer token validation
- Add sqlc queries for all entities (CRUD operations)
- Extract router into reusable NewRouter() for testability
- Expand SDK with full API client methods (CRUD for all resources)
- Add updateWorkspace to SDK, add Member type to shared types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 11:50:03 +08:00