mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
8ade96e671bca7d84fd2d764ffca305e2fdc358d
277
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
4ff37edf91 |
fix(daemon): record in_progress when work on the issue's ask starts, not only at turn end (MUL-6417 follow-up) (#7283)
Post-merge report from Bohan: a todo issue assigned to an agent sat in todo for the whole first work turn — half an hour of active work the board attributed to nobody. The end-of-turn-only rule conflated when the agent can JUDGE with when the FACT changes: a turn that advances the issue's own ask makes "being worked" true the moment it begins. The activity indicator shows the run, but columns, filters, and sorting read status — the indicator alone is not the surface people watch. The status rule now has two write moments, same fact anchor: - start: the moment the turn is known to advance the issue's own ask → in_progress (only if it differs — rework on an in_review issue flips it back the same way); - end: the state the work reached (in_review / in_progress-continuing / blocked). An ancillary turn (research, answers, review, discussion) still writes nothing at either moment, so the research-stays-todo case and the concurrent-convergence property from the original MUL-6417 change are unchanged. This is the pre-MUL-6417 timing with the fact anchor's conditionality — not a revert to the unconditional lifecycle flip. Docs, skill reference, and source maps updated to the two-moment rule; guard tests pin the start moment and ban the end-only phrasing. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
e1be1625cb |
MUL-5959: feat(agent): add Dim (DimCode) ACP runtime (#6675)
* feat(agent): add Dim (DimCode) ACP runtime Add Dim (dimcode, the `dim` CLI) as a first-party agent runtime, driven over the ACP (Agent Client Protocol) transport via `dim acp`. Key integration points: - New dim backend (pkg/agent/dim.go): spawns `dim acp`, performs the ACP initialize/session/new handshake, and raises the runtime's hardcoded read-only permission preset to full-access (plus agent mode) via session/set_config_option before the first prompt — without it every file write is denied by a capability rule. Model override uses session/set_model; the model catalog is read from session/new. - Session resume is intentionally skipped: Dim binds sessions to the creating process, so a later process's session/load is rejected with "held by another process" even after a clean session/close. The backend always starts a fresh session and reports ResumeRejected so the daemon classifies the run correctly. A best-effort session/close keeps Dim's own session list free of orphaned entries. - Registration: SupportedTypes whitelist, New() factory, launch header, daemon probe (MULTICA_DIM_PATH / MULTICA_DIM_MODEL), default agent command name, protocol_family CHECK migration (255), model discovery, MCP config support, metrics label, and runtime display. - Docs/UI: provider logo, landing i18n (en/zh/ja/ko), providers and install-agent-runtime docs, README, CLI_AND_DAEMON, SELF_HOSTING. - Tests: dim backend unit tests (fresh session + resume behavior), shared ACP deliverable case, logo and MCP-support tests. * feat(views): use official DimCode app icon for Dim provider logo Replace the placeholder inline SVG with the official DimCode desktop client app icon (from /opt/DimAgent/resources/build/icon.png, resized to 128px), imported as a static asset the same way the Qwen Code mark is handled. * test(agent): add real dim ACP smoke tests (agentintegration) Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1: - TestDimRealACPSmoke: full end-to-end against real `dim acp` — initialize, session/new, set_config_option (permission/mode), prompt, and assert completed output. - TestDimRealResumeRejected: pass a fake ResumeSessionID, assert the backend starts a fresh session (no session/load), and reports ResumeRejected=true so the daemon classifies the run correctly. Verified against dimcode 0.3.2 and 0.3.8. * fix: renumber dim migration 265 → 271 (upstream added 265-270) * fix: renumber dim migration to 272 (upstream added 271) * fix(agent): dim cross-run resume via session/load + review fixes Address review feedback on the Dim ACP runtime: - #1 Deliver runtime brief to Dim: add dim to the AGENTS.md provider list (verified dim 0.3.8+ reads AGENTS.md from the session cwd). - #2 Cross-run session continuity: dim 0.3.10+ releases its per-process session lock ~5s after the owning process exits, so resume now goes through the standard ACP session/load (same as traecli/kiro/grok) instead of always starting fresh. A loaded session retains its permission/mode, so set_config_option runs only on fresh sessions. Add a two-run regression (TestDimRealCrossRunResume) proving run B recalls context established only in run A. - #3 Process lifecycle races: the deferred cleanup now cancels the run context before cmd.Wait (a child ignoring stdin EOF no longer hangs Result delivery) with a bounded force-kill fallback; the success path waits for the final prompt notification with a grace window instead of a non-blocking read that could miss the last message. - #4 Isolate model discovery by executable path (discoveryCacheKey). - #6 Real smoke test now writes a sentinel file to prove full-access is effective; a best-effort session/close is sent before teardown. - #7 gofmt. * test(agent): add dim process-lifecycle regressions (review #3) - TestDimCleanupKillsHangingChild: a child that ignores stdin EOF and SIGTERM after an early failure is force-killed within dimProcessWaitTimeout, so Result still closes. - TestDimPromptMissingNotificationStillCompletes: when session/prompt returns without stopReason (onPromptDone never fires), the bounded final-notification wait falls through and Result completes instead of hanging. * fix(migrations): renumber dim runtime_profile migration 272 → 273 (upstream added 272) * fix(agent): review round 2 — process tree, quiescence, fail-closed, version check Address all remaining blockers from the second review: - #1 Require dim >= 0.3.10 (version check at initialize); bounded retry on session/load when the lock is not yet released instead of silently starting fresh; integration test no longer sleeps before resume. - #2 Process-tree cleanup: configureProcessGroup + signalProcessGroup + waitProcessGroupGone; cmd.Cancel returns nil so we own all signalling; regression asserts the descendant PID is gone. - #3 Notification quiescence: onActivity + waitForACPNotificationQuiescence (same as hermes); Dim equivalent of the late-final-notification test. - #4 Permission fail-closed: set_config_option runs on BOTH fresh and resumed sessions; on config failure, session/close is sent before returning so a partially configured session is not resumed; regression. - #5 Migration renumbered 273 → 274 (upstream added 273). - #6 AGENTS.md mapping regression, two-executable cache isolation test, session/close unit assertion, smoke test now executes a command. - #7 README.md + CLI_AND_DAEMON.md updated with 22 CLIs including Dim. * chore: remove temporary review actions doc (not for PR) * fix(migrations): renumber dim migration 274 → 310 (upstream added 274-309) * review: fix retry break scope, dedup isACPSessionNotFound comment, update stale notes * fix(agent): pass *exec.Cmd to signalProcessGroup/waitProcessGroupGone (upstream signature change) * fix(agent): close session on set_model failure + resume regression test Audit-found gaps from self-review: - set_model failure now sends session/close before returning (same as set_config_option failure) — reviewer #4 said 'permission, mode, or model'. - Add TestDimConfigFailThenResumeReestablishes: run A config fails → session/close sent → run B resumes and re-applies set_config_option (fail-closed), completing successfully. - Fix two stale comments that contradicted the code (config block now runs on both fresh and resumed sessions). * fix(agent): add WaitDelay, use labeled break in retry loop Self-audit improvements: - Add cmd.WaitDelay=10s for consistency with claude.go (hard backstop if a process somehow survives SIGKILL). - Use labeled break (break loadRetry) so runCtx cancellation during the retry delay exits the for loop directly, not just the select. - Update PR description: migration 273→310, set_config_option now re-applied on both fresh and resumed sessions. * fix(migrations): renumber dim migration 310 → 313 (upstream added 310-312) * fix(migrations): remove stale 313 dim migration (replaced by 314 after upstream added dsh at 313) * fix: DSH compatibility + thinking levels + reviewer round 3 Systematic fix of all 39 items from the compatibility gap analysis: Rebase + migration: - Rebase to latest upstream/main (resolves all DSH conflicts) - Migration 314_runtime_profile_add_dim (whitelist includes both dsh + dim) - Remove stale 313 dim migration DSH + Dim coexistence (provider lists, counts, docs): - SupportedTypes, config.go, metrics labels: both dsh + dim - i18n (4 files): count 23, lists include DSH + Dim + Oh-My-Pi - README.md/README.zh.md: count 23 - CLI_AND_DAEMON.md: 23-row table - environment-variables.mdx (4 langs): MULTICA_DIM_PATH/MODEL callout - display.ts, mcp-support.ts, types/agent.ts, provider-logo.tsx: both dsh+dim Thinking levels (was MISSING — dim supports thought_level): - dim.go: call applyACPEffortOption after set_model - thinking.go: add dim to acpCatalogThinkingProviders - dim.go: retain sessionResult for effort option Reviewer round 3: - Version check fail-closed: empty/malformed → reject (not allow) - Cache test through ListModels call site with fake executables - set_model failure regression test - Test companions: sidecar_manifest_test, runtime_config_test add dim Code quality: - agent.go: fix unreachable duplicate return (rebase artifact) - dim.go: labeled break in retry loop, WaitDelay, session/close on set_model fail * fix: deliverable test fake version + provider-logo rebase artifact * fix(migrations): renumber dim migration 314 → 315 (upstream added 314_workspace_mcp_config) * fix(migrations): renumber dim migration 315 → 319 (upstream added 315-318) * fix: Windows process tree, MinVersions, retry tests, migration 327 Address all 5 remaining blockers from review round 4: 1. Windows process-tree ownership: use startOwnedProcessTree instead of cmd.Start(), releaseProcessGroup in cleanup defer. This attaches the child to a Job Object on Windows (no-op on Unix), ensuring descendants are captured and terminated. 2. Migration renumbered 319 → 327 (upstream added 319-326). 3. Add dim: 0.3.10 to MinVersions in version.go so the daemon registers old Dim binaries as offline and refuses triggers (defense in depth on top of the ACP agentInfo.version check in dim.go). 4. Session-lock retry contract tests: success after bounded retries, exhaustion without falling through to session/new. Fake script supports DIM_LOAD_HELD_N (held for N calls then succeed) and DIM_LOAD_HELD_ALWAYS (always held). 5. README.md:206 20→23 runtimes. PR description updated. * test: add missing reviewer-requested regressions (round 4) - Windows Job Object regression: dim_windows_test.go proves the Dim backend captures and terminates descendants via startOwnedProcessTree (Windows-only build tag; companion to TestStartOwnedProcessTree). - MinVersions registration: add dim test cases to TestCheckMinVersion (0.3.10 ok, 0.3.9 rejected, invalid rejected). - Retry cancellation: TestDimSessionLoadRetryCancelled cancels the context during the retry delay, asserts no session/new fallback. * fix: sync upstream Command signature changes + restore dim additions Upstream changed ListModels/discoverXxxModels/detectCLIVersion to accept Command (struct with Path+Prefix) instead of string. The previous rebase kept our old-signature versions, causing widespread compile failures. Restored all affected files from upstream, then re-applied dim-specific additions: - agent.go: dim in SupportedTypes, New(), launchHeaders - hermes.go: isACPHeldByProcess + -32002 in isACPSessionNotFound - thinking.go: dim in acpCatalogThinkingProviders - models.go: dim case in ListModels + discoverDimModels - version.go: dim in MinVersions - All dim test files preserved (dim_test.go, dim_integration_test.go, dim_windows_test.go, models_test.go dim cache test) * fix: rebase to latest main, migration 327→341, resolve DetectVersion conflict * fix: remove unused os/exec import in dim_windows_test.go * fix(migrations): renumber dim migration 341 → 342 (upstream added 341) * fix: use Command.exec instead of exec.CommandContext (upstream GH #7046) Upstream's TestOnlyLaunchGoSpawnsRuntimeProcesses requires all backends to build processes through Command.exec (launch.go), so a custom runtime's fixed_args are carried into the subprocess. Dim was the only backend still using exec.CommandContext directly. * fix: launch prefix policy + remove empty test files + deterministic retry tests Address all 4 findings from review round 5: 1. Add "dim": dimBlockedArgs to launchPrefixBlockedArgs so protocol-breaking flags (--help/--auth-setup/--remote) are filtered from fixed_args. 2. Remove 77 zero-byte *_test.go files accidentally added at repo root. 3. Make retry tests deterministic: TestDimSessionLoadRetryCancelled waits for the first session/load request before cancelling (no fixed sleep); TestDimSessionLoadRetryExhausted asserts exactly 4 load attempts. 4. PR description will be updated separately. * fix: rebase to latest main, migration 342→343 (upstream added mcode at 342) * chore: trigger CI * chore: refresh PR * fix: restore mcode that was lost during merge conflict resolution Merge took 'ours' side which predated mcode. Restore mcode in: - SupportedTypes, New() factory, launchHeaders - agent_supported_types_test.go want map - metrics/labels.go - config.go defaultAgentCommandNames - migration 343 up/down whitelists * fix: restore mcode probe in agents_probe.go + agent-cli-command-names.txt * fix: restore all remaining mcode references lost during merge Files fixed: - README.md: mcode row in runtimes table - mcp-support.test.ts: mcode assertion - config.go: mcode in Agents comment + error message - runtime_config.go: mcode in AGENTS.md case - version.go: mcode in MinVersions - version_test.go: mcode test cases Verified: no file has fewer mcode references than upstream. * fix: restore mcode in environment-variables docs + CLI_AND_DAEMON ACP list - environment-variables.{mdx,ja,ko,zh}: restored 'MiniMax Code' in the QwenPaw model-variable sentence - CLI_AND_DAEMON.md: added MiniMax Code to ACP-family list + loadSession fallback description Verified: every changed file now has >= mcode references vs upstream. * fix(migrations): renumber dim migration 343 → 344 (upstream added 343) * fix(migrations): renumber dim migration 344 → 348 (upstream added 344-347 plugin migrations) * fix(migrations): update down migration comment 344 → 348 * chore: retrigger CI (flaky TestOpenclawDiscoveryCacheConcurrentPreparations — upstream test, passes locally x5) * fix(migrations): renumber dim migration 348 → 352 (upstream added 348-351) * fix: Dim launch-prefix regression test + version.go comment repair (review #6) - Add TestDimLaunchPrefixFiltersBlockedFlags: proves allowed prefix reaches command before acp, and --help/--auth-setup/--remote/-h are stripped from Dim's launch prefix. - Add dim to TestLaunchPrefixReachesACPFamilies family list. - Repair version.go: dim's cross-run session/load comment was appended to mcode entry during a rebase conflict; restore two independent accurate comments. * fix(migrations): update down migration comment 348 → 352 * chore: retrigger CI (backend-tests stuck 50+ min) * fix(migrations): renumber dim migration 352 → 362 (upstream added 352-361) * fix: use logAgentCommand for dim argv logging (upstream redaction requirement) Upstream's TestOnlyLaunchGoLogsAgentCommandArgs enforces that all runtime argv logging goes through Config.logAgentCommand so sensitive args are redacted. dim.go was still using Logger.Info directly. * fix(migrations): renumber dim migration to 370 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
4f56785b02 |
refactor(daemon): merge Reply/Ownership turn modes into one fact-driven workflow (MUL-6417) (#7245)
* refactor(daemon): merge Reply/Ownership turn modes into one fact-driven workflow (MUL-6417)
The Reply/Ownership split dated from PR #205 and had already been merged
in substance by MUL-5377 (one brief section + mode router) and MUL-6300
(reply turns own the same status arc). What remained was a per-turn
marker plus two mode blocks restating information the per-turn message
already carries as data. This finishes the merge:
- Status is judged once, at the END of the turn, from what the work
actually changed about the issue — not from the trigger type, not from
the run lifecycle, and not gated on being the assignee. No opening
in_progress write: the board's in-flight signal is the issue activity
indicator, and a todo issue the agent was only asked to research now
correctly stays todo (the old unconditional arc got that wrong twice).
Lifecycle writes oscillate under concurrent runs; fact writes converge,
which is what the retired assignee gate existed to protect.
- Delivery routes on data: a triggering comment in the per-turn message
(reply in that thread with THIS turn's --parent, one reply per thread)
or none (post a new top-level comment).
- The blocked rule and the squad-leader dispatch case become end states
of the same judgment instead of mode-block special cases; the leader
keeps one bullet (dispatching members is not delivery). The reply-mode
mention pointer is gone — the discipline lives in ## Mentions.
- The Turn mode markers are removed from the per-turn prompts, deleting
the marker/router-drift failure class (
|
||
|
|
7fdc854c26 |
fix(issues): copy durable local path for worktree tasks (MUL-6378) (#7176)
A worktree-mode local_directory task runs in a disposable git worktree that Finalize removes on every exit path, so the task's `work_dir` pointed at a directory that no longer existed. "Copy local workdir path" handed the user a path they could not `cd` into. The daemon now reports a `durable_work_dir` handoff instead of having clients infer one: - `removeLocalWorktreeDir` returns an error and confirms removal with `Lstat`, so a broken symlink cannot be mistaken for an absent directory. Finalize fails when removal cannot be confirmed. - `durable_work_dir` is written only after Finalize succeeds and the worktree is verifiably gone. A preserved worktree, an in-place run, a running task, a squad-leader task, and any older daemon all leave it unset, keeping the real `work_dir` authoritative. - The field travels on the complete, fail, and cancel-ack callbacks and is persisted on the task row (migration 362, additive nullable column). Writes use COALESCE for replay idempotency; the cancel-ack path CASes on `status = 'cancelled'` so a late ack cannot touch a completed or failed row. - The server derives `relative_durable_work_dir` through the same home-stripping helper as `relative_work_dir`; the absolute value is reserved for explicit clipboard actions and is never rendered. - Clients select the durable path only for a terminal task that carries the field. The issue actions menu and the transcript dialog share one resolver, and the label and toast say the task worktree was reclaimed, naming the delivery branch when one exists. The stored path is a point-in-time delivery snapshot: later renames or detachments of the project resource do not rewrite historical tasks. |
||
|
|
eb2a94e860 |
MUL-6368: Prevent cross-agent task and workdir contamination (#7205)
* fix(daemon): isolate task identity and workdir reuse Co-authored-by: multica-agent <[email protected]> * test(llm): use validated retry override in contract Co-authored-by: multica-agent <[email protected]> * fix(daemon): address task isolation review Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
728ba9c58e |
fix(daemon): stop concurrent tasks evicting each other's OpenClaw discovery cache (#7204)
loadOpenclawDiscoveryCache rejected any entry stamped later than the reader's clock sample, treating it as a clock jump. Callers sample time.Now() once and pass it down, so on a daemon starting several tasks at once a peer routinely commits a valid entry dated microseconds after the reader sampled — and the reader, whose own store just succeeded, threw it away and re-ran discovery for nothing. TestOpenclawDiscoveryCacheConcurrentPreparations flakes on CI for exactly this reason; it reproduces locally in a few hundred runs. Tolerate a bounded skew (1s) instead of any future dating at all. That is far above the interleaving window and far below the clock jump the guard exists to catch, and it bounds the cost of being wrong: an entry dated ahead outlives its TTL by at most the tolerance. TestOpenclawDiscoveryCacheFutureDatedEntry pins both edges — a concurrent writer's entry is a hit, a jumped clock is still a miss — so the tolerance cannot be widened into 'never expires' or narrowed back into the flake. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
4d5a679b9b |
fix(openclaw): unblock the OpenClaw runtime on slow hosts (MUL-6321) (#7142)
Fixes #7112. Task preparation ran `openclaw config file` and `config get agents.list --json` with a hardcoded 5s deadline; on a 2020 Intel MacBook Pro those take 8.4-10.9s and 4.3s every time, so the OpenClaw runtime was unusable there with no way to raise the limit. - Deadline 5s -> 30s, overridable via MULTICA_OPENCLAW_CLI_TIMEOUT / backends.openclaw.cli_timeout, clamped to [1s, 60s] so the worst case (4 serial calls) stays inside the 5-minute preparation budget. - Config discovery cached per daemon profile (on disk, since preparation runs in a one-shot helper process), invalidated by binary identity, OpenClaw path env, active config stat, and a 60s TTL. Only complete successes are cached; any cache fault degrades to a live CLI run. - The timeout is classified structurally into a new platform-side, non-retryable reason (runtime_cli_timeout) instead of being text-matched into agent_error.provider_network, which showed "check your network" copy and auto-retried a deterministic local failure. Copy added for web/desktop/mobile; a NormalizeDaemonReason shim upgrades reports from older daemons. |
||
|
|
4e10d6805a |
fix(execenv): handle OpenClaw JSON errors on stdout (#7133)
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
de902a4aab |
MUL-6273: feat(agent): add MiniMax Code ACP runtime (#7035)
* feat(agent): add MiniMax Code ACP runtime * fix(agent): terminate mcode process trees * fix(agent): address mcode review blockers * test(agent): keep mcode model contract API-stable * fix(migrations): resolve mcode prefix collision * fix(agent): launch mcode through runtime command Assisted-by: MiniMax Code * fix(migrations): renumber mcode migration Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
212ecfb8dd |
MUL-6300: fix(daemon): let reply turns own the status arc for their own issue (#7083)
* fix(daemon): let reply turns own the status arc for their own issue (MUL-6300) A comment-triggered run was forbidden from touching issue status: "Do NOT change the issue status unless the comment explicitly asks for it", written in PR #205 (2026-03-31) on the day on_comment became the default trigger. It was aimed at conversation — questions, thanks, agent-to-agent chatter should not churn the board — but it also covered the most common real trigger, a comment asking for rework on a delivered issue. The agent reworked the issue with its status frozen, and a human had to move the card by hand (MUL-5165). Reply mode now owns the same arc Ownership mode does — in_progress when the work starts, the status the work is actually in when the turn completes — but only for turns that carry work on an issue assigned to this agent. The two properties the old prohibition really protected are kept and pinned by tests: - a purely conversational turn never writes status; - a run that is a guest on someone else's issue (@mention pull-in) never writes status, so an agent pulled in to answer a question cannot push another assignee's in-flight issue to in_review. Squad leaders keep routing on the Squad Operating Protocol's "Own the parent issue status" grant, which the server injects only when the issue is assigned to that squad — so the guest-leader path (MUL-3724) stays closed, and the grant no longer has to be phrased as an exception to a prohibition. Also refreshes the two built-in skills that documented the old rule. Supersedes #5781, whose one-line exception was written against brief text that MUL-5377 has since rewritten. Co-authored-by: multica-agent <[email protected]> * fix(daemon): compress reply-mode status rule, cover unassigned issues (MUL-6300) Review round on #7083. The ordinary reply-mode status bullet drops from 103 to 70 words and the squad-leader bullet from 110 to 92, with the conversational-turn sentence now identical in both branches. The no-write invariant broadens from 'assigned to someone else via @mention' to 'not assigned to you': an @mention can also land an agent on an UNASSIGNED issue (triage), which the old blanket prohibition covered but the first draft of the positive rule left undefined. Guard-test pins and the fallback pin in reply_instructions_test.go are synced to the new wording; the arc-trigger substring is unchanged so its two pins survive as-is. SKILL.md carries the same broadened phrasing. Co-authored-by: multica-agent <[email protected]> * docs(daemon): purge stale no-status-unless-asked semantics from ledger surfaces (MUL-6300) Elon's review round on #7083: four surfaces still explained current behavior with the pre-MUL-6300 rule. The squads source map (agent- readable) now says the status-ownership grant, not the system comment's ask, authorizes the leader's wrap-up write; prompt.go's turn-mode comment, squad_briefing.go's grant rationale (which also still pointed at the long-gone writeWorkflowComment), and the child-done stage test comment now describe the conditional arc. Past-tense history quotes (contract-test design note, MUL-6300 replacement note) stay verbatim. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
8b1acfd19e |
fix(execenv): fold redundant metadata-list read into the issue-get step (#7016) (#7042)
Issue-triggered runs paid two reads for the same data: the runtime brief mandated 'issue get' (whose JSON always carries the metadata bag) and then a separate 'issue metadata list'. Fold the read guidance into step 1, renumber shared steps 1-6 to 1-5, and update the brief tests to pin the new wording and ban the retired standalone read step. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
cc2aea3878 |
MUL-6104: fix(daemon): tell the brief's reader not to merge stderr into parsed output (#6870)
Both brief builders now state that `--output json` writes JSON to stdout while confirmations and warnings go to stderr, and that merging them (`2>&1`) into anything that parses the output makes a successful write look failed and invites a duplicate retry. Adds a regression test anchored on the rule's wording — premise, prohibition and consequence — asserted exactly once per rendered brief, so a reversed or duplicated rule fails the test. 234 bytes per rendered brief; a run renders one of the two builders. |
||
|
|
19155e41f9 |
refactor(prompts): compress duplicate --no-start guidance from #6956 (MUL-6168) (#6971)
PR #6956 stated the same rule — use --no-start when the write only records ownership/status of work already underway — five times across the always- injected brief, the sibling-runs block, and the working-on-issues skill. State it once per surface and keep the flag in the command signatures. Prompt-text only; no behavior change. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b5a48eca13 |
fix(agent tasks): prevent duplicate self-assignment work (MUL-6168) (#6956)
* fix(agent tasks): avoid duplicate self-assignment runs Co-authored-by: multica-agent <[email protected]> * fix(agent tasks): address duplicate-run review Co-authored-by: multica-agent <[email protected]> * fix(agent tasks): resolve sibling-run nits Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
ceb6c6845c |
MUL-6132 fix: stop stranding daemon task markers in user directories (#6917)
* fix(daemon): roll back local_directory sidecars when prepare fails (MUL-6132)
Prepare writes the daemon task marker and the rest of the sidecar tree into
the workdir early, but only persists the manifest that records them at the
very end. Every error return in between left that tree on disk with no record
of what to remove, and since the caller gets no Environment on those paths,
none of the teardown defers that normally undo it were ever registered.
For a local_directory task the workdir is the user's own repository, so the
leftover marker disabled every human-local multica command for that whole
directory tree — silently, and until someone deleted the file by hand. No
crash was needed to reach this state: a failed provider setup was enough, and
writeSidecarManifest failing was only a warning, so a disk or permission
hiccup produced the same permanent residue.
Roll back in place from the in-memory manifest whenever Prepare fails after
writing context files, and treat a manifest write failure as fatal in place so
that rollback runs while the record still exists. Worktree and cloud envs are
untouched: the former discards the whole worktree, the latter is wiped by GC.
Co-authored-by: multica-agent <[email protected]>
* fix(cli): retire stale daemon task markers instead of refusing forever (MUL-6132)
A daemon task marker left behind in a user's own directory made every
human-local command (daemon start/stop/restart/logs, login, logout, setup,
workspace switch, runtime profile set-path/unset-path) fail anywhere in that
directory tree, permanently and with a message that named neither the file nor
a way out. The reported instance sat undetected for five weeks.
Retire the marker automatically, but only on positive evidence that nothing is
running: every profile's daemon must answer and report zero active tasks.
Silence is not absence — an unreachable daemon is what a crashed one looks
like, and a task orphaned by that crash keeps running with this marker as its
only remaining guard, so any unreachable or unparseable profile keeps the
command refused. A minimum marker age closes the window where a daemon that
reports zero is claiming a task and about to write this very marker.
Two things stay out of scope by construction: markers without an agent and
issue id, which is the permanent node-wide guard at the workspaces root rather
than a task leftover, and any invocation carrying MULTICA_* task identity,
where the daemon really did launch this process and no file may override that.
When staleness cannot be proven the command still fails closed, but the error
now names the marker path, matching what newAPIClient has reported since
MUL-3922 and replacing a message that sent the user to the source.
Co-authored-by: multica-agent <[email protected]>
* fix(daemon): arm the in-place rollback before the first sidecar write (MUL-6132)
The rollback was registered after writeContextFiles returned, but that function
writes the daemon task marker as its very first act and can then fail on any
later step — .agent_context, skill files, project resources. Those failures
returned before the defer existed, so the case the fix was written for still
stranded a marker in the user's repository with nothing beside it.
Arm the defer immediately after the manifest is allocated, before any write.
Rolling back an empty manifest is a no-op, which is what makes arming it that
early safe.
Adds a regression test whose induced failure lands inside writeContextFiles
(a plain file where .agent_context must be a directory), one step after the
marker is written. It fails on the previous commit and passes here.
Co-authored-by: multica-agent <[email protected]>
* fix(cli): drop CLI-side marker deletion, keep the actionable message (MUL-6132)
The self-heal could delete the fail-closed guard of a running task. The daemon
increments active_task_count only after a claim returns, so a probe can read
zero for a task that is already claimed and about to write the marker at this
very path; the marker's age says nothing about a file written after the probe.
Serial probing across profiles widened the window further. Separately, the
"every profile answered" proof did not hold: profiles were matched by health
port, and that port is a hash that can collide, so one idle daemon could be
counted as two profiles while a crashed one kept an orphan task running.
Both are properties of doing this from outside the daemon. Removing a guard
needs synchronization with the claim path that only the daemon has, so
retirement moves there and the CLI keeps the half it can do soundly.
What remains is the message, now shared by both refusal paths. Previously only
newAPIClient named the marker file, so whether a user could act on the refusal
depended on which command they happened to run first; requireHumanLocalCommand
sent them to the source instead. Both now resolve the leftover through one
helper and report it identically.
Co-authored-by: multica-agent <[email protected]>
* fix(cli): report leftover markers on every refusal path, never the root marker (MUL-6132)
Two gaps from the previous round's review.
requireTaskLocalConfigRoot is the third path a leftover marker can refuse
through — config show, config set and auth status all call it — and it still
returned a message that named no file. Whether the error was actionable
depended on which command the user happened to run first, which is exactly what
unifying the other two paths was meant to end. It now appends the same suffix
when a marker is the only daemon signal.
The leftover helper also could not tell the two markers apart, because
daemonTaskContextMarkerPath matches on managed_by alone. Running from the
daemon workspaces root therefore described the permanent node-wide guard as a
leftover to delete — advice that closes the hole it exists to cover, aimed at
whoever reads the error, including an agent subprocess that lost its
environment. The helper now requires task identity, defined as agent_id OR
issue_id: a chat task has no issue, so requiring both would misfile its marker
as the root marker, while the root marker has neither field.
chdirWithDaemonTaskMarker was writing the bare managed_by form, which under
this rule models the root marker rather than the workdir marker its tests are
about; it now writes a task-scoped one.
Also corrects two comments the earlier commits left stale: cmd_agent.go still
described an automatic retirement that no longer exists, and
writeSidecarManifest still claimed every caller treats a write failure as
non-fatal, which stopped being true when the in-place path was made fatal.
Co-authored-by: multica-agent <[email protected]>
* docs(cli): correct the refusal-path count in leftover-marker comments (MUL-6132)
Both comments still said "both refusal paths" from when the suffix was shared by
newAPIClient and requireHumanLocalCommand only. requireTaskLocalConfigRoot
joined them in
|
||
|
|
4f10a944b1 |
feat(runtime): add DeepSeek Harness support (#6923)
* feat(runtime): add DeepSeek Harness support * docs(runtime): reflect public DSH release |
||
|
|
85045228a1 |
MUL-6102: fix(daemon): surface which HERMES_HOME a Hermes task actually read (#6892)
* fix(daemon): surface which HERMES_HOME a Hermes task actually read A Hermes task runs against a per-task HERMES_HOME overlay, seeded from a source home the daemon resolves from its own process environment. When that resolution lands on a home holding no config.yaml, the overlay is built anyway from nothing: the derived config keeps only skills.external_dirs, so the user's provider and credentials are gone and Hermes refuses every session/new with "No LLM provider configured". Nothing on that path said so. The overlay was seeded silently, no line logged the resolved source home, and the failure surfaced Hermes' own remedy -- run `hermes model` -- which edits whichever home the user's shell points at, not the one the task read. GH #6872 is what that costs: eight documented workarounds, none of which could have worked, and a root cause filed against an unrelated resume bug. Three additions, no behaviour change: - Warn when the source home is missing, or exists without a config.yaml. Kept a warning rather than a hard failure because config-less is a legitimate setup: an image that ships Hermes and injects OPENAI_API_KEY through the environment resolves a provider fine. - Log the resolved source home once per task, at Info. - Append the overlay and source homes to a "no LLM provider configured" failure, so the task itself names the directory that was read. The annotation runs after every classifier has read the error text. It embeds two user-chosen paths and Classify matches substrings anywhere, so annotating earlier would let a directory name pick the failure_reason -- a home under /srv/token-cache/limit/ satisfies rule 1 (context overflow), which is evaluated before rule 2 (missing config) and feeds retry eligibility and resume blacklisting. The phrase rule 2 already matched is now a shared const so the new predicate and the classifier cannot drift apart. Co-authored-by: multica-agent <[email protected]> * fix(daemon): keep the Hermes hint out of resume-guard input Review caught that the annotation did not stop at the daemon. It travels to the server as the task's error, is persisted in agent_task_queue.error, and is re-scanned there for the life of the row by service.ResumeUnsafeFailure and by the ILIKE/regex guards in GetLastTaskSession / GetLastChatTaskSession. Both interpolated paths are user-controlled -- HERMES_HOME comes from the agent's custom_env, the overlay root from MULTICA_WORKSPACES_ROOT -- so a legal directory name got a vote on session recovery. Measured, with a source home under /srv/400-invalid_request_error/: raw error ResumeUnsafeFailure = false annotated with the paths ResumeUnsafeFailure = true annotated with this hint ResumeUnsafeFailure = false That true is a healthy chat session pointer being cleared on a failure that has nothing to do with it, so "no resume semantics change" was not true as written. The hint is now fixed prose with nothing interpolated, and names the daemon log line that does carry the paths. This removes the vector rather than containing it: no wire or schema change, and it is correct against old servers too, so there is no mixed-version window where an un-upgraded server misreads a new daemon's text. Tests cover the boundary the previous ones missed: classification AND service.ResumeUnsafeFailure agree with the unannotated text across five real failure shapes, plus a check against the SQL guard phrases, which no unit test can execute. Also softens the missing-config warning, which asserted the task runs without credentials when the environment may well supply them -- the config-less-but-working setup this PR deliberately keeps supported. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
c9727e6b2b |
feat(daemon): worktree mode for local_directory resources (MUL-5707) (#6759)
* feat(daemon): worktree mode for local_directory resources (MUL-5707) Local directory resources serialise every task on a per-path mutex, so two tasks on one folder queue behind each other. This adds an opt-in worktree mode: each task gets its own git worktree of the user's repo inside the daemon's env root, so tasks run concurrently and deliver their work as a branch instead of editing the user's working copy. Opt in per resource with execution_mode=worktree (default stays in_place, so existing resources are unchanged). Requires a git repo with at least one commit; a non-git directory fails with an actionable message rather than silently falling back to serial execution. Design points that make the mode safe to use: - The agent starts from what the user sees, not HEAD. Tracked edits are captured with `git stash create` (which writes a commit object without touching the index, working tree, or stash list) and replayed into the worktree; untracked non-ignored files are copied, bounded at 2000 files / 200MB with the skipped count logged at warn level. - That replayed state is committed as a baseline, so "did this task change anything?" has an exact answer and `git diff <baseline>..<branch>` is precisely the agent's work. - Nothing is silently discarded: leftovers are committed to the branch before the worktree is removed, including when the task fails partway. - A task that changes nothing leaves no branch behind. - Sidecars stay out of the branch by running the existing CleanupRuntimeConfig + CleanupSidecars pass over the worktree before finalizing. The .git/info/exclude approach repocache uses is not usable here: a linked worktree resolves info/exclude to the user's own common git dir, so it would change what `git status` hides in their checkout. - Worktree-mode env roots do not get the local_directory GC exemption, so they age out normally instead of accumulating one env root per task. - `git worktree prune` on create self-heals registrations orphaned by a crashed daemon. Adds --execution-mode to `project resource add/update`, and updates the docs and the projects-and-resources built-in skill. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close data-loss and isolation gaps in worktree mode (MUL-5707) Review follow-up on #6759. Six issues, all on failure or skew paths. 1. Preserve work when the final commit fails. Finalize logged a warning and then ran `git worktree remove --force`, destroying the agent's changes. commit.gpgSign with no key available to the daemon triggers this, and --no-verify does not disable signing. Finalize now returns an error and deliberately keeps the worktree — it stays registered, so `git worktree list` points at the work — and the daemon fails the task instead of reporting success with a branch that is missing the changes. 2. Fail closed on an unsupported execution_mode. Treating an unknown mode as in_place was wrong: execution_mode is how a user asks for ISOLATION, so the "safe" fallback would edit the working copy they asked us to stay out of. An unrecognised mode now fails the task naming the version skew. 3. Keep Codex conversations alive across turns. The per-issue session store was keyed on LocalWorkDir != "", so worktree tasks — which also never reuse an env root — got a fresh empty CODEX_HOME per turn and silently lost the thread. Covered by a two-turn regression test that fails without the fix. 4. Fail closed on an incomplete snapshot. A failed stash create/apply, a failed untracked copy, or hitting the copy bounds all degraded to "agent sees HEAD" with only a daemon log line. A half-replayed tree looks like a working checkout, so nothing downstream questions it while the agent reads different code than the user has. All of these now abort the prepare and tear the partial worktree back down. 5. Do not snapshot a directory mid-mutation. The same real path can be attached to another project as an in_place resource; snapshotting under a running in_place task captured a half-written tree plus that task's in-flight .agent_context/.multica sidecars, which then reached the branch and could feed the agent another issue's brief. Worktree prepare now takes the per-path mutex for the snapshot alone (released before the run, so tasks still overlap), and the untracked replay skips Multica's own sidecars. 6. Stop the test helper from masking regressions. gitRun skipped on any git failure, including post-finalize assertions, so a lost branch could report as a skip. Git availability is now checked once at repo setup; every later git failure is fatal. Also rebased onto main, keeping both MulticaConfigRoot and LocalWorktree in the Environment initialiser. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close sidecar-commit and wait-visibility gaps in worktree mode; gate worktree saves on daemon version (MUL-5707) - runTask: register the sidecar-cleanup defer immediately after the Finalize defer. Previously two early returns (temp-dir setup, StartTask failure) sat between them, and on those paths Finalize auto-committed the sidecars Prepare had just written — delivering a branch whose only content is Multica's own runtime files (in_place: leaving them in the user's tree). - runTask: give the worktree snapshot wait the same plumbing as the in-place acquire — waiting_local_directory status, resourceWaitTasks accounting, and a cancellation poller. The holder can be an hours-long in_place task; the wait was invisible and a user cancel went unnoticed until the 5-minute prepare timeout. - isMulticaSidecarPath: match sidecar dirs as whole path segments at any depth, not only at the repo root. An in_place resource pointed at a SUBDIRECTORY leaves its sidecars below that subdirectory; replaying them committed another issue's brief into the delivered branch. - handler: refuse saving execution_mode=worktree while the owning daemon's registered cli_version is below MinLocalWorktreeCLIVersion (422, daemon_version_unsupported). An old daemon json-skips the field and runs tasks IN PLACE — the daemon-side unknown-mode refusal cannot protect a daemon that predates the field, so the server has to. Residual (documented): a daemon downgraded after the save needs a claim-time gate. - copyUntrackedFiles: read the -z listing raw instead of TrimSpace-ing it, which corrupted filenames beginning/ending with whitespace into paths that silently vanished from the replay. - docs + skill: describe the save-time version gate; drop the stale claim that old runtimes fail the task themselves. Co-authored-by: multica-agent <[email protected]> * feat(views): UI switch for local_directory execution mode (MUL-5707) Worktree mode shipped CLI/API-only, which for its target user — someone running several tasks at once from Desktop — means it does not exist. This adds the switch to the resources panel. The choice is framed by what the user gets back, not by the mode identifiers: "Edit this folder directly" (one task at a time, changes land in your working copy) vs "Run in parallel, isolated" (tasks run at the same time and each hands back an agent/… branch). `in_place` / `worktree` stay visible as a secondary hint for anyone cross-referencing the CLI or docs. Two entry points, matching where the decision is actually made: - The add-a-folder flow now asks for the mode after the folder is picked and validated, instead of creating the resource and leaving the mode to be discovered later. - Every local_directory row has a mode control. It is deliberately NOT gated on being the owning machine — switching the mode only rewrites a field, so it works from the web app too, unlike rename or the folder picker. Both blocked states are handled where the user chooses, not after they commit: - Non-git folder: the desktop validator now reports whether the path is inside a git working tree (walking up for `.git`, so a subdirectory of a repo counts), and the option is disabled with that reason. Unknown — an older desktop build, or an existing row — stays permissive; the daemon re-checks authoritatively. - Daemon too old: the version floor is mirrored from the server as MIN_LOCAL_WORKTREE_CLI_VERSION and checked against the runtime for the resource's OWN daemon, not the machine the browser happens to be on. A server rejection is rendered inline in the dialog rather than as a toast, so the dialog stays actionable. A "Parallel" badge on the row is always visible (not hover-only): without it there is no way to tell whether tasks on a folder edit it directly or hand back a branch, which is the first thing anyone asks when a task queues. Also adds `execution_mode` to LocalDirectoryResourceRef — the field existed server-side but had no TypeScript counterpart — and brings the zh/ja/ko project-resources docs in line with the English ones, which were the only locale updated when the mode landed. Co-authored-by: multica-agent <[email protected]> * fix(daemon,handler): close worktree isolation and delivery gaps (MUL-5707) Second review round. Five must-fix items, all on paths where the mode's promises could still break. 1. A failed sidecar cleanup no longer ships Multica's own files as the user's branch. The cleanup errors were logged and Finalize then ran `git add -A`, so a permission/disk/lock failure could deliver a branch whose entire content was the runtime brief and issue context. Worktree mode now escalates a cleanup failure to an abort: nothing is committed, the worktree is kept for inspection, and the task fails with the preserved path. In place it stays a warning — no commit, no branch, and the leftovers are visible in the user's own tree. 2. The untracked replay no longer drops files silently. `Lstat` failures and symlinks were `continue`d without counting, so the caller read a partial snapshot as complete. Both now count as skipped, which the existing fail-closed check turns into a refused prepare. Non-content entries (sockets, FIFOs, devices) stay uncounted deliberately. 3. Prepare rolls back a worktree it created. Any later failure — context files, provider home, MCP config — returned without an Environment, so nothing downstream knew a worktree existed, leaving a registration and a branch no task ever ran in. A deferred Discard now runs unless Prepare reached its successful return. 4. The daemon-version gate moved to where it can actually hold. Save-time validation cannot see a machine downgraded afterwards, and an old daemon json-skips execution_mode entirely — it would run the task in place, editing the working copy the user asked to isolate. Claims are now checked against the runtime that is actually claiming, and a blocked task is cancelled with a reason rather than left to be redelivered to the same too-old runtime forever. 5. branch_name is delivered end to end. The daemon sent it, but TaskCompleteRequest had no field so it vanished at decode, and the failure path never sent it at all. It now has a column, travels on both terminal callbacks, and renders as a copyable row in Run details. The failure path matters most: worktree mode commits the agent's leftovers before tearing the worktree down, so a run that died partway still has work worth finding. Docs (all four locales) and the projects-and-resources skill are updated for the claim-time gate and the branch surface. Co-authored-by: multica-agent <[email protected]> * fix(handler,daemon): migration renumber, cancel reasons, branch on every terminal path (MUL-5707) Third review round. 1. Rebased onto main and renumbered the migration to 285 — main had taken 273 and is now at 284. sqlc regenerated on top of the rebased queries; the conflicts were confined to generated files. 2. The claim gate now persists why it cancelled. It called CancelTask, which writes only status='cancelled', and returned the reason as a 4xx that reaches the daemon's log and nothing else — batch claim drops the task from its response entirely. A run refused for a durable reason the user has to act on cannot present as a bare "cancelled", so this adds CancelAgentTaskWithReason and records error + failure_reason on the row. 3. branch_name now survives every terminal path, not just the two direct callbacks. Three were dropping it: - the context-exhausted complete→fail reroute did not copy it; - the daemon's complete→fail fallback (server rejected the complete, agent had succeeded) did not set it; - both cancellation paths discarded the result wholesale, even though the worktree is finalized — and its work committed — before the daemon learns of the cancel. The branch now rides the cancel-ack and is recorded by SetAgentTaskBranchName, which never overwrites a name a terminal callback already wrote. 4. Rollout/rollback compatibility. Isolation is enforced by two server-side gates, so a daemon downgraded at any point after the resource was saved is caught. The gap they cannot close is rolling the SERVER back past this change while sub-floor daemons are still connected: an old server has no gate and an old daemon ignores execution_mode. That constraint is now documented where an operator will meet it (the migration and the docs), and the version floor is pinned across server and frontend by test so the two gates cannot silently drift apart. Co-authored-by: multica-agent <[email protected]> * fix(server,views): route claim-gate cancels through TaskService, make cancel reasons visible, harden cancel-ack branch delivery (MUL-5707) Closes the fourth-round review findings on worktree mode's cancellation chain: - Claim gate: cancel through a new TaskService.CancelTaskWithReason instead of a raw CancelAgentTaskWithReason query, so a gate-cancelled task gets the full cancellation side effects — audit capture, chat settle, agent status reconcile, task:cancelled broadcast, NotifyTaskFinished. Added REAL claim integration tests: singular claim (422 + row cancelled with reason), batch claim (task omitted + row cancelled with reason), and a current-runtime control. - Cancel reasons are now user-visible: execution log rows, agent activity rows, and the transcript dialog label a SERVER-cancelled task (persisted error/failure_reason) like a failed one, with the actionable text on hover and a Reason row in Run details. User-initiated cancels stay a plain "Cancelled". New cancelReasonLabel helper + local_directory_error label; details_reason locale key in en/zh-Hans/ja/ko; widened AgentTask.failure_reason to the open string the wire actually carries. - Cancel-ack is now a terminal delivery: the daemon retries it on transient errors (same schedule as complete/fail), the server fails LOUD (5xx) when persisting the branch/error fails instead of warn-and-200, and re-broadcasts task:cancelled after a successful write so clients that already refetched a branchless row pick up the delivery. Replays are idempotent (never overwrite). - Cancel + Finalize-abort no longer swallows the preserved-worktree pointer: the finalize error is wrapped in worktreePreservedError (joined onto an existing run error, never displacing it), extracted on the cancel path, and delivered via the ack into agent_task_queue.error through the new SetAgentTaskErrorIfEmpty query — never overwriting a reason recorded by a fail callback or the claim gate. Other run errors on a cancelled run stay discarded as before: they are expected noise. - Docs: the self-hosted rollback constraint callout now exists in zh/ja/ko, not just English. Co-authored-by: multica-agent <[email protected]> * fix(handler): guard cancel-ack writes to cancelled rows; requeue when the claim gate's cancel fails (MUL-5707) Fifth-round review fixes: - SetAgentTaskBranchName / SetAgentTaskErrorIfEmpty now carry a status='cancelled' CAS. The daemon acks on EVERY terminal status it observes, so a late or replayed ack from a stale run could previously backfill its branch or preserved-path error into a completed/failed row whose own callback — the authoritative channel — recorded nothing, without any event invalidating the change. A CAS-refused write is a deliberate no-op (still 200: nothing to retry), and the rebroadcast stays guarded by the same status check. Regression tests: a late ack against a completed row with empty fields and against a failed row leaves both untouched. - When the worktree claim gate's CancelTaskWithReason fails, the claimed row no longer strands in dispatched (daemon refused, nothing to release it but stale reclaim). The gate requeues via RequeueTaskAfterClaimFailure — the next claim re-runs the gate and retries the cancel — and reports 5xx so the singular daemon reads a transient server problem, not a claim refusal; the batch path omits the task as before, now with the row requeued. Fault injection tests (row-scoped trigger raising on the cancel UPDATE while letting the requeue through) cover singular and batch. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
28a7176328 |
MUL-6053: fix(daemon): give Hermes conversations a session that survives the task (#6845)
* fix(daemon): give Hermes conversations a session that survives the task Hermes keeps every ACP session in `<HERMES_HOME>/state.db`. Because the daemon takes over HERMES_HOME to inject bound skills, that database lived inside the per-task overlay, so a conversation lasted exactly one task. The failure was silent, which is what made it expensive. `session/resume` against a database that does not hold the session does not fail: Hermes creates a fresh session and answers with an ordinary success frame, and ACP's ResumeSessionResponse has no `sessionId` field to contradict it. The daemon therefore believed it had resumed, stored the same dead id again, and every turn restarted from zero with nothing in the logs (GH #6806). Three changes, in the order they matter: - The backend now reads `_meta.hermes.sessionProvenance.acpSessionId`, the only place Hermes reports the session it actually served. A turn that landed on a different session continues under the new id and carries the existing ResumeContinuityNotice, so the agent can read the transcript back instead of pretending it remembers. - `state.db` is linked to a per-conversation store at `<profile dir>/hermes-sessions/<agent>/<hermes profile>/<conversation>/`. Sharding per conversation is what makes this safe where #6693 left it out of scope: tasks of one conversation are serial, so a shard has a single writer, while two issues never share a database. SQLite resolves the link when deriving the WAL paths, so the journal sidecars live in the store and a task teardown cannot take the write-ahead log with it. Windows without symlink privileges keeps the database task-local rather than copying it. - `gateResumeToReusedWorkdir` no longer treats a matching workdir as proof the session is reachable. The two keys came apart in the local_directory flow, where workdir reuse is disabled but envWorkDir still equals PriorWorkDir, so the gate forwarded an id that could never resolve. Stores are GC'd on their own retention (`MULTICA_GC_HERMES_SESSION_TTL`, default 14d, matching the Codex session store) and held against the GC for the duration of a task. Co-authored-by: multica-agent <[email protected]> * fix(daemon): never destroy a Hermes session database on a failed mount Review of #6845 found three ways the new session store could lose or misreport a conversation. All three are on failure and reclaim paths that the original tests did not reach. - **The degradation path was destructive.** The mount migrated the task-local state.db into the store and deleted it, and only then tried to create the symlink. On a host that cannot symlink, that turned a conversation which previously survived via workdir reuse into an empty one — a regression on exactly the hosts the fallback exists to protect. The link is now created under a staging name first, and nothing destructive runs until it exists; a host that cannot link is left byte for byte as it was found. - **The migration was not atomic.** It copied state.db and its journal sidecars straight into the store, so a failure after the main database landed left a store that looked migrated. The next attempt skipped the migration, deleted the source, and took the un-checkpointed tail of the conversation with it. It now stages the whole family and publishes with one rename, reusing the memory store's staging helpers (renamed to drop their `memory` prefix now that both stores share them). - **"Mounted" was read as "resumable".** A store the GC reclaimed between turns, a switched Hermes profile, an operator's `rm`, or a dangling link from an older overlay all mount cleanly onto nothing, and the resume gate forwarded a dead session id into them — the same silent restart this change set out to fix. The mount now reports two separate facts, and the gate reads whether the store actually holds a transcript, not whether the plumbing succeeded. A zero-length database counts as no history. Tests cover each path with injected failures rather than host-dependent skips: a link failure preserves the database and its WAL and still mounts once linking works again, a migration that dies between the database and its WAL publishes nothing and loses nothing on retry, and a mounted-but- empty store (fresh, reclaimed, dangling, zero-length) reports no history. Verified non-vacuous — reverting each fix reproduces the reviewed symptom. Co-authored-by: multica-agent <[email protected]> * fix(daemon): make the Hermes session store agree with itself on "empty" Review of the previous commit found a certain data-loss branch left between the migration and its publish step. The two used different definitions of an occupied store: - the migration asks hermesStoreHasSessionDB — a zero-length state.db, or journal sidecars with no database, carry no transcript, so it proceeds; - the shared promote asked hermesStorePopulated — any directory entry at all — so the same remnants read as "a competitor published first". With a non-empty source and a zero-length state.db in the store, the staging copy completed, os.Remove(storeDir) failed on the non-empty directory, promote reported a lost race, the migration returned success, and the caller deleted the source database and its un-checkpointed WAL. The link then pointed at a database with nothing in it. Occupancy is now the caller's predicate: the memory store keeps "any entry", the session store passes its own "holds a real database". The session publish clears the remnants it has already classified as nothing before the atomic directory rename, and a store it cannot clear fails closed — the migration returns the error and the source stays put — rather than passing for a lost race. The regression test drives the whole matrix through prepareHermesHome: a zero-length database, orphan sidecars, a real database that must never be overwritten, and an unclearable store that must fail closed with the source intact. It fails on the previous commit with the reviewed symptom (HistoryPresent=false after the source was deleted). Co-authored-by: multica-agent <[email protected]> * fix(daemon): never delete a competitor's freshly published Hermes transcript publishHermesSessionStaging decided the store held no history, then deleted the state.db family by path name. Between those two steps a competing task could publish its real transcript, and the stale decision would remove the winner's database — after the winner had already reported success and discarded its source. The check, the remnant cleanup and the promote now hold one process-wide mutex. In-process exclusion is the real thing here, not an approximation: the daemon is the store tree's only writer (even the GC guard is in-memory refcounts on the Daemon), so two publishers can only ever be two goroutines of this process. A deterministic barrier test parks a publisher inside the critical section and proves a competitor cannot enter the window. The family cleanup also stops using RemoveAll: regular files and symlinks are unlinked (never followed), while a directory or any other irregular type under a state.db name is content this code cannot identify, so it fails closed and the caller keeps its source. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: Steve Jobs (Multica Agent) <[email protected]> |
||
|
|
8d292ef7ce |
MUL-5963: feat(server): let web chat read back history via multica chat history (#6680)
* feat(server): let web chat read back history via `multica chat history` When an agent's provider session resume fails on a web chat / Feishu task, the daemon injected SessionContinuityNoticeUnrecoverable, making the agent reply "this is a new session, no memory". But the conversation was never lost — it is persisted in chat_message, and the agent just had no command to read it back (only Slack had a history reader). Make `multica chat history` serve the stored chat_message transcript for any non-Slack session (web chat, Feishu), reusing the existing task-token authorization and the shared response shape. Route web chat / Feishu continuity notices to a new variant that tells the agent to read the transcript back instead of scripting a "new session" disclosure, and teach the Feishu channel prompt the read-back command. - handler/chat_history.go: GetChatChannelHistory falls back to chatMessageHistory (ListChatMessages, oldest-first, default recent 30) on ErrNoSlackSession. - execenv: add SessionContinuityNoticeChatTranscript; keep Unrecoverable for channels Multica stores no transcript for (e.g. WeCom). - prompt.go: route web chat / Feishu to the transcript notice; Feishu channel awareness teaches `multica chat history`. - tests: update surface/notice matrix, Feishu prompt policy, add handler transcript fallback tests. Co-Authored-By: Claude Fable 5 <[email protected]> * test(server): fix transcript read-back test to insert a real assistant row The insertChatVisibilityMessage helper hardcodes role='user', so the "assistant reply" row in TestGetChatHistory_NoSlackBindingReadsStoredTranscript was actually stored as a user message and the handler (correctly) returned role "user". Add an insertChatMessageRole helper and use it for the assistant row so the role-mapping assertion is meaningful. Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(server): treat WeCom as a readable transcript surface for session continuity WeCom is fully wired on main: it persists the conversation to chat_message and stamps ChatChannelType="wecom", so `multica chat history` reads it back just like Feishu's. Stop routing it to the Unrecoverable fallback; give it the transcript notice and the `multica chat history` history command, symmetric with Feishu (no thread reader, no upload). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(server): make transcript read-back reachable and honor the paging contract Addresses review on the web-chat history read-back feature. - GetChatChannelHistory no longer dead-ends on "no channel integration" when SlackHistory is nil: a no-Slack deployment (the exact target of this feature) now serves the stored chat_message transcript directly, and Slack-backed sessions still go through the Slack reader. - chatMessageHistory swaps ListChatMessages for ListChatMessagesPage, honoring the ?before cursor (encoded as created_at|id) and clamping ?limit (default 30, max 50), and advertises NextCursor on a full page so an agent can page back without re-reading the recent window. Author now uses the channel vocabulary (Bot / User) instead of the raw role string. - Adds a single SurfacePersistsTranscript predicate (web chat, Feishu, WeCom, DingTalk) as the one source of truth for "which surfaces are readable", shared by the continuity-notice router, the chat-prompt history copy, and the surface list — closing the DingTalk gap where the notice said "read it back" but the prompt said "no history reader for dingtalk". Tests: nil-reader-with-rows regression, transcript paging without duplicates or gaps, Author/TS pins, and DingTalk cases in the surface/notice and prompt tables. Co-Authored-By: Claude Fable 5 <[email protected]> * test(server): fix transcript paging test assertion The paging test wrongly asserted messages are strictly oldest-first ACROSS pages, but paging to older messages means each later page is older than the previous one — the sequence is not monotonic. The handler was correct; the assertion was wrong. Now it checks the real invariants: each page is oldest-first, and the union of all pages is exactly the transcript with no duplicates or gaps. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(server): stop telling transcript surfaces their history is not in Multica The channel-awareness block opened with "This conversation and its history live in <platform>, NOT in Multica" for every channel type, and the very next line told a Feishu/WeCom/DingTalk agent that Multica stores the conversation and hands it `multica chat history` to read it back. An agent that believes the first sentence has no reason to run the command in the second. Only the useful half — do not go hunting through issues and comments — is unconditional now. Where the conversation actually lives moved into each branch: Slack keeps the original wording (true there), transcript surfaces say the conversation happens on the platform and Multica stores a transcript of it, and the defensive fallback keeps both claims for a surface that really has no reader. Co-authored-by: multica-agent <[email protected]> * fix(server): name the channel a served transcript came from HistoryPage.ChannelType is documented as empty ONLY for a session bound to no channel, but the transcript branch never set it. A WeCom/Feishu/DingTalk session that HAD messages therefore reported channel_type "" — telling the agent it was in a web-only chat — while the same session's empty read already named the platform. One endpoint, two answers, decided by whether the session happened to have messages. Reuses the existing sessionChannelType helper, so the transcript path inherits its refusal to guess: a binding read that fails for any reason other than "no such row" is reported rather than silently answered as web-only. That failure now returns the same status as the empty-read path does for the same cause, instead of the 502 reserved for a failed channel read. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: qiushihao279-cloud <[email protected]> Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
84dc02cd65 |
MUL-6034: fix(cursor): normalize MCP approval server shapes (#6776)
Cursor normalizes an MCP server config into a fixed field set and order before
hashing it into an approval key, so equivalent configs written in a different
field order produced keys Multica never matched, leaving the server unapproved.
Serialize stdio servers as {type?, command, args?, env?, cwd?} and remote
servers as {type?, url, headers?}, dropping unknown fields, and marshal without
Go's HTML escaping so the bytes match Cursor's JSON.stringify output.
Approval keys verified against cursor-agent 2026.08.04-aaa8809 and
2026.07.20-8cc9c0b via `mcp enable`; expected hashes in the tests are captured
from a real Cursor install.
Reported and fixed by @wuchangming.
|
||
|
|
b904e6b71c |
MUL-6029: docs(daemon): correct two comments #6604 made false, and enforce the Slack/Lark brief policy (#6771)
Three follow-ups to #6604: two comments the merged diff made false, and one policy the tests stated but did not enforce. - `handler.go` said the brief turns `chat_channel_delivers_files` into upload-or-describe copy. After #6604 the per-turn prompt does, and the brief states no verdict at all — it is the prompt cache prefix, and a per-turn verdict there renders two briefs (MUL-5377). - `outbound_media.go` cited `TestDeliverAttachments_AdmissionBoundsTheLookupStage` for a sentence about turns carrying no file; that test's turns all carry one. The paragraph now states both ways admission is held without a pending slot and cites the one the test drives. - `TestBriefSurfaceDeliveryPolicy`'s `chat_slack` and `chat_feishu` rows denied two phrases where the WeCom rows deny four, so a denial worded around "does NOT apply" passed. Both rows now carry the same four. Review follow-up (tests only): `TestBriefChannelDeliveryCopyIsPlatformNeutral` holds the same policy structurally — with the display name substituted out, every channel-backed brief must be byte-identical, so a per-platform position fails whatever wording it uses. Slack/Lark `mustHave` reaches parity with WeCom, `mustNot` failures quote the real match instead of a section it may not be in, and the admission test asserts `pending == 0` rather than implying it. No behaviour change: two comments and test coverage. |
||
|
|
90eadd940a |
MUL-5905: feat(wecom): send the files an agent produced into the chat (#6604)
* feat(wecom): send the files an agent produced into the chat An agent that produced a file and answered in WeCom delivered only the sentence. `multica attachment upload` had already run, the object was in storage, and CompleteTask had bound the row to the assistant message — but nothing downstream of that bind knew how to reach a WeCom conversation, so the file stopped there. The user read "here is the forecast" and got no forecast, with no error anywhere to explain it. Worse when the agent said nothing at all: an empty completion with a file bound to it returned early, so the artifact was thrown away silently. This adds the last hop. media_upload.go carries a file up the long connection — WeCom has no REST endpoint for this, so it goes as aibot_upload_media_init / _chunk / _finish over the same socket, and all three read their verdict through the request() layer #6590 generalized. outbound_media.go is the caller: after the answer is out, it reads the bound attachment out of object storage, decides what to call it, and pushes it in as its own message. Three decisions worth naming. The answer goes first and the file never blocks it. An upload is megabytes and round trips and it can fail; delivery runs after the words are out, on its own goroutine and a budget of its own, and its worst outcome is one extra line saying a file did not make it. A lost ack and a refusal are opposite failures. A chunk whose verdict never came is offered once more — the protocol makes re-sending idempotent, and losing one slice of a hundred must not cost the file. A chunk the server refused is not, because the refusal is its answer and will be its answer again. The push of the finished media follows the same rule in reverse: a timeout is reported as-is rather than retried, because the same media_id sent twice puts the file on the user's screen twice with nothing to undo. WeCom validates bytes against the msgtype, so what to call a file is a decision. A kind past its ceiling is demoted to a plain file rather than sent and refused — a card the user can open beats a photo the server rejected. WithAttachments is what turns this on, and router.go passes it only when the deployment configured object storage. Without it the adapter behaves exactly as it did before. * fix(daemon): stop telling a WeCom agent its conversation is text-only The per-turn chat prompt and the runtime brief both decided file delivery by asking "is there a channel at all". Any non-empty ChatChannelType got the text-only paragraph: `attachment upload` does not apply here, describe the file in words, never write its local path. That was true of every IM channel when it was written, and it stopped being true for WeCom, whose adapter now reads the bound attachment out of object storage and sends it into the room. So an agent on WeCom was instructed not to use the one command that would have delivered its file. The capability shipped and the agent was told it did not exist. The delivery question is per-adapter, not "is there a channel", and ChannelCarriesFiles is where that answer now lives. Both call sites branch three ways instead of two. The new copy is deliberately built from main's existing web-chat branch rather than written fresh. Both open on the same clause — "To include a file or image you produced in your reply, run `multica attachment upload <local-path>`" — and diverge exactly where the platforms diverge: web chat says the file "appears as an attachment card below it" and offers a markdown snippet to place it inline; WeCom says Multica "sends it into the WeCom conversation as a separate message right after your text" and that "there is no way to place it inline". That last clause is the one worth having. An agent told only that files work here writes "see the chart below" and then nothing appears below, because the long connection has no msg_item and a picture cannot be embedded in the reply. Slack and Lark keep their existing text-only wording untouched. Two comments are rewritten rather than carried over, because they asserted the binary the same commit falsifies — "keys off 'is there a channel at all'" in prompt.go, and "any non-empty channel type means the reply leaves Multica for an external IM platform, where `attachment upload` has nothing to bind to" in the brief. A comment that contradicts the code beneath it is worse than none. ChannelCarriesFiles names every channel type this package knows rather than leaving Slack and Lark to the default, so a fourth adapter surfaces here as a decision to make. An unrecognized type answers false: the agent is told to describe its file in words, and the worst case is a deliverable that could have been sent was not. False the other way would have it promise a delivery nothing performs. * fix(wecom): stop claiming more certainty than the system has Three review blockers, one shape: a promise made in one place that another place cannot keep. Capability came from the channel type. The daemon answered "this conversation takes files" from `chat_channel_type == "wecom"`, but the last hop only exists where the server wired object storage — so a WeCom deployment without storage had its agents told to send files nothing would carry, and a new daemon on an older server inferred a capability that build has no code for. The server now answers it per turn on the claim (`chat_channel_delivers_files`), declared by the same `if store != nil` branch that gives the adapter the storage, and absent/false everywhere else. Upload limits came from the framing. 50 MB is what 100 chunks of 512 KB can describe; WeCom's own OpenClaw plugin puts the hard file ceiling at 20 MiB, so that is now the cap, enforced against the attachment's recorded size before any byte is read. Concurrency was a fixed 4 regardless of size; WeCom's own SDK steps it down — all at once up to 4 chunks, 3 up to 10, 2 past that, because their backend answers a burst of chunks on a large upload with a system error. Failure handling had two words for three outcomes. A send whose ack never came was reported as a definite failure although the file may well be in the chat, and the local failures — a full pending queue, a slot wait that expired, a lookup that errored — were reported to nobody at all. There are now three states: delivered says nothing, definitely_failed keeps the definite wording, and unknown gets wording that holds either way and is never resent, since a duplicate file cannot be taken back. The attachment lookup moved ahead of the rationing so a shed delivery knows there was a file to apologise for, and a turn with nothing attached still says nothing. * fix(wecom): bound the attachment lookup, and stop calling an attempted write a failure Two gates, not one better place for one gate. The pending counter is claimed after the lookup so a shed can name a real file, which is also why it cannot bound the lookup: at the point it would have to be claimed, whether the turn carries a file is exactly what is not yet known. admitAttachmentDelivery is claimed before the spawn and bounds the goroutines and the queries they run. Its cap is twice the pending cap, so a backlog meets the honest post-lookup shed first and only reaches the silent one when the system is already well past it. A refusal there is logged and not spoken — telling a user their file was dropped on a turn that may have carried none is the false alarm the post-lookup gate exists to prevent. Separately, a WebSocket write error stopped being read as proof of non-delivery. wsSender.write already distinguished the stages for tracing; it now carries that out in the error. Past WriteMessage the frame may be at the peer — a half-closed connection reports "broken pipe" to the writer for bytes the reader has — so those classify as unknown and are never resent. What fails before the write is provably local and stays definite. Tests drive the real path rather than the counters: one blocks the lookup, submits past the cap, and counts the goroutines and queries that actually happen; one hands the frame to a connection that records it and then fails. Both fail without the change. fakeObjectStore takes a lock, because it is no longer only ever driven inline. * docs(wecom): cite the size limits to WeCom's docs, not a third-party constant The upload ceilings were sourced to Tencent's OpenClaw plugin. That file carries the right numbers but cites nothing itself, so it corroborates rather than establishes them. WeCom documents all four on aibot_upload_media_init's body.total_size row, and repeats them on the classic media upload API. Point at those. * fix(wecom): drop the two media helpers #6605 brought in ahead of this branch This branch carried its own cleanShort filename and extension helpers because nothing else in the package had them when it was written. #6605 has since merged, and it brings both — so the package now declares each twice and does not compile against current main. GitHub still reported the branch mergeable and CI was green, because both were measured against a main that predated that merge by six hours. Upstream's copies are the ones to keep: its cleanMediaFilename also strips control characters out of a name, and its mediaExtension drops a charset parameter before matching. Neither of those was in the copies here. * fix(daemon): keep the file-delivery verdict out of the cached brief chat_channel_delivers_files arrives on every claim and answers a deployment question: does this server carry a file the last hop into the room. Upgrading a server so it starts sending the field, or turning object storage on or off, flips it under a chat session that is already running — and the brief branched on it, so one resumed session rendered two different briefs. The brief is the prompt-cache prefix (MUL-5377), which makes that a miss on every turn after the flip. The two rows in the delivery test asserting different briefs for the two verdicts were the exposure written down: they are what one session sees in sequence. The brief's channel-backed chat copy now names the platform and says the answer is stated per turn. It holds on either verdict and contradicts neither. The verdict has a home already — the per-turn chat prompt, whose branch was correct and is untouched — and that message is emitted on every turn, so nothing is lost by the brief declining to answer. Slack and Lark take the same copy. Scoping it to WeCom would put a channel-type decision back into the brief, which is what ChannelCarriesFiles exists to prevent, and would hardcode a denial a server-side change can falsify. Coverage moves rather than goes. TestBriefByteIdenticalAcrossRunsForEveryKind gains a WeCom kind and a variant that flips the field; both fail without this change, and the existing Slack kind fails on the variant too. The delivery test's two WeCom rows assert the inverse property now — one brief for both verdicts — and the upload/describe split they used to hold is pinned by TestBuildChatPromptTwoLayerChannelPolicy on the per-turn prompt, which has covered both branches all along. * docs(wecom): correct what the admitted cap implies, and note that delivery cannot answer back The comment on the two counters said reaching the admitted cap means the pending cap is full and as many turns again are still in their lookup. It does not. Admission is held for a goroutine's whole life, including turns whose lookup finds no file and which therefore never claim a pending slot — TestDeliverAttachments_AdmissionBoundsTheLookupStage fills admission with pending at zero. The two-counter split and the ordering it buys are unchanged: a backlog of file-carrying turns still meets the honest post-lookup shed first. The package doc gains the fact that an agent cannot learn its delivery was refused. `multica attachment upload` returns once the object is in storage and bound to the reply, while the send into the room runs on EventChatDone, after the run has ended, so everything the adapter knows about a failed delivery goes to the person in the chat and to nobody else. |
||
|
|
e0d0b38153 |
fix(daemon): keep the runtime owner's Reasonix deny rules in a task (MUL-6021) (#6764)
* fix(daemon): keep the runtime owner's Reasonix deny rules in a task The per-task reasonix.toml denies the `ask` tool, but Reasonix resolves the project file over the user config, so `deny = ["ask"]` replaced the runtime owner's own deny list instead of extending it: an owner who had denied `bash` globally got it back inside every Multica task. The task config is now rendered from the owner's own [permissions] table with `ask` merged into deny. The whole table is restated, not just deny, so the policy survives whether Reasonix layers these files per key or per table, and the table is read from the same REASONIX_HOME the child gets (the agent's custom_env override, else the daemon's env, else the default). When that config cannot be read or restated faithfully — unreadable, malformed, or a deny list that is not an array of strings — the daemon writes no project config at all rather than one that would drop the owner's rules. The task then runs with `ask` available, exactly as it already does when a repository ships its own reasonix.toml. Co-authored-by: multica-agent <[email protected]> * fix(daemon): resolve the Reasonix user config the way Reasonix does Restating the owner's permissions only helps if the daemon reads the file the Reasonix child actually loads. The first cut assumed $REASONIX_HOME/config.toml with a ~/.reasonix default, which misses: - Windows, where the home is %AppData%\reasonix (falling back to %USERPROFILE%\AppData\Roaming\reasonix), not %USERPROFILE%\.reasonix; - the legacy OS-support and XDG locations Reasonix reads when the primary config does not exist and REASONIX_HOME is unset; - an agent that sets REASONIX_HOME to empty, which sends the child to the platform default while the daemon kept reading its own. Each miss reads "no owner permissions" and writes a task config holding only deny = ["ask"] — the exact widening this change set exists to stop. reasonix_user_config.go now ports Reasonix's own resolution (upstream internal/config/paths.go): REASONIX_HOME with ${VAR} / ${VAR:-default} expansion, ~ handling and absolute normalization, the per-platform home defaults, and userConfigLoadPath's legacy fallbacks with the isolation rule that switches them off once REASONIX_HOME is set. The lookup runs against the sanitized custom_env layered over the daemon's environment — the same env the child is launched with — so presence, not just value, decides where to read. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
8e96329196 |
refactor(daemon): drop the MULTICA_HERMES_TASK_MEMORY rollback switch (#6760)
The switch let an operator restore the pre-MUL-5932 behaviour of a fresh task-local memories/ per task. Hermes agent memory migrated correctly, so it no longer earns its keep — and it never covered the failure it looked like it covered: the overlay already depends on directory links to mirror the user's ~/.hermes, so a host that cannot create them fails at the mirror step regardless of this env. Flipping it was also lossy in both directions: with the switch on the daemon stopped marking the store in-use and refreshing its mtime, so the GC could reclaim the agent's memory after GCHermesMemoryTTL; flipping it back discarded whatever the task-local dir had accumulated, because a populated store is never migrated into. The task-local path itself stays — it is still what a task with no agent to key on, or an unresolvable Multica profile dir, gets. Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
807b8706bb |
MUL-5992 fix(daemon): deny the Reasonix ask tool per task (supersedes #6745) (#6746)
* Revert "fix(agent): keep Reasonix off the ask tool in unattended tasks (#6745)"
This reverts commit
|
||
|
|
a1da8e536f |
MUL-6000 fix(daemon): link user Codex skills into codex-home instead of copying them (#6742)
* fix(daemon): link user Codex skills into codex-home instead of copying them Every Codex task got a full recursive copy of ~/.codex/skills/ inside its per-task CODEX_HOME. For a user with npm-backed skills that is ~100 MB per task directory, re-copied on every task start — hydrateCodexSkills wipes and re-seeds on both the Prepare and the Reuse path — and never reclaimed: the GC artifact sweep only matches node_modules / .next / .turbo, and whole-directory reclaim needs the issue to reach a terminal status first. Total cost scales with the number of live task directories. Link instead. The same per-task home already links its other two shared resources, sessions and the plugin cache, and a skill directory is read-only input to the CLI, so the write isolation a copy incidentally bought has no requester. EvalSymlinks still runs first, so an installer-shipped symlink (lark-cli into ~/.agents/skills/) resolves to the real directory rather than producing a link to a link. The three paths that could reach through the new link are unchanged in behavior: os.RemoveAll deletes a link and not its target, writeSkillFiles probes with os.Lstat so a workspace skill colliding with a linked user skill lands in a `-multica` sibling, and the GC refuses to walk into links. Tests pin all three, plus a windows-tagged test for the junction form of the link. Co-authored-by: multica-agent <[email protected]> * fix(daemon): stop GC walks from descending into Windows directory junctions The GC's artifact sweep and its size accounting both refuse to follow links out of a task directory, but they tested for os.ModeSymlink alone. That misses the link shape Windows actually produces: createDirLink falls back to a directory junction (mklink /J) whenever os.Symlink is denied, which is the default without Developer Mode, and since Go 1.23 os.Lstat reports a junction as ModeDir|ModeIrregular with no ModeSymlink bit while its DirEntry still answers IsDir() == true. filepath.WalkDir therefore descends straight into the link target, and the sweep would delete a node_modules / .next / .turbo directory that lives inside the user's real home. Match both mode bits in all three walks. The task directory links user content in three places today — the Codex session store, the plugin cache, and now the user's skills — so this is reachable on any Windows host running Codex tasks. Windows-tagged tests create the junction with mklink /J directly, so the junction shape is covered even on a runner where symlinks are permitted, and they run in the existing windows-execenv CI job. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
673104d26e |
docs(daemon): correct the Hermes overlay's skill-less claim (#6703)
The Hermes memory docs said an agent with no skills bound gets no overlay and keeps using the host's ~/.hermes. The daemon does gate the overlay on the task carrying skills, but the server appends the platform's built-in skills to every agent (LoadAgentSkillBundles), so a claimed task's skill set is never empty and that branch is unreachable in production. Two consequences the old wording got wrong: every Hermes agent gets the overlay, and therefore the persistent memory store from #6693, not just ones with user-bound skills; and clearing an agent's skill list is not a way to keep using the host's memories. Reported on #6638, where the same sentence was read as a contradiction. Docs only, plus a note at the gate so the next reader does not take the skill-less branch for observable behaviour. |
||
|
|
f26b97e395 |
MUL-5932: fix(daemon): give Hermes agents memory that survives the task (#6693)
Hermes discovers skills only from its own home, so Multica takes over HERMES_HOME to inject an agent's bound skills. The overlay inherited the decision of where memory lives and made it task-local, so a skill-bound agent forgot everything between tasks while a skill-less one shared the host's memories with every other agent on the runtime (#6638). Link the overlay's memories/ to a store Multica owns, keyed on (hermes profile, agent), mounted by directory link with its own GC TTL and the shared active-store guard. Memory now survives across tasks and issues for one agent, never touches the user's home, and is invisible to other agents. Session history (state.db) stays task-local, and skill-less agents are unchanged. MULTICA_HERMES_TASK_MEMORY=1 reverts; MULTICA_GC_HERMES_MEMORY_TTL bounds retention (90d default). Migration of a pre-store memories dir copies into a staging sibling and publishes with a single atomic rename, fails closed on any I/O error or entry it will not carry, and never infers a lost race from a failure — the source directory is only removed once its contents are confirmed in the store. |
||
|
|
3dd128532c |
feat(agent): add oh-my-pi (omp) as a supported agent runtime (#6514)
* feat(agent): add oh-my-pi (omp) as a supported agent runtime oh-my-pi (omp, https://omp.sh) is a separate CLI that is a drop-in fork of pi and speaks the same JSON event protocol. It is registered as an independent provider so a host with both pi and omp installed gets two runtimes instead of one. omp is a runtime identity on the pi protocol, NOT a new protocol family. It is declared in a single BuiltinRuntimes descriptor (server/pkg/agent/builtin_runtimes.go) that carries id, protocol_family, default_command, env_prefix, display_name, skills_dir, user_skills_dir, launch_header, backend overrides, and model_discovery strategy. Every consumer (agents_probe.go, config.go, daemon.go, execenv/context.go, local_skills.go, agent.go New/LaunchHeader, models.go ListModels) derives from this descriptor — no per-file hardcoded omp special cases. NewRuntime() is the typed entry point for runtime identities: it resolves id → descriptor → protocol family backend → applies overrides through a backendOverrideApplicator interface (piBackend implements it). New() delegates to NewRuntime() for built-in runtime identities, keeping New() meaning exactly one thing: the protocol-family factory. Model discovery: parseOmpModels reads the real {"models":[...]} wrapper with separate provider/id/selector/name fields. Model.ID is the selector (provider/id), matching parsePiModels' convention so buildPiArgs emits both --provider and --model — not just --model. A regression test (TestOmpSelectorSurvivesToBuildPiArgs) pins this end-to-end. Frontend: provider-logo.tsx maps omp to PiLogo. The transcript dialog uses the shared providerDisplayName helper. Landing copy updated to 21 tools in all 4 locales. Tests (omp_test.go): descriptor dispatch, NewRuntime entry point, binary resolution, omp-labeled errors, event-stream completion, pi+omp side-by-side registration, selector→buildPiArgs regression, model parser coverage (real JSON/empty wrapper/invalid stderr/duplicate), and a guard that every descriptor field is non-empty and consumed. Closes #3989 * fix(views): keep Claude Code label in the transcript run details Switching the provider row to the shared runtime formatter renamed every Claude run: the daemon has no display-name override for claude, so the shared formatter answers "Claude", and the legacy claude-code value title-cased into "Claude-code". Both read as "Claude Code" before. Keep those two aliases in a table local to this view and defer everything else to the shared formatter, so the row stays in lockstep with the runtime list (#5260) without renaming Claude runs. * test(daemon): assert pi and omp both reach the register payload The existing omp test stopped at probeAgentCLIs, so nothing covered the part a user actually sees: whether both runtimes are registered with the server, each under its own display name. Discovery finding two entries and New() building two backends both stop short of version detection and the registration payload. Add a daemon-level test that runs real discovery off a fake PATH, drives syncWorkspacesFromAPI, and asserts the payload carries type=pi and type=omp with names "Pi" and "Oh-My-Pi". Rename the discovery test to say what it covers, and record the registered display name in the batch fixture so the name can be asserted. * docs(agent): correct descriptor comments that describe the old behaviour Three doc comments still described behaviour the fail-closed rework replaced: - ModelDiscovery claimed a nil strategy falls back to the family's discovery; ListModels deliberately returns an empty catalog instead, because omp exits non-zero on pi's --list-models. - backendOverrideApplicator claimed backends without it are returned unchanged; NewRuntime returns an error rather than dropping the descriptor's executable and label. - ProtocolFamily credited New() with applying the ID-specific defaults; that moved to NewRuntime. Also repair the truncated first sentence of the NewRuntime doc, and fix discoverOmpModels still calling the output a JSON array when parseOmpModels right below it documents the {"models":[...]} wrapper. * style(server): gofmt the files this branch touched Four files were left unformatted: the descriptor literal and the omp test struct lost their key alignment, execenv/context.go had its new import out of order, and local_skills.go kept the old indentation after its switch moved inside an if/else. Whitespace only — no behaviour change. --------- Co-authored-by: Bohan-J <[email protected]> |
||
|
|
586a624aa5 | fix(cli): isolate task profile state (#6407) | ||
|
|
7c7ed8bf55 |
MUL-5937 fix(daemon): persist direct chat Codex sessions (#6644)
Co-authored-by: 전영진 <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
93fa120c4c |
MUL-5925 fix(execenv): expand Windows ~\ in the openclaw active config path (#6630) (#6643)
`openclaw config file` shortens the home prefix with its host OS's separator, so Windows reports `~\.openclaw\openclaw.json`. The tilde expansion matched only the `~/` form, so the Windows path fell through unexpanded; not being absolute, it was then joined onto the daemon's working directory into a path that can never exist. The resulting stat miss is indistinguishable from a fresh install, so the per-task wrapper was written without the `$include` of the user's config and without an include root. Every task then booted OpenClaw with no model providers and no auth profiles, failing with `Unknown model: openai/gpt-5.5` or `No credentials found for profile ...`. Accept both separators regardless of GOOS so the Windows shape stays testable from the Linux/macOS test job, and log the discovery outcome: discovery was previously silent, which is why this could only be diagnosed by reading the generated file. A missing active config now warns that the task will not see the user's models or auth profiles. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
545d890dae |
feat(cli): add --compact to issue comment list, adopt it in every agent read command (MUL-5442) (#6546)
* feat(cli): add --compact to issue comment list, adopt it in every agent read command (MUL-5442) The #5999 follow-up its closing note asked for, now that the workspace measurements exist: the two bounded reads the agent workflow prescribes are dominated by reader-noise metadata, and every byte of it enters the conversation history at full price once and is replayed at cache-read price by every later call of the session. --compact (JSON output only, opt-in) drops per comment: the issue_id echoed from the request path, source_task_id, updated_at when identical to created_at, null-valued fields, and empty arrays. Content, identity, and thread-summary fields pass through untouched; a real edit timestamp or non-empty array is never dropped. Measured on MUL-5442 (production data): --roots-only --summary scan 6,117B -> 4,738B (-23%); --thread --tail 30 deep read 47,359B -> 39,349B (-17%). Adoption: all nine runnable comment-list command templates in the per-turn channel (prompt.go, reply_instructions.go) now carry --compact, and the brief's Available Commands line and workflow step 3 document it (+~40B one-time, byte-stable). Command pins updated accordingly; new unit test pins the drop/keep contract and the help-contract test pins the flag's rendered usage. Known local-env failure: TestResolveWorkspaceID_AgentContextSkipsConfig fails identically on clean origin/main in this agent runtime (agent context env interference) — unrelated. Co-authored-by: multica-agent <[email protected]> * test(cli): wire-level --compact coverage and zero-value keep pins (MUL-5442) Review nits on #6546: TestRunIssueCommentListCompactWiring drives runIssueCommentList against an httptest server both ways — default JSON keeps every field, --compact drops the reader-noise set while zero-value scalars (reply_count: 0, content_truncated: false, folded_count: 0) survive, pinning that null pruning never generalizes to zero pruning. Same zero-value pins added to the pure-function test. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
2f5efa34ca |
test(daemon): add fan-out guard anchors for --content-file and inline --content (MUL-5825) (#6544)
Elon's #6517 review nit: the block-level banned list omitted the old cookbook's two central mechanism anchors, so a prose-only restatement of the flag mechanics ("use --content-file / don't use inline --content") could regrow in the fan-out block under green tests. Add both anchors, and replace the phrasing-fragile "Do NOT write literal" anchor with the semantic backtick-\n-escape anchor that survives rewording. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
73ab498d1d |
MUL-5811: fix(squad) derive leader role from wire fields, not instructions text (#6519)
* fix(squad): derive leader role from wire fields, not instructions text (MUL-5811) The daemon decided whether a task ran in the squad-leader role by grepping the agent's Instructions for the "## Squad Operating Protocol" heading that the server-injected briefing starts with. Role detection therefore depended on user-writable Markdown: any ordinary agent whose own instructions happened to contain that heading was promoted to leader and handed the leader rules — mandatory `multica squad activity` calls, a licence to exit silently on no_action, and the squad-maintenance CLI section in its brief. The correct signals were already on the wire the whole time. The claim handler decides injection from explicit fields (`task.is_leader_task` + `squad_id` for issue-bound runs, the quick-create `squad_id` for the picker path) and forwards both to the daemon. Read those instead: - `taskIsSquadLeader` returns `IsLeaderTask || SquadID != ""`; brief assembly now shares that helper instead of repeating the substring check. - The claim handler clears `is_leader_task` on the response whenever its defensive gate withholds the briefing (NULL squad_id, squad hard-deleted, leader swapped after enqueue). The flag now means "briefing injected", so a leader task with no roster and no protocol degrades to an ordinary agent turn instead of booting into a leader role it cannot perform. Every legal path is byte-identical: the server sets these fields on exactly the claims it injects into, so real leader runs and ordinary runs produce the same brief and prompt as before. The only behavior change is the misdetection case, which is the bug. Also corrects two comments claiming IsSquadLeader is agent configuration rather than a per-task role, and notes the flag contract in the squad source map. Co-authored-by: multica-agent <[email protected]> * fix(squad): gate leader-role field reads behind a server capability (MUL-5811) Review catch (#6519): reading is_leader_task / squad_id unconditionally breaks the new-daemon → old-server direction the repo deliberately supports (claimTasksLegacy, MUL-4257). is_leader_task only reached the claim response in #4951, while briefing injection predates it — so against an older server a real issue-bound leader task arrives with the full briefing and both fields at zero, and field-only detection would silently demote that leader to a plain worker, dropping the entire operating protocol. Add `leader_role_resolved`, a claim-only wire capability the server sets on every claim response (leader or not). Its absence is what identifies a server too old to have answered the role question: - capability present → the explicit fields decide, and an ordinary agent whose instructions contain the briefing heading is no longer promoted to leader; - capability absent → keep the legacy briefing-marker inference, which is exactly today's behavior against those servers. That branch goes away once a minimum server version is enforced. The field is claim-only and never rendered into a prompt, so it costs no prompt tokens and changes no brief or prompt bytes on any path. Tests: the role table now covers both server generations, including legacy real-leader (heading, no capability), current ordinary-agent-with-heading, and current withheld-briefing; an end-to-end legacy prompt test pins that an old server's leader keeps its rules; and every claim test now asserts the server actually advertises the capability — losing it would send all daemons back to text inference. Also rewords two comments per review: the brief byte-stability tradeoff is owner-accepted and recorded in MUL-5811, not an open tracking item. Co-authored-by: multica-agent <[email protected]> * docs(squad): state the pre-capability server shapes precisely (MUL-5811) Review nit: the comments said every server without leader_role_resolved "never sent is_leader_task", which is only true before #4951. Servers between #4951 and this change do send the flag — they just never promised it implies an injected briefing, which is the actual reason the fallback covers them too. Names both shapes at each site (daemon types, handler response, helper doc, role-table doc, squad source map) so a future cleanup of the fallback reads the version window correctly. Comments only; no behavior change. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
6ca6b27ce3 |
refactor(daemon): converge multi-thread fan-out cookbook into Comment Formatting pointer (MUL-5825) (#6517)
The cross-thread reply fan-out block embedded a full file-operations cookbook (body file -> --content-file -> cleanup, the inline/stdin bans, the \n-escape rule, plus two example command pairs) that triple-wrote the mechanism already carried by the brief's ## Comment Formatting and the single-thread cookbook. The block now keeps only what is multi-thread-specific — fan-out announcement + override, posting order, per-thread --parent targets, the DISTINCT-body-file-per-thread delta, and the squad leader's whole-block no_action scope sentence — and points at ## Comment Formatting for the mechanism. Dropping the embedded commands removes the only OS-dependent text, so the windows/unix cookbook variants collapse and the block no longer branches on runtimeGOOS; the OS split lives solely in the brief. Measured (2-thread, 36-char-UUID fixture): ordinary 1,657B (linux) / 1,675B (windows) -> 919B both; leader 1,870B / 1,888B -> 1,132B both. ~-740-760B per coalesced multi-thread turn. Pins updated per discipline: ordinary unconditional form and the leader scope-ordering assertions are retained (obligation strings track the new text, ledger comments record the mapping); retired cookbook strings get negative guards in the block test and the assembled-prompt test; a new non-parallel test pins OS-invariance. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
d6d1996210 |
refactor(daemon): retire the generic no-reply rule — ordinary agents always post one comment (MUL-5442) (#6493)
* refactor(daemon): retire the generic no-reply rule — ordinary agents always post one comment (MUL-5442) Owner decision (MUL-5442, 2026-08-06): the no-reply / silent-exit behavior is squad-leader-only by design. Mechanically it always was — computeCommentAgentTriggers routes agent-authored comments only via explicit @mentions (the sole implicit wake is the squad-leader path), so loop prevention lives in the Mentions discipline, and the generic rule only suppressed one terminal noise comment on an already-enqueued run while giving every ordinary agent a legal silent-exit path that risks silently dropped results. - prompt.go: delete the per-turn agent-trigger warning block entirely (−728B per agent-triggered turn; supersedes #6484's compression). - runtime_config_sections.go: delete the Reply-mode reply-warranted bullet and step 5's conditional pointer; the reply bullets become unconditional (−472B one-time in the brief). Squad-leader no_action bullets and the Mentions section are untouched. - reply_instructions.go: 'If you decide to reply, post it' → 'Post your reply' (−16B per Reply turn); the leader's no_action block states its own exception. - Tests: retired pins are converted to negative guards with ledger comments (brief and per-turn both must NOT carry the retired apparatus); attribution, member-leak, and no-MUST-respond guards stay. Co-authored-by: multica-agent <[email protected]> * fix(daemon): scope the reply imperative with the leader's no_action carve-out, close the pin-ledger gaps (MUL-5442) Review catches by Elon on #6493, both accepted: 1. The leader's only silent path was contradicted later in the same prompt: the no_action rule said EXIT without commenting, then the cookbook and the brief's mandatory bullet said post unconditionally. Both now branch on squad leadership — agent configuration, never per-run state, so each agent's brief stays byte-identical across turns and issues (cache-safe): leaders get 'Unless your outcome is no_action, post your reply…' in the cookbook and 'Unless your outcome is no_action (Squad leader rule above), posting your reply as a comment is mandatory' in the brief; ordinary agents keep the unconditional wording byte-for-byte. New tests render the COMPLETE leader prompt and leader brief and assert the carve-out is present AND the unconditional capital-P imperative is absent — the scope relation, not two isolated Contains. 2. The retirement ledger named five brief pins but guarded only two, and the per-turn 'If you decide to reply' had no guard. Added the four missing negative guards in ordinary-agent scope (the leader's 'DO NOT post any comment' matches none of them) plus a leader-leak guard on the ordinary brief. Co-authored-by: multica-agent <[email protected]> * fix(daemon): scope the multi-thread fan-out under the leader's no_action carve-out (MUL-5442) Ports the per-turn half of #6493 review rounds 2-3 into the slimmed PR (the brief role-independence refactor moves to its own sub-issue/PR per owner decision): BuildMultiThreadCommentReplyInstructions takes the leader flag, and the leader lead sentence scopes the ENTIRE fan-out block — every later obligation sits under the 'Otherwise' — with index-order test guards. Ordinary fan-out output is byte-unchanged. Also repairs a truncated TestFreshSessionMayHelp from the earlier rebase conflict resolution (missing closing braces). Co-authored-by: multica-agent <[email protected]> * docs: correct the taskIsSquadLeader comment — leadership is a per-task role (MUL-5442) Review nit on #6493: the helper's comment still claimed leadership is 'agent configuration, never per-run state' — the disproven premise that drove the earlier review rounds. State the per-task-role fact and point at MUL-5811 for the role-independence work. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
62a2d70afd |
refactor(daemon): stage-2 — Mentions and Comment Formatting judgment rewrite (MUL-5442) (#6453)
* refactor(daemon): rewrite Mentions and Comment Formatting to their judgment form (MUL-5442) Stage 2, final two sections, bundled per the small-sections rule. Mentions: the four-link side-effect table stays verbatim (platform facts); the two H3 subsections merge into one policy paragraph keeping every anti-loop anchor — the no-mention default with its cost mechanism, the no-sign-off-mention ban, the end-with-no-mention rule, the three mention-warranted cases, and the silence closer. The retired headings' pins re-anchor to the policy phrases. Comment Formatting: both variants keep the full operational contract (file-first sentence verbatim, both bans with incident ids, workdir scope, --parent continuity, cleanup, newline rule); what goes is the mechanism narration (what the shell rewrites, how flags get swallowed, PowerShell version/encoding detail — the consequence stays). Co-authored-by: multica-agent <[email protected]> * fix(daemon): correct the PowerShell version claim, pin the mention scope qualifiers, section-scope the formatting assertions (MUL-5442) Review catches by Elon on #6453, all three accepted: 1. The compressed Windows rationale over-generalized a version-specific fact: '$OutputEncoding drops non-ASCII' is true of Windows PowerShell 5.1 (ASCII default), false of PowerShell 6+ (utf8NoBOM). Now reads 'Windows PowerShell 5.1 ... may replace non-ASCII characters with ?'; the Go comment documents the version split and why file-first stays version-agnostic (agents cannot rely on which shell services the pipe). 2. The merged Mentions paragraph was pinned only at the list head — the scope qualifiers ARE the anti-repeat-notify boundary: 'not yet involved', 'for the first time', 'explicitly asks to loop someone in', and the loop-cost mechanism are each pinned individually now. 3. The Comment Formatting assertions ran against the whole file, where '#4182' also appears in Available Commands — the HEREDOC ban could vanish with green tests. The assertions now slice the section (matched at the line-start heading, since Available Commands references the heading inline) and cover all seven contract elements within it. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
f44df04f96 |
refactor(daemon): rewrite the Output section to its judgment form (MUL-5442) (#6425)
Stage 2 section 2. The delivery contract keeps every platform fact and boundary: comment-add as the only delivery channel, the invisible terminal, exactly one comment per run before turn exit, --attachment as the only file path, the runtime-local-path ban with the code-location form and the say-so-in-words fallback. What goes is derivation: the invisible-task consequence restatement, the plans-in-your-reasoning elaboration, the good/bad style examples, the exists-right-now clause, and two quick-create explanation tails. One pin re-anchor: 'Do not assume any workspace issue prefix' follows the rewrite to 'never assume a workspace issue prefix'. Every other Output and delivery-invariant pin passes unchanged, including the MUL-4899 trio and the anti-dangling pointer target the Attachments section names. Issue-kind Output: 1,493 -> 978. Brief (real-UUID fixture): 13,382 -> 12,907 (-475); quick-create variant sheds ~160 more on its own kind. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
c896f677d5 |
fix(agent): recover from a codex resume that overflows the reader (MUL-5722) (#6420)
Layer 1 (#6383) raised the shared scanner cap to 32 MiB, which moved the cliff without removing it — a codex rollout is append-only, so a thread that outgrows any fixed cap still fails its resume forever. This adds the recovery path. Layer 2: an oversized thread/resume response is reported as Result.ResumeRejected rather than a crash, which is the positive evidence shouldRetryWithFreshSession needs and reconnects the recovery #5715 had unintentionally cut off. Reaching it required one real fix — the reader wrapped the scanner error with %v, so bufio.ErrTooLong never survived to a caller. Layer 3: a new codex_resume_oversized reason marks the session resume-unsafe, and both resume lookups block by TIME rather than by matching the failed row. That shape is required, not stylistic: the failure happens before the turn starts, so the row lands with session_id NULL and is dropped by latest_per_session before any error-text filter runs. The block expires once a thread terminates after the overflow, so an issue recovers instead of starting cold forever. Also splits the MUL-4424 continuity notice by what each surface can still read — issue comments and Slack channel history can be re-read, web chat and Feishu cannot — so only the last group tells the user a loss happened. The backend no longer holds any of that wording; it receives ExecOptions.ResumeContinuityNotice from the caller and stays silent when the prompt already carries it, which makes the duplicate injection on the retry path structurally impossible. Known gap, tracked not claimed: for chat, the claim handler reads chat_session.session_id before the fallback query, so a daemon predating this PR leaves that pointer naming the oversized thread. Clearing it needs the attempted session recorded at claim time, which is a schema change. |
||
|
|
86f255658f |
MUL-5730 feat(server): expose chat audience without bloating prompts (#6428)
Tell a chat agent whether it is answering in a shared room or a 1:1, without splitting the cached brief prefix: the room shape rides the existing channel binding row to the claim response, and is stated once per turn in the chat prompt rather than in the static runtime brief. Originally contributed as #6390; re-rolled to move the audience fact to the per-turn prompt and to minimize the copy it renders. Co-authored-by: Seacen Zhao <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
2bb13667c6 |
refactor(daemon): cross-channel command dedup — brief states the loop, per-turn carries the commands (MUL-5442) (#6421)
* refactor(daemon): move the workflow's command templates to the per-turn channel (MUL-5442) Cross-channel dedup, brief side. MUL-5721 measured that every issue-turn variant of the per-turn message already carries the ready-to-run commands with real ids (issue-get line, reading hints, reply cookbook), fresher than the brief's static copies. The six workflow steps now state the loop shape — command names and flag mnemonics only; full templates and every embedded issue UUID leave the brief. The Ownership status commands and the squad-activity call switch to <issue-id> placeholder form. Doctrine pins stay on the brief side (mandatory catch-up, scan-first order, both motivation anchors); command-template pins re-anchor to names and mnemonics. Two design-consequence test updates: the static-catch-up assertion repoints at the doctrine (the full command now lives in every per-turn variant), and the byte-identity non-vacuity guard varies agent identity instead of issue id — because the brief is now deliberately issue-id-independent, which opens the cross-issue prefix-cache door. Co-authored-by: multica-agent <[email protected]> * refactor(daemon): point the reply cookbook at Comment Formatting instead of restating it (MUL-5442) Cross-channel dedup, per-turn side. The cookbook's prose restated the brief's Comment Formatting rules (file-first rationale, inline-content and HEREDOC hazards, the full Windows $OutputEncoding mechanics) around the command that already demonstrates the shape. It now keeps the file-first order, the ready-to-run command, the literal-newline rule, and the pointer; the hazard mechanics live once, in the brief section the pointer names. Default cookbook: 639 -> 494 bytes (-145 per reply turn, uncacheable channel); the Windows variant sheds ~200 more. Pins re-anchor from the deleted prose to the surviving anchors (file-first order, the pointer, the command form); the MUL-2904/#4182 banned-shape negative guards are untouched. Co-authored-by: multica-agent <[email protected]> * fix(daemon): narrow the per-turn coverage claim, test the cross-issue invariant, strip the command shape from the guest-visible prohibition (MUL-5442) Review catches by Elon on #6421 plus the CI failure, all three related: 1. The steps header overstated what the per-turn message carries — it ships the issue id and ready-to-run context-read commands; other calls are assembled from Available Commands. Reworded to say exactly that (a factual claim in a prompt cannot rely on the reader discovering it is wrong). 2. The cross-issue byte-equality this PR claims as a design benefit is now asserted directly per provider (same stable inputs, different 36-char issue UUIDs, byte-identical brief) — a Contains-based negative cannot catch truncated/transformed/conditional id use. 3. CI: the squad guest-leader contract test bans any runnable 'issue status <issue-id> in_review' shape in guest-visible text; the placeholder rewrite made the leader dispatch rule's NEGATIVE sentence match that ban. The prohibition now states itself without a command form ('do NOT move it to in_review or done on this turn') — negative sentences should not carry copy-pasteable command shapes at all. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |