Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions pkg/mcp/strategies/playbook.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,10 @@ func LoadPlaybooksFrom(pluginDir, systemDir string, userDirs ...string) (map[str
// playbook they don't want.
//
// Per-id invariant: an id may NOT span multiple type directories in
// the same user dir. The loader rejects collisions so the type of an
// id stays unambiguous.
// the same user dir. WriteUserPlaybook refuses to create a second slot;
// if one exists anyway, the loader keeps the copy in the first type dir
// (directory order) and soft-skips the rest so the type of an id stays
// unambiguous without taking the server down.
func loadUserDir(dir string) (map[string]*Playbook, error) {
typeDirs, err := os.ReadDir(dir)
if err != nil {
Expand Down Expand Up @@ -401,7 +403,14 @@ func loadUserDir(dir string) (map[string]*Playbook, error) {
}
pb.Type = typeName
if existingType, dup := idType[pb.ID]; dup && existingType != typeName {
return nil, fmt.Errorf("user playbook id %q exists in multiple type dirs (%s and %s); pick one", pb.ID, existingType, typeName)
// Soft-skip the later copy rather than hard-fail: a
// hard error here takes the whole strategies MCP down
// on startup for every new session, dragging every
// playbook with it. Directory order is stable, so the
// first type dir keeps the id; warn so the operator can
// delete the stray copy.
fmt.Fprintf(os.Stderr, "strategies: skipping user playbook %s/%s: id %q already loaded from %s/%s (an id lives in one type dir; delete the copy that should not exist)\n", typeName, name, pb.ID, existingType, name)
continue
}
idType[pb.ID] = typeName
out[pb.ID] = pb
Expand Down Expand Up @@ -892,7 +901,7 @@ func WriteUserPlaybook(dir, typeName, id, body string, activate bool) (validatio

// userTypeDirHolding returns the type dir under dir that already holds
// <id>.yaml, or "" when none does. Keeps an id in a single type slot:
// loadUserDir refuses a user dir where one id spans two type dirs.
// loadUserDir only honours one copy when an id spans two type dirs.
func userTypeDirHolding(dir, id string) string {
entries, err := os.ReadDir(dir)
if err != nil {
Expand Down
40 changes: 40 additions & 0 deletions pkg/mcp/strategies/playbook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,46 @@ nodes:
assert.False(t, present, "broken playbook should have been skipped")
}

// TestLoadPlaybooksFrom_IdInTwoTypeDirs keeps the strategies MCP up
// when one id has a copy under two type dirs — the state an older
// approve left behind before WriteUserPlaybook refused the second slot.
// The first type dir in directory order wins, the other copy is skipped
// with a stderr warning, and every other playbook still loads.
func TestLoadPlaybooksFrom_IdInTwoTypeDirs(t *testing.T) {
t.Parallel()
dir := t.TempDir()
body := func(symptom string) string {
return `id: dup
symptom: "` + symptom + `"
entrypoint: a
nodes:
a:
description: "step"
`
}
other := `id: other
symptom: "unaffected neighbour"
entrypoint: a
nodes:
a:
description: "step"
`
for typ, content := range map[string]string{"investigation": body("first slot"), "verification": body("second slot")} {
require.NoError(t, os.MkdirAll(filepath.Join(dir, typ), 0o700), "mkdir %s", typ)
require.NoError(t, os.WriteFile(filepath.Join(dir, typ, "dup.yaml"), []byte(content), 0o600), "write %s", typ)
}
require.NoError(t, os.WriteFile(filepath.Join(dir, "verification", "other.yaml"), []byte(other), 0o600), "write other")

books, err := LoadPlaybooksFrom("", "", dir)
require.NoError(t, err, "an id spanning two type dirs must not fail the whole load")
dup, ok := books["dup"]
require.True(t, ok, "dup should load from one of its slots")
assert.Equal(t, "investigation", dup.Type, "first type dir in directory order wins")
assert.Equal(t, "first slot", dup.Symptom, "the winning copy is the one in the first type dir")
_, ok = books["other"]
assert.True(t, ok, "neighbouring playbooks must still load")
}

// TestLoadPlaybooksFrom_IgnoresRepoArtefacts confirms README.md,
// LICENSE, dot-prefixed dirs (.git, .github) and other repo-management
// files at the system-dir root are skipped without error. The clone
Expand Down
Loading