Skip to content

Commit d04ccde

Browse files
committed
fix: follow source claude active version
1 parent 4a0d8f5 commit d04ccde

5 files changed

Lines changed: 197 additions & 14 deletions

File tree

docs/claude-binary-cache-dedup-plan.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ Default managed Claude launches should still use an agent-scoped private
101101
`HOME`, but the Claude executable/version cache should resolve outside that
102102
private home.
103103

104+
When the source user home has an active Claude Code binary under
105+
`~/.local/bin/claude` pointing into `~/.local/share/claude/versions/`, CCB should
106+
treat that active source-home version as the preferred managed Claude binary
107+
version. Managed startup may copy that version into the CCB provider cache, but
108+
the managed `.local/bin/claude` link should follow the source-home active
109+
version instead of selecting a different version only because it is already
110+
present or numerically newer in the shared cache.
111+
104112
Preferred target:
105113

106114
```text

lib/cli/services/provider_hooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def _route_claude_binary_cache_if_possible(*, layout, home_root: Path) -> None:
263263
cache_root = layout.ensure_provider_external_cache_dir('claude')
264264
except Exception:
265265
return
266-
route_claude_binary_cache(home_root, cache_root)
266+
route_claude_binary_cache(home_root, cache_root, source_home=current_provider_source_home())
267267

268268

269269
def _claude_versions_cache_signature(versions_dir: Path) -> dict[str, object] | None:

lib/provider_backends/claude/launcher_runtime/binary_cache.py

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@
99
_IGNORED_VERSION_ENTRIES = {'.DS_Store'}
1010

1111

12-
def route_claude_binary_cache(home_root: Path, shared_cache_root: Path) -> dict[str, object]:
12+
def route_claude_binary_cache(
13+
home_root: Path,
14+
shared_cache_root: Path,
15+
*,
16+
source_home: Path | None = None,
17+
) -> dict[str, object]:
1318
home = Path(home_root).expanduser().resolve(strict=False)
1419
shared_versions_dir = Path(shared_cache_root).expanduser().resolve(strict=False) / 'versions'
1520
versions_dir = home / '.local' / 'share' / 'claude' / 'versions'
21+
source_active_version = _source_active_version(source_home, managed_home=home)
22+
source_active_version_name = source_active_version.name if source_active_version is not None else ''
1623

1724
try:
1825
shared_versions_dir.mkdir(parents=True, exist_ok=True)
@@ -27,7 +34,18 @@ def route_claude_binary_cache(home_root: Path, shared_cache_root: Path) -> dict[
2734

2835
if versions_dir.is_symlink():
2936
if _same_path(versions_dir, shared_versions_dir):
30-
active_version_name = _ensure_latest_claude_link(home, shared_versions_dir)
37+
failure = _copy_source_active_version_to_shared(
38+
source_active_version,
39+
shared_versions_dir=shared_versions_dir,
40+
versions_dir=versions_dir,
41+
)
42+
if failure is not None:
43+
return failure
44+
active_version_name = _ensure_claude_link(
45+
home,
46+
shared_versions_dir,
47+
preferred_version_name=source_active_version_name,
48+
)
3149
write_projected_marker(
3250
versions_dir,
3351
label=_PROJECTION_LABEL,
@@ -75,16 +93,27 @@ def route_claude_binary_cache(home_root: Path, shared_cache_root: Path) -> dict[
7593
versions_dir=versions_dir,
7694
version_names=scan['version_names'],
7795
)
96+
if failure is not None:
97+
return failure
98+
failure = _copy_source_active_version_to_shared(
99+
source_active_version,
100+
shared_versions_dir=shared_versions_dir,
101+
versions_dir=versions_dir,
102+
)
78103
if failure is not None:
79104
return failure
80105
linked = _link_versions_dir(
81106
versions_dir,
82107
shared_versions_dir,
83108
reason='migrated_symlink' if scan['version_paths'] else 'linked_empty',
84-
version_names=scan['version_names'],
109+
version_names=_version_names(shared_versions_dir),
85110
)
86111
if linked.get('status') == 'ok':
87-
linked['active_version_name'] = _ensure_latest_claude_link(home, shared_versions_dir) or ''
112+
linked['active_version_name'] = _ensure_claude_link(
113+
home,
114+
shared_versions_dir,
115+
preferred_version_name=source_active_version_name,
116+
) or ''
88117
if scan['ignored_entries'] and linked.get('status') == 'ok':
89118
linked['warnings'] = tuple(scan['ignored_entries'])
90119
return linked
@@ -98,11 +127,26 @@ def route_claude_binary_cache(home_root: Path, shared_cache_root: Path) -> dict[
98127
)
99128

100129
if not versions_dir.exists():
101-
return _link_versions_dir(
130+
failure = _copy_source_active_version_to_shared(
131+
source_active_version,
132+
shared_versions_dir=shared_versions_dir,
133+
versions_dir=versions_dir,
134+
)
135+
if failure is not None:
136+
return failure
137+
linked = _link_versions_dir(
102138
versions_dir,
103139
shared_versions_dir,
104140
reason='linked_empty',
141+
version_names=_version_names(shared_versions_dir),
105142
)
143+
if linked.get('status') == 'ok':
144+
linked['active_version_name'] = _ensure_claude_link(
145+
home,
146+
shared_versions_dir,
147+
preferred_version_name=source_active_version_name,
148+
) or ''
149+
return linked
106150

107151
scan = _scan_versions_dir(versions_dir)
108152
if scan['unknown_entries']:
@@ -121,17 +165,28 @@ def route_claude_binary_cache(home_root: Path, shared_cache_root: Path) -> dict[
121165
versions_dir=versions_dir,
122166
version_names=scan['version_names'],
123167
)
168+
if failure is not None:
169+
return failure
170+
failure = _copy_source_active_version_to_shared(
171+
source_active_version,
172+
shared_versions_dir=shared_versions_dir,
173+
versions_dir=versions_dir,
174+
)
124175
if failure is not None:
125176
return failure
126177

127178
linked = _link_versions_dir(
128179
versions_dir,
129180
shared_versions_dir,
130181
reason='migrated' if scan['version_paths'] else 'linked_empty',
131-
version_names=scan['version_names'],
182+
version_names=_version_names(shared_versions_dir),
132183
)
133184
if linked.get('status') == 'ok':
134-
linked['active_version_name'] = _ensure_latest_claude_link(home, shared_versions_dir) or ''
185+
linked['active_version_name'] = _ensure_claude_link(
186+
home,
187+
shared_versions_dir,
188+
preferred_version_name=source_active_version_name,
189+
) or ''
135190
if scan['ignored_entries'] and linked.get('status') == 'ok':
136191
linked['warnings'] = tuple(scan['ignored_entries'])
137192
return linked
@@ -171,6 +226,22 @@ def _copy_versions_to_shared(
171226
return None
172227

173228

229+
def _copy_source_active_version_to_shared(
230+
source_active_version: Path | None,
231+
*,
232+
shared_versions_dir: Path,
233+
versions_dir: Path,
234+
) -> dict[str, object] | None:
235+
if source_active_version is None:
236+
return None
237+
return _copy_versions_to_shared(
238+
version_paths=(source_active_version,),
239+
shared_versions_dir=shared_versions_dir,
240+
versions_dir=versions_dir,
241+
version_names=(source_active_version.name,),
242+
)
243+
244+
174245
def _scan_versions_dir(versions_dir: Path) -> dict[str, object]:
175246
version_paths: list[Path] = []
176247
unknown_entries: list[str] = []
@@ -237,25 +308,34 @@ def _link_versions_dir(
237308
)
238309

239310

240-
def _ensure_latest_claude_link(home: Path, shared_versions_dir: Path) -> str:
241-
latest = _newest_version_path(shared_versions_dir)
242-
if latest is None:
311+
def _ensure_claude_link(home: Path, shared_versions_dir: Path, *, preferred_version_name: str = '') -> str:
312+
target_version = _preferred_or_newest_version_path(shared_versions_dir, preferred_version_name=preferred_version_name)
313+
if target_version is None:
243314
return ''
244-
executable = _version_executable_path(latest)
315+
executable = _version_executable_path(target_version)
245316
if executable is None:
246317
return ''
247318
link = home / '.local' / 'bin' / 'claude'
248319
try:
249320
if link.is_symlink() and _same_path(link, executable):
250-
return latest.name
321+
return target_version.name
251322
if link.exists() and not link.is_symlink():
252323
return ''
253324
link.parent.mkdir(parents=True, exist_ok=True)
254325
link.unlink(missing_ok=True)
255326
link.symlink_to(executable)
256327
except Exception:
257328
return ''
258-
return latest.name
329+
return target_version.name
330+
331+
332+
def _preferred_or_newest_version_path(versions_dir: Path, *, preferred_version_name: str) -> Path | None:
333+
preferred = str(preferred_version_name or '').strip()
334+
if preferred:
335+
candidate = versions_dir / preferred
336+
if _looks_like_claude_version_name(candidate.name) and _version_executable_path(candidate) is not None:
337+
return candidate
338+
return _newest_version_path(versions_dir)
259339

260340

261341
def _newest_version_path(versions_dir: Path) -> Path | None:
@@ -300,6 +380,33 @@ def _version_names(versions_dir: Path) -> tuple[str, ...]:
300380
return ()
301381

302382

383+
def _source_active_version(source_home: Path | None, *, managed_home: Path) -> Path | None:
384+
if source_home is None:
385+
return None
386+
try:
387+
home = Path(source_home).expanduser().resolve(strict=False)
388+
except Exception:
389+
return None
390+
if _same_path(home, managed_home):
391+
return None
392+
link = home / '.local' / 'bin' / 'claude'
393+
if not link.is_symlink():
394+
return None
395+
try:
396+
target = link.resolve(strict=True)
397+
versions_dir = (home / '.local' / 'share' / 'claude' / 'versions').resolve(strict=True)
398+
relative = target.relative_to(versions_dir)
399+
except Exception:
400+
return None
401+
if not relative.parts:
402+
return None
403+
version_name = relative.parts[0]
404+
if not _looks_like_claude_version_name(version_name):
405+
return None
406+
candidate = versions_dir / version_name
407+
return candidate if _version_executable_path(candidate) is not None else None
408+
409+
303410
def _same_path(left: Path, right: Path) -> bool:
304411
try:
305412
return left.resolve() == right.resolve()

test/test_provider_hook_settings.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,41 @@ def test_prepare_provider_workspace_routes_claude_binary_cache_to_external_cache
520520
assert all(event.get('event_type') != 'claude_binary_cache_drift' for event in events)
521521

522522

523+
def test_prepare_provider_workspace_routes_claude_binary_cache_from_home_active_version(
524+
tmp_path: Path,
525+
monkeypatch,
526+
) -> None:
527+
project_root = tmp_path / 'repo'
528+
workspace = project_root / 'workspace'
529+
system_home = tmp_path / 'system-home'
530+
active_binary = system_home / '.local' / 'share' / 'claude' / 'versions' / '2.1.141' / 'claude'
531+
active_binary.parent.mkdir(parents=True, exist_ok=True)
532+
active_binary.write_text('home active binary\n', encoding='utf-8')
533+
active_binary.chmod(0o755)
534+
(system_home / '.local' / 'bin').mkdir(parents=True, exist_ok=True)
535+
(system_home / '.local' / 'bin' / 'claude').symlink_to(active_binary)
536+
monkeypatch.setenv('HOME', str(system_home))
537+
monkeypatch.setenv('XDG_CACHE_HOME', str(tmp_path / 'xdg-cache'))
538+
layout = PathLayout(project_root)
539+
540+
prepare_provider_workspace(
541+
layout=layout,
542+
spec=_spec('agent1'),
543+
workspace_path=workspace,
544+
completion_dir=layout.agent_provider_runtime_dir('agent1', 'claude') / 'completion',
545+
agent_name='agent1',
546+
refresh_profile=True,
547+
)
548+
549+
managed_home = layout.agent_provider_state_dir('agent1', 'claude') / 'home'
550+
shared_active = layout.provider_external_cache_dir('claude') / 'versions' / '2.1.141'
551+
assert (managed_home / '.local' / 'share' / 'claude' / 'versions').resolve() == (
552+
layout.provider_external_cache_dir('claude') / 'versions'
553+
).resolve()
554+
assert (shared_active / 'claude').read_text(encoding='utf-8') == 'home active binary\n'
555+
assert (managed_home / '.local' / 'bin' / 'claude').resolve() == (shared_active / 'claude').resolve()
556+
557+
523558
def test_prepare_provider_workspace_keeps_unknown_claude_versions_dir_unmodified(
524559
tmp_path: Path,
525560
monkeypatch,

test/test_provider_profiles.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,39 @@ def test_route_claude_binary_cache_points_existing_shared_home_to_latest_version
10771077
assert (home / '.local' / 'bin' / 'claude').resolve() == new_binary.resolve()
10781078

10791079

1080+
def test_route_claude_binary_cache_prefers_source_home_active_version(tmp_path: Path) -> None:
1081+
home = tmp_path / 'home'
1082+
source_home = tmp_path / 'source-home'
1083+
shared_cache = tmp_path / 'shared-cache' / 'claude'
1084+
shared_versions = shared_cache / 'versions'
1085+
old_shared = shared_versions / '2.1.139'
1086+
newer_shared = shared_versions / '2.1.140'
1087+
source_active = source_home / '.local' / 'share' / 'claude' / 'versions' / '2.1.138'
1088+
old_shared.parent.mkdir(parents=True, exist_ok=True)
1089+
old_shared.write_text('old shared executable\n', encoding='utf-8')
1090+
newer_shared.write_text('newer shared executable\n', encoding='utf-8')
1091+
source_active.parent.mkdir(parents=True, exist_ok=True)
1092+
source_active.write_text('source active executable\n', encoding='utf-8')
1093+
source_active.chmod(0o755)
1094+
(source_home / '.local' / 'bin').mkdir(parents=True, exist_ok=True)
1095+
(source_home / '.local' / 'bin' / 'claude').symlink_to(source_active)
1096+
versions = home / '.local' / 'share' / 'claude' / 'versions'
1097+
versions.parent.mkdir(parents=True, exist_ok=True)
1098+
versions.symlink_to(shared_versions, target_is_directory=True)
1099+
(home / '.local' / 'bin').mkdir(parents=True, exist_ok=True)
1100+
(home / '.local' / 'bin' / 'claude').symlink_to(newer_shared)
1101+
1102+
result = route_claude_binary_cache(home, shared_cache, source_home=source_home)
1103+
1104+
copied_active = shared_versions / '2.1.138'
1105+
assert result['status'] == 'ok'
1106+
assert result['reason'] == 'already_shared'
1107+
assert result['active_version_name'] == '2.1.138'
1108+
assert copied_active.read_text(encoding='utf-8') == 'source active executable\n'
1109+
assert copied_active.stat().st_mode & 0o111
1110+
assert (home / '.local' / 'bin' / 'claude').resolve() == copied_active.resolve()
1111+
1112+
10801113
def test_materialize_claude_home_config_projects_system_settings_into_managed_home(tmp_path: Path) -> None:
10811114
source_home = tmp_path / 'system-home'
10821115
target_home = tmp_path / 'managed-home'

0 commit comments

Comments
 (0)