Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.

Commit e344097

Browse files
committed
fix: preserve tmux session compatibility
1 parent 6d63159 commit e344097

4 files changed

Lines changed: 47 additions & 14 deletions

File tree

skills/bmad-story-automator/data/crash-recovery.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ truth from story files and `sprint-status.yaml` before retrying.
3636
project_slug=$("$scripts" tmux-wrapper project-slug)
3737
PROJECT_HASH=$("$scripts" tmux-wrapper project-hash)
3838
timestamp=$(date +%y%m%d-%H%M%S)
39-
session_name="sa-${project_slug}-${PROJECT_HASH}-${timestamp}-e{epic}-s{story_suffix}-{step}-r2"
39+
session_name="sa-${project_slug}-${timestamp}-e{epic}-s{story_suffix}-{step}-r2"
4040

4141
# Clear stale state (project-scoped v2.0)
4242
rm -f "/tmp/.sa-${PROJECT_HASH}-session-${session_name}-state.json"

skills/bmad-story-automator/data/tmux-commands.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@
66

77
## Session Names
88

9-
**Pattern (v3.1 - HASH-SCOPED MULTI-PROJECT):** `sa-{project_slug}-{project_hash}-{YYMMDD}-{HHMMSS}-e{epic}-s{story}-{step}`
9+
**Pattern:** `sa-{project_slug}-{YYMMDD}-{HHMMSS}-e{epic}-s{story}-{step}`
1010

1111
**Examples:**
12-
- `sa-myproj-a1b2c3d4-260114-223045-e6-s64-dev` (Project "myproject", Epic 6, Story 6.4, dev step)
13-
- `sa-webapp-e5f6a7b8-260114-223512-e6-s64-review-r1` (Project "webapp", review cycle 1)
12+
- `sa-myproj-260114-223045-e6-s64-dev` (Project "myproject", Epic 6, Story 6.4, dev step)
13+
- `sa-webapp-260114-223512-e6-s64-review-r1` (Project "webapp", review cycle 1)
1414

1515
### Project Slug for Multi-Project Support
1616

17-
**Why project slug + hash (v3.1):**
17+
**Why project slug + artifact hash (v3.1):**
1818
- **Isolates sessions per project** - List only current project's sessions
1919
- **Prevents cross-project interference** - Won't kill another project's sessions
2020
- **Enables parallel orchestration** - Run story-automator on multiple projects simultaneously
21-
- **Avoids same-folder-name collisions** - Worktrees with the same basename still get different hashes
21+
- **Avoids same-folder-name collisions** - Runtime artifacts are scoped by project hash while public session names keep their legacy shape
2222

2323
**Generate project slug:**
2424
```bash
@@ -27,7 +27,7 @@ project_slug=$("$script" tmux-wrapper project-slug)
2727
project_hash=$("$script" tmux-wrapper project-hash)
2828
```
2929

30-
**Example:** Project at `/home/user/my-awesome-project``project_slug="myawesom"` plus a stable project hash.
30+
**Example:** Project at `/home/user/my-awesome-project``project_slug="myawesom"` plus a stable project hash for runtime artifacts.
3131

3232
**Why timestamps with seconds (v2.1):**
3333
- Prevents collisions when multiple sessions spawn in same minute
@@ -66,7 +66,7 @@ session_suffix=$(echo "{story_id}" | tr '.' '-')
6666
```
6767

6868
**WRONG:** `sa-epic6-s6.2-review-1` ← Will fail with "can't find pane" error
69-
**RIGHT:** `sa-myproj-a1b2c3d4-260114-223045-e6-s6-2-review-r1` ← Works correctly
69+
**RIGHT:** `sa-myproj-260114-223045-e6-s6-2-review-r1` ← Works correctly
7070

7171
---
7272

skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def resolve_command_shell() -> str:
7272
def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") -> str:
7373
stamp = time.strftime("%y%m%d-%H%M%S", time.localtime())
7474
suffix = story_id.replace(".", "-")
75-
name = f"sa-{project_slug()}-{project_hash()}-{stamp}-e{epic}-s{suffix}-{step}"
75+
name = f"sa-{project_slug()}-{stamp}-e{epic}-s{suffix}-{step}"
7676
if cycle:
7777
name += f"-r{cycle}"
7878
return name
@@ -141,11 +141,21 @@ def tmux_list_sessions(project_only: bool) -> tuple[list[str], int]:
141141
return ([], code)
142142
sessions = [line.strip() for line in output.splitlines() if line.strip().startswith("sa-")]
143143
if project_only:
144-
prefix = f"sa-{project_slug()}-{project_hash()}-"
145-
sessions = [line for line in sessions if line.startswith(prefix)]
144+
sessions = [line for line in sessions if _matches_current_project_session(line)]
146145
return (sessions, 0)
147146

148147

148+
def _matches_current_project_session(session: str) -> bool:
149+
hashed_prefix = f"sa-{project_slug()}-{project_hash()}-"
150+
if session.startswith(hashed_prefix):
151+
return True
152+
legacy_prefix = f"sa-{project_slug()}-"
153+
if not session.startswith(legacy_prefix):
154+
return False
155+
paths = session_paths(session)
156+
return any(path.exists() for path in (paths.state, paths.command, paths.runner, paths.output))
157+
158+
149159
def monitor_session_state_issue(session: str, project_root: str) -> object | None:
150160
return serialized_session_state_issue(session_paths(session, project_root).state)
151161

tests/test_cli_contracts.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ def test_name_cycle_uses_cycle_value_not_flag_token(self) -> None:
202202

203203
self.assertEqual(code, 0)
204204
session = stdout.getvalue().strip()
205-
self.assertIn(f"sa-{project_slug(str(self.root))}-{project_hash(str(self.root))}-", session)
205+
self.assertIn(f"sa-{project_slug(str(self.root))}-", session)
206+
self.assertNotIn(f"sa-{project_slug(str(self.root))}-{project_hash(str(self.root))}-", session)
206207
self.assertTrue(session.endswith("-review-r2"), session)
207208
self.assertNotIn("-r--cycle", session)
208209

@@ -239,6 +240,27 @@ def test_project_only_session_filter_uses_slug_and_hash(self) -> None:
239240
self.assertEqual(code, 0)
240241
self.assertEqual(sessions, [own])
241242

243+
def test_project_only_session_filter_keeps_current_project_legacy_sessions_with_artifacts(self) -> None:
244+
own = f"sa-{project_slug(str(self.root))}-{project_hash(str(self.root))}-260521-101010-e5-s5-3-review"
245+
legacy_own = f"sa-{project_slug(str(self.root))}-260521-101012-e5-s5-3-review"
246+
legacy_other = f"sa-{project_slug(str(self.root))}-260521-101013-e5-s5-4-review"
247+
legacy_state = Path(tempfile.gettempdir()) / f".sa-{project_hash(str(self.root))}-session-{legacy_own}-state.json"
248+
legacy_state.write_text("{}", encoding="utf-8")
249+
output = "\n".join([own, legacy_own, legacy_other])
250+
251+
try:
252+
with (
253+
mock.patch.dict(os.environ, {"PROJECT_ROOT": str(self.root)}),
254+
mock.patch("story_automator.core.tmux_runtime.command_exists", return_value=True),
255+
mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=(output, 0)),
256+
):
257+
sessions, code = tmux_list_sessions(project_only=True)
258+
finally:
259+
legacy_state.unlink(missing_ok=True)
260+
261+
self.assertEqual(code, 0)
262+
self.assertEqual(sessions, [own, legacy_own])
263+
242264
def test_kill_all_defaults_to_all_automator_sessions(self) -> None:
243265
with (
244266
mock.patch("story_automator.commands.tmux.tmux_list_sessions", return_value=(["sa-one"], 0)) as list_sessions,
@@ -273,11 +295,12 @@ def test_kill_all_all_projects_opt_in(self) -> None:
273295
self.assertEqual(code, 0)
274296
list_sessions.assert_called_once_with(False)
275297

276-
def test_generate_session_name_includes_project_hash(self) -> None:
298+
def test_generate_session_name_preserves_legacy_public_shape(self) -> None:
277299
with mock.patch.dict(os.environ, {"PROJECT_ROOT": str(self.root)}):
278300
session = generate_session_name("dev", "2", "2.4")
279301

280-
self.assertIn(f"sa-{project_slug(str(self.root))}-{project_hash(str(self.root))}-", session)
302+
self.assertIn(f"sa-{project_slug(str(self.root))}-", session)
303+
self.assertNotIn(f"sa-{project_slug(str(self.root))}-{project_hash(str(self.root))}-", session)
281304
self.assertTrue(session.endswith("-e2-s2-4-dev"), session)
282305

283306

0 commit comments

Comments
 (0)