fix(phone): accept is_reconnect in PhoneAdapter.connect — unbreak two-way reply

The gateway's platform supervisor calls adapter.connect(is_reconnect=...)
(BasePlatformAdapter.connect contract, gateway/run.py:3195). PhoneAdapter.connect
was declared connect(self), so every gateway connect raised TypeError, the
adapter never came up, and the Phase 2c inbound reply long-poll
(_run_reply_loop, spawned only inside connect()) never started. Net: the phone's
replies reached the relay and sat buffered forever (0 GET /phone/replies polls);
the agent never saw them and never answered back.

The 2026-06-29 device round-trip surfaced it: agent->phone push worked
(relay->phone), the reply buffered in the relay (proactive <<< ... buffered=1),
but the gateway never drained it.

- Add `*, is_reconnect: bool = False` to PhoneAdapter.connect (matches the base
  contract + the ntfy template it was modeled on). Behavior otherwise unchanged.
- Add ConnectContractTests regression guard (inspect-based — the live adapter
  binds to the gateway base class that's absent in CI, which is the blind spot
  that let this ship).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-06-29 12:23:06 -04:00
co-authored by Claude Opus 4.8
parent 64c0c25c25
commit 3018a186c9
2 changed files with 43 additions and 1 deletions
+11 -1
View File
@@ -289,12 +289,22 @@ class PhoneAdapter(BasePlatformAdapter): # type: ignore[misc,valid-type]
# -- Connection lifecycle ----------------------------------------------
async def connect(self) -> bool:
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Open the outbound HTTP client and start the inbound reply loop.
Outbound is loopback POSTs (no socket held). Inbound is a background
long-poll against the relay's ``/phone/replies`` — the phone's replies
are buffered by the relay (different process) and drained here.
The gateway's platform supervisor invokes ``connect(is_reconnect=...)``
(the ``BasePlatformAdapter.connect`` contract, forwarded from
``gateway/run.py``), so the keyword MUST be accepted. A bare
``connect(self)`` raises ``TypeError`` at connect time — the adapter
never comes up and the inbound reply loop never starts, so the relay
buffers replies that nothing drains (the Phase 2c round-trip silently
dies here). ``is_reconnect`` does not change behavior today: we always
drain the relay's small bounded reply buffer. Using it to drop stale
replies on a cold boot is a possible future refinement.
"""
if not HTTPX_AVAILABLE:
logger.warning("[%s] httpx not installed — cannot push to phone", self.name)
+32
View File
@@ -351,5 +351,37 @@ class NormalizeReplyTests(_EnvIsolated):
self.assertIsNone(pp._normalize_reply(["x"], "home"))
# ── Adapter ↔ gateway connect contract (Phase 2c regression) ───────────────
class ConnectContractTests(unittest.TestCase):
"""Guard the gateway's ``connect(is_reconnect=...)`` call contract.
The gateway supervisor calls ``adapter.connect(is_reconnect=is_reconnect)``
(``BasePlatformAdapter.connect`` / ``gateway/run.py``). When
``PhoneAdapter.connect`` was declared ``connect(self)``, that call raised
``TypeError`` at connect time — the adapter never came up and the inbound
reply loop never started, so the two-way reply round-trip silently died
with the user's reply stuck buffered in the relay. The live ``PhoneAdapter``
binds to the gateway base class (absent here), so we assert via ``inspect``
rather than instantiating it.
"""
def test_connect_accepts_is_reconnect_keyword(self) -> None:
import inspect
sig = inspect.signature(pp.PhoneAdapter.connect)
self.assertIn(
"is_reconnect",
sig.parameters,
"PhoneAdapter.connect must accept is_reconnect — the gateway always "
"passes it; a bare connect(self) raises TypeError and the inbound "
"reply loop never starts.",
)
param = sig.parameters["is_reconnect"]
self.assertEqual(param.kind, inspect.Parameter.KEYWORD_ONLY)
self.assertEqual(param.default, False)
if __name__ == "__main__":
unittest.main()