Skip to content

Commit e371ac9

Browse files
fix(hooks): config-less Desktop/SDK bootstrap deadlock (#1019)
Two surgical session-id-anchored edits resolve the bootstrap deadlock under any config-less team substrate (Claude Desktop / Agent-SDK / older-CLI full-UUID): GATE 1 resolver branch-2 (a positive `teams/<session_id>/` substrate probe) + GATE 2 inbox-witness marker fallback. No harness detection, no config self-provision, CLI byte-identical, never-raises preserved. Independently double-verified (two adversarial passes — the first caught that the originally-converged config-absence detection was unsound; the second confirmed completeness). Full suite green (9492 passed / 0 errors); non-vacuity proven by source-only revert. 0-Blocking unanimous review including an independent security PASS (real functions driven against 14 adversarial inputs). Scope: the deadlock only. Completes the deadlock-fix acceptance criteria of #1019; closure pending the deferred manual Desktop validation (negative probe + 7-step manual protocol — this CI environment cannot exercise the real substrate). The secondary silent-HANDOFF-loss is tracked separately in #1020. Version bumped to 4.4.39 (PATCH).
1 parent 32cbdb7 commit e371ac9

10 files changed

Lines changed: 875 additions & 39 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "PACT",
1313
"source": "./pact-plugin",
1414
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
15-
"version": "4.4.38",
15+
"version": "4.4.39",
1616
"author": {
1717
"name": "Synaptic-Labs-AI"
1818
},

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache:
605605
│ └── cache/
606606
│ └── pact-plugin/
607607
│ └── PACT/
608-
│ └── 4.4.38/ # Plugin version
608+
│ └── 4.4.39/ # Plugin version
609609
│ ├── agents/
610610
│ ├── commands/
611611
│ ├── skills/

pact-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "PACT",
3-
"version": "4.4.38",
3+
"version": "4.4.39",
44
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
55
"author": {
66
"name": "Synaptic-Labs-AI",

pact-plugin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PACT — Orchestration Harness for Claude Code
22

3-
> **Version**: 4.4.38
3+
> **Version**: 4.4.39
44
55
Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically.
66

pact-plugin/hooks/bootstrap_marker_writer.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,24 +200,59 @@ def _suppress_output(event_name: str) -> str:
200200

201201

202202
def _team_has_secretary(team_name: str) -> bool:
203-
"""Return True iff the team config contains a member with
204-
``name == "secretary"``.
203+
"""Return True iff the secretary has joined the team — proven by EITHER the
204+
config.json members[] roster OR (config-less fallback) the secretary's
205+
inbox file.
205206
206207
Pre-condition for marker write. Returns False silently on any I/O
207208
error, malformed JSON, or missing-secretary case — the sibling
208209
bootstrap_prompt_gate owns the user-visible advisory.
209210
210-
Built on the shared ``pact_context._iter_members`` helper, so the
211-
JSON-shape adversarial-input semantics (missing config, malformed
212-
JSON, non-list members, non-dict member entries, missing keys)
213-
match those of the id-keyed
214-
``pact_context._lookup_agent_in_team_config`` consumer
215-
byte-for-byte. The predicate stays distinct: this one filters on
216-
the member ``name`` field; the lookup filters on member ``id``.
211+
PRIMARY (CLI byte-identical): the members[] check via the shared
212+
``pact_context._iter_members`` helper, so the JSON-shape adversarial-input
213+
semantics (missing config, malformed JSON, non-list members, non-dict
214+
member entries, missing keys) match those of the id-keyed
215+
``pact_context._lookup_agent_in_team_config`` consumer byte-for-byte. The
216+
predicate stays distinct: this one filters on the member ``name`` field;
217+
the lookup filters on member ``id``. Tried FIRST, so under CLI (config
218+
present) the inbox arm is never reached.
219+
220+
CONFIG-LESS FALLBACK: under the Desktop / older-CLI / print
221+
substrate the platform creates ``teams/<full-uuid>/`` with ``inboxes/`` but
222+
no ``config.json`` — so members[] is empty and the marker would deadlock.
223+
Accept ``teams/<team_name>/inboxes/secretary.json`` as the witness.
224+
225+
The inbox witnesses the secretary was DISPATCHED, not that it has joined a
226+
members[] roster — and that is the right signal here. PACT's bootstrap is
227+
``TaskCreate -> TaskUpdate(owner) -> Agent(secretary)`` with NO pre-spawn
228+
``SendMessage`` to the secretary, so the platform writes the secretary's
229+
inbox file only on delivery of a message to an already-dispatched
230+
secretary; it cannot predate the spawn within PACT's choreography. And the
231+
gated tools (Edit/Write/Agent) do not hard-require a LIVE secretary at
232+
unblock time — the one liveness contract (memory queries) is enforced by
233+
awaiting a ``SendMessage`` reply, not by this marker. So a dispatch-level
234+
witness is sufficient and correct for the marker precondition.
235+
236+
Fail-safe ``False`` on any error in either arm (the inbox ``is_file`` probe
237+
is wrapped so an unexpected FS error degrades to the existing silent-False
238+
semantic).
217239
"""
218240
for member in pact_context._iter_members(team_name):
219241
if member.get("name") == _SECRETARY_NAME:
220242
return True
243+
# Config-less fallback: the secretary's inbox file is the witness
244+
# when no config.json members[] roster exists. team_name is get_team_name()
245+
# output (path-safe by construction); the read is wrapped so any FS error
246+
# preserves the fail-safe False.
247+
try:
248+
teams_dir = pact_context.get_claude_config_dir() / "teams"
249+
secretary_inbox = (
250+
teams_dir / team_name / "inboxes" / f"{_SECRETARY_NAME}.json"
251+
)
252+
if secretary_inbox.is_file():
253+
return True
254+
except Exception:
255+
return False
221256
return False
222257

223258

pact-plugin/hooks/dispatch_gate.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,13 @@ def _team_member_names(team_name: str) -> set[str]:
263263
architect §5 contract intentionally did NOT include this in
264264
dispatch_helpers.py because task_lifecycle_gate has no need for the
265265
member roster.
266+
267+
ACCEPTED CONFIG-LESS GAP: under the config-less Desktop/SDK
268+
substrate there is no ``config.json``, so this returns ``set()`` and the
269+
name-uniqueness rule degrades to "no collision detected". This is an
270+
ACCEPTED degradation, not a deadlock: the rule fails OPEN (permits), so
271+
dispatch is not blocked; only the (advisory) duplicate-name check is
272+
silently inactive in that substrate.
266273
"""
267274
cfg_path = get_claude_config_dir() / "teams" / team_name / "config.json"
268275
try:

pact-plugin/hooks/shared/pact_context.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -362,15 +362,18 @@ def _resolve_aligned_team_name(
362362
A typed outer tuple would LEAK the RuntimeError/TypeError above and break
363363
never-raises, which is why the outer guard is a bare ``except Exception``.
364364
365-
NOTE — ``session_id`` is NOT a raise source here. It is used ONLY as a
366-
string compared against ``config.json['leadSessionId']`` (and an empty
367-
check); it is NEVER composed into a ``Path``. So a path-unsafe raw
368-
``session_id`` (embedded ``/`` or NUL) does NOT raise in this function —
369-
it simply never equals any stored ``leadSessionId`` -> NO MATCH ->
370-
``default``. The path-safety gate is applied instead to the matched DIR
371-
NAME (``is_safe_path_component`` below), which IS used as a path segment.
372-
The bare-except precedents in this module are ``persist_context`` and
373-
``heal_context_if_missing``.
365+
NOTE — ``session_id`` is NOT an uncaught raise source here. In the
366+
identity-match loop it is used ONLY as a string compared against
367+
``config.json['leadSessionId']`` (and an empty check). In the branch-2
368+
fallthrough below it IS composed into a ``Path`` (``teams_root /
369+
session_id``), but ONLY after ``is_safe_path_component(session_id)`` gates
370+
it as the FIRST conjunct — so a path-unsafe raw ``session_id`` (embedded
371+
``/`` or NUL) is rejected by that gate and falls through to ``default``,
372+
never reaching the composition. The subsequent ``is_dir()`` / ``exists()``
373+
probes raise at most an ``OSError``-family error, which the outer bare
374+
``except`` catches. The path-safety gate also guards the matched DIR NAME
375+
in the loop (``is_safe_path_component`` there). The bare-except precedents
376+
in this module are ``persist_context`` and ``heal_context_if_missing``.
374377
375378
PERF (SessionStart hot-path scan cost): on a MATCH the scan stops at the
376379
first matching dir; on NO MATCH it iterates EVERY team dir under
@@ -427,15 +430,40 @@ def _resolve_aligned_team_name(
427430
# This dir is unreadable / malformed — skip it, keep scanning
428431
# the rest. A single bad sibling must not abort detection.
429432
continue
433+
# Branch-2: config-less full-UUID divergence (Desktop child / older-CLI
434+
# / print). The identity-match loop above missed because no team dir
435+
# carries a config.json with this leadSessionId — but the platform may
436+
# still have created teams/<session_id>/ (full UUID) with inboxes/ and
437+
# no config.json. Anchor on the harness-invariant session_id directly:
438+
# if a real own-session substrate exists, resolve to it. is_safe_path_
439+
# component(session_id) is the FIRST conjunct (guard-order — short-
440+
# circuit before any FS probe; a path-unsafe session_id never reaches a
441+
# Path composition). The inboxes/ | file-edits.json witness proves the
442+
# platform built a genuine team substrate for this session (not a bare
443+
# dir). Unreachable under new-CLI: the platform names the dir
444+
# session-<id8>, so teams/<full-uuid>/ never exists (steady-state AND
445+
# the ~38s cold-start) -> CLI byte-identical.
446+
if (
447+
is_safe_path_component(session_id)
448+
and (teams_root / session_id).is_dir()
449+
and (
450+
(teams_root / session_id / "inboxes").is_dir()
451+
or (teams_root / session_id / "file-edits.json").exists()
452+
)
453+
):
454+
return session_id
430455
return fallback
431456
except Exception:
432457
# TOTAL fail-safe: home-resolution RuntimeError (get_claude_config_dir
433458
# -> Path.home, the teams_dir=None branch), a non-str teams_dir
434459
# TypeError (Path(teams_dir)), or any other unexpected error -> the
435-
# persisted/computed default. (session_id is NOT a raise source here —
436-
# it is only string-compared to leadSessionId, never composed into a
437-
# Path; see the NOTE in the docstring above.) NEVER raises —
438-
# get_team_name and the heal path depend on this contract.
460+
# persisted/computed default. (In the identity-match loop session_id is
461+
# only string-compared to leadSessionId; the branch-2 fallthrough DOES
462+
# compose teams_root / session_id, but only AFTER is_safe_path_component
463+
# gates it, and the is_dir()/exists() probes raise at most an
464+
# OSError-family error that THIS except catches — so session_id is still
465+
# not an uncaught raise source; see the NOTE in the docstring above.)
466+
# NEVER raises — get_team_name and the heal path depend on this contract.
439467
if default is not None:
440468
return default
441469
try:
@@ -479,12 +507,30 @@ def get_team_name() -> str:
479507
return _aligned_cache
480508
# Read the persisted SSOT first. An empty value is the fail-closed signal
481509
# (see the security-gate note above) — short-circuit BEFORE identity-match.
510+
#
511+
# LATENT COUPLING (the config-less fix depends on this): the
512+
# _resolve_aligned_team_name BRANCH-2 config-less fallback (session-id-
513+
# anchored teams/<uuid>/ resolution) is reached ONLY through the non-empty
514+
# path below. So branch-2's config-less reachability DEPENDS on the
515+
# persisted team_name being non-empty here. That holds today because
516+
# session_init persists a non-empty computed default (generate_team_name ->
517+
# session-<id8>, threaded as the resolver default at session_init main()).
518+
# If a future change ever persisted an EMPTY team_name, this short-circuit
519+
# would return '' BEFORE branch-2 ran and the config-less Desktop/SDK fix
520+
# would SILENTLY stop firing (the deadlock would return) — with no error,
521+
# because '' is the legitimate fail-closed "team unknown -> refuse" signal.
522+
# Do NOT "fix" that by recovering a team from an empty SSOT here: that would
523+
# break the deliberate fail-closed gate (test_empty_ssot_team_fails_closed_
524+
# both_modes). The correct invariant to preserve is upstream: keep the
525+
# persisted SSOT non-empty for a real session.
482526
ctx_team = get_pact_context().get("team_name", "")
483527
if not ctx_team:
484528
_aligned_cache = ""
485529
return _aligned_cache
486530
# Non-empty SSOT: identity-match can UPGRADE it to the real platform dir
487-
# (or no-op back to ctx_team on a cold-start / no-match).
531+
# (or no-op back to ctx_team on a cold-start / no-match). This is also the
532+
# ONLY path that reaches the branch-2 config-less fallback (see the LATENT
533+
# COUPLING note above).
488534
resolved = _resolve_aligned_team_name(get_session_id(), default=ctx_team)
489535
_aligned_cache = resolved.lower()
490536
return _aligned_cache

0 commit comments

Comments
 (0)