5252# most), while keeping the marker filename short.
5353_OCCUPANT_HASH_LEN = 16
5454
55+ # Default O_EXCL marker directory name — the agent_handoff event family's
56+ # namespace. Keyword-only `namespace` parameters below default to this so
57+ # every pre-existing caller resolves a byte-identical marker path; a sibling
58+ # event family (task_metadata_snapshot) passes its own module-constant
59+ # namespace through hard-bound wrappers so the two families' dedup markers
60+ # can never suppress each other. Namespace values are module constants,
61+ # never input-derived.
62+ _DEFAULT_MARKER_NAMESPACE = ".agent_handoff_emitted"
63+
5564# Signal-task types — tasks that MUST NOT emit a phantom agent_handoff event
5665# (a blocker/algedonic completion is a control signal, not a HANDOFF; emitting
5766# would pollute read_events("agent_handoff") + mis-route secretary harvest).
@@ -123,24 +132,31 @@ def occupant_hash(teammate_name: str, task_subject: str) -> str:
123132 return hashlib .sha256 (payload .encode ("utf-8" )).hexdigest ()[:_OCCUPANT_HASH_LEN ]
124133
125134
126- def _marker_dir (team_name : str ) -> Path :
135+ def _marker_dir (
136+ team_name : str , * , namespace : str = _DEFAULT_MARKER_NAMESPACE
137+ ) -> Path :
127138 """
128- Return the per-team marker directory path.
139+ Return the per-team marker directory path for one event-family namespace .
129140
130- Lives under ~/.claude/teams/{team}/.agent_handoff_emitted/ — a sibling
131- to the team's inboxes/ and config.json. session_end.py's team reaper
132- removes the whole team directory (shutil.rmtree), so the marker dir is
133- cleaned up automatically when the team ages out.
141+ Lives under ~/.claude/teams/{team}/{namespace}/ (default
142+ .agent_handoff_emitted) — a sibling to the team's inboxes/ and
143+ config.json. session_end.py's team reaper removes the whole team
144+ directory (shutil.rmtree), so every namespace's marker dir is cleaned
145+ up automatically when the team ages out.
134146
135147 Kept task-scoped (not session-scoped) so fire-once semantics survive
136148 pause/resume: a secretary standing task that spans sessions must emit
137149 its agent_handoff event exactly once across the whole team lifespan.
138150 """
139- return get_claude_config_dir () / "teams" / team_name / ".agent_handoff_emitted"
151+ return get_claude_config_dir () / "teams" / team_name / namespace
140152
141153
142154def _resolve_marker_target (
143- team_name : str , task_id : str , occupant : str
155+ team_name : str ,
156+ task_id : str ,
157+ occupant : str ,
158+ * ,
159+ namespace : str = _DEFAULT_MARKER_NAMESPACE ,
144160) -> "tuple[int | None, str | None]" :
145161 """
146162 Sanitize + validate + pin the marker directory; return the open directory
@@ -195,7 +211,7 @@ def _resolve_marker_target(
195211 ):
196212 return None , None
197213
198- marker_dir = _marker_dir (team_name )
214+ marker_dir = _marker_dir (team_name , namespace = namespace )
199215 # Symlink-containment pre-check: if marker_dir already exists as a
200216 # symlink, refuse to use it (a pre-planted symlink could redirect marker
201217 # creation outside the team directory). Fail-open emit rather than risk
@@ -251,7 +267,13 @@ def _resolve_marker_target(
251267 return dir_fd , f"{ task_id } -{ occupant } "
252268
253269
254- def already_emitted (team_name : str , task_id : str , occupant : str ) -> bool :
270+ def already_emitted (
271+ team_name : str ,
272+ task_id : str ,
273+ occupant : str ,
274+ * ,
275+ namespace : str = _DEFAULT_MARKER_NAMESPACE ,
276+ ) -> bool :
255277 """
256278 Test-and-set the per-(team, task_id, occupant) marker.
257279
@@ -285,7 +307,9 @@ def already_emitted(team_name: str, task_id: str, occupant: str) -> bool:
285307 compensating-unclaim closes the OTHER suppression source (a marker this
286308 process claimed but whose journal write then failed) — see unclaim().
287309 """
288- dir_fd , filename = _resolve_marker_target (team_name , task_id , occupant )
310+ dir_fd , filename = _resolve_marker_target (
311+ team_name , task_id , occupant , namespace = namespace
312+ )
289313 if dir_fd is None :
290314 # Degenerate key / symlink-guarded / unresolvable → fail-open emit.
291315 return False
@@ -307,7 +331,13 @@ def already_emitted(team_name: str, task_id: str, occupant: str) -> bool:
307331 os .close (dir_fd )
308332
309333
310- def unclaim (team_name : str , task_id : str , occupant : str ) -> None :
334+ def unclaim (
335+ team_name : str ,
336+ task_id : str ,
337+ occupant : str ,
338+ * ,
339+ namespace : str = _DEFAULT_MARKER_NAMESPACE ,
340+ ) -> None :
311341 """
312342 Compensating rollback (#901, R1) — remove the marker THIS process just
313343 created when the subsequent journal write FAILED (append_event returned
@@ -332,7 +362,9 @@ def unclaim(team_name: str, task_id: str, occupant: str) -> None:
332362 Worst case the marker persists and behavior reverts to today's (a poisoned
333363 marker) — strictly no worse than not having the rollback. Never raises.
334364 """
335- dir_fd , filename = _resolve_marker_target (team_name , task_id , occupant )
365+ dir_fd , filename = _resolve_marker_target (
366+ team_name , task_id , occupant , namespace = namespace
367+ )
336368 if dir_fd is None :
337369 return
338370 try :
0 commit comments