Skip to content

Ask one Session how far it has got, and bound the answer once for both lanes (#76) - #101

Merged
Simon-AI-coding merged 7 commits into
mainfrom
issue-76-progress
Aug 26, 2026
Merged

Simon-AI-coding merged 7 commits into
mainfrom
issue-76-progress

Conversation

@Simon-AI-coding

@Simon-AI-coding Simon-AI-coding commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #76. Build 3/7 of the signed port table: P6, read-only progress inspection, plus the control-plane verb over it.

Provenance

Base 37cf18e (the branch was written there; rebased onto 663cf1b before this PR)
Standards review 3a79826c-7fa0-487b-b5d7-0cc1e79b67ff — no hard violations
Spec review 8e858938-2903-42ac-a86b-cddeeca2173e — 4 findings, all closed (re-review)
Acceptance 20260826T074208Z, bundle byte-identical to 2dfcefe
Gates all six .github/workflows/ci.yml run: lines green

Advisor rulings cited, by ticket comment on #76 — not by this document:

Two decisions made in-session and upheld: PROTOCOL_VERSION stays 4 (adding an action is additive in the direction the version guards; removing one is not), and the reviewer's "one action touches six dispatch tables → use an action descriptor object" was declined, with no ticket.

What is here

Progress.recent widens to ProgressEntry(role, text), because a roster of bare strings reads "make it blue" and "I made it blue" the same way. adapters/agent/_progress.py holds the bound once, for both lanes: how many entries travel is a property of the type and of the wire, not of either lane.

Both lanes answer Progress and last_activity, in two different places on purpose. The roster is the cheap projection — discover runs every five seconds over the whole machine, so it reads only rows that are not mid-turn. The verb reads now, and reads whatever state it finds, because "how far along is it" is the question a user asks precisely while a Session works.

On the Codex lane that gate is worth 558,875 bytes: measured against the real daemon on 0.149.1, thread/read with includeTurns: true answered that for a thread of two turns where the same read without them answered 3,600, and numTurns is not a parameter of that method. Hence TurnCache, keyed on the thread's own updatedAt — the same argument transcript.py already makes from (size, mtime_ns).

CodexAgentAdapter._shared_daemon() stops returning None unconditionally. Join-only (#83, ADR 0012): this product starts a daemon the user's Sessions will join and never stops one they are attached to.

bridgectl progress <target> is the eighth control-plane action. It refuses rather than answering emptily — LaneUnreadable, StaleSessionError, ProgressUnavailable — because "nobody could read it" and "it has said nothing" are different facts, and a surface handed the first as the second renders a working Session as an idle one.

One thing a reviewer will be tempted to "fix" — it is deliberate

BridgeCore.progress ends no roster row, even when the lane reports the Session gone. SessionRegistry.observe is the only thing that ends a row. The value inspect returns for a Session it could not find carries no workspace and no name, so folding it in strips the fields a surface needs to say what happened. The next discovery ends it properly within one cadence.

Acceptance: what moved, and what is gated elsewhere

gpt-voicecoding-0f ran this same suite twice at 2a7f535 and got identical cards step-for-step on both lanes, so anything that moved here is this diff.

step 2a7f535 (×2) 2dfcefe
claude progress FAIL — progress for claude:…:98262 is None after a turn that ran PASS
codex roster address codex:None:17102 codex:01a03d13-9906-73e1-90c1-1e6820021b97:86492

claude progress evidence:

progress read without a turn, through bridgectl progress and the roster row: 'user: Reply with the single word READY…, assistant: READY'; record steady at 41805 bytes; state 'idle' → 'idle'

The second row is Q1 landing: engine.log says joined the shared Codex daemon at ~/.codex/app-server-control/app-server-control.sock, and the roster row's session id is the daemon's own thread id. On main no Codex Session could be identified at all.

Codex progress is red, and the cause is not in this diff. The chain, established rather than inferred:

  1. journey.py:433-441 — stable_name() raises StepFailed before it reaches self._drive_turn("acknowledge", ACKNOWLEDGE).
  2. Codex stable name fails: has no name in the roster after 3 reads — #78 requires the official one.
  3. So no turn is ever driven on the codex lane, and stop notice reports exactly that and skips the remaining five steps.
  4. Read straight off the live daemon afterwards, the thread holds turns: [] and name: null. The Session genuinely never said anything.
  5. Therefore codex progress cannot go green for any diff until a name exists, and the name is Build 5/7 — Session naming: official names, immutable labels, self-report only for unnamed Codex #78's.

Advisor ruling (Q6): this lands, and re-verifying the codex half is an explicit exit item on #78, with run 20260826T074208Z as the evidence.

One observation, reported unclosed

During the run, thread/read with includeTurns: true failed 3/3 on the daemon-held thread: thread/read failed: list_turns is not supported yet. Read-only against the same thread after the TUI exited, the same call succeeds. The obvious hypothesis is that the daemon proxies a live thread to its owning app-server; the mechanism is not confirmed — three attempts to construct the live case failed, because a hand-started codex registered no thread with the daemon within 90–120s. It did not cause the red (turns were [] regardless). Advisor: no ticket — an unconfirmed mechanism is an observation. Recorded on #78's exit item and cross-noted on #82.

Every other claude step is unchanged from main and belongs to #77–#80.

Reconciling with #96, which landed while this was in review

Two semantic conflicts, not textual ones:

Overlap with main's new commits is codex/adapter.py, codex/discovery.py and core/bridge.py; none of #96's process.py, server.py or the Telegram adapter. The acceptance was re-run after the rebase rather than carried over from before it.

The acceptance is at 2dfcefe and the tip is 1bcf34f. That one commit changes the text of a degraded constant on the Codex lane and its test, and nothing else — no graded step reads degraded, and the claude lane never sees it. Said out loud rather than left for a reader to work out, because "the tree moved under the evidence" is the failure this branch has already paid for twice.

Against the reference implementation

Ported: the tail read (legacy@1d32845:bridge/transcript.py:1184-1246), the bounding rule (:2828-2860), the Codex item selection (bridge/codex.py:1465-1520), the verb's one-identity-one-lane rule and its refusal (bridge/daemon.py:2202-2271,2222-2246). Adapted: legacy raised on an unrecognised Codex item, which was right for a per-Session verb and wrong for a roster row; its bounds were a per-Session verb's (12 / 32 KB) against a reply with a 64 KB ceiling. Dropped: the shared-daemon client has no legacy counterpart — gen 1 drove a per-Session app-server it owned; and turn_id / turn_status, because no v1.0 consumer reads them.

🤖 Generated with Claude Code

Simon-AI-coding and others added 6 commits August 26, 2026 19:35
…76)

Build 3/7 of the signed port table: P6, read-only progress inspection. This
commit is the vocabulary and the two pure readers; the wiring follows.

`Progress.recent` widens from `tuple[str, ...]` to `ProgressEntry(role, text)`.
#76's shape asks for "bounded recent user/assistant entries" and #74's type
carried no role, so porting the one into the other would have dropped it — and
a roster of bare strings reads "make it blue" and "I made it blue" the same
way, leaving every surface to guess. Legacy carried the role to the wire
(`legacy@1d32845:bridge/transcript.py:40-46`). Widened through the seam's own
rule rather than beside it (`seams/agent.py:35-41`). Advisor ruling on the
ticket, Q4.

`adapters/agent/_progress.py` holds the bound, and holds it once. Each lane
finds its entries its own way; how many of them travel is a property of the
type and of the wire, not of either lane. Three entries and 3 KB per row —
legacy's 12 and 32 KB were a per-Session verb's (`config.plist:449-452`),
where this rides on **every** roster row of a reply with a 64 KB ceiling
(`control_plane/client.py:88-94`). The rule is ported whole from
`legacy@1d32845:bridge/transcript.py:2828-2860`: keep the newest, widen down
until it fits, say so when anything was dropped. `drop_oversize_tail` is the
only behaviour here rather than an option — a reply nobody can read is worse
than a row that admits it is empty. Advisor ruling, Q2.

`claude/transcript_tail.py` reads the records `transcript.py` already opened.
`Progress` and `last_activity` are two fields because they are two facts: a
Session that spent a minute running tools has moved without saying anything a
reader would show, so the time comes off *any* record — a subagent writing
there is this Session running — and the entries only off the conversation.

`stop_analysis._speaks` becomes a public `visible_text`, and `_is_visible` /
`_is_pipeline_noise` become public with it. `_speaks`'s own docstring already
pointed at this ticket as "its own reader"; answering the tail boundary from a
second scan of the same content is how the reference implementation ended up
with readers that disagreed about one record. One extractor now, and the
boundary is `bool(visible_text(...))`. No behaviour change, proved by #75's 88
tests passing untouched.

`codex/thread_tail.py` is the same reading over what the shared daemon says.
Item selection ported from `legacy@1d32845:bridge/codex.py:1465-1520`;
**adapted** in one place, deliberately — legacy *raised* on an item type it did
not recognise (`:1519`), which was right for a verb about one Session and wrong
for a reading that now rides on a roster row, where one unknown item would
blank a row the user is looking at. `turn_id` and `turn_status` are **dropped,
because** no v1.0 consumer reads them (`:1484-1492,1516-1520`).

Every Codex shape here was measured against the real daemon on 0.149.1
(2026-08-26), not assumed: turn items are `agentMessage` / `userMessage` /
`reasoning` / `commandExecution`, and `updatedAt` is epoch **seconds**
(`1787712279` = 2026-08-26T02:44:39Z) where legacy's reader never read a time
at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CodexAgentAdapter._shared_daemon()` returned `None` unconditionally, so every
Codex row came off the process table and no thread could be read at all —
which made progress on that lane `None` by construction and its acceptance
step red for a reason no amount of building #76 could reach. #82 established
the daemon as the only source of a thread's own truth and #83 installed the
login job that starts it; nothing ever connected the two. Advisor ruling on the
ticket, Q1: the client is one thing, and whoever needs it first builds it once.

`adapters/agent/codex/shared_daemon.py`, read side only. The address comes from
the daemon itself — `codex app-server daemon version` names the socket it is
listening on, which cannot go stale under a moved `CODEX_HOME` or an updated
managed binary — ported from #82's prototype
(`scripts/prototype_codex_daemon.py:51-68,442-454`), where the shape was proved
against a real daemon. The transport already existed: `AppServerConnection`
speaks WebSocket-framed JSON-RPC over a Unix socket, and `process.attach()` is
the become-one-more-client verb, private-socket checks included.

**Join-only, and that is a rule rather than a scope note.** This product starts
a daemon the user's Sessions will join and never stops one they are attached to
(#83, ADR 0012): by the time the engine shuts down, the user's `codex` TUIs are
its clients. `aclose` lets go of this engine's end and nothing else. Asserted on
what the module *runs* rather than on its source text — the one daemon
subcommand it asks for is the one that only reports.

**Against legacy: dropped, because gen 1 had no shared daemon to join.** It
drove a launched, wrapped, per-Session app-server it owned
(`legacy@1d32845:bridge/codex.py:1319-1347`). What survives is the shape of the
read side: locate, connect, ask, and never fall back to another source.

No version pin (Simon's ruling on #67): a CLI and a daemon that disagree still
get dialled, and the disagreement rides out as a note on `LaneDiscovery.
degraded` rather than the lane going quiet. Both versions are checked for being
*said* before they are compared, because a document carrying neither field
would otherwise report a daemon that said nothing as one whose versions agree.

The address is looked up only when there is no live connection, so a healthy
engine spawns no subprocess per tick; an engine whose daemon is down probes once
per discovery, measured at 139 ms, which is the only honest way to notice it
came back. A connection the far side dropped is never reused — the daemon's own
updater restarts it.

Proven live, read-only, on this machine: located
`~/.codex/app-server-control/app-server-control.sock`, `cliVersion` and
`appServerVersion` both `0.149.1`, a real `AppServerConnection` through
`attach()`'s checks, and `discover()` answering `enumerated=True, degraded=None`
where every run before it carried the no-daemon note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and (#76)

Both lanes now answer `Progress` and `last_activity`, and they answer them in
two different places on purpose.

**The roster is the cheap projection.** `discover` runs every five seconds over
the whole machine, so it reads only rows that are not mid-turn — #75's rule,
which #76 rides on rather than widening. On the Claude lane that costs nothing
new: the same one transcript read already open for #75 answers both questions
at the same moment, which is the whole reason `transcript.py` exists.

On the Codex lane it costs a great deal, and the number is why `TurnCache` is
here. Measured against the real daemon on 0.149.1 (2026-08-26): `thread/read`
with `includeTurns: true` answered **558,875 bytes** for a thread of two turns,
where the same read without them answered 3,600 — and `numTurns` is not a
parameter of that method, so there is no smaller answer to ask for. Gated but
uncached, a machine of three stopped Sessions would spend ~1.7 MB a tick on an
answer that had not changed. So the reading is cached on the thread's own
`updatedAt`, which is the argument `transcript.py` already makes from
`(size, mtime_ns)`: a thread nobody has touched cannot have changed what its
tail says, and the key is the far side's own account rather than a clock this
module would have to guess the length of. A thread naming no `updatedAt` is
never cached; the cache is pruned to the daemon's roster each tick.

`last_activity` comes off the *cheap* read, so a thread mid-turn has one even
though it has no progress — which is exactly the case the field exists for.

**The verb reads now, and reads whatever state it finds.** `inspect` has no
`RUNNING` gate, because "how far along is it" is the question a user asks
precisely while a Session works. Advisor ruling on the ticket, Q3.

Making it ungated exposed a bug the gate had been hiding: on a working Session
the transcript tail routinely holds a `tool_use` with no result yet, and that
is a tool *running*, not a dialog waiting on the user. `_read_into` reads a
`RUNNING` row for progress and never for a stop.

Nothing bypasses the Claude reader's cache, and nothing needs to — a hit means
the file has not been written to since, so it *is* the freshness proof, and
forgetting the parse would cost a second pass over a file measured at 186 MB
here for no gain. The ruling was amended on the ticket to say so.

Codex takes exactly one deep read per ask, and getting there took two goes.
`inspect` enumerates before it answers, so the cadence's read is part of what
one ask costs; the first draft paid for two. It now drops that thread's
remembered turns *before* enumerating, so the enumeration takes the one fresh
read and `_turns_into` keeps a reading the row already carries. A thread
mid-turn is gated out of that read and picked up by `_turns_into` — still one.

An unattached row keeps `progress=None` and never a guessed one: its rollout is
on disk and reading it would be a second source answering with worse evidence,
which the port table left behind (P6, P13).

`_shared_daemon()` is restored as the lane's single door to the daemon, because
there is what its pre-wire `FAILED` means.

The process table is injectable for the reason the daemon is: the test that
proves one-read-per-ask has to run the real enumeration, and a unit test that
shelled out to `ps` would answer differently depending on what the person
running it has open. `FakeAgent.inspect` now answers `ENDED` for a Session it
did not find, which is what both real adapters answer — a fake that returned a
live row let a caller pass a test the product fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…can (#76)

`progress` is the eighth control-plane action and the last piece of P6: the
port table asked for "a thin Core/control-plane `progress` verb" over the
inspection type, and `bridgectl progress <agent>:<id>[:<pid>]` is it.

`BridgeCore.progress` resolves one exact identity, asks that lane and no other,
and returns the same `Session` row `status` renders — the reference
implementation's own rule, ported: `legacy@1d32845:bridge/daemon.py:2202-2271`
resolved one registered identity, asked only that agent's authority, and never
fell back to another lane, a terminal or a screen. It is a read and costs no
turn, which `core/router.py:31-32` already said of the whole class.

**It refuses rather than answering emptily**, which is #76's "honest error for
an unattached or ended row" and the line the whole action is drawn around:

- The lane could not be read at all — `LaneUnreadable`, carrying the lane's own
  words. The roster's row stands: not being able to look is not a sighting.
- The Session has ended — `StaleSessionError`.
- Nothing could read how far it has got — `ProgressUnavailable`: an unattached
  Codex Session, or one whose first turn has written no record yet (#73).

A Session that *was* read and had said nothing is none of those. It answers
normally with an empty reading, and a surface handed the second as the first
would render a working Session as an idle one. `ProgressUnavailable` is ported
from legacy's own rejection for this verb (`bridge/daemon.py:2222-2246`),
adapted from a reply document to a refusal on the closed error set.

**The verb ends nothing.** `SessionRegistry.observe` stays the only thing that
ends a row, which is what its own docstring claimed and what an early draft of
this verb broke: the value `inspect` answers for a Session it could not find
carries no workspace and no name, so folding it in stripped the very fields a
surface needs to say what happened. A later reader will be tempted to "fix"
this; it is deliberate, and the next discovery ends the row within one cadence.
`_admit` is promoted to the public `observed_one` rather than wrapped, so there
is one way in and it says what it does not do.

`PROTOCOL_VERSION` stays 4. A version lets a surface tell an engine that
disagrees from one too old to have been asked, and what it guards against is a
surface sending something the engine does not have — which adding an action
cannot cause. Removing one can, and that is what 4 was for.

The Swift shell gains `case progress`, and `tests/test_app_bundle.py` now holds
the two action sets to each other. `Wire.swift`'s own comment said "Seven, and
the set is closed" with nothing checking it against the engine — #47's shape,
one file over. Verified the regex finds all eight rather than passing vacuously.

The Companion Channel's `/progress` and the Delegated Turn's command card come
free: both are generated from the closed set, so neither can grow a command the
other lacks.

The acceptance step now runs `bridgectl progress <target>` — the ticket names
that command — and cross-checks it against the roster row, because #76 built
both surfaces and they are one reading. It still proves the read costs no turn
the only way it can from outside: the agent's own record does not grow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce (#76)

Three findings from the Codex-lane review of the committed branch (base
`37cf18e`, spec axis `8e858938-2903-42ac-a86b-cddeeca2173e`). Two are defects
and one is a ruling written down.

**A `codex` that never answers stopped both lanes' rosters.** `_run` awaited
`communicate()` with no bound, and `core/bridge.py:505` awaits `discover()` with
no deadline of its own — one loop over the adapters, so a subprocess hung here
took the *Claude* lane's roster with it, for as long as that process lived. The
lane's other subprocess reader has carried this bound since it was written
(`codex/processes.py:175-181`) and so has the Claude lane's
(`claude/discovery.py:94-100`); this one did not, and nothing said why. It is the
same ten seconds, in the runner rather than at the call site, and the process is
killed rather than abandoned — the wait ending is not the command ending, and
the next tick would otherwise start another one beside it.

The two handlers in `locate` are ordered, and the ordering is the behaviour:
`TimeoutError` **is** an `OSError`, so caught second a daemon that hung was
reported as a `codex` that is not installed. Pinned by a test that asserts the
reason it must *not* give.

**Two callers dialled twice.** `client()` read the connection, awaited the
lookup and the attach, and only then wrote what it had made. The engine has two
callers that arrive independently — the five-second cadence and a control-plane
`progress` ask — so both could find no connection and both attach: two clients
of a daemon this engine is meant to be one client of, with the loser dropped and
nothing left to close it. Now one dial at a time, asked for twice: a live
connection still answers without taking the lock, and a caller that waited asks
again, because what it waited on is very likely the dial that answers it.
`aclose` takes the same lock, so shutdown waits for a dial in flight instead of
having it write its connection back afterwards.

The fake attach yields, because a fake that returns without ever awaiting hands
the loop to nobody and a race cannot happen in a test that never lets the second
caller start. Both tests fail against the pre-fix code.

**`last_activity` counts a sidechain record, and that is now written down.**
#76's body says the helper excludes "child/sidechain, system and
command-plumbing records as legacy does"; `recent`'s entries do, and
`last_activity` deliberately does not. Advisor ruling on #76, and the reasons
are in the docstring rather than here: the field means *this Session moved*, a
Task subagent writes into this transcript and has no row of its own to own that
time, and the Codex lane's `updatedAt` counts all work — one meaning on both
lanes. **Adapted** rather than dropped: legacy's sidechain exclusion
(`legacy@1d32845:bridge/transcript.py:1125-1142`) is a *visibility* rule, ported
whole into `recent`'s entries and into `stop_analysis.analyse`; `last_activity`
is not a visibility fact, and gen 1 had no such field to cite either way. A
sidechain record is also not #68's Child Process — that is a separate `claude`
process with a row of its own — and the docstring names the distinction so the
two stop being conflated. No behaviour change; the existing test stays the pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rebase onto `663cf1b` put this branch's `SharedDaemon` beside #96's
shutdown, and two things had to be reconciled rather than merged. Both are
here; the textual conflicts are in the rebased commits where they arose.

**`aclose` no longer waits for a dial in flight.** It took the dial's lock,
which is the obvious way to stop a dial writing its connection back after the
engine had let go — and it is wrong under #96's arithmetic.
`runner.SHUTDOWN_SECONDS` is sixteen because the phases it bounds sum to 13.2
and it must exceed them, checked by a test that computes the sum from the
constants rather than from a comment. A phase that can sit out a ten-second
daemon lookup is not one that sum has room for, and overrunning the grace does
not buy a tidier stop — it buys a SIGKILL with nothing written down, which is
the exact leak #96 exists to end and which #96 already once reintroduced
through arithmetic.

So the dial is invalidated instead of waited on: `aclose` bumps a generation
and returns, and a dial that finishes on a stale one closes what it made rather
than writing it back. Clearing the field alone would have left that client with
nothing holding it and nothing to close it. Two tests, both verified red against
the lock — one on the leak, one holding the *ordering*, so a later change that
reaches for the obvious lock fails instead of quietly spending the budget.

**`NO_CLIENT` had to stop saying something that is no longer true.** #96 split
that sentence out of `NO_DAEMON` because `_shared_daemon()` returned `None`
unconditionally, so a roster reading "the daemon did not answer" was produced
without a byte being sent while `bridge-install status` dialled the same daemon
and got an answer. #76 builds the client, so "this build does not connect yet"
is now the false half. What survives is #96's rule, which is the part that
mattered: never claim the daemon was silent, and never claim anything about it
this build did not observe.

`NO_CLIENT` is now "could not be dialled", and it is the *fallback*: every path
in `SharedDaemon.client()` that answers `None` leaves its own reason on `note`,
and `_degraded` prefers that reason rather than printing both. "Could not be
dialled; codex did not answer within 10 seconds" is two sentences making one
claim, and a roster that makes more claims than it observed is what #96 is the
record of. #96's test keeps its name and now holds the rule instead of the old
spelling; a second test holds the preference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`NO_CLIENT` now reads "this engine holds no connection to the shared Codex
app-server daemon", which is the reading the Advisor fixed on #96 ("consequence
for part B", 2026-08-26) and which the previous commit here approximated rather
than used. The difference is not decoration: "could not be dialled" asserts an
attempt was made and failed, and a client `None` is not a witness to that — it
is a fact about this process and nothing else. The claim that the daemon was
dialled and stayed silent is `NO_DAEMON`'s, and it is only ever said after a
request was really made.

That ruling also named the sentence #96 must not write, and #96 wrote it:
"this build does not connect to the shared Codex app-server daemon yet" was
true for one day and false the moment #76 built the client. A test now holds
that spelling out by name, so it cannot return under a later ticket that has
forgotten why it went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build 3/7 — Progress inspection: the progress verb over the locked inspection type

1 participant