Skip to content

Cache successful validate results in hook mode - #457

Open
schurchleycci wants to merge 7 commits into
mainfrom
validate-result-cache
Open

Cache successful validate results in hook mode#457
schurchleycci wants to merge 7 commits into
mainfrom
validate-result-cache

Conversation

@schurchleycci

@schurchleycci schurchleycci commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a generic FileCache[T] (internal/filecache) — one JSON file per entry, filename is sha256(key), silent miss on corrupt entries. Each write sweeps entries older than 7 days plus any staged file an interrupted run left behind, so the directory stays bounded
  • Adds CachedResult and BuildCacheKey to internal/validate, gitutil.Fingerprint for the working-tree half of the key, and internal/hashutil for the length-prefixed digest primitive both of those rest on
  • Replaces validate.HasGitChanges with that same fingerprint, so the Stop hook's existing clean-tree skip and the new cache read one pass over the tree instead of disagreeing about what changed
  • Wires the cache into chunk validate's Stop hook path: on a successful run the outcome is stored, and subsequent hook invocations with identical inputs skip execution and print a one-line "skipped" message

Motivation

In hook mode chunk validate fires on every Claude Code Stop turn. Re-running go test ./... (or an SSH exec to a sidecar) when nothing has changed since the last successful run is pure waste.

Invalidation is by content hash. The key is the command name plus a SHA-256 over:

  • .chunk/config.json whole — commands and the environment block that decides what they run against, so a project that gitignores .chunk/ still re-runs when either changes
  • the execution target — a result validated against one sidecar is never reused for another
  • the HEAD SHA
  • the porcelain status and the contents of every changed file — the status entries record deletions, renames and mode changes; hashing contents on top is what catches a second edit to an already-dirty file, which git status output alone reports identically

Failures are never cached, so the agent always retries after applying a fix.

Entries also expire 7 days after they were written, swept on the next write. That is not the invalidation mechanism — a stale key simply never matches again — it just stops a content-addressed directory from growing one file per Stop turn forever.

When the tree cannot be fingerprinted at all (a dirty submodule, an unreadable changed file, more than 64 MiB of changed files) no cache is consulted, and hook mode prints the reason — otherwise a repo that gets no benefit from this has no way to find out why.

Test plan

  • go test -race ./... and task acceptance-test pass
  • TestValidateHookCacheMissAfterEdit — second run on an untouched tree skips; editing a file puts the commands back on
  • TestValidateHookFailureIsNotCached — a failing command is never stored, so the next invocation re-runs rather than reporting a hit
  • Both verified by mutation: caching failures reddens the first, dropping the working-tree digest from the key reddens the second
  • TestFileCache_Put_Sweeps* — stale entries and orphaned staged files are collected; a staged file still being written, and any file the cache did not write, are left alone

🤖 Generated with Claude Code

Comment thread internal/filecache/filecache.go
Comment thread internal/validate/cache.go Outdated
Comment thread internal/cmd/validate.go
@schurchleycci
schurchleycci force-pushed the validate-result-cache branch from a09c0bc to c36a76d Compare July 30, 2026 17:24
@schurchleycci
schurchleycci marked this pull request as ready for review July 30, 2026 17:29
@danmux

danmux commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

needs rebase

schurchleycci and others added 5 commits August 6, 2026 11:12
…othing has changed

Adds a generic FileCache[T] (internal/filecache) and wires it into
chunk validate's Stop hook path. On a successful run the outcome is
stored keyed by a SHA-256 of the command config, HEAD commit, and git
status. Subsequent hook invocations with the same inputs skip execution
entirely and print a one-line "skipped" message instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The working-tree component of the cache key hashed `git status --porcelain`
output, which encodes paths and status codes but not content. That output is
byte-identical before and after a further edit to an already-dirty file (both
report " M path"), so the agent edit loop the Stop hook runs in could hit a
false cache hit: edit, validate, edit the same file again, and validate reports
"skipped (no changes since last successful run)" without checking the new code.

worktreeDigest now hashes the porcelain entries plus the current contents of
every changed path. Untracked files are listed individually with -uall so their
contents are hashed too, rather than collapsing an untracked directory into a
single "dir/" entry that hides edits inside it. Paths come from -z output and
are resolved against `rev-parse --show-toplevel`, since porcelain paths are
repo-root-relative while workDir may be a subdirectory.

BuildCacheKey now returns ok=false when git state cannot be established: no
repo, no commits yet, or a changed path that cannot be read. Previously gitOut
swallowed those errors and returned "", collapsing the key to config-only —
stable across code changes, and so another route to false hits. hookResultCache
disables caching entirely in that case.

Also narrows the commands parameter from `any` to []config.Command, and fixes a
pre-existing ARCHITECTURE.md claim that manual runs cache per-file; they don't
cache at all, and the unit is the whole run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Reset the hook failure counter on a cache hit, so a cached success and a
  real success behave identically. Without it a stale count survives into the
  next re-signal and brings the give-up message forward a turn.
- Fold the execution target (configured snapshot image, active sidecar ID)
  into the cache key. Sidecar routing depends on state outside the repo, so
  switching sidecars previously left the key unchanged and the second sidecar
  was never validated against. BuildCacheKey now takes a CacheKeyInputs struct.
- Make filecache.Put atomic via temp file + rename, and correct the type
  comment: entry filenames hash the key, not the value, so concurrent Puts
  wrote different bytes to one path and could tear.
- Cap the working-tree digest at 64 MiB and fail closed past it, so a large
  un-gitignored untracked tree can't be re-hashed on every hook invocation.
- Document that gitignored files and env vars do not invalidate the cache.

Also use gitutil.HeadRef instead of a third git rev-parse shell-out, hoist the
repo-root trim out of the digest loop, and note that CachedAt is informational.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove ResultCache single-impl interface, use *filecache.FileCache[CachedResult]
directly at call sites. Log Put errors to streams at dim level instead of
silently discarding them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@danmux danmux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Description mentions a ResultCache interface that isn't in the diff. Worth dropping from the body since there is only one implementation anyway.

Comment thread internal/cmd/validate.go

execErr := runValidate(ctx, circleCIClient, rc, workDir, name, opts.inlineCmd, opts.save, opts.sidecarID, freshlyCreated, opts.workdir, allRemote, envVars, cfg, statusFn, streams)

if execErr == nil && resultCache != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This condition is the whole "failures are never cached" guarantee, and nothing tests it. TestValidateHookCacheHitResetsAttempts only runs exit 0.

If this ever gets refactored so a failure is stored, every later hook invocation on the same tree prints skipped and returns nil, so the agent stops with the build broken and no test goes red. Can we get a sibling test with Run: "exit 1" asserting the second run does not skip? A miss-path case (edit a file between runs, assert re-execution) would cover the other unchecked box in the test plan.

// as needed. The entry is staged in a temporary file alongside its final path
// and renamed into place, which is atomic within a single directory. Entries
// have mode 0600, the mode os.CreateTemp applies and the rename preserves.
func (c FileCache[T]) Put(key string, v T) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded entries are never removed, so this is roughly one file per Stop turn, per project, indefinitely. CachedResult.CachedAt is written and, per its own comment, read by nothing.

Also, os.CreateTemp orphans a .tmp-* file if the process dies between staging and rename, which is what happens when a Stop hook is interrupted. Those aren't collected either.

Since CachedAt is already there, would a sweep on Put (drop entries older than N days, or keep the newest few) be cheap enough to avoid the manual rm -rf that HOOKS.md currently prescribes?

Comment thread internal/cmd/validate.go Outdated
// WrapHookResult does for a real one. Leaving a stale count behind
// would bring the "ask the user for guidance" bail-out forward by a
// turn. resultCache is only non-nil in hook mode, so hook is set.
validate.ResetAttempts(hook.sessionID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a second hook success path that has to stay in sync with finishValidate by hand. The comment is honest about it, but the next change to the success branch there (another counter, an event log write, a telemetry attribute) silently won't happen on a cache hit, and the symptom is drifting hook state rather than a failing test.

Would return finishValidate(cmd, hook, nil, start, opts.sidecarID, cfg, statusFn, streams) work here instead? Collapses the two paths and the duplicated reasoning goes with it.

Comment thread internal/validate/cache.go Outdated
if in.Worktree.Head == "" || in.Worktree.Digest == "" {
return "", false
}
cfgBytes, err := json.Marshal(in.Commands)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only Commands is hashed. execTarget rescues Validation.SidecarImage, but cfg.Environment is in the key nowhere, and survives today only because .chunk/config.json shows up in git status and gets content-hashed (or moves HEAD when committed).

In a project that gitignores .chunk/, editing the environment block to add an env var or package the commands need changes neither the fingerprint nor this hash, so the next invocation reports a hit and never re-runs against the new environment. The gitignore caveat in HOOKS.md covers files a command reads, not chunk's own config driving the run.

Any reason not to marshal the fields that determine the run, or cfg wholesale, given it is already loaded?

Comment thread internal/gitutil/fingerprint.go Outdated
defer func() { _ = f.Close() }()

info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One unreadable file, one non-regular changed path (a dirty submodule), or one changed file over the budget fails the whole fingerprint, which makes hookResultCache return nil for that repo on every invocation.

So a repo with a submodule gets no benefit from this PR and no way to find out why: no skipped line ever, no diagnostic, and the HOOKS.md remedy doesn't apply because the cache was never consulted. A dim stderr note when Fingerprint fails in hook mode would make it diagnosable. That wants an error return rather than a bool to say which condition hit, which reads better at the call site too.

Comment thread internal/gitutil/fingerprint.go Outdated

// writePart mixes s into h length-prefixed, so that concatenations of different
// parts cannot collide (["ab","c"] and ["a","bc"] hash differently).
func writePart(h io.Writer, s string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Byte-identical to writePart in internal/validate/cache.go, doc comment included. It is the length-prefixing primitive both collision-avoidance arguments rest on, so if one copy is ever strengthened the other stays as-is, both remain internally consistent, and all tests keep passing while the two digests quietly diverge.

Separately: validate.CacheKey is exported but its only caller is BuildCacheKey in the same package.

@schurchleycci
schurchleycci force-pushed the validate-result-cache branch from c36a76d to d033e3d Compare August 6, 2026 15:37
schurchleycci and others added 2 commits August 6, 2026 11:54
Tests for the two guarantees that had none. A failing run must never be
cached, and an edit between runs must miss — both verified by mutation
(caching failures reddens the first, dropping the working-tree digest from
the key reddens the second).

Hash the whole project config into the cache key, not just the commands.
The environment block decides what the commands run against, and a project
that gitignores .chunk/ got no invalidation from the working-tree digest
when it changed.

Fingerprint returns an error rather than a bool, and hook mode prints it.
A repo with a dirty submodule got no benefit from the cache and no way to
find out why: no "skipped" line ever, and no diagnostic.

FileCache.Put sweeps entries past MaxAge (7 days by default) and staged
files orphaned by an interrupted write, so the directory stays bounded
without the manual rm -rf HOOKS.md prescribed.

A cache hit now returns through finishValidate instead of repeating its
hook bookkeeping, and the length-prefixing digest primitive both
collision-avoidance arguments rest on lives in one place, internal/hashutil,
rather than in two byte-identical copies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The key covers all of .chunk/config.json and the execution target, not
just the commands, and entries now expire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants