Fleet Dependency Graph (design)
TL;DR. Derive a static agent-dependency graph from role notes: edge A→B when A's
write_patterns can intersect B's trigger/read scope (glob-overlap decided by a small exact
pattern-intersection algorithm over the doublestar subset we use). Build it as a fleet graph
subcommand in cmd/fleet — trip2g is the single source of truth (all role notes across every
fleet + the webhook registry with fleet:<id>: markers), so one command sees every fleet.
Output: a _fleet-graph.md note with a Mermaid diagram (trip2g renders it natively — "see the
graph" = open a note) + machine JSON (satisfies task #29 "Fleet graph + internal API for UI,
JSON, localhost-only"). The same graph doubles as the multi-fleet guardrail: nodes colored by
owning fleet; roles claimed by 0 or >1 fleets, cycles, self-triggers, orphan writes, and
desired-vs-registered drift are flagged. v1 = static note-graph over disjoint AgentsFolders;
v2 = fleet: frontmatter selector + liveness overlay + localhost /graph.json.
Status: design, not built. Grounded in internal/fleet/{role,discovery,reconcile}.go on
feat/agent-runtime and internal/webhookutil/patterns.go.
1. What the graph is
Nodes = roles (one per role note). Edges = "A's output can activate or feed B":
| Edge kind | Condition | Meaning |
|---|---|---|
| trigger (solid) | overlap(A.write_patterns, B.trigger_include) and B.trigger_on ∩ {create, update} ≠ ∅ and B is mode: change|both |
A's writes fire B's change-webhook |
| feed (dashed) | overlap(A.write_patterns, B.read_patterns) and no trigger edge A→B |
A writes notes B reads as context (incl. cron roles, which have no trigger edges — they are time-driven but still consume state) |
Everything comes from parsed Role fields (role.go): WritePatterns, TriggerInclude,
TriggerExclude, TriggerOn, ReadPatterns, Mode, ForEach, Concurrency, MaxDepth,
CronSchedule. No runtime data is required for v1 — the graph is note-derived, which is the
point: you can validate a fleet topology before any agent runs.
Facts the derivation relies on (from code)
- Delivery matching is
doublestar.Match(pattern, path)per changed path
(webhookutil.MatchesAny, used by the change-webhook pipeline). So "A can fire B" is exactly
"∃ path matching both A's write glob and B's include glob" — a glob-intersection question. - Writes an agent performs are creates/updates (
updateNotesvia the scoped lane). A role with
trigger_on: [remove]only gets a trigger edge from A if A can delete notes; v1 assumes
agents don't delete → no remove-edges. Revisit if a delete tool ships. trigger_excludenarrows B's scope. Deciding "the exclude fully subsumes the include∩write
intersection" is glob containment — more machinery than it is worth. v1 rule: if
overlap(A.write, B.trigger_exclude)also holds, keep the edge but annotate it
maybe-excluded(dotted label). Conservative: never hides a real edge.
2. Glob-overlap semantics (Overlap(p, q) bool)
Question: can two doublestar patterns match a common path? Requirements: sound toward true
(a spurious edge is a review nuisance; a missing edge hides a live trigger chain), exact for the
subset we actually use, cheap (patterns are tiny).
Supported subset (everything parseList can carry — note it splits on spaces/commas, so
patterns never contain spaces): literals, *, ?, [...] classes, ** segments, {a,b}
braces. Algorithm:
- Brace expansion.
expandBraces(p)→ list of brace-free patterns.
Overlap(P, Q)= any expanded pair overlaps. - Segment recursion. Split both on
/.rec(i, j), memoized on(i, j):- both exhausted →
true; p[i] == "**"→rec(i+1, j) || (j < len(q) && rec(i, j+1))(**matches zero or more
segments); symmetric forq[j];- one exhausted →
trueiff the other side has only**segments left; - otherwise →
segOverlap(p[i], q[j]) && rec(i+1, j+1).
- both exhausted →
- Within-segment intersection.
segOverlap(a, b), memoized on positions:- both empty →
true; ahead*→segOverlap(a[1:], b) || (b ≠ "" && segOverlap(a, b[1:])); symmetric;- otherwise compare heads as character sets (
?= universal,[...]= the class, literal =
singleton): sets intersect → recurse on both tails.
- both empty →
- Unknown construct (future doublestar syntax we don't model) → return
truewith a
diagnostic. Conservative by design.
This is a product-NFA emptiness check specialized to globs — exact for the subset, worst-case
exponential only in pathological patterns that never occur in role frontmatter (memoization
makes realistic inputs linear-ish).
Examples: Overlap("roles/*", "transcripts/**") = false (first segments roles vs
transcripts don't intersect). Overlap("wiki/**", "wiki/topics/*.md") = true.
Overlap("logs/*.md", "logs/run-[0-9]*.md") = true.
for_each fan-out
for_each (changed_files / attached_notes) does not change edge existence — the trigger
scope is the same webhook; fan-out multiplies runs per delivery, not reachability. In the
graph it is an amplification annotation: the node gets a fan-out badge, and every outgoing
edge from a fan-out node is labeled ×N (unbounded multiplier). This matters for cycle
severity (below): a cycle through a for_each: changed_files + allow_overlap node can
multiply writes each lap even though depth is bounded.
Cycle detection
Directed graph over trigger edges only (feed edges don't cause runs). Tarjan SCC:
- Self-trigger:
Overlap(A.write_patterns, A.trigger_include)— the most common footgun,
flagged even when depth would stop it. - Cycle: any SCC with >1 node, or a node with a self-loop.
- Severity vs the depth guard.
max_depthalready bounds every chain: a webhook is skipped
when incomingdepth >= max_depth, andpass_api_keymints the next token withdepth+1
(seechange_webhooks.md). So no cycle is infinite — but a cycle still burns budget up to the
depth ceiling, and fan-out amplifies within it. Report per cycle:
lap_budget = min(max_depth over members)(default 1, per reconcileMaxDepth), plus flags
forfor_eachmembers andallow_overlapmembers.lap_budget == 1and no fan-out → WARN (structurally a loop, practically fires once).lap_budget > 1, or any member hasfor_each+allow_overlap→ ERROR (real
token-burn risk; this is the "catch structure ahead of time" complement to the depth cap
called out in agent.md Phase 4).
3. Where it's built and how it's shown
Build from trip2g, ship as a fleet graph subcommand. trip2g is the only place that has
the complete picture: every role note of every fleet (they are all just notes) and the
webhook registry, where ownership is already encoded in reconcile markers
(fleet:<FleetID>:<notePath>#<specVer> and fleetcron:<FleetID>:..., reconcile.go). A fleet
daemon only knows its own AgentsFolder; the graph must not.
Not a third binary: cmd/fleet grows subcommand dispatch (fleet graph [flags]; bare
invocation stays the daemon for compatibility, --once/--dry-run keep working). The
subcommand:
- Reads the registry over the existing admin lane (
trip2ggql.ListChangeWebhooks/
ListCronWebhooks), parses markers →{fleetID, notePath, specVer, webhookID, url}. This
yields (a) the set of role-note paths that are actually live, (b) which fleet owns each, and
(c) each fleet's callback base (webhookurlminus/deliver/<key>), which is how the
liveness probe finds/healthwithout extra config. - Discovers role notes via
notePaths(filter: {like})+ meta, reusing
Discovery.DiscoverParsedgeneralized to take a list of folders. v1 folder list: the union
of--agents-folderflags plus folders inferred from registry marker paths (so the graph
sees fleets you didn't list). Parse withParseRole; runValidate-level checks but keep
invalid roles as flagged nodes (like--dry-rundoes) — a broken role is exactly what the
graph should show. - Derives nodes, edges, cycles, and conflicts (§2, §4–5).
- Emits:
_fleet-graph.md— a note written via the admin lane (updateNotes), containing a
Mermaidflowchart LR+ a findings table. trip2g renders Mermaid natively
(docs/dev/mermaid.md), so viewing the graph = opening a note in any browser, no UI work.--json <path|->— the machine graph (schema below).--probe— GET each fleet's derived<callback-base>/health(exists today,
cmd/fleet/main.go:93) and overlay liveness: fleet swimlane header getsup/down,
answering "who sits where, and is it running".
Mermaid conventions:
- node label: role name + trigger badge (
⚡change /⏰cron /⚡⏰both) +for_eachbadge; classDefper fleet (color = owning fleet), a distinct class for conflict (0 or >1
owners) and invalid (parse/validate error);- solid arrows = trigger edges, dashed = feed edges,
×Nlabel on fan-out edges,
maybe-excludedlabel wheretrigger_excludeoverlaps; - pure entry points (trigger scope nobody writes into — human-edited folders) get an implicit
humansource node so the flow reads left-to-right.
JSON schema (stable, for the future admin UI):
{
"generated_at": "...",
"fleets": [{"id": "krisp", "callback": "http://...", "health": "up|down|unknown"}],
"nodes": [{
"note_path": "roles/segmenter.md", "fleet_ids": ["krisp"],
"mode": "change", "for_each": "changed_files", "concurrency": "allow_overlap",
"max_depth": 1, "registered": true, "valid": true, "errors": []
}],
"edges": [{"from": "...", "to": "...", "kind": "trigger|feed",
"via": {"write": "wiki/**", "match": "wiki/topics/*"},
"fanout": true, "maybe_excluded": false}],
"findings": [{"severity": "error|warn|info", "kind": "cycle|conflict|orphan-write|drift|...",
"nodes": ["..."], "detail": "..."}]
}
Task #29 fit ("Fleet graph + internal API for UI, JSON, localhost-only"): the JSON emitter
is the internal API payload; v2 additionally serves it from the fleet daemon's existing mux as
GET /graph.json (same ListenAddr, which deployments already bind to localhost — no auth
added, no new port). The Mermaid note covers the "UI" half with zero frontend work; a real
admin view later consumes the same JSON.
4. Multi-fleet partition
Multiple fleets against one trip2g already work — ownership is the marker prefix, and
Reconcile never touches foreign markers. What is not guarded is role assignment:
- v1 rule — disjoint
AgentsFolders. Each fleet discoversLIKE folder%
(discovery.go:likePattern). Folders MUST be pairwise non-overlapping (no prefix of
another), or two fleets parse the same role note and register two webhooks for it =
duplicate execution. Nothing enforces this today; the graph does (§5). One fleet per role;
a role note outside every configured folder is simply not a role. - v2 rule — frontmatter selector.
fleet: krispin role frontmatter; discovery becomes
"notePaths in scope + meta filterfleet == cfg.FleetID" instead of pure path prefix. A role
then lives wherever it makes sense semantically (next to the data it tends), assigned by
meaning, not by folder. This rides the planned discovery-selector / lang-awarenotePaths
direction: the natural implementation is a meta-key filter on thenotePathsquery
(NotePathsFiltergainsmetaKey/metaValueor similar) so selection happens server-side,
not by fetching everything. Rules: exactly one fleet per role (fleet:is a scalar, not
a list); untagged = ignored (not picked up by any selector-mode fleet; a fleet may opt in
as--default-fleetto claim untagged roles under its folder for migration). v1 and v2
coexist: a fleet runs in prefix mode or selector mode; the graph reads both.
5. Conflict + validation via the graph
The graph is the "are two fleets running the same thing" guardrail. Findings, computed from the
same derivation pass:
| Finding | Rule | Severity |
|---|---|---|
| Ownership conflict | role note matched by >1 fleet (overlapping folders in v1; duplicate fleet: claims can't happen in v2, but folder-mode + selector-mode overlap can) — detected both statically (folder prefix overlap) and from the registry (same notePath in markers of two FleetIDs) |
ERROR |
| Unclaimed role | parseable role note owned by 0 fleets (folder not covered / untagged in selector mode) | WARN |
| Cycle / self-trigger | §2, with lap_budget + fan-out severity rule |
WARN/ERROR |
| Orphan write | some write_patterns entry overlaps no one's trigger_include or read_patterns |
INFO (often fine — logs, human-facing output; the note says so) |
| Dangling trigger | trigger_include no role writes into |
INFO, rendered as a human entry point, not an error |
| Drift | registry marker with no matching current role (stale webhook — fleet down or KeepWebhooksOnShutdown leftovers), or valid role with no marker (fleet hasn't reconciled it) |
WARN |
| Invalid role | ParseRole/Validate error (same checks as --dry-run) |
ERROR node, kept in the graph |
Coloring: fleet color per node; conflict nodes red regardless of fleet; drift-only markers
shown as ghost nodes.
6. Change surface + phasing
New code, all read-only against trip2g except the one _fleet-graph.md note write:
| Piece | Where | Notes |
|---|---|---|
| Glob intersection | internal/fleet/graph/overlap.go |
Overlap(p, q) bool per §2; table-driven tests are the bulk of the work |
| Derivation | internal/fleet/graph/derive.go |
roles + markers → nodes/edges/SCC/findings; pure functions, no IO — trivially testable |
| Emitters | internal/fleet/graph/mermaid.go, json.go |
golden-file tests |
| Registry + note IO | reuse trip2ggql (ListChangeWebhooks, ListCronWebhooks, DiscoverRoles) + one updateNotes call over the admin lane |
one new genqlient op at most |
| CLI | cmd/fleet: subcommand dispatch, fleet graph --agents-folder ... [--json -] [--note _fleet-graph.md] [--probe] |
bare invocation unchanged (daemon) |
v1 — static note-graph. Subcommand + overlap + derivation + Mermaid/JSON emitters; folder
list from flags ∪ registry markers; conflicts/cycles/drift findings; disjoint-folder rule
enforced as a finding. No server changes, no schema changes.
v2 — multi-fleet overlay + selector. fleet: frontmatter selector (needs the notePaths
meta filter — small server change, coordinate with the lang-aware notePaths work);
--default-fleet migration mode; --probe liveness overlay; GET /graph.json on the daemon
mux (localhost-only by deployment convention, closing task #29's "internal API for UI" half);
optionally a dynamic overlay from webhook_delivery_logs later (agent.md Phase 4's
static+dynamic split) — out of scope here.
Open questions for review: (a) does _fleet-graph.md live at vault root or under a fleet
folder (root keeps it fleet-agnostic; it must NOT match any role's trigger_include, or the
graph note itself triggers agents — the emitter should check its own path against the graph and
refuse/warn); (b) whether fleet graph runs on a cron webhook itself eventually (a role with
executor: code? cute, but v1 is a manually-run CLI); (c) v2 selector key name (fleet vs
fleet_id).