fix(bottle): identity-key all per-bottle resources by (agent, cwd)
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 1m30s

The single point that computed `slug = slugify(agent_name)` in
prepare.py is now `slug = bottle_identity(agent_name, cwd)`. With
--cwd the identity has a sha256(resolved-cwd)[:12] suffix, so the
same agent against different projects gets distinct container
names, network names, queue dir, audit log paths, and per-bottle
state (Dockerfile + transcript). Without --cwd the identity is
just slugify(agent_name), unchanged from before — no-cwd bottles
look the same as today.

The downstream `slug` field on DockerBottlePlan keeps its name —
every module already threads it under "slug" and the value flowing
through is now the bottle's full identity. A comment in prepare.py
flags the change.

Fixes the bug surfaced in PR #22 review: running the same agent
against project-A's cwd then project-B's would silently share
project-A's per-bottle Dockerfile + transcript snapshot, container
name (forcing serialized runs), and queue/audit history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 05:46:26 -04:00
parent ac8f14ae6f
commit e996f72532
3 changed files with 99 additions and 15 deletions
+45 -1
View File
@@ -1,5 +1,6 @@
"""Unit: per-bottle state helpers (PRD 0016 Phase 1)."""
"""Unit: per-bottle state helpers (PRD 0016 Phase 1) + identity."""
import re
import tempfile
import unittest
from pathlib import Path
@@ -68,5 +69,48 @@ class TestPerBottleDockerfile(_FakeHomeMixin, unittest.TestCase):
self.assertTrue(str(path).endswith("/.claude-bottle/state/dev/transcript"))
class TestBottleIdentity(unittest.TestCase):
"""bottle_identity(agent_name, cwd) — PRD 0016 follow-up.
Without --cwd, identity == slugify(agent_name) so existing
no-cwd bottles look unchanged. With --cwd, identity has a
cwd-hash suffix so the same agent against different projects
gets distinct container / queue / audit / state dirs."""
def test_no_cwd_returns_slug(self):
self.assertEqual("dev", bottle_state.bottle_identity("dev", None))
self.assertEqual("api-foo", bottle_state.bottle_identity("Api Foo", None))
def test_cwd_appends_hash_suffix(self):
identity = bottle_state.bottle_identity("dev", Path("/proj/A"))
self.assertTrue(identity.startswith("dev-"))
suffix = identity[len("dev-"):]
self.assertEqual(12, len(suffix))
self.assertTrue(re.fullmatch(r"[0-9a-f]+", suffix), suffix)
def test_same_cwd_same_identity(self):
a = bottle_state.bottle_identity("dev", Path("/proj/A"))
b = bottle_state.bottle_identity("dev", Path("/proj/A"))
self.assertEqual(a, b)
def test_different_cwds_differ(self):
a = bottle_state.bottle_identity("dev", Path("/proj/A"))
b = bottle_state.bottle_identity("dev", Path("/proj/B"))
self.assertNotEqual(a, b)
def test_different_agents_same_cwd_differ(self):
a = bottle_state.bottle_identity("dev", Path("/proj/A"))
b = bottle_state.bottle_identity("api", Path("/proj/A"))
self.assertNotEqual(a, b)
def test_agent_name_slugified(self):
# Identity's agent-name prefix is slugify(name), not the raw
# name — same rule the rest of the codebase has always used.
self.assertEqual(
"my-agent",
bottle_state.bottle_identity("My Agent", None),
)
if __name__ == "__main__":
unittest.main()