From 8caa3d6b3003e9e86acc958025a8b5c7834a27ba Mon Sep 17 00:00:00 2001 From: Raedmund <30367709+Pinstack@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:09:51 +0100 Subject: [PATCH 1/8] fix(adapter): isolate parent lifecycle events Co-Authored-By: Raedmund <30367709+Pinstack@users.noreply.github.com> --- src/bmad_loop/adapters/generic.py | 62 ++++++++++++++ tests/test_generic_tmux.py | 137 +++++++++++++++++++++++++++++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 6fdda5195..f1b4c6bfa 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -637,6 +637,15 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi # wall clock stepped backward must not stretch the session). wall_deadline = time.time() + spec.timeout_s session_id: str | None = None + # The first identified SessionStart belongs to the CLI session this + # adapter launched. Nested CLIs can inherit the hook relay environment + # and write into the same task event stream, so their lifecycle events + # must never be allowed to replace this identity or score this session. + # Events without an ID deliberately retain the legacy compatibility + # path below: there is no reliable attribution signal to enforce. + outer_session_id: str | None = None + session_start_seen = False + expects_session_start = "SessionStart" in self.profile.hooks.events.values() transcript_path: str | None = None nudges_left = self._stop_nudges # Positive grace arms at launch for dev/review sessions, so a CLI that @@ -999,6 +1008,59 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi stop_seen=stop_seen, ) continue + # Bind task attribution only from the launched session's first + # identified SessionStart. Once bound, reject a differently + # identified event before it can change identity, transcript, + # completion, or retry-driving state. This is intentionally ahead + # of profile-specific subagent filtering too: the diagnostic should + # cover every foreign lifecycle event while exposing no transcript + # or prompt payload. + if event.event == "SessionStart": + session_start_seen = True + if event.session_id and outer_session_id is None: + outer_session_id = event.session_id + elif event.session_id and event.session_id != outer_session_id: + self._note_lifecycle( + handle.task_id, + "foreign-hook-event-ignored", + hook_event=event.event, + foreign_session_id=event.session_id, + ) + continue + elif ( + outer_session_id is not None + and event.session_id + and event.session_id != outer_session_id + ): + self._note_lifecycle( + handle.task_id, + "foreign-hook-event-ignored", + hook_event=event.event, + foreign_session_id=event.session_id, + ) + continue + elif ( + expects_session_start + and not session_start_seen + and event.event == "SessionEnd" + and event.session_id + ): + # A profile that declares SessionStart has not established even + # the beginning of its launched session yet. An identified + # session-death event on the shared task channel is therefore + # unattributable and must fail closed instead of crashing on a + # child. Stop remains on the compatibility path: several CLIs + # and established adapters can validly deliver it without an + # observed SessionStart, while SessionEnd is the crash signal + # that caused this regression. Stop-only profiles likewise + # cannot establish this proof and retain their existing path. + self._note_lifecycle( + handle.task_id, + "unattributed-hook-event-ignored", + hook_event=event.event, + foreign_session_id=event.session_id, + ) + continue if ( event.event == "Stop" and self.profile.subagent_stop_without_transcript diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 3022698aa..8aa33c9ff 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -659,10 +659,10 @@ def wait_for(self, task_id, kinds, timeout_s, since_ns=0): return self._events.pop(0) if self._events else None -def _stop_event(task_id, session_id, transcript_path): +def _hook_event(task_id, event, session_id=None, transcript_path=None): return HookEvent( ts=1, - event="Stop", + event=event, task_id=task_id, session_id=session_id, transcript_path=transcript_path, @@ -670,6 +670,10 @@ def _stop_event(task_id, session_id, transcript_path): ) +def _stop_event(task_id, session_id, transcript_path): + return _hook_event(task_id, "Stop", session_id, transcript_path) + + def _dev_handle(launched_ns=0) -> SessionHandle: return SessionHandle(task_id="3-1-dev-1", native_id="@1", launched_ns=launched_ns) @@ -1185,6 +1189,135 @@ def flush_terminal_spec(call_n): assert result.session_id == "main-sess" # the subagent's toolu_ id is never recorded +def test_wait_for_completion_ignores_foreign_identified_lifecycle_events(tmp_path): + """A nested CLI may inherit the outer task's hook relay, but must not be + allowed to overwrite its session identity or terminate its completion loop.""" + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + outer_id = "outer-session" + child_id = "nested-child" + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), + _hook_event("3-1-dev-1", "SessionStart", child_id, "/child.jsonl"), + _stop_event("3-1-dev-1", child_id, "/child.jsonl"), + _hook_event("3-1-dev-1", "SessionEnd", child_id, "/child.jsonl"), + _stop_event("3-1-dev-1", outer_id, "/outer.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id == outer_id + assert result.transcript_path == "/outer.jsonl" + assert result.stop_seen is True + ignored = [ + entry + for entry in _lifecycle_lines(adapter) + if entry["event"] == "foreign-hook-event-ignored" + ] + assert [entry["hook_event"] for entry in ignored] == ["SessionStart", "Stop", "SessionEnd"] + assert [entry["foreign_session_id"] for entry in ignored] == [child_id] * 3 + assert all( + set(entry) == {"ts", "event", "hook_event", "foreign_session_id"} for entry in ignored + ) + assert "/child.jsonl" not in json.dumps(ignored) + + +def test_wait_for_completion_ignores_identified_session_end_before_session_start(tmp_path): + """An identified SessionEnd cannot crash a SessionStart-capable profile + before the launched session has emitted its own start evidence.""" + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + outer_id = "outer-session" + child_id = "early-nested-child" + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("3-1-dev-1", "SessionEnd", child_id, "/child.jsonl"), + _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), + _stop_event("3-1-dev-1", outer_id, "/outer.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id == outer_id + assert result.transcript_path == "/outer.jsonl" + ignored = [ + entry + for entry in _lifecycle_lines(adapter) + if entry["event"] == "unattributed-hook-event-ignored" + ] + assert [entry["hook_event"] for entry in ignored] == ["SessionEnd"] + assert all( + set(entry) == {"ts", "event", "hook_event", "foreign_session_id"} for entry in ignored + ) + assert "/child.jsonl" not in json.dumps(ignored) + + +def test_wait_for_completion_keeps_identified_stop_for_stop_only_profile(tmp_path): + """Profiles without SessionStart cannot supply the attribution proof, so + their established identified-Stop completion behavior must remain intact.""" + adapter, impl = make_dev_adapter(tmp_path, profile_name="antigravity") + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + adapter.watcher = _ScriptedWatcher( + [_stop_event("3-1-dev-1", "stop-only-session", "/legacy.jsonl")] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id == "stop-only-session" + assert result.transcript_path == "/legacy.jsonl" + assert _lifecycle_lines(adapter) == [] + + +def test_wait_for_completion_keeps_matching_parent_session_end_crash(tmp_path): + adapter, _ = make_dev_adapter(tmp_path) + outer_id = "outer-session" + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), + _hook_event("3-1-dev-1", "SessionEnd", outer_id, "/outer.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "crashed" + assert result.session_id == outer_id + assert result.transcript_path == "/outer.jsonl" + assert _lifecycle_lines(adapter) == [] + + +def test_wait_for_completion_preserves_no_id_hook_compatibility(tmp_path): + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("3-1-dev-1", "SessionStart", transcript_path="/legacy.jsonl"), + _stop_event("3-1-dev-1", None, "/legacy.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id is None + assert result.transcript_path == "/legacy.jsonl" + assert _lifecycle_lines(adapter) == [] + + def test_wait_for_completion_transcriptless_stop_is_terminal_without_flag(tmp_path): """Gating: a profile without subagent_stop_without_transcript (claude) still treats every Stop as the main turn-end, so a result-less one stalls the dev From ace18c2b83338786389417f7556b7b395548cbb7 Mon Sep 17 00:00:00 2001 From: Raedmund <30367709+Pinstack@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:08:50 +0100 Subject: [PATCH 2/8] fix(adapter): retain attribution after anonymous starts --- src/bmad_loop/adapters/generic.py | 4 +++- tests/test_generic_tmux.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index f1b4c6bfa..d02136e70 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -1016,9 +1016,9 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi # cover every foreign lifecycle event while exposing no transcript # or prompt payload. if event.event == "SessionStart": - session_start_seen = True if event.session_id and outer_session_id is None: outer_session_id = event.session_id + session_start_seen = True elif event.session_id and event.session_id != outer_session_id: self._note_lifecycle( handle.task_id, @@ -1027,6 +1027,8 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi foreign_session_id=event.session_id, ) continue + elif event.session_id: + session_start_seen = True elif ( outer_session_id is not None and event.session_id diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 8aa33c9ff..e21e99c71 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -1261,6 +1261,41 @@ def test_wait_for_completion_ignores_identified_session_end_before_session_start assert "/child.jsonl" not in json.dumps(ignored) +def test_wait_for_completion_ignores_child_end_after_unidentified_session_start(tmp_path): + """An unidentified SessionStart cannot attribute a later identified end. + + The launched parent must remain live until its own identified start and stop + arrive, even when a nested child shares the hook relay in between. + """ + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + outer_id = "outer-session" + child_id = "early-nested-child" + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("3-1-dev-1", "SessionStart", transcript_path="/unknown.jsonl"), + _hook_event("3-1-dev-1", "SessionEnd", child_id, "/child.jsonl"), + _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), + _stop_event("3-1-dev-1", outer_id, "/outer.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id == outer_id + assert result.transcript_path == "/outer.jsonl" + ignored = [ + entry + for entry in _lifecycle_lines(adapter) + if entry["event"] == "unattributed-hook-event-ignored" + ] + assert [entry["hook_event"] for entry in ignored] == ["SessionEnd"] + assert [entry["foreign_session_id"] for entry in ignored] == [child_id] + + def test_wait_for_completion_keeps_identified_stop_for_stop_only_profile(tmp_path): """Profiles without SessionStart cannot supply the attribution proof, so their established identified-Stop completion behavior must remain intact.""" From 9d7e3c36e33cfeb6f5947fc752bdc168bd57ae3f Mon Sep 17 00:00:00 2001 From: t Date: Mon, 28 Sep 2026 11:44:17 -0700 Subject: [PATCH 3/8] fix(adapter): attribute hook events with a deny-list SessionAttribution helper Fixes the #767 merge blockers and replaces the inline allow-list with a pure helper in signals.py, the second layer after is_session_event: - an id is foreign only once it announces its own SessionStart after the launched session's first one (identified or anonymous); its events are dropped and journaled as foreign-hook-event-ignored - an identified SessionEnd before any SessionStart is admitted again, so the #727 trust-dialog exit crashes the session instead of hanging the loop (test_no_work_session_end_after_nudge_echo passes unchanged) - unannounced ids are admitted, so a rotated session id (/clear, compaction) still completes the session - tests: drop the PR's module-level _hook_event that main's later helper shadowed (36 TypeErrors); extend main's helper with keyword-only task_id/session_id/transcript_path instead; realign the PR's tests and add rotation, copilot toolu_ and attribution-table coverage --- src/bmad_loop/adapters/generic.py | 68 ++----------- src/bmad_loop/signals.py | 61 +++++++++++- tests/test_generic_tmux.py | 155 ++++++++++++++++++++---------- tests/test_signals.py | 90 ++++++++++++++++- 4 files changed, 260 insertions(+), 114 deletions(-) diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index aa23e4a23..33cf0b0d4 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -47,7 +47,7 @@ from ..mountpaths import rebased_project from ..policy import Policy from ..process_host import ProcessHostError, get_process_host -from ..signals import SignalWatcher +from ..signals import SessionAttribution, SignalWatcher from ..tokens import read_usage as tally_usage from ..verify import read_frontmatter, status_of from .base import ( @@ -908,15 +908,7 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi # wall clock stepped backward must not stretch the session). wall_deadline = time.time() + spec.timeout_s session_id: str | None = None - # The first identified SessionStart belongs to the CLI session this - # adapter launched. Nested CLIs can inherit the hook relay environment - # and write into the same task event stream, so their lifecycle events - # must never be allowed to replace this identity or score this session. - # Events without an ID deliberately retain the legacy compatibility - # path below: there is no reliable attribution signal to enforce. - outer_session_id: str | None = None - session_start_seen = False - expects_session_start = "SessionStart" in self.profile.hooks.events.values() + attribution = SessionAttribution() transcript_path: str | None = None nudges_left = self._stop_nudges # Positive grace arms at launch for dev/review sessions, so a CLI that @@ -1501,32 +1493,14 @@ def produced_work() -> bool: return dataclasses.replace(stalled, parked=True, parked_evidence=parked_now) return stalled continue - # Bind task attribution only from the launched session's first - # identified SessionStart. Once bound, reject a differently - # identified event before it can change identity, transcript, - # completion, or retry-driving state. This is intentionally ahead - # of profile-specific subagent filtering too: the diagnostic should - # cover every foreign lifecycle event while exposing no transcript - # or prompt payload. - if event.event == "SessionStart": - if event.session_id and outer_session_id is None: - outer_session_id = event.session_id - session_start_seen = True - elif event.session_id and event.session_id != outer_session_id: - self._note_lifecycle( - handle.task_id, - "foreign-hook-event-ignored", - hook_event=event.event, - foreign_session_id=event.session_id, - ) - continue - elif event.session_id: - session_start_seen = True - elif ( - outer_session_id is not None - and event.session_id - and event.session_id != outer_session_id - ): + # Drop a nested CLI's events before they can re-point the identity + # or transcript, set stop_seen, spend nudges or re-arm the stall + # timer. Deny-list (signals.SessionAttribution): only an id that + # announced its own SessionStart after the launched session's first + # one is foreign; unannounced ids and id-less events pass. Copilot + # toolu_ subagent Stops never announce, so they pass here and stay + # owned by the subagent filter below. + if not attribution.admit(event): self._note_lifecycle( handle.task_id, "foreign-hook-event-ignored", @@ -1534,28 +1508,6 @@ def produced_work() -> bool: foreign_session_id=event.session_id, ) continue - elif ( - expects_session_start - and not session_start_seen - and event.event == "SessionEnd" - and event.session_id - ): - # A profile that declares SessionStart has not established even - # the beginning of its launched session yet. An identified - # session-death event on the shared task channel is therefore - # unattributable and must fail closed instead of crashing on a - # child. Stop remains on the compatibility path: several CLIs - # and established adapters can validly deliver it without an - # observed SessionStart, while SessionEnd is the crash signal - # that caused this regression. Stop-only profiles likewise - # cannot establish this proof and retain their existing path. - self._note_lifecycle( - handle.task_id, - "unattributed-hook-event-ignored", - hook_event=event.event, - foreign_session_id=event.session_id, - ) - continue if ( event.event == "Stop" and self.profile.subagent_stop_without_transcript diff --git a/src/bmad_loop/signals.py b/src/bmad_loop/signals.py index e0a80ecbe..3cc42fd76 100644 --- a/src/bmad_loop/signals.py +++ b/src/bmad_loop/signals.py @@ -20,7 +20,7 @@ import json import time -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Callable @@ -76,10 +76,67 @@ def is_session_event(event: HookEvent, task_id: str, since_ns: int = 0) -> bool: the session's own id, which folds in the attempt number and generation (``engine._session_task_id``), so another attempt's events never match; ``since_ns`` is that attempt's launch floor, which drops a stale event a - resumed run left under the same re-minted id (see ``SignalWatcher.wait_for``).""" + resumed run left under the same re-minted id (see ``SignalWatcher.wait_for``). + + It is the first of two layers. The task id comes from the relay's inherited + environment, so a nested coding-CLI process started inside the session + stamps its own events with the same id; :class:`SessionAttribution` is the + second layer, deciding which CLI session inside the attempt's stream is the + launched one.""" return event.task_id == task_id and (not since_ns or event.ts >= since_ns) +@dataclass +class SessionAttribution: + """Second layer after :func:`is_session_event`: which CLI session inside one + attempt's event stream is the launched one (#767). + + A nested coding-CLI process started from inside the session inherits the + relay environment, so its SessionStart/Stop/SessionEnd land in the parent's + stream under the parent's task id. The rule is a deny-list: an id is foreign + only once it has announced its own SessionStart after the launched session's + first SessionStart. Every other event is admitted — id-less events, ids that + never announced a start (a rotated id, a Copilot ``toolu_`` subagent Stop), + and anything before the first start, including an identified SessionEnd from + a CLI that exited before its SessionStart fired (#727). Failing toward + acceptance keeps attribution a pure filter: it only ever drops a known + child's events and never adds a completion path. + + The first SessionStart is the launched session's whether identified or not: + an anonymous start (a payload the relay could not read) still uses up the + parent's slot, so a child's identified start after it is foreign. + + Accepted limitation: a child SessionEnd whose child never announced a + SessionStart is indistinguishable from the parent's own and is admitted. + Nested CLIs announce their start, so this is documented, not defended.""" + + started: bool = False # the launched session's first SessionStart was seen + bound_id: str | None = None # its id (None when that start was anonymous) + foreign_ids: set[str] = field(default_factory=set) + + def admit(self, event: HookEvent) -> bool: + """Whether ``event`` belongs to the launched session. Stateful: a + SessionStart can bind the session or mark its id foreign.""" + sid = event.session_id + if event.event == "SessionStart": + if not self.started: + self.started, self.bound_id = True, sid + return True + if not sid or sid == self.bound_id: + return True + self.foreign_ids.add(sid) + return False + return not (sid and sid in self.foreign_ids) + + +def attribute_events(events: list[HookEvent]) -> tuple[list[HookEvent], set[str]]: + """Replay :class:`SessionAttribution` over an oldest-first snapshot (e.g. + :func:`session_events`): the admitted events, and every id found foreign.""" + attribution = SessionAttribution() + admitted = [event for event in events if attribution.admit(event)] + return admitted, attribution.foreign_ids + + def session_events( events_dir: Path, legacy_dir: Path | None, task_id: str, since_ns: int = 0 ) -> list[HookEvent]: diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index c502681f6..d1578db77 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -799,10 +799,10 @@ def wait_for(self, task_id, kinds, timeout_s, since_ns=0): return self._events.pop(0) if self._events else None -def _hook_event(task_id, event, session_id=None, transcript_path=None): +def _stop_event(task_id, session_id, transcript_path): return HookEvent( ts=1, - event=event, + event="Stop", task_id=task_id, session_id=session_id, transcript_path=transcript_path, @@ -810,10 +810,6 @@ def _hook_event(task_id, event, session_id=None, transcript_path=None): ) -def _stop_event(task_id, session_id, transcript_path): - return _hook_event(task_id, "Stop", session_id, transcript_path) - - def _dev_handle(launched_ns=0) -> SessionHandle: return SessionHandle(task_id="3-1-dev-1", native_id="@1", launched_ns=launched_ns) @@ -1484,8 +1480,10 @@ def flush_terminal_spec(call_n): def test_wait_for_completion_ignores_foreign_identified_lifecycle_events(tmp_path): - """A nested CLI may inherit the outer task's hook relay, but must not be - allowed to overwrite its session identity or terminate its completion loop.""" + """A nested CLI inherits the relay environment and writes into the parent's + stream (#767). Its announced SessionStart marks its id foreign, so neither + its Stop nor its SessionEnd may re-point the identity or end the session: + only the parent's own Stop completes it.""" adapter, impl = make_dev_adapter(tmp_path) (impl / "spec-3-1-foo.md").write_text( "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" @@ -1494,10 +1492,10 @@ def test_wait_for_completion_ignores_foreign_identified_lifecycle_events(tmp_pat child_id = "nested-child" adapter.watcher = _ScriptedWatcher( [ - _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), - _hook_event("3-1-dev-1", "SessionStart", child_id, "/child.jsonl"), + _hook_event("SessionStart", session_id=outer_id, transcript_path="/outer.jsonl"), + _hook_event("SessionStart", session_id=child_id, transcript_path="/child.jsonl"), _stop_event("3-1-dev-1", child_id, "/child.jsonl"), - _hook_event("3-1-dev-1", "SessionEnd", child_id, "/child.jsonl"), + _hook_event("SessionEnd", session_id=child_id, transcript_path="/child.jsonl"), _stop_event("3-1-dev-1", outer_id, "/outer.jsonl"), ] ) @@ -1521,19 +1519,43 @@ def test_wait_for_completion_ignores_foreign_identified_lifecycle_events(tmp_pat assert "/child.jsonl" not in json.dumps(ignored) -def test_wait_for_completion_ignores_identified_session_end_before_session_start(tmp_path): - """An identified SessionEnd cannot crash a SessionStart-capable profile - before the launched session has emitted its own start evidence.""" +def test_wait_for_completion_accepts_identified_session_end_before_any_start(tmp_path): + """An identified SessionEnd that arrives before any SessionStart never + announced a foreign id, so it is the launched session's own exit — a CLI + that quit before its SessionStart hook fired (the #727 trust-dialog exit, + see test_no_work_session_end_after_nudge_echo). It must crash the session, + not be dropped (dropping it hung the loop forever: review blocker B2).""" + adapter, _ = make_dev_adapter(tmp_path) + adapter.watcher = _ScriptedWatcher( + [_hook_event("SessionEnd", session_id="outer-session", transcript_path="/outer.jsonl")] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "crashed" + assert result.session_id == "outer-session" + assert not any( + entry["event"] == "foreign-hook-event-ignored" for entry in _lifecycle_lines(adapter) + ) + + +def test_wait_for_completion_ignores_child_end_after_unidentified_session_start(tmp_path): + """An anonymous first SessionStart (a payload the relay could not read) + still uses up the launched session's slot, so a nested child's identified + start after it is foreign: the child's Stop and SessionEnd are dropped and + the parent's own identified Stop completes the session.""" adapter, impl = make_dev_adapter(tmp_path) (impl / "spec-3-1-foo.md").write_text( "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" ) outer_id = "outer-session" - child_id = "early-nested-child" + child_id = "nested-child" adapter.watcher = _ScriptedWatcher( [ - _hook_event("3-1-dev-1", "SessionEnd", child_id, "/child.jsonl"), - _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), + _hook_event("SessionStart", session_id=None), + _hook_event("SessionStart", session_id=child_id, transcript_path="/child.jsonl"), + _stop_event("3-1-dev-1", child_id, "/child.jsonl"), + _hook_event("SessionEnd", session_id=child_id, transcript_path="/child.jsonl"), _stop_event("3-1-dev-1", outer_id, "/outer.jsonl"), ] ) @@ -1546,53 +1568,74 @@ def test_wait_for_completion_ignores_identified_session_end_before_session_start ignored = [ entry for entry in _lifecycle_lines(adapter) - if entry["event"] == "unattributed-hook-event-ignored" + if entry["event"] == "foreign-hook-event-ignored" ] - assert [entry["hook_event"] for entry in ignored] == ["SessionEnd"] - assert all( - set(entry) == {"ts", "event", "hook_event", "foreign_session_id"} for entry in ignored - ) - assert "/child.jsonl" not in json.dumps(ignored) - + assert [entry["hook_event"] for entry in ignored] == ["SessionStart", "Stop", "SessionEnd"] + assert [entry["foreign_session_id"] for entry in ignored] == [child_id] * 3 -def test_wait_for_completion_ignores_child_end_after_unidentified_session_start(tmp_path): - """An unidentified SessionStart cannot attribute a later identified end. - The launched parent must remain live until its own identified start and stop - arrive, even when a nested child shares the hook relay in between. - """ +def test_wait_for_completion_accepts_rotated_session_id_stop(tmp_path): + """A session id can rotate without a new SessionStart (claude /clear or + compaction). The rotated id never announced itself, so its Stop is the + launched session's own turn-end and completes it (review finding M1: the + allow-list dropped it and the session stalled).""" adapter, impl = make_dev_adapter(tmp_path) (impl / "spec-3-1-foo.md").write_text( "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" ) - outer_id = "outer-session" - child_id = "early-nested-child" adapter.watcher = _ScriptedWatcher( [ - _hook_event("3-1-dev-1", "SessionStart", transcript_path="/unknown.jsonl"), - _hook_event("3-1-dev-1", "SessionEnd", child_id, "/child.jsonl"), - _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), - _stop_event("3-1-dev-1", outer_id, "/outer.jsonl"), + _hook_event("SessionStart", session_id="sess-a", transcript_path="/a.jsonl"), + _stop_event("3-1-dev-1", "sess-b", "/b.jsonl"), ] ) result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) assert result.status == "completed" - assert result.session_id == outer_id - assert result.transcript_path == "/outer.jsonl" - ignored = [ - entry - for entry in _lifecycle_lines(adapter) - if entry["event"] == "unattributed-hook-event-ignored" - ] - assert [entry["hook_event"] for entry in ignored] == ["SessionEnd"] - assert [entry["foreign_session_id"] for entry in ignored] == [child_id] + assert result.session_id == "sess-b" + assert result.transcript_path == "/b.jsonl" + assert not any( + entry["event"] == "foreign-hook-event-ignored" for entry in _lifecycle_lines(adapter) + ) + + +def test_wait_for_completion_copilot_bound_subagent_stop_not_crumbed(tmp_path): + """Once Copilot's sessionStart binds the main id, a subagent's toolu_ Stop + still passes attribution (the toolu_ id never announced a start) and is + dropped by the subagent_stop_without_transcript filter instead — so it is + never journaled as a foreign session, and the main Stop completes.""" + adapter, impl = make_dev_adapter(tmp_path, profile_name="copilot") + + def flush_terminal_spec(call_n): + # the spec lands only after the (ignored) subagent Stop, as in + # test_wait_for_completion_skips_transcriptless_subagent_stop + if call_n == 3: + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("SessionStart", session_id="main-sess", transcript_path=None), + _stop_event("3-1-dev-1", "toolu_bdrk_subagent", None), # subagent: ignored + _stop_event("3-1-dev-1", "main-sess", "/run/events.jsonl"), # main turn-end + ], + on_call=flush_terminal_spec, + ) + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id == "main-sess" + assert result.transcript_path == "/run/events.jsonl" + assert not any( + entry["event"] == "foreign-hook-event-ignored" for entry in _lifecycle_lines(adapter) + ) def test_wait_for_completion_keeps_identified_stop_for_stop_only_profile(tmp_path): - """Profiles without SessionStart cannot supply the attribution proof, so - their established identified-Stop completion behavior must remain intact.""" + """A Stop-only profile (no SessionStart) never binds, so attribution admits + every event and its established identified-Stop completion stays intact.""" adapter, impl = make_dev_adapter(tmp_path, profile_name="antigravity") (impl / "spec-3-1-foo.md").write_text( "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" @@ -1610,12 +1653,14 @@ def test_wait_for_completion_keeps_identified_stop_for_stop_only_profile(tmp_pat def test_wait_for_completion_keeps_matching_parent_session_end_crash(tmp_path): + """The launched session's own SessionEnd (same id as its bound start) is + admitted and still crashes the session.""" adapter, _ = make_dev_adapter(tmp_path) outer_id = "outer-session" adapter.watcher = _ScriptedWatcher( [ - _hook_event("3-1-dev-1", "SessionStart", outer_id, "/outer.jsonl"), - _hook_event("3-1-dev-1", "SessionEnd", outer_id, "/outer.jsonl"), + _hook_event("SessionStart", session_id=outer_id, transcript_path="/outer.jsonl"), + _hook_event("SessionEnd", session_id=outer_id, transcript_path="/outer.jsonl"), ] ) @@ -1628,13 +1673,15 @@ def test_wait_for_completion_keeps_matching_parent_session_end_crash(tmp_path): def test_wait_for_completion_preserves_no_id_hook_compatibility(tmp_path): + """Id-less events carry no attribution signal and are always admitted, so a + relay/CLI that sends no session id completes exactly as before.""" adapter, impl = make_dev_adapter(tmp_path) (impl / "spec-3-1-foo.md").write_text( "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" ) adapter.watcher = _ScriptedWatcher( [ - _hook_event("3-1-dev-1", "SessionStart", transcript_path="/legacy.jsonl"), + _hook_event("SessionStart", session_id=None, transcript_path="/legacy.jsonl"), _stop_event("3-1-dev-1", None, "/legacy.jsonl"), ] ) @@ -8891,13 +8938,15 @@ def append(self, kind, **fields): BYPASS_FOOTER = "Enter to confirm · Esc " + "to cancel" -def _hook_event(kind, notification_type=None): +def _hook_event( + kind, notification_type=None, *, task_id="3-1-dev-1", session_id="sess", transcript_path=None +): return HookEvent( ts=1, event=kind, - task_id="3-1-dev-1", - session_id="sess", - transcript_path=None, + task_id=task_id, + session_id=session_id, + transcript_path=transcript_path, path=Path("x"), notification_type=notification_type, ) diff --git a/tests/test_signals.py b/tests/test_signals.py index e9c2b9422..d0317e50d 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -1,8 +1,16 @@ import json +from pathlib import Path import pytest -from bmad_loop.signals import SignalWatcher, is_session_event, session_events +from bmad_loop.signals import ( + HookEvent, + SessionAttribution, + SignalWatcher, + attribute_events, + is_session_event, + session_events, +) def write_event(events_dir, ts, task_id, event, **extra): @@ -249,3 +257,83 @@ def test_is_session_event_is_the_rule_wait_for_matches_on(tmp_path): assert is_session_event(event, "t1", since_ns=7) assert not is_session_event(event, "t1", since_ns=8) assert not is_session_event(event, "t2") + + +def _event(kind, session_id=None, ts=1): + return HookEvent( + ts=ts, + event=kind, + task_id="t1", + session_id=session_id, + transcript_path=None, + path=Path("x"), + ) + + +@pytest.mark.parametrize( + ("sequence", "expected"), + [ + pytest.param( + [("SessionStart", "A"), ("Stop", "A")], [True, True], id="first-identified-start-binds" + ), + pytest.param( + [("SessionStart", "A"), ("SessionStart", "A"), ("Stop", "A")], + [True, True, True], + id="same-id-restart-admitted", + ), + pytest.param( + [ + ("SessionStart", "A"), + ("SessionStart", "B"), + ("Stop", "B"), + ("SessionEnd", "B"), + ("Stop", "A"), + ], + [True, False, False, False, True], + id="announced-child-is-foreign", + ), + pytest.param( + [("SessionStart", None), ("SessionStart", "B"), ("Stop", "B"), ("Stop", "A")], + [True, False, False, True], + id="anonymous-first-start-uses-the-parent-slot", + ), + pytest.param( + [("SessionStart", "A"), ("Stop", "B"), ("SessionEnd", "B")], + [True, True, True], + id="unannounced-rotated-id-admitted", # M1: /clear or compaction + ), + pytest.param( + [("SessionEnd", "A")], [True], id="identified-end-before-any-start-admitted" + ), # B2: the #727 trust-dialog exit + pytest.param( + [("SessionStart", "A"), ("SessionStart", None), ("Stop", None), ("SessionEnd", None)], + [True, True, True, True], + id="id-less-events-always-admitted", + ), + pytest.param( + [("SessionStart", "main"), ("Stop", "toolu_bdrk_x"), ("Stop", "main")], + [True, True, True], + id="never-announced-toolu-stop-admitted", # the copilot subagent filter owns it + ), + ], +) +def test_session_attribution_admits(sequence, expected): + """#767: the deny-list rule, event by event. Only an id that announced its + own SessionStart after the launched session's first one is dropped.""" + attribution = SessionAttribution() + assert [attribution.admit(_event(kind, sid)) for kind, sid in sequence] == expected + + +def test_attribute_events_returns_admitted_and_foreign_ids(): + """The replay form the post-mortem diagnostic uses: admitted events in + order, plus every id found foreign.""" + events = [ + _event("SessionStart", "A", ts=1), + _event("SessionStart", "B", ts=2), + _event("Stop", "B", ts=3), + _event("SessionStart", "C", ts=4), + _event("Stop", "A", ts=5), + ] + admitted, foreign = attribute_events(events) + assert [(e.event, e.session_id) for e in admitted] == [("SessionStart", "A"), ("Stop", "A")] + assert foreign == {"B", "C"} From eecac7da20bc36e07d5e2ec5bb47e86d204a472b Mon Sep 17 00:00:00 2001 From: t Date: Mon, 28 Sep 2026 11:50:10 -0700 Subject: [PATCH 4/8] fix(adapter): forward SessionStart source and rebind attribution on clear/compact Both relay twins now forward the SessionStart payload's `source` (string only), HookEvent carries it as an appended defaulted field, and SessionAttribution rebinds to a new id announced with source clear/compact instead of marking it foreign. "resume" and "startup" stay foreign: a nested child launched with --resume must not take over the launched session. An older vendored relay sends no source, so a clear/compact start with a new id still reads as foreign there; `bmad-loop init` re-vendors the relay. --- src/bmad_loop/data/bmad_loop_hook.py | 12 +++++ src/bmad_loop/events.py | 16 ++++++- src/bmad_loop/signals.py | 26 +++++++++++ tests/test_events.py | 43 +++++++++++++++++ tests/test_generic_tmux.py | 38 ++++++++++++++- tests/test_signals.py | 70 +++++++++++++++++++++++++++- 6 files changed, 200 insertions(+), 5 deletions(-) diff --git a/src/bmad_loop/data/bmad_loop_hook.py b/src/bmad_loop/data/bmad_loop_hook.py index 1dbd97ad8..a77677171 100644 --- a/src/bmad_loop/data/bmad_loop_hook.py +++ b/src/bmad_loop/data/bmad_loop_hook.py @@ -66,6 +66,14 @@ def _notification_type(payload): return value if isinstance(value, str) else None +def _source(payload): + # A SessionStart payload's `source` (#767): claude/gemini send + # startup|resume|clear|compact; codex/copilot send their own values. Only a + # string is forwarded, like the notification subtype above. + value = payload.get("source") + return value if isinstance(value, str) else None + + def _is_link_like(path): """True when `path` redirects elsewhere: a POSIX symlink, or a Windows symlink OR DIRECTORY JUNCTION. @@ -220,6 +228,10 @@ def main() -> int: # `notification_type` (e.g. "permission_prompt"); the profile maps it onto # a parked kind. Kept only when it is a string; absent everywhere else. "notification_type": _notification_type(payload), + # Why a SessionStart fired (#767): a "clear"/"compact" start with a new id + # is still the launched session, so attribution rebinds instead of + # reading it as a nested CLI. Kept only when it is a string. + "source": _source(payload), } # The orchestrator's own events dir when it named one, else the legacy # in-tree location this file's older selves are still installed at (see the diff --git a/src/bmad_loop/events.py b/src/bmad_loop/events.py index 78ab89aaa..13a5a5a03 100644 --- a/src/bmad_loop/events.py +++ b/src/bmad_loop/events.py @@ -6,8 +6,8 @@ module — and this module cannot import it back, since it ships as package DATA rather than as an importable module. Hence a twin rather than shared code: ``_LINK_REPARSE_TAGS``, ``_first_workspace``, ``_notification_type``, -``_is_link_like``, ``_write_all`` and ``_write_event`` below are byte-identical -copies of the hook's, pinned that way by +``_source``, ``_is_link_like``, ``_write_all`` and ``_write_event`` below are +byte-identical copies of the hook's, pinned that way by ``tests/test_events.py::test_the_twinned_source_is_identical`` — which AST-extracts both sides and compares the source segments, so a fix applied to one writer of the events control plane and not the other cannot pass review silently. @@ -64,6 +64,14 @@ def _notification_type(payload): return value if isinstance(value, str) else None +def _source(payload): + # A SessionStart payload's `source` (#767): claude/gemini send + # startup|resume|clear|compact; codex/copilot send their own values. Only a + # string is forwarded, like the notification subtype above. + value = payload.get("source") + return value if isinstance(value, str) else None + + def _is_link_like(path): """True when `path` redirects elsewhere: a POSIX symlink, or a Windows symlink OR DIRECTORY JUNCTION. @@ -218,6 +226,10 @@ def shape_event(ts: int, event_name: str, task_id: str, payload: dict[str, Any]) # `notification_type` (e.g. "permission_prompt"); the profile maps it onto # a parked kind. Kept only when it is a string; absent everywhere else. "notification_type": _notification_type(payload), + # Why a SessionStart fired (#767): a "clear"/"compact" start with a new id + # is still the launched session, so attribution rebinds instead of + # reading it as a nested CLI. Kept only when it is a string. + "source": _source(payload), } diff --git a/src/bmad_loop/signals.py b/src/bmad_loop/signals.py index 3cc42fd76..5a810dcc0 100644 --- a/src/bmad_loop/signals.py +++ b/src/bmad_loop/signals.py @@ -38,6 +38,11 @@ class HookEvent: # relay that predates the field, and for a non-string value. APPENDED with a # default so every positional construction stays valid. notification_type: str | None = None + # A SessionStart's `source` (#767) — claude/gemini's startup|resume|clear| + # compact, or another CLI's own value. None on every other event, on an older + # vendored relay that predates the field, and for a non-string value. + # APPENDED with a default, like notification_type above. + source: str | None = None def _event_dirs(events_dir: Path, legacy_dir: Path | None) -> list[Path]: @@ -60,6 +65,7 @@ def _parse_event(entry: Path) -> HookEvent | None: if not isinstance(data, dict) or "event" not in data or "task_id" not in data: return None notification_type = data.get("notification_type") + source = data.get("source") return HookEvent( ts=int(data.get("ts", 0)), event=str(data["event"]), @@ -68,6 +74,7 @@ def _parse_event(entry: Path) -> HookEvent | None: transcript_path=data.get("transcript_path"), path=entry, notification_type=notification_type if isinstance(notification_type, str) else None, + source=source if isinstance(source, str) else None, ) @@ -86,6 +93,11 @@ def is_session_event(event: HookEvent, task_id: str, since_ns: int = 0) -> bool: return event.task_id == task_id and (not since_ns or event.ts >= since_ns) +# SessionStart sources that keep the launched session's identity across an id +# change: claude/gemini rotate the session id on /clear and may on compaction. +REBIND_SOURCES = frozenset({"clear", "compact"}) + + @dataclass class SessionAttribution: """Second layer after :func:`is_session_event`: which CLI session inside one @@ -106,6 +118,16 @@ class SessionAttribution: an anonymous start (a payload the relay could not read) still uses up the parent's slot, so a child's identified start after it is foreign. + A later SessionStart with a new id whose ``source`` is in + :data:`REBIND_SOURCES` ("clear", "compact") is the launched session itself + rotating its id, so it rebinds rather than going foreign. Deliberately not + "resume" — a nested child launched with ``--resume`` must stay foreign, and a + bmad-loop resume is a new attempt with a fresh task id and so a fresh + attribution — and not "startup", which is exactly what a nested child sends. + An older vendored relay forwards no ``source``, so there a clear/compact start + with a new id reads as foreign and the session falls back to window death or + its timeout; ``bmad-loop init`` re-vendors the relay. + Accepted limitation: a child SessionEnd whose child never announced a SessionStart is indistinguishable from the parent's own and is admitted. Nested CLIs announce their start, so this is documented, not defended.""" @@ -124,6 +146,10 @@ def admit(self, event: HookEvent) -> bool: return True if not sid or sid == self.bound_id: return True + if event.source in REBIND_SOURCES: + self.bound_id = sid + self.foreign_ids.discard(sid) + return True self.foreign_ids.add(sid) return False return not (sid and sid in self.foreign_ids) diff --git a/tests/test_events.py b/tests/test_events.py index 53d813871..5f10c5a3d 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -36,6 +36,7 @@ "_LINK_REPARSE_TAGS", "_first_workspace", "_notification_type", + "_source", "_is_link_like", "_write_all", "_write_event", @@ -200,6 +201,48 @@ def test_both_relay_twins_forward_the_notification_type(tmp_path, monkeypatch, p assert from_relay["notification_type"] == expected +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"source": "clear"}, "clear"), + ({"source": "compact"}, "compact"), + # only a string is forwarded — attribution compares it against a str set + ({"source": 1}, None), + ({"source": ["clear"]}, None), + ({}, None), + ], +) +def test_both_relay_twins_forward_the_session_start_source( + tmp_path, monkeypatch, payload, expected +): + """#767: SessionStart's `source` reaches the event file from BOTH writers, + so attribution can rebind on clear/compact. Pinned behaviorally for the same + reason as the notification subtype: the hook shapes the event inline. + + Ablation: drop the `"source"` key from either writer's event dict and this + fails on that side's KeyError.""" + hook_run, relay_run = tmp_path / "hook", tmp_path / "relay" + proc = subprocess.run( + [sys.executable, str(HOOK), "SessionStart"], + input=json.dumps(payload), + env={ + "PATH": os.environ.get("PATH", ""), + **({"SYSTEMROOT": os.environ.get("SYSTEMROOT", "")} if os.name == "nt" else {}), + "BMAD_LOOP_RUN_DIR": str(hook_run), + "BMAD_LOOP_TASK_ID": "1-1-a-dev-1", + }, + capture_output=True, + text=True, + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + assert _relay("SessionStart", payload, monkeypatch, relay_run, task_id="1-1-a-dev-1") == 0 + from_hook = json.loads(next((hook_run / "events").glob("*.json")).read_text()) + from_relay = json.loads(next((relay_run / "events").glob("*.json")).read_text()) + assert from_hook["source"] == expected + assert from_relay["source"] == expected + + # -------------------------------------------------------- hardening (twinned) diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index d1578db77..f6e32c293 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -1600,6 +1600,35 @@ def test_wait_for_completion_accepts_rotated_session_id_stop(tmp_path): ) +def test_wait_for_completion_rebinds_on_clear_source_start(tmp_path): + """A /clear fires a fresh SessionStart with a new id and source "clear". That + is the launched session rotating its id, not a nested CLI, so attribution + rebinds to it: its Stop completes the session under the new id and nothing + is journaled as foreign (#767).""" + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("SessionStart", session_id="sess-a", transcript_path="/a.jsonl"), + _hook_event( + "SessionStart", session_id="sess-b", transcript_path="/b.jsonl", source="clear" + ), + _stop_event("3-1-dev-1", "sess-b", "/b.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id == "sess-b" + assert result.transcript_path == "/b.jsonl" + assert not any( + entry["event"] == "foreign-hook-event-ignored" for entry in _lifecycle_lines(adapter) + ) + + def test_wait_for_completion_copilot_bound_subagent_stop_not_crumbed(tmp_path): """Once Copilot's sessionStart binds the main id, a subagent's toolu_ Stop still passes attribution (the toolu_ id never announced a start) and is @@ -8939,7 +8968,13 @@ def append(self, kind, **fields): def _hook_event( - kind, notification_type=None, *, task_id="3-1-dev-1", session_id="sess", transcript_path=None + kind, + notification_type=None, + *, + task_id="3-1-dev-1", + session_id="sess", + transcript_path=None, + source=None, ): return HookEvent( ts=1, @@ -8949,6 +8984,7 @@ def _hook_event( transcript_path=transcript_path, path=Path("x"), notification_type=notification_type, + source=source, ) diff --git a/tests/test_signals.py b/tests/test_signals.py index d0317e50d..1e0169a14 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -36,6 +36,24 @@ def test_parse_event_reads_the_notification_type(tmp_path, extra, expected): assert event.notification_type == expected +@pytest.mark.parametrize( + ("extra", "expected"), + [ + ({"source": "clear"}, "clear"), + ({"source": 3}, None), # a non-string is dropped, not coerced + ({}, None), # an older vendored relay forwards no source at all + ], +) +def test_parse_event_reads_the_session_start_source(tmp_path, extra, expected): + """#767: the relay's forwarded SessionStart source lands on the HookEvent + (str only).""" + watcher = SignalWatcher(tmp_path / "events") + write_event(watcher.events_dir, 1, "t1", "SessionStart", **extra) + (event,) = watcher.poll() + assert event.event == "SessionStart" + assert event.source == expected + + def test_poll_returns_new_events_once(tmp_path): watcher = SignalWatcher(tmp_path / "events") write_event(watcher.events_dir, 2, "t1", "Stop") @@ -259,7 +277,7 @@ def test_is_session_event_is_the_rule_wait_for_matches_on(tmp_path): assert not is_session_event(event, "t2") -def _event(kind, session_id=None, ts=1): +def _event(kind, session_id=None, ts=1, source=None): return HookEvent( ts=ts, event=kind, @@ -267,6 +285,7 @@ def _event(kind, session_id=None, ts=1): session_id=session_id, transcript_path=None, path=Path("x"), + source=source, ) @@ -315,13 +334,60 @@ def _event(kind, session_id=None, ts=1): [True, True, True], id="never-announced-toolu-stop-admitted", # the copilot subagent filter owns it ), + pytest.param( + [ + ("SessionStart", "A"), + ("SessionStart", "B", "clear"), + ("Stop", "B"), + ("SessionStart", "C", "startup"), + ("Stop", "C"), + ("Stop", "B"), + ], + [True, True, True, False, False, True], + id="clear-start-rebinds-then-startup-child-is-foreign", + ), + pytest.param( + [ + ("SessionStart", "A"), + ("SessionStart", "B", "compact"), + ("Stop", "B"), + ("SessionStart", "C", "startup"), + ("Stop", "C"), + ("Stop", "B"), + ], + [True, True, True, False, False, True], + id="compact-start-rebinds-then-startup-child-is-foreign", + ), + pytest.param( + [("SessionStart", "A"), ("SessionStart", "B", "resume"), ("Stop", "B")], + [True, False, False], + id="resume-start-is-foreign", # a nested child launched with --resume + ), + pytest.param( + [("SessionStart", "A"), ("SessionStart", "B", None), ("Stop", "B")], + [True, False, False], + id="sourceless-start-is-foreign", # an older relay: no rebind + ), ], ) def test_session_attribution_admits(sequence, expected): """#767: the deny-list rule, event by event. Only an id that announced its own SessionStart after the launched session's first one is dropped.""" attribution = SessionAttribution() - assert [attribution.admit(_event(kind, sid)) for kind, sid in sequence] == expected + admitted = [] + for kind, sid, *source in sequence: # an optional third item is the start's source + admitted.append(attribution.admit(_event(kind, sid, source=source[0] if source else None))) + assert admitted == expected + + +def test_session_attribution_clear_start_moves_the_binding(): + """#767: a clear/compact start with a new id is the launched session + rotating its id, so the binding follows it rather than marking it foreign.""" + attribution = SessionAttribution() + attribution.admit(_event("SessionStart", "A")) + assert attribution.admit(_event("SessionStart", "B", source="clear")) + assert attribution.bound_id == "B" + assert attribution.foreign_ids == set() def test_attribute_events_returns_admitted_and_foreign_ids(): From fe92c8c7adbe79f410ef6c82fcd5c412d224df37 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 28 Sep 2026 12:02:20 -0700 Subject: [PATCH 5/8] fix(adapter): count and journal ignored foreign hook events; attribute the sweep diagnostic wait_for_completion now counts every event dropped as a nested CLI's and crumbs `foreign-hook-event-ignored` once per foreign id (with `dropped_so_far`) instead of once per event. The count reaches heartbeat.json (`foreign_hook_events`) and the `timeout-fired` crumb, so a timeout caused by dropped events can be diagnosed from the journal. The sweep's non-completed-session diagnostic replays `attribute_events`, so a nested child's Stop no longer reads as the session's own. It adds `hook_foreign_ids`, and the escalation suffix says `foreign ignored: N`. Adds the M2 guard (a dropped foreign start never re-points the transcript), a once-per-id crumb test, heartbeat and timeout-fired count tests, sweep replay tests, and a zero-token real-tmux E2E with a nested child CLI. --- src/bmad_loop/adapters/generic.py | 26 +++++-- src/bmad_loop/sweep.py | 18 ++++- tests/test_conftest.py | 2 +- tests/test_generic_tmux.py | 118 ++++++++++++++++++++++++++++-- tests/test_stories_e2e.py | 74 +++++++++++++++++++ tests/test_sweep.py | 65 +++++++++++++++- 6 files changed, 281 insertions(+), 22 deletions(-) diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 33cf0b0d4..0cf546f82 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -909,6 +909,10 @@ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> Sessi wall_deadline = time.time() + spec.timeout_s session_id: str | None = None attribution = SessionAttribution() + # events dropped as a nested CLI's (heartbeat + timeout-fired carry the + # count); the crumb fires once per foreign id, not once per event. + foreign_hook_events = 0 + crumbed_foreign: set[str] = set() transcript_path: str | None = None nudges_left = self._stop_nudges # Positive grace arms at launch for dev/review sessions, so a CLI that @@ -1115,6 +1119,9 @@ def produced_work() -> bool: mono_remaining_s=round(remaining, 3), # nonzero = the deadline landed while liveness was unknown probe_failures=probe_failures, + # nonzero = a nested CLI's events were dropped (the parent's + # own Stop may have been read as foreign) + foreign_hook_events=foreign_hook_events, ) return SessionResult( status="timeout", @@ -1172,6 +1179,8 @@ def produced_work() -> bool: "probe_failures": probe_failures, # the running budget usage-sample failure streak (DW-452) "usage_sample_failures": usage_failures, + # hook events dropped as a nested CLI's (#767) + "foreign_hook_events": foreign_hook_events, }, ) # Mid-session spec-status transition sampling (#276 M2) rides the @@ -1501,12 +1510,17 @@ def produced_work() -> bool: # toolu_ subagent Stops never announce, so they pass here and stay # owned by the subagent filter below. if not attribution.admit(event): - self._note_lifecycle( - handle.task_id, - "foreign-hook-event-ignored", - hook_event=event.event, - foreign_session_id=event.session_id, - ) + foreign_hook_events += 1 + # admit() only drops identified events, so session_id is set. + if event.session_id and event.session_id not in crumbed_foreign: + crumbed_foreign.add(event.session_id) + self._note_lifecycle( + handle.task_id, + "foreign-hook-event-ignored", + hook_event=event.event, + foreign_session_id=event.session_id, + dropped_so_far=foreign_hook_events, + ) continue if ( event.event == "Stop" diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 965cd7adf..12dd3bf01 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -51,7 +51,7 @@ safe_segment, ) from .runs import StateRootError, _project_of_run_dir, events_dir_for -from .signals import session_events +from .signals import attribute_events, session_events from .statemachine import advance @@ -1392,7 +1392,10 @@ def _diagnostic_suffix(diagnostic: dict[str, Any] | None) -> str: artifact = str(diagnostic["artifact"]) if diagnostic.get("artifact_error"): artifact += f" ({diagnostic['artifact_error']})" - return f" [result.json: {artifact}; hook events: {diagnostic['hook_events']}]" + hooks = str(diagnostic["hook_events"]) + if diagnostic.get("hook_foreign_ids"): + hooks += f"; foreign ignored: {diagnostic['hook_foreign_ids']}" + return f" [result.json: {artifact}; hook events: {hooks}]" class SweepEngine(Engine): @@ -4923,6 +4926,9 @@ def _session_failure_diagnostic( the out-of-tree channel and the legacy in-tree ``/events`` through ``signals.is_session_event`` — this attempt's session id and launch floor, so an earlier healthy attempt's events cannot mask this failure. + ``hook_events``, ``hook_event_kinds`` and ``hook_event_count`` cover only the + events ``signals.attribute_events`` admits as the launched session's (#767); + ``hook_foreign_ids`` counts the nested-CLI session ids it dropped. Observation degrades, never raises: this runs on a failure path that must still reach its retry/escalation decision.""" @@ -4972,10 +4978,14 @@ def _session_failure_diagnostic( except Exception as exc: # observation degrades; see docstring diagnostic["hook_events"] = f"unreadable: {_bounded(f'{type(exc).__name__}: {exc}')}" else: - kinds = {event.event for event in events} + # The replay wait_for_completion applied live: a nested CLI's Stop + # did not end this session, so it must not read as "stop" here. + admitted, foreign = attribute_events(events) + kinds = {event.event for event in admitted} diagnostic["hook_events"] = _hook_verdict(kinds) diagnostic["hook_event_kinds"] = sorted(kinds) - diagnostic["hook_event_count"] = len(events) + diagnostic["hook_event_count"] = len(admitted) + diagnostic["hook_foreign_ids"] = len(foreign) return diagnostic def _migrate_prompt(self, manifest: Path, feedback: Path | None) -> str: diff --git a/tests/test_conftest.py b/tests/test_conftest.py index 32ec7ff74..df28156b6 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -1470,7 +1470,7 @@ def _declares_loadgroup(addopts: object) -> bool: _EXPECTED_E2E_DEF_COUNTS: dict[str, dict[str, int]] = { "test_generic_tmux.py": {"test_tmux_": 6}, "test_stories_e2e.py": { - "test_e2e_": 16, + "test_e2e_": 17, "test_reap_e2e_": 3, # Not asserted, and deliberately so — see the residual note above. Today these are # the local-process identity harness defs: `test_detach_gate_*`, diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index f6e32c293..ef8d286fb 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -1511,11 +1511,15 @@ def test_wait_for_completion_ignores_foreign_identified_lifecycle_events(tmp_pat for entry in _lifecycle_lines(adapter) if entry["event"] == "foreign-hook-event-ignored" ] - assert [entry["hook_event"] for entry in ignored] == ["SessionStart", "Stop", "SessionEnd"] - assert [entry["foreign_session_id"] for entry in ignored] == [child_id] * 3 - assert all( - set(entry) == {"ts", "event", "hook_event", "foreign_session_id"} for entry in ignored - ) + # one crumb per foreign id, on its first dropped event (the announcing start) + (crumb,) = ignored + assert crumb == { + "ts": crumb["ts"], + "event": "foreign-hook-event-ignored", + "hook_event": "SessionStart", + "foreign_session_id": child_id, + "dropped_so_far": 1, + } assert "/child.jsonl" not in json.dumps(ignored) @@ -1570,8 +1574,107 @@ def test_wait_for_completion_ignores_child_end_after_unidentified_session_start( for entry in _lifecycle_lines(adapter) if entry["event"] == "foreign-hook-event-ignored" ] - assert [entry["hook_event"] for entry in ignored] == ["SessionStart", "Stop", "SessionEnd"] - assert [entry["foreign_session_id"] for entry in ignored] == [child_id] * 3 + assert [(entry["hook_event"], entry["foreign_session_id"]) for entry in ignored] == [ + ("SessionStart", child_id) + ] + + +def test_wait_for_completion_foreign_start_never_repoints_transcript(tmp_path, monkeypatch): + """Review finding M2: a nested child's SessionStart as the LAST hook event, + then window death. The dropped start must not re-point the identity or the + transcript at the child's, so the crash is reported against the parent's + session and transcript (the ones the engine reads usage and evidence from). + + Ablation: turn the non-admitted `continue` into crumb-and-fall-through and + this fails (the PR's own foreign-start test passed under that change).""" + monkeypatch.setattr(generic, "RESULT_GRACE_S", 0.0) + monkeypatch.setattr(generic, "RESULT_POLL_S", 0.0) + adapter, _ = make_dev_adapter(tmp_path) + adapter._window_alive = lambda handle: False # dies after the child's start + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("SessionStart", session_id="sess-a", transcript_path="/a.jsonl"), + _hook_event("SessionStart", session_id="sess-b", transcript_path="/b.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "crashed" + assert result.session_id == "sess-a" + assert result.transcript_path == "/a.jsonl" + + +def test_wait_for_completion_crumbs_each_foreign_id_once(tmp_path): + """Two nested children, each with several dropped events: one + `foreign-hook-event-ignored` crumb per foreign id (on its announcing + start), each carrying the running drop count — not one crumb per event. + + Ablation: crumb every dropped event and the single-crumb-per-id assertion + fails.""" + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text( + "---\nstatus: done\n---\n\n## Auto Run Result\n\nStatus: done\n" + ) + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("SessionStart", session_id="parent", transcript_path="/p.jsonl"), + _hook_event("SessionStart", session_id="child-1", transcript_path="/c1.jsonl"), + _stop_event("3-1-dev-1", "child-1", "/c1.jsonl"), + _hook_event("SessionStart", session_id="child-2", transcript_path="/c2.jsonl"), + _stop_event("3-1-dev-1", "child-2", "/c2.jsonl"), + _stop_event("3-1-dev-1", "child-1", "/c1.jsonl"), + _hook_event("SessionEnd", session_id="child-2", transcript_path="/c2.jsonl"), + _stop_event("3-1-dev-1", "parent", "/p.jsonl"), + ] + ) + + result = adapter.wait_for_completion(_dev_handle(), _dev_spec(tmp_path)) + + assert result.status == "completed" + assert result.session_id == "parent" + ignored = [ + (entry["foreign_session_id"], entry["hook_event"], entry["dropped_so_far"]) + for entry in _lifecycle_lines(adapter) + if entry["event"] == "foreign-hook-event-ignored" + ] + assert ignored == [("child-1", "SessionStart", 1), ("child-2", "SessionStart", 3)] + + +def test_foreign_hook_events_count_reaches_heartbeat_and_timeout_fired(tmp_path, monkeypatch): + """A session whose only hook events were a nested child's times out with + the drop count visible where an operator looks: heartbeat.json's + `foreign_hook_events` and the `timeout-fired` crumb (#767). A timeout + caused by dropped events is then diagnosable from the journal alone. + + Ablation: drop the key from either payload and this fails.""" + adapter, clock = _timeout_clock_adapter(tmp_path, monkeypatch) + adapter._stall_grace_s = 0.0 + heartbeats: list[dict] = [] + adapter._write_heartbeat = lambda task_id, payload: heartbeats.append(payload) + + def advance(call_n): + # the third event's tick crosses a heartbeat interval; the idle tick + # after it crosses the deadline + if call_n == 3: + clock["mono"] += generic.HEARTBEAT_INTERVAL_S + 1.0 + elif call_n > 3: + clock["mono"] += 1000.0 + + adapter.watcher = _ScriptedWatcher( + [ + _hook_event("SessionStart", session_id="parent", transcript_path=None), + _hook_event("SessionStart", session_id="child", transcript_path=None), + _stop_event("3-1-dev-1", "child", None), + ], + on_call=advance, + ) + result = adapter.wait_for_completion(_dev_handle(), _short_spec(tmp_path, timeout_s=100.0)) + + assert result.status == "timeout" + assert [hb["foreign_hook_events"] for hb in heartbeats] == [0, 2] + (fired,) = _lifecycle_events(adapter, "timeout-fired") + assert fired["foreign_hook_events"] == 2 def test_wait_for_completion_accepts_rotated_session_id_stop(tmp_path): @@ -2627,6 +2730,7 @@ def advance(call_n): "stall_nudges_failed": 0, "probe_failures": 0, "usage_sample_failures": 0, + "foreign_hook_events": 0, # no nested CLI's events were dropped (#767) } assert [w["remaining_s"] for w in writes] == [100.0, 59.0] # tick 2 was throttled hb = json.loads((adapter.tasks_dir / "3-1-dev-1" / "heartbeat.json").read_text()) diff --git a/tests/test_stories_e2e.py b/tests/test_stories_e2e.py index dbc53b59c..3e17e9c81 100644 --- a/tests/test_stories_e2e.py +++ b/tests/test_stories_e2e.py @@ -238,6 +238,32 @@ # FAKE_CLI in nothing else. LEGACY_EVENTS_FAKE_CLI = FAKE_CLI.replace('ed="$BMAD_LOOP_EVENTS_DIR"', 'ed="$rd/events"') +# The same script with a nested coding CLI launched inside the session (#767): the +# child inherits BMAD_LOOP_TASK_ID/BMAD_LOOP_EVENTS_DIR, so its own SessionStart, +# Stop and SessionEnd land in the parent's stream, before the parent has written a +# result. Fresh timestamps (and a pause so they reach the watcher as their own ticks) +# order every child event ahead of the parent's Stop, which is re-stamped too, so the +# parent's completion cannot overtake them in one poll. +_NESTED_CHILD_EVENTS = r""" +for kind in SessionStart Stop SessionEnd; do + cts=$(date +%s%N) + printf '{"ts": %s, "event": "%s", "task_id": "%s", "session_id": "child-1"}' \ + "$cts" "$kind" "$tid" > "$ed/$cts-$tid-$kind.json" +done +sleep 2 +""" +_PARENT_START = """ "$ts" "$tid" > "$ed/$ts-$tid-SessionStart.json" +""" +_STORY_STOP_TS = """write_done # normal fresh dispatch +fi + +ts2=$(( ts + 1 )) +""" +assert FAKE_CLI.count(_PARENT_START) == 1 and FAKE_CLI.count(_STORY_STOP_TS) == 1 +NESTED_CHILD_FAKE_CLI = FAKE_CLI.replace( + _PARENT_START, _PARENT_START + _NESTED_CHILD_EVENTS +).replace(_STORY_STOP_TS, _STORY_STOP_TS.replace("$(( ts + 1 ))", "$(date +%s%N)")) + PROFILE_TOML = """\ name = "fakestories" binary = "{binary}" @@ -2268,6 +2294,54 @@ def test_e2e_a_relay_that_only_knows_the_legacy_events_dir_still_completes(tmp_p assert not list(runs.events_dir_for(root, run_id).glob("*.json")) +def test_e2e_nested_child_cli_events_do_not_end_the_parent(tmp_path): + """A nested coding CLI started from inside the session writes its own + SessionStart, Stop and SessionEnd into the parent's event stream before the + parent has finished (#767). Through the real CLI and real tmux, the child's + announced start marks it foreign: its SessionEnd must not crash the story and + its Stop must not complete it early. Only the parent's own Stop does. The + profile maps SessionEnd here (as claude.toml does) so the child's SessionEnd + really reaches the wait loop. + + Ablation guard: make `SessionAttribution.admit` always admit and the + `foreign-hook-event-ignored` assertion fails. The outcome assertions alone do + NOT catch that ablation: the child's SessionEnd then crashes the session, but + a crash is graded on the artifact read back within RESULT_GRACE_S (15 s), and + the parent's spec lands inside it, so the story still reaches `done`.""" + assert NESTED_CHILD_FAKE_CLI != FAKE_CLI, "the child splice did not take" + session_end_profile = PROFILE_TOML.replace( + 'Stop = "Stop" }', 'Stop = "Stop", SessionEnd = "SessionEnd" }' + ) + assert session_end_profile != PROFILE_TOML, "the SessionEnd mapping did not take" + + root = tmp_path / "sbx" + _scaffold(root, [_entry("1")]) + fake = root / ".bmad-loop" / "fake-cli.sh" + fake.write_text(NESTED_CHILD_FAKE_CLI, encoding="utf-8") + (root / ".bmad-loop" / "profiles" / "fakestories.toml").write_text( + session_end_profile.format(binary=str(fake)), encoding="utf-8" + ) + _git(root, "commit", "-q", "-am", "nested-child fake") + base = _commit_count(root) + + proc = _run(root, "run") + assert proc.returncode == 0, proc.stderr or proc.stdout + assert _status(root, "1") == "done" + assert _commit_count(root) == base + 1 + + run_id = _run_id(root) + lifecycle = root / ".bmad-loop" / "runs" / run_id / "tasks" + crumbs = [ + json.loads(line) + for path in lifecycle.glob("*/session-lifecycle.jsonl") + for line in path.read_text(encoding="utf-8").splitlines() + ] + ignored = [c for c in crumbs if c["event"] == "foreign-hook-event-ignored"] + assert [(c["foreign_session_id"], c["hook_event"]) for c in ignored] == [ + ("child-1", "SessionStart") + ] + + def test_e2e_sprint_mode_regression(tmp_path): # Scenario 6 (audit MAJOR-2): the new folder+id-capable bmad-dev-auto skill is # installed, but this is a plain SPRINT-mode run. It must drive dev → verify → diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 2d6e390e0..07de57f53 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -5356,7 +5356,9 @@ def failed_session_effect( would, the evidence the #752 diagnostic reads: `artifact` (a dict, or raw text for a malformed document) at `/tasks//result.json`, and hook events on the out-of-tree channel (`BMAD_LOOP_EVENTS_DIR`) and/or the legacy - in-tree `/events`, correlated exactly as the relay stamps them. + in-tree `/events`, correlated exactly as the relay stamps them. An + event is a kind (session id "s") or a `(kind, session_id)` pair; timestamps + strictly increase, so events replay in the order given. `ledger` rewrites the deferred-work ledger first (a migration's work).""" def effect(spec): @@ -5373,11 +5375,13 @@ def effect(spec): (Path(spec.env["BMAD_LOOP_EVENTS_DIR"]), primary_events), (run_dir / "events", legacy_events), ) + last_ts = 0 for directory, kinds in channels: - for kind in kinds: + for item in kinds: + kind, session_id = (item, "s") if isinstance(item, str) else item directory.mkdir(parents=True, exist_ok=True) - ts = time.time_ns() - payload = {"ts": ts, "event": kind, "task_id": task_id, "session_id": "s"} + ts = last_ts = max(time.time_ns(), last_ts + 1) + payload = {"ts": ts, "event": kind, "task_id": task_id, "session_id": session_id} (directory / f"{ts}-{task_id}-{kind}.json").write_text(json.dumps(payload)) return result if result is not None else SessionResult(status=status) @@ -5521,6 +5525,59 @@ def test_non_completed_triage_reports_hook_evidence_distinctly(project, primary, assert diag["hook_event_count"] == len(primary) +def test_non_completed_triage_diagnostic_replays_session_attribution(project): + """A nested CLI launched from inside the triage session writes into its event + stream (#767). The diagnostic replays the attribution wait_for_completion + applied live: the child's Stop is not this session's, so the verdict, kinds + and count cover the parent alone, and the dropped child is counted and named + in the escalation text. + + Ablation guard: compute the verdict over every event instead of the admitted + ones and `hook_events` reads "stop" from the child's Stop alone.""" + write_ledger(project, {"DW-1": "open"}) + effect = failed_session_effect( + primary_events=[ + ("SessionStart", "parent"), + ("SessionStart", "child"), + ("Stop", "child"), + ] + ) + engine, _ = make_sweep(project, [effect, effect]) + engine.run() + + diag = _decisions(engine, "triage-decision")[-1]["diagnostic"] + assert diag["hook_events"] == "session-start-without-stop" + assert diag["hook_event_kinds"] == ["SessionStart"] + assert diag["hook_event_count"] == 1 + assert diag["hook_foreign_ids"] == 1 + assert "hook events: session-start-without-stop; foreign ignored: 1]" in ( + engine.state.paused_reason + ) + + +def test_non_completed_triage_diagnostic_counts_parent_stop_beside_a_foreign_child(project): + """Parent start/Stop plus a child's start/Stop: the parent's own Stop makes the + verdict "stop", the count excludes the child's two events, and the dropped + child is still reported.""" + write_ledger(project, {"DW-1": "open"}) + effect = failed_session_effect( + primary_events=[ + ("SessionStart", "parent"), + ("SessionStart", "child"), + ("Stop", "child"), + ("Stop", "parent"), + ] + ) + engine, _ = make_sweep(project, [effect, effect]) + engine.run() + + diag = _decisions(engine, "triage-decision")[-1]["diagnostic"] + assert diag["hook_events"] == "stop" + assert diag["hook_event_count"] == 2 + assert diag["hook_foreign_ids"] == 1 + assert "hook events: stop; foreign ignored: 1]" in engine.state.paused_reason + + def test_non_completed_triage_on_a_hookless_adapter_reports_hooks_not_applicable(project): """opencode-http observes over SSE and never writes an event channel, so an empty scan says nothing about its session: the diagnostic reports hooks as not From dafbcb4ecbbc981c3e6af79262ead2083dc0b869 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 28 Sep 2026 12:06:42 -0700 Subject: [PATCH 6/8] docs: document hook-event session attribution (#767) CHANGELOG Unreleased/Fixed entry crediting @Pinstack; FEATURES generic-adapter bullet (deny-list rule, anonymous first start, clear/compact rebind, accepted limitation, old-relay degrade); tui-guide foreign-hook-event-ignored crumb and foreign_hook_events heartbeat/timeout-fired field; adapter-authoring-guide session identity expectations for profile authors; copilot.toml note that toolu_ ids pass attribution and stay with the subagent filter. --- CHANGELOG.md | 11 ++++++++ docs/FEATURES.md | 1 + docs/adapter-authoring-guide.md | 35 ++++++++++++++++++++++-- docs/tui-guide.md | 11 ++++++-- src/bmad_loop/data/profiles/copilot.toml | 2 ++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7171dbd52..8dd1b3d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ breaking changes may land in a minor release. ## [Unreleased] +### Fixed + +- Ignore hook events from nested coding-CLI sessions that inherit the relay environment, + so a child's `Stop`/`SessionEnd` no longer completes or crashes the launched session: + an id that announces its own `SessionStart` after the launched session's first is + foreign, and its events are dropped, crumbed once per id as + `foreign-hook-event-ignored`, counted as `foreign_hook_events` in `heartbeat.json` and + `timeout-fired`, and excluded from the sweep diagnostic (`hook_foreign_ids`). Forward + SessionStart `source` so a `clear`/`compact` start rebinds; re-run `bmad-loop init` to + re-vendor the relay (#767). Contributed by [@Pinstack](https://github.com/Pinstack). + ## [0.13.0] — 2026-09-28 ### Added diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 16552bc50..9babd321b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -80,6 +80,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - A session parked on a human prompt is escalated instead of nudged (DW-348/DW-350). The stall wake nudge ends in `Enter`, which can _answer_ the prompt a parked CLI shows (#727: it confirmed "No, exit" on Claude Code's Bypass Permissions dialog). Two opt-in, profile-declared signals now gate it. **Hook-reported**: the relay forwards a `Notification` payload's `notification_type`, and the profile's `[hooks.notification_types]` maps the subtypes that mean "waiting on a human" onto the canonical parked kinds `PermissionPrompt` / `IdlePrompt` / `QuotaPrompt` (a profile may also map a native event straight to one). The claude profile maps `permission_prompt`, `idle_prompt`, `quota_auto_resume_stale`, `quota_auto_resume_disabled` and the MCP elicitation dialogs `elicitation_dialog` / `elicitation_url_dialog` (as `PermissionPrompt`, DW-434); `agent_needs_input` stays unmapped. One of its triggers is a different (background) session waiting while agent view is open, and the payload does not say which trigger fired. The other, a teammate setup question, needs experimental agent teams, which bmad-loop does not enable. An overlay may map it. The adapter latches the signal, and a `Stop` clears it. Because claude sends `idle_prompt` about 60 s after every finished turn, a dev, review or workflow session that ends a turn without a result and then sits idle re-latches and, at stall-grace expiry, pauses as parked instead of receiving the stall wake nudge — intended; a project overlay that drops `idle_prompt` from `[hooks.notification_types]` restores the nudge. **Pane-matched**: at every stall-grace expiry with no latch set — a nudge due, or the final stall once the nudges are spent or when none are configured (DW-433) — the visible pane (`capture-pane -p`) is matched line by line against the profile's `parked_prompt_patterns` (claude ships the two captured #727 dialog lines). Neither signal completes a session or acts on arrival. The one decision point is the stall-grace expiry: with a parked signal, no nudge is typed and the session ends `stalled` with `parked` / `parked_evidence` (a dead window still ends `crashed`). Only an adapter with a stall grace armed reaches that point — the dev/review adapter (which also drives fix and workflow sessions) with `dev_stall_grace_s > 0`; the plain triage adapter (sweep triage and migration) has no stall grace, so its sessions still time out or retry as before. Every dev, review, fix, blocking-workflow, migration and triage site PAUSEs a parked result (`parked: session stalled (; …)`), after the env-fault arm and ahead of the no-work arm. Re-arm restores the attempt. `parked` rides `dev-decision`, `fix-decision`, `workflow-end`, `migrate-decision`, `triage-decision` and (when true) `session-end`. Capture or regex failures degrade to "no match": a due nudge goes out, or the final stall ends unparked, as before. A project initialized before this has no `Notification` relay; `hooks.registered` still passes, and hook-reported signals start after the next `bmad-loop init`. The opencode HTTP adapter is unchanged. - An idle session is visible while it sits (#680). A session idling inside a tool call (`sleep 590; cat …`) keeps its pane log growing through spinner repaints, so the stall re-arm — correctly — never fires and nothing separated it from a working session but `session_timeout_min`. The adapter now stats the live transcript's `(mtime_ns, size)` — a baseline the moment the first hook event names it, then on the heartbeat cadence (a stat, never parsed usage, so it works for `usage_parser = "none"`) — stamps the age on `heartbeat.json` as `transcript_idle_s` (`null` before a transcript is known), and — when the run's journal is attached, which the engine does for every adapter it owns — journals one `session-idle` (`task_id`, `idle_s`, `since_ts`, `threshold_s`) when the age crosses `limits.dev_stall_grace_s` and one `session-active` (`task_id`, `idle_s`) when the transcript moves again; a later stretch emits a fresh pair. The threshold is the stall grace on purpose — the event fires exactly when the session _would_ have stalled had its pane not kept repainting, so the two records are directly comparable — and `0` disables the events with no new knob. The TUI's agent line shows the open stretch as `· idle `. Observability only: nothing bounds the stretch, and the session is neither nudged, stalled nor killed for it. Scope: the notice starts when a hook event names the transcript — `SessionStart` on the `claude`, `codex`, `gemini` and `copilot` profiles, so it covers a session's first turn there; `antigravity` fires no `SessionStart` and names the transcript only on its `Stop`, so its first turn is invisible to the notice (the heartbeat's `transcript_idle_s` stays `null` until then), and the `opencode-http` transport has no pane wait loop and emits neither the field nor the events. - A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. When the probe itself cannot ask (`has_session` raises `MultiplexerError`), the verdict stays an undiagnosed `crashed` and a `session-probe-failed` breadcrumb (`session`, `error`) records that the question went unanswered (DW-382). A negative lookup is confirmed before it is recorded (DW-459): `has_session` maps every nonzero backend result to False, including psmux's `Invalid session key` and `connection timed out` from a session that is still alive, so the adapter then lists the session's windows and writes `session-vanished` only when that listing proves the session gone; a listing that raises or still finds windows writes `session-probe-failed` instead. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: a session the multiplexer no longer reports says nothing about what removed it — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. +- Hook events from a nested coding CLI are attributed, not trusted (#767). A CLI process started from inside a session inherits the relay environment, so its `SessionStart`/`Stop`/`SessionEnd` land in the parent's event stream under the parent's task id and could complete or crash it. The generic adapter applies a second layer after the task-id filter (`SessionAttribution`): the first `SessionStart` is the launched session's — identified or not, so an anonymous start (a payload the relay could not read) still takes the parent's slot — and a later `SessionStart` with a new id marks that id foreign, unless its `source` is `clear` or `compact`, which is the launched session rotating its id and rebinds (`resume` and `startup` stay foreign). A foreign id's events are dropped before they can complete the session, re-point its transcript, spend nudges or re-arm the stall timer; the first drop per foreign id writes a `foreign-hook-event-ignored` breadcrumb (`hook_event`, `foreign_session_id`, `dropped_so_far`), every drop counts into `foreign_hook_events` on `heartbeat.json` and the `timeout-fired` crumb, and the sweep's failed-session diagnostic replays the same rule (`hook_foreign_ids`, `; foreign ignored: N` in the escalation suffix). The rule fails toward acceptance: id-less events, ids that never announced a `SessionStart` (a rotated id, a copilot `toolu_…` subagent `Stop`) and everything before the first start — including an identified `SessionEnd` from a CLI that exited before its `SessionStart` fired (#727) — are admitted, so attribution only ever drops a known child's events and never adds a completion path. Accepted limitation: a child `SessionEnd` whose child never announced a `SessionStart` is indistinguishable from the parent's own and is admitted; nested CLIs announce their start. A profile that maps no `SessionStart` (Stop-only, e.g. `antigravity`) gets no nested-CLI protection. A relay vendored before this change forwards no `source`, so there a `clear`/`compact` start with a new id reads as foreign and the session falls back to window death or its timeout — re-run `bmad-loop init` to re-vendor the relay. - Transport faults in the generic (multiplexer-driven) adapter leave crumbs instead of healthy-looking answers (DW-447/449/453/454). Like `session-probe-failed`, a window-liveness probe that raises `MultiplexerError` never reads as death, and every verdict is unchanged; what changes is the record. `liveness-probe-failed` (`site`, `error`) is written by the `tick` site at a wait-loop streak's first failed tick only, never once per tick, and by the one-shot sites (`over-budget`, `stall`, `post-kill`) once per verdict probe that raised. `liveness-probe-recovered` (`failures`) closes a tick streak when a later tick probes cleanly; a session that ends mid-streak (Stop, SessionEnd, abort, over-budget) leaves no recovered crumb. `heartbeat.json` carries the running streak as `probe_failures`, and `timeout-fired` carries the final one. `over-budget-fired` and `kill-escalated` gain `liveness_unknown`, which is true when the probe behind that verdict (for the kill, the last poll before escalation) raised. A stall, budget, stop or contract nudge whose send raised writes `nudge-send-failed` (`nudge`, `error`) and is never reported as sent: `stall_nudges_sent` on the heartbeat counts delivered nudges, `stall_nudges_failed` counts the failed attempts, and `contract-nudge-sent` is written only after a successful send (a failed contract nudge is still not retried). The stall-nudge cap and the #727 activity window count attempts, delivered or not. A post-kill rescue abandoned on unknown liveness or an unreadable artifact writes `post-kill-rescue-abandoned` (`reason` = `liveness-unknown` / `unreadable-artifact`, `status`, plus `error` for the read fault). The opencode HTTP adapter's heartbeat carries neither new key (its `stall_nudges_sent` counts attempts), but its nudges are crumbed the same way (DW-503): its `send_text` raises a `MultiplexerError` when the prompt POST fails, so an undelivered contract nudge writes `nudge-send-failed` instead of `contract-nudge-sent`, and its own budget, stall and stop nudges write `nudge-send-failed` and carry on exactly as before. Its dev sessions do share the post-kill reconcile, so they write `post-kill-rescue-abandoned` too, on the `unreadable-artifact` arm only (their liveness probe never answers "unknown"). - Three generic-adapter observation folds are crumbed the same way, verdicts unchanged (DW-448/450/451). A stall-expiry look at the pane whose capture raises `MultiplexerError`, or whose parked-prompt search blows `PARKED_PROMPT_MATCH_TIMEOUT_S`, still reads as "not parked", and writes `parked-probe-failed` (`reason` = `capture-failed` / `match-timeout`, `pattern` for a timeout, `error`) once per expiry; a profile with no `parked_prompt_patterns` or a backend without `capture_pane` stays silent. A pane-log stat fault other than absence still leaves the #261/#727 proof-of-work signal unknown, and writes `log-evidence-failed` (`error`) once per session however many verdict sites consult it. A present `result.json` the read-back refuses (unreadable, unparseable, not an object) still reads as no result: the Stop read-back's give-up record in `resultless-stops.jsonl` says `malformed-result-json` with the refusal instead of `no-result-json`, once per give-up rather than per poll, and the exit read-back writes `result-json-refused` (`error`). The opencode HTTP adapter shares the `resultless-stops.jsonl` verdict but not the other crumbs. - Four more generic-adapter folds are crumbed, verdicts unchanged (DW-452/455/456/457). A budget usage sample whose transcript read raises still reads as "no sample" — with a persistent fault, `token_budget_mode = "enforce"` stays off for the session — and its streak writes `usage-sample-failed` (`error`) at the first failure and `usage-sample-recovered` (`failures`) at the next clean sample, never once per tick (one torn mid-append read is one pair); `heartbeat.json` carries the running count as `usage_sample_failures`. A #727 transcript activity scan that raises still counts as no model-side evidence, with its streak crumbed the same way (`transcript-scan-failed` / `transcript-scan-recovered`). A launch-snapshot fault that turns the #276 M1 refuse gate inert — the identity check's `resolve()` raising (the 3.11 symlink-loop `RuntimeError` included, which used to escape) or the digest read raising — still reads NEUTRAL, and writes `spec-identity-unreadable` (`spec`, `snapshot`, `error`) or `spec-digest-unreadable` (`spec`, `error`) once per read-back, not per grace poll. A spec read-back that gives up on a fault says so: `stat-failed` for a stat fault the stories read-back used to file as `stale-mtime`, `unreadable-spec` for an unreadable or undecodable spec it filed as `not-terminal` (and the scan fallback as `no-artifact`), each with the error, in `resultless-stops.jsonl` on a Stop read-back and as `spec-readback-failed` (`reason`, `spec`, `error`) on a one-shot or dead-window (post-kill reconcile) read, which recorded nothing before. diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 943eff0ed..2cd5dca8a 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -288,7 +288,9 @@ The hard part of a new profile isn't the TOML — it's the **facts that live in doc**: the CLI's exact hook payload shape (field names and casing, whether `session_id` / `transcript_path` / `cwd` are present), where it writes its session transcript and in what format, and the token-usage schema a `usage_parser` has to -read. Historically the only way to get these was to hand a volunteer a manual +read. `session_id` matters beyond bookkeeping: it is how the generic adapter tells the +launched session's events from a nested CLI's (see +[Session identity and nested CLIs](#session-identity-and-nested-clis)). Historically the only way to get these was to hand a volunteer a manual recipe and ask them to sanitize the output by hand — error-prone and PII-risky. **`bmad-loop probe-adapter`** (alias `collect-adapter-data`) pulls all of that and @@ -514,7 +516,7 @@ resolves to `claude`. | `usage_parser` | | `none` | Which transcript token parser to use — one of `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events`, `none`. | | `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends. `0` = read once. Raise it for CLIs that flush totals only on shutdown (copilot writes `modelMetrics` ~1s after the turn-end hook). Must be ≥ 0. | | `stop_without_result_nudges` | | unset (use global) | Per-adapter floor for Stop-without-result nudges. Leave unset to inherit `limits.stop_without_result_nudges`. Raise it for CLIs that fire a turn-end hook _per response turn_ (copilot's `agentStop`), where the global default of 1 declares them stalled too early. Must be ≥ 0 if set. | -| `subagent_stop_without_transcript` | | `false` | Set `true` for CLIs that fire the turn-end hook for _subagent_ turns too, with an empty `transcriptPath` and a tool-use session id (copilot's `agentStop`). A `Stop` carrying no transcript is then treated as a subagent stop and ignored, so the main session's real turn-end drives completion. Leave `false` and every `Stop` is the main turn-end. | +| `subagent_stop_without_transcript` | | `false` | Set `true` for CLIs that fire the turn-end hook for _subagent_ turns too, with an empty `transcriptPath` and a tool-use session id (copilot's `agentStop`). A `Stop` carrying no transcript is then treated as a subagent stop and ignored, so the main session's real turn-end drives completion. Leave `false` and every `Stop` is the main turn-end. Session attribution runs first, but a subagent's tool-use id never announces a `SessionStart`, so attribution admits it and this filter still owns it. | | `first_run_note` | | `""` | Human note printed by `init` about a manual first-run/auth step this CLI needs. | | `seed_files` | | `()` | Project-relative gitignored configs (MCP/CLI settings) a `git worktree add` checkout omits; `provision_worktree` copies them into isolated dev/review worktrees. Must be relative. | | `env_fault_patterns` | | `()` | Regex patterns matched line-by-line against the ANSI-stripped tail of a **non-completed** (`timeout`/`stalled`/`crashed`) session's log to classify a transport/API **environment fault** (#194); a match stamps `env_fault` / `env_fault_evidence` on the `SessionResult`. **Which log is per-adapter**, named by `EnvFaultMixin.ENV_FAULT_LOG_SUFFIX`: the tmux adapters scan the pane capture `logs/.log`, `opencode-http` scans `logs/.server.out` (the `opencode serve` process's own stdout) and never its `.log` conversation transcript. A pattern is only **sound** if the model cannot write to the log it is matched against — a pane capture carries the model's own output, so a story that merely _implements_ rate limiting prints `429`/`quota` in ordinary healthy work. So for a pane-capture profile a pattern must reproduce the **whole first sentence** of one of the CLI's error messages, taken from captured output — the shipped `claude` patterns are prefix matches on that sentence, with no end-of-sentence anchor, so a line quoting the first sentence verbatim still matches. An error-shaped token **plus** a cause on the same line is _not_ sufficient — that is exactly the shape a story writing about the error produces, and it is why the previous doctrine had to be withdrawn (#507). A shipped pattern must also not match **its own line in the profile file**, or a session that prints or diffs its profile reads its own configuration as an outage; the seeded patterns carry single-character classes (`t[o]`, `respons[e]`) for that, changing nothing about what they match and everything about what their source line matches. `test_shipped_patterns_do_not_match_their_own_profile_line` pins the self-match rule and `test_pane_capture_patterns_do_not_match_this_repo` extends the same scan to every tracked file, so a doc bullet or profile comment written in the forbidden shape fails the suite. No quota patterns are seeded for the pane-capture profiles: no captured line exists, and that vocabulary is what a rate-limiting story prints all day. Must be a **list of strings**; compiled and validated at parse time (an invalid regex is a profile error). Matched with the `regex` module under a per-pattern timeout (`ENV_FAULT_MATCH_TIMEOUT_S`), so a pathological pattern can't hang a session teardown. Seeded for `claude` (three first-sentence prefix patterns over its captured errors — connection loss and the two captured provider 5xx refusals, whose statuses are enumerated rather than ranged so an uncaptured `503` stays prose, #507) and `opencode` (provider quota/rate-limit + connection, #323); empty = inert. | @@ -530,6 +532,32 @@ resolves to `claude`. | `events` | ✅ | Map of **native** event name → **canonical** event name. The canonical side must be one of `SessionStart`, `Stop`, `SessionEnd`, `PreCompact`, `Notification`, or a parked kind (`PermissionPrompt`, `IdlePrompt`, `QuotaPrompt` — see [Parked-session signals](#parked-session-signals)); the native side is whatever the CLI emits (e.g. `agentStop = "Stop"`). At least one entry. | | `notification_types` | | `[hooks.notification_types]`: map of the CLI's **notification subtype** → **parked kind** (`PermissionPrompt` / `IdlePrompt` / `QuotaPrompt`). The relay forwards a `Notification` payload's `notification_type`; a mapped subtype latches a parked signal, an unmapped one is ignored. Non-empty requires some native event mapped to `Notification`; hookless profiles must not set it. Default `{}` (inert). | +### Session identity and nested CLIs + +The relay stamps every hook event with the session's task id from the inherited +environment, so a coding CLI launched from _inside_ a session — a nested process, not a +built-in subagent — writes its events into the parent's stream. The generic adapter +tells them apart by `session_id` (`SessionAttribution` in `signals.py`, #767), which +sets these expectations for a profile: + +- **`SessionStart`'s `session_id` is the binding identity.** The first `SessionStart` is + the launched session's; a later one with a new id marks that id foreign and its events + are dropped, unless the payload's `source` is `clear` or `compact` (the relay forwards + `source`; such a start rebinds to the new id). Map `SessionStart` whenever the CLI has + one, and check that its payload carries the id. +- **`Stop` and `SessionEnd` should carry the same id.** Attribution fails toward + acceptance: an id-less event, or an id that never announced a `SessionStart`, is + admitted. A CLI that rotates its id without a new `SessionStart` still completes, but + a CLI whose events carry no id cannot be protected. +- **Subagent turn-ends stay with `subagent_stop_without_transcript`.** Subagent ids + (copilot's `toolu_…`) never announce a `SessionStart`, so attribution admits them and + the transcript filter decides. +- **Stop-only profiles get no nested-CLI protection.** With no `SessionStart` mapped, + nothing is ever bound and every event is admitted (`antigravity` today). + +`bmad-loop probe-adapter --probe` shows the payload's field names, so confirm the +`session_id` (and `source`) casing before relying on them. + ### `WorkspaceTrustSpec` (the `[workspace_trust]` table) Some CLIs refuse to start in a workspace that is not listed verbatim in a @@ -670,7 +698,8 @@ Three frozen dataclasses cross the seam: the floor for hook events). - **`SessionResult`** (returned by `wait_for_completion`) — `status` (one of `completed`, `stalled`, `timeout`, `crashed`, `over_budget`, `aborted`), `result_json`, - `session_id`, `transcript_path`, and the optional post-mortem forensics + `session_id` (on the generic adapter, the id of the last hook event session + attribution admitted — never a nested CLI's), `transcript_path`, and the optional post-mortem forensics `env_fault` / `env_fault_evidence` (set by `_classify_env_fault` when a non-completed session is matched as a transport/API **environment fault** — see the `env_fault_patterns` profile key). diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 1f9876938..45bf61edb 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -286,10 +286,14 @@ One row per story (or sweep bundle/triage task) in the selected run: (the guard's mid-session sample at trip time). The matching `tasks//` dir holds the forensic breadcrumbs the adapter wrote while the session ran: `session-lifecycle.jsonl` (timeout-fire — its - `probe_failures` counts a liveness-probe failure streak open at the deadline —, + `probe_failures` counts a liveness-probe failure streak open at the deadline and + its `foreign_hook_events` the hook events dropped as a nested CLI's —, budget-guard `budget-tripped` / `over-budget-fired`, kill-escalation — both of these carry `liveness_unknown`, true when the verdict's probe raised —, `session-vanished` (the mux no longer reported the session during the run, #489), + `foreign-hook-event-ignored` (a nested coding CLI's hook events dropped by session + attribution, #767 — written once per foreign id, with `hook_event`, + `foreign_session_id`, and `dropped_so_far`, the running count of dropped events), mux transport faults `liveness-probe-failed` / `liveness-probe-recovered` / `nudge-send-failed`, observation faults `parked-probe-failed` / `log-evidence-failed` / `result-json-refused` / `usage-sample-failed` / @@ -302,8 +306,9 @@ One row per story (or sweep bundle/triage task) in the selected run: `heartbeat.json` (the wait loop's proof-of-life — stale under a live session means the orchestrator itself was frozen; on the generic adapter it also carries `probe_failures`, the running liveness-probe - failure streak, `stall_nudges_failed`, and `usage_sample_failures`, the running - budget usage-sample failure streak), and + failure streak, `stall_nudges_failed`, `usage_sample_failures`, the running + budget usage-sample failure streak, and `foreign_hook_events`, the running count + of hook events dropped as a nested CLI's, #767), and `resultless-stops.jsonl` (each give-up Stop with its verdict: `no-result-json` / `malformed-result-json`, `no-artifact`, `stat-failed` / `unreadable-spec` (a spec read fault, with the error), diff --git a/src/bmad_loop/data/profiles/copilot.toml b/src/bmad_loop/data/profiles/copilot.toml index 5bbe4c03b..c8df120b9 100644 --- a/src/bmad_loop/data/profiles/copilot.toml +++ b/src/bmad_loop/data/profiles/copilot.toml @@ -38,6 +38,8 @@ stop_without_result_nudges = 5 # turn-end: ignore a Stop with no transcript so a subagent's premature Stop is not # read as a (result-less) completion. Without this the dev stage (0 nudges) stalls # outright on the first subagent Stop, before bmad-dev-auto writes its terminal spec. +# Session attribution (#767) runs first, but toolu_ ids never announce a +# sessionStart, so it admits them and this filter still owns them. subagent_stop_without_transcript = true first_run_note = "run `copilot` once and authenticate (gh / Copilot subscription); requires Copilot CLI GA (>= 2026-02)" skill_tree = ".agents/skills" From b1603a161730ba99503f954d753df21207c6ed6f Mon Sep 17 00:00:00 2001 From: t Date: Mon, 28 Sep 2026 13:38:26 -0700 Subject: [PATCH 7/8] fix(adapter): never rebind attribution on source alone; drop non-string hook ids (#767) A foreign id's compact start and a clear start that follows a foreign id's SessionEnd stay foreign instead of moving the binding to a nested child. _parse_event now reads a non-string session_id/transcript_path as absent, so a malformed payload cannot raise TypeError out of attribution. --- docs/FEATURES.md | 2 +- src/bmad_loop/signals.py | 26 ++++++++++++++++++---- tests/test_signals.py | 48 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9babd321b..bc6120969 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -80,7 +80,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - A session parked on a human prompt is escalated instead of nudged (DW-348/DW-350). The stall wake nudge ends in `Enter`, which can _answer_ the prompt a parked CLI shows (#727: it confirmed "No, exit" on Claude Code's Bypass Permissions dialog). Two opt-in, profile-declared signals now gate it. **Hook-reported**: the relay forwards a `Notification` payload's `notification_type`, and the profile's `[hooks.notification_types]` maps the subtypes that mean "waiting on a human" onto the canonical parked kinds `PermissionPrompt` / `IdlePrompt` / `QuotaPrompt` (a profile may also map a native event straight to one). The claude profile maps `permission_prompt`, `idle_prompt`, `quota_auto_resume_stale`, `quota_auto_resume_disabled` and the MCP elicitation dialogs `elicitation_dialog` / `elicitation_url_dialog` (as `PermissionPrompt`, DW-434); `agent_needs_input` stays unmapped. One of its triggers is a different (background) session waiting while agent view is open, and the payload does not say which trigger fired. The other, a teammate setup question, needs experimental agent teams, which bmad-loop does not enable. An overlay may map it. The adapter latches the signal, and a `Stop` clears it. Because claude sends `idle_prompt` about 60 s after every finished turn, a dev, review or workflow session that ends a turn without a result and then sits idle re-latches and, at stall-grace expiry, pauses as parked instead of receiving the stall wake nudge — intended; a project overlay that drops `idle_prompt` from `[hooks.notification_types]` restores the nudge. **Pane-matched**: at every stall-grace expiry with no latch set — a nudge due, or the final stall once the nudges are spent or when none are configured (DW-433) — the visible pane (`capture-pane -p`) is matched line by line against the profile's `parked_prompt_patterns` (claude ships the two captured #727 dialog lines). Neither signal completes a session or acts on arrival. The one decision point is the stall-grace expiry: with a parked signal, no nudge is typed and the session ends `stalled` with `parked` / `parked_evidence` (a dead window still ends `crashed`). Only an adapter with a stall grace armed reaches that point — the dev/review adapter (which also drives fix and workflow sessions) with `dev_stall_grace_s > 0`; the plain triage adapter (sweep triage and migration) has no stall grace, so its sessions still time out or retry as before. Every dev, review, fix, blocking-workflow, migration and triage site PAUSEs a parked result (`parked: session stalled (; …)`), after the env-fault arm and ahead of the no-work arm. Re-arm restores the attempt. `parked` rides `dev-decision`, `fix-decision`, `workflow-end`, `migrate-decision`, `triage-decision` and (when true) `session-end`. Capture or regex failures degrade to "no match": a due nudge goes out, or the final stall ends unparked, as before. A project initialized before this has no `Notification` relay; `hooks.registered` still passes, and hook-reported signals start after the next `bmad-loop init`. The opencode HTTP adapter is unchanged. - An idle session is visible while it sits (#680). A session idling inside a tool call (`sleep 590; cat …`) keeps its pane log growing through spinner repaints, so the stall re-arm — correctly — never fires and nothing separated it from a working session but `session_timeout_min`. The adapter now stats the live transcript's `(mtime_ns, size)` — a baseline the moment the first hook event names it, then on the heartbeat cadence (a stat, never parsed usage, so it works for `usage_parser = "none"`) — stamps the age on `heartbeat.json` as `transcript_idle_s` (`null` before a transcript is known), and — when the run's journal is attached, which the engine does for every adapter it owns — journals one `session-idle` (`task_id`, `idle_s`, `since_ts`, `threshold_s`) when the age crosses `limits.dev_stall_grace_s` and one `session-active` (`task_id`, `idle_s`) when the transcript moves again; a later stretch emits a fresh pair. The threshold is the stall grace on purpose — the event fires exactly when the session _would_ have stalled had its pane not kept repainting, so the two records are directly comparable — and `0` disables the events with no new knob. The TUI's agent line shows the open stretch as `· idle `. Observability only: nothing bounds the stretch, and the session is neither nudged, stalled nor killed for it. Scope: the notice starts when a hook event names the transcript — `SessionStart` on the `claude`, `codex`, `gemini` and `copilot` profiles, so it covers a session's first turn there; `antigravity` fires no `SessionStart` and names the transcript only on its `Stop`, so its first turn is invisible to the notice (the heartbeat's `transcript_idle_s` stays `null` until then), and the `opencode-http` transport has no pane wait loop and emits neither the field nor the events. - A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. When the probe itself cannot ask (`has_session` raises `MultiplexerError`), the verdict stays an undiagnosed `crashed` and a `session-probe-failed` breadcrumb (`session`, `error`) records that the question went unanswered (DW-382). A negative lookup is confirmed before it is recorded (DW-459): `has_session` maps every nonzero backend result to False, including psmux's `Invalid session key` and `connection timed out` from a session that is still alive, so the adapter then lists the session's windows and writes `session-vanished` only when that listing proves the session gone; a listing that raises or still finds windows writes `session-probe-failed` instead. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: a session the multiplexer no longer reports says nothing about what removed it — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. -- Hook events from a nested coding CLI are attributed, not trusted (#767). A CLI process started from inside a session inherits the relay environment, so its `SessionStart`/`Stop`/`SessionEnd` land in the parent's event stream under the parent's task id and could complete or crash it. The generic adapter applies a second layer after the task-id filter (`SessionAttribution`): the first `SessionStart` is the launched session's — identified or not, so an anonymous start (a payload the relay could not read) still takes the parent's slot — and a later `SessionStart` with a new id marks that id foreign, unless its `source` is `clear` or `compact`, which is the launched session rotating its id and rebinds (`resume` and `startup` stay foreign). A foreign id's events are dropped before they can complete the session, re-point its transcript, spend nudges or re-arm the stall timer; the first drop per foreign id writes a `foreign-hook-event-ignored` breadcrumb (`hook_event`, `foreign_session_id`, `dropped_so_far`), every drop counts into `foreign_hook_events` on `heartbeat.json` and the `timeout-fired` crumb, and the sweep's failed-session diagnostic replays the same rule (`hook_foreign_ids`, `; foreign ignored: N` in the escalation suffix). The rule fails toward acceptance: id-less events, ids that never announced a `SessionStart` (a rotated id, a copilot `toolu_…` subagent `Stop`) and everything before the first start — including an identified `SessionEnd` from a CLI that exited before its `SessionStart` fired (#727) — are admitted, so attribution only ever drops a known child's events and never adds a completion path. Accepted limitation: a child `SessionEnd` whose child never announced a `SessionStart` is indistinguishable from the parent's own and is admitted; nested CLIs announce their start. A profile that maps no `SessionStart` (Stop-only, e.g. `antigravity`) gets no nested-CLI protection. A relay vendored before this change forwards no `source`, so there a `clear`/`compact` start with a new id reads as foreign and the session falls back to window death or its timeout — re-run `bmad-loop init` to re-vendor the relay. +- Hook events from a nested coding CLI are attributed, not trusted (#767). A CLI process started from inside a session inherits the relay environment, so its `SessionStart`/`Stop`/`SessionEnd` land in the parent's event stream under the parent's task id and could complete or crash it. The generic adapter applies a second layer after the task-id filter (`SessionAttribution`): the first `SessionStart` is the launched session's — identified or not, so an anonymous start (a payload the relay could not read) still takes the parent's slot — and a later `SessionStart` with a new id marks that id foreign, unless its `source` is `clear` or `compact`, which is the launched session rotating its id and rebinds (`resume` and `startup` stay foreign). `source` alone does not rebind: an id already found foreign stays foreign (a child compacting), and a `clear` start right after a foreign id's `SessionEnd` is that child clearing, so its new id is foreign too. A foreign id's events are dropped before they can complete the session, re-point its transcript, spend nudges or re-arm the stall timer; the first drop per foreign id writes a `foreign-hook-event-ignored` breadcrumb (`hook_event`, `foreign_session_id`, `dropped_so_far`), every drop counts into `foreign_hook_events` on `heartbeat.json` and the `timeout-fired` crumb, and the sweep's failed-session diagnostic replays the same rule (`hook_foreign_ids`, `; foreign ignored: N` in the escalation suffix). The rule fails toward acceptance: id-less events, ids that never announced a `SessionStart` (a rotated id, a copilot `toolu_…` subagent `Stop`) and everything before the first start — including an identified `SessionEnd` from a CLI that exited before its `SessionStart` fired (#727) — are admitted, so attribution only ever drops a known child's events and never adds a completion path. Accepted limitation: a child `SessionEnd` whose child never announced a `SessionStart` is indistinguishable from the parent's own and is admitted; nested CLIs announce their start. A profile that maps no `SessionStart` (Stop-only, e.g. `antigravity`) gets no nested-CLI protection. A relay vendored before this change forwards no `source`, so there a `clear`/`compact` start with a new id reads as foreign and the session falls back to window death or its timeout — re-run `bmad-loop init` to re-vendor the relay. - Transport faults in the generic (multiplexer-driven) adapter leave crumbs instead of healthy-looking answers (DW-447/449/453/454). Like `session-probe-failed`, a window-liveness probe that raises `MultiplexerError` never reads as death, and every verdict is unchanged; what changes is the record. `liveness-probe-failed` (`site`, `error`) is written by the `tick` site at a wait-loop streak's first failed tick only, never once per tick, and by the one-shot sites (`over-budget`, `stall`, `post-kill`) once per verdict probe that raised. `liveness-probe-recovered` (`failures`) closes a tick streak when a later tick probes cleanly; a session that ends mid-streak (Stop, SessionEnd, abort, over-budget) leaves no recovered crumb. `heartbeat.json` carries the running streak as `probe_failures`, and `timeout-fired` carries the final one. `over-budget-fired` and `kill-escalated` gain `liveness_unknown`, which is true when the probe behind that verdict (for the kill, the last poll before escalation) raised. A stall, budget, stop or contract nudge whose send raised writes `nudge-send-failed` (`nudge`, `error`) and is never reported as sent: `stall_nudges_sent` on the heartbeat counts delivered nudges, `stall_nudges_failed` counts the failed attempts, and `contract-nudge-sent` is written only after a successful send (a failed contract nudge is still not retried). The stall-nudge cap and the #727 activity window count attempts, delivered or not. A post-kill rescue abandoned on unknown liveness or an unreadable artifact writes `post-kill-rescue-abandoned` (`reason` = `liveness-unknown` / `unreadable-artifact`, `status`, plus `error` for the read fault). The opencode HTTP adapter's heartbeat carries neither new key (its `stall_nudges_sent` counts attempts), but its nudges are crumbed the same way (DW-503): its `send_text` raises a `MultiplexerError` when the prompt POST fails, so an undelivered contract nudge writes `nudge-send-failed` instead of `contract-nudge-sent`, and its own budget, stall and stop nudges write `nudge-send-failed` and carry on exactly as before. Its dev sessions do share the post-kill reconcile, so they write `post-kill-rescue-abandoned` too, on the `unreadable-artifact` arm only (their liveness probe never answers "unknown"). - Three generic-adapter observation folds are crumbed the same way, verdicts unchanged (DW-448/450/451). A stall-expiry look at the pane whose capture raises `MultiplexerError`, or whose parked-prompt search blows `PARKED_PROMPT_MATCH_TIMEOUT_S`, still reads as "not parked", and writes `parked-probe-failed` (`reason` = `capture-failed` / `match-timeout`, `pattern` for a timeout, `error`) once per expiry; a profile with no `parked_prompt_patterns` or a backend without `capture_pane` stays silent. A pane-log stat fault other than absence still leaves the #261/#727 proof-of-work signal unknown, and writes `log-evidence-failed` (`error`) once per session however many verdict sites consult it. A present `result.json` the read-back refuses (unreadable, unparseable, not an object) still reads as no result: the Stop read-back's give-up record in `resultless-stops.jsonl` says `malformed-result-json` with the refusal instead of `no-result-json`, once per give-up rather than per poll, and the exit read-back writes `result-json-refused` (`error`). The opencode HTTP adapter shares the `resultless-stops.jsonl` verdict but not the other crumbs. - Four more generic-adapter folds are crumbed, verdicts unchanged (DW-452/455/456/457). A budget usage sample whose transcript read raises still reads as "no sample" — with a persistent fault, `token_budget_mode = "enforce"` stays off for the session — and its streak writes `usage-sample-failed` (`error`) at the first failure and `usage-sample-recovered` (`failures`) at the next clean sample, never once per tick (one torn mid-append read is one pair); `heartbeat.json` carries the running count as `usage_sample_failures`. A #727 transcript activity scan that raises still counts as no model-side evidence, with its streak crumbed the same way (`transcript-scan-failed` / `transcript-scan-recovered`). A launch-snapshot fault that turns the #276 M1 refuse gate inert — the identity check's `resolve()` raising (the 3.11 symlink-loop `RuntimeError` included, which used to escape) or the digest read raising — still reads NEUTRAL, and writes `spec-identity-unreadable` (`spec`, `snapshot`, `error`) or `spec-digest-unreadable` (`spec`, `error`) once per read-back, not per grace poll. A spec read-back that gives up on a fault says so: `stat-failed` for a stat fault the stories read-back used to file as `stale-mtime`, `unreadable-spec` for an unreadable or undecodable spec it filed as `not-terminal` (and the scan fallback as `no-artifact`), each with the error, in `resultless-stops.jsonl` on a Stop read-back and as `spec-readback-failed` (`reason`, `spec`, `error`) on a one-shot or dead-window (post-kill reconcile) read, which recorded nothing before. diff --git a/src/bmad_loop/signals.py b/src/bmad_loop/signals.py index 5a810dcc0..a5e7bf496 100644 --- a/src/bmad_loop/signals.py +++ b/src/bmad_loop/signals.py @@ -64,14 +64,18 @@ def _parse_event(entry: Path) -> HookEvent | None: return None if not isinstance(data, dict) or "event" not in data or "task_id" not in data: return None + session_id = data.get("session_id") + transcript_path = data.get("transcript_path") notification_type = data.get("notification_type") source = data.get("source") + # Payload values are forwarded from the CLI unvalidated, so a non-string one + # reads as absent rather than reaching attribution's set arithmetic (#767). return HookEvent( ts=int(data.get("ts", 0)), event=str(data["event"]), task_id=str(data["task_id"]), - session_id=data.get("session_id"), - transcript_path=data.get("transcript_path"), + session_id=session_id if isinstance(session_id, str) else None, + transcript_path=transcript_path if isinstance(transcript_path, str) else None, path=entry, notification_type=notification_type if isinstance(notification_type, str) else None, source=source if isinstance(source, str) else None, @@ -128,6 +132,13 @@ class SessionAttribution: with a new id reads as foreign and the session falls back to window death or its timeout; ``bmad-loop init`` re-vendors the relay. + ``source`` alone is not trusted. An id already found foreign never rebinds + (a child compacting under its own id), and a "clear" start right after a + foreign id's SessionEnd is that child clearing — claude ends the old session + with a SessionEnd before the clear start — so the new id is foreign too. A + child that rotates its id without a preceding SessionEnd still rebinds; only + a relay-side lineage check could tell it apart. + Accepted limitation: a child SessionEnd whose child never announced a SessionStart is indistinguishable from the parent's own and is admitted. Nested CLIs announce their start, so this is documented, not defended.""" @@ -135,23 +146,30 @@ class SessionAttribution: started: bool = False # the launched session's first SessionStart was seen bound_id: str | None = None # its id (None when that start was anonymous) foreign_ids: set[str] = field(default_factory=set) + ended_id: str | None = None # the most recent identified SessionEnd's id def admit(self, event: HookEvent) -> bool: """Whether ``event`` belongs to the launched session. Stateful: a SessionStart can bind the session or mark its id foreign.""" sid = event.session_id if event.event == "SessionStart": + ended_id, self.ended_id = self.ended_id, None if not self.started: self.started, self.bound_id = True, sid return True if not sid or sid == self.bound_id: return True - if event.source in REBIND_SOURCES: + if ( + sid not in self.foreign_ids + and event.source in REBIND_SOURCES + and not (event.source == "clear" and ended_id in self.foreign_ids) + ): self.bound_id = sid - self.foreign_ids.discard(sid) return True self.foreign_ids.add(sid) return False + if event.event == "SessionEnd" and sid: + self.ended_id = sid return not (sid and sid in self.foreign_ids) diff --git a/tests/test_signals.py b/tests/test_signals.py index 1e0169a14..b262d4137 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -54,6 +54,21 @@ def test_parse_event_reads_the_session_start_source(tmp_path, extra, expected): assert event.source == expected +@pytest.mark.parametrize("value", [["A", "B"], {"id": "A"}, 3], ids=["list", "dict", "int"]) +def test_parse_event_drops_a_non_string_session_id(tmp_path, value): + """#767: a non-string id reads as absent, so attribution never hashes it — + a list-valued child start used to raise TypeError out of the wait.""" + watcher = SignalWatcher(tmp_path / "events") + write_event(watcher.events_dir, 1, "t1", "SessionStart", session_id="A") + write_event( + watcher.events_dir, 2, "t1", "SessionStart", session_id=value, transcript_path=value + ) + events = watcher.poll() + assert [(e.session_id, e.transcript_path) for e in events] == [("A", None), (None, None)] + attribution = SessionAttribution() + assert [attribution.admit(e) for e in events] == [True, True] + + def test_poll_returns_new_events_once(tmp_path): watcher = SignalWatcher(tmp_path / "events") write_event(watcher.events_dir, 2, "t1", "Stop") @@ -358,6 +373,39 @@ def _event(kind, session_id=None, ts=1, source=None): [True, True, True, False, False, True], id="compact-start-rebinds-then-startup-child-is-foreign", ), + pytest.param( + [ + ("SessionStart", "A"), + ("SessionStart", "B", "startup"), + ("SessionStart", "B", "compact"), + ("Stop", "B"), + ("Stop", "A"), + ], + [True, False, False, False, True], + id="foreign-id-compacting-stays-foreign", + ), + pytest.param( + [ + ("SessionStart", "A"), + ("SessionStart", "B", "startup"), + ("SessionEnd", "B"), + ("SessionStart", "C", "clear"), + ("Stop", "C"), + ("Stop", "A"), + ], + [True, False, False, False, False, True], + id="clear-after-foreign-end-is-the-child-clearing", + ), + pytest.param( + [ + ("SessionStart", "A"), + ("SessionEnd", "A"), + ("SessionStart", "B", "clear"), + ("Stop", "B"), + ], + [True, True, True, True], + id="clear-after-own-end-rebinds", # claude ends the old id before a clear start + ), pytest.param( [("SessionStart", "A"), ("SessionStart", "B", "resume"), ("Stop", "B")], [True, False, False], From 5d94a2e55f41cbd698528d889632276afcc00f16 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 28 Sep 2026 15:45:58 -0700 Subject: [PATCH 8/8] fix(adapter): judge a clear start by whether the bound session ended (#767) Track bound and foreign SessionEnds separately since the last SessionStart. A foreign child's end landing between the parent's SessionEnd and its clear start no longer marks the parent's rotated id foreign. --- src/bmad_loop/signals.py | 27 ++++++++++++++++++--------- tests/test_signals.py | 12 ++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/bmad_loop/signals.py b/src/bmad_loop/signals.py index a5e7bf496..3eddec210 100644 --- a/src/bmad_loop/signals.py +++ b/src/bmad_loop/signals.py @@ -133,11 +133,13 @@ class SessionAttribution: its timeout; ``bmad-loop init`` re-vendors the relay. ``source`` alone is not trusted. An id already found foreign never rebinds - (a child compacting under its own id), and a "clear" start right after a - foreign id's SessionEnd is that child clearing — claude ends the old session - with a SessionEnd before the clear start — so the new id is foreign too. A - child that rotates its id without a preceding SessionEnd still rebinds; only - a relay-side lineage check could tell it apart. + (a child compacting under its own id), and a "clear" start after a foreign + id's SessionEnd is that child clearing — claude ends the old session with a + SessionEnd before the clear start — so the new id is foreign too, unless the + bound session also ended since the last start (the two relays write + independently, so a child's end can land between the parent's end and its + clear start). A child that rotates its id without a preceding SessionEnd + still rebinds; only a relay-side lineage check could tell it apart. Accepted limitation: a child SessionEnd whose child never announced a SessionStart is indistinguishable from the parent's own and is admitted. @@ -146,14 +148,18 @@ class SessionAttribution: started: bool = False # the launched session's first SessionStart was seen bound_id: str | None = None # its id (None when that start was anonymous) foreign_ids: set[str] = field(default_factory=set) - ended_id: str | None = None # the most recent identified SessionEnd's id + # Which sessions ended since the last SessionStart: evidence for whose + # "clear" start comes next. + bound_ended: bool = False + foreign_ended: bool = False def admit(self, event: HookEvent) -> bool: """Whether ``event`` belongs to the launched session. Stateful: a SessionStart can bind the session or mark its id foreign.""" sid = event.session_id if event.event == "SessionStart": - ended_id, self.ended_id = self.ended_id, None + bound_ended, foreign_ended = self.bound_ended, self.foreign_ended + self.bound_ended = self.foreign_ended = False if not self.started: self.started, self.bound_id = True, sid return True @@ -162,14 +168,17 @@ def admit(self, event: HookEvent) -> bool: if ( sid not in self.foreign_ids and event.source in REBIND_SOURCES - and not (event.source == "clear" and ended_id in self.foreign_ids) + and not (event.source == "clear" and foreign_ended and not bound_ended) ): self.bound_id = sid return True self.foreign_ids.add(sid) return False if event.event == "SessionEnd" and sid: - self.ended_id = sid + if sid == self.bound_id: + self.bound_ended = True + elif sid in self.foreign_ids: + self.foreign_ended = True return not (sid and sid in self.foreign_ids) diff --git a/tests/test_signals.py b/tests/test_signals.py index b262d4137..6ee58ae00 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -406,6 +406,18 @@ def _event(kind, session_id=None, ts=1, source=None): [True, True, True, True], id="clear-after-own-end-rebinds", # claude ends the old id before a clear start ), + pytest.param( + [ + ("SessionStart", "A"), + ("SessionStart", "B", "startup"), + ("SessionEnd", "A"), + ("SessionEnd", "B"), + ("SessionStart", "C", "clear"), + ("Stop", "C"), + ], + [True, False, True, False, True, True], + id="child-end-racing-the-parent-clear-still-rebinds", + ), pytest.param( [("SessionStart", "A"), ("SessionStart", "B", "resume"), ("Stop", "B")], [True, False, False],