mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
8ade96e671bca7d84fd2d764ffca305e2fdc358d
4970
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
18a85b8c7f |
fix(github): support single-character issue prefixes (#7385)
Co-authored-by: Rogee <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
6450c7ad30 |
Revert "chore(server): log task message batch size (#7371)" (#7382)
This reverts commit
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
52a063d0df |
chore(server): log task message batch size (#7371)
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
a3bd38da6a |
fix(cli): redact autopilot webhook credentials (#7365)
* fix(cli): redact autopilot webhook credentials * fix(cli): preserve webhook token presence metadata |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
211eaeb91e |
fix(build): add Windows executable suffix (#7271)
Fixes source builds on Windows producing extensionless executables that Windows cannot re-execute. Closes #7255 |
||
|
|
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]> |
||
|
|
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]> |