Skip to content

Commit 7dc108f

Browse files
dastinclaude
andcommitted
fix(cache): correctness fixes — live skills dir, timestamp, workspace cleanup
Three cache-correctness bugs that caused stale or wrong results: 1. **Tree-hash didn't include the LIVE skills directory.** When workspace ≠ project source root, the cache key hashed the workspace's .claude/skills/ (which has only stub .disabled files) instead of the project's. Result: same cache key across iterations even as evolved skills changed → cache returned responses generated under DIFFERENT skill conditions → mid-gate produced bogus "fixed=0, regressed=0, same=N" artifacts. Adds `live_skills_dir` and `project_source_root` to CacheConfig and threads them through. 2. **`program.yaml` embedded a wall-clock timestamp.** Per-run `created_at` field made every fresh run produce a different yaml blob, busting the run-cache key on every `--fresh`. Drop `created_at` from base_metadata in options_to_config + ProgramConfig.child. (Same fix as PR sentient-agi#32, intentionally duplicated here so this bundle stands alone if sentient-agi#32 is merged separately.) 3. **Workspace branches left over from GATE-rejected mutations.** When mid-gate or val GATE discarded an iter, the workspace's program/iter-skill-N branch remained, crashing the next iter's `git checkout -b program/iter-skill-N` with "branch already exists". Adds defensive cleanup in ProgramManager._git_checkout_new plus skill-tree snapshot/restore so historical iters reproduce. Files: src/cache/run_cache.py, src/registry/{models,sdk_utils,manager}.py Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a9e6d1d commit 7dc108f

4 files changed

Lines changed: 373 additions & 44 deletions

File tree

src/cache/run_cache.py

Lines changed: 99 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
44
Cache invalidates automatically when behavior-affecting files change:
55
- .claude/skills/** (skill definitions)
6-
- src/agent_profiles/base_agent/prompt.txt (prompt text)
6+
- src/agent_profiles/base_agent/prompt.txt (legacy fallback) and
7+
src/agent_profiles/officeqa_agent/prompt.md (prompt text)
78
89
Excludes metadata files like .claude/program.yaml to avoid unnecessary
910
cache invalidation when only scores or timestamps change.
@@ -34,6 +35,22 @@ class CacheConfig(BaseModel):
3435
store_messages: bool = False # Whether to cache the full messages list
3536
hash_length: int = 12 # Length of hash prefix for filenames
3637
cwd: Path = Path(".") # Working directory for git commands
38+
# Path to the LIVE `.claude/skills/` the agent reads from at run time.
39+
# When workspace ≠ project source root (typical with `--workspace`),
40+
# the workspace's `.claude/skills/` only contains stub snapshot files
41+
# — the real skills evolve in the project's `.claude/skills/`. Hashing
42+
# the workspace dir alone produces a stable cache key even as the
43+
# actual loaded skills change between iterations, returning stale
44+
# responses generated under different skill conditions. Pointing here
45+
# at the live dir fixes that. Defaults to `<cwd>/.claude/skills` for
46+
# backward compatibility (single-root setups where workspace == project).
47+
live_skills_dir: Path | None = None
48+
# Path to the project source root (parent of `src/agent_profiles/...`)
49+
# so the cache hashes the agent's actual prompt files. Without this,
50+
# `_get_tree_hash` looks for prompts at `<cwd>/src/agent_profiles/...`
51+
# which DOES NOT EXIST in a workspace setup → prompt edits go undetected.
52+
# Defaults to `cwd` for single-root backward compatibility.
53+
project_source_root: Path | None = None
3754

3855
class Config:
3956
arbitrary_types_allowed = True
@@ -86,35 +103,95 @@ def _get_tree_hash(self) -> str:
86103
"""
87104
Get combined hash of files that affect agent behavior.
88105
89-
Only hashes files that actually affect agent behavior:
90-
- .claude/skills/** - skill definitions
91-
- src/agent_profiles/base_agent/prompt.txt - prompt text
106+
Primary source of truth: the workspace's current git HEAD commit +
107+
the tree-hash of `.claude/skills/` on that commit. Since every
108+
program is a distinct branch with its own committed skill state,
109+
git already knows when things actually changed — using git's hashes
110+
sidesteps pathlib glob edge cases, stat-cache freshness, and
111+
uncommitted-working-tree drift.
92112
93-
Excludes metadata files like .claude/program.yaml which contain
94-
mutable fields (score, created_at) that don't affect behavior.
113+
Falls back to the prior content-hashing logic only when git queries
114+
fail (e.g. workspace isn't a git repo).
95115
96116
Returns:
97-
Combined hash of behavior-affecting files.
117+
Combined hash of behavior-affecting state.
98118
"""
99-
# Define paths that affect agent behavior
100-
# (directory, glob_pattern) tuples
101-
behavior_paths = [
102-
(".claude/skills", "**/*"), # All skill files
103-
("src/agent_profiles/base_agent", "prompt.txt"), # Prompt text
119+
# Three ingredients go into the hash:
120+
# (a) git HEAD — captures branch switches (each program = new branch).
121+
# (b) live file content of .claude/skills/ — captures uncommitted
122+
# working-tree edits (evolver writes before branch is created).
123+
# (c) prompt.txt content — captures prompt edits.
124+
# Mixing (a) and (b) means we detect BOTH "switched to a different
125+
# program branch" AND "wrote uncommitted skill changes" — either
126+
# forces a cache miss.
127+
git_token = self._git_behavior_token() or "no-git"
128+
129+
# Hash the LIVE skills directory (where the agent actually reads
130+
# skill files from). When the loop runs with --workspace, the
131+
# live dir is on the project side (e.g. <EvoSkill>/.claude/skills),
132+
# not the workspace side (which carries only stub snapshots). If
133+
# we hash the workspace dir, the cache key stays constant across
134+
# iterations even as skills change, masking the evolver's effect.
135+
skills_dir = self.config.live_skills_dir or (
136+
self.config.cwd / ".claude" / "skills"
137+
)
138+
skills_h = self._hash_files(skills_dir, "**/*") if skills_dir.exists() else ""
139+
140+
# Hash whichever agent prompts exist. Use the project source root
141+
# (where the actual `src/agent_profiles/...` tree lives), not cwd
142+
# — same workspace/project split issue as skills_dir above.
143+
source_root = self.config.project_source_root or self.config.cwd
144+
prompt_paths = [
145+
source_root / "src" / "agent_profiles" / "base_agent" / "prompt.txt",
146+
source_root / "src" / "agent_profiles" / "officeqa_agent" / "prompt.md",
104147
]
148+
hasher = hashlib.sha256()
149+
for prompt_path in prompt_paths:
150+
try:
151+
if prompt_path.is_file():
152+
with open(prompt_path, "rb") as f:
153+
hasher.update(prompt_path.name.encode("utf-8"))
154+
hasher.update(f.read())
155+
except (IOError, OSError):
156+
pass
157+
prompt_h = hasher.hexdigest()
105158

106-
content_hashes = []
107-
for base_dir, pattern in behavior_paths:
108-
dir_path = self.config.cwd / base_dir
109-
if dir_path.exists():
110-
content_hashes.append(self._hash_files(dir_path, pattern))
111-
else:
112-
content_hashes.append("")
113-
114-
# Combine hashes into a single hash
115-
combined = ":".join(content_hashes)
159+
combined = f"git:{git_token}|skills:{skills_h}|prompt:{prompt_h}"
116160
return hashlib.sha256(combined.encode("utf-8")).hexdigest()
117161

162+
def _git_behavior_token(self) -> str | None:
163+
"""Return a git-derived token for the workspace's current skill state.
164+
165+
Uses the **root tree SHA** (`git rev-parse HEAD^{tree}`) plus the
166+
skills tree SHA. Tree SHAs are content-addressed: identical content
167+
produces the same SHA regardless of when the commit was made. Using
168+
the commit SHA (HEAD) instead would break cache reuse across
169+
`--fresh` runs because each run creates a new "Create program: base"
170+
commit with a fresh timestamp → different commit SHA, identical
171+
content. Tree SHAs sidestep this by hashing the snapshot, not the
172+
commit metadata.
173+
174+
Returns None when git isn't available, the cwd isn't a repo, or
175+
`.claude/skills` isn't tracked.
176+
"""
177+
try:
178+
import subprocess
179+
root_tree = subprocess.check_output(
180+
["git", "rev-parse", "HEAD^{tree}"],
181+
cwd=self.config.cwd, text=True, stderr=subprocess.DEVNULL,
182+
).strip()
183+
try:
184+
skills_tree = subprocess.check_output(
185+
["git", "rev-parse", "HEAD:.claude/skills"],
186+
cwd=self.config.cwd, text=True, stderr=subprocess.DEVNULL,
187+
).strip()
188+
except subprocess.CalledProcessError:
189+
# Skills dir not tracked on this commit → use empty-tree SHA
190+
skills_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
191+
return f"{root_tree}:{skills_tree}"
192+
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
193+
return None
194+
118195
def _hash_files(self, dir_path: Path, pattern: str = "**/*") -> str:
119196
"""
120197
Compute content hash of files matching a pattern in a directory.

0 commit comments

Comments
 (0)