Commit Graph
25 Commits
Author SHA1 Message Date
5ed09342af MUL-7680: issue wakeup v2 — conditions, runaway protection, check-ins and visibility (#8807)
* feat(wakeups): add rule deadlines and expose the child-done system rule (MUL-7680)

Wakeups can now end: an absolute deadline (expires_at) or a relative wait
(expires_in_seconds) that restarts when the rule is re-enabled. When the
deadline passes first, the scheduler ends the rule; event rules with
on_timeout=wake run the target once with a wakeup.timeout fact. A timed-out
rule keeps disabled_at NULL so its timeout run stays claimable.

The implicit "wake the parent's assignee when a stage of sub-issues
finishes" behavior is now described by GET /api/issues/{id}/system-wakeups
and can be turned off or given a supplementary instruction per issue via
PUT /api/issues/{id}/system-wakeups/child_done. Rule reads fail open to the
previous behavior.

List responses add the creator (member or agent) and the new expiry fields.
The CLI gains --expires-in, --expires-at and --on-timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): let members create wakeups and show the child-done system rule (MUL-7680)

The issue sidebar's Wakeups section is always available on open issues and
gains a New wakeup popover. Members pick a condition (a time, a recurring
check with an end date, someone's reply, an agent's run ending, or raw
events), the agent to wake, the instruction, how often it fires, how long
to wait and what happens on timeout. It posts the same configuration agents
create with the CLI.

The parent's child-done wake appears as a System rule with its stage,
remaining sub-issues and target, a per-issue toggle and a supplementary
instruction. Rows show when a rule ends, timed-out rules read as such, and
details name who created the rule. Rule titles wrap to two lines instead of
truncating.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document deadlines, member-created rules and the system rule (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): platform conditions, runaway protection and check-ins (MUL-7680)

Conditions: a wakeup can now wait for a fact the platform checks itself
(a status, assignee, label or property value; sub-issues or one stage
finishing; a linked PR's checks finishing or merging; another issue's
status). Related events only prompt an early evaluation; the rule wakes
its agent when the predicate becomes true, and a repeating rule fires
again only after it turned false or its facts changed. Finished PR checks
from before registration are ignored.

Runaway protection: repeating event rules stop after max_fires runs
(default 20), a rule pauses when its causal chain passes through it a
third time without a person in between, or when it starts 12 runs in an
hour. The reason is stored and shown.

Also: silent check-ins for every/cron checks (the run then posts no
fallback comment), wake now, delete, a per-rule run history, paused-rule
lists, timeline entries for created/triggered/timed-out/paused/check-in,
and source, paused scope, 7-day runs and child-done system rows in the
workspace list. The CLI gains --until-* conditions, --max-fires and the
trigger/delete/checkin/runs commands; the brief and wakeup prompt state
the check-in exception.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): record child-done transitions in the status write (MUL-7680)

The child-done system rule ran after the status write committed and gave
up on any error, so a crash, deploy or transient failure lost the
parent's wake. A trigger now records each child's move into a closed
status in the writing transaction, for every writer. The request that
wrote it processes the rows right away and a scheduler sweep retries
anything left over; claims make the two paths process a transition
once.

The rule also follows a workspace default (settings key
system_wakeup_child_done) until an issue sets its own, the per-issue
update accepts partial bodies, and each trigger adds a timeline entry
that names its system comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): wakeup conditions, history, timeline and workspace list (MUL-7680)

Issue sidebar: members can create the platform-checked conditions (a field
reaching a value, sub-issues or a stage finishing, a linked PR's CI or merge,
another issue's status) and cap how often a repeating wait fires. Rule
details show the fire count, why the platform paused a rule, the latest runs
with silent-check notes, and offer wake now and delete.

The issue header says what the issue is waiting for ("Emacs 在等 Jiayuan 回复
+2") and opens the Wakeups section. Board and list cards use the same short
sentence, or flag a paused rule. Wakeup runs read as "由唤醒触发 · <condition>"
in the execution log, transcript and on the comments they post.

The timeline shows created, triggered, timed-out, paused and check-in entries
with the rule they came from; consecutive check-ins merge, and the child-done
entry stands in for its system comment, which it can reveal.

Automation → Issue wakeups adds a source column and filter, 7-day runs, a
paused scope with a banner, the child-done system rule on each waiting parent,
and "New wakeup" with an issue picker. Settings → Issue statuses sets the
system rule's workspace default. Copy in all five languages.

The server records the watched issue's identifier in other-issue conditions
and the rule's creator in timeline snapshots, and returns board summaries with
their condition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document conditions, runaway protection and check-ins (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): list system rules without a revision and localize statuses (MUL-7680)

System rule rows returned revision 0, which clients reject as a rule
revision; they now return null, and a list row with an invalid revision no
longer fails the whole page. Built-in statuses in conditions and the status
picker read in the viewer's language. Adds a browser test for a condition
created from the form, the header summary, the scheduler waking the agent
and the workspace list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): say why a rule paused without repeating "paused" (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(i18n): use the product's task terms in wakeup copy (MUL-7680)

ja, ko and fr wakeup strings said イシュー / 이슈 / ticket; the product term
for an issue in those languages is タスク / 태스크 / tâche (and sub-tasks
accordingly), per the conventions page. French strings are rewritten for
the feminine noun.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(views): pin the shortcut platform in the wakeup form test (MUL-7680)

The send shortcut is primary+Enter, which is Ctrl on Linux CI runners, so
pressing Cmd+Enter only submitted on macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): simplify the workspace wakeup list

Put the filters on one row: scope as a segmented control on the left,
source/trigger/agent and a search-as-you-type box on the right. "New
wakeup" moves to the page header, which drops the separate Search button.

Rows are single-line: the identifier sits before the title, frequency and
timezone move into the trigger tooltip, and selection, prompt edit and
the last run's transcript show on row hover or focus. The issue column
takes the remaining width and truncates, so the table fits the page.

Also fix creating from the list: the issue picker closes itself right
after reporting the selection, and that close cancelled the flow before
the form could open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(server): run the child_done rule as a system issue wakeup

The parent-assignee wake on closed sub-issue stages was its own code path:
a system comment, a mention-style run, an override table and endpoints.
It is now an ordinary issue_wakeup row with system_rule set, sharing the
condition, receipts, runaway protection, timeline entries and run model of
people's rules.

- One children_done evaluator: a stage wakes the assignee when it closes
  while a later stage waits; the wrap-up waits for every sub-issue,
  unstaged ones included. People's sub-issue conditions read the same set.
- issue_child_event records closing, reopening, re-parenting and restaging
  for every writer; the request processes it right after commit and the
  sweep retries. User sub-issue rules become immediate too.
- The target is resolved when it fires: an agent run, a squad leader run,
  an inbox notification for a member, or only the timeline entry.
- A parked (backlog) parent catches up when it leaves backlog; a waiting
  run of the same agent is joined instead of queuing a second one; the
  hourly limit pauses a runaway rule. No system comment is posted.
- The run gets the issue's instruction, else the workspace's, else the
  built-in one, plus each stage's counts and the next stage.
- Workspace defaults move to GET/PUT /api/system-wakeups (owners and
  admins) and apply to every rule nobody customized; turning a rule on
  records what already holds so old facts do not fire.
- Existing open parents get their rule from a one-time backfill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): workspace wakeup settings and sub-issue notifications

- Settings → Wakeups holds the sub-issue rule's workspace default and
  default instruction; the toggle leaves Issue statuses.
- The issue's system rule shows who it reaches (a member is notified),
  a platform pause, and the instruction it will use.
- Timeline entries say whether the assignee was woken, notified or joined
  a waiting run. The folding of the old system comment is gone.
- Inbox renders the new children_done notification on web, desktop and
  mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): stop wakeups from repeating their agent's runs

Runs are serialized per issue and agent, so a wakeup never ran beside
another run of its agent, but it queued behind one and repeated it. Each
rule now checks two things before it starts a run:

- Acknowledged: every input came from the agent itself. Its own comments
  and issue changes never wake it, and a condition its own unfinished run
  on the issue satisfied does not wake it when the platform or the agent
  set the rule up. This fixes #8849: a coordinator closing its own stage is
  not woken again while that run is going. A person's condition rule still
  runs afterwards, since the running agent lacks its instruction.
- Merged: when a run of the agent is already waiting to start on the issue
  (assignment, comment, another rule), the rule's instruction and facts
  join that run (context.wakeup_joined, sent to the daemon as
  wakeup_joined and rendered for every prompt kind) instead of queuing
  another. Turning the rule off or replacing it withdraws what it joined.

Sub-issue changes now record the run that made them, and hints and
condition.met inputs carry those runs so a satisfied condition knows
whether the agent caused it. A rule that names the agent itself as the
actor keeps waking it on its own changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): show merged and acknowledged wakeups, hint assignee comment rules

- Timeline entries say when a wakeup joined the agent's waiting run or was
  the agent's own doing, for people's rules and the sub-issue rule.
- The create form tells a member that a reply or comment rule for the
  issue's agent assignee joins the run a comment already starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): hand waiting wakeups to a run when it is claimed

A rule that fired while its agent had a run waiting used to write its
instruction into that run right away and consume its inputs. That let a
member's rule run under another member's identity, kept deleted rules'
instructions in the run, lost the inputs when the run was cancelled or
claimed by an older daemon, and skipped the fire cap and loop check.

Now the rule only waits for a run that runs as the same person its own
run would, keeping its inputs. When a daemon advertising
joined-wakeups-v1 claims that run, JoinWaitingWakeups rechecks each rule
(on, same agent and person, creator still allowed, not the agent's own
input, no loop), consumes its inputs, counts the fire toward max_fires,
adds its chain and records the merge. A re-claim drops entries of rules
turned off or changed since. Anything else leaves the rule its inputs to
start its own run.

A child_done rule created by the backfill or by processing a change now
treats sub-issues with unprocessed close events as still open, so the
backfill no longer swallows a close waiting for the sweep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): consume joined wakeup inputs only once the run starts

A claim used to consume the inputs of the rules that joined the run. If
that claim did not go through, the run went back to the queue carrying
inputs the rules no longer had: turning the sub-issue rule off on the
issue or for the workspace (which only changes enabled) did not remove
them from the next claim, and cancelling the run lost a once rule that
had already ended.

A claim now reserves the inputs for the run (receipt task_id, still
unprocessed). The rule's next dispatch settles them: a run that started
consumes them as a merged firing (once ends, max_fires counts and can
pause, timeline entry); a run that ended without starting gives them
back; one that has not started keeps them. Turning a rule off or
changing it discards its pending inputs, reserved ones included, and a
later claim of the same run rechecks every entry and drops the ones
without reserved inputs or no longer allowed to reach the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 21:08:54 +08:00
9df0cae78a MUL-7429: complete issues when every linked PR merges (#8758)
* feat(pr): complete issues when every linked PR merges (MUL-7429)

Title/branch identifiers link a PR; keywords and the body no longer matter.
Completion is evaluated only on a PR event for the issue (merge, link,
unlink), so settings changes and reopening never complete an issue.
Adds manual link/unlink with remembered exclusions and a per-issue
auto-complete switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(pr): issue-page PR automation and single auto-complete setting (MUL-7429)

The issue's Pull requests section says what the merge rule will do next,
links a PR by URL, removes one per row, and turns auto-complete off for
that issue. The workspace switch lives under Issue statuses; the GitHub
page reports it. Agent skill and docs describe the title/branch rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(pr): accept PR links pasted without a scheme (MUL-7429)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(skills): pin the PR auto-complete contract in the platform skill (MUL-7429)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(pr): re-check linked PRs at the completion write; skip stale GitHub events (MUL-7429)

The status write now requires every linked PR to still be merged when it
runs, so a PR linked between the decision and the write keeps the issue
open. GitHub PR upserts ignore events older than the stored row, like the
self-hosted path, so a late delivery cannot roll a merged PR back to open
and turn a redelivered merge into a new one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-24 02:58:19 +08:00
93ae552ba9 feat: add messages to active Claude Code and Codex runs (MUL-7565) (#8682)
* feat: add live run supplements (MUL-7565)

Co-authored-by: multica-agent <[email protected]>

* fix: address MUL-7565 review feedback

Co-authored-by: multica-agent <[email protected]>

* fix: resolve MUL-7565 supplement panel and receipt review

Co-authored-by: multica-agent <[email protected]>

* refactor: remove MUL-7565 supplement ordinals

Co-authored-by: multica-agent <[email protected]>

* fix: close supplement composer after run remount

* fix: exclude task supplements from completion reconciliation

* fix: build a persistent binary for local daemon launches

* feat: support Claude task supplements through SDK hooks

* refactor: simplify task supplement delivery

* test: remove redundant task supplement coverage

* fix: remove redundant supplement delivery notices

* refactor: extract local daemon launcher fix to its own PR

* fix: preserve ordinary comment size compatibility

* fix: accept empty task start acknowledgements

* fix: bound task start response decoding and retries

---------

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: yushen <[email protected]>
2026-09-23 16:10:07 +08:00
Multica Eveandmultica-agent 8c9f865e3e MUL-7480: Revert comment steering feature (#8648)
* Revert "MUL-7480: Link delivered steers to final replies (#8623)"

This reverts commit 3de6275eb3.

Co-authored-by: multica-agent <[email protected]>

* Revert "fix: preserve active task intent in steer frames (#8621)"

This reverts commit b866dacd1c.

Co-authored-by: multica-agent <[email protected]>

* Revert "MUL-7480: Steer active agent turns from comments (#8605)"

This reverts commit 8ce795b007.

Co-authored-by: multica-agent <[email protected]>

* chore(db): remove reverted comment steer schema

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
2026-09-21 18:29:50 +08:00
c375f83c2e MUL-7418: add issue event and time wakeups (#8467)
* feat: add issue event and time wakeups (MUL-7418)

Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): capture issue collaboration event lifecycle

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): preserve reply source when reopening threads

Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): summarize sidebar and issue activity cues

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): restore disabled configurations with a two-way toggle

Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): manage workspace wakeups in Autopilot

Co-authored-by: multica-agent <[email protected]>

* chore(wakeups): reserve migrations after current main

Co-authored-by: multica-agent <[email protected]>

* test(wakeups): align inventory fixtures with shared contracts

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): distinguish trigger conditions and execution results

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): suppress registration feedback and drain bounded evidence

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): align sidebar actions with titles

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): bound database work and unify shared issue visibility

Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): filter mutation events by member or agent actor

Co-authored-by: multica-agent <[email protected]>

* refactor(wakeups): merge structured evidence and narrow receipt locks

Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): let people edit wakeup instructions

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): align inventory controls and compact prompt editing

Co-authored-by: multica-agent <[email protected]>

* fix(mobile): handle deferred agent run status

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-21 04:17:12 +08:00
8ce795b007 MUL-7480: Steer active agent turns from comments (#8605)
* feat: steer active agent turns from comments (MUL-7480)

Co-authored-by: multica-agent <[email protected]>

* fix: make comment steering hint driven

Co-authored-by: multica-agent <[email protected]>

* fix: harden active comment steering races

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-20 19:00:53 +08:00
Bohan Jiangandmultica-agent 1df4e0a8bf MUL-7477 fix(telegram): one reply per turn across replicas, restarts and retries (#8545)
* feat(db): record who owns a channel reply and what the provider accepted

A task's reply reaches a chat through three code paths — the streamed
placeholder, the final answer, the failure notice — and across replicas
through three processes: the daemon's transcript report and its completion
callback are independent HTTP requests, and the event bus is in-process. There
is nowhere for them to agree on whether a reply has already been sent.

channel_reply_delivery is that place: one row per task, carrying the phase the
reply is in, whether a send is out, and which message id it produced.

No foreign keys, and each index builds CONCURRENTLY in its own migration.
Workspace teardown deletes the rows alongside channel_outbound_message.

Co-authored-by: multica-agent <[email protected]>

* fix(telegram): deliver one reply per turn, across replicas and restarts

Users see every reply twice (#8049, #7750). #8117 closed the window where a
chat:done landed while the placeholder's sendMessage was still in flight, but
the reply's owner was an in-process map, so the duplicate simply came through
another door — most often the one #8117 could not reach at all: on the cloud
deployment the text report and the completion callback land on different
replicas, and the replica that cannot see the placeholder posts its own copy
of the same answer.

Every path now claims channel_reply_delivery before touching Telegram:

- The placeholder send is claimed in the database, so exactly one process
  opens a message and every other path edits it — including a process that
  never saw the stream.
- A send whose response was lost is recorded as unknown rather than absent.
  sendMessage takes no caller-supplied idempotency key, so re-sending cannot
  be deduplicated by the provider: delivery stops and the row says why.
- Terminal delivery claims from any phase but settled, so a second completion
  event is dropped while an interrupted delivery resumes from the chunk it
  reached. Chunk progress is written after each part, not after the last.
- Edit failures are classified instead of falling back to a fresh send:
  markup is retried as plain text, an ambiguous failure is retried on the same
  message under a budget, a permanently rejected edit settles the reply rather
  than spinning (which held the session's queue and blocked later answers),
  and only a target Telegram confirms is gone justifies a new message.
- The failure notice goes through the same ownership and the same
  classification, so it replaces the placeholder instead of appearing beside it.
- Cancelled and empty completions close the reply, so a text frame still in
  flight cannot reopen it.
- An automatic retry inherits the placeholder its previous attempt left behind
  instead of answering beside it. Scoped to a parked row, so two ordinary
  turns that happen to say the same thing still get one reply each.

The regression tests are the audit probes written against this bug: 12 of the
14 failed on main, and the two that passed are controls that must keep
passing — a normal streamed reply, and two separate turns with identical text.

Co-authored-by: multica-agent <[email protected]>

* refactor(db): key reply delivery by user turn and guard it with a lease

Review of the first version found the state model, not the implementation, to
be the problem. Three invariants were missing and are now in the schema:

- A placeholder is not progress. Recording the streamed message used to bump
  chunks_sent, which terminal delivery then read as "the first part of the
  final answer is already out" — so the answer was never written. The columns
  are now independent.
- One turn, one owner. owner_token / owner_expires_at are a lease: a path
  proves it holds the turn before calling the provider and proves it still
  holds it when recording the outcome. An atomic UPDATE was never the same
  thing as a single delivery, and two processes could both claim a reply.
- Ownership follows the real retry lineage. The row is keyed by the root of
  the agent_task_queue retry_of_task_id chain instead of "the most recently
  parked reply on this binding", which let an ordinary new request adopt an
  unrelated one and left the previous attempt able to reopen its own.

An expired lease frees a turn whose owner died, but never resets an
outstanding send to "not sent": there is no idempotency key to make that safe.

Co-authored-by: multica-agent <[email protected]>

* fix(telegram): one reply per turn across replicas, restarts and retries

Follow-up to the review of the first attempt, which shipped a broken success
path: its in-memory stand-in for the ownership table disagreed with the SQL
about one column, and the whole suite passed while the final answer never
reached Telegram. The tests no longer model that state machine — every
ownership query in this package now runs for real against Postgres.

Beyond the truncation, the review found seven ways a reply could still arrive
twice, be overwritten, or block the session behind it. All are covered here:

- Terminal delivery is exclusive. Two processes could both take a reply and
  both send it; taking the turn is now a lease, re-proven before every call.
- Chunk sends get the same published-before, recorded-after treatment the
  placeholder had, so an interrupted multi-part answer resumes after the last
  part instead of repeating it, and a lost response ends delivery rather than
  risking a second copy.
- A send left in flight by a process that died no longer blocks its session
  forever: the lease expires, the successor inherits it as unknown, and the
  turn settles instead of waiting on an owner that will never return.
- A streaming edit re-proves the turn immediately before it edits. Guarding
  only the first send left a stalled frame able to overwrite a final answer
  another replica had already published.
- Cancelled and empty completions create the closing record when the turn
  never reached Telegram at all, so a first frame arriving afterwards cannot
  open a placeholder nothing will finish.
- The failure notice honours an in-flight or unknown send instead of posting a
  second bubble about a turn the user may already have been answered about.
- Delivery without ownership is gone. It was written up as an accepted risk;
  it was a default duplicate path. A reply that cannot be owned is reported as
  a failure, on a budget that does not hold up the session.

Both suites — the delivery contract and the review's boundary cases — run by
default, and against real SQL, because that is what the fake hid.

Co-authored-by: multica-agent <[email protected]>

* refactor(db): order reply attempts and index the paths that delete them

Keying reply ownership by user turn stopped a retry from opening a second
answer, but left every attempt of that turn equal: a late frame from an
attempt the chain had already moved past could take the turn back and rewrite
what the user was reading with content from a superseded run. attempt_depth
records how far down the retry chain the holder sits, and the claim only ever
moves forward.

The table is also deleted by installation and by binding — workspace teardown,
a re-pointed installation, a retired chat-session binding — and had an index
on neither, so each of those was a full scan that grows with every turn. Both
are indexed now, and the three teardown paths that clean channel_outbound_
message clean this table alongside it, which the no-foreign-key rule requires.

Co-authored-by: multica-agent <[email protected]>

* fix(telegram): close the remaining duplicate and lost-notice paths

Review of the lease design found five more ways a reply could go wrong, each
fixed with a test that fails without it:

- A 5xx was treated as proof that nothing was posted. Telegram can accept a
  sendMessage and fail on the way back; the reply then went out twice. Only a
  4xx is evidence of a message that never landed.
- A failed retry-lineage lookup fell back to "this task is its own turn",
  which opens a second reply beside the one the previous attempt is holding.
  It is reported now, and delivery waits or stops rather than guessing.
- An attempt the retry chain had moved past could re-acquire its turn and
  overwrite the visible answer with its own stale tail.
- A reply settled by another replica left this process holding the stream and
  its chat-scheduler reference with nothing left to release them. Enough of
  those and the scheduler refuses new chats, so streaming stops for chats with
  nothing wrong with them. The next frame releases them, and an idle sweep
  covers turns where no next frame ever comes.
- The failure notice was posted inline and simply dropped whenever another
  path held the turn at that instant — likely, since that is exactly when a
  text frame is mid-request on another replica — leaving the placeholder as
  the last thing the user saw. It is terminal work now, so it waits for the
  turn like the answer does.

The notice moving to the queue also takes the last Telegram call off the
synchronous event bus.

Co-authored-by: multica-agent <[email protected]>

* fix(db): upgrade installed databases to attempt_depth, and guard the close path

attempt_depth was added by editing migration 500, which every database that
already installed this feature has recorded. Those databases never saw the
change, so every query touching the column failed on exactly the deployments
where the table already existed — and rebuilding from scratch passed, which is
why it went unnoticed. It moves to its own forward migration, written so a
database created by 500 and one upgraded through 504 end up identical, with a
test that runs the upgrade against the old table shape rather than a fresh one.

The close path also skipped the ordering the claim enforces. A cancellation or
empty completion from an attempt the retry chain had moved past could settle
the whole turn, and the retry's answer was then dropped as already delivered —
the user gets nothing. It now follows the same one-way rule, and a close that
belongs to a superseded attempt is a no-op rather than something to wait for.

Co-authored-by: multica-agent <[email protected]>

* fix(telegram): bound the notice to one call per step, and sweep on evidence

Two paths still escaped the ownership model:

The failure notice looped inside a single step — up to two edit attempts, and
a 429 wait taken inline. A step can then outlast the lease by minutes, so the
call after the wait lands on a turn another replica has taken over and may
already have finished. It is now shaped like the answer's delivery: at most
one Telegram call, hand back a retryAt, and let the next step re-prove the
turn first.

The stale-stream sweep treated thirty quiet minutes as abandoned. A run's
silence budget is hours — a task can sit in a tool call or a test run far
longer than any interval worth picking — so the sweep was free to reclaim a
live reply, after which the next frame would rebuild the stream with an empty
accumulator and edit the existing message down to just that fragment. Quiet
now only makes a stream a candidate; what releases it is the delivery row
saying the turn is settled or that a later attempt holds it.

Co-authored-by: multica-agent <[email protected]>

* chore(db): renumber the reply-delivery migrations above main's

main landed 500 and 501 while this branch was in review, so the numeric
prefixes collided on the merge ref and TestMigrationNumericPrefixesAreUnique
failed in CI. These move to 502-506.

Renumbering is safe for a database that already applied the old numbers: every
file here is idempotent — CREATE TABLE IF NOT EXISTS, CREATE INDEX
CONCURRENTLY IF NOT EXISTS, ADD COLUMN IF NOT EXISTS — so re-running them
under the new versions is a no-op that only records the new rows. Verified
against the local database, which had the old numbering applied.

Co-authored-by: multica-agent <[email protected]>

* fix(telegram): keep a provider call inside the lease that authorises it

The lease is only meaningful if a call made under it cannot outlive it. The
shared Bot API client allows 65s — which getUpdates needs for long polling —
against a 30s lease, and the turn was re-proven before waiting for the chat's
scheduler lock rather than after. A slow call could therefore still be talking
to Telegram after another process had taken the turn over and settled it, and
its result would then be fenced off: the reply is lost rather than retried.

Delivery calls now get their own budget, sized so one call plus recording its
outcome fits inside the lease, and every path re-proves the turn immediately
before the call rather than merely at the top of the step. A test asserts the
budget relationship so changing one constant cannot quietly reopen the gap.

Reaching the bad outcome needed a duplicate terminal event, which the task
completion path does not currently produce, so this was assessed as hardening
rather than a live defect. It is cheap enough to make hold by construction.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-09-20 18:12:39 +08:00
YYClaw 9e2d9b6d1c MUL-6661 feat(channels): add /new and /clear conversation controls (#7468)
* feat(channels): add new and clear conversation controls

* fix(dingtalk): resolve bot identity before dispatch

* feat(chat): expose channel-created chats consistently

* refactor(channels): snapshot task delivery routes
2026-08-25 18:28:01 +08:00
Jiayuan Zhangandmultica-agent 5bf10d485c MUL-6582: add durable scheduled Plugin hooks (#7448)
* MUL-6582 feat(plugins): add durable scheduled hooks

Co-authored-by: multica-agent <[email protected]>

* MUL-6582: add schedule-pulse example plugin

Ship a real scheduled plugin that records each unique delivery_id in
workspace storage, so a host retry does not look like a second pulse.
The cmd/server test installs this example manifest and drives two
scheduler replicas through a dropped first attempt.

Co-authored-by: multica-agent <[email protected]>

* MUL-6582 fix(migrations): make schedule indexes retry-safe

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:37:21 +08:00
1cc46b2697 MUL-6516 fix(channels): enforce durable context boundaries for /new (#7362)
* fix(channels): enforce new context boundaries

* fix(channels): address context boundary review findings

* fix(channels): enforce causal new context boundaries

* fix(channels): scope outbound provenance to target

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-22 11:27:39 +08:00
f45b591623 MUL-6469: host plugin artifacts, bind installs to immutable versions (#7321)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden surface validation and error reporting

* fix(migrations): renumber plugin package migrations

* fix(plugins): validate classic surfaces and reset failures

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:03:44 +08:00
YYClaw 5e2d349ec4 MUL-6426: refactor(dingtalk): safely retire group routing and enhance discovery (#7241)
Retire mutable DingTalk group routing: inbound messages always target the installation's configured Agent, and observed groups become read-only inventory in dedicated tables with persisted activity counters.

Migrations 382-389 clean stale cross-Agent bindings, create and backfill dingtalk_group_presence and dingtalk_bot_identity, and install a mirror trigger so writes from an older process during a rollback window are not lost. The legacy dingtalk_group_route table and its indexes are retained for expand/contract; a later release drops the trigger and table together.

Closes #7239
2026-08-21 14:26:48 +08:00
659bb41abd MUL-6350: rebuild the plugin system (3/4) — hook engine (#7182)
* feat(plugins): hook engine — signed outbound calls, trigger-decided identity (MUL-6350)

The direction reverses here. PR 1 and 2 only ever ran inbound: a sandboxed
surface asked the host, and the host acted on the signed-in user's session, so
nothing left our infrastructure on a plugin's say-so. A hook leaves, and every
new check exists because of that.

Destination: the target host must be inside the granted `net:` set, resolve to
a public address, and be re-checked at dial. Body: HMAC-SHA256 over
timestamp.body, so a receiver can tell our call from anyone else's and a
captured one expires.

Identity follows the trigger, not the plugin. A ui/manual hook runs because a
person pressed something, so its writes stay theirs; an event hook has nobody
behind it and writes as the installation — hence the fourth author_type.

Two credentials, moving opposite ways, which is what decides how each is
stored. The install token goes plugin -> host and is only ever verified, so it
is hashed. The signing secret goes host -> plugin and must be reproduced to
sign with, so it is derived from the deployment key and stored nowhere.

Event hooks never block the host: Bus.Publish runs listeners inline on the
publishing request's goroutine, so the dispatcher hands off to a worker pool
immediately. Three attempts, then a circuit breaker, then silence.

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): manual hook actions, ui trigger, and a worked hook example (MUL-6350)

The host owns the manual menu entry rather than letting a surface draw one,
because the trigger decides identity: a manual call acts as the person who
picked it, so the entry has to live where the host can prove somebody did.

The ui trigger goes through the bridge for the same reason a surface cannot
fetch its own backend directly — routing through the host is what makes the
call signed, rate limited, bounded to the granted net: domains and recorded.

buildActorNameResolver gains a plugin branch. Without it an event-written
comment rendered as "System", which is the one attribution that is actively
misleading: it reads as the platform having said something.

examples/plugins/triage-notify carries the half that does NOT run in Multica —
a reference handler showing the four things a receiver must do, including the
replay guard the host cannot do for it.

TestShippedExamplesParseAndInstallOnThisHost closes the gap that let an example
drift: nothing checked that shipped documentation still parses, or that it
declares only what this build can run.

TestCheckCapabilitiesReportsEveryUnavailableContribution rewritten the way the
other two were in PR 2 — it named today's unshipped set, so flipping ui/manual/
event on turned it red with a message about the flip rather than about the gate.
It now derives its expectation from HostCapabilities and survives a flip while
still failing if the gate stops checking a category.

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): modal surface, invocation cleanup, honest capability guards (MUL-6350)

TestShippedHostCapabilitiesRunTheSurfacesTheHostMounts caught me flipping the
modal surface on without a renderer — an install that succeeds and then never
appears, which is the exact failure that guard exists to prevent. Built the
mount point rather than weakening the guard, and rewrote it to name the host
code behind each line so the next flip has to change both together.

plugin_invocation was missing from workspace teardown and from uninstall.
TestWorkspaceDeletionManifestCoversPublicSchema caught the first; the second
would have left rows naming an installation that no longer exists. Deleted by
workspace_id rather than through installation ids, so a record whose plugin was
already uninstalled does not outlive the workspace it describes.

A modal opens because a person picked it from the issue menu, never on the
plugin's own initiative — same sandbox as a panel, differing only in where it
appears.

Co-authored-by: multica-agent <[email protected]>

* test(plugins): prove the event path never blocks, and contain worker panics (MUL-6350)

Writing the never-blocks test found a real bug rather than confirming one.
Bus.Publish recovers panics in its listeners so one bad handler cannot kill the
request that published; moving delivery onto a worker goroutine stepped outside
that protection, where a panic is not a failed hook but a dead process.
runGuarded restores the guarantee the hand-off gave up.

The bridge now takes an EventSink rather than the concrete dispatcher, so the
vocabulary mapping — which internal event becomes which published one — is
asserted directly instead of through a live endpoint. That mapping is a
published contract; the internal names are not.

The backpressure test stops the workers rather than racing them, so it
reproduces a saturated pool without depending on timing, and asserts the exact
overflow instead of "something was dropped".

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): two hook-contract bugs a live run found (MUL-6350)

Both were invisible to the tests because the tests made one call each, and a
real handler does not.

The callback token was single-use. The reference handler reads the issue,
decides, then posts a comment — and the second call died on a spent token.
Read-then-write is the floor for a handler that does anything with what it
read, so a single-use grant made the callback unusable.

The trade that decides it is not one call versus several. It is this token
versus the installation's standing token: a handler that cannot finish with the
callback gets written against the install token instead, which never expires
and is not scoped to an invocation. A control that pushes authors toward the
stronger credential is worse than the looser one they will actually use. The
grant is now invocation-scoped — minutes, this installation's scopes, the actor
fixed at dispatch — and revoked as soon as the call it was issued for returns.

The hook body also omitted the issue. The host resolves and permission-checks
it, then said nothing, so a handler had to read a client-supplied field —
unvalidated for ui/manual, and absent entirely for event, where there is no
client. issue_id now travels in the body.

Also adds MULTICA_PLUGIN_DEV_CA: a hook URL must be HTTPS, so an author testing
against localhost needs its certificate trusted by something. A named CA file
rather than InsecureSkipVerify, which survives into production behind a config
flag nobody remembers and cannot be spotted by reading the code.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): enforce the callback token's issue scope, sweep invocations (MUL-6350)

Addresses Fabel 5's review. The first item is the one that mattered.

The callback grant's issue narrowing existed only in two doc comments and the
PR description — pluginTokenCaller never read grant.IssueID, so a ui/manual
token was worth every issue in the workspace its actor could see, for the whole
five minutes it lived, on read and write alike. Now carried into the caller and
checked in pluginIssueForUser, after resolution so an identifier and a uuid for
the same issue agree. 404 rather than 403: "you are scoped elsewhere" confirms
the id exists.

DeleteExpiredPluginInvocations was generated and never called, while the table
comment and the PR both said TTL-swept. A sweeper now runs on the dispatcher's
lifecycle — same concern, bounded resources for something a third party drives.

Event subscriptions bypassed the read scopes. issue.* pushes the description
and comment.created pushes the body — the same content the Action API refuses
without issues:read / comments:read. Subscribing was a way to receive what
reading was not granted. Enforced at manifest validation so the consent screen
shows the read scope a subscription implies.

Migration 349's ADD CONSTRAINT copied 107's shape onto a table that is no
longer 107's size: it revalidated every comment row under ACCESS EXCLUSIVE.
NOT VALID plus a 352 that VALIDATEs under SHARE UPDATE EXCLUSIVE costs nothing
and stops nothing.

Plus the half-built breaker UI (a plugin could be enabled and silently
delivering nothing), redirect refusal on the dev hook client (a 302 would have
replayed the signed body and the callback token onward), and omitting
callback_url rather than sending a relative one.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): the invocation sweeper must not touch the database at construction (MUL-6350)

CI panicked in cmd/server: the sweeper I added last round fired immediately on
its own goroutine, and that test builds a router over a Queries whose pool was
never opened. pgxpool dereferenced the nil pool and the process died.

Two things were wrong, and only one of them is the obvious one.

The sweep no longer runs at construction. It waited for nothing and gained one
hour of retention on a cold start against a 7-day TTL; the cost was a goroutine
reaching for the database before the process is known to have one.

The nil check was also the wrong instrument. sqlc's Queries wraps an executor,
so a non-nil Queries over an unopened pool passes every comparison available at
that layer and only fails inside pgxpool — no amount of nil-checking here can
see it. The guard is a recover, for the same reason runGuarded exists: on a bare
goroutine a panic is not a failed sweep, it is a dead server. I had already
fixed that class once for delivery and then reintroduced it in the sweeper.

The regression test builds the exact shape CI hit — a dispatcher over db.New(nil)
— and fails if either the construction-time sweep or the recover comes back.
cmd/server cannot reproduce it locally, where DATABASE_URL supplies a real pool.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate the event dispatch path behind plugins_v1 (MUL-6350)

Answering "is this still behind the flag" turned up the one place it was not.

Every other plugin surface is refused by a handler that reads the flag off a
request. An event hook has no request: the dispatcher is subscribed to the bus
at startup and delivers on a worker. Nothing in it consulted the flag, so
turning plugins off hid the UI and refused the Action API while the OUTBOUND
calls kept going — fail-open on the one path that reaches a third party.

It only bites after a plugin has been installed, because installation is
flag-gated, so a deployment that never enabled plugins was never exposed. A
workspace that enabled it, installed something, then turned it off was.

The check reads the flag per delivery rather than once at subscription, so the
flip takes effect immediately instead of at the next restart. It also removes
the flag-off cost: every dispatched event used to run a
ListWorkspacePluginInstallations query to discover there was nothing to call.

Nil flags read as disabled — a deployment that never wired the flag service must
not get outbound hooks by omission.

The test lives in internal/handler because it needs a real installed plugin to
mean anything: with the flag on the endpoint is called, with it off nothing
leaves while the same installation stays enabled and still declares the hook.
Removing the check makes it fail on the second half.

Co-authored-by: multica-agent <[email protected]>

* perf(plugins): stop parsing event payloads on the publishing goroutine (MUL-6350)

Found while answering whether merging can affect existing users, and the answer
was "in one measurable way it can".

The bus listener passed issueIDFromPayload(e.Payload) as an argument, so a JSON
marshal plus unmarshal of the full event payload — for issue.created that is the
whole issue body, description included — ran inline on the goroutine of whatever
request published. Seven event types, every workspace, and the feature flag was
checked far downstream on the worker, which then threw the result away. A
deployment with plugins switched off paid for all of it.

The listener now hands the payload over unexamined and the id is extracted on
the worker, past the flag check. It also fits the rule this file already
claimed: the publishing request does the least it possibly can.

Not moved: the status_changed lookup, which is a map read, not a parse.

TestPublishingDoesNotParseThePayload counts MarshalJSON calls during Publish and
fails if any happen — restoring the old argument makes it fail with the count.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:29:15 +08:00
3460f9aeaf MUL-6342: enforce entitlement-backed autopilot quotas (#7194)
* feat(autopilot): enforce entitlement run quotas

Co-authored-by: multica-agent <[email protected]>

* chore(deploy): expose entitlement policy config (#7195)

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): address quota review findings

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): finish quota review nits

Co-authored-by: multica-agent <[email protected]>

* refactor(autopilot): simplify quota schema

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:51:26 +08:00
6a17c00144 MUL-6350: rebuild the plugin system (1/4) — cleanup and foundations (#7144)
* MUL-6350 refactor(plugins): reset the plugin system to Action / Hook / Resource foundations

The V1 plugin system shipped 14 tables, an append-only revision log per
relationship, a capability-snapshot compiler, a per-task execution manifest
pinned by an INSERT trigger on agent_task_queue, release signing, trust tiers,
and a bundled catalog — and never left its feature flag. It holds no production
data, so this removes it outright instead of migrating it forward.

What replaces it, per the design in MUL-6338:

- A plugin relates to Multica in exactly three ways. Action (plugin calls
  Multica), Hook (Multica calls plugin), Resource (a static contribution with no
  call at all). "Who triggers" and "what capability is called" are orthogonal:
  a hook is declared once and lists which triggers may invoke it.
- We never execute third-party code. A plugin runs only in a sandboxed iframe in
  the user's browser or on the author's own server.
- Distribution is "install by URL, admin reads the scopes". No signature, no
  trust tier, no publisher verification — the scope list IS the trust decision.

This change is the foundation, with no user-visible surface yet:

- Decouples remote MCP first. RemoteMCPConnection/RemoteMCPTool/DigestBytes move
  into pkg/remotemcp, where they belonged; the protocol layer and daemon broker
  survive intact and dormant, ready to be rewired for the agent trigger.
- Drops all 14 plugin_* tables, their triggers, the manifest-composition
  function, and agent_task_queue.plugin_execution_manifest_id. Adds three:
  plugin_installation (one row per workspace+plugin, holding the manifest
  snapshot the admin consented to), plugin_storage, plugin_secret.
- Defines the manifest schema in full, once — surfaces, hooks with their
  triggers and events, resources, and a closed scope list. Parsing stays strict
  (DisallowUnknownFields, no trailing JSON). Contribution kinds this build does
  not ship yet fail installation loudly rather than installing half-working.
- Two-step install: preview parses and returns the scopes without writing, then
  install requires consent matching the manifest exactly. Manifest fetches reuse
  the remote MCP SSRF guard; MULTICA_PLUGIN_DIR adds a local source for
  self-hosting and plugin authors.
- Storage quotas are enforced on write and never by eviction. Secrets live in a
  separate encrypted table with no read path that returns a value.

Co-authored-by: multica-agent <[email protected]>

* MUL-6350 fix(migrate): register the plugin index builds for invalid-index cleanup

Every migration that builds an index CONCURRENTLY must be registered in
concurrentIndexCleanups, or an interrupted build leaves a permanently INVALID
index that the `IF NOT EXISTS` retry records as success. TestEveryConcurrentUp\
BuildHasCleanup caught all three new plugin index migrations.

Co-authored-by: multica-agent <[email protected]>

* MUL-6350 fix(plugins): address review — byte quotas, version bound, secret pruning, script entry

From the review on #7144, plus one contract change the surface design forced.

- Storage quotas were accounted in characters and enforced in bytes. The usage
  query and the size_bytes it reports now use octet_length, and so do the
  plugin_storage column checks. A UTF-8 value could otherwise spend up to a
  quarter of the budget it appeared to.
- manifest version had no length bound while plugin_installation.version caps
  at 64, so a legal-but-long semver failed at INSERT as an opaque install
  error instead of a parse error naming the field.
- Upgrading pruned config values the new manifest dropped but left the matching
  plugin_secret rows. Ciphertext nothing can reach is exactly the residue
  pruneConfig exists to prevent; upgrade now prunes both in one transaction.
- SetConfig wrote secrets and config across two tables with no transaction, so
  a failed config write could leave a saved form half-applied.
- A duplicate first install mapped a unique-index violation to 502; it is 409.
- net: is now an exact host match. It was a suffix match in the hook transport
  check while the same scope becomes an exact-host CSP connect-src in the
  surface runtime — one scope string cannot mean two things across the two
  places the consent screen claims to describe.
- surfaces[].entry must be a .js/.mjs script, not an HTML document. The host
  generates the surface's document so it can attach the CSP derived from net:;
  a plugin-authored document would carry its own server's policy and net: would
  be a claim rather than a control.

Tests: the three storage limits get a canonical unit matrix plus DB-backed
coverage of the byte counting and the exclude-the-candidate-key replacement
semantics; upgrade-time secret pruning is pinned end to end (verified failing
without the fix); and the admin-only routes now have a real middleware-level
403 check, which every direct-handler test bypassed.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 19:15:56 +08:00
patrickstar179 8cf2acbaa7 feat(share-link): workspace share-link invite system (#6833)
Adds workspace share-link invites: owner/admin can create a link with a target role (member/admin) and expiry, share it, and revoke it; invitees see the workspace and inviter before joining.

MUL-6070
Co-authored-by: patrickstar179
2026-08-17 14:39:32 +08:00
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add private Exa example

Co-authored-by: multica-agent <[email protected]>

* docs(plugins): remove private plugin guide

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): stabilize remote MCP tool digests

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): preserve remote MCP notification status

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make Exa example runnable end to end

Co-authored-by: multica-agent <[email protected]>

* fix(codex): record MCP tool calls in agent log

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add community skills example

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): support multi-file skill bundles

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add Mobbin MCP example

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): bundle full Matt Pocock skill set

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add remote MCP OAuth connections

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate enable on remote MCP readiness

Co-authored-by: multica-agent <[email protected]>

* fix(plugin): authenticate remote mcp credential broker

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden remote mcp oauth flow

Co-authored-by: multica-agent <[email protected]>

* refactor(plugins): use a single feature flag

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): clean up remote MCP OAuth state

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): remove Matt Pocock example

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00
31645cc510 [MUL-6125] Ship Private Skill Plugin developer loop (#6900)
* [MUL-6125] Ship private Skill Plugin developer loop

Co-authored-by: multica-agent <[email protected]>

* [MUL-6125] Address Private Plugin review findings

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 17:49:43 +08:00
mxbao063-baoandmxbao063-bao 8060aa7277 MUL-5947: feat(dingtalk): route groups to different agents (#6660)
Route each DingTalk group discovered by one Stream robot to its own agent, with a revision-fenced cutover so a reassigned group never inherits the previous agent's session, transcript, or pending outbound card.

Co-authored-by: mxbao063-bao <[email protected]>
2026-08-13 14:09:25 +08:00
d467cc9069 MUL-6075: complete Plugin V1 execution chain (#6853)
* feat(server): add Plugin V1 lifecycle foundation

Co-authored-by: multica-agent <[email protected]>

* feat(server): complete Plugin V1 execution chain

Co-authored-by: multica-agent <[email protected]>

* feat(web): add temporary plugin settings demo

Co-authored-by: multica-agent <[email protected]>

* refactor(plugin): keep foundation free of demo content

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 02:29:49 +08:00
c4f0d8cc8c MUL-5999: make workspace teardown index-driven, bounded and fenced (#6754)
* perf(workspace): make teardown task discovery index-driven

Workspace teardown found a workspace's tasks with a three-way
`OR agent_id IN (…) OR issue_id IN (…) OR runtime_id IN (…)`, three times
per delete, and swept task_token with the same shape. PostgreSQL costs
that as a Seq Scan on agent_task_queue — the largest table in the
database (5.08M rows / 8.3 GB in production) — so deleting even a tiny
workspace paid for the whole table, inside the transaction that holds the
workspace row lock and global advisory lock 4246.

Resolve the task set one owner key at a time instead, in the handler, and
reuse that snapshot for every task-keyed step. Measured on a 1.2M-row
synthetic copy of the production shape (937 agents / 109 issues / 12
runtimes): the OR form and a 937-element `= ANY(array)` form both Seq
Scan; single-key equality is a Bitmap Index Scan. A UNION of parent-key
joins is worse than the OR (three hash joins, each seq scanning the
table), which is why that earlier suggestion was dropped.

All three ownership paths are preserved: nothing enforces that a task's
agent, runtime and issue share a workspace, so dropping a path would turn
a performance fix into a tenant-cleanup bug.

task_token needs no loop. Its workspace_id has been NOT NULL since
migration 108, so the workspace key alone covers this workspace's rows,
and the two extra OR arms only ever matched tokens owned by another
workspace whose task or agent lives here — both NOT NULL FKs with
ON DELETE CASCADE, so the later task/agent deletes remove exactly those.

Two indexes make the new shapes index-eligible:

- 273 agent_task_queue(runtime_id): every existing runtime_id index is
  partial, so all-status consumers had none. This also covers the FK's
  own ON DELETE CASCADE probe, which runs per deleted agent_runtime row
  on the ordinary delete path.
- 274 task_token(workspace_id): the column has never been indexed.

Measured block usage for the same workspace, before → after:
agent_task_queue discovery 25,665 → 14,485 blocks; task_token sweep
4,086 → 3 blocks. The point is not the ratio at this size but that the
cost now scales with the workspace being deleted instead of with the
global table.

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* fix(workspace): fence, bound and un-cascade the teardown task sweep

Addresses all four blocking findings from the MUL-5999 review.

1. Concurrency (correctness / tenant isolation). The task set is no longer
   a lock-free snapshot reused across steps. Teardown now locks every row
   through which a task can belong to the workspace — its agents, issues
   and runtimes — FOR UPDATE, and holds them for the rest of the
   transaction. Inserting an agent_task_queue row takes FOR KEY SHARE on
   each referenced owner, which conflicts with FOR UPDATE, so a concurrent
   enqueue (or a reassignment onto one of these runtimes) can no longer
   slip a task in behind the sweep. Locking the tasks alone could not do
   that: a row that does not exist yet cannot be locked. This is the same
   technique the handler already uses on the workspace row to keep
   CreateChatSession out of the delete window.

   The per-batch page query also takes FOR UPDATE, so under READ COMMITTED
   PostgreSQL re-checks ownership after each lock: a task a concurrent
   commit already moved elsewhere is skipped rather than deleted on a
   stale claim, and a task we locked cannot move before we delete it.

   Deadlock (40P01) joins lock_timeout (55P03) as a retryable 503, because
   the owner locks can be taken in the opposite order by a concurrent task
   update.

2. Cleanup ownership (no cascade reliance). task_token keeps all three
   explicit paths, split into separate index-driven statements instead of
   one OR: workspace_id in DeleteWorkspaceLeafData, task_id in
   DeleteTaskBatch, agent_id in DeleteTaskTokensByAgent. Nothing in the
   teardown graph now depends on ON DELETE CASCADE to finish the job.

3. Bounded memory. The whole-workspace task array is gone. The sweep pages
   through one owner key at a time with LIMIT 1000 and deletes exactly the
   page it read, so process memory, the array parameter and each DELETE's
   row count are bounded by the batch size rather than by a workspace's
   lifetime task count. The task-keyed arms moved out of the leaf-data
   statement into DeleteTaskBatch, which runs per page, and the separate
   whole-workspace task delete is gone.

4. Migration retryability. The concurrent-index cleanup hooks are now
   derived from a version -> index-name map that covers 257, 261 and
   273-277, so an interrupted CREATE INDEX CONCURRENTLY cannot be mistaken
   for success on retry.

Three more indexes, each an unindexed FK referencing column that the
per-batch statements need and that the FK's own ON DELETE action already
scanned per row on ordinary deletes: task_token(agent_id),
chat_draft_restore(task_id), autopilot_run(task_id).

Plans re-verified on a 1.2M-row synthetic copy of the prod shape with the
child tables populated: page query is a Bitmap Index Scan under LockRows;
task_usage, chat_draft_restore, task_token and autopilot_run arms are all
index scans.

Tests: the ownership-path test now asserts the sweep is self-sufficient
inside the transaction, before any owner row is deleted, so a cascade
cannot stand in for teardown. New tests cover the enqueue / reassignment
fence (blocked writers, unrelated writer unaffected), multi-page sweeps
and the page bound, plus a migration-retry repair test and a check that
every cleanup hook names the index its migration builds.

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* fix(workspace): keyset-page the teardown sweep and fail closed on leaks

Addresses the second review round on MUL-5999.

1. Bounded scan, not just bounded result. The page query was
   `owner = $1 ORDER BY id LIMIT n` against indexes that cannot produce id
   order, so the executor had to read the owner's whole task set before
   applying LIMIT — quadratic in pages for a busy owner, which is the case
   this issue is about. Measured on a 550k-row probe: the old shape spent
   11,630 buffers per page (planner chose a full pkey index scan; on other
   statistics it chose an explicit Sort), the keyset shape spends ~2,000
   and a later page costs the same as the first.

   Pages are now `owner = $1 AND id > $cursor ORDER BY id LIMIT n` against
   composite indexes: 273 becomes (runtime_id, id), 278 adds (agent_id, id),
   279 adds (issue_id, id) and 280 drops the single-column issue_id index it
   supersedes so the hot table does not carry both.

2. Owner enumeration is bounded too. The three LockWorkspaceTaskOwner*
   statements no longer return ids at all — they are a set-based write fence
   and now cost the API process nothing. Owners are walked separately in
   keyset pages of 500 (281-283 add the (workspace_id, id) indexes those
   pages need), so a workspace with a huge agent or issue set never has its
   whole owner list held in memory.

   The keyset column is id rather than a natural key on purpose: a cursor
   column must be immutable, and renaming an owner mid-teardown would
   otherwise let it sort behind the cursor and escape the sweep.

3. The fence is no longer the only guarantee. Blocking a concurrent enqueue
   still relies on an INSERT taking FOR KEY SHARE on the owner rows it
   references, which is a property of the legacy FKs rather than a protocol
   every enqueue path opts into. Two things now backstop it:

   - every owner is re-swept until a pass finds nothing, and teardown fails
     closed (rolling the whole transaction back) if tasks keep arriving,
     instead of committing a partially cleaned tenant;
   - a schema-contract test asserts the three owner FKs still exist, so a
     migration that removes them turns the missing fence into a red build
     rather than a silent hole.

   An explicit deleting-state protocol shared with all seven enqueue paths
   remains the right long-term fix and is out of scope here.

End-to-end teardown, real handler, identical synthetic data, main vs this
branch (1.2M-row agent_task_queue, 200k task_token, 300k task_usage):

  small workspace, 5k tasks:   29,247 ms -> 1,013 ms   (4.38M -> 0.27M blocks)
  busy workspace, 60k tasks:  243,863 ms -> 1,724 ms   (5.76M -> 2.23M blocks)

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* fix(workspace): fence task writes in-transaction, independent of the FKs

Addresses the third review round on MUL-5999.

1. The re-scan did not close the window, and the reviewer is right that it
   cannot: a pass that comes back empty returns success, and a task
   inserted after that final scan but before COMMIT is not caught by any
   number of passes. The only thing that closed that window was the
   FOR KEY SHARE an INSERT takes on the agent / issue / runtime rows it
   references — a side effect of foreign keys that workspace_delete.sql
   itself documents as an expand-phase net a later contract removes.

   Migration 284 replaces it with an explicit fence: a BEFORE INSERT OR
   UPDATE OF (agent_id, issue_id, runtime_id) trigger on agent_task_queue
   that takes FOR KEY SHARE on the workspace row behind every owner the row
   references, and raises if one is gone. That conflicts with the FOR UPDATE
   LockWorkspaceForDelete already holds for the whole teardown transaction,
   so a task write against a workspace being torn down blocks until teardown
   finishes and then either proceeds (rolled back) or fails closed
   (committed).

   A trigger rather than a per-call-site check because the fence has to be
   in the same transaction as the write: there are seven INSERTs and several
   ownership-changing UPDATEs across agent.sql, autopilot.sql, chat.sql and
   runtime.sql, several of them single autocommit statements, so a separate
   "take the fence" statement from the caller would be its own transaction
   and protect nothing. In-transaction by construction, covers every current
   path, and cannot be forgotten by a path added later.

   TestTaskWriteFence_BlocksOnWorkspaceLockAlone isolates the fence from the
   FKs: the holder locks ONLY the workspace row — no agent, issue or runtime
   row — so no FK on agent_task_queue has anything to block on, since none of
   them references workspace. Inserts through all three ownership paths and a
   reassignment all block; an unrelated workspace's insert and a status-only
   update still pass. Dropping the trigger fails that test.

   Cost, measured with 5,000 bare inserts: 147 ms without the fence,
   238 ms with it — about 18 us per inserted row for up to three indexed
   lookups and a shared row lock. It does not fire on the hot claim /
   dispatch / complete path, which updates status and timestamps rather than
   ownership.

   The verification passes stay as a self-check, no longer described as the
   concurrency guarantee.

2. Migration 280 is withdrawn. Its down path rebuilt the superseded index
   with CREATE INDEX CONCURRENTLY, and the runner only runs INVALID-index
   cleanup hooks in the up direction, so an interrupted rollback would leave
   an invalid index that a later `down` skips — and rolling back 279 too
   would then leave no usable issue_id index at all. The redundant
   single-column index stays for now; dropping it needs its own migration
   with a direction-aware cleanup. This also settles the stale comment in
   task_usage.sql, which still names the old index and remains correct.

3. Keyset cursors no longer start from the all-zero uuid, which is itself a
   valid uuid and would have made a row with that id invisible forever.
   Every walk now has an explicit first-page query with no cursor predicate.

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* fix(workspace): move the task-write fence into the write statements

Addresses the fourth review round on MUL-5999.

1. The trigger is gone. Migration 284 now defines
   lock_task_owner_workspaces(agent_id, issue_id, runtime_id), a predicate
   that every statement writing agent_task_queue ownership calls in its own
   WHERE clause:

       INSERT INTO agent_task_queue (...)
       SELECT ... WHERE lock_task_owner_workspaces($1, $3, $2)

   Nine statements now carry it: the seven task INSERTs (agent.sql x5,
   autopilot.sql, chat.sql), LinkTaskToIssue, and ReassignTasksToRuntime —
   the last through a MATERIALIZED CTE so a bulk UPDATE evaluates the
   VOLATILE predicate once instead of per row. Clearing runtime_id is
   exempt: it cannot move a task into another workspace.

   Calling it from the writing statement keeps the lock in the writer's own
   transaction, which a separate caller-issued statement could not do for
   the autocommit enqueue paths. TestTaskOwnershipWritesCallTheFence is the
   trade for dropping the trigger: it scans the query files and fails the
   build if a statement writes ownership without the fence.

2. The fence no longer defers to the foreign keys. The trigger version
   counted only owner references that still resolved, so a deleted owner
   made itself invisible: with all three gone it returned early, and with
   one of three gone the count still matched. The predicate now compares
   resolved owners against the number of owner arguments supplied and
   returns false on any mismatch, so a stale write is rejected on its own
   terms. TestLockTaskOwnerWorkspaces_RejectsUnresolvableOwner covers each
   shape, including the one-of-three case.

3. Deterministic lock order. The workspace rows are locked with
   ORDER BY w.id, so mirrored cross-workspace writes cannot take the same
   pair in opposite orders. Honest note on the test: 25 mirrored concurrent
   pairs never deadlock either way, because the current plan happens to
   return the rows in a stable order — the ORDER BY is what makes that a
   guarantee rather than an accident of the plan, and the test is a
   regression test for the symptom.

4. The fail-closed test now tests what its name says. The previous one only
   inserted a neighbour-owned task and could not have failed. The
   replacement runs the actual race: a writer calls the real
   CreateAgentTask, blocks on the workspace lock, teardown deletes the
   workspace and commits, and the writer must come back with pgx.ErrNoRows
   — the fence's own verdict. An FK doing the work instead surfaces as
   SQLSTATE 23503, which the assertion rejects explicitly.

Cost: 5,000 predicate calls in 53.7 ms, about 11 us per fenced write, for
up to three indexed lookups and a shared row lock. Status, dispatch and
completion updates do not call it.

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): stop the legacy merge when the task fence refuses

Addresses the P1 from the fifth review round on MUL-5999.

ReassignTasksToRuntime returned only a row count, so a fenced refusal was
indistinguishable from "the old runtime had no tasks". mergeLegacyRuntimes
read both as success and went on to DeleteAgentRuntime(old), where
agent_task_queue's ON DELETE CASCADE silently took the history the
reassignment exists to preserve — and once that FK goes away, the same path
would instead leave tasks pointing at a deleted runtime. Either way the
merge was leaning on the cascade, which is what this PR set out to stop.

The query now returns the fence verdict alongside the count:

    SELECT (SELECT ok FROM fence) AS fence_ok,
           (SELECT count(*) FROM reassigned) AS reassigned_tasks

and the merge is a single helper, mergeLegacyRuntime, that aborts with
errRuntimeMergeFenced before touching anything else when fence_ok is false.
Tasks now move FIRST, because that is the step carrying the fence: nothing
else in the merge — agent reassignment, legacy daemon_id bookkeeping, the
old runtime delete — runs unless the fence agreed. The next daemon
registration re-runs the merge, by which time the workspace is either gone
or its teardown rolled back.

TestMergeLegacyRuntime_KeepsOldRuntimeWhenFenceRefuses covers it: with a
vanished merge target the old runtime and its task both survive and the task
stays on the old runtime, while a merge onto a live runtime still completes.
Removing the abort fails that test — and fails it with SQLSTATE 23503 from
agent_runtime_id_fkey, which is precisely the "an FK is doing the work"
shape the review objected to.

Also replaced the fixed 300 ms sleep in the queued-writer race with a poll
on pg_stat_activity for a backend actually parked on a lock, so the test
cannot pass without the race being set up (it now fails on timeout instead).

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): make the legacy runtime merge a single transaction

Addresses the P1 from the sixth review round on MUL-5999.

The merge ran four autocommit statements, so the fence's workspace lock
ended with the statement that took it. Two ways that left a tenant
inconsistent while daemon registration still reported success:

  * a transient failure on the agent reassignment committed the task move
    and nothing else, leaving tasks on the new runtime and their agents on
    the old one until some later registration happened to retry;
  * in the gap after the task commit, a workspace teardown could lock and
    delete the target runtime — and its UnbindTasksFromRuntime step clears
    runtime_id on exactly the tasks that had just moved. The next merge
    looks tasks up by the OLD runtime id, so that history was detached for
    good.

mergeLegacyRuntime now opens a transaction and runs the whole merge on one
qtx: fenced task reassignment, agent reassignment, the legacy daemon_id
audit write, and the old-runtime delete, then commits. The fence stays the
first statement, so the workspace lock it takes is held until COMMIT — a
teardown cannot start deleting the target mid-merge — and the lock order
against teardown is unchanged. Any failure returns an error and rolls the
whole thing back.

The audit write is no longer best-effort: inside a transaction a failed
statement poisons the rest, so it either lands with the merge or the merge
is abandoned.

TestMergeLegacyRuntime_RollsBackWhenAStepFails injects the failure in the
database rather than through a code seam — a trigger makes the agent move
raise — and asserts the task, the agent, both runtime rows and the audit
column are all exactly as they were, then that the same merge completes
once the injection is removed. Reverting to autocommit fails it with
"task moved to <new runtime>; the task reassignment was not rolled back".

Also took the non-blocking suggestion: the queued-writer race now has the
writer report its own pg_backend_pid() over a channel and waits for that
specific backend to park on a lock, instead of matching statement text
against every backend on a shared test database.

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): fence late writes against the runtime a merge is collapsing

Addresses the P1 from the seventh review round on MUL-5999, which the
reviewer reproduced rather than inferred.

The fence locked the workspace row and nothing else. A task write takes
FOR KEY SHARE there, and so did the legacy runtime merge — and two KEY SHARE
locks do not conflict. So an enqueue against the OLD runtime went straight
through after the merge had scanned its tasks, and DeleteAgentRuntime then
removed that brand-new task through agent_task_queue's ON DELETE CASCADE.
Making the merge one transaction widened the window rather than creating it:
the agent's new runtime_id is invisible until COMMIT, so concurrent writers
keep resolving the old runtime for the whole merge.

Both sides of the contract now lock the owner rows too, in one order:
workspace rows (by id), then agent, issue, runtime.

  * lock_task_owner_rows (migration 284, renamed from
    lock_task_owner_workspaces) takes FOR KEY SHARE on each owner row after
    the workspace rows, and re-checks that they still exist. That is what
    conflicts with a delete.
  * The merge takes FOR KEY SHARE on the workspace and then FOR UPDATE on
    both runtime rows in id order, before the task scan, and holds them to
    COMMIT. FOR UPDATE is the level that conflicts with a writer's KEY
    SHARE; both runtimes in one ordered statement so two merges running in
    opposite directions cannot take the pair in opposite orders. Locking
    fewer rows than asked for means one disappeared, and the merge is
    abandoned.

A late writer therefore either commits before the merge takes the lock — in
which case the scan that follows sees its task and moves it — or waits, and
then finds the runtime gone and writes no row.

TestRuntimeMerge_LateWriteToOldRuntimeIsFencedNotCascaded drives the merge
statement by statement so it can stop where the window was (after the agent
update, before the old runtime is deleted), enqueues against the old runtime
through the generated query, and requires that the writer is parked on a
lock, that it comes back as pgx.ErrNoRows, and that the merge's own history
ends up on the target. It explicitly rejects both 23503 and a row that
lands and then vanishes.

Both halves are load-bearing: dropping the runtime lock from the fence, or
the FOR UPDATE from the merge, fails that test — the latter with "the late
write was rejected by a foreign key (23503), not by the fence".

Cost: 5,000 predicate calls in 74.4 ms, ~15 us per fenced write (was ~11 us
with the workspace lock alone).

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

* docs(workspace): correct the fence comment and drop a trailing newline

Two review nits, no behaviour change.

lockWorkspaceTaskOwners still described migration 284 as a BEFORE trigger on
agent_task_queue. It became a called predicate, lock_task_owner_rows, in
9fc1d79, and this comment is the place someone reads to understand how the
fence works — so it now says what it is: a predicate every ownership-writing
statement calls in its own WHERE clause, taking FOR KEY SHARE on the
workspace rows and then the owner rows.

Also removed the blank line at EOF in the migration, which git diff --check
was flagging.

Refs MUL-5999

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 14:09:48 +08:00
6bce42b84a MUL-5983 fix(workspace): stop workspace delete from hanging on advisory lock 4246 (#6710)
* fix(workspace): stop workspace delete from hanging on advisory lock 4246 (MUL-5983)

Deleting a workspace from the latest client did nothing: the confirm dialog
stayed on "Deleting…" forever and the workspace survived.

The teardown transaction takes global advisory lock 4246 (added in #6230) so
the task_usage_hourly rollup cannot write aggregates for a workspace being
torn down, and it waited on that lock with no bound. Nothing above the handler
has a deadline either — the API client's fetch has no timeout — so any holder
of 4246 turns the delete into an infinite spinner.

Two fixes:

- Migration 272 makes the rollup's lock transaction-scoped. Migration 102 used
  the session-scoped pg_try_advisory_lock and released it from an
  `EXCEPTION WHEN OTHERS` handler, but plpgsql's OTHERS does not match
  query_canceled and a session-level advisory lock survives the rollback that
  follows. A cancelled tick therefore handed its pooled connection back to
  pgxpool still holding 4246 — permanently blocking every later tick (usage
  aggregation silently stops) and every workspace delete.

- DeleteWorkspace sets SET LOCAL lock_timeout on its transaction and maps
  SQLSTATE 55P03 to 503. Lock 4246 is also held for whole runs by the backfill
  commands and for up to 25 min by a rollup tick, so contention has to surface
  as a retryable error instead of a request that never returns. The cap applies
  to lock waits only; the teardown work itself stays unbounded.

Both paths are pinned by tests that fail without the fix: the scheduler test
proves a cancelled tick leaks 4246 against the old function, and the handler
test proves DeleteWorkspace blocks on a held 4246 without the lock timeout.

Co-authored-by: multica-agent <[email protected]>

* test(workspace): serialise the new 4246 tests with the rollup guard (MUL-5983)

Review found that the two new handler tests take advisory lock 4246 by hand
without joining the cross-binary guard the rollup family uses (42463980), so
`go test` running internal/handler and internal/scheduler in parallel against
one database made the scheduler's lock tests fail. Reproduced locally, then
fixed by calling lockRollupSingleton in both.

Stressing the combined run surfaced a second, older interference: every
workspace teardown takes 4246 in production code (#6230), so any handler
delete test can own the lock for a moment and leave the scheduler's rollup
tests seeing "no work" — TestPgCronConcurrentNoDoubleWrite fails with
winners=0, and this predates the guard fix above. Both rollup tests now treat
a round lost to an outside holder as a retry rather than a verdict; the real
invariants (no leaked lock, no double write) are unchanged.

Also from review: correct the sqlc source comment that still described the
rollup's lock as session-scoped (regenerated), and drop migration 272's claim
that the TTL prune is bounded and only delays the next tick — it has no row
cap and now also delays workspace deletes waiting on 4246.

Co-authored-by: multica-agent <[email protected]>

* test(workspace): make the advisory-lock holder release idempotent (MUL-5983)

The 15 s deadline branch releases the 4246 holder early so the blocked
handler can finish, and the defer then released it again. pgxpool's
Release is idempotent but Exec on a returned connection dereferences a
nil resource, so the second call panicked and buried the assertion
failure it was supposed to report. sync.Once makes the cleanup safe from
both paths.

Verified by forcing the deadline branch (lock_timeout temporarily removed
from DeleteWorkspace): the test now reports the assertion cleanly where it
previously ended in a nil-pointer panic.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 02:23:11 +08:00
Naiyuan QingandClaude Fable 5 984a2c2bff MUL-5954: feat: saved issue views V1 (MUL-4796) (#6516)
* feat(issues): server-backed saved issue views API (MUL-4796)

issue_view table (migrations 262-264: table + two CONCURRENTLY partial
indexes) stores named filter definitions: query jsonb is the shared
identity, display jsonb only seeds a user's first open. Scope model is
workspace / my (with scope_variant, forced private) / project (validated
scope_id, deleted with the project in the same app transaction).

issue_view_preference (migration 265) holds each user's view-bar layout
(hidden + order doc) per surface container, last-write-wins.

CRUD + GET/PUT preference endpoints are gated on the saved_issue_views_v1
feature flag (404 while off, key published to the frontend config).
Authorization: private views 404 for everyone but the owner; shared views
editable by owner or workspace admin; updates use expected_revision -> 409.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(core): saved issue views client core (MUL-4796)

- zod schemas + parseWithFallback for views and preferences (definition
  blobs stay loose records interpreted per definition_version), with
  malformed-response tests per the API-compat rules
- issue-views module: list/create/update/delete + preference queries with
  wsId-scoped keys; preference upsert is optimistic (toggle-grade rollback)
- active-view store (zustand client state, URL is the durable carrier) and
  useActiveIssueView with missing-view detection
- baseline.ts: enum-sanitized FilterSnapshot of a view's query for
  value-level locking, delta chips, and baseline-aware resets
- view-store: clearFilterDimension + resetFiltersTo actions; surface store
  seeding routes server blobs through mergeViewStatePersisted

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): three-bar priority icons with a distinct urgent badge

low/medium/high share one three-bar frame (fill level = severity, ghost
bars at 35%); urgent breaks out of the scale as a filled badge with an
exclamation cutout — it is an interrupt, not a fourth bar. All glyphs
share the same 2-14 optical frame.

Board card: PickerWrapper's bare block div gave its inline-flex trigger a
line box (line-height 24px > 20px button), floating icons above the text
midline — the wrappers are flex containers now, and the priority trigger
gets a constant size-5 box so the row's rhythm doesn't depend on the glyph.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(views): saved views UI — view bar, save/edit dialog, locked view mode (MUL-4796)

View bar replaces the built-in tab row on Issues and My Issues: built-in
tabs and saved views render as one flat, per-user ordered row that wraps
instead of overflowing, with a menu for new-view / manage. The manage
dialog does drag reorder (translate-only transform, vertical axis lock),
show/hide (anchor built-in unhidable), and edit/delete for owned views.

Open-view semantics: the view's query is the baseline — its values render
checked-and-disabled in the filter menu, never as chips; chips show only
the user's additions and every reset path (chip x, clear, menu reset,
filtered-empty CTA, board/swimlane hide-column) returns to the baseline,
not to empty. Display seeds once per user (view:<id> surface key) and is
free afterwards. Built-in tab click exits; deleted/revoked views fall back
with a toast; ?view= deep links sync on web via a platform hook.

Save dialog doubles as the edit dialog (PATCH with expected_revision ->
409 toast); manager edits seed from the view definition, in-view edits
from the live panel. Filter chips bar, extracted IssueFilterMenu (virtual
frozen anchor), and the reworked display panel round out the header.

Also: filter menu consistency fixes (closeOnClick, calendar apply,
TableColumnPicker checkbox items), SelectGroup padding, dropdown anchor
passthrough, four-locale i18n.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(views): neutralize workspace tab filter while a saved view is open

The Members/Agents tabs are a coarse assignee-type filter carried in the
surface scope, outside the filter-menu dimensions a view can express. It
kept applying underneath an open view, so the same shared view returned
different rows depending on which tab the user stood on when opening it —
with nothing on screen to explain the difference (tabs dimmed, no chip).

While a view is active the workspace scope now resolves to actorKind
"all": a view's results are defined by the view alone, matching what the
dimmed tabs already imply. Deliberately NOT captured into saved queries —
a view condition must be visible and lockable in the filter menu, and
assignee-type has no menu representation (add the menu dimension first if
that need ever materializes; the loose schema takes the field additively).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): workspace scope_variant captures and applies the assignee-type tab

A workspace view now saves which built-in tab it was created from
(members/agents, NULL = all) as scope_variant, alongside the existing
my-scope variants. The save dialog shows a scope selector so the
variant can be switched later; scope_type stays immutable. While a
view is open, its own variant drives the workspace scope's
assignee-type axis instead of whichever tab the user stood on.

- migration 266 widens the scope_variant CHECKs (members/agents on
  workspace, NULL = all; my keeps its four variants; project stays NULL)
- UpdateIssueView persists scope_variant; handler validates the variant
  against the view's scope_type on create and update
- issue-surface applies activeView.scope_variant to effectiveScope,
  replacing the blanket tab neutralization
- issues-header seeds the dialog scope from the live tab

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): project pages get a real assignee-type axis and clearer variant copy

The Members/Agents tabs on project pages were decorative: they wrote the
global tab store but the project query never read it. The project scope
now carries actorKind like the workspace scope (assignee_types composes
with project_id server-side), so the tabs filter for real, and project
views join the same optional scope_variant vocabulary — saved from the
tab, switchable in the dialog, applied while the view is open.

- migration 266 (unmerged) widened in place: project allows
  members/agents, NULL = all
- handler treats workspace and project variants identically; new
  TestProjectIssueViewVariant covers persist / normalize / reject
- project scope key gains a per-tab segment (members/agents) so each tab
  keeps its own display state; the unrestricted tab keeps the old key
- save dialog shows the variant selector for project views; the surface
  now provides the dialog's default variant directly (header-side
  injection removed)
- variant labels spell out the meaning (Assigned to members/agents, Any
  assignee, Any relation) and project hints are variant-aware, x4 locales

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-table): project table scope honors assignee_types

The table/rows channel dropped the Members/Agents narrowing on project
pages: the client's project scope never sent assignee_types and the
server's project branch never compiled it, so switching tabs only
changed the highlight while table rows stayed put (the board/list
channel already filtered). Both sides now share the workspace scope's
optional assignee-type narrowing, with a compile test covering the
predicate and the invalid-type rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): single assignee-type mapping + per-page tab state

One core helper pair (assigneeTypesForActorKind / actorKindForViewVariant)
replaces the four inlined tab->assignee_types literal mappings across
query-plan, the table spec compiler, and the view-variant application.

The assignee-type tab store becomes page-keyed (issues / project:<id>,
persisted with a v0 migration carrying the old global tab to the Issues
page): switching tabs inside a project no longer drags the Issues page or
other projects along. The save-view dialog's default variant now comes
from the header's own page tab, restoring the /issues default that the
surface-side injection had dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): converge all issue surfaces onto the table query channel

The legacy list/grouped query layer duplicated the table channel's scope
semantics behind enable-conditions that were provably always false: list
and status-board ride server status branches, every non-status grouping
and swimlane ride server group branches, and the assignee-board /
per-status load-more / flat-list / flat-export / client-side my:any
3-request merge fallbacks could never execute. Delete the whole layer:

- queries: myIssueListOptions, myIssueAssigneeGroupsOptions,
  issueAssigneeGroupsOptions, issueFlatListOptions/ExportOptions,
  fetchAllFlatPages / fetchAllMyFlatIssues / fetchAllMyFirstPages /
  fetchAllMyAssigneeGroups, compareIssuesForSort (server owns ordering)
- mutations: useLoadMoreByStatus, useLoadMoreByAssigneeGroup
- board/swimlane: the never-taken client-paginated columns, property
  pool loader, hidden-column client counts, legacy footer rows
- surface data hook / controller: dead queries and passthrough fields
- gantt keeps its scheduled-only endpoint and now compiles the
  assignee-type tab into it, restoring the change alongside the cleanup

query-plan shrinks to the scope's non-Table residue: scopeKey, the
gantt filter, and create defaults. issueKeys and cache invalidation
prefixes are untouched; mobile's own data layer is unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): review fixes — active view's variant wins in save dialog, stable dialog scope, dead mocks

Post-review pass on the convergence refactor:

- The save dialog's default variant now prefers the OPEN view's
  scope_variant over the page tab: saving a copy while an agents-scoped
  view is open must describe the rows on screen, not the tab underneath.
- The dialog scope prop is memoized on primitive projections — a fresh
  object per header render re-armed the dialog's draft-reset effect, so
  any background refetch wiped a half-typed name.
- Tests: persist migration coverage for the page-keyed tab store; gantt
  assignee_types reaches the request and forks the cache key.
- Dead legacy load-more mocks removed from three view test files; stale
  hidden-columns doc comment; unknown hidden-column totals render no
  count instead of a blank; my-relation plan branch is return-exhaustive;
  leftover blank lines.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): keep the grabbing cursor for the whole row drag in manage views

The handle's cursor classes only apply while the pointer hovers the
handle — the vertical-locked drag loses it immediately, and buttons the
pointer crosses flip it to pointer. Mirror the sidebar-resize cursor
contract: an html[data-dnd-dragging] rule forces cursor: grabbing (and
no text selection) document-wide for the drag's duration, toggled by the
DndContext lifecycle with an unmount safety net.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): my views apply their relation at the query layer, not by switching the tab

Opening a my-view used to overwrite the user's relation tab with the
view's variant (VARIANT_TO_TAB), so when an open view vanished
(deleted / access revoked) the fallback landed on the view's tab
instead of where the user actually was. Views are now applied the same
way on all three surfaces: while a view is open, effectiveScope
substitutes the view's variant — assignee-type for workspace/project,
relation for my — and the user's own tab state is never touched.
myRelationForViewVariant joins its sibling mapping in scope.ts with
unit coverage.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): ease the save-view dialog's expand and validation transitions

The display-defaults section snapped open/closed and the name-required
hint popped in. The panel now runs the Base UI height transition
(--collapsible-panel-height + data-starting/ending-style — the
tw-animate collapsible keyframes only know Radix's variable, so the
accordion's animate-* classes cannot be reused here), and the hint eases
in with the house animate-in vocabulary. Scoped to this dialog: the
shared Collapsible primitive stays untouched because chat/transcript
panels grow while open and a pinned panel height would clip them.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): filter chips ease in

Chips popped into the bar and the save-view dialog's embedded list with
no transition. Each chip now enters with the house fade+zoom vocabulary,
keyed per dimension so value edits inside an existing chip don't
re-animate. No exit animation by design.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): drag-reorder the view bar, tab context menu, pin views to sidebar

The bar's tabs (built-ins and saved views alike) now reorder by drag,
writing the same per-user preference document the manage dialog edits —
one list, two editors. Saved-view tabs gain a context menu:

- Edit: visible to everyone, disabled without manage permission (the
  greyed row is the signal); Delete: rendered only for the owner or a
  workspace admin on shared views — mirroring the server's
  canManageIssueView — and routed through the shared confirm dialog now
  extracted from the manage dialog. The manage dialog's own affordances
  follow the same widened rule (admins included).
- Pin to sidebar: pinned_item grows a 'view' type (migration 267; create
  validates read access so foreign private views 404). Sidebar pin rows
  resolve the view's name via the new GET-by-id options, render the
  view-bar icon, activate the view in the store and navigate to its
  owning surface on click. Flag-off clients keep view pins dormant
  instead of auto-unpinning; deleted views follow the existing
  404-auto-unpin pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): review fixes — guard preference wipes, never auto-unpin views

Two data-loss paths from review, plus hardening:

- A drag landing before the views list loaded pruned the preference
  document against an empty list, silently erasing every saved-view
  entry from order and hidden. savePrefs now refuses to write until the
  list has actually loaded (viewsReady threads down from
  useActiveIssueView).
- View pins are exempt from 404-auto-unpin: the detail endpoint also
  404s when the feature flag is switched off server-side, and a client
  that booted with the flag on cannot tell the difference — a flag flip
  would have deleted every view pin permanently. Deleted views' rows
  hide instead.
- POST /api/pins with item_type=view now sits behind the same feature
  gate as every other view endpoint.
- SortableBarTab: drop dnd-kit's aria attributes (they nested a second
  focusable role=button around the tab), move the wasDragged ref into an
  effect, and clear it after a cancelled drag so the next real click
  isn't swallowed.
- Sidebar view pins carry ?view= in their href so a web reload stays on
  the view; scope resolution collapsed to one branch driving both the
  path and the container key.
- Malformed GET /api/issue-views/{id} responses pinned by test as null
  (hide the row), never an error (which alone may unpin).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): single-row view bar with overflow popover

The bar no longer wraps: tabs that don't fit collapse into a trailing
popover, Linear-style, with two differences — built-ins are first-class
orderable entries, and the whole list (hidden rows dimmed) lives in the
pulled-down panel with drag reorder, per-row actions (edit/delete under
the same permission rule, pin, hide/show) and a new-view entry.

- useSingleRowFit (views/common): hidden mirror row measures natural tab
  widths; ResizeObserver + change-guarded per-commit remeasure computes
  the fitting prefix with trigger reservation. First reusable
  collapse-into-menu primitive in the repo.
- The open view is always visible: when it overflows it takes the
  trigger slot, showing its name + chevron (wider reservation; the
  two-pass promotion settle is monotonic, no oscillation).
- Every tab now shares one max width (max-w-40) and truncates with the
  full name in tooltips/rows.
- ManageViewsDialog retired: the popover carries all of its duties
  (reorder, hide/show incl. built-ins, edit/delete); hiding stays
  impossible for the anchor built-in and still exits a hidden open view.
  DeleteViewConfirm and ViewBarItem move to view-bar-popover.tsx;
  orphaned i18n keys dropped, row action keys added x4 locales.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): restore [layers] menu + manage dialog; 'more' appears only on overflow; fix zero-width bar

The single-row rework overreached: it replaced the existing new/manage
entries and dialog with an all-in-one popover, and the bar's container
collapsed to zero width (content-sized parent + measurement feedback),
rendering an empty header. Back to the intended shape:

- The [layers] menu (new view / manage views) and the manage dialog are
  restored exactly as before; DeleteViewConfirm and ViewBarItem stay
  shared from view-bar-popover.
- The 'more' trigger exists ONLY while tabs overflow, showing just the
  overflowed entries (click to open; per-row edit/delete/pin/hide with
  the same permission rules — those tabs have no rendered surface to
  right-click). The open view still takes the trigger slot when it
  overflows.
- Header view-bar slots gain flex-1 so the fit measures a real width;
  reserve tiers (menu / +more / +promoted) settle monotonically.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): list panel shows ALL tabs with drag reorder; trigger always visible

The pulled-down panel now lists every bar entry (built-ins included) in
bar order with vertical drag reorder — since the bar renders the longest
fitting prefix, reordering in the panel directly decides what sits on
the bar versus tucks away, with a separator marking the current fold.
The trigger no longer appears/disappears with overflow: it is a fixed
part of the bar's right side (still replaced by the open view's own tab
when that view overflows). Row actions (edit/delete/pin/hide) and the
[layers] new/manage entries are unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): kill the post-drop bounce with a synchronous order mirror

The preference mutation is optimistic but its onMutate is async: on drop,
dnd-kit clears the drag transform in the same frame while the reordered
list lands a beat later, so the dropped tab flashed back to its old slot
and then jumped — the manage dialog never bounced because it mirrors the
new order synchronously in the drag handler. Both bar drag and panel
drag now route through applyMove: set a local order mirror in the same
tick, persist, and clear the mirror once the preference data catches up.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): panel row menu is always visible, not hover-revealed

Hover-reveal made the three-dot row menu unreachable on touch devices;
it is a primary affordance in the list panel, so it renders always.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): long view names truncate/wrap everywhere

The manage dialog's row strip is a grid item — min-width:auto let an
80-char name blow past the dialog frame, carrying the row controls with
it. Pin the wrapper (min-w-0 + overflow-hidden) and the rows (min-w-0)
so labels truncate as designed. The delete confirmation and the save
dialog's scope hint interpolate names/titles into prose — break-words
so an unbroken name wraps instead of overflowing. Audited every other
name surface (bar tabs, promoted trigger, panel rows, sidebar pins,
context menus): all already bounded.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issue-views): remove the saved_issue_views_v1 feature flag

The feature ships unconditionally. The flag never reached any released
client, so — unlike the retired flags that live on as always-on compat
keys — the key is removed outright on both sides:

- server: the issueViewsEnabled 404 gate drops from every view endpoint,
  the preference endpoints and the view pin type; the key leaves the
  registry and the frontend-public list; gate tests and the per-test
  enable helper go with it
- frontend: the === true checks, the pre-view-bar fallback tab rows in
  both headers, and useActiveIssueView's enabled parameter are deleted;
  the view bar is now the only rendering
- compat holds by construction: a NEW client against an OLD backend gets
  404s on the list/preference queries → empty views, viewsReady stays
  false (no preference writes), sidebar view pins hide without
  auto-unpinning (comment updated to name this as the reason); an OLD
  client against the NEW backend reads an absent flag as false and keeps
  the feature hidden

Full-suite run also surfaced that the workspace deletion manifest never
classified issue_view / issue_view_preference: both now fall with the
other issue roots in DeleteWorkspaceIssueRoots (no-FK policy: explicit
teardown), and the manifest records the decision.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): fit reserve applies even when every tab fits

The all-fit fast path skipped the reserve — a leftover from when the
overflow trigger only rendered on overflow. With the list trigger and
the [layers] menu now permanent, a row whose tabs exactly filled the
container declared everything fitting and overflow-hidden clipped the
trailing chrome. The reserve is now subtracted unconditionally.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): a freshly created view opens itself

Creating a view now activates it on its surface immediately — the view
you just saved is the one you meant to be looking at. Skipped only when
the create response fails to parse (the list refetch still shows it).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): creating a view no longer bounces the surface to the default tab

Activation raced the list invalidation: the fresh view's id was set
active before the refetched list contained it, the stale list read as
'view deleted', and the missing-view fallback kicked the surface back to
the anchor tab (with an 'unavailable' toast). Two-sided fix: the create
mutation seeds the created view into its scope's list cache before
invalidating, and the missing verdict now requires the list to not be
mid-refetch.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): release-gate fixes — rollback safety, limits, old-client pin compat, revoke sweep

Addresses the pre-ship review blockers:

- CI styles: text-muted-foreground/70 -> text-faint-foreground (solid
  tone rule) in the header hint and the dialog my-note; the chip emoji
  stack's text-[9px] -> text-micro (type-scale rule). apps/web
  text-contrast + type-scale suites pass.
- migration 269 down normalizes workspace/project members/agents
  variants to NULL before restoring the old constraints — verified on a
  scratch DB with real variant data (up -> write -> down).
- view pins are withheld from the legacy /api/pins contract unless the
  client opts in (?include=view): old Desktop builds classified any
  non-issue pin as a project pin and permanently auto-unpinned it on
  404. New clients opt in; regression test pins one and asserts the
  legacy list never leaks it.
- write hardening: MaxBytesReader (128KB) on view create/update and
  preference PUT; isJSONObject rejects JSON null (was a DB-CHECK 500);
  per-owner view quota (100/workspace); list query LIMIT 200. Boundary
  tests for null blob, oversized body, and quota.
- member revoke now sweeps the departed member's PRIVATE views and their
  view-bar preferences in the same transaction (shared views stay, same
  rule as private quick actions) — the migration comments promised this;
  test covers private-gone/shared-stays/prefs-gone.
- sidebar comment no longer claims project ?view= reload support.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): deleting a view sweeps its sidebar pins on every path

View pins never auto-unpin client-side (old-backend safety), so a pin
whose view is gone was invisible in the UI and unremovable forever —
and could leave an empty-looking Pinned group. All three deletion paths
now sweep matching pinned_item rows atomically in the same statement
via CTEs: direct view delete, project deletion (its scoped views), and
member revoke (their private views). Regression tests cover each path.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-10 11:55:05 +08:00
Naiyuan QingandClaude Opus 5 a5c1d44701 MUL-5642: fix(agents): stop the creation studio polling, and stop it losing work (#6307)
* feat(agents): make AI agent creation resumable (#6246)

Leaving the Agent Creation Studio destroyed the conversation. The unmount
cleanup called deleteChatSession, so a sidebar click, a tab close or a route
change deleted the builder session and every message in it — the bug external
users reported. Archiving instead (PR #6247) would have stopped the deletion
without giving anyone a way back in: builder sessions hang off a hidden
`kind = 'system'` carrier agent, which the `kind = 'user'` filter keeps out of
every chat list, so an archived one is unreachable rather than recoverable.

A creation conversation is now a durable object with its own address.

Server:
  - GET /api/agent-builder/sessions lists the caller's unfinished creations.
    Creator-scoped like every other chat read. It reports the CARRIER's
    runtime, not chat_session.runtime_id — the latter is the daemon's resume
    pointer and is deliberately left stale after a switch, so resuming from it
    would put the picker on a runtime that executes nothing (MUL-5163).
  - PUT /api/agent-builder/sessions/{id}/draft stores the configuration,
    including the edits the user typed but never sent. Migration 251 adds
    agent_builder_draft (no FK per repo rule; pruned explicitly by
    DeleteChatSession, the runtime teardown and the workspace teardown, and
    registered in the workspace-deletion manifest).
  - The payload is opaque to the server: its shape is the studio's AgentDraft,
    validated client-side. Teaching Postgres and the handler about it would
    create a second definition to keep in sync for no gain.

Client:
  - The session id lives in `?session=`, so a refresh, a back/forward and a
    reopened tab land back in the same conversation.
  - Leaving no longer deletes anything. The only destructive path is an
    explicit "discard", confirmed in a dialog, next to the create button.
  - Creating the agent archives the conversation instead of deleting it: it is
    the record of how that agent was designed, and an idle carrier costs
    nothing since usage is booked per task.
  - The configuration autosaves (debounced) and restores on arrival, with the
    applied-assistant-message marker stored alongside it so a restore cannot
    re-apply the last reply over edits made after it.
  - The 1.5s polling of messages and pending-task is gone. The global realtime
    sync already invalidates both per session id, exactly as it does for the
    main chat window, which has never polled.
  - The `<agent_draft>` block collapses to one "configuration updated" line.
    The regex now also swallows an unterminated block, which is what streaming
    produces — the raw payload used to scroll past on every turn.

The 2185-line agent-creation-studio.tsx is split into three routes
(`/agents/new`, `/agents/new/manual`, `/agents/new/ai`), its pure logic moves
to packages/core/agents/ with its tests, and the unreachable template flow —
`setMode("templates")` had no caller — is removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): let the builder panes resize

The conversation / configuration split was not draggable. Two structural
reasons, both fixed by giving the group the same shape the chat page uses:

  - The panels reached the group through BuilderWorkspace's fragment, so they
    were not children the group could measure.
  - The group's children alternated between one panel (runtime setup) and two
    (conversation), under one persisted layout id.

The group now lives inside BuilderWorkspace with its two panels as its only
children, and the setup screen renders no group at all — it has nothing to
split.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(ui): give the resize handle a cursor on hover

The separator had no cursor of its own, so the only signal that a split was
draggable arrived after the drag started — the library writes a global
`cursor: ... !important` while dragging, and nothing before it.

Fixed on the shared handle rather than at one call site: every split surface
(chat, inbox, issue detail, project detail, the agent builder) was missing the
same affordance. The library's drag-time rule still outranks this one, so the
cursor keeps narrowing to `e-resize` / `w-resize` once a panel hits its bound.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* feat(agents): render the builder's draft block as an inspectable row

Every builder reply ends in an <agent_draft> block that rewrites the form on
the right. Flattening it to a line of prose said that something changed but not
what, and the payload — the only record of what the builder actually claimed —
was unreachable.

A settled reply now carries a full-width row saying the configuration was
updated, which opens the exact payload. A streaming one keeps a text line
instead: the block is still being written, so there is nothing complete to
open, and without the line the half-finished JSON scrolls past.

ChatMessageList gains an optional `renderAssistantAddon`. It is opt-in per
surface and undefined everywhere but this one, because no other chat speaks
this protocol — the alternative was to keep pushing an embedded protocol
through `transformContent`, which can only ever produce prose.

`extractBuilderDraftBlock` returns an unparseable payload verbatim rather than
withholding it: a malformed block is exactly when someone wants to read it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): address review blockers on the resumable builder

1. Migration prefix collision. `251_agent_runtime_unbind` landed on main after
   this branch cut, so the backend's prefix-uniqueness guard failed. Renumbered
   to 252.

2. #6287 — the manual form still lost everything. The route split moved where
   you land, not what survives: the draft was `useState`, so a tab switch (the
   desktop shell mounts only the active tab) remounted it empty, and the
   beforeunload guard covered a hard reload and nothing else. It now persists
   through the repo's draft-store factory, scoped by what is being created — a
   blank agent and a copy of agent X are different work, and a copy of X is not
   a copy of Y — cleared once the agent is committed, and registered for logout
   / workspace-delete cleanup.

3. A saved draft with no messages was unreachable. The configuration form is
   editable from the moment a builder session exists and autosaves, so someone
   could open it, type a name and leave before the first turn; the list keyed
   "is this a draft" on messages alone, so that row existed and nothing could
   reach it. A session now qualifies on a message OR a stored draft, and sorts
   by whichever it has.

4. The debounce dropped the last edits. Its timer died with the component, so
   navigating away inside the 800ms window lost exactly the keystrokes the user
   had just made. The pending payload is now flushed on unmount.

`useUnsavedDraftWarning` is gone with its last caller: both routes persist, so
the browser prompt would have been warning about work that is already saved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(ui): stop the resize cursor flipping mid-drag

The library narrows its cursor the moment a panel hits a bound — col-resize
while both directions are open, a one-way arrow once only one is. Truthful, but
it reads as a glitch: the icon changes under your hand halfway through a drag
you never stopped making.

`disableCursor` turns that global rule off; the handle's own `cursor-col-resize`
is now the only source. A drag captures the pointer and walks it across the
panels, away from the 8px handle, so the group carries the same cursor for as
long as a separator is active — otherwise it would fall back to a text caret the
instant the pointer left the handle.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): key manual drafts by owner, drop the draft-block rendering

Two changes.

One slot destroyed the other flow's work. The manual draft was stored under a
single key: opening a blank form, or a copy of a different agent, refused to
adopt the stored draft and then immediately wrote its own empty form over it —
so a half-finished copy of agent A died the moment the user opened anything
else, before typing a character. Drafts are now keyed by what is being created,
the same shape the chat composer uses for its per-session drafts, and a slot is
dropped when its content is gone rather than parked blank (which also stops the
map growing a dead key per agent ever opened for duplication). Committing an
agent clears that flow's slot only.

The `<agent_draft>` block goes back to being hidden outright. Labelling it and
opening its payload dressed up machinery as content: the block drives the
configuration form, and the form is where its effect is already visible.
`renderAssistantAddon` goes with it — ChatMessageList is back to what it was,
since no surface needs the slot. The two-pattern strip stays: an unterminated
block is what streaming produces, and without matching it the raw JSON scrolled
past the reader on every turn.

Also removed six barrel exports nothing imported through, and unexported five
types only their own file used.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): keep a manual draft whose only edit is a picker

The "is this worth storing" predicate listed six fields by name, and the draft
serializes eleven. A form whose only change was the model, the thinking level,
the service tier, the access scope or a team grant read as untouched, so the
next save deleted its slot — picking a model before typing a name and switching
tabs lost the model.

Enumerating was the mistake, not the specific omissions: the predicate stops
covering every field added after it is written, and the failure is invisible
because each field saves correctly as long as some *other* field is also set.
It now compares the whole draft against a fresh one. The runtime stays outside
that comparison, on the entry rather than in the draft, because the form seeds
it on every visit and counting it would store a draft for a form nobody
touched.

Covered field by field, one edit at a time, so a future field cannot quietly
fall out.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-03 19:00:00 +08:00
f48bd655bc MUL-5562: optimize application-owned workspace deletion (#6230)
* fix(workspace): optimize application-owned deletion

Co-authored-by: multica-agent <[email protected]>

* fix(workspace): address deletion review findings

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-31 16:09:19 +08:00