mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
* 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>
422 lines
18 KiB
Go
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
|
|
}
|