mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* fix(skills): give every skill one name across brief, directory, and frontmatter (MUL-5529)
A skill could answer to three different names at once. The runtime brief listed
`AgentSkillData.Name` verbatim — a workspace skill's human display name ("PR
review") — while the invocable identity on disk is the sanitized slug
(`pr-review`). Separately, ensureSkillFrontmatter returned valid upstream
frontmatter untouched, so a SKILL.md could declare `name: multica-dev-workflow`
inside a directory called `multica-git-workflow`.
That last divergence is the sharp one: runtimes disagree on which field
identifies a skill. Claude routes on the directory name, OpenCode on the
frontmatter `name`. So the same skill is invocable under different names
depending on where it runs, and the brief's instruction to use "only names from
the listing" pointed at names that resolve nowhere.
The slug is authoritative: it is what lands on disk, it derives from the name
users see in the product, and it is the only value with a uniqueness guarantee
(allocateCollisionFreeSkillDir). A frontmatter `name` is author-supplied and two
imported skills may both claim the same one.
- modelVisibleSkills normalizes Name to the slug. All four model-visible
listings (runtime brief + the three issue_context renderers) already route
through it, so they cannot drift apart.
- ensureSkillFrontmatter rewrites `name` to the allocated slug and keeps every
other key byte-identical, so deliberately shaped upstream frontmatter still
survives. The rewrite follows the collision fallback slug too.
- Name matching is now top-level only. An indented `name:` belongs to a nested
mapping; treating it as the skill's identity both missed that the block had no
top-level name and would have spliced a top-level key into the nested one.
Known gap, tracked separately: the listings render the natural slug, so a
collision fallback to `<slug>-multica` still leaves the brief naming the user's
skill. Closing it needs the allocated slug threaded back from Prepare, which the
renderers cannot reach without giving up the byte-identical-brief guarantee.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): cover multi-line name values and in-batch slug collisions (MUL-5529)
Two holes in the name-unification change, both found in review, both
reproduced before fixing.
1. setFrontmatterName replaced only the `name:` line, not the rest of the YAML
value. A value may continue onto indented lines — `name: >-\n upstream`,
multi-line plain scalars, wrapped quoted scalars — so the continuation
survived and YAML folded it into the new value: the block parsed as
"my-slug upstream-name", not "my-slug". The directory == frontmatter-name
invariant this change set exists to establish was still broken, just less
visibly. frontmatterNameSpan now covers the whole value.
As a side effect this also fixes `name:` with the value entirely on the
following line, which previously read as "no name" and got a second
top-level `name` injected above it — two `name` keys, which strict loaders
reject outright.
2. The listings derived slugs with sanitizeSkillName alone, which is not
injective: "A B" and "A-B" both reduce to "a-b". writeSkillFiles resolved
that at write time, so the second skill landed in `a-b-multica` while both
were listed as `a-b` — the second skill had no invocable name and the model
was pointed at the first. This needed no user-installed skill and no
local_directory; two such skills bound to one agent reproduce it in a clean
workdir. resolveSkillSlugs now deduplicates the batch up front and both the
listings and the writer derive from it, with skillSlugCandidate shared so
the in-memory and filesystem allocators cannot disagree on the suffix
sequence.
Slugs are allocated over the unfiltered batch: hidden
(disable-model-invocation) skills are still written to disk and still
consume a slug, so filtering first would shift every later suffix.
Filesystem-dependent collisions against user-installed directories remain out
of scope and are still tracked in MUL-5550.
Regression tests assert the parsed YAML value rather than the output text —
the first line looked correct in every one of these cases — and set-equality
between listed names and the directories actually written. Both were confirmed
to fail against the previous implementation.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): bound the frontmatter name value with the YAML parser (MUL-5529)
Review round two found a valid multi-line name the indentation rule still
mangled. A quoted scalar may wrap onto a line at the *same* indentation as its
key:
name: "upstream
continued"
The rule stopped at the key's line, stranding `continued"` and producing
invalid YAML. Probing the parser showed the same holds for flow collections
(`name: [a,\nb]`, `name: {a: 1,\nb: 2}`), so this was not a quoting special
case: indentation simply does not bound a YAML value, and no amount of
patching the heuristic would have made it one.
Value extent now comes from yaml.v3's own line numbers. frontmatterNameValueSpan
locates the `name` key node and ends the span where the next top-level key
begins, stepping back over blank lines and unindented comments so they survive
the rewrite. Unindented is the operative word: block scalar content is always
indented, so an unindented `#` can only be a comment, while ` # text` inside a
block scalar is value and stays in the span.
Detection stays lexical, in lexicalFrontmatterNameSpan. It runs on malformed
blocks too, where there is no parse to consult, and only decides which branch
to take.
setFrontmatterName now re-parses its own output and returns verified=false
unless `name` really is the slug; ensureSkillFrontmatter then routes to the
existing re-synthesis path rather than emitting a block it cannot vouch for.
This adds no new fallback — it feeds an already-present one. The invariant is
the whole point of the change, so an unprovable rewrite is worth less than a
reformatted block: with the span deliberately broken, output stays valid YAML
carrying the right name and loses only upstream formatting.
Tests extend the table to same-indent single/double quoted scalars, flow
sequences and mappings, and name-as-last-key, and now assert the *input* parses
so a case cannot pass by silently taking the re-synthesis path. Two more cover
comment survival and a `#` line inside a block scalar name. All four
same-indent cases fail against the previous implementation.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve policy keys when the surgical name rewrite fails (MUL-5529)
Review round three: the post-condition check added last round was routing valid
YAML into bare re-synthesis, and re-synthesis emits only name and description.
An anchor on the name value is one way to get there. Replacing the line drops
`&skill_name`, so `description: *skill_name` no longer resolves, the check
correctly rejects the rewrite — and the block was then rebuilt as just:
name: my-slug
`disable-model-invocation: true` went with it. That key is the author's
instruction that a runtime must not surface the skill on its own, and the
SKILL.md we write is what native discovery reads, so losing it advertises a
skill that was deliberately hidden. Confirmed on the written output:
skillDisablesModelInvocation went from true to false. That is a semantic
regression, not the formatting loss the fallback was justified by.
The two failure modes are now separate:
- invalid YAML → re-synthesize, unchanged; there is nothing to preserve.
- valid YAML, rewrite unprovable → renameFrontmatterNameViaNode rebuilds the
block from the parsed node with `name` set to the slug, keeping every other
key. Formatting normalizes; semantics survive.
The anchor is deliberately kept on the name node. Dropping it is what
invalidates a document that aliases it; keeping it means the alias resolves to
the slug — that value changes, but the document still loads and every policy
key is intact. The rebuilt block is re-parsed and checked like the surgical
path, so an unprovable result still falls through to re-synthesis.
Regression test asserts name, disable-model-invocation, and a custom key all
survive, and that the written file still reads as hidden to
skillDisablesModelInvocation. It fails without the new path.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): materialize aliases before renaming an anchored name (MUL-5529)
Round three kept the anchor on the name node so aliases would not dangle. That
avoided an invalid document but created a worse one: every alias resolves
through the anchor, so renaming the anchored value silently rewrote whatever
those fields meant.
name: &shared "true"
disable-model-invocation: *shared
skillDisablesModelInvocation went true -> false across the rewrite. Same
skill-exposing regression as round three, reached by keeping the key instead of
dropping it — which is the lesson: preserving a key is not the invariant,
preserving each key's resolved value is.
Aliases pointing at the name node are now materialized to the value they
resolved to *before* the rename, and the anchor is dropped afterwards as
unreferenced. Ordering matters: the clones are taken first, so they capture the
original value rather than the slug. Copies are per-alias, since sharing one
node would make the encoder re-emit an anchor/alias pair.
Nested aliases are covered by walking the whole document, not just the top
mapping.
Tests: three shapes (alias carrying the policy value, alias nested in another
mapping, two aliases of one anchor) each assert the fixture starts hidden and
stays hidden, that no anchor or alias survives, and that name is the slug. The
round-three test now also pins its aliased `description` to the pre-rename
value instead of merely tolerating the slug. All four fail without the change.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): let the parser decide whether a name key exists (MUL-5529)
The branch that chooses between "rewrite the name" and "inject a name" was
still gated on a lexical scan, which only recognizes a bare `name:` carrying a
value on the same line. Two valid spellings therefore read as nameless:
"name": upstream # quoting is syntax, not identity
name: # a key with no value is still the key
Both got a second `name` injected above the existing one, and a duplicate
mapping key is rejected outright — `mapping key "name" already defined`. The
skill does not end up misnamed, it fails to load. Single-quoted keys have the
same problem.
For valid YAML the parsed top-level mapping now answers the question, so any
spelling of the key routes to the rewrite. The lexical scan is confined to the
invalid-YAML branch, where there is no parse to consult and it is only choosing
between re-synthesis and injection. Nesting still reads as absent: a `name`
under another mapping is not the skill's name, so one is added.
Once past the gate the existing paths handle both shapes unchanged, since
frontmatterNameValueSpan already bounds the entry by node line numbers rather
than by how the key is written.
Also tightens the alias tests per review: the nested and two-alias cases now
assert `meta.inner` and `other` still resolve to the anchor's original value,
not merely that visibility survived. The invariant is every key's resolved
value, so every alias should be pinned, not just the one that gates hiding.
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): detect quoted and valueless name keys in malformed frontmatter (MUL-5529)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
432 lines
18 KiB
Go
432 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++ {
|
|
candidate := skillSlugCandidate(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)
|
|
}
|
|
|
|
// skillSlugCandidate is the nth name to try for a skill whose natural slug is
|
|
// baseSlug: the bare slug first, then `-multica`, then numbered variants.
|
|
//
|
|
// Two callers must agree on this sequence — allocateCollisionFreeSkillDir,
|
|
// which probes the filesystem, and resolveSkillSlugs, which deduplicates a
|
|
// batch in memory before anything is written. If they disagreed, a skill would
|
|
// be listed under one name and written under another.
|
|
func skillSlugCandidate(baseSlug string, attempt int) string {
|
|
switch {
|
|
case attempt <= 0:
|
|
return baseSlug
|
|
case attempt == 1:
|
|
return baseSlug + "-multica"
|
|
default:
|
|
return fmt.Sprintf("%s-multica-%d", baseSlug, attempt)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|