Commit Graph
4970 Commits
Author SHA1 Message Date
Jiayuan Zhangandmultica-agent 8ade96e671 MUL-6584: establish Public API v1 foundation (#7450)
* feat(api): establish public v1 contract foundation

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

* fix(plugins): close public API contract gaps

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

* fix(issues): enforce revision guard during update

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 22:47:37 +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
Jiayuan Zhangandmultica-agent a4e1ea7140 MUL-6581: expose globally versioned Plugin Public API (#7446)
* feat(plugins): expose globally versioned public API

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

* fix(plugins): proxy same-origin public API

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:15:46 +08:00
Jiayuan Zhangandmultica-agent 9622dd55ac MUL-6583: fix archived inbox group counts (#7447)
* fix(inbox): align archive operations with issue groups

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

* perf(inbox): narrow archived group scan

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 16:58:18 +08:00
8d2da6b8db MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step (#7325)
* MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step

Local development had six entry points, thirty-four make targets and no way to
see or delete what they created. `make up` / `status` / `list` / `down` /
`destroy` / `gc` / `env-exec` replace that with one set of verbs, shared by
humans and agents, with `C=api,web,daemon,desktop` selecting components.

Three defects made the old flow untrustworthy rather than merely verbose.

1. The database the tooling created was not the one the application reached.
   `ensure-postgres.sh` creates through `docker exec`; when a native PostgreSQL
   owns 5432 the container never binds the host port, so the create lands in a
   server the backend never talks to. It printed "✓ PostgreSQL ready" and the
   next step died with `SQLSTATE 3D000`. On the machine this was written on the
   two servers had already diverged: 94 same-named databases, 69 more in the
   container with no counterpart. `up` now creates through DATABASE_URL and
   treats a successful `migrate up` — which pings that same string — as the only
   proof; failure diagnoses the port owner instead of reporting a missing
   database.

2. Identity was computed, not allocated. Ports, database names and CLI profiles
   all came from `cksum($PWD) % 1000`, and a collision was silent: the loser
   fails to bind while the winner keeps answering 200. Allocation now happens
   under a lock, probing from the same path hash so a checkout keeps its
   familiar numbers, and requires the slot to be both unregistered and free of a
   live listener. The manifest in ~/.multica/dev/envs/<name>/ is what every
   later command reads.

3. Creating an environment left no record of how to destroy it, so there was no
   destroy — only archaeology. `down` stops processes and keeps the data;
   `destroy` consumes the manifest: processes, database, profile, slot. `list`
   makes leaks visible and `gc` collects environments whose checkout is gone or
   whose TTL passed.

/health now reports pid, commit and started_at. A 200 alone proves something is
listening, which is exactly what let a failed restart keep serving the previous
build; `up` compares started_at against its own launch and refuses an answer
that predates it.

Two constraints are enforced rather than documented: the daemon is started from
a built server/bin/multica, never `go run` (the daemon re-execs its own path for
every task, and `go run` deletes that binary when the launcher exits), and
`C=daemon` under a .multica/daemon_task_context.json marker stops with that
explanation up front instead of spending a login on a refusal.

scripts/dev-env.test.sh covers the registry verbs against a throwaway
MULTICA_DEV_HOME with no services running, and pins the regression that made
`down` exit 1 after reporting success: on bash 3.2 a command substitution whose
function ends in a failing command aborts the script under `set -e`, and "no
process is listening" is that function's normal answer.

`make dev` is unchanged and still runs in the foreground.

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

* fix(dev): close local environment lifecycle gaps

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 16:43:58 +08:00
Jiayuan Zhangandmultica-agent 0716081bbe MUL-6580: add right sidebar toggle shortcut (#7445)
* feat(shortcuts): add right sidebar toggle (MUL-6580)

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

* fix(shortcuts): use Mod+/ for right sidebar toggle

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 15:43:11 +08:00
bedad9e222 MUL-6485: isolate hosted plugin surfaces (#7329)
* 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]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

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

* 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

* fix(plugins): refresh issue-scoped surface launches

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

* feat(plugins): isolate hosted surfaces (MUL-6485)

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

* fix(plugins): refresh issue-scoped surface launches

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

* fix(plugins): make surface termination event-driven

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

* feat(plugins): isolate hosted surfaces (MUL-6485)

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

* fix(plugins): refresh issue-scoped surface launches

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

* fix(plugins): make surface termination event-driven

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 13:47:06 +08:00
Martin MontesandMartin Montes 5a80f802d3 MUL-6558 feat(server): make task queue expiry configurable via MULTICA_TASK_QUEUED_TTL (#7418)
Lets self-hosted deployments raise the queued-task expiry window above the built-in 2h default, so low-concurrency runtimes stop losing legitimately-pending work to queued_expired. Default behavior is unchanged. Covers .env.example, Compose, Helm values/ConfigMap, and the behavior docs in all four locales.

Co-authored-by: Martin Montes <[email protected]>
2026-08-23 12:56:06 +08:00
aa4c504c78 fix(issues): re-rank an issue when its status moves it to a new column (#7422)
position ranks an issue inside its own (workspace, status) column, but a
status change carried the old column's value over unchanged. The value
that put an issue on top of Todo is roughly zero, and lands it below
every hand-dragged issue in Done — so agent-completed work sank into the
middle of a long Done column and read as "the task disappeared".

Re-rank to the top of the destination column on both status-write paths,
reusing the MIN(position) - 1 policy new issues already get. An explicit
position still wins, so cross-column drag-and-drop keeps the slot it
dropped on, and a same-status write leaves position alone.

The re-rank reads MIN(position) for the destination (workspace, status),
which idx_issue_status could only serve with a heap fetch per issue in
the column — paid on every completion against the column that grows
without bound. Add (workspace_id, status, position) to make it an
index-only min.

Closes #7410

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 12:36:10 +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
6431076012 MUL-6552 docs(self-hosting): correct the PostgreSQL extension requirements (#7421)
* docs(self-hosting): correct the PostgreSQL extension requirements

The docs claimed Multica requires pgvector and told operators to run
`CREATE EXTENSION IF NOT EXISTS vector`. Nothing in the codebase uses it:
across all 429 migrations there is no `vector` column and no migration
creating the extension, and no Go package imports a pgvector driver. On a
stock PostgreSQL 17 that `CREATE EXTENSION` fails outright, so the
instruction actively blocked the "use your own PostgreSQL" path it appeared
in.

There are exactly four `CREATE EXTENSION` statements in the migration set.
`pgcrypto` (001) and `pg_trgm` (137) are bare statements that fail the
migration when missing; both ship with every standard PostgreSQL 17 build.
`pg_bigm` (032) and `pg_cron` (076) are wrapped in `DO ... EXCEPTION` and
skip gracefully. Document all four with their real status, and note that a
`schema_migrations` row does not prove a conditionally skipped object
exists.

`pg_bigm` is described as what it actually is — a CJK search-quality
improvement, not a correctness requirement. Migrations 138-142 install
portable `pg_trgm` fallback indexes covering every column the search
handlers touch, so a deployment without `pg_bigm` still searches on an
index.

References to the bundled image name are left alone: those describe which
image the compose file pulls, which is still accurate. The Helm chart
description and values comment are updated because they present pgvector as
part of the stack rather than as an image name.

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

* docs(self-hosting): document the local disk attachment storage variables

The File Storage section only covered S3 and CloudFront, but local disk is
what the backend uses whenever `S3_BUCKET` is unset — every default
self-host install. `LOCAL_UPLOAD_DIR` and `LOCAL_UPLOAD_BASE_URL` were
absent from the self-hosting docs.

The default `./data/uploads` resolves against the backend's working
directory. Inside the bundled image that is `/app`, where the compose file
mounts the `backend_uploads` volume, so it happens to be correct. Running
the binary manually resolves it against whatever directory the process was
launched from, which silently scatters attachments as that directory
changes and leaves `attachment` rows pointing at files the server no longer
looks for.

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

* docs(search): fix the migration numbers in the statement-timeout comment

The comment on `searchStatementTimeout` cited migration 134 as the one that
installed the extensions the search fast path needs. 134 is
`134_runtime_profile_add_qoder`, which widens a `protocol_family` CHECK
constraint and has nothing to do with search. The migration that runs
`CREATE EXTENSION IF NOT EXISTS pg_trgm` is 137.

The referenced index list is also extended to 137-142: 137 only installs
the extension, while the trigram indexes themselves are created by 138-142.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: sail-cn <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-22 11:18:59 +08:00
Bohan JiangandRAVN fa797d0063 MUL-6547 fix(daemon): resolve claim project context once, workspace-scoped (#7405)
Collapses the project-context resolution that had been copied across the
issue, chat and quick-create claim paths (and would have been copied a
fourth time) into one workspace-scoped, fail-closed resolver, and hydrates
run_only autopilot project context on top of it.

- Project and resource reads are both workspace-scoped, so a dangling or
  cross-tenant project reference can no longer lift another workspace's
  repository URL, local directory path, title or description into a claim.
- A failed read is no longer treated as "there is no project". Transient
  failures reject the claim and preserve the task for the stale-dispatched
  reclaim rather than silently falling back to workspace repos.
- Source rows (issue, chat session, autopilot run, autopilot) fail closed
  the same way. Previously a read error skipped the branch entirely and,
  because taskToResponse seeds the runtime workspace, sailed through the
  backstop isolation check — dispatching an agent with no issue context or,
  for chat, no input at all, bypassing the empty-input guard.
- Workspace isolation is resolved authoritatively per branch and checked
  before any context is hydrated. For run_only autopilots that check had
  been a tautology, comparing the runtime workspace against itself.
- run_only autopilots now receive their configured project's repositories
  and local_directory, the same contract issue-bound and quick-create tasks
  already had.

Absorbs #7396. Its inline implementation is replaced by the shared
resolver; the author's regression test is carried over unchanged.

Co-authored-by: RAVN <[email protected]>
2026-08-22 11:18:02 +08:00
fbbe697578 MUL-6540: retry stale seat purchase quotes (#7401)
* fix(billing): retry stale seat purchase quotes

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

* fix(billing): bound seat preview recovery

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

* fix(billing): handle failed summary refresh

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 18:00:19 +08:00
8f9c766e40 MUL-6460: feat(brief): inject the workspace status catalog into the agent brief (#7306)
Agents could not see custom issue statuses (MUL-6243): the brief's status
command line hardcoded the seven built-in keys, so a workspace's Human
Review / Rework statuses were invisible even when instructions asked for
them, and the CLI offers no catalog read an agent could fall back on.

Ship the catalog on the claim payload, the same path WorkspaceContext
already rides: buildClaimedTaskResponse reads the workspace's ACTIVE
CUSTOM statuses (built-ins stay off the wire — their keys, names and
semantics are locked) and the daemon copies them into TaskContextForEnv.
The brief's status bullet then renders the workspace's statuses grouped
by category, with each custom status's name and description — category
anchors the inherited platform behavior, name is what users call the
status in instructions, and description is the admin's disambiguator
when a category holds more than one status. A bridge bullet in the issue
workflow connects the existing category-level status rules (which stay
untouched) to the concrete key choice.

With no custom statuses — every old-server pairing included — both
renders are byte-identical to before, keeping the prompt-cache prefix
stable for existing deployments (MUL-5377). The catalog is durable
workspace configuration like WorkspaceContext, not per-turn state, so it
belongs in the brief rather than the per-turn prompt. The claim read is
not gated on the custom_issue_statuses flag: the flag only guards
creation, and a catalog written before a flag flip must stay visible as
long as issues can sit on it. Name/description are user-authored and
pass through sanitizeNameForBriefMarkdown; a key failing the code-token
guard drops its entry. A 30-entry cap with an explicit omitted count
keeps a scripted catalog from flooding the brief while disclosing the
truncation.

Also update the built-in skills that document status semantics
(multica-working-on-issues, multica-squads) and their source maps:
statuses beyond the built-ins inherit their category's behavior in full,
with the two literal-key exceptions (failed-task rollback writes todo,
merged close-intent PR writes done) called out.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 17:40:27 +08:00
520359a2c9 MUL-6518 fix(daemon): reclaim orphaned per-task temp dirs in the GC (#7391)
* refactor(daemon): give the lock and dir-stat helpers neutral names

openEnvRootLockFile, releaseEnvRootLock and codexStoreStat are all
generic: they take a path, and nothing in them knows about env roots or
Codex stores. The next commit reuses the same execution lock for per-task
temp directories, where the old names would read as a bug at the call
site.

No behaviour change.

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

* fix(daemon): reclaim orphaned per-task temp dirs in the GC

Every task gets a private temp dir (multica-task-*) under the system temp
base, exported to the agent as TMPDIR/TMP/TEMP. Its only exit was the
RemoveAll deferred at the end of runTask, which does not run when the
daemon is killed and does not succeed while a file inside is still open —
the Windows case reported in #7364, where one machine had accumulated 174
leftover directories. The GC never covered them: it scans WorkspacesRoot,
and the temp base is a different root, so multica-task- had exactly one
create site and zero reclaim sites.

Sweep the temp base as a fourth out-of-root pruner. Liveness is decided by
the directory's .task_lock — the same OS advisory lock an env root uses —
not by age: the sweep removes a directory only once it has itself acquired
the lock the owner would still be holding, which the kernel grants only
after that owner is gone.

Using the lock rather than a marker or a TTL is what makes this correct
rather than probabilistic. It is held by an open file description, so it
answers for a live task in this very process as well as one owned by a
different daemon sharing /tmp, which no in-memory active set could see.
And it needs no upper bound on task runtime — there is none to assume,
since DefaultAgentTimeout is 0.

Directories left by daemons predating the lock carry no lock file, so age
is the only signal available for them; they are reclaimed after
MULTICA_GC_TASK_TEMP_LEGACY_TTL (72h). That branch exists to clear the
existing leftovers and can be dropped in a release or two.

The end-of-run RemoveAll stays exactly as it was, and nothing was added to
the task-completion path: the run releases the lock before removing, so a
removal that fails there costs nothing and the next GC cycle finishes the
job.

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

* fix(daemon): keep the temp dir marker when cleanup cannot finish

Two fixes from review.

1. A failed cleanup destroyed the marker the next sweep needs.

   os.RemoveAll keeps walking after a failure, so removing a directory
   whose payload is still open deleted the .task_lock sitting next to it
   and then returned the error. The directory came back as one with no
   marker — indistinguishable from a pre-lock leftover, so it was no
   longer reclaimable on liveness at all. That is exactly #7364's failure
   path, and it made the claim this change is built on ("a failed removal
   costs nothing, the next cycle retries") false.

   RemoveTaskTempDir removes contents first and stops at the first one it
   cannot remove, leaving the marker behind; the lock file goes last, once
   everything else is gone. Both the task-completion path and the GC use
   it. A cleanup that fails now leaves a directory that is still
   lock-bearing and still unlocked, so the next cycle takes the lock and
   retries — for as many cycles as the holder needs.

2. The legacy TTL could delete a live task's TMPDIR.

   Pre-lock directories were reclaimed on age after 72h by default, but
   age cannot establish liveness: DefaultAgentTimeout is 0, so a task may
   legitimately run far longer than any TTL; a daemon on another profile
   can still be running the old binary during an upgrade; and every daemon
   on the machine shares one temp base. The default is now 0 — leave them
   alone — and reclaiming them is an operator's explicit opt-in. Each
   cycle logs how many were left in place so the choice stays visible
   rather than silent.

   Classification also fails closed: only os.ErrNotExist means "no lock
   file". Any other stat error (another user's dir under a shared /tmp)
   now reads as in-use instead of legacy.

Regressions for both: a Windows test that makes a real sharing violation
with CreateFile minus FILE_SHARE_DELETE, asserting the marker survives the
failed cleanup and the directory goes once the handle closes (wired into
the Windows CI job, which runs targeted filters rather than whole
packages); and unix equivalents driving the same paths through an
unlinkable payload, plus fail-closed coverage for an unreadable directory.

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

* fix(daemon): publish the temp dir marker atomically, restore it on rmdir failure

Two more windows in the marker's lifecycle, from review.

1. A directory could be swept between marker creation and lock acquisition.

   LockTaskTempDir created .task_lock and locked it as two steps, so there
   was an instant where the marker existed unlocked. A sweep landing there
   reads exactly what a dead owner leaves behind: it takes the lock,
   concludes the owner is gone, and deletes the directory — after which
   the starting task's own lock call succeeds and it runs with a TMPDIR
   that no longer exists.

   The marker is now locked under .task_lock.claiming and renamed into
   place once held. Rename within one directory is atomic and the lock
   rides on the open file description, not the name, so the marker becomes
   visible and held in the same step.

   Before that rename the directory carries no marker, which classifies as
   legacy — and legacy only reclaims what is OLDER than legacyTTL. A
   directory created microseconds ago is younger than any positive TTL,
   and a TTL of 0 disables the branch outright, so no configuration can
   sweep the pre-publish state either.

2. A failed rmdir left a directory with no marker.

   The marker has to be deleted before the directory can be removed, so a
   failure at that last step — a Windows process holding a handle to the
   directory, or content an orphaned child recreated — left a directory
   nothing could classify again. With the legacy branch disabled by
   default that is a permanent leak, and it was the one path where cleanup
   got furthest. The marker is now put back when that removal fails, so
   every failure mode leaves the retry available.

Regressions: the publication order and the mid-claim on-disk state (spared
under a disabled TTL and under positive ones), a sweeper running flat out
against 200 publish/use/cleanup cycles, and the rmdir-failure branch —
which fails without the restore, verified by removing it.

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

* fix(daemon): decide the legacy sweep on content, not on age alone

The publication window was still open. The claim-then-rename ordering
stopped .task_lock from ever existing unlocked, but a directory mid-claim
carries no .task_lock at all, so the sweep still reaches it through the
legacy branch — and the argument that protected it there was wrong.

That argument was "a directory created microseconds ago is younger than
any positive TTL". MULTICA_GC_TASK_TEMP_LEGACY_TTL takes any duration,
down to a nanosecond, and an owner stalled between MkdirTemp and the
rename outlasts a large TTL just as easily. Reproduced at 1ns, 1ms, 1h
and 72h alike: the sweep deleted a directory whose claim was held.

Age cannot separate a pre-lock leftover from a directory being published,
so stop asking it to. Content can: a directory being published holds
nothing but its own lock files, because the task has not been handed the
path yet and cannot have written anything, while a leftover worth
reclaiming is by definition one with something in it. The legacy branch
now reclaims only directories that hold task content, which covers both
halves of the window — the empty directory before the claim exists, and
the claim-only directory after — at every TTL value, with no timing
assumption left in the argument.

The cost is that a pre-lock daemon which crashed before its task wrote
anything leaves an empty directory nothing reclaims. That is a few bytes
against deleting the temp dir of a task that is starting.

Tests: the mid-claim cases now run at 1ns/1ms/1h/72h with now pushed PAST
the TTL, so they fail if anything reintroduces a timing argument; the
pre-claim (empty directory) half is covered the same way; and the
invariant itself is pinned directly, so relaxing it breaks a test rather
than a race. The existing legacy TTL test now models a real leftover by
giving it content. Verified all of these fail with the content rule
removed.

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

* fix(daemon): re-check the lock immediately before removing a legacy dir

The legacy branch's decision is several separate observations — classify,
then walk for content, then walk for mtime — and a directory can finish
publishing in the middle of them. Classified as legacy while its marker
was still unpublished, then published and written to by its task, and the
removal went ahead without ever looking at the marker that had appeared:
the sweep deletes a live task's TMPDIR. What was accidentally holding this
together was that the task's own write refreshes the mtime — a timing
crutch, in the branch whose whole point is not to rely on one.

Re-read the classification immediately before removing, and skip unless it
is still legacy.

That closes the window rather than narrowing it, because of what content
means: a task only writes into the directory after it has been handed the
path, which happens only after LockTaskTempDir returns and therefore only
after the marker is published and held. So content observed at the top of
the branch, plus no marker seen at the bottom, proves no live task owns
this directory — and none can arrive later either, since MkdirTemp only
ever hands out a fresh empty directory under a new name.

The regression drives that exact interleaving through a barrier: the sweep
commits to removing the directory, the owner then publishes and its task
writes, and the sweep must notice. `now` is far past the TTL so age cannot
mask it. Verified it fails without the re-check.

Also from review: drop a stale test comment that still claimed a positive
TTL can only be configured far above a directory's age, and record in the
config and operator docs that a directory holding no task content is never
reclaimed on age — including a shell left by a CURRENT daemon that died
between creating the directory and publishing its lock, not just an old
daemon's empty leftover.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 17:38:44 +08:00
d60775aa93 docs(changelog): add v0.4.32 release entry (2026-08-21) (MUL-6541) (#7402)
* docs(changelog): add v0.4.32 release entry (2026-08-21) (MUL-6541)

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

* docs(changelog): refine v0.4.32 release copy (MUL-6541)

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
v0.4.32
2026-08-21 17:35:51 +08:00
13f9f8e5ca MUL-6529: let admins add workspace seats (#7388)
* feat(billing): let admins add workspace seats (MUL-6529)

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

* fix(billing): recover seat purchase retries

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

* fix(billing): reconcile seat purchase feedback

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

* fix(billing): parse processing seat purchases

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:16:16 +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
003a505b87 MUL-6486: reduce stale dispatch reclaim load (#7375)
* perf(tasks): gate stale dispatch reclaim scans

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

* fix(tasks): harden reclaim scheduling gate

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

* fix(tasks): retry unreclaimed dispatch hints

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 15:36:54 +08:00
18a85b8c7f fix(github): support single-character issue prefixes (#7385)
Co-authored-by: Rogee <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 15:36:33 +08:00
115bfab759 MUL-6523: persist task message batches in one statement (#7377)
* perf(server): persist task message batches in one statement

/api/daemon/tasks/{id}/messages is the hottest write path in the system:
every running task POSTs its buffered transcript every 500ms. The handler
was paying 3 + N database round trips per batch — GetAgentTask, the
workspace lookup inside requireDaemonTaskAccess, a SECOND identical
GetIssue/GetChatSession in the handler body, and then one
`INSERT ... RETURNING` per message, each in its own implicit transaction.

Two changes, both on that path:

- Take the workspace id from requireDaemonTaskAccessWithWorkspace, which
  already resolved it to authorize the daemon, instead of re-querying it.
  Broadcast reach is deliberately unchanged: the id is still only used for
  issue- and chat-backed tasks, so autopilot / quick-create tasks keep
  their current (non-)streaming behaviour.
- Add CreateTaskMessages, which expands the whole batch from one jsonb
  document via jsonb_to_recordset and inserts it with a single statement.
  A jsonb document rather than parallel arrays because `input` is itself
  jsonb and a text[]/uuid[] column cannot express a per-row NULL from Go;
  it also stays clear of the ~64k bind-parameter ceiling a multi-row
  VALUES list would hit.

Per batch this takes 3 + N round trips down to 3, and collapses N commits
into 1. Measured locally against Postgres 17 (200 iterations per shape,
same rows): 2.1ms -> 0.7ms at 3 messages, 7.0ms -> 0.8ms at 10, 80ms ->
1.1ms at 50.

Atomicity is the other half of the fix, not a side effect: the daemon does
not retry this endpoint, so a loop that failed halfway left a permanent
hole in the transcript. One statement makes a batch all-or-nothing.

The NUL sanitizer calls stay exactly where they were and matter more now —
jsonb rejects \u0000 for the whole document, so an unsanitized byte would
fail the entire batch rather than one row (GH #7098).

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

* fix(server): guarantee task message publish order, test the real query

Review follow-ups on the batch insert.

Blocking:

- Realtime ordering had no contract. `INSERT ... RETURNING` defines no row
  order, but the publish loop walked the result as-is, so a batch could
  reach subscribers scrambled where the per-message loop always published
  in request order. CreateTaskMessages now returns through an ordered CTE
  (`WITH inserted AS (INSERT ... RETURNING *) SELECT * FROM inserted ORDER
  BY seq`), putting the ordering in the query rather than asking every
  client to re-sort. Verified it is not a no-op: with the ORDER BY removed,
  the new test reports the published order as [3 1 2] for a request listing
  seq 3, 1, 2 — i.e. the bug was real, not theoretical.

- The atomicity test copied the batch SQL by hand, so it could have passed
  while CreateTaskMessages was broken. It now calls the generated query.
  Both tests were also rewritten onto the repo's shared fixtures — dbfx for
  rows, testutil.JSONRequest / WithURLParams / Call(...).Want(...) for the
  handler — instead of open-coded INSERT/cleanup pairs and recorder
  quartets.

- New TestReportTaskMessagesPublishesInSeqOrder subscribes to the event bus
  and asserts the published seq order, with the request deliberately listing
  messages out of seq order so array order cannot pass for seq order.

Non-blocking:

- A failed `json.Marshal` of a message's input is no longer swallowed. A
  tool_use row that silently loses its arguments is worse than a 500 the
  daemon logs, because nothing anywhere records the loss.

- Corrected the atomicity claim in the query and test comments: one
  statement buys CONSISTENCY, not completeness. Without a retry a failed
  batch is now lost whole rather than leaving a prefix; actually closing the
  gap needs a retry plus a (task_id, seq) uniqueness rule, which is out of
  scope here.

The CTE makes sqlc name the result CreateTaskMessagesRow rather than reusing
the table model, so the publish site converts explicitly — a compiler-checked
cast that fails loudly if the query ever stops returning the whole row.

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

* perf(server): pass task message batches as arrays, not a JSON envelope

Second review round. The jsonb-document parameter was a regression on large
messages: content and output had just been decoded out of the request, and
wrapping them back into JSON made the server escape every byte a second time
and Postgres parse the envelope out again. Measured against the per-row
insert it replaced, at one message: ~1.0x at 8KB, 1.46-1.48x at 128KB,
~3.0x at 1MB — worst exactly where the old path was cheapest, and those
messages are the least likely to be batched.

CreateTaskMessages now takes parallel arrays. NULLIF(value, '') is what makes
a per-row NULL expressible through the non-nullable []string sqlc gives a
text[] parameter, and it reproduces the single-row query's
pgtype.Text{Valid: x != ""} mapping exactly. input stays a text parameter
cast to jsonb in SQL — it is the one column that genuinely has to be parsed.

The arrays are zipped by several single-argument unnest calls in one SELECT
list, which expand in lockstep (PG10+ SRF semantics). The tidier
unnest(a, b, ...) form is equivalent but sqlc's analyzer only knows the
single-argument signature and fails to generate for it.

Measured after the change, same harness, two runs:

  one message, 8KB      0.98-0.90x of the per-row insert
  one message, 128KB    0.99-1.04x   (was 1.46-1.48x)
  one message, 1MB      1.59-1.67x   (was 2.99-3.02x)
  3 messages, 512B      2.7-3.2x faster
  10 messages, 512B     7.7-8.2x faster

128KB is now parity. 1MB is still above parity because any batch shape makes
Postgres materialize the array and unnest it before the insert, which a
direct single-row parameter skips; closing that needs a len == 1 fast path,
which review explicitly preferred not to have for the extra code path. A real
1MB single message is also unlikely: tool_result output is truncated to 8KB
in the daemon (internal/daemon/daemon.go). The durable answer for oversized
payloads is a body/batch cap on this endpoint, which is still a follow-up.

Also from review:

- The conflicting row in the atomicity test is built with dbfx.Insert instead
  of CreateTaskMessage, so test setup no longer shares code with the code
  under test.
- The ordering test runs on its own event bus and restores the shared one, so
  it stops leaving a listener behind on a bus that has no unsubscribe.
- The per-batch probe is Debug, not Info. At Info it was ~2 lines/second/task
  in production; it was added at Info in #7371 only to count batch sizes,
  which is answered.

Re-verified the ordering test still fails without the ORDER BY (reports
[3 1 2]) against the new query shape.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 15:17:02 +08:00
Bohan Jiangandmultica-agent d2532f766b MUL-6491 fix(daemon): fence env-root resets on a live execution, not a task id (#7356)
* fix(daemon): fence env-root resets on a live execution, not a task id

Follow-up to #7347. The identity marker answers WHO owns an env root, not
whether that owner is still running, so Prepare read "owner == my task id"
as "my own rerun, safe to reset".

That is reachable rather than theoretical. ReclaimStaleDispatchedTaskForRuntime
re-delivers a task row whose prepare lease expired using the SAME task id,
and a failing lease renewal only logs — it does not cancel the Prepare still
in flight. The re-delivered execution then wiped the running one's workdir,
config and worktree: the cross-execution deletion the env-root claim exists
to prevent, arriving through the one door it left open.

Add a second signal alongside the marker: an OS advisory lock on the env
root, held for the life of the Environment and released by Cleanup. The
kernel drops it when the holder exits for any reason, so it answers "is the
previous execution still alive?" across processes with no heartbeat, no PID
table and no stale-state cleanup path — a crashed execution's directory is
recoverable on the next dispatch, a running one's is not.

Taking the lock first also serialises the claim, which replaces the
Mkdir/O_EXCL arbitration with something simpler and stronger: no other
execution can be mid-claim, so the checks below it cannot race.

That in turn fixes the torn-marker case from the same review. The marker was
created empty and written a moment later, so a crash in between left a
zero-length file that read back as "no owner" while still counting as
directory content — the env root could then be neither adopted nor reset,
and stayed wedged until someone deleted it by hand. Under the lock an
unnamed marker is simply rewritten.

Tests, each verified to fail without its fix: an overlapping execution of one
task id is refused and the live work survives; the same id recovers once the
prior execution released the root; a torn marker is repaired instead of
wedging.

Refs #7326

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

* fix(daemon): release the env-root lock at the end of the task run

Windows CI caught that the lock outlived the work it guards. It was released
only by Environment.Cleanup, which production never calls — the GC reclaims
env roots on its own schedule — so the lock was effectively held until the
daemon exited. Two consequences: every later dispatch of that task would
fail closed as "held by a running execution", and on Windows the open handle
pinned the directory so nothing could remove it:

    TempDir RemoveAll cleanup: unlinkat ...\.task_lock:
    The process cannot access the file because it is being used by another process

Release it where the execution actually ends. The daemon now defers
ReleaseLock alongside unmarkActiveEnvRoot, which is the same lifetime: the
task run. Cleanup still releases too, and both are idempotent.

Also open the lock file with FILE_SHARE_DELETE on Windows. os.OpenFile does
not request it, and without it Windows refuses to delete a file anyone holds
open — the lock is meant to coordinate executions, not to pin a directory
against the GC. Unix already allows unlinking an open file; this makes
Windows behave the same.

Refs #7326

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

* fix(daemon): hold the env-root claim in the parent, not the prepare helper

Review found the lock never applied in production. Real daemons always
prepare through PrepareIsolated, which runs a short-lived helper process:
the lock was taken inside that helper, the kernel released it the moment the
helper exited, and Environment.lockFile is an *os.File that cannot cross the
helper's JSON response. The parent got back a nil lock, so ReleaseLock was a
no-op and the agent ran with no protection at all — the exact re-dispatch
window this was meant to close.

Every test added with the lock went through the in-process Prepare, where
the "helper" never exits, so none of them could see it.

Move the claim to the daemon parent, whose lifetime IS the task run:

- ClaimEnvRoot / EnvRootClaim.Release are now exported, and runTask takes
  the claim alongside markActiveEnvRoot and releases it on every exit.
- PrepareParams.EnvRootPreclaimed tells preparation the caller already holds
  the claim and performed its reset, so it does not try to take a lock the
  parent is holding. Direct in-process callers still claim for themselves.

Also cover the reuse path, which took no claim at all: a task continuing in
a PRIOR task's env root now excludes on that root through
LockEnvRootForReuse. Identity there belongs to the earlier task, so this is
the exclusion half of a claim only — without it two continuations of one
task could refresh and run the same workdir concurrently. Guarded to roots
under the workspaces root, since a local_directory task's PriorWorkDir is
the user's own directory.

Tests now exercise the production path: one drives PrepareIsolated through
the real helper harness and asserts the parent still holds the claim after
the helper exits, the other covers reuse exclusion. Against the previous
arrangement the first fails with "a second execution claimed the env root of
a task that just prepared".

Refs #7326

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

* fix(daemon): lock a prior env root only after proving it is a managed one

Review found the reuse guard wrote its lock file before it had established
where it was writing. runTask locked filepath.Dir(task.PriorWorkDir) up
front, gated only by a lexical filepath.Rel containment check — which does
not resolve symlinks. A link inside the workspaces root pointing anywhere
else passed that gate, and .task_lock was created in the target: a user's
own directory, or a path outside the root entirely. Eligibility was not
settled until much later, in shouldReusePriorWorkdir.

Reorder so the proof comes first. shouldReusePriorWorkdir already resolves
symlinks and verifies the {workspace}/{task}/workdir shape, managed
provenance and the task-context marker; it now returns the canonical path it
validated, and that is the only path ever opened. The lock is re-confirmed
against the same check while held, closing the gap between proving
eligibility and acting on it.

Declining is not fatal on this path: a busy root, a vanished directory or a
failed re-check falls back to a fresh Prepare, which costs session
continuity rather than correctness. ErrEnvRootBusy makes that distinguish-
able from a real I/O failure. The lexical isUnderWorkspacesRoot helper is
gone — it promised containment it could not deliver.

The canonical path deliberately stays internal. Reuse still receives
task.PriorWorkDir, so the workdir reported to the server and handed to the
next task keeps its existing shape.

Tests: negative cases for a symlink escaping the root, a plain outside path
and a managed-looking path without provenance, each asserting no .task_lock
appears outside — against the previous ordering the symlink case fails with
"prior-workdir guard wrote the lock outside workspaces root". A positive
case pins that a provenanced root IS locked, in the right directory, and
excludes a concurrent continuation. And a helper-process test pins that a
caller who holds the claim but forgets EnvRootPreclaimed fails immediately
and says so, instead of preparing while believing it is protected.

Refs #7326

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

* fix(daemon): claim a prior env root by identity, not by name

Review found the remaining TOCTOU: the canonical path validation returns is
a string, not a handle. Between proving eligibility and opening the lock,
the validated env root can be renamed away and a symlink to somewhere
outside dropped in its place — an ordinary open follows it and creates
.task_lock out there, and no later re-check can undo a write that already
happened. A swap to a different but equally provenanced directory at the
same path is worse still: every name-based check agrees, and the daemon ends
up holding a lock on one directory while running in another.

Resolve the lock through an os.Root pinned at the workspaces root. Root
refuses any component that resolves outside its tree, so the lock file
cannot land outside no matter when the swap arrives — containment stops
being a check that races and becomes a property of the open.

Root still follows symlinks that stay inside the tree, so containment alone
cannot answer "is this the same directory?". Chain identity instead: stat
the directory validation approved, require the lock to have landed on that
same inode, and require the directory handed to Reuse to be it as well.
os.SameFile compares identity; comparing canonical path strings cannot tell
a directory apart from its replacement at the same name.

Reuse now receives the canonical path that was locked, so the object locked
and the object used are the same one. Two consequences that fell out:

- The workspaces root has to be resolved before measuring the env root
  against it, or a symlinked root (macOS /tmp, a home on a linked volume)
  makes them look unrelated and every reuse is refused.
- gateResumeToReusedWorkdir compared workdirs textually, so a canonical
  env.WorkDir silently dropped the prior session. It now compares directory
  identity, which is what it meant all along.

Tests use a deterministic hook to swap the directory between validation and
the lock rather than racing it. Against the previous code they fail with
"validated canonical root was retargeted and the lock was written outside"
and "accepted reuse after the validated directory was replaced at the same
path".

Refs #7326

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

* fix(daemon): pin the workspaces root before validating, and lock from one handle

Three findings from review, all in how os.Root was being used rather than in
the choice of it.

Pin the workspaces root BEFORE validating anything. Opening it afterwards,
from a name validation had just approved, re-resolved that name: rename the
whole root aside, leave a symlink to a look-alike tree, and os.Root
faithfully pins the replacement. Root promises you cannot escape the tree it
opened — not that it opened the tree you meant. Holding the handle first
makes that ordering impossible.

Take the directory's identity and create its lock file from ONE sub-Root
pinned on the env root. Resolving the env root's relative path twice from
the workspaces Root — once to stat, once to create — allowed A on the first
resolution and B on the second, reporting A's identity while holding B's
lock.

Close the last window by detection rather than by claiming it is closed. A
file descriptor cannot cross into the preparation helper process, so Reuse
can only be handed a name, and the gap between the final identity check and
Reuse's own opens cannot be pinned away. The environment Reuse produces is
now checked back against the directory the lock is held on, so a
substitution ends the run in a fresh environment instead of executing in a
directory nothing holds. What remains uncovered is stated in the code: the
writes Reuse itself performs inside that window.

Also drop sameExistingDir's string fast path, which returned true without a
stat and contradicted the identity contract the function documents. Its
table test used synthetic paths that never existed; they are real
directories now, which is what the comparison was always about.

Tests, each verified to fail without its fix: a workspaces-root swap must
not let the lock be written into the replacement tree; reported identity
must be the directory the lock file is actually in; and a swap after the
claim, before Reuse, must end in a fresh environment rather than the
substitute.

Refs #7326

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:49:27 +08:00
b2e682de97 MUL-6493 feat(issues): add Project as a grouping option on Board and Table (#7383)
* feat(issues): add Project as a grouping option on Board and Table

Board and Table only offered status / assignee (plus select properties) as
grouping dimensions, so a workspace that models each customer as a Project
could filter to one project everywhere but only ORGANISE by project in the
Swimlane. Closes #7337.

The server side already exists: the table group endpoint has carried
`{ kind: "project" }` — group expression, `__no_project__` bucket, ordering
by project title — since the Swimlane shipped, so this only widens the two
client grouping types and routes them to it.

Notes:

- `projectGroupId()` reproduces the server's group key exactly
  (`project:<id>` / `project:none`). The board builds column ids from cards
  and the server builds them from descriptors; a mismatch drops every card.
- `projectId` sits beside `assigneeId` on BoardColumnGroup, so the drop
  handler and the column heading both match it explicitly — the assignee
  fallthrough would otherwise unassign a card on every drop and head a
  project column with a no-assignee glyph.
- Group titles come from the projects query, which the surface now primes
  for this grouping. A project it cannot resolve reads as unavailable, never
  as its raw id.
- Sorting by project / assignee is deliberately not part of this change.

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

* fix(issues): pin the No project board column and match the Table's unavailable wording

Review nits on #7383.

The "No project" column was derived from the result set like every other
project column, so a workspace where every card has a project lost it — and
with it the only way to clear a card's project by dragging. It is now kept
whenever the board has any columns at all; an entirely empty board still
renders its empty state rather than one lone column.

The board also fell back to an empty title for a project the projects query
cannot resolve, while the Table said "Unavailable value" for the same project.
Both now share the Table's wording, so a board column can no longer render as
an icon and a count with no name at all.

Both paths that build a project column go through one helper now, which is
what keeps the client fallback and the server-descriptor path from drifting
apart again.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:35:08 +08:00
53e15b35c6 MUL-6502: fix startup recovery during PostgreSQL outages (#7372)
* fix(startup): retry transient database outages

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

* fix(startup): address database retry review

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

* test(startup): address retry review nits

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

* fix(startup): preserve native pgx connect timeout

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

* fix(startup): apply timeout fallback to services

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:33:04 +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
3adc0061a9 MUL-6490 fix(server): keep the A2A originator chain across cross-issue agent comments (#7381)
* fix(server): keep the originator chain across cross-issue agent comments

An agent comment only recorded its authoring run in source_task_id when that
run's task was on the SAME issue as the comment. The ordinary "agent creates
issue Y, then coordinates there" flow therefore persisted a NULL lineage: the
run that comment woke resolved as unattributed, and every @mention, assign and
sub-issue inside it hit invocation_not_allowed — while the identical delegation
on the originating issue succeeded. Admins recovered by widening public_to to
workspace, which dismantles the minimum-privilege setup the gate exists for.

Stamp the lineage unconditionally. The header it comes from is server-trusted
(the task token forces X-Task-ID and X-Agent-ID from its own row; resolveActor
rejects the JWT path unless the task belongs to the agent), and propagation is
monotonic: a run can only carry the human it already acts for, and every hop
re-runs canInvokeAgent against that same human. Substituting a human — the owner
fallback the report proposed, or adopting the target issue's originator — stays
rejected.

The same-issue requirement moves to the consumers that actually need it: the
reply-parent / no_action guards, and autopilotDelegationAuthority, which already
verified task.issue_id itself (MUL-4857). The edit path re-stamps the same way,
so the admission gate and the persisted chain can no longer disagree — the split
that let the gate admit a delegation whose run then started with no authority.

Also name the root cause in the logs when the gate denies an agent whose chain
carries no human. The wire reason stays the deliberately generic
invocation_not_allowed, so the one denial a workspace can fix was previously
diagnosable only by querying agent_task_queue directly.

Fixes #7328

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

* docs(skills): document that a delegation chain keeps its human across issues

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

* test(server): build the cross-issue chain suite on the shared fixtures

Rewrite the MUL-6490 regression suite against internal/testutil instead of
hand-rolled setup, per the repo rule that DB-backed handler tests go through
dbfx and testutil.Call: dbfx.Agent/Issue/Task/InsertNoID for rows (with explicit
teardown for the grants and handler-written rows the fixture cannot know about),
and testutil.WithHeaders/WithURLParams/Call for requests.

Drive the assign and sub-issue delegations through the real UpdateIssue and
CreateIssue handlers rather than calling validateAssigneePair directly. This
fix's risk is that one entry point resolves the originator chain differently
from another, and only the wired path can hold that: the suite now runs
{mention, assign, sub-issue} x {an allow-listed human's chain, another member's
chain, no chain} from inside a run woken across an issue boundary.

Issue numbers come from the workspace counter here, because CreateIssue
allocates from it and dbfx's MAX(number)+1 default would collide with the
sub-issue the test asks the handler to create.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:12:36 +08:00
bae3a324fc MUL-6504 fix(daemon): stop chat tasks holding the local_directory mutex they never write to (#7378)
* fix(daemon): stop chat tasks holding the local_directory path mutex (MUL-6504)

A chat turn bound to an in_place local_directory queued on that path's
mutex, so every message to a squad leader sat behind whatever coding task
was building in the same repo — measured at 23-67 minutes on a self-hosted
install, with cancelling the chat by hand as the only way out (#7344).

The carve-out that was supposed to cover this keyed on IsLeaderTask, and
its own comment describes a chat turn exactly ("may create child issues or
comments, but should not hold the path mutex"). It never fired: the server
does not write is_leader_task on the chat-task insert path, so it is false
on every chat turn ever dispatched.

The reported one-line fix — returning no assignment for chat — would have
freed the lock by also moving chat out of the user's directory: that same
return value drives the agent's working directory and the GC-meta stamp.
A "look at my repo" chat would have stopped seeing the repo.

So split the two decisions instead. localDirectoryAssignmentForTask keeps
answering where a task runs; the new localDirectoryLockExempt answers
whether it queues for it. Chat resolves, validates and binds exactly as
before, and skips only the mutex.

What the mutex protects is unchanged: two coding runs interleaving edits
into one tree. It was never a general write barrier — the user's own
editor and any external script write to that tree unsynchronised — so a
conversation whose occasional write is the same risk class as Cmd+S does
not need a slot in it. An exempt turn is told its directory is shared, via
a per-turn block that stays after the cached prompt prefix.

Tests: chat skips the mutex while a coding task holds it AND still
resolves an assignment; worktree chat keeps its assignment; two issue
tasks still serialise; the leader carve-out is unchanged.

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

* feat(chat): explain a waiting_local_directory hold in the status pill (MUL-6504)

wait_reason was written end-to-end except for the last hop: the daemon
stamped the path and holder, the column stored it, the realtime payload
carried it, and the UI read only the status — as the type comment said
outright. A parked chat showed "Waiting for local directory" with a timer
and no way to tell a one-minute wait from a forty-minute one, or whether
cancelling was safe. That ambiguity is what pushed the reporter into
cancel-by-id and cost them 36 minutes of a worker's progress.

Surface it. The daemon now names the directory by its resource label or
basename instead of its absolute path: this text reaches every client on
the session and lands in screenshots, which is the same contract
handler.relativeWorkDir already enforces for the sibling work_dir chip.
The absolute path stays in the daemon's logs, where an operator debugging
a wedged lock looks for it.

wait_reason is written once on the way into the hold and never cleared, so
both the server DTO and the client cache gate it on the status still being
waiting_local_directory — otherwise a task running fine for ten minutes
keeps claiming to be held by another one.

Servers predating the field send nothing and render today's bare label.

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

* fix(server): strip legacy absolute-path wait reasons before clients render them

Surfacing wait_reason created a leak that did not exist while the field was
being ignored. Daemons before the display-name change send
`local_directory /Users/<name>/repo (held by task abc12345)`, and a new
server paired with one of those would now put a home path — and the
account name in it — into the StatusPill and any screenshot of it.

Sanitize at the write boundary rather than at each reader. It is the only
place wait_reason is written, so both the REST payload and the websocket
event read through it, and the column itself never stores a path.

While here, put wait_reason on the task:waiting_local_directory event.
The payload never carried it, so the field the client type has always
declared was undefined in practice and the reason only arrived on the
follow-up refetch — the pill spent that round-trip saying nothing useful,
which is the gap the hint exists to close. Already sanitized, so the
socket cannot carry what the REST payload would withhold.

Dropping the whole string rather than editing it: an absent reason is a
case clients already handle — older servers never sent one — and it
renders as the plain waiting label, which is the pre-change behaviour.

Also notes the known mixed-script rough edge in the holder clause: it is
English inside a localized "Waiting for {reason}" label. Fixing it means
structured fields and a client-side join, which is not worth it for one
parenthetical.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 13:56:18 +08:00
LinYushen 6450c7ad30 Revert "chore(server): log task message batch size (#7371)" (#7382)
This reverts commit 52a063d0df.
2026-08-21 13:43:05 +08:00
55c7cf0575 fix(daemon): add the reference fact to the Mentions brief — @-as-attribution is not a mention need (MUL-6528) (#7379)
MUL-6417 replaced the prescriptive "Default: NO mention" rule with facts
that invalidate each false prior from human @-culture, but only covered
two of the three: notify-for-visibility and courtesy/thank-you. The
third prior — the @-form used merely to write someone's name — was left
standing, and an agent attributing a product decision to "@Steve Jobs"
in prose enqueued a run for the agent it was crediting.

Add the missing fact in the section's established style: crediting or
citing someone is prose about them, not work for them; the link form
dispatches whoever it names, so a reference stays plain text. Pin it in
TestInjectRuntimeConfigMentionLoopHardening alongside the other facts.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 13:41:21 +08:00
Sebastian San Blas 8febf40970 MUL-6525: docs: document GitHub App Contents permission for PR snapshots (#7369)
Add Contents: Read-only to the self-hosting GitHub App permission table in all four locales, note that it cannot be skipped along with the CI permissions, and list it in the No CI status troubleshooting entry.

Co-authored-by: Sebastian San Blas <[email protected]>
2026-08-21 13:21:07 +08:00
341372a890 MUL-6509 fix(telegram): speak English in the Telegram bot (#7358)
Every message the Telegram bot sends was hardcoded Chinese, including the
account-binding prompt — the first thing a new user ever sees from the bot, and
the one they must act on before anything else works. Telegram is blocked in
mainland China and reaches everyone else, so a Chinese-first bot there speaks
the wrong language to almost every user it has. A non-Chinese reader had no way
through it.

The copy is taken word for word from the Slack and DingTalk adapters wherever
they already say the same thing — both have been English since their first
commit (#4566, #4829), and Lark already renders its issue-created and duplicate
lines in English. English is also the product default (DEFAULT_LOCALE in
packages/core/i18n/types.ts). So this is an alignment, not a new i18n layer: no
catalog, no migration, no abstraction.

Changed: replier.go (binding prompt, /issue usage, not-a-member,
bot-not-connected, created / duplicate), sender.go (offline, archived,
unsupported type, group binding hint), telegram_channel.go (dispatch failure),
outbound.go (run failure).

WeCom and Lark keep their Chinese copy on purpose: they are Chinese-market
products (WeCom is China-only) and their users are the audience that copy was
written for. sender.go carries the decision record so this does not drift back.

Localizing per user is a separate question and deliberately not answered here.
user.language is only ever written by the settings language switcher — nothing
populates it at signup and the web never syncs the browser-detected locale back
to the server — so it is NULL for anyone who never opened that switcher, and a
locale lookup on this path would fall through to English for nearly everyone
anyway.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 13:16:52 +08:00
1dc135598e MUL-6343: Enforce recently created issue windows (#7305)
* MUL-6343: enforce recently created issue windows

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

* fix(issues): address creation window review

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

* fix(issues): bound table window ID cache

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 12:55:57 +08:00
52a063d0df chore(server): log task message batch size (#7371)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 12:55:51 +08:00
iiwish a3bd38da6a fix(cli): redact autopilot webhook credentials (#7365)
* fix(cli): redact autopilot webhook credentials

* fix(cli): preserve webhook token presence metadata
2026-08-21 12:39:43 +08:00
Bohan Jiangandmultica-agent 3622db21cf MUL-6491 fix(daemon): give each task an exclusively claimed env root (#7347)
* fix(daemon): derive task env roots and branches from the full task id

Task ids are UUIDv7, whose leading 8 hex chars are the high 32 bits of a
48-bit millisecond timestamp: they only advance once every 2^16 ms
(~65.5s). Deriving the env-root segment from that prefix meant every task
created inside one such window landed on the same directory.

Prepare removes an existing env root before recreating it, guarded only by
the comment "task IDs are unique". That premise held for the id but not for
the truncation, so the second task's Prepare ran os.RemoveAll over a live
sibling's env root — taking its workdir (including a worktree with
uncommitted work), output/, logs/ and the 0700 multica-config/ with it.
Overwritten CLAUDE.md / AGENTS.md identity files were the visible symptom;
the deletion was the actual damage.

Use the whole id for the env root and for the agent/<name>/<task> branch
names, which truncated the same way and collided for concurrent tasks of
one agent. shortID stays for logs and display only. Prepare now derives its
root through PredictRootDir so the two can no longer drift apart.

Existing short-prefix directories need no migration: the GC enumerates task
dirs with ReadDir and reads .gc_meta.json, so it still reclaims them.

Fixes #7326

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

* fix(daemon): take the task path segment from the id's random tail

The first attempt used the whole 32-char task id, which overflowed Windows
MAX_PATH: a branch name becomes a path under .git/refs/heads/ inside an
already-deep task checkout, and CI failed with

    cannot lock ref 'refs/heads/agent/windows-codex/<32 chars>':
    Filename too long

Length was never the fix. The bug was which end the segment came from: a
UUIDv7 leads with a 48-bit millisecond timestamp, so its first 8 hex chars
are the high 32 bits and only advance once every ~65.5s. Read from the tail
instead, where the id is random, and 12 chars are enough — 4 more than the
original segment rather than 24.

That trades a certainty for a probability, so make the destructive step
prove itself instead of assuming: Prepare stamps a .task_owner marker into
the env root and refuses to run os.RemoveAll over a root another task owns.
A rerun of the same task still resets its own directory. A task that fails
to start is recoverable; one that deletes a running sibling's uncommitted
work is not.

Two existing fixtures used synthetic ids differing only in the head and so
shared a tail. The guard caught them; they now differ where a real UUIDv7
does.

Fixes #7326

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

* fix(daemon): make the env-root claim atomic instead of check-then-delete

Review caught that the ownership guard did not cover the case it was added
for. It read .task_owner, then deleted, then wrote the marker — three steps,
so two tasks sharing a taskKey could both observe "no owner", and the slower
one would still os.RemoveAll the faster one's live directory. The guard only
held when a foreign marker was already durably in place, i.e. serially.

Replace it with a claim protocol built on two atomic filesystem primitives:
os.Mkdir fails if the directory exists, and O_EXCL fails if the marker
exists. Exactly one racing caller wins each, and a caller that wins neither
never reaches the destructive path.

The claim is then held for the rest of Prepare. A rerun clears the env
root's CONTENTS and leaves the marker in place rather than removing and
recreating the directory, so there is no window in which the root looks
unowned to a racing task.

A directory that holds files but names no owner now fails closed instead of
being deleted as abandoned. The marker is written before any content, so the
only unowned directory that is safe to adopt is an empty one — the residue
of a crash between Mkdir and the marker write, which would otherwise wedge
the task permanently.

Tests: a barrier-synchronised concurrent Prepare asserting exactly one task
starts and the winner's tree survives (fails against the previous sequence
with "directory not empty" / "invalid argument" — the deletion racing the
create), plus fail-closed and empty-adoption cases. repocache carried a
third copy of the task-segment rule with no test; it now has the same
contract test as the handler.

Fixes #7326

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-21 00:36:59 +08:00
VvVandweiweiwei ad2bdd3e38 MUL-6476 fix(execenv): expand $OPENCLAW_HOME in the openclaw active config path (#7310)
* fix(execenv): expand `$OPENCLAW_HOME` in the openclaw active config path

#6630 was the same bug in the tilde shape: `openclaw config file` prints a
path the daemon cannot stat, the miss is indistinguishable from a fresh
install, so prepareOpenclawConfig omits the `$include` and every task on
that host runs without the user's model providers and auth profiles. #6643
fixed `~\`. This is the other shape.

With OPENCLAW_HOME set, current releases print the variable name rather than
its value:

    $OPENCLAW_HOME\.openclaw\openclaw.json

Nothing expands it, and it is not absolute, so filepath.Abs joins it onto
the daemon's working directory:

    <daemon-cwd>/$OPENCLAW_HOME\.openclaw\openclaw.json

which can never exist. The user sees a task that starts and then behaves as
if they had never configured OpenClaw.

openclawHomeRest accepts `$OPENCLAW_HOME` and `${OPENCLAW_HOME}`, bare or
followed by either separator, keying on the character rather than
runtime.GOOS so the Windows form is exercised from the ordinary Linux and
macOS test jobs — the same trade openclawTildeRest documents. It is checked
before the tilde branch, and an empty OPENCLAW_HOME is an error rather than
a fall-through: resolving the literal variable name against the working
directory would hand back a confident absolute path to a file that cannot
exist, which is the failure this change is about.

Tests: the two path shapes, the empty-variable error, and an end-to-end
prepare guard on the reporter's banner-then-path output shape, mirroring
TestPrepareOpenclawConfigExpandsWindowsTilde. Each was mutation-checked by
disabling the new branch — the unit tests then report the path resolved
against the test's own directory, and the end-to-end one reports a wrapper
with no $include at all.

* fix(execenv): resolve a tilde-valued OPENCLAW_HOME, per review

The branch this PR adds joined the remainder onto the raw environment value, so
a documented OPENCLAW_HOME reproduced the very failure the PR exists to remove.

Upstream accepts a tilde-valued OPENCLAW_HOME — `docs/help/environment.md` at
`v2026.5.27` ("can also be set to a tilde path (e.g. `~/svc`), which gets
expanded using the same OS home fallback chain before use"), implemented in
`src/infra/home-dir.ts:41-47` — and expands it *before* computing the home that
`config file` then shortens. So on such a host the printed `$OPENCLAW_HOME`
stands for `<os-home>/svc`, and landing on the same file means resolving it the
same way. Measured before this commit:

    OPENCLAW_HOME=~/svc, input "$OPENCLAW_HOME\.openclaw\openclaw.json"
      -> "<daemon-cwd>/~/svc/.openclaw\openclaw.json"   err=nil

No error: filepath.Abs turns the embedded `~` into a confident absolute path,
the stat misses, and the wrapper goes out with no `$include` — silent
fresh-install, reached through the new branch.

The file also disagreed with itself: openclawFallbackConfigCandidates already
ran the value through expandOpenclawPath, so the fallback discovery route
resolved `OPENCLAW_HOME=~/svc` correctly while the new branch did not. Two
routes, same variable, different answers. They now agree — verified directly:
the fallback yields `<os-home>/svc/.openclaw/openclaw.json` and
openclawHomeFromEnv yields `<os-home>/svc`.

openclawHomeFromEnv expands only the tilde in the value, deliberately not the
variable form, so a self-referential value cannot resolve against itself.

Tests, both levels because a unit assertion alone would not have caught this —
the value's `~` survived into a path that *looks* absolute, so only following it
to the wrapper shows the `$include` going missing:

- unit: tilde value with either separator, bare `~` value, and the bare variable
  against a tilde value;
- `prepareOpenclawConfig`: `OPENCLAW_HOME=~/svc`, CLI printing
  `$OPENCLAW_HOME/...`, asserting `$include` and IncludeRoot land on
  `<os-home>/svc/.openclaw/openclaw.json`;
- a negative case (`$OPENCLAW_HOMEX/y`, `$OPENCLAW_HOME_EXTRA/y`,
  `${OPENCLAW_HOMEX}/y`, `$OPENCLAW/y`), which is what actually guards the risk
  section's claim that no path working today changes.

Mutation-checked by returning the raw value: the unit cases report the literal
`~` left in the path, and the prepare-level case reports `wrapper has no
$include`.

Also per review: both expansion errors now name the path with %q, not just the
variable, since the path is what makes a daemon log line actionable.

---------

Co-authored-by: weiweiwei <[email protected]>
2026-08-21 00:26:22 +08:00
Eric BerryandClaude Opus 5 ff90875003 fix(web): only let the routed layout own the workspace singleton (#7346)
`app/[workspaceSlug]/layout.tsx` writes the platform workspace singleton from
render. The App Router can keep a previous workspace's layout mounted beside
the incoming one, and both re-render on their own query activity, so the
unguarded write let them alternate — every render flipping the singleton back
to its own slug, indefinitely.

Two things read that singleton and broke with it:

- `packages/core/realtime/provider.tsx` binds the realtime socket to the slug,
  so each flip tore the socket down and rebuilt it against the other
  workspace. Seen on a self-host as a `close 1005` / reconnect pair every
  20-35s alternating workspace, with realtime updates lost for whichever
  workspace the user was actually looking at.
- `packages/views/editor/extensions/mention-suggestion.tsx` reads it
  synchronously when the user types `@`, then looks up the agent and member
  caches by that id. Pointed at the other workspace those caches are cold, so
  the mention list came back empty.

Gate the write on the live pathname. The URL is the source of truth for
workspace identity and `usePathname()` returns one value shared by every
mounted instance, so exactly one layout — the routed one — can define it, with
no ownership handshake. Desktop keeps its own protocol
(`workspace-route-layout.tsx`) because it has no URL bar to appeal to.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-21 00:22:15 +08:00
Danila Katalshov ec6a7b1285 MUL-6356: fix(skills): skip binary supporting files in runtime-local skill sync (#7175)
* fix(skills): skip binary supporting files in runtime-local skill sync

collectLocalSkillFiles read every supporting file into
SkillFileData.Content as a Go string. That string crosses the
daemon/server hop as JSON, and encoding/json rewrites each invalid UTF-8
byte to U+FFFD, so writeSkillFiles recreated a file whose bytes differ
from the original. A .docx, .xlsx or .pdf carried in a runtime-local
skill bundle therefore arrived in the prepared task environment corrupt
and would not open.

The archive/URL importer already refuses this class of file for the
mirror-image reason: those bytes cannot go into a PG TEXT column. Its
extension blacklist moves to internal/skill as
IsLikelyBinaryFilePath so both ingestion paths share one definition,
and runtime-local collection now skips the same files with a log
breadcrumb, matching the importer's behaviour.

The skip does not depend on includeContent: discovery reports the bundle
with content omitted and sync uploads it with content included, so a
skip in only one pass would leave the two disagreeing about which files
make up the bundle.

Fixes #7143

* fix(skills): catch binary supporting files by content, not just extension

collectLocalSkillFiles only skipped files whose extension matched a
fixed blacklist, so a binary file with an unlisted or missing
extension (.safetensors, .parquet, a stray no-extension blob) still
went through SkillFileData.Content and came back corrupted on the
other side. Read the file and require utf8.Valid on both the
includeContent=false (discovery) and includeContent=true (sync)
passes so they keep agreeing on the bundle; IsLikelyBinaryFilePath
stays as the cheap extension-based first pass.

Adds regression cases for an unlisted binary extension, a
no-extension binary file, and a valid-UTF-8 text file that must still
pass through untouched.

* fix(skills): require NUL-free content, check extension before reading

utf8.Valid alone is not a sufficient round-trip guarantee: a NUL byte
is legal UTF-8, but the server-side import path strips every 0x00 via
sanitizeNullBytes (server/internal/handler/skill_create.go), so a file
that is valid UTF-8 but contains an embedded NUL still comes back
different from what went in. UTF-16LE text made of ASCII characters is
a realistic example. Require content to be both valid UTF-8 and
NUL-free, with embedded_nul as its own skip reason distinct from
invalid_utf8.

Also reorders the extension check ahead of the read: IsLikelyBinaryFilePath
is genuinely the cheap first pass now, so a known-binary extension never
pays for the os.ReadFile it doesn't need.

Adds regression cases for an unlisted extension and a no-extension file,
each containing valid UTF-8 with an embedded NUL.
2026-08-20 23:57:52 +08:00
fa34f0e134 MUL-6243 test(gc): pin that gc-check reports a status's category, not its key (#7327)
* test(gc): pin that gc-check reports a status's category, not its key

The daemon GC decides reclaim eligibility with literal string comparisons
— `result.Status == "done" || "cancelled"`, and an `isKnownIssueStatus`
switch over the 7 built-ins. Neither knows custom statuses exist, and
that is correct: an installed daemon has no catalog, and daemons
predating MUL-6243 must keep deciding correctly against an upgraded
server.

So the normalization belongs to the server, and both gc-check endpoints
already do it via issuestatus.Effective. Nothing pinned that. Dropping
either call compiles, passes every existing test, and silently means:

  - an issue on a `done`-category custom status is never terminal, so its
    workdir is retained indefinitely, and
  - isKnownIssueStatus rejects the raw key, disabling the
    GCCompletedTaskTTL full-cleanup path for that issue.

Adds the regression test over both endpoints (batch and the legacy
per-issue fallback older daemons still call), and records the invariant
on the daemon side, where a reader of those literals is most likely to
conclude the function needs teaching about custom statuses.

No behavior change.

MUL-6243

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

* fix(gc): resolve a gc-check batch with one catalog read, not one per row

Elon's review. BatchIssueGCCheck resolved each row through the
package-level issuestatus.Effective, which issues a
GetIssueStatusEntryByKey for every CUSTOM key it sees. With the batch
capped at 500 ids, one request could cost 500 catalog point lookups — on
the endpoint that exists specifically to replace per-issue requests, and
which every installed daemon runs on a timer. Enabling custom statuses
would have turned the GC loop into a query amplifier.

A request-scoped issuestatus.NewResolver reads the catalog lazily and at
most once, so cost is flat in the number of custom rows and still zero
when a batch holds only built-ins. The legacy per-issue endpoint keeps
the single-shot Effective — one issue, at most one lookup — but now goes
through the same injection point so its cost is observable too.

Pinned with the existing counting querier, extended to count per-key
lookups: that count IS the N+1, and nothing was watching it. Reverting to
the package-level call fails with "per-key catalog lookups = 4, want 0".

MUL-6243

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 23:53:49 +08:00
Bohan Jiangandmultica-agent 8c9b7503a1 MUL-6146 fix(rerun): stop cancelling an in-flight agent run on manual rerun (#7282)
* fix(rerun): stop cancelling an in-flight agent run on manual rerun (MUL-6146)

RerunIssue cancelled every active task for the (issue, agent) pair before
enqueueing the replacement, including 'running' and
'waiting_local_directory'. That made a manual rerun destructive: asking an
agent for another pass silently killed the pass it was still working on.

Neither status appears in idx_one_pending_task_per_issue_agent_v2, so a
fresh queued row can be inserted alongside an executing one, and
ClaimAgentTask's per-(issue, agent) serialization already holds it there
until the active run reaches a terminal state. Narrow the cancel to rows
that have not begun executing and let the queue do the rest.

Rows that have not started are still cancelled and replaced rather than
reused, which keeps the new run attributed to the rerunning member
(MUL-4302 §5) instead of inheriting the attribution of whoever created the
pending row.

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

* fix(rerun): skip auto-retry when a manual rerun already holds the pending slot (MUL-6146)

Letting a manual rerun queue behind a running task opened a collision the
previous commit did not account for. If that running task then failed for a
reason whose retry child is created immediately (timeout, runtime_recovery,
codex_semantic_inactivity, skill_bundle_unavailable, or provider_network's
first attempt), CreateRetryTask inserted a SECOND queued row for the same
(issue, agent) and violated idx_one_pending_task_per_issue_agent_v2.

That insert shares FailTask's transaction, so the unique violation rolled the
parent's failed status back with it: the daemon's terminal callback saw a 500
and the task was left stuck in 'running'.

Guard both auto-retry paths with hasRunnableSuccessor. When something is
already pending for the pair, skip the retry — a runnable successor is exactly
what the retry was for, and the rerun is it. The FailTask check runs inside the
failure transaction so a concurrently committing rerun cannot take the slot
between check and insert.

runtime_offline and provider_network's final attempt were never affected:
retryDelayForAttempt defers those, and a deferred child is outside the index.

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

* fix(rerun): make the retry insert yield the pending slot instead of raising (MUL-6146)

The previous commit guarded both auto-retry paths with a successor check and
claimed the in-transaction check prevented a concurrent rerun from taking the
slot. It does not: hasRunnableSuccessor is a plain count at READ COMMITTED and
takes no lock, and rerun's cancel and enqueue are separate commits, so a rerun
can always commit between the check and the insert. The unique violation then
aborts FailTask's transaction, which also carries the parent's failed status,
leaving the task stuck in 'running' — the very bug the guard was meant to fix.

Move correctness into the insert: CreateRetryTask now yields via ON CONFLICT DO
NOTHING, so it can never abort its caller's transaction. That makes the policy
explicit — whoever already holds the slot keeps it, so a deliberate human rerun
wins over an automatic retry. Both callers read the resulting zero rows as 'no
retry created' and still commit their own work; this also stops the pre-existing
workspace-teardown fence from aborting a failure the same way.

For the reverse order, RerunIssue re-clears and enqueues once when a retry beat
it into the slot, so the human action still wins. Bounded to one extra attempt.

The successor checks stay as an optimisation, with comments corrected to say so
rather than claiming a mutual exclusion they never provided.

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

* fix(rerun): fence deferred promotion and recognise the normalized slot-taken error (MUL-6146)

Two gaps left by the previous commit, both reachable now that a manual rerun can
sit in the pending slot while a task is still running.

Deferred retries escaped the conflict fence entirely. runtime_offline and
provider_network's final attempt arm the retry as a 'deferred' row, which is not
covered by idx_one_pending_task_per_issue_agent_v2, so CreateRetryTask's ON
CONFLICT never fires and the row is created alongside the rerun. Promotion then
flipped it to 'queued' unconditionally and hit the index — and because the claim
loop promotes before it claims, that error blocked every claim on the runtime,
starving the rerun the operator was waiting for. Both promote queries now skip a
row whose (issue, agent) slot is occupied, and promote at most one row per pair
so a single statement cannot collide with itself. Skipped rows stay deferred and
promote on a later tick once the slot frees, so no retry is lost.

RerunIssue's reclaim only matched the raw pgconn violation, but
enqueueMentionTaskWithCommentPlan normalizes it to the bare
ErrDuplicatePendingTask sentinel — the path taken by every rerun whose target is
not the issue's current agent assignee (squad leader, an agent re-fired by
task_id after being displaced, a mentioned agent). Those never entered the
reclaim branch, so losing the race surfaced as a hard error. pendingSlotTakenErr
now matches both shapes.

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

* fix(rerun): cancel superseded deferred retries and tolerate promotion slot conflicts (MUL-6146)

Keeps the one product invariant this change exists for: one rerun click means
exactly one more run.

The previous commit only declined to promote a deferred retry while the slot was
occupied, and a test froze that as correct. It is not. Once the manual rerun
starts running it stops occupying the queued slot, so the stale retry promotes
and runs the agent a SECOND time — duplicate comments, duplicate side effects,
duplicate cost, and nothing anywhere to signal it. That also contradicted this
file's own 'a manual rerun replaces the pending plan' and FailTask's 'a runnable
successor already exists, so no retry is needed'.

A superseded deferred retry is now cancelled before promotion instead. Scope is
deliberately tight — retry clones only (retry_of_task_id set), never
assignee-fallback escalations or channel-media pending rows, which are supposed
to coexist with an active primary.

Promotion also tolerates the unique violation now. The in-query NOT EXISTS fence
cannot see an enqueue that has not committed yet, so promotion can still collide;
that is self-healing (the next tick sees the committed row) but the error used to
fail the claim outright, and for the batch path it failed every runtime in the
set. One contended row now skips promotion for a tick instead.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-20 18:12:30 +08:00
20ceaccc43 MUL-6471: fix(daemon): let pi and opencode reach custom providers (#7312)
* fix(daemon): let pi and opencode reach custom providers (MUL-6471)

A self-host setup whose pi/opencode providers point at an
OpenAI/Anthropic-compatible gateway could not run a Multica task on either
runtime, while the same work succeeded when the CLI was invoked by hand in
the same container (GH #7300). Gateway-style providers register model ids
that themselves contain a slash — `claude/claude-opus-5` under provider
`multica-anthropic` — and both failures came from treating that slash as a
provider boundary, or from having no provider at all.

pi: buildPiArgs split the model id on the first slash and passed the leading
segment as --provider, so a slash-shaped id became `--provider claude --model
claude-opus-5`. pi answers an unknown --provider with a hard error before it
ever looks at --model. The selector now goes to --model whole and --provider
is never synthesized: pi's own resolver accepts `provider/id`, a bare id, and
an id containing a slash, so passing less is strictly more capable. Verified
against pi 0.67.2 — the old argv reproduces `Unknown provider "claude"`, the
new one resolves to provider=multica-anthropic, model=claude/claude-opus-5.

opencode: --model is `provider/model`, and an id persisted without its
provider is rejected outright (`UnknownError`, reproduced against opencode
1.18.14). The daemon cannot invent the missing segment, so it asks the
runtime's own catalog: an id that exactly one provider claims is promoted to
the catalog's canonical selector, and anything uncertain — a static fallback
catalog, an already-canonical id, two providers exposing the same bare id —
is passed through untouched for the CLI to judge.

Two reported side effects fall out of the same root cause. `thinking_level`
was dropped for both runtimes because the capability lookup is keyed on the
model id and missed every catalog entry; it now matches. And `starting agent`
logged entry.Model, the env-var tier alone, so every task whose model came
from agent.model announced itself with an empty model — the resolution now
happens before the log.

The task row's `model: null` is not part of this: it is read from the run's
usage rows, which a run that fails before its first provider call never
produces.

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

* fix(daemon): gate selector qualification on the runtimes that can use it

Review follow-up on MUL-6471. The first pass ran catalog discovery for every
task that pinned a model, on the argument that the path already takes one
memoized lookup for thinking_level / service_tier. That argument does not
hold: cachedDiscovery deliberately skips the cache for empty and fallback
results (#3729, MUL-5549), precisely so a transient failure can retry. A
logged-out or failing runtime would therefore pay its full discovery ceiling —
15s for opencode/pi/omp, 30s for openclaw — on every task, including runtimes
whose bare model ids could never be rewritten anyway.

Qualification is now an explicit runtime capability. ModelIDsAreProviderQualified
is true only where Model.Provider is literally the ID's own prefix: pi builds
`provider + "/" + id`, opencode reads the provider back off the id. Runtimes
carrying a bare id next to a display-name Provider — claude's "anthropic",
copilot's "openai", dsh's "DeepSeek" against a `deepseek-official/...` id —
never reach discovery. Built-in runtime identities resolve through their
protocol family, so a pi-family fork like omp inherits the capability from its
descriptor rather than a name check.

The daemon side moves into qualifyTaskModel so the call boundary is testable.
TestQualifyTaskModelSelectorsAndDiscoveryBoundary pins both halves: pi,
opencode and omp resolve to the selector their CLI accepts, and claude, codex
and an empty model never reach listModels at all.
TestModelIDsAreProviderQualifiedMatchesCatalogShape keeps the capability
honest by deriving the expected answer from the real catalog producers instead
of restating the list — it fails if the predicate and the emitted ID shape
ever disagree.

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

* fix(daemon): read a runtime's model catalog at most once per task

Second review round on MUL-6471. Two findings, one root cause: the daemon
treated catalog discovery as cheap when cachedDiscovery deliberately makes it
anything but — an empty or fallback result is never memoized (#3729,
MUL-5549), so every read costs a logged-out or timing-out runtime its full
15-30s ceiling again.

A single task could pay that twice. qualifyTaskModel read the catalog to
promote the model selector, then ValidateThinkingLevel read it again to check
the level — back to back, on exactly the reporter's configuration (pi or
opencode plus thinking_level=high). The two now share one lazily-loaded,
task-scoped catalog: resolveTaskModelSelection owns it, and the validators
take that loader through ValidateThinkingLevelWith / ValidateServiceTierWith
rather than reaching for ListModels themselves. The global immediate-retry
contract is untouched — the reuse lives and dies with the task.

The second finding removes the read entirely for pi. Qualification is not a
launch requirement there: buildPiArgs already proves pi's resolver takes a
canonical selector, a bare id, and an id containing a slash, so a pi task with
no capability override now starts without spawning a discovery subprocess at
all. ModelIDsAreProviderQualified is replaced by
ModelSelectorMustBeProviderQualified, which states the contract that actually
justifies the cost — the CLI refuses an unprefixed id — and is therefore true
only for opencode and its DevEco fork. That also retires the catalog-shape
claim the old predicate made, which deveco was already a counterexample to.

The resulting matrix, pinned by
TestResolveTaskModelSelectionReadsTheCatalogAtMostOnce: pi/opencode with a
thinking level qualify and validate on one read; pi, omp, claude and
unpinned-model tasks read nothing; opencode without an override still reads,
because it cannot launch otherwise; codex without a model still fails both
checks closed without reading. A failed read is not retried inside the task.
Verified the count assertion bites by removing the memoization and watching
the pi and opencode cases report 2 reads.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 18:01:53 +08:00
f737974b74 docs(changelog): add v0.4.31 release entry (2026-08-20) (MUL-6474) (#7314)
* docs(changelog): add v0.5.0 release entry (MUL-6474)

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

* docs(changelog): reclassify v0.4.31 release (MUL-6474)

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

* docs(changelog): name what shipped in the v0.4.31 title (MUL-6474)

The v0.4.31 title was three evaluative phrases -- "smoother agent runs",
"safer automation", "reliable Windows builds" -- none of which names a
thing a reader can recognize. House style for changelog titles is 2-3
parallel noun phrases naming the actual feature, page, or surface.

Replace all four locales with the three changes a reader would notice:
the run confirmation before an assigned Issue leaves the backlog, the
Webhook event filters, and the Windows build fix. Terms follow the
existing glossary and each entry body.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
v0.4.31
2026-08-20 17:42:49 +08:00
814ee0cbf7 docs: remove six finished implementation records (#7258)
* docs: remove the two implementation docs under docs/

The database index audit and the Telegram channel review were one-off
implementation records for MUL-6108 and the Telegram contribution; both
are finished work and neither is referenced by the product docs. Drops
the now-dangling review pointer from the Telegram handoff notes.

docs/assets/ stays — the READMEs load the logos from it.

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

* docs: remove the Telegram integration handoff notes

HANDOFF-telegram.md was the implementation record for the Telegram
channel contribution and is referenced by nothing. Operator-facing setup
lives in the localized Telegram guides under apps/docs/content/docs/.

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

* docs: remove three finished implementation leftovers

- apps/mobile/docs/markdown-renderer-research.md: 2026-05 renderer
  selection research and incident log. Its conclusions already live in
  markdown-rendering-adr.md and in the MD_FONT/MD_LINE comments; the HIG
  calibration source is folded into markdown-style.ts so the numbers keep
  their rationale.
- packages/views/locales/glossary.md: a redirect stub. The content moved
  to the docs-site conventions pages, which now say the file is gone
  instead of claiming a stub still points at them.
- scripts/audit-redundant-indexes.sql: the MUL-6108 one-off audit query,
  orphaned once its report was deleted — no caller in Makefile, check.sh
  or CI.

All pointers into the deleted files are updated; nothing dangles.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 17:33:29 +08:00
18f130a5d9 fix(migrate): restore unique migration prefixes after the 362 collision (#7319)
#7176 and #7182 both took prefix 362 before either merged, and the
uniqueness lint was silenced by adding 362 to the historical
duplicate-prefix exception list. Neither migration has shipped in a
release, so the collision is still fixable.

Renumber the later of the two (362_agent_task_durable_work_dir -> 376)
and drop the 362 exception, so the uniqueness guard covers the whole
tree again. 362_plugin_hook_engine keeps its prefix because migrations
363-365 build on the objects it creates and must stay ordered after it.

The runner keys schema_migrations on the full stem, so any database that
already ran 362_agent_task_durable_work_dir re-runs this file under the
new stem. Its DDL is idempotent (ADD COLUMN IF NOT EXISTS / DROP COLUMN
IF EXISTS), so the replay is a no-op and the stale 362 ledger row is
inert -- the readiness check only requires every on-disk version to be
recorded, not the reverse.

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 17:31:27 +08:00
8676e063c2 docs(plugins): drop the "never executes plugin code" claim (#7307)
The sentence conflated two different things: not EXECUTING plugin code and
not HOSTING it. Serving a static file is not executing it, so the claim was
doing rhetorical work it could not support — it read as a security rationale
for making authors self-host, which the isolation model does not require.

The isolation that is real stays documented where it is enforced: the
sandboxed iframe without allow-same-origin, the host-generated document, and
the no-credential-in-frame bridge.

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:54:40 +08:00
a12985ab01 MUL-6472: fix(autopilots): stop leaking dispatch error text through Run now (#7311)
* MUL-6472: fix(server): stop echoing dispatch errors from TriggerAutopilot

The unclassified-failure branch appended err.Error() to its 500 body, so a
member clicking "run now" could read the Go error chain behind it — pgx table
and constraint names, SQLSTATE, internal ids. It was the only 5xx in this file
that did; the other err.Error() sites are 400s written for the user.

Return the fixed string and log the chain server-side instead, matching how
autopilot_webhook.go reports its own internal failures. The quota branch above
keeps its structured 429 + Retry-After body untouched.

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

* MUL-6472: fix(views): keep server 5xx text out of the run-now toast

The catch branch fell back to err.message, which is the server's error string —
so the fixed 500 above would still have shown as English "failed to trigger
autopilot" in a Chinese UI, and any other 5xx would show its raw body.

clientErrorMessage() keeps a 4xx message (handlers write those for the user:
"autopilot is not active") and withholds a 5xx one or a transport failure, so
run-now degrades to its localized generic sentence. Typed reason_code outcomes
are unaffected — they were already localized.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:48:03 +08:00
1ea40db1c4 fix(build): honor GOOS as a Make variable when naming build outputs (#7309)
#7271 derives the Windows `.exe` suffix from a parse-time `go env GOOS`,
which cannot see GOOS passed as a Make variable: `make build GOOS=windows`
still emits PE binaries named `bin/server`, `bin/multica`, `bin/migrate` —
the exact artifacts #7255 reported as unable to re-exec themselves. The
top-level `export` puts the Make variable in the recipe's environment, so
`go build` targets Windows while the name says otherwise.

Reading `$(GOOS)` first covers both invocation forms, and making the
assignment target-specific keeps the toolchain probe on `build` alone: a
global assignment is expanded on every make invocation — `export` expands
even a recursive one — so `make help` / `make clean` printed
`go: Command not found` on checkouts without Go.

Adds scripts/makefile-build.test.sh to pin both invocation forms, the
non-Windows names, and the no-Go case, wired into the backend CI job that
already runs the other Makefile-adjacent shell tests.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:24:22 +08:00
Jamison-J 211eaeb91e fix(build): add Windows executable suffix (#7271)
Fixes source builds on Windows producing extensionless executables that Windows cannot re-execute.

Closes #7255
2026-08-20 16:14:15 +08:00
3268d01172 fix(daemon): move the in_progress start write into workflow step 3 (MUL-6417 follow-up 2) (#7295)
* fix(daemon): move the in_progress start write into workflow step 3; define ancillary by output (MUL-6417 follow-up 2)

Field evidence from MUL-6460: a run that verifiably received the #7283
brief (its workdir CLAUDE.md carries the two-moment rule) walked steps
1→2→3 for over an hour and never issued a single status write. Two
compounding causes, both in the prompt:

- Placement. The start-moment rule lived in the status block BELOW the
  numbered steps. At the moment its condition triggers the model is
  executing the list, and a rule outside the list does not fire — the
  pre-MUL-6417 opening write was reliable precisely because it was an
  explicit step. The conditional write now opens step 3 itself, at the
  read→work boundary.
- The research example. "A todo issue you were asked to research stays
  todo" was written for consult pull-ins, but the MUL-6460 turn's work
  WAS research toward its own issue's ask — the example pattern-matched
  the exact case it was never meant to cover. Ancillary is now defined
  by output (the turn produced none of the issue's own deliverable),
  and research/design explicitly count as work when they are the ask.

Guard tests pin the step-3 placement and the research-counts-as-work
clause, and ban the retired example. Skill, source maps, and user docs
updated to match.

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

* refactor(daemon): state the status rule as continuous, anchored at the moments that fire (MUL-6417)

Bohan's review of #7295: 'at the end of your work, write the state it
reached' still read as restricting writes to fixed checkpoints. The lead
now states the invariant — keep status true at every point in the turn,
write whenever the work changes it, mid-turn included (a blocker is
recorded when hit) — and the end of the turn becomes a verification
('do not exit with a stale value') instead of the write moment.

The anchors stay. A free-floating 'write when necessary' with no
attachment points is exactly the shape that produced zero writes in the
MUL-6460 incident: permission without a trigger moment does not fire.
Step 3 keeps the start write; the exit check keeps the guarantee that a
turn never ends with a lying board.

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

* fix(daemon): drop activity-word skip-lists; ancillary is decided by output alone (MUL-6417)

Elon's #7295 review: 'review' still sat in step 3's skip clause and the
no-write bullet's example list, replaying the research incident's exact
failure class on an issue whose ask IS reviewing a PR. Activity words
carry no deliverable information in either direction, so both lists are
gone: step 3 names forms of work (code, research, a design, a plan, or a
review when reviewing is the ask), the no-write case is stated purely by
output (the turn produced none of THIS issue's deliverable), and the
rule now says so explicitly — the kind of activity never decides, only
whether the output is part of what the issue asks for.

Guards pin the review-as-work clause and the never-decides sentence, and
ban both retired skip-lists. Skill, source map, and docs synced.

Measured brief delta vs main (claude, production-length UUID): ordinary
13,362 → 13,971; leader 14,467 → 15,076 (+609 each, cached prefix);
per-turn messages unchanged.

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

* refactor(daemon): adopt J's review — one criterion, anchors in steps, category-scoped skip (MUL-6417)

J's review of #7295, all four points taken:

- The positive form-list that replaced the banned skip-list was the same
  failure shape read as exhaustive (a triage or reproduce-the-bug turn
  finds itself in no list). Both directions of activity-word lists are
  gone; the never-decides sentence moves INTO step 3 — the position that
  fires — instead of the status block, the position this PR itself
  proved dead.
- The exit-side check is anchored inside step 5 ('Before exiting, ...'),
  making the PR's own placement argument self-consistent.
- The step-3 skip is scoped to the in_progress CATEGORY, so a custom
  status like Planning already counts as recorded once MUL-6460 puts the
  status catalog in front of agents — no rewording needed then.
- Tense: 'if any part of what this turn will produce ...' — step 3 is a
  prediction; mispredictions self-heal under the continuous rule.

Plus J's meta-point on guards: presence-pins cannot tell where a
sentence lives, and both field incidents were correct sentences in dead
positions — the anchors are now pinned positionally (must appear inside
the step-3 / step-5 lines), and the retired positive list joins the
banned set. Kept the questions/discussion/acknowledgements clause (J's
conservative option): acknowledgements are not covered by 'answered a
question' and the sentence is the MUL-6300-era invariant other tests
pin.

Measured (claude, production-length UUID): ordinary brief 13,685,
leader 14,790 — +243 vs main, down from +609 at the previous commit.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:09:32 +08:00
cc21d19353 MUL-6463: confirm the promotion that starts an agent run (#7293)
* feat(issues): confirm the promotion that starts an agent run (MUL-6463)

Assigning an agent to an issue opens a confirmation with a handoff note and
"Don't start yet". Promoting a parked issue that ALREADY has an agent owner
starts a run just as directly — the status change alone is what fires
RunSourceStatus — but applied silently from a one-click picker, with no way to
tell the agent anything before it woke up.

The dialog now covers both writes. `RunConfirmModal` gains a `promote` mode
that submits the status change (plus the same optional handoff note, or
suppress_run for "Don't start yet") instead of an assignee change; the owner is
already on the issue, so the promotion payload carries the status and nothing
else.

The gate is the run predicate, not a status allow-list: parked (backlog
CATEGORY) + agent/squad owner + a target outside backlog/done/cancelled. So
built-in `todo` and every custom Todo-category status behave identically, which
is what the report asked for, and so do the other promotion targets that start
a run the same way. Drag-and-drop and the batch toolbar keep applying directly
— the existing split (MUL-4155), not a new one.

Also fixes the assign gate next to it, which still compared the raw status KEY
to "backlog": an issue parked in a CUSTOM backlog-category status popped a
confirm promising a run the server would never start. Both gates now resolve
the category the way the server does (issuestatus.Effective), preferring the
payload's own status_category and falling back to the workspace catalog.

Tests: promotion routing for built-in todo / custom todo / custom backlog
origin / non-todo target, the five no-run status changes that stay direct, the
custom-backlog assign regression, and the modal's promote payloads (note,
suppress_run, no assignee fields) plus its status naming. Verified each fails
without the change.

Docs: the promotion dialog in assigning-issues (en/zh/ja/ko).
Co-authored-by: multica-agent <[email protected]>

* fix(issues): bold the status name in the promotion confirm

"Move this issue to Rework." left the status looking like an ordinary word —
worse for a custom status an admin named something like Later or Blocked-ish.
The headline already had a fence-and-bold mechanism for the assignee name, so
generalize it to bold every fenced span and fence the status too.

CJK copy drops the 「」 it used for the same job: bold is now the one mechanism
across all four locales.

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

* fix(issues): close the three run-confirm gaps from review (MUL-6463)

1. Server and client disagreed about moving WITHIN the backlog category.
   `WillEnqueueRun`'s status branch only excluded `done`/`cancelled`, so
   `backlog` → a custom Backlog-category status (`Later`) changed the key,
   normalized both sides to `backlog`, and still enqueued a run — while the
   client, which reads categories, applied it silently. Before custom statuses
   the two conditions were the same; now leaving the category is its own
   requirement. Regression test covers both halves: no run inside the category,
   still exactly one on the way out.

2. The table's inline pickers bypassed the gate entirely — `meta.updateIssue`
   went straight to the surface action, so promoting an agent-owned backlog row
   from the table started a run with no confirmation. `meta.updateIssue` now
   takes the issue (the gate needs its category and owner) and routes through
   the same intent.

3. "Unresolvable category" was collapsing into a guess. `catalog.categoryOf`
   answers `todo` for a custom key it has not loaded, which made an unknown
   status read as not-parked and skipped the promote dialog. Category
   resolution is now explicitly three-state — carried, built-in, catalog, else
   null — and both writes that can start a run confirm on null.

The gate itself moves to a pure `runConfirmIntent` next to a canonical matrix
test, so the issue detail, the context menu and the table all route on one
answer instead of three copies; the hook/table suites keep only their wiring.

Docs nit from the same review: the new CJK paragraphs said issue / イシュー /
이슈 where the glossary requires 任务 / タスク / 태스크, and named statuses in
capitalized prose instead of backticked keys.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:03:51 +08:00