mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
3 Commits
codex/agen
...
agent/j/0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5fb164b9 | ||
|
|
09548ef418 | ||
|
|
147cc16583 |
@@ -2583,6 +2583,17 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
if cerr := execenv.CleanupRuntimeConfig(env.WorkDir, provider); cerr != nil {
|
||||
d.logger.Warn("execenv: cleanup runtime config failed (non-fatal)", "error", cerr)
|
||||
}
|
||||
// Excise the sidecar tree (.agent_context/, .multica/,
|
||||
// provider-specific .claude/skills/ etc.) that Prepare wrote
|
||||
// into the user's repo. Without this pass the user's tree
|
||||
// accumulates one directory layer per task — see MUL-2784.
|
||||
// CleanupRuntimeConfig handles the runtime brief inside
|
||||
// CLAUDE.md / AGENTS.md / GEMINI.md; CleanupSidecars handles
|
||||
// every other file Prepare placed under WorkDir. Together
|
||||
// they round-trip the workdir to its exact pre-task bytes.
|
||||
if cerr := execenv.CleanupSidecars(env.RootDir); cerr != nil {
|
||||
d.logger.Warn("execenv: cleanup sidecars failed (non-fatal)", "error", cerr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ package execenv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -23,26 +23,43 @@ import (
|
||||
// Kiro: skills → {workDir}/.kiro/skills/{name}/SKILL.md (native discovery)
|
||||
// Antigravity: skills → {workDir}/.agents/skills/{name}/SKILL.md (native discovery — see https://antigravity.google/docs/gcli-migration "Workspace skills")
|
||||
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
||||
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
//
|
||||
// manifest, when non-nil, is populated with every file we created and every
|
||||
// intermediate directory we had to MkdirAll (skipping any that pre-existed).
|
||||
// CleanupSidecars uses it to roll the workdir back to its pre-Prepare
|
||||
// state for local_directory tasks. Callers that don't need cleanup —
|
||||
// cloud-mode tasks whose envRoot is wiped wholesale by the GC loop — may
|
||||
// pass nil to skip the bookkeeping entirely.
|
||||
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv, manifest *sidecarManifest) error {
|
||||
contextDir := filepath.Join(workDir, ".agent_context")
|
||||
if err := os.MkdirAll(contextDir, 0o755); err != nil {
|
||||
if err := recordMkdirAll(contextDir, 0o755, manifest); err != nil {
|
||||
return fmt.Errorf("create .agent_context dir: %w", err)
|
||||
}
|
||||
|
||||
content := renderIssueContext(provider, ctx)
|
||||
path := filepath.Join(contextDir, "issue_context.md")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("write issue_context.md: %w", err)
|
||||
if err := recordWriteFile(path, []byte(content), 0o644, manifest); err != nil {
|
||||
// A pre-existing path means the user already owns
|
||||
// .agent_context/issue_context.md — either they created it
|
||||
// themselves or it survived from a crashed prior run we can't
|
||||
// safely distinguish from intentional content. Refusing the
|
||||
// write is the correct call: the runtime brief (CLAUDE.md /
|
||||
// AGENTS.md / GEMINI.md) already carries every fact this file
|
||||
// would, so the agent runs fine without the sidecar copy.
|
||||
// Anything else is a real failure.
|
||||
if !errors.Is(err, errPathPreExists) {
|
||||
return fmt.Errorf("write issue_context.md: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
skillsDir, err := resolveSkillsDir(workDir, provider)
|
||||
skillsDir, err := resolveSkillsDir(workDir, provider, manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve skills dir: %w", err)
|
||||
}
|
||||
// Codex skills are written to codex-home in Prepare; skip here.
|
||||
if provider != "codex" {
|
||||
if err := writeSkillFiles(skillsDir, ctx.AgentSkills); err != nil {
|
||||
if err := writeSkillFiles(skillsDir, ctx.AgentSkills, manifest); err != nil {
|
||||
return fmt.Errorf("write skill files: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +69,7 @@ func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
// block task startup. Missing resources surface as the agent simply not
|
||||
// seeing the file, which matches the "scoped, not dumped" design (the
|
||||
// meta skill content always lists what the agent should expect).
|
||||
if err := writeProjectResources(workDir, ctx); err != nil {
|
||||
if err := writeProjectResources(workDir, ctx, manifest); err != nil {
|
||||
// Caller logs warnings; avoid noisy returns for non-fatal context.
|
||||
return fmt.Errorf("write project resources: %w", err)
|
||||
}
|
||||
@@ -94,12 +111,16 @@ func (p ProjectResourceForEnv) MarshalJSON() ([]byte, error) {
|
||||
// working directory when the task carries project context. The file is
|
||||
// always written when a project is attached (even with zero resources) so
|
||||
// agents can rely on its presence as a signal that a project exists.
|
||||
func writeProjectResources(workDir string, ctx TaskContextForEnv) error {
|
||||
//
|
||||
// manifest, when non-nil, is populated with the .multica/project chain
|
||||
// of created directories and the resources.json file so CleanupSidecars
|
||||
// can undo them on local_directory teardown.
|
||||
func writeProjectResources(workDir string, ctx TaskContextForEnv, manifest *sidecarManifest) error {
|
||||
if ctx.ProjectID == "" && len(ctx.ProjectResources) == 0 {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Join(workDir, ".multica", "project")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
if err := recordMkdirAll(dir, 0o755, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
resources := ctx.ProjectResources
|
||||
@@ -115,12 +136,24 @@ func writeProjectResources(workDir string, ctx TaskContextForEnv) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "resources.json"), data, 0o644)
|
||||
if err := recordWriteFile(filepath.Join(dir, "resources.json"), data, 0o644, manifest); err != nil {
|
||||
// .multica/project/resources.json is Multica-owned and a
|
||||
// pre-existing path is almost certainly user content the
|
||||
// manifest must not destroy. The runtime brief already lists
|
||||
// every project resource so the agent runs fine without the
|
||||
// JSON sidecar — collision degrades to brief-only mode.
|
||||
if !errors.Is(err, errPathPreExists) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSkillsDir returns the directory where skills should be written
|
||||
// based on the agent provider.
|
||||
func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
// based on the agent provider. manifest, when non-nil, is populated with
|
||||
// every intermediate directory we had to MkdirAll so CleanupSidecars can
|
||||
// rmdir them on local_directory teardown.
|
||||
func resolveSkillsDir(workDir, provider string, manifest *sidecarManifest) (string, error) {
|
||||
var skillsDir string
|
||||
switch provider {
|
||||
case "claude":
|
||||
@@ -174,7 +207,7 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
||||
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
||||
}
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
if err := recordMkdirAll(skillsDir, 0o755, manifest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return skillsDir, nil
|
||||
@@ -287,32 +320,57 @@ func sanitizeSkillName(name string) string {
|
||||
}
|
||||
|
||||
// writeSkillFiles writes skill directories into the given parent directory.
|
||||
// Each skill gets its own subdirectory containing SKILL.md and supporting files.
|
||||
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error {
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
// Each skill gets its own subdirectory containing SKILL.md and supporting
|
||||
// files. manifest, when non-nil, is populated with every newly-created
|
||||
// directory and file so CleanupSidecars can remove them on
|
||||
// local_directory teardown without touching user-owned skill directories
|
||||
// that happen to live alongside ours under the same skills/ parent.
|
||||
//
|
||||
// When a Multica skill's natural slug collides with a user-installed
|
||||
// skill at the same path, we allocate a collision-free sibling slug
|
||||
// (e.g. `issue-review-multica`) and write there instead. Provider-native
|
||||
// discovery still picks it up because every subdir under skillsDir is a
|
||||
// distinct skill; the user's original directory stays bit-for-bit
|
||||
// intact. Without this fallback writeSkillFiles would have to either
|
||||
// overwrite user bytes (the bug PR #3444 review caught) or skip the
|
||||
// skill entirely (which would silently drop a Multica skill the agent
|
||||
// expects to see).
|
||||
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv, manifest *sidecarManifest) error {
|
||||
if err := recordMkdirAll(skillsDir, 0o755, manifest); err != nil {
|
||||
return fmt.Errorf("create skills dir: %w", err)
|
||||
}
|
||||
|
||||
for _, skill := range skills {
|
||||
slug := sanitizeSkillName(skill.Name)
|
||||
dir := filepath.Join(skillsDir, slug)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
baseSlug := sanitizeSkillName(skill.Name)
|
||||
slug, dir, err := allocateCollisionFreeSkillDir(skillsDir, baseSlug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("allocate skill dir for %q: %w", skill.Name, err)
|
||||
}
|
||||
if err := recordMkdirAll(dir, 0o755, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write main SKILL.md
|
||||
// ensureSkillFrontmatter synthesises a `name:` value when the
|
||||
// upstream skill is missing one. Use the chosen slug (which
|
||||
// may differ from baseSlug on collision) so the YAML name
|
||||
// matches the directory name; runtimes that key on either
|
||||
// stay consistent.
|
||||
body := ensureSkillFrontmatter(skill.Content, slug, skill.Description)
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
if err := recordWriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write supporting files
|
||||
// Write supporting files. The skill directory is collision-
|
||||
// free by construction, so a recordWriteFile collision under
|
||||
// it would mean the skill's bundled files list two entries
|
||||
// at the same path — that's an upstream data bug, not a
|
||||
// user-content collision, and we surface it.
|
||||
for _, f := range skill.Files {
|
||||
fpath := filepath.Join(dir, f.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||
if err := recordMkdirAll(filepath.Dir(fpath), 0o755, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fpath, []byte(f.Content), 0o644); err != nil {
|
||||
if err := recordWriteFile(fpath, []byte(f.Content), 0o644, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,9 +192,19 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
|
||||
}
|
||||
|
||||
// Write context files into workdir (skills go to provider-native paths).
|
||||
if err := writeContextFiles(workDir, params.Provider, params.Task); err != nil {
|
||||
// Track every file/dir we create in a manifest so CleanupSidecars can
|
||||
// roll a local_directory workdir back to its pre-Prepare state. Cloud
|
||||
// tasks don't need the manifest (the GC loop wipes envRoot wholesale),
|
||||
// but we always write one — it's cheap, keeps Prepare/Reuse symmetric,
|
||||
// and avoids a conditional that would silently disable cleanup if the
|
||||
// local_directory detection logic ever drifts.
|
||||
manifest := &sidecarManifest{}
|
||||
if err := writeContextFiles(workDir, params.Provider, params.Task, manifest); err != nil {
|
||||
return nil, fmt.Errorf("execenv: write context files: %w", err)
|
||||
}
|
||||
if err := writeSidecarManifest(envRoot, manifest); err != nil {
|
||||
logger.Warn("execenv: write sidecar manifest failed (non-fatal)", "error", err)
|
||||
}
|
||||
|
||||
// For Codex, set up a per-task CODEX_HOME seeded from ~/.codex/ with skills.
|
||||
if params.Provider == "codex" {
|
||||
@@ -271,10 +281,25 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment {
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Refresh context files (issue_context.md, skills).
|
||||
if err := writeContextFiles(params.WorkDir, params.Provider, params.Task); err != nil {
|
||||
// Refresh context files (issue_context.md, skills). Reuse tracks a
|
||||
// fresh manifest under env.RootDir so a later CleanupSidecars sees
|
||||
// the up-to-date list of writes (an old manifest from a prior run
|
||||
// would otherwise reference files this Reuse no longer creates). For
|
||||
// local_directory tasks the daemon skips Reuse entirely (see
|
||||
// daemon.runTask), but writing the manifest unconditionally keeps
|
||||
// Prepare/Reuse symmetric so a future caller can rely on the
|
||||
// manifest being current after either path. RootDir is empty on the
|
||||
// legacy local_directory Reuse fallback — skip the persist in that
|
||||
// case to avoid creating a stray manifest at the filesystem root.
|
||||
manifest := &sidecarManifest{}
|
||||
if err := writeContextFiles(params.WorkDir, params.Provider, params.Task, manifest); err != nil {
|
||||
logger.Warn("execenv: refresh context files failed", "error", err)
|
||||
}
|
||||
if env.RootDir != "" {
|
||||
if err := writeSidecarManifest(env.RootDir, manifest); err != nil {
|
||||
logger.Warn("execenv: refresh sidecar manifest failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore CodexHome for Codex provider — the per-task codex-home directory
|
||||
// lives alongside the workdir. Re-run prepareCodexHomeWithOpts to ensure
|
||||
@@ -342,7 +367,11 @@ func hydrateCodexSkills(codexHome string, workspaceSkills []SkillContextForEnv,
|
||||
if len(workspaceSkills) == 0 {
|
||||
return nil
|
||||
}
|
||||
return writeSkillFiles(skillsDir, workspaceSkills)
|
||||
// Codex skills live under env.RootDir/codex-home, which the GC loop
|
||||
// (cloud) or env teardown (local_directory) wipes wholesale — they
|
||||
// don't sit inside the user's workdir and don't need sidecar manifest
|
||||
// tracking.
|
||||
return writeSkillFiles(skillsDir, workspaceSkills, nil)
|
||||
}
|
||||
|
||||
// GCMetaKind identifies which kind of parent record a task workdir belongs to.
|
||||
|
||||
@@ -269,7 +269,7 @@ func TestProjectReposReplaceWorkspaceReposInMetaSkill(t *testing.T) {
|
||||
func TestWriteProjectResourcesSkippedWhenNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := writeProjectResources(dir, TaskContextForEnv{}); err != nil {
|
||||
if err := writeProjectResources(dir, TaskContextForEnv{}, nil); err != nil {
|
||||
t.Fatalf("writeProjectResources: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".multica", "project", "resources.json")); !os.IsNotExist(err) {
|
||||
@@ -352,7 +352,7 @@ func TestWriteContextFiles(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) {
|
||||
IssueID: "minimal-issue-id",
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ func TestWriteContextFilesAutopilotRunOnly(t *testing.T) {
|
||||
AutopilotSource: "manual",
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -479,7 +479,7 @@ func TestWriteContextFilesClaudeNativeSkills(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "claude", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "claude", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -757,7 +757,7 @@ func TestWriteContextFilesCopilotNativeSkills(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "copilot", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "copilot", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -808,7 +808,7 @@ func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "opencode", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "opencode", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -880,7 +880,7 @@ func TestWriteContextFilesPreservesExistingSkillFrontmatter(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "opencode", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "opencode", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -915,7 +915,7 @@ func TestWriteContextFilesInjectsNameIntoNamelessFrontmatter(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "opencode", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "opencode", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -953,7 +953,7 @@ func TestWriteContextFilesOpenclawNativeSkills(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "openclaw", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "openclaw", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -993,7 +993,7 @@ func TestWriteContextFilesKiroNativeSkills(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "kiro", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "kiro", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -1125,7 +1125,7 @@ func TestWriteContextFilesAntigravityNativeSkills(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "antigravity", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "antigravity", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -1550,7 +1550,7 @@ func TestWriteContextFilesHermesFallbackSkills(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "hermes", ctx); err != nil {
|
||||
if err := writeContextFiles(dir, "hermes", ctx, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -464,7 +464,7 @@ func TestPrepareOpenclawSkillWriteMatchesScanPath(t *testing.T) {
|
||||
if err := writeContextFiles(workDir, "openclaw", TaskContextForEnv{
|
||||
IssueID: "issue-1",
|
||||
AgentSkills: skills,
|
||||
}); err != nil {
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("writeContextFiles: %v", err)
|
||||
}
|
||||
|
||||
|
||||
364
server/internal/daemon/execenv/sidecar_manifest.go
Normal file
364
server/internal/daemon/execenv/sidecar_manifest.go
Normal file
@@ -0,0 +1,364 @@
|
||||
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 / GEMINI.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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
1126
server/internal/daemon/execenv/sidecar_manifest_test.go
Normal file
1126
server/internal/daemon/execenv/sidecar_manifest_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user