* fix(realtime): drop WS frames without a string type (MUL-3418)
The onmessage handler dispatched every parsed frame to onAny and the
ws.on subscribers without validating its shape. A frame whose parsed JSON
lacked a string `type` (an out-of-protocol frame injected by a proxy /
extension, or a bare JSON primitive) reached the realtime-sync onAny
dispatcher, where `msg.type.split(":")` threw an uncaught TypeError out of
onmessage. The browser reported it via window.onerror, flooding PostHog
`$exception` (~12k events). Non-fatal (no crash, connection stays up), but
pure noise.
Validate once at this trust boundary: a frame must be an object carrying a
string `type`, otherwise drop it as a no-op. The bad-frame log is
rate-limited to one entry per connection — a misbehaving source can repeat
the frame hundreds of times per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(analytics): drop benign ResizeObserver exceptions from telemetry
ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test: enable -race detector in Go test pipeline (WOR-61)
Add the -race flag to all three Go test invocation sites so the existing
concurrency regression harness (workdir_race_test.go for #3999,
runtime_gone_test.go, runtime_profile_drift_test.go) actually exercises
the race detector. The daemon package alone has 28+ goroutine launch
points with no automated race coverage before this change.
Sites updated:
- Makefile:299 (make test, local)
- .github/workflows/ci.yml:101 (CI backend job)
- .github/workflows/release.yml:55 (release verify job)
go test already runs a vet subset by default, so no separate -vet flag
is added. No production code touched.
Co-authored-by: multica-agent <github@multica.ai>
* test(execenv): serialize runtimeGOOS-mutating test (WOR-61)
TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged called
t.Parallel() while mutating the package-level runtimeGOOS to drive the
windows/linux branches, racing with the other parallel tests that read
runtimeGOOS in buildMetaSkillContent. The -race flag enabled in the
prior commit surfaced it as 3 WARNING: DATA RACE reports and 11
"race detected" failures in CI (only the execenv package failed).
Drop t.Parallel() and add the "// Not parallel: mutates the package-level
runtimeGOOS." comment already used by the six sibling writer tests across
execenv_test.go and reply_instructions_test.go. This is test-isolation
only; no production code, no mutex/atomic, no signature change.
Verified locally:
go test -race -count=1 ./internal/daemon/execenv/ -> ok 2.276s
go test -race -count=1 ./internal/daemon/... -> all 3 pkgs ok
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): discover local skills from ~/.agents/skills (MUL-3333)
Upgrade local skill discovery and import from a single provider root to an
ordered multi-root scan: the runtime's own skill directory (e.g.
~/.claude/skills) first, then the cross-tool universal root ~/.agents/skills.
- Rename localSkillRootForProvider -> localSkillRootsForProvider, returning
ordered roots [provider, universal] with a kind classifier.
- listRuntimeLocalSkills iterates the roots, gives each root its OWN visited
set (so a cross-root symlink alias is not collapsed), dedupes strictly by
Key with the provider root winning, and sorts once after the merge.
- loadRuntimeLocalSkillBundle walks the same priority order and only falls
through to the next root on os.IsNotExist; any other stat error is returned
so import never silently resolves a different same-key skill.
- Add a Root ("provider" | "universal") field to the local skill summary
(daemon + handler structs and the TS RuntimeLocalSkillSummary type) so the
UI can label a skill's origin without a future schema break.
Backward compatible: every skill visible today keeps its Key, SourcePath and
FileCount; the universal root only surfaces additional, non-conflicting skills.
Out of scope (follow-up issues): execution-time injection of ~/.agents/skills
into runtimes (e.g. Codex seedUserCodexSkills) and workspace-relative
.agents/skills discovery.
Tests cover universal-root discovery + import, provider-wins conflict
priority, both-roots merge, missing/both-missing roots, nested layouts,
IsNotExist fallback, the no-fallthrough-on-read-error guarantee, and the
per-root visited cross-root symlink alias. Docs updated in en/zh/ja/ko.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): fall through to next root when a same-key dir has no SKILL.md
loadRuntimeLocalSkillBundle previously only fell through to the next root on
os.IsNotExist for the skill DIRECTORY. A provider-root directory that shares a
skill's key but contains no SKILL.md (so listRuntimeLocalSkills descends past
it and surfaces the universal-root skill instead) made load stop on the
invalid provider dir and error — list and load disagreed, and the import the
user picked from the list could not be fetched.
Make the validity predicate match list: a root "has" the skill at a key only
when it is a directory containing a SKILL.md. A missing entry, a non-directory,
or a directory without a SKILL.md all mean "this root doesn't have it" and we
continue to the next root. Only a genuine non-IsNotExist stat error or an
unreadable existing SKILL.md (permission/IO) is returned, so we still never
silently substitute a different-content same-key skill from a lower-priority
root (Eve review #1, preserved by the existing read-error guard test).
Adds regression tests for the provider-dir-without-SKILL.md and provider-non-dir
fall-through cases.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
In context mode (chat) a query merges context items (current page / recently
viewed) with search results into one popup. The old contextLayout made only the
"Recent" group scrollable (`min-h-0`) while every other group was `shrink-0`, and
the Recent `<section>` did not clip its own overflow. When the search groups
(Users/Issues) filled the height, the flex algorithm squeezed Recent toward zero
and its un-clipped rows painted on top of the groups below — the overlap users saw.
Collapse the two render branches into a single `overflow-y-auto` flex column so
every group just stacks and the whole popup scrolls; no group can collapse onto
another. The context variant only differs in width / max-height / chrome.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(desktop): coerce commit-hash versions to valid semver
normalizeGitVersion checked only the first character (/^\d/) to tell a real
version from a bare commit hash. A hash beginning with a digit (e.g.
'2f24057b') passed that check and was stamped as the app version, but bare
'2f24057b' is not valid semver, so electron-updater threw on launch.
Require a full major.minor.patch prefix; anything else (including a
digit-leading hash) falls back to 0.0.0-<hash> as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(desktop): prefix bare-hash fallback with `g` for valid semver
normalizeGitVersion coerced an untagged build's bare commit hash into
`0.0.0-<hash>`, but that is not guaranteed valid semver: an all-digit
short hash with a leading zero (e.g. `0123456`) produced `0.0.0-0123456`,
and a numeric semver pre-release identifier must not have a leading zero.
electron-updater then threw on launch for exactly the untagged builds
this fallback exists to protect.
Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
shorthand) so the pre-release is always a single alphanumeric identifier.
Add a regression test for the all-digit leading-zero case.
Addresses PR #4183 review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#4288 swapped the chip cap from a fixed `max-w-72` to `max-w-[min(18rem,100%)]`.
A percentage max-width on a flex item is dropped while its flex-container wrapper
(`<a class="inline-flex">`) computes its own max-content size, so the wrapper
ballooned to the untruncated title width while the chip truncated to the cap —
leaving an empty, clickable strip after the visible chip.
Fix it as standard atomic-inline behavior instead of a fixed magic cap:
- IssueChip caps at `max-w-full` with the title truncating to an ellipsis, so it
wraps to the next line as a unit and only truncates once a whole line can't
hold it.
- Drop `inline-flex` from the editor NodeView `<a>` (issue + project) and the
markdown AppLink so the chip's only wrapper is a plain inline box with a
definite (line-based) percentage basis — no second flex container to balloon.
ProjectChip uses a definite `max-w-72`, so it never hit the gap; left as-is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a dry-run-first operator command for historical Codex usage cache correction, packages it in the backend image, documents the operator-job flow, and covers execution with DB-backed tests.
Bundles the MUL-3404 disk-usage feature with the two Preflight BLOCK fixes.
- feat(daemon): `disk-usage --all-profiles` aggregates across every workspace
root (default + each ~/.multica/profiles/* root, incl. the Desktop app's),
with a per-root breakdown and combined grand total; the cross-root hint now
also fires when the current root is non-empty.
- fix(db): drop DB-level foreign keys/cascades from the new autopilot_subscriber
and comment.source_task_id migrations (resolved in the app layer — autopilot
delete now removes subscribers in a transaction); the autopilot_subscriber
down-migration relabels reason='autopilot' to 'manual' instead of deleting.
- fix(server): readiness verifies every required migration is applied, not just
the lexically-last one, so an out-of-order migration can't be masked.
MUL-3404.
* fix(daemon): reclaim autopilot_run workdir on terminal status (MUL-3403)
Autopilot run workdirs are never reused — there is no PriorWorkDir path
that hands a later run the same directory, so every run gets a fresh one.
Yet GC waited the full GCTTL (default 24h) before reclaiming a terminal
run's dir. Combined with one fresh dir per run, high-frequency autopilots
piled up hundreds of stale dirs (508 dirs / 22GB in the field report).
Drop the TTL gate so a terminal run (completed/failed/skipped/
issue_created) is reclaimed immediately, mirroring gcDecisionQuickCreate.
Existing safety constraints are untouched: active-env-root short-circuit,
404 -> orphanByMTime, non-404 error -> skip, and the local_directory
override all still apply.
Co-authored-by: multica-agent <github@multica.ai>
* docs(daemon): fix GetAutopilotRunGCCheck comment — completed_at is not a TTL anchor
The endpoint comment still claimed the daemon uses completed_at as the TTL
anchor for terminal runs. GC now decides purely on terminal status (the
workdir is never reused, so a terminal run is reclaimed on sight);
completed_at is returned for the API contract / diagnostics only. Addresses
the review nit on #4287.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The create-workspace and onboarding UI hardcoded `multica.ai/` as the
workspace slug URL prefix, so self-hosted deployments showed the wrong
domain. Add a `workspaceUrlHost` helper that derives the host from the
deployment's app URL (`daemon_app_url` from `/api/config`, via the config
store) and falls back to the brand host when none is configured, then use
it in both views. Fixes#4263.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Disambiguate client-side model pricing by provider: generic ids (e.g. `auto`) resolve ${provider}/${model} first, so they only price under their real provider instead of borrowing Cursor's rate. Provider is LOWER()-normalized on read and write so mixed-case historical rows merge.
Closes#4199. MUL-3346
Extract three module-local helpers in content-editor.tsx and route the
duplicated call sites through them — no behavior change:
- normalizeMarkdown(md) / normalizeEditorMarkdown(editor): the single
definition of the "strip blob URLs + trimEnd" canonical form, replacing
the five editor-markdown sites and the one incoming-string site.
- hasUploadingNode(editor): replaces the two byte-identical document scans
(content-sync Guard 0 and hasActiveUploads).
The imperative getMarkdown() is deliberately left untrimmed; a safety-net
test pins its exact current return value.
Scope: WOR-59 upload-readiness review. file-upload.ts untouched; follow-up
items B1/F4/F5 are out of scope.
Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The `codebuddy` case in ProviderLogo was aliased to ClaudeLogo when the
backend was integrated (#3186), so CodeBuddy runtimes rendered the Claude
mark and were visually indistinguishable from Claude in the runtime list,
runtime detail, agent runtime picker, and onboarding.
Add a dedicated CodeBuddyLogo using the official mark shipped in Tencent's
own @tencent-ai/codebuddy-code CLI package (dist/web-ui/logo.svg) — the
same CLI this runtime spawns. The artwork overflows the 24×24 box and is
cropped by a clipPath whose id is per-instance (useId), mirroring KiroLogo,
so multiple logos on one page don't collide on a shared id.
Presentation-only: no provider string, backend/API, DB, daemon/runtime,
CLI, mobile, or copy changes. Shared views package, so web + desktop both
pick it up.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): workspace-scoped attachment binding + fire-and-forget send
Uploads are now workspace-scoped: the chat session is created and
attachments are bound to the message at send time, so a paste/drop no
longer creates an empty session the user never sends.
- LinkAttachmentsToChatMessage returns the ids it actually bound; the
client diffs requested-vs-bound and warns on partial bind, replacing
an extra listChatMessagesPage fetch.
- Cancelling an empty chat task detaches attachments before deleting the
user message (attachment FK is ON DELETE CASCADE) and returns them via
cancelled_chat_message.attachments, so a restored draft can re-bind.
- SendChatMessageResponse.attachment_ids has no omitempty: "requested but
bound zero" serializes [] so the client can tell it apart from an older
server and still warn.
- Send is fire-and-forget: it no longer steals focus when the user has
navigated to another session (guarded on the live store + new-chat agent
id); the reply surfaces via the unread dot. commitInput gets clearEditor
so a navigated-away commit doesn't wipe the editor now showing another
session, while still clearing the sent draft's data.
- Draft restore is session-aware so a failed fire-and-forget send restores
into the session it was sent from, never the one the user moved to.
- Removed the now-unreferenced migrateInputDraft store action.
Verified: core/views typecheck, chat-input (15) / store (3) / api client
(24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser
DB tests. Full make check / E2E left to CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(chat): guard attachment survival on empty-chat cancel
Cancelling an empty chat task deletes the user message, and
attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the
detach-before-delete in finalizeCancelledChatMessage is the only thing
keeping the user's attachment from being silently destroyed. Nothing
covered it.
Add a DB regression test that binds an attachment to the cancelled user
message and asserts: the row survives the cascade (chat_message_id NULL,
chat_session_id retained), the cancel response returns it via
cancelled_chat_message.attachments, and a resend re-binds it to the new
message. Verified red when the detach step is removed.
Related issue: MUL-3364
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(comment): pessimistic submit for comment/reply composers
The comment and reply composers cleared the editor after `await onSubmit`
returned, with no in-flight lock. On a slow send the WS `comment:created`
event already dropped the real comment into the timeline while the box
still held the same text + spinner, so it read as two comments. And
because `submitComment`/`submitReply` swallow errors (toast, no rethrow),
a failed send still reached `clearContent` and silently discarded the
user's draft.
Recover the comment/reply portion of the closed#4236: make the submit
callback resolve a success boolean (true on success, false on the caught
failure), lock the editor while in flight (pointer-events-none + dimmed
wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable`
post-mount), keep the button spinning, and clear only on success — a
failed send keeps the draft. Chat composer is out of scope (already
reworked on this branch); attachment binding is untouched.
Adds two view tests (in-flight lock then clear-on-success; failed send
keeps the draft); both verified red against the un-fixed code.
Related issue: MUL-3364
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): default subscriber template (MUL-2533) — server
Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.
API: POST/PATCH /api/autopilots accept a `subscribers` array (member
user_type only for the first version); PATCH semantics are full-replace.
GET returns subscribers on the detail endpoint; the list endpoint omits
them to avoid an N+1.
Dispatch: dispatchCreateIssue lists the template inside the same tx as
the issue insert and writes the rows with reason='autopilot' before
EventIssueCreated fires, so notification listeners see the full
subscriber set on the first event.
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): default subscriber template (MUL-2533) — frontend
New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.
TS types expose the new `subscribers` field on Autopilot, plus an
AutopilotSubscriberInput shape for the create/update wire payloads.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): notify template subscribers on issue creation (MUL-2533)
The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").
Fix it where the divergence lives: in dispatchCreateIssue, right after
EventIssueCreated fires, write an inbox_item (type='issue_subscribed',
severity='info') for each member subscriber and publish EventInboxNew so
the recipient's inbox WS feed updates in real time. The write is after
the tx commit so an inbox hiccup can't roll back the issue; failures are
logged, not propagated. The manual path is unchanged — manual subscribers
don't exist at creation time, so there is nothing to notify there.
Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): align subscriber PR with current main
Co-authored-by: multica-agent <github@multica.ai>
* fix autopilot subscriber template transaction
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The runtimes page header has three outline action buttons — "Add runtime",
"Cloud Runtime", and "Add a computer" — that previously rendered with
inconsistent dimensions and responsive behavior:
- "Add a computer" used `h-8 w-8 ... md:w-auto md:px-2.5` (icon-only
below md, expands to icon+label on md+), with an `aria-label` for the
icon-only state and the label wrapped in `<span class="hidden md:inline">`.
- "Add runtime" had no responsive className, no aria-label, and the label
stayed visible at every width.
- "Cloud Runtime" had the same gaps as "Add runtime", plus the Cloud
icon was sized `h-3 w-3` instead of `h-3.5 w-3.5` like the others.
Visually they read as three different button styles crammed into one
header. Aligning all three to the "Add a computer" pattern gives the
header a single, consistent action group at all breakpoints, fixes the
mobile layout (header no longer wraps onto multiple lines for users with
both Cloud Runtime enabled and admin role), and makes every icon-only
state screen-reader accessible.
Changes:
- Apply `h-8 w-8 gap-1 px-0 md:w-auto md:px-2.5` to all three buttons.
- Add `aria-label` to "Add runtime" and "Cloud Runtime".
- Wrap the label of "Add runtime" and "Cloud Runtime" in
`<span className="hidden md:inline">` so they collapse to icon-only
below md, matching "Add a computer".
- Normalize the Cloud icon size from `h-3 w-3` to `h-3.5 w-3.5`.
Verification: typecheck + lint + 1395 unit tests across @multica/views
all pass.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The Custom runtimes dialog repeated the same idea in three places: the
header tagline, the empty-state body, and the right-panel default state.
Per MUL-3367, tighten the prose so each surface adds new information:
- dialog_description: short tagline that points at the protocol families
list directly below.
- empty_description: drops the "such as Claude or Codex" example (already
enumerated in the protocols list right under it) and the "your daemon
should run" filler.
- detail.default_title / default_description: replace the
"Manage custom runtimes" + paragraph (which restated the header) with
a quiet "Select a runtime" placeholder.
- detail.default_builtin_hint: dropped entirely. The supported-protocols
section is already visible in the left column and each row carries a
Reference badge — pointing at it from the right panel is noise.
Updated en, zh-Hans, ja and ko locales in lockstep, plus the dialog test
assertions.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): don't auto-link bare filenames as external URLs
Agent comments that mention a project file like `plan.md` were turned into
clickable links to https://plan.md (dead external site). linkify-it fuzzy
detection matches `plan.md` as a domain because its extension is also a valid
TLD (md = Moldova; likewise io, sh, rs, py).
Suppress schemeless (fuzzy) linkify matches whose token is a bare filename
(single segment ending in a known source/config extension). Explicit schemes
(`https://plan.md`) and real domains (`example.com`) are unaffected. The file
extension list is now shared between the file-path and bare-filename detectors
so they can't drift.
Fixes#4222
Co-authored-by: multica-agent <github@multica.ai>
* docs(markdown): drop inaccurate .io example from bare-filename comment
io is not in the FILE_EXTENSIONS list, so .io domains are never suppressed.
Listing it as an example was misleading.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The runtime detail page disabled the Delete button — and the list-page
kebab dropped its only action — whenever the runtime was an online
local daemon (isSelfHealingRuntime). The intent was to spare users a
delete that the daemon would silently undo via re-register, but the
side effect was an Owner staring at an action they had every
permission for, with no obvious explanation.
The fix moves the warning into the confirmation dialog instead of
hiding the action:
- runtime-detail.tsx: drop the disabled+tooltip branch around the
Diagnostics card Delete button. canDelete (workspace owner/admin OR
runtime owner) now fully governs visibility, and the button is
always clickable when present.
- runtime-list.tsx: the row kebab no longer hides itself for
self-healing runtimes. showActions follows along.
- delete-runtime-dialog.tsx: drop the defensive self-healing guard in
handleConfirm (it returned early with a toast on the very click the
user just confirmed). Add a SelfHealNotice banner that renders in
both LightBody and CascadeBody when isSelfHealingRuntime is true,
so the user sees the trade-off before pressing the destructive
button rather than after.
- locales: drop delete_disabled_tooltip and the cascade
self_healing_blocked_toast across en/zh-Hans/ja/ko; add a single
detail.delete_dialog.self_heal_notice key that powers the banner.
- tests: flip the row-menu self-healing assertion (kebab is now
visible), add a banner-present / banner-absent / confirm-proceeds
triplet to delete-runtime-dialog.test.tsx, and pin the
enabled-Delete-for-owner case on runtime-detail-visibility.test.tsx.
Verified with @multica/views typecheck (clean) and a targeted vitest
run across the four affected suites — 73/73 tests pass. Lint stays at
0 errors on the package.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): surface in-flight CI on PRs and recover out-of-order check_suite events
Two bugs caused PR cards to render "checks not reported yet" while CI
was actually running (MUL-2392):
1. handleCheckSuiteEvent dropped every action except `completed`, so
`requested`/`rerequested` events (status queued/in_progress) never
landed in the suite table. Aggregated `checks_pending` stayed at 0
until the first suite finished, and the frontend fell through to the
unknown bucket. Persist all actions; the ListPullRequestsByIssue
aggregation already counts status<>completed as pending.
2. A check_suite for an unmirrored PR was logged and dropped, with no
replay path. Add a `github_pending_check_suite` stash keyed by
(workspace, repo, pr_number, suite_id); the pull_request webhook
drains it after the PR upsert and replays each entry through the
normal check_suite upsert. One-shot drain via DELETE … RETURNING
keeps it idempotent and free of retry storms.
Follow-ups for fork PRs (empty `pull_requests[]`) and a more specific
frontend placeholder ship in separate issues.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): guard pending check_suite stash against out-of-order events
UpsertPendingCheckSuite previously overwrote unconditionally on
conflict, so an older `requested/in_progress` event arriving after a
newer `completed/success` for the same suite would roll the stash
back to pending. The subsequent PR upsert then drained the stale
state and the PR card stuck on "pending" until the next suite. Mirror
the suite_updated_at guard from UpsertPullRequestCheckSuite and add a
regression test covering the PR-missing path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): unify trigger chip copy to will-start phrasing
Make the comment trigger chip's on/off states symmetric around the verb
'start' instead of mixing natural language with the 'trigger' jargon:
- on: Will start when sent
- skipped: Won't start this time
- all skipped: No agents will start
- multi on: N agents will start when sent
Updates all four locales (en/zh-Hans/ja/ko); CJK on-state copy already
reads as future-conditional so only the skip states are realigned to the
'start' verb. Updates the component test expectations to match.
Refs MUL-3211
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): align restore hint to will-start phrasing
Carry the trigger-chip copy unification into the suppressed-agent restore
hint (trigger_click_to_restore), the last surface still mixing 'trigger'
with the chip's 'start' wording:
- en: Won't start this time. Click to restore.
- CJK: skip-state term realigned to the 'start' verb, rest unchanged.
Refs MUL-3211
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): trim trigger preview popover copy and drop redundant reason lines
The active hover popover stacked header + reason + presence, repeating the
same fact across lines and again against the chip. Tighten it:
- Drop the reason line for assignee / @mention: the header (name · source)
already conveys why they fire. Keep reason only for squad-leader (the link
is non-obvious) and the unknown fallback, both trimmed of the duplicated
name.
- Shorten presence (Starts right away. / Offline now — starts once online.)
and de-jargon the skip/manage hints (no more 'trigger').
- Align the popover title to the chip wording (Will start when sent).
All four locales updated; removes the two now-unused reason keys.
Refs MUL-3211
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The agent record already carries a top-level `thinking_level` field —
exposed by `agent get --output json`, settable from the web inspector,
and accepted/validated by `PUT /api/agents/{id}` — but the CLI had no
flag to write it. Scripted or version-controlled agent management could
set `--model` but not the thinking depth, forcing a drop to raw HTTP.
Add `--thinking-level` to `agent create` and `agent update`, mirroring
`--model`: a thin pass-through to the top-level `thinking_level` field.
On update an empty string clears it back to the runtime default (the
server reads it as a tri-state pointer: omitted = no change, "" = clear,
value = set). The CLI deliberately does not enumerate valid levels —
they are runtime/model-specific and the server already owns the catalog
(`agent.IsKnownThinkingValue`, `server/pkg/agent/thinking.go`), returning
a 400 for an unknown value or a runtime with no thinking concept, which
the CLI surfaces verbatim.
- server/cmd/multica/cmd_agent.go: register the flag on both commands,
Changed-gate it into the request body, add it to the no-fields error.
- server/cmd/multica/cmd_agent_test.go: cover create/update send,
unset-omission, empty-clears, the flag-exposed guard, and that a
server-side rejection surfaces to the user.
- multica-creating-agents builtin skill + source map: document the new
CLI write surface and re-derive shifted cmd_agent.go line numbers.
Closes#4170🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
* feat(analytics): session-level $exception dedupe in before_send
A runaway client error (a render loop, a polling fetch that keeps
throwing) emits 100+ identical $exception events per session, which
showed up as a top PostHog cost/noise source after exception
autocapture landed (MUL-3331 / MUL-3330).
Add a per-tab-session fuse in before_send, after redaction: fingerprint
the already-redacted exception (type + redacted value + one deterministic
stack frame incl. colno), keep the first 3 per (session, fingerprint),
drop the rest. State lives in sessionStorage as a hash->count blob, so no
PII is persisted. Every storage failure fails open (keep the event);
before_send never throws.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(diagnostics): global 60s cooldown for client_unresponsive
A single sustained freeze is delivered as several long-task entries, so
emitting per entry made client_unresponsive volume grow without bound
with the freeze length (MUL-3331). Cap it with a module-level (page-
lifetime) global cooldown: at most one event per 60s window. No route
bucketing — a global window is the most direct cap on volume.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(analytics): add exception-dedupe safety matrix
This file was written alongside the dedupe implementation but missed the
original commit, so the $exception fail-open / cap / fingerprint matrix
never landed on the branch. No implementation change — the tests pass as
written against the existing exception-dedupe.ts.
Covers: first-3-then-drop, fingerprint independence, colno discrimination,
hash-only storage (no PII), degraded/missing frames, undefined / throwing
/ corrupt-JSON sessionStorage fail-open, setItem-failure under-counts, and
the distinct-fingerprint cap (51st new fingerprint kept).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
`multica version` now outputs two lines (version + go info). The old
`awk '{print $2}'` captured both lines, causing the version comparison
to always mismatch and triggering unnecessary upgrades.
Fixes https://github.com/multica-ai/multica/issues/4226
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* MUL-3332: daemon picks up new custom runtime profiles without restart
The workspaceSyncLoop's already-tracked branch refreshed only settings and
repos via refreshWorkspaceRepos and never re-fetched runtime profiles, so
a custom runtime profile created via the web UI / CLI did not become a
registered runtime row until the daemon restarted (or a runtimeGone
recovery happened to fire).
Detect server-side profile drift each sync tick by hashing the workspace's
profile list with profileSetSignature(), caching the digest on
workspaceState.profileSetSig, and triggering reregisterWorkspaceAfterRuntimeGone
when the live signature differs from the cached one. Steady-state syncs cost
exactly one extra GetRuntimeProfiles round trip; only real drift fans out to
a Register call.
The fetch is best-effort: a 404 / network blip preserves the cached signature
so a transient failure cannot loop the daemon into spurious re-registrations.
Tests in runtime_profile_drift_test.go cover digest stability under reorder,
field-by-field drift detection (add / enable-flip / command_name /
protocol_family / fixed_args / visibility), the no-drift hot path (no
re-register), the new-profile drift path (single re-register + index update +
sig converges), and best-effort fetch error handling.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3332: split orphan recovery from profile drift; converge to zero
Addresses two blocking review concerns on #4225 (raised by GPT-Boy):
1. Profile drift must not kill running tasks on existing runtimes.
The first cut reused reregisterWorkspaceAfterRuntimeGone, which after
re-register calls /recover-orphans for every returned runtime ID. The
server's RecoverOrphanedTasksForRuntime hard-fails every
dispatched/running/waiting_local_directory row on that runtime — the
correct response when a runtime row was actually deleted server-side,
but a catastrophic false positive on profile drift: a built-in runtime
still actively executing the user's tasks would have its work killed
just because the user added an unrelated sibling custom profile.
Fix: extract applyRegisterResponseInPlace as the shared in-place state
converger between the two paths, and stop calling /recover-orphans from
the drift path. reregisterWorkspaceAfterRuntimeGone keeps the
/recover-orphans call because in that path the rows really were gone.
2. Disabling the only profile on a custom-only daemon must converge.
The first cut hit registerRuntimesForWorkspace's len(runtimes)==0 guard
and bailed out, so the disabled profile's runtime stayed alive in
local tracking and on the server (still polling, still heartbeating,
still online for the full 150 s stale-heartbeat window).
Fix: introduce ErrNoRuntimesToRegister as a sentinel, have
registerRuntimesForWorkspace return profileSig even on the empty case
(so the drift path can cache the converged-empty signature), and have
the drift refresh's error handler take a convergeWorkspaceRuntimesToZero
branch that clears local runtimeIDs / runtimeIndex entries and
Deregisters the orphaned IDs so the server marks them offline
immediately. The same Deregister step also runs on partial drift (a
built-in survives, the disabled profile's runtime drops) so the user
sees the dropped runtime go offline within the next sync tick instead
of after the 150 s sweep.
Tests:
- TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery
(mixed built-in + custom, add another profile, asserts zero
/recover-orphans calls).
- TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon
(custom-only daemon, disable only profile, asserts local state
cleared, signature converges to empty digest, Deregister called with
the orphaned ID, no recover-orphans, follow-up tick is no-op).
- TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID
(partial drift: only the dropped ID is Deregistered, surviving
built-in is left alone and not orphan-recovered).
- TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister
extended to also assert no /recover-orphans calls.
- TestRegisterRuntimes_SkipsProfileNotOnPath strengthened to assert the
ErrNoRuntimesToRegister sentinel and that profileSig is still returned
on the empty path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
/ unset-path for a per-machine command override. protocol-family validated
client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
validated; update only sends changed flags (protocol_family immutable);
delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
appendProfileRuntimes now prefers an override path when set AND executable,
else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
relative-path rejection, config preservation); cli/config round-trip;
daemon prefers-override / falls-back-when-not-executable.
Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page
Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).
- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
RuntimeProtocolFamily / RuntimeProfile types; api client
list/get/create/update/deleteRuntimeProfile against
/api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
protocol family → step 2 display_name/command_name/description; edit form
for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
create/edit/delete flow is keyboard + screen-reader operable.
Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.
Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)
Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".
- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
detail row, the create body field, the update patch field, and the form
textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
update and stopped sending `fixed_args`; updated the "no fields" message.
Test now asserts the CLI never sends fixed_args.
Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.
Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)
Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.
- Server (runtime_profile.go): drop `visibility` from the create + update
request structs; CreateRuntimeProfile always stores 'workspace'
(runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
removed validRuntimeProfileVisibility. The column + response field stay
(always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
the VisibilityOption component, the detail row, the visibility state, and the
create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
(profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
the VISIBILITY list column; removed validateVisibility; stopped sending the
field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
visibility:"private" -> response and DB row are 'workspace'); CLI create test
now asserts visibility is never sent.
Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.
Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: add runtime_profile schema (custom runtime PR1)
Schema-only foundation for custom runtimes. Additive migration 120:
- New workspace-level `runtime_profile` table: the shared, team-visible
definition of a custom runtime (e.g. an in-house Codex wrapper).
protocol_family is CHECK-constrained to the exact backend list in
agent.New() (server/pkg/agent/agent.go). The only args column is
`fixed_args` (args every agent on the runtime must inherit); there is
deliberately no generic per-agent args field — those stay on
agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
(workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.
The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.
Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)
Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):
- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
-> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
-> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
-> plain UUID.
CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.
Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.
Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2 (server): runtime_profile CRUD + profile-aware registration
Server/DB half of the custom-runtime feature.
- Migration 121: convert the legacy UNIQUE (workspace_id, daemon_id, provider)
constraint on agent_runtime into a partial unique index scoped to built-in
rows (WHERE profile_id IS NULL). With 120's partial index on profile_id this
lets one daemon host the built-in provider AND custom profiles of the same
protocol family without collision.
- Queries: runtime_profile CRUD; ListEnabledRuntimeProfilesForWorkspace
(daemon-facing); CountAgentsByProfile + DeleteAgentRuntimesByProfile for the
app-layer cascade; profile-aware UpsertAgentRuntimeWithProfile; the built-in
UpsertAgentRuntime ON CONFLICT now spells out WHERE profile_id IS NULL so it
targets the right partial index. sqlc regenerated.
- agent.SupportedTypes / IsSupportedType: single-source protocol_family
whitelist, in lockstep with agent.New and the migration 120 CHECK.
- Handlers + routes: runtime_profile CRUD (member-read, admin-write) with
protocol_family whitelist validation, display_name uniqueness (409), and
fixed_args validation (no generic per-agent args — iron rule); a
daemon-token endpoint GET /api/daemon/workspaces/{id}/runtime-profiles;
DeleteRuntimeProfile does the app-layer cascade (delete instance rows then
profile, in one tx) and refuses (409) while active agents are bound.
- DaemonRegister accepts an optional per-runtime profile_id: validates the
profile belongs to the workspace and is enabled, registers via the
profile-aware upsert, and skips legacy hostname merge for custom rows.
AgentRuntimeResponse now carries profile_id.
Verified on Postgres 17: migrate up through 121; built-in + custom codex
coexist on one daemon; both upsert arbiters are idempotent; delete-by-profile
cascade removes only the custom instance; migrate down reverses 121 then 120
and replays clean. go build ./... and go vet pass; handler test package
compiles.
Daemon-side wiring (fetch profiles, PATH-resolve command_name, register with
profile_id, exec uses command_name) lands in a follow-up commit on this branch.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2 (daemon): pull profiles, PATH-resolve, register, exec command
Daemon-side half of custom runtime profiles, against the server contract on
this branch.
- client.go: GetRuntimeProfiles(workspaceID) -> GET
/api/daemon/workspaces/{id}/runtime-profiles (mirrors GetWorkspaceRepos);
RuntimeProfile / RuntimeProfilesResponse types.
- types.go: Runtime gains profile_id (parsed from the register response so
runtimeIndex carries it).
- daemon.go:
* appendProfileRuntimes — called inside registerRuntimesForWorkspace before
the empty-runtimes guard. Best-effort fetch (older server 404s are logged
and swallowed; never fails registration). Per enabled profile: resolve
command_name via PATH (exec.LookPath, behind a `lookPath` test hook),
skip+log when absent, best-effort version probe, record the resolved
absolute path keyed by profile_id, and append a registration entry
{name, type=protocol_family, version, status:online, profile_id}. A
custom-only host (no built-in agents) still registers.
* profileCommandPaths map (guarded by d.mu) + recordProfileCommandPath /
customCommandPathForRuntime helpers.
* runTask: looks up the claimed task's RuntimeID -> profile command path and
overrides the executable path, synthesizing an AgentEntry so a custom
runtime runs even when the host has no built-in agent of the same
provider. provider (=protocol_family) is unchanged so agent.New still
selects the right backend.
- Tests: GetRuntimeProfiles request shape; profile runtime appended + path
recorded (custom-only host); profile skipped when command not on PATH;
profiles-fetch-404 is best-effort; customCommandPathForRuntime bookkeeping.
- agent: lockstep test pinning SupportedTypes to agent.New and the migration
120 protocol_family CHECK.
Iron rule honored: profile carries no generic per-agent args. fixed_args are
parsed and carried but intentionally NOT wired into the launch command yet
(optional/best-effort; explicit TODO(MUL-3284) in appendProfileRuntimes).
Verified: go build ./... clean; go vet ./internal/daemon/... clean;
go test ./internal/daemon/... pass (existing + 5 new); full
go test ./internal/handler/ suite passes against a migrated Postgres 17;
agent lockstep test passes.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2: profile delete runs full archived-agent cascade (fix 500)
Review fix. DeleteRuntimeProfile previously guarded only on ACTIVE agents, but
agent.runtime_id is ON DELETE RESTRICT — a profile whose runtimes had only
ARCHIVED agents passed the guard, then DeleteAgentRuntimesByProfile hit the FK
and the handler 500'd.
Now it mirrors the mature runtime-delete cascade (DeleteAgentRuntime): in one
transaction it enumerates the profile's runtime rows, refuses (409) any with
active agents or active squads led by archived agents, then for each runtime
pauses autopilots pinned to its archived agents, drops archived squads led by
them, and hard-deletes the archived agents before removing the runtime rows
and the profile. No code path can now fall through to a raw FK error.
- queries: ListAgentRuntimeIDsByProfile (sqlc regen). Reuses the existing
per-runtime teardown queries (CountActiveSquadsWithArchivedLeadersByRuntime,
ListArchivedAgentIDsByRuntime, PauseAutopilotsByAgentAssignees,
DeleteSquadsByArchivedAgentsOnRuntime, DeleteArchivedAgentsByRuntime).
- tests: TestDeleteRuntimeProfile_ArchivedAgentCascade (archived-only profile
deletes cleanly: 204, runtime + archived agent + profile gone) and
TestDeleteRuntimeProfile_ActiveAgentBlocks (active agent → 409, survives).
Verified against Postgres 17: both new tests pass; full handler suite, daemon
tests, and agent lockstep test pass; go vet clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: add runtime_profile schema (custom runtime PR1)
Schema-only foundation for custom runtimes. Additive migration 120:
- New workspace-level `runtime_profile` table: the shared, team-visible
definition of a custom runtime (e.g. an in-house Codex wrapper).
protocol_family is CHECK-constrained to the exact backend list in
agent.New() (server/pkg/agent/agent.go). The only args column is
`fixed_args` (args every agent on the runtime must inherit); there is
deliberately no generic per-agent args field — those stay on
agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
(workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.
The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.
Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)
Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):
- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
-> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
-> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
-> plain UUID.
CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.
Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.
Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): don't wipe in-flight uploads on external content sync
When a brand-new chat's first file upload triggers lazy session creation,
`setActiveSession(null → uuid)` flips ChatInput's draft key mid-upload, which
changes `defaultValue` to the new (empty) session draft. ContentEditor's
"sync external defaultValue" effect then ran `setContent` over a document that
still held the `uploading` image/fileCard node, wiping it — so the upload's
finalize could no longer find the node. The file vanished and the draft was
left with an empty `!file[name]()`.
The editor was never remounted (instance stays alive); the node was removed by
the content-sync effect. An uploading node is local state an external sync must
not overwrite, exactly like the existing dirty/focused guards. Add a guard that
bails the sync while any `uploading` node is present.
Pure frontend; affects only the first upload in a new chat (subsequent uploads
hit an existing session, so no draft-key flip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(editor): cover the in-flight-upload content-sync guard
The content-sync effect now reads `editor.state.doc.descendants` on every run
to detect uploading nodes; the mocked editor didn't implement it, crashing all
ContentEditor tests. Add `descendants` (driven by `editorState.uploadingNodes`)
to the mock and a regression test asserting an external `defaultValue` change
does not setContent while an upload is in flight, and resumes once it settles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): migrate new-chat draft onto the session id on lazy create
The first file upload in a brand-new chat lazily creates the session, flipping
ChatInput's draft key from `__new__:agent` to the session id mid-upload. The
in-progress (empty-href) file-card markdown the editor had already written into
the `__new__:agent` draft was neither migrated nor cleared, so it stayed
stranded under that key — and resurfaced as a stale `!file[name]()` the next
time a new chat opened for the same agent (the send only cleared the
session-keyed draft).
Migrate the `__new__:agent` draft onto the new session id the moment the
session is created (upload path only — text send already clears the pre-flip
key via `keyAtSend`). Add a shared `newSessionDraftKey` helper so ChatInput and
ensureSession agree on the slot name, and a `migrateInputDraft` store action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>