Files
multica/server/internal/daemon/execenv/sidecar_manifest.go
Bohan Jiang 76c58a4ee8 MUL-3617: remove Gemini CLI runtime (#4503)
* fix: remove gemini cli runtime

Co-authored-by: multica-agent <github@multica.ai>

* fix: skip unsupported custom runtime profiles

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 15:15:42 +08:00

422 lines
18 KiB
Go

package execenv
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
// sidecarManifestFile is the on-disk JSON Prepare writes into envRoot to
// record every file and intermediate directory it created inside WorkDir.
// CleanupSidecars reads it back to roll the workdir to its pre-Prepare
// state. The file lives in envRoot (daemon scratch), never in WorkDir,
// so a local_directory run does not litter the user's repo with the
// bookkeeping file used to undo the litter.
const sidecarManifestFile = ".multica_sidecar_manifest.json"
// errPathPreExists is the sentinel recordWriteFile returns when the
// target path already exists. The manifest contract is that we never
// mutate paths we don't own: a pre-existing file belongs to the user
// (or to stale state from a crashed prior run we cannot safely
// distinguish from intentional user content) and the write must be
// refused so cleanup can be a pure deletion of paths we created.
//
// Callers handle this in one of two ways:
//
// - For per-skill directories the caller allocates a collision-free
// alternative slug (see allocateCollisionFreeSkillDir) and retries
// so the agent still discovers the Multica skill, just under a
// different directory name.
// - For Multica-only namespaces (.agent_context/issue_context.md,
// .multica/project/resources.json) the caller swallows the error
// and proceeds — the agent's runtime brief already carries every
// fact that would have appeared in those files, so missing-from-
// disk is degraded behavior, not failure.
var errPathPreExists = errors.New("execenv: refuse to overwrite pre-existing path")
// sidecarManifest records the filesystem mutations writeContextFiles and
// its callees make inside the agent's WorkDir for a single task. The
// manifest is the second half of the contract that makes local_directory
// runs byte-exactly reversible:
//
// - Files lists absolute paths of regular files we created. Files are
// recorded only after recordWriteFile has verified the target did
// NOT pre-exist; recordWriteFile refuses to overwrite a pre-existing
// path, so the manifest's existence rule and the write side's
// refuse-to-clobber rule are the same invariant viewed from two
// sides.
// - Dirs lists absolute paths of directories we created, in root-first
// creation order. Cleanup walks the list in reverse so deepest dirs
// get tried first; rmdir of a directory the user has populated since
// (e.g. .claude/skills/my-own-skill alongside our .claude/skills/
// issue-review) fails ENOTEMPTY and is skipped silently — the
// user's content is preserved without any per-dir bookkeeping. A
// directory is recorded only when it did NOT pre-exist for the same
// reason files are conditional.
//
// The manifest is intentionally minimal: it carries the paths needed to
// reverse our writes and nothing else. It is not a log of every operation
// and is not a substitute for the runtime config marker block, which has
// its own dedicated round-trip mechanism in runtime_config.go (the brief
// is appended to user-owned content rather than written into a new sidecar
// directory).
type sidecarManifest struct {
Files []string `json:"files,omitempty"`
Dirs []string `json:"dirs,omitempty"`
}
// recordMkdirAll behaves like os.MkdirAll(path, perm) but additionally
// records every parent directory it had to create (skipping any that
// already existed) into m so CleanupSidecars can rmdir them later. The
// recorded paths are appended in root-first order; Cleanup iterates in
// reverse so the deepest directory is removed first.
//
// When m is nil this is identical to os.MkdirAll — the Reuse path uses
// the nil mode because Reuse runs on cloud workdirs that the GC loop
// wipes wholesale, so per-file cleanup is irrelevant and tracking the
// dirs would just leave stale manifest bytes around.
func recordMkdirAll(path string, perm os.FileMode, m *sidecarManifest) error {
if path == "" {
return os.MkdirAll(path, perm)
}
if m == nil {
return os.MkdirAll(path, perm)
}
// Walk leaf-first, collecting ancestors that don't currently exist.
// We stop at the first existing ancestor (or the filesystem root) so
// pre-existing user directories are never recorded — Cleanup must
// not rmdir a path the user owned before this task started.
var toCreate []string
cur := filepath.Clean(path)
for {
if _, err := os.Lstat(cur); err == nil {
break
} else if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("stat ancestor %s: %w", cur, err)
}
toCreate = append(toCreate, cur)
parent := filepath.Dir(cur)
if parent == cur || parent == "." {
break
}
cur = parent
}
if err := os.MkdirAll(path, perm); err != nil {
return err
}
// Reverse leaf-first → root-first so Cleanup can reverse-iterate
// to peel directories from the leaves upward.
for i, j := 0, len(toCreate)-1; i < j; i, j = i+1, j-1 {
toCreate[i], toCreate[j] = toCreate[j], toCreate[i]
}
m.Dirs = append(m.Dirs, toCreate...)
return nil
}
// recordWriteFile writes data to path with perm and records the path in
// m for later cleanup, but ONLY when path does not already exist. When
// path is occupied — by a regular file, a symlink, a directory, or any
// other filesystem entry — the function returns errPathPreExists
// without touching the path. The user's bytes (or pre-existing entry
// type) are preserved exactly.
//
// This is the invariant the manifest design rests on: cleanup is a
// pure deletion of paths we created, never a restore. Overwriting a
// pre-existing path and then refusing to delete it on cleanup (the
// pre-fix behavior) destroys user data twice — once at write time and
// once by leaving the corrupted bytes in place at exit. Refusing to
// overwrite removes both halves of that failure mode.
//
// When m is nil this collapses to a plain os.WriteFile — the Reuse
// path uses the nil mode because Reuse runs on cloud workdirs that
// the GC loop wipes wholesale, so per-file collision avoidance is
// irrelevant.
func recordWriteFile(path string, data []byte, perm os.FileMode, m *sidecarManifest) error {
if m == nil {
return os.WriteFile(path, data, perm)
}
_, statErr := os.Lstat(path)
if statErr == nil {
// Any existing entry — regular file, symlink, directory —
// is a collision. Refuse to touch it.
return fmt.Errorf("%w: %s", errPathPreExists, path)
}
if !errors.Is(statErr, fs.ErrNotExist) {
return fmt.Errorf("stat target %s: %w", path, statErr)
}
if err := os.WriteFile(path, data, perm); err != nil {
return err
}
m.Files = append(m.Files, path)
return nil
}
// allocateCollisionFreeSkillDir picks a directory under skillsParent
// whose path does NOT currently exist, so writeSkillFiles can lay
// down a Multica skill without colliding with a user-installed skill
// of the same slug. The first attempt is always the natural baseSlug
// — that's the path provider-native discovery already knows. On
// collision we append `-multica`, then `-multica-2`, `-multica-3`,
// … until a free slot is found. The chosen slug is returned alongside
// the absolute path so callers can use it in frontmatter and brief
// listings.
//
// The collision-free fallback name is still a sibling under the same
// skillsParent, so provider-native discovery still picks the skill up
// (each subdir under .claude/skills/ etc. is scanned independently).
// The user's directory at baseSlug is left bit-for-bit intact.
//
// The probe is bounded to a small ceiling — a user with thousands of
// collisions on the same slug indicates an upstream bug, not a
// realistic state. Returning an error in that case forces the caller
// to surface the problem instead of looping forever.
func allocateCollisionFreeSkillDir(skillsParent, baseSlug string) (slug, dir string, err error) {
const maxAttempts = 64
for i := 0; i < maxAttempts; i++ {
var candidate string
switch {
case i == 0:
candidate = baseSlug
case i == 1:
candidate = baseSlug + "-multica"
default:
candidate = fmt.Sprintf("%s-multica-%d", baseSlug, i)
}
path := filepath.Join(skillsParent, candidate)
if _, statErr := os.Lstat(path); statErr != nil {
if errors.Is(statErr, fs.ErrNotExist) {
return candidate, path, nil
}
return "", "", fmt.Errorf("stat candidate %s: %w", path, statErr)
}
}
return "", "", fmt.Errorf("allocate collision-free skill dir under %s: exhausted %d attempts for base %q", skillsParent, maxAttempts, baseSlug)
}
// writeSidecarManifest persists m to {envRoot}/{sidecarManifestFile}.
// Empty manifests are still written so a later Cleanup that finds the
// file knows tracking was attempted (vs. an old build that predates this
// mechanism, where the file is absent and Cleanup must no-op). Failures
// are returned to the caller; the caller treats them as non-fatal because
// a missed manifest only degrades local_directory cleanup, not task
// execution.
func writeSidecarManifest(envRoot string, m *sidecarManifest) error {
if envRoot == "" {
return nil
}
if m == nil {
m = &sidecarManifest{}
}
data, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("marshal sidecar manifest: %w", err)
}
return os.WriteFile(filepath.Join(envRoot, sidecarManifestFile), data, 0o644)
}
// CleanupSidecars rolls the user's workdir back to its pre-Prepare
// state by removing every file the manifest at envRoot records and
// then rmdir-ing every directory it records, deepest first.
//
// Two failure modes the function deliberately swallows:
//
// - ENOENT on a recorded path. The file or directory was already
// gone — either the user removed it during the task, or a prior
// Cleanup run on the same envRoot already cleared it. Either
// way there is nothing left for this call to do.
// - Non-empty directory on rmdir. The user has populated a
// directory we created (added a sibling file under .claude/
// skills/, for example) and rmdir-ing would destroy that
// content. We detect this by re-reading the directory after
// rmdir fails: a non-empty listing means "user owns this — stop
// here." This is the must-fix from PR #3444 review — the
// previous version swallowed ANY non-ENOENT rmdir error as
// "non-empty," which silently dropped real I/O failures
// (EACCES, EPERM, EBUSY) and made cleanup look successful when
// it wasn't.
//
// All other errors — ReadFile failure, JSON parse failure, real
// EACCES/EPERM/EIO during file deletion, real EACCES/EPERM/EIO
// during dir removal — are captured into firstErr and surfaced to
// the caller. Cleanup still continues for the remaining manifest
// entries so a single bad path does not strand the rest of the
// rollback.
//
// The function is a no-op when:
// - envRoot is empty (no daemon scratch for this task),
// - the manifest file is missing (older build, or Prepare did not run).
//
// Pair this with CleanupRuntimeConfig on the local_directory cleanup
// path: that function handles the runtime brief inside CLAUDE.md /
// AGENTS.md, this one handles the sidecar tree
// (.agent_context/, .multica/, .claude/skills/, .github/skills/,
// .opencode/skills/, skills/, .pi/skills/, .cursor/skills/,
// .kimi/skills/, .kiro/skills/, .agents/skills/, fallback
// .agent_context/skills/). The two together restore the workdir to
// byte-exact pre-task state.
func CleanupSidecars(envRoot string) error {
if envRoot == "" {
return nil
}
manifestPath := filepath.Join(envRoot, sidecarManifestFile)
data, err := os.ReadFile(manifestPath)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read sidecar manifest %s: %w", manifestPath, err)
}
var m sidecarManifest
if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("parse sidecar manifest %s: %w", manifestPath, err)
}
var firstErr error
captureErr := func(err error) {
if firstErr == nil {
firstErr = err
}
}
for _, f := range m.Files {
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
captureErr(fmt.Errorf("remove %s: %w", f, err))
}
}
// Reverse iterate so the deepest directory is tried first. When
// rmdir fails we re-read the directory to tell ENOTEMPTY (user
// content present — skip silently) apart from real I/O errors
// (permission denied, busy, etc. — capture and surface).
for i := len(m.Dirs) - 1; i >= 0; i-- {
d := m.Dirs[i]
err := os.Remove(d)
if err == nil || errors.Is(err, fs.ErrNotExist) {
continue
}
hasEntries, ok := dirHasEntries(d)
switch {
case !ok:
// ReadDir also failed — we can't tell ENOTEMPTY apart
// from a real I/O error. Surface the ORIGINAL rmdir
// error (not the ReadDir failure) so the operator sees
// the actual cleanup blocker; the ReadDir branch is
// just diagnostic plumbing and would distract from the
// root cause. Silently skipping here was the v1 bug:
// it hid EACCES on locked directories behind a phantom
// "directory non-empty" assumption.
captureErr(fmt.Errorf("rmdir %s: %w", d, err))
case hasEntries:
// User has populated this dir since Prepare ran. Leave
// it in place without surfacing the rmdir error — the
// whole point of the manifest design is to preserve
// user content under directories we created.
default:
// Empty directory but rmdir still failed → real I/O
// error (EACCES, EPERM, EBUSY, EIO, or a directory we
// mistakenly recorded that we don't actually own).
// Surface it so the caller can log a warning and an
// operator can investigate.
captureErr(fmt.Errorf("rmdir %s: %w", d, err))
}
}
if err := os.Remove(manifestPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
captureErr(fmt.Errorf("remove manifest %s: %w", manifestPath, err))
}
return firstErr
}
// removeReusedManagedSkillDirs force-removes the skill directories the prior
// dispatch recorded under skillsParent in its sidecar manifest at envRoot,
// even when they are now non-empty. It is the reuse-path companion to
// CleanupSidecars and runs just before it.
//
// CleanupSidecars deliberately preserves a recorded directory once it has
// become non-empty — the agent may have dropped a file inside a dir we
// created, and on the local_directory teardown path that content must
// survive. But that same preservation reopens #3684 on the reuse path: if a
// prior-run agent wrote into .claude/skills/issue-review/, CleanupSidecars
// deletes the recorded SKILL.md yet keeps the directory, so the canonical
// slug stays occupied and the refreshed skill dodges to
// issue-review-multica. A managed skill directory is platform-owned — the
// manifest is proof we created it — so on reuse we reclaim the whole
// directory (dropping any scratch the agent left inside it, exactly as the
// Codex path's os.RemoveAll(skillsDir) already does) and let the refresh
// re-create it at its natural slug.
//
// Only directories whose immediate parent is skillsParent are removed, so
// the blast radius is exactly the platform's own skill roots: sibling skills
// the agent installed under the same parent, checked-out repos, and the rest
// of the workdir are untouched. The reuse path only ever runs on cloud
// workdirs (the daemon skips Reuse for local_directory tasks), so there is no
// user-owned skills tree to protect here in the first place.
//
// envRoot or skillsParent empty, a missing manifest, or a parse failure are
// all no-ops — the refresh simply proceeds. The manifest file is left in
// place; CleanupSidecars, which runs next, owns deleting it.
func removeReusedManagedSkillDirs(envRoot, skillsParent string) error {
if envRoot == "" || skillsParent == "" {
return nil
}
data, err := os.ReadFile(filepath.Join(envRoot, sidecarManifestFile))
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read sidecar manifest for reuse skill rollback: %w", err)
}
var m sidecarManifest
if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("parse sidecar manifest for reuse skill rollback: %w", err)
}
cleanParent := filepath.Clean(skillsParent)
var firstErr error
for _, d := range m.Dirs {
if filepath.Dir(filepath.Clean(d)) != cleanParent {
continue
}
if err := os.RemoveAll(d); err != nil && firstErr == nil {
firstErr = fmt.Errorf("remove managed skill dir %s: %w", d, err)
}
}
return firstErr
}
// dirHasEntries inspects dir and reports whether it currently contains
// any entries. The second return value distinguishes three states
// CleanupSidecars must handle separately:
//
// - (false, true) — dir exists and is empty, OR dir disappeared
// between the failed rmdir and our readdir (the race collapses
// into "empty" so cleanup keeps moving). When paired with a
// non-ENOENT rmdir failure in CleanupSidecars this is the
// "empty + rmdir refused" branch — a real I/O error that gets
// surfaced.
// - (true, true) — dir has user content. When paired with a rmdir
// failure this is the intended ENOTEMPTY branch — skip silently
// so the user's content is preserved.
// - (_, false) — readdir failed with a real I/O error (EACCES on a
// chmod'd dir, ENOTDIR on a recorded path that isn't actually a
// dir, EIO on a hardware fault, etc.). The caller cannot tell
// ENOTEMPTY from a real failure and MUST surface the original
// rmdir error instead of silently skipping. The v1 of this
// helper returned `true` here, which made CleanupSidecars treat
// every readdir failure as "user content present" and hid the
// underlying rmdir error.
func dirHasEntries(dir string) (hasEntries bool, ok bool) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false, true
}
return false, false
}
return len(entries) > 0, true
}