Enforce §3's monotonic version and its DAG requirement at publish
Two rules §3 states and nothing checked.
MONOTONIC. The rewrite guard added earlier compares content at ONE version
number, so republishing an OLDER number with the text originally published
under it looked like a no-op: nothing conflicted, nothing was refused, and the
live row silently reverted. The agent then reads v1 in the UI while the newest
thing anybody approved was v2. The test publishes v1, publishes v2, republishes
v1 byte-for-byte, and asserts both the refusal and that the live row is still
v2. Without the guard it answers 200 and the row goes back to version 1.
DAG. §3 says cycle detection runs at publish; only the runtime depth cap
existed. That cap means a cycle was never a safety problem — it was a budget
one. Every run entering the loop spends its whole allowance delegating in a
circle before terminating, and the author learns about it from a bill rather
than from the publish that created it.
definition.FindSubagentCycle is a pure function over id -> subagent ids, so it
is tested directly: chains, diamonds, self-reference, loops not involving the
first agent walked, and a 5000-long chain that would matter if this were
written to recurse carelessly. It REPORTS the cycle ("a -> b -> c -> a")
rather than merely detecting one, because an operator otherwise has to find it
by hand across a set of specs. The report is deterministic — a test runs it
fifty times over a graph with two cycles and requires the same answer, since Go
randomises map iteration and an error message that changes between identical
runs is one nobody trusts.
Wired into both publish paths. importagents has every spec in hand, which is
the only place that is cheaply true. The API builds the graph from
organization-visible agents plus the incoming definition standing in for its
stored self — otherwise an edit that CREATES a cycle is checked against the
version that did not have one and passes. Personal agents are excluded: they
are invisible to everyone else so cannot complete anyone else's loop, and
reading them would mean reading other people's drafts to validate your own.
An edge to an agent that is not in the set is ignored rather than reported.
That is a different failure with a different message
(runtime.unknown_subagent), and conflating them prints "cycle detected" for
what is actually a typo.
The cycle test bumps the version on the loop-closing edit. Without that the
rewrite guard refuses it for changing published text, the test passes for the
wrong reason, and it would keep passing with cycle detection deleted — which
is how it was first written, and what running it without the guard showed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
103
go-api/internal/definition/graph.go
Normal file
103
go-api/internal/definition/graph.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindSubagentCycle reports the first delegation cycle in a set of agents, or
|
||||
// "" if the graph is acyclic.
|
||||
//
|
||||
// §3: "subagents must form a DAG. Cycle detection runs at publish." This is the
|
||||
// publish-time half. The runtime half is runtime.MaxDelegationDepth, which
|
||||
// bounds a cycle that reaches run time anyway — because a graph can only be
|
||||
// checked against the agents the checker was GIVEN, and an agent published
|
||||
// while another is being edited can complete a loop neither publish saw.
|
||||
//
|
||||
// The returned string names the cycle in the order it was walked, so an
|
||||
// operator can see which edge to cut:
|
||||
//
|
||||
// a -> b -> c -> a
|
||||
//
|
||||
// Edges pointing at agents not in the set are ignored rather than treated as
|
||||
// missing. Resolving those is a different check with a different message
|
||||
// (runtime.unknown_subagent), and conflating the two produces "cycle detected"
|
||||
// for what is actually a typo.
|
||||
func FindSubagentCycle(subagents map[string][]string) string {
|
||||
// Depth-first search tracking the path, so the cycle can be REPORTED
|
||||
// rather than merely detected — "there is a cycle" leaves an operator to
|
||||
// find it by hand across a set of specs.
|
||||
//
|
||||
// Recursive, and deliberately: a goroutine stack grows on demand, so depth
|
||||
// here costs memory rather than a crash, and a 5000-long chain is covered
|
||||
// by a test. An explicit stack would buy nothing and lose the path
|
||||
// bookkeeping that makes the message useful.
|
||||
const (
|
||||
unvisited = 0
|
||||
onPath = 1
|
||||
done = 2
|
||||
)
|
||||
state := make(map[string]int, len(subagents))
|
||||
|
||||
// Sorted, so the same set of agents always reports the same cycle. An
|
||||
// error message that changes between runs on identical input is one
|
||||
// nobody trusts.
|
||||
roots := make([]string, 0, len(subagents))
|
||||
for id := range subagents {
|
||||
roots = append(roots, id)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
|
||||
var path []string
|
||||
var walk func(id string) string
|
||||
walk = func(id string) string {
|
||||
switch state[id] {
|
||||
case done:
|
||||
return ""
|
||||
case onPath:
|
||||
// Found it. Report from the first occurrence of this id, so the
|
||||
// message is the cycle itself and not the walk that reached it.
|
||||
for i, seen := range path {
|
||||
if seen == id {
|
||||
return strings.Join(append(append([]string{}, path[i:]...), id), " -> ")
|
||||
}
|
||||
}
|
||||
return id + " -> " + id
|
||||
}
|
||||
|
||||
state[id] = onPath
|
||||
path = append(path, id)
|
||||
for _, next := range subagents[id] {
|
||||
if _, known := subagents[next]; !known {
|
||||
continue // not ours to judge; see the doc comment
|
||||
}
|
||||
if cycle := walk(next); cycle != "" {
|
||||
return cycle
|
||||
}
|
||||
}
|
||||
path = path[:len(path)-1]
|
||||
state[id] = done
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, id := range roots {
|
||||
if cycle := walk(id); cycle != "" {
|
||||
return cycle
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrVersionWentBackwards describes a publish that lowers a version.
|
||||
//
|
||||
// §3 calls the version monotonic. Nothing enforced it: the upsert wrote
|
||||
// whatever the frontmatter said, so a spec edited from an older copy silently
|
||||
// rolled a deployed agent backwards — no conflict, because the older version's
|
||||
// content still matched what was published under that number.
|
||||
func ErrVersionWentBackwards(id string, from, to int) error {
|
||||
return fmt.Errorf(
|
||||
"%q is published at version %d and this publishes version %d; "+
|
||||
"a version is monotonic, so raise it above %d rather than lowering it",
|
||||
id, from, to, from)
|
||||
}
|
||||
101
go-api/internal/definition/graph_test.go
Normal file
101
go-api/internal/definition/graph_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package definition_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/definition"
|
||||
)
|
||||
|
||||
func TestFindSubagentCycle(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
graph map[string][]string
|
||||
want string // "" means acyclic; otherwise a substring the report must contain
|
||||
}{
|
||||
{"empty", map[string][]string{}, ""},
|
||||
{"no edges", map[string][]string{"a": nil, "b": nil}, ""},
|
||||
{"a chain is not a cycle", map[string][]string{
|
||||
"a": {"b"}, "b": {"c"}, "c": nil,
|
||||
}, ""},
|
||||
{"a diamond is not a cycle", map[string][]string{
|
||||
"a": {"b", "c"}, "b": {"d"}, "c": {"d"}, "d": nil,
|
||||
}, ""},
|
||||
{"self reference", map[string][]string{"a": {"a"}}, "a -> a"},
|
||||
{"two-agent loop", map[string][]string{
|
||||
"a": {"b"}, "b": {"a"},
|
||||
}, "a -> b -> a"},
|
||||
{"longer loop", map[string][]string{
|
||||
"a": {"b"}, "b": {"c"}, "c": {"a"},
|
||||
}, "a -> b -> c -> a"},
|
||||
{"cycle not involving the first agent walked", map[string][]string{
|
||||
"a": {"b"}, "b": {"c"}, "c": {"b"},
|
||||
}, "b -> c -> b"},
|
||||
{"an edge to an unknown agent is not a cycle", map[string][]string{
|
||||
"a": {"nowhere"},
|
||||
}, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := definition.FindSubagentCycle(tc.graph)
|
||||
switch {
|
||||
case tc.want == "" && got != "":
|
||||
t.Errorf("reported a cycle %q in an acyclic graph", got)
|
||||
case tc.want != "" && got == "":
|
||||
t.Errorf("missed the cycle; want something containing %q", tc.want)
|
||||
case tc.want != "" && !strings.Contains(got, tc.want):
|
||||
t.Errorf("cycle = %q, want it to contain %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The report must be stable: the same graph reported differently on different
|
||||
// runs is an error message nobody trusts, and map iteration order in Go is
|
||||
// deliberately random.
|
||||
func TestFindSubagentCycleIsDeterministic(t *testing.T) {
|
||||
graph := map[string][]string{
|
||||
"e": {"f"}, "f": {"e"},
|
||||
"a": {"b"}, "b": {"c"}, "c": {"a"},
|
||||
"z": nil, "y": {"z"},
|
||||
}
|
||||
first := definition.FindSubagentCycle(graph)
|
||||
if first == "" {
|
||||
t.Fatal("no cycle found in a graph with two")
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
if got := definition.FindSubagentCycle(graph); got != first {
|
||||
t.Fatalf("run %d reported %q, first run reported %q — the report "+
|
||||
"depends on map iteration order", i, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A deep chain must not overflow the stack. An author supplies this graph.
|
||||
func TestFindSubagentCycleHandlesADeepChain(t *testing.T) {
|
||||
graph := map[string][]string{}
|
||||
const n = 5000
|
||||
for i := 0; i < n; i++ {
|
||||
graph[itoa(i)] = []string{itoa(i + 1)}
|
||||
}
|
||||
graph[itoa(n)] = nil
|
||||
if got := definition.FindSubagentCycle(graph); got != "" {
|
||||
t.Errorf("reported a cycle %q in a %d-long chain", got, n)
|
||||
}
|
||||
// And the same chain closed into a loop is found.
|
||||
graph[itoa(n)] = []string{itoa(0)}
|
||||
if definition.FindSubagentCycle(graph) == "" {
|
||||
t.Error("missed a cycle closing a long chain")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for i > 0 {
|
||||
b = append([]byte{byte('0' + i%10)}, b...)
|
||||
i /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user