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
8 changes: 8 additions & 0 deletions docs/review-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ artifact directory. The file `dossier/final/repo-guidance.md` records:
- the trust-boundary note that PR-head guidance changes do not affect that run
- whether the base branch guidance source was available, missing, unreadable,
or invalid
- any declared agent that could not be honored and was skipped

This file is intended for reviewers and operators who need to understand which
repo guidance influenced a review without reading pipeline code.
Expand All @@ -55,6 +56,13 @@ profile or flag agents. Unreadable or invalid repo guidance remains blocking
because it indicates that maintainers attempted to declare authoritative
review behavior that could not be honored.

A source can also load *partially*. A malformed agent or category — a bad field,
a missing `index.yaml` or `prompt.md` — disqualifies only itself; its siblings
still load and the source reports as available. A skipped agent is warned, not
blocking, and each skip is named in the dossier under "Guidance not honoured",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc quotes the dossier skip-line label as "Guidance not honoured" (British spelling), but internal/dossier/dossier.go writes the literal string "Guidance not honored: " (American spelling, and with a trailing colon the doc omits). Since this is one of the small set of files that map cr's on-disk artifacts to what an operator or agent should grep for, an exact-string mismatch here is exactly the kind of drift this doc exists to prevent — someone searching a dossier for the documented label won't find it. Fix: quote the string exactly as emitted, "Guidance not honored: ".

Reply inline to this comment.

because the declaration that could not be honored is scoped to that agent rather
than to the repo. A source where *nothing* loads is still invalid.

Without an explicit positive `--max-agents`, all applicable repo-local reviewers
and all matching `required_on_match` reviewers run, and the orchestrator may
select up to five optional shared reviewers. A positive `--max-agents` is a hard
Expand Down
132 changes: 118 additions & 14 deletions internal/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,27 @@ type SourceInfo struct {
Status SourceStatus `json:"status"`
Fingerprint string `json:"fingerprint,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Error string `json:"error,omitempty"`
// Skipped records definitions this source declared but could not honor, so a
// caller can branch on the degradation instead of parsing Warnings prose.
// A source with Skipped entries and Status available loaded only in part.
Skipped []SkippedDefinition `json:"skipped,omitempty"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Skipped slice field opts out of the copy convention this struct already has for its other slice field, so two existing "clone" sites silently stop being clones (U-G1: new public surface should carry the house pattern; U-D1: shared mutable state).

SourceInfo is copied in at least two places that deliberately deep-copy Warnings after a shallow struct copy:

  • internal/pipeline/artifacts.go:116artifact.Sources[i].Warnings = append([]string(nil), catalog.Sources[i].Warnings...)
  • internal/view/agents.go:257 (cloneSources) — same shape

Both carry Skipped only as an aliased slice header, so cloneSources no longer does what its name says and the pipeline artifact shares backing storage with the live catalog. It is harmless today because nothing mutates Skipped after load, but the defensive copy of Warnings exists precisely because relying on that is fragile, and a partial application of the convention is worse than none — the next reader cannot tell which fields are safe.

Fix: add out[i].Skipped = append([]SkippedDefinition(nil), sources[i].Skipped...) (and the artifacts.go equivalent) alongside the existing Warnings copies.

Reply inline to this comment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

U-G1: the new exported slice field extends a per-field deep-copy obligation that lives in consumers, and two of the four copy sites were not updated. Provenance.SourceInfo (line 150) and provenanceFromSource (line 906) now clone Skipped, but the two out-of-package sites that exist precisely to break aliasing still enumerate only Warnings: internal/pipeline/artifacts.go:110-116 (Sources: append([]agents.SourceInfo(nil), catalog.Sources...) then artifact.Sources[i].Warnings = append(...)) and internal/view/agents.go:249-258 (cloneSources). After a shallow struct copy those two carry a Skipped slice whose backing array is shared with the live catalog. Nothing appends to Skipped post-load today, so this is latent rather than a live bug — but the convention is now inconsistent, and a reader of cloneSources cannot tell that one slice field is deliberately shared.

Suggested fix: put the copy contract on the type instead of re-deriving it at each site — add func (s SourceInfo) Clone() SourceInfo (and, if kept, func (p Provenance) Clone() Provenance) in this file that copies both slice fields, and have artifacts.go and view/agents.go call it. Then a future slice field on SourceInfo is safe by construction rather than by four call sites remembering.

Reply inline to this comment.

Error string `json:"error,omitempty"`
}

// SkippedDefinition is one agent or category that was declared but not loaded.
// Agent is empty when the whole category was skipped.
type SkippedDefinition struct {
Category string `json:"category"`
Agent string `json:"agent,omitempty"`
Reason string `json:"reason"`
}

// String renders the entry for display surfaces that take prose.
func (s SkippedDefinition) String() string {
if s.Agent == "" {
return fmt.Sprintf("skipped category %q: %s", s.Category, s.Reason)
}
return fmt.Sprintf("skipped agent %s/%s: %s", s.Category, s.Agent, s.Reason)
}

// Provenance identifies the winning source for one loaded agent.
Expand All @@ -90,6 +110,9 @@ type Provenance struct {
CanonicalPath string `json:"canonical_path,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Warnings []string `json:"warnings,omitempty"`
// Skipped mirrors SourceInfo.Skipped so a SourceInfo round-tripped through
// Provenance keeps the definitions this source could not honor.
Skipped []SkippedDefinition `json:"skipped,omitempty"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

U-O1 (the premature-abstraction direction): Provenance.Skipped has no producer anywhere in the tree, so the round-trip it exists to preserve cannot currently occur. The only SourceInfo -> Provenance conversion is provenanceFromSource, called at line 390 (file sources, whose loader never skips), 840 and 884 (only to compute .String()); and repo agents are built with the provenance value constructed before loadRepoSource runs, so Provenance.Skipped is always empty. No test sets it either. It is state carried, copied in two places, and serialized, that is unreachable.

It is also worth deciding whether it should ever be populated: Provenance.SourceInfo() is invoked per agent (internal/pipeline/artifacts.go:128, internal/view/agents.go:72,97), so a populated Skipped would stamp the whole source-wide skip list onto every loaded agent's rendered/serialized Source — N copies of a fact that belongs to the source, not to any one agent that loaded fine.

Suggested fix: drop Provenance.Skipped and its two copy sites (lines 150, 906) and let skips live only on SourceInfo, where loadRepoSource actually writes them and internal/dossier actually reads them. If a real round-trip need appears later, reintroduce it together with the producer and a test.

Reply inline to this comment.

}

// String returns the user-facing provenance label.
Expand Down Expand Up @@ -124,6 +147,7 @@ func (p Provenance) SourceInfo() SourceInfo {
Status: SourceStatusAvailable,
Fingerprint: p.Fingerprint,
Warnings: append([]string(nil), p.Warnings...),
Skipped: append([]SkippedDefinition(nil), p.Skipped...),
}
return info
}
Expand Down Expand Up @@ -449,8 +473,19 @@ func readFileAgent(agentPath string, category Category, pathName string, provena
return newAgent(category, pathName, index, string(prompt), provenance), nil
}

func loadRepoSource(ctx context.Context, source RepoSource, provenance Provenance, allowSoftFailures bool) ([]Agent, SourceInfo, error) {
func loadRepoSource(ctx context.Context, source RepoSource, provenance Provenance, allowSoftFailures bool) (loaded []Agent, info SourceInfo, err error) {
repoSource := provenance.SourceInfo()
var skipped []SkippedDefinition
// Flush on every return, including ones added later that do not go through
// fail(). Dropping the skip list would reproduce, one layer down, the silent
// failure this loader exists to remove — so the invariant is enforced here
// rather than left to each exit remembering.
defer func() {
for _, entry := range skipped {
info.Skipped = append(info.Skipped, entry)
info.Warnings = append(info.Warnings, entry.String())
}
}()
fail := func(err error) ([]Agent, SourceInfo, error) {
if classified, ok := classifyRepoCatalogError(repoSource, err); allowSoftFailures && ok {
return nil, classified, nil
Expand Down Expand Up @@ -493,29 +528,77 @@ func loadRepoSource(ctx context.Context, source RepoSource, provenance Provenanc
continue
}
if err := validateName("category", categoryName); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'skip if ErrInvalid, else fail' shape is now hand-duplicated four times (category-name validation at ~526, readRepoCategory at ~536, and the two branches inside readRepoAgents' loop at ~636 and ~645), each re-typing the same errors.Is(err, ErrInvalid) check and SkippedDefinition construction. This is exactly the kind of copy-pasted branch that drifts silently: a future error class that should also be skip-safe (or a fix to the classification logic itself) is one missed call site away from being applied to only three of the four. Extract a small helper, e.g. skipIfInvalid(err error, def SkippedDefinition) (SkippedDefinition, bool, error) or a closure capturing category.Name, and call it at all four sites so the skip predicate has one place to change.

Reply inline to this comment.

if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: categoryName}); ok {
skipped = append(skipped, entry)
continue
}
return fail(err)
}
categoryPath := path.Join(repoAgentsRoot, categoryName)
category, err := readRepoCategory(ctx, source.Reader, source.Ref, baseSHA, categoryPath, categoryName)
if err != nil {
if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: categoryName}); ok {
skipped = append(skipped, entry)
continue
}
return fail(err)
}
categoryAgents, err := readRepoAgents(ctx, source.Reader, source.Ref, baseSHA, categoryPath, category, provenance)
categoryAgents, agentSkips, err := readRepoAgents(ctx, source.Reader, source.Ref, baseSHA, categoryPath, category, provenance)
skipped = append(skipped, agentSkips...)
if err != nil {
return fail(err)
}
if len(categoryAgents) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadRepoSource has three distinct category-level skip-and-continue branches: invalid category name (line 500-506), malformed category index.yaml (line 508-515), and a category that loads but ends up with zero usable agents (line 521-524, 'no usable agents'). Only the second is covered by a sibling-continuation test (TestRepoLoadSkipsMalformedCategoryAndKeepsSiblingCategories). The invalid-category-name and 'no usable agents' branches are only exercised in single-category tests where the whole source ends up SourceStatusInvalid (TestRepoLoadRejectsUnsafeTreeAndYAMLNames's 'unsafe category tree name' case, and TestRepoLoadInvalidWhenEveryAgentIsMalformed), so a regression that turned either of these two branches back into a whole-source failure when a good sibling category exists would pass CI unnoticed — the same class of silent breakage this PR exists to close. Add a case per branch with a healthy sibling category present, mirroring TestRepoLoadSkipsMalformedCategoryAndKeepsSiblingCategories's shape (e.g. one category with an unsafe tree name plus one valid category; one category whose only agent is malformed, alongside a valid category), asserting catalog.Agents still contains the sibling's agent and Sources[0].Warnings names the skipped category.

Reply inline to this comment.

return fail(fmt.Errorf("%w: repo source %s category %q contains no agents", ErrInvalid, repoAgentsRoot, categoryName))
// When the agents skipped themselves they already carry the actionable
// detail; a second entry for the same root cause makes the operator read
// the failure twice, once without it. A category that declared nothing
// is a different situation and says so.
if len(agentSkips) == 0 {
skipped = append(skipped, SkippedDefinition{Category: categoryName, Reason: "declares no agents"})
}
continue
}
loadedAgent = true
agents = append(agents, categoryAgents...)
}
// Only a source with nothing usable is treated as invalid. One malformed
// definition disabling every other agent in the repo is what this avoids:
// the failure is indistinguishable from a review that ran and found
// problems, so it can sit unnoticed for a long time.
if !loadedAgent {
return fail(fmt.Errorf("%w: repo source %s contains no agents", ErrInvalid, repoAgentsRoot))
return fail(fmt.Errorf("%w: repo source %s contains no usable agents", ErrInvalid, repoAgentsRoot))
}
return agents, repoSource, nil
}

// skipIfInvalid decides whether err disqualifies just this definition or the whole
// source. Kept in one place so a future skip-safe error class, or a fix to the
// classification itself, cannot reach three of the four call sites and miss one.
func skipIfInvalid(err error, def SkippedDefinition) (SkippedDefinition, bool) {
if !errors.Is(err, ErrInvalid) {
return SkippedDefinition{}, false
}
def.Reason = err.Error()
return def, true
}

// scopedToDefinition reclassifies a missing file *beneath* one agent or category
// directory as a malformed definition. Whether a failure disqualifies one agent or
// the whole source is a question of path scope, not error class: forgetting to commit
// prompt.md is as much a malformed agent as a bad field in index.yaml, and only the
// caller here knows the path is agent-scoped. Reader failures on the tree itself, and
// any non-NotFound transport error, are left alone and still fail the source.
func scopedToDefinition(err error, format string, args ...any) error {
if !errors.Is(err, gitprovider.ErrNotFound) {
return err
}
// Keep the provider error in the chain: these reasons reach the operator, and a
// NOT_FOUND raised for something other than an absent blob leaves nothing to
// diagnose with otherwise. Safe for classification — skipIfInvalid and
// classifyRepoCatalogError both test ErrInvalid first.
return fmt.Errorf("%w: %s: %w", ErrInvalid, fmt.Sprintf(format, args...), err)
}

func classifyRepoCatalogError(source SourceInfo, err error) (SourceInfo, bool) {
switch {
case errors.Is(err, ErrInvalid):
Expand All @@ -539,22 +622,34 @@ func repoSourceError(source SourceInfo, status SourceStatus, err error) SourceIn
func readRepoCategory(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, categoryPath, pathName string) (Category, error) {
var index categoryYAML
if err := decodeRepoYAML(ctx, reader, ref, gitRef, path.Join(categoryPath, "index.yaml"), &index); err != nil {
return Category{}, err
return Category{}, scopedToDefinition(err, "category %s is missing or unreadable index.yaml", pathName)
}
if err := validateMatchingName("category", pathName, index.Name); err != nil {
return Category{}, err
}
return Category{Name: pathName, Description: index.Description, Owner: index.Owner}, nil
}

func readRepoAgents(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, categoryPath string, category Category, provenance Provenance) ([]Agent, error) {
// readRepoAgents returns the agents it could load, plus a message per agent it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

U-L1: the two loaders that feed the same Catalog now have divergent failure semantics with no stated reason. readFileAgents (line 408) still returns on the first validateName/readFileAgent error, so a profile source with one malformed agent loses all of them — while a repo source skips and keeps its siblings. The new doc comment justifies skipping in terms that apply identically to both ("an unusable agent is never selected, so skipping it is as safe as refusing the whole source"), which reads as an oversight rather than a decision, and invites the next change to close the gap by copying the repo behavior into the filesystem path without thinking about it.

There is a good rationale available — repo sources are PR-adjacent content the operator does not own, profile sources are the operator's own configuration where failing loudly is correct — but it is nowhere in the diff. Either say that in the comment (naming the trust boundary, not just the safety argument), or mirror the behavior in readFileAgents. Given docs/review-guidance.md treats repo guidance as the authoritative-but-untrusted surface, stating the boundary is the cheaper of the two.

Reply inline to this comment.

// skipped. A malformed definition disqualifies that agent only — an unusable
// agent is never selected, so skipping it is as safe as refusing the whole
// source and leaves the rest of the repo's guidance working.
//
// This deliberately diverges from readFileAgents, which still fails on the first
// bad agent. The difference is the trust boundary, not an oversight: profile
// sources are the operator's own configuration, where failing loudly is what they
// want, while repo sources are PR-adjacent content the operator does not own and
// cannot fix in the run. One contributor's malformed agent should not silently
// disarm review for everyone else's PRs.
func readRepoAgents(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, categoryPath string, category Category, provenance Provenance) ([]Agent, []SkippedDefinition, error) {
entries, err := reader.ListTreeAtRef(ctx, ref, gitRef, categoryPath)
if err != nil {
return nil, err
return nil, nil, err
}
sortTreeEntries(entries)

var agents []Agent
var skipped []SkippedDefinition
for _, entry := range entries {
if entry.Type != "tree" {
continue
Expand All @@ -564,22 +659,30 @@ func readRepoAgents(ctx context.Context, reader RepoReader, ref gitprovider.PRRe
continue
}
if err := validateName("agent", agentName); err != nil {
return nil, err
if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: category.Name, Agent: agentName}); ok {
skipped = append(skipped, entry)
continue
}
return nil, skipped, err
}
agentPath := path.Join(categoryPath, agentName)
agent, err := readRepoAgent(ctx, reader, ref, gitRef, agentPath, category, agentName, provenance)
if err != nil {
return nil, err
if entry, ok := skipIfInvalid(err, SkippedDefinition{Category: category.Name, Agent: agentName}); ok {
skipped = append(skipped, entry)
continue
}
return nil, skipped, err
}
agents = append(agents, agent)
}
return agents, nil
return agents, skipped, nil
}

func readRepoAgent(ctx context.Context, reader RepoReader, ref gitprovider.PRRef, gitRef, agentPath string, category Category, pathName string, provenance Provenance) (Agent, error) {
var index agentYAML
if err := decodeRepoYAML(ctx, reader, ref, gitRef, path.Join(agentPath, "index.yaml"), &index); err != nil {
return Agent{}, err
return Agent{}, scopedToDefinition(err, "agent %s:%s is missing or unreadable index.yaml", category.Name, pathName)
}
if err := validateMatchingName("agent", pathName, index.Name); err != nil {
return Agent{}, err
Expand All @@ -589,7 +692,7 @@ func readRepoAgent(ctx context.Context, reader RepoReader, ref gitprovider.PRRef
}
prompt, err := reader.GetFileAtRef(ctx, ref, gitRef, path.Join(agentPath, "prompt.md"))
if err != nil {
return Agent{}, err
return Agent{}, scopedToDefinition(err, "agent %s:%s is missing prompt.md", category.Name, pathName)
}
return newAgent(category, pathName, index, string(prompt), provenance), nil
}
Expand Down Expand Up @@ -639,7 +742,7 @@ func validateAgentYAML(categoryName, agentName string, index agentYAML) error {
}
for _, pattern := range index.FileGlobs {
if _, err := glob.Compile(pattern, '/'); err != nil {
return fmt.Errorf("%w: agent %s:%s file_glob %q is invalid: %w", ErrInvalid, categoryName, agentName, pattern, err)
return fmt.Errorf("%w: agent %s:%s file_glob %q is invalid: %w", ErrInvalid, categoryName, agentName, pattern, err)
}
}
}
Expand Down Expand Up @@ -800,6 +903,7 @@ func provenanceFromSource(source SourceInfo) Provenance {
CanonicalPath: source.CanonicalPath,
Fingerprint: source.Fingerprint,
Warnings: append([]string(nil), source.Warnings...),
Skipped: append([]SkippedDefinition(nil), source.Skipped...),
}
}

Expand Down
Loading
Loading