Compare definitions by meaning, and stop miscounting what was recorded
Some checks failed
CI / test (push) Has been cancelled
CI / fixture (push) Has been cancelled

Two problems found by deploying the previous commits to production.

FIRST: the rewrite guard compared raw Markdown, so it refused a publish over
formatting. The authoring UI re-serialises a definition when somebody saves it
— writing `webSearch: false` where the hand-authored file omitted the key, and
ordering the frontmatter its own way — and the parser reads absent and false
identically (agent.go: `data["webSearch"] == true`). A definition nobody
meaningfully changed stopped a deploy. Refusing a change that is not a change
is still a bug, even though it fails safe.

definition.SameAgent and SameSkill compare the parsed definition instead, and
are deliberately conservative, because the two ways of being wrong are not
equally bad. A false difference blocks a deploy: visible, recoverable. A false
SAMENESS lets a changed agent overwrite an approved version silently, which is
the thing versioning exists to prevent. So:

  - The body is compared verbatim. Agent.Body carries `json:"-"`, so a
    comparison that only marshalled the struct would call a completely
    rewritten system prompt "unchanged". There is a test that fails loudly on
    exactly that, because it is the mistake this design invites.
  - List ORDER stays significant. loader.go resolves Skills in order and that
    order reaches prompt assembly, so two definitions listing the same skills
    differently are still different. A deploy that only reorders still has to
    raise its version. That is a limit, recorded in a test rather than left to
    be discovered: loosening it needs somebody to decide skill order cannot
    matter, which is not a decision to bury in a comparison function.

What it absorbs is exactly what the round trip produces: frontmatter key order,
whitespace, and a defaulted value written out in full.

SECOND: importagents reported "9 agent version(s) recorded" on a run that
recorded nothing. The counter incremented on every successful Snapshot call,
and Snapshot returns nil for the idempotent no-op as well as for a real insert.
The skill counter was already honest; the agent one was not. snapshotAgent now
distinguishes recorded / conflict / already-present, and only the first counts.
Verified locally: 1 on the run that added activity-agent v2, 0 on the re-run,
where it previously said 9. A number that says nine every time is one nobody
checks on the day it matters.

Full suite green against PostgreSQL, only TestLive* skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
2026-08-29 11:02:22 +05:30
parent 5ab16b836a
commit a1e91f776d
5 changed files with 373 additions and 18 deletions

View File

@@ -210,23 +210,14 @@ func run(dir, skillDir, orgSlug string, dryRun bool, timeout time.Duration) erro
updated++
}
err = versions.Snapshot(ctx, ident, repo.SnapshotInput{
Kind: repo.KindAgent,
DefinitionID: s.parsed.ID,
Version: s.parsed.Version,
Markdown: s.raw,
Name: s.parsed.Name,
Description: s.parsed.Description,
Pages: s.parsed.Pages,
})
var apiErr *domain.Error
recorded, conflict, err := snapshotAgent(ctx, versions, ident, s)
switch {
case err == nil:
versioned++
case errors.As(err, &apiErr) && apiErr.Code == "conflict":
rewrites = append(rewrites, fmt.Sprintf(" %s: %s", s.name, apiErr.Message))
default:
case err != nil:
return fmt.Errorf("%s: record version: %w", s.name, err)
case conflict != "":
rewrites = append(rewrites, fmt.Sprintf(" %s: %s", s.name, conflict))
case recorded:
versioned++
}
}
@@ -466,7 +457,7 @@ func snapshotSkill(ctx context.Context, versions *repo.VersionsRepo,
}
if latest > 0 {
stored, err := versions.Load(ctx, ident, repo.KindSkill, sk.parsed.ID, latest)
if err == nil && stored != nil && stored.Markdown == sk.raw {
if err == nil && stored != nil && definition.SameSkill(stored.Markdown, sk.raw) {
return false, nil // unchanged since the last publish
}
}
@@ -484,3 +475,54 @@ func snapshotSkill(ctx context.Context, versions *repo.VersionsRepo,
}
return true, nil
}
// snapshotAgent records an agent version, or reports why it will not.
//
// Three outcomes, and the caller needs to tell them apart:
//
// - recorded: this version was not in the history and now is.
// - conflict: this version IS in the history and says something else. The
// caller collects these and fails the whole import.
// - neither: this version is already recorded and the spec still means the
// same thing. Nothing to do, and NOT counted as recorded — a re-run that
// writes nothing must not report that it wrote nine versions, or the
// number stops being worth reading.
//
// The comparison is definition.SameAgent rather than raw text, so a spec that
// has been through the authoring UI and come back re-serialised is recognised
// as the same definition instead of stopping a deploy.
func snapshotAgent(ctx context.Context, versions *repo.VersionsRepo,
ident authctx.Identity, s spec) (recorded bool, conflict string, err error) {
stored, err := versions.Load(ctx, ident, repo.KindAgent, s.parsed.ID, s.parsed.Version)
if err != nil {
var apiErr *domain.Error
if !errors.As(err, &apiErr) || apiErr.Code != "not_found" {
return false, "", err
}
stored = nil // nothing published at this number yet
}
if stored != nil {
if definition.SameAgent(stored.Markdown, s.raw) {
return false, "", nil
}
return false, fmt.Sprintf(
"version %d of %q is already published and says something different; "+
"raise the version to publish a change",
s.parsed.Version, s.parsed.ID), nil
}
if err := versions.Snapshot(ctx, ident, repo.SnapshotInput{
Kind: repo.KindAgent,
DefinitionID: s.parsed.ID,
Version: s.parsed.Version,
Markdown: s.raw,
Name: s.parsed.Name,
Description: s.parsed.Description,
Pages: s.parsed.Pages,
}); err != nil {
return false, "", err
}
return true, "", nil
}