Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 62 additions & 9 deletions src/gpt_voicecoding/adapters/agent/claude/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
RelayReceipt,
RelayRoute,
ReplyWindow,
SessionEnded,
SessionInspection,
SessionLifecycle,
SessionState,
Expand Down Expand Up @@ -343,7 +344,12 @@ def reported(self, target: SessionTarget) -> SessionReport | None:
return self._reported.get(target)

def forget_session(self, target: SessionTarget) -> None:
"""Stop holding a route to one Session. The Session itself is untouched."""
"""Stop holding a route to one Session. The Session itself is untouched.

Called by `_emit` whenever this adapter reports a Session ended, which is
what keeps the caches below bounded by the machine's live Sessions rather
than by everything that has ever registered (#98).
"""
self._channels.pop(target, None)
self._windows.forget(target)
report = self._reported.pop(target, None)
Expand Down Expand Up @@ -478,10 +484,10 @@ def _overlay(
precedence — a decision only the user can supply outranks a permission
call beside it (`legacy@1d32845:bridge/transcript.py:1691-1692`) — and
a hook holding a dialog open never overrides one.
2. **A permission read from the transcript keeps its own fields** and
takes the `approval_id` from the dialog, which is the only place a
handle exists at all. It fills a tool name the record did not carry
and never overwrites one it did.
2. **A permission read from the transcript takes the `approval_id` from
the dialog**, which is the only place a handle exists at all — and
the dialog is then also the authority on which call that handle
belongs to (`_announced_as`).
3. **A dialog with nothing readable behind it is still a stop.** This is
the first-turn case: `PermissionRequest` fires when the dialog opens,
before the `tool_use` record is flushed. The reference implementation
Expand All @@ -505,11 +511,9 @@ def _overlay(
if dialog is None or found.kind is WaitingKind.QUESTION:
return found
if found.kind is WaitingKind.PERMISSION:
tool_name, detail = _announced_as(found, dialog)
return replace(
found,
tool_name=found.tool_name or dialog.tool_name or None,
detail=found.detail or dialog.detail or None,
approval_id=dialog.approval_id,
found, tool_name=tool_name, detail=detail, approval_id=dialog.approval_id
)
_log.info(
"the transcript for %s has not flushed the call behind the dialog parked here; "
Expand Down Expand Up @@ -821,10 +825,59 @@ async def _dial(self, socket_path: Path) -> ChannelConnection:
)

def _emit(self, event: AgentEvent) -> None:
"""Raise one event upward, and let go of a Session that has ended (#98).

**The adapter that says a Session ended is the one that forgets it.**
`forget_session` had no caller: it is not on the `AgentAdapter` seam, and
Bridge Core's `_session_ended` only marks state — so on an engine that
starts at login, every Session that ever registered kept its channel
address, its window and its parsed transcript for the life of the
process, and those records are of files measured at 186 MB on this
machine. It is done here rather than behind a new seam method because
the knowledge is already here, and rather than behind a timer because a
death is an observation and not an age.

Forgetting happens *before* the sink sees the event, so this adapter is
never holding a route to a Session it has already declared ended: a
Relay aimed at one has to fail on the way out rather than be written
into a channel still listed here.
"""
if isinstance(event, SessionEnded):
self.forget_session(event.target)
if self._sink is not None:
self._sink.emit(event)


def _announced_as(found: WaitingFor, dialog: ApprovalRequest) -> tuple[str | None, str | None]:
"""The tool name and detail the announcement uses, when a dialog is parked here.

**The hook payload is the authority on what is parked** (advisor, 2026-08-26,
#98). One assistant message may carry several `tool_use` blocks; the
transcript reading is held up on the newest outstanding one, while the dialog
is parked on whichever call the far side actually stopped to ask about. The
two can name different tools, and the `approval_id` beside them belongs to
the dialog's — so a reading that kept the transcript's name would announce
one tool and carry the user's verdict to another, which is an Approval
delivered to a call they were never shown.

So the dialog's `tool_name` wins whenever it has one, and `detail` follows
the name it describes: **the record's summary is kept only while the two
agree on the call.** "Agree" means the record named the same tool, not
merely that it named none — a record that could not read the tool's name
could still summarise its input, and that summary describes whichever call
the record was held up on rather than the one on screen.
"""
if not dialog.tool_name:
# The hook payload named no tool, so it contradicts nothing the record
# said, and #75's fill-a-gap-never-overwrite rule stands whole.
return found.tool_name or None, found.detail or dialog.detail or None
if found.tool_name == dialog.tool_name:
# One call, read twice. The record read the call's whole input, so its
# summary is the fuller of the two and the dialog's fills a gap.
return dialog.tool_name, found.detail or dialog.detail or None
return dialog.tool_name, dialog.detail or None


def _classify(reply: dict[str, object], request_id: RequestId) -> DeliveryReceipt | None:
"""What one channel reply says about this attempt, or `None` for "keep waiting"."""
kind = reply.get("type")
Expand Down
11 changes: 10 additions & 1 deletion src/gpt_voicecoding/adapters/agent/claude/transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,18 @@ def _parse(text: str) -> tuple[Record, ...]:
A line that does not parse is the record being written right now far more
often than it is a broken file, so it costs itself and nothing else. Anything
that parses to something other than an object is not a record either.

**Split on `"\n"` and on nothing else.** JSONL is newline-delimited, while
`str.splitlines` also breaks on U+2028, U+2029 and U+0085 — and
`JSON.stringify`, which writes these files, leaves all three raw inside a
string. A record carrying one would be cut in two, neither half would parse,
and "costs itself and nothing else" would quietly cost the whole record: a
dropped `tool_result` leaves the call it closes outstanding, which reads as a
permanent false `PERMISSION`. Measured on 2026-08-26 over the 200 most recent
transcripts on this machine: 4 files, 31 records (#98).
"""
records: list[Record] = []
for line in text.splitlines():
for line in text.split("\n"):
if not line.strip():
continue
try:
Expand Down
50 changes: 40 additions & 10 deletions src/gpt_voicecoding/adapters/agent/codex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ def __init__(
#: Prompts already resolved by somebody else, so a late verdict is
#: refused rather than answered into a closed request.
self._resolved: set[Any] = set()
#: Subscription retries in flight, so none outlives this adapter.
self._retrying: set[asyncio.Task[None]] = set()
#: Work this adapter started off a callback that could not await it — a
#: subscription retry, a connection being given back — held so that none
#: outlives the adapter.
self._background: set[asyncio.Task[None]] = set()
self._own = OwnedAppServer(
settings=self._settings,
socket_path=own_socket_path or default_own_socket_path(self._settings),
Expand Down Expand Up @@ -180,9 +182,9 @@ async def aclose(self) -> None:
a machine with two and not on a machine with nine, which is the kind of
bound that passes every test and fails on somebody's Tuesday (#96).
"""
for task in list(self._retrying):
for task in list(self._background):
task.cancel()
self._retrying.clear()
self._background.clear()
watching = list(self._threads.values())
self._threads.clear()
await asyncio.gather(*(self._detached(watched) for watched in watching))
Expand Down Expand Up @@ -743,7 +745,12 @@ def _heard(self, message: Message) -> None:
if isinstance(settings, dict):
watched.read_routing(settings)
case "thread/closed" | "thread/deleted":
self._emit(SessionEnded(target=watched.target, detail=str(method)))
self._drop_and_report_ended(watched, str(method))
# And the connection with it: it was opened to watch this
# thread, and there is no thread left to watch. The other
# emitter has no socket to give back — its far side is what
# went away.
self._spawn(self._detached(watched))

def _asked(self, message: Message) -> None:
"""One server request. Only the permission prompts are ours to hold."""
Expand Down Expand Up @@ -819,12 +826,30 @@ def _connection_lost(self, target: SessionTarget, reason: str) -> None:
restart, and dialling a dead one in a loop would be this adapter
inventing a recovery it cannot perform.
"""
watched = self._threads.pop(target, None)
watched = self._threads.get(target)
if watched is None:
return
self._drop_and_report_ended(watched, reason)

def _drop_and_report_ended(self, watched: WatchedThread, detail: str) -> None:
"""Stop holding one Session, and say that it ended — in that order (#98).

**The adapter that says a Session ended is the one that forgets it.**
`forget_session` had no caller: it is not on the `AgentAdapter` seam, and
Bridge Core's `_session_ended` only marks state — so anything held for a
Session that ended was held for the life of the process. Both emitters of
`SessionEnded` go through here, so a third one cannot be written that
forgets to.

The dropping comes first because a Relay aimed at a Session this adapter
has just declared ended must fail on the way out, rather than be written
down a connection still listed here. What each emitter does about the
*connection* differs, and stays with the emitter.
"""
self._threads.pop(watched.target, None)
watched.subscribed = False
watched.pending.clear()
self._emit(SessionEnded(target=target, detail=reason))
self._emit(SessionEnded(target=watched.target, detail=detail))

async def _resubscribe(self, watched: WatchedThread) -> None:
"""Try again to resume a thread that had nothing to resume."""
Expand All @@ -834,10 +859,15 @@ async def _resubscribe(self, watched: WatchedThread) -> None:
_log.info("could not subscribe to %s: %s", watched.thread_id, unreachable)

def _spawn(self, work: Any) -> None:
"""Run a retry without letting it outlive the adapter that started it."""
"""Run work off a callback, without letting it outlive this adapter.

The callers are the notification handlers, which are synchronous and
cannot await: a subscription retry, and the connection given back when
the thread it was opened for is closed.
"""
task = asyncio.ensure_future(work)
self._retrying.add(task)
task.add_done_callback(self._retrying.discard)
self._background.add(task)
task.add_done_callback(self._background.discard)

def _thread_for(self, thread_id: Any) -> WatchedThread | None:
if not isinstance(thread_id, str):
Expand Down
Loading
Loading