You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Build 7/7 — Duty bookkeeping: reconcile current state when Duty turns on #80
Part of #67 · Build group 7 of 7 from the signed port table.
Rows covered
P12 Duty bookkeeping while off — adapt/simplify; #61 V1 binding (all-off defaults, persisted preferences, no historical replay).
Legacy citations
legacy@1d32845:bridge/coordinator.py:356-412 (observe while off, dispatch nothing); bridge/daemon.py:2582-2612 (re-enable reports what accumulated); bridge/store.py:3192-3208 (left behind).
Shape
Replace the blind retained-notice sweep on switch activation (core/bridge.py:259-270) with reconciliation: on Duty (or an outlet) turning on, Bridge Core calls inspect on every live main Session and raises a Stop Notice only for those whose waiting_for is still question/permission now. Nothing is replayed from memory of what happened while off.
While off, Core keeps applying events and inspections to the registry (state stays current) and pushes nothing — Switchboard (core/switches.py:67-81,136-151) unchanged.
Acceptance step switches: Duty off → a Session stops with a question → nothing pushed; Duty on → exactly one notice for that Session, none for Sessions that moved on — red before, green after.
Observation item for this ticket's run of record (advisor, 2026-08-27, from #113): record the codex lane's Session Name at its first Stop Notice — generated title, id-prefix fallback, or prompt-cut. #113 makes the name follow the daemon's rename but the swap time cannot be read back from the daemon; this one observation says whether the user ever hears the fallback name. The stop notice step already records the name it matched; no harness change.
The run of record carries #113's observation item (codex lane's Session Name at its first Stop Notice — comment above). Acceptance runs are standing-authorised (#67). Legacy classification for the close: coordinator.py:356-412 observe-while-off — ported; daemon.py:2582-2612 "report what accumulated" — dropped, because v1.0 reconciles current state and replays nothing (#61 V1); store.py:3192-3208 — dropped with it.
Ruling (advisor, 2026-08-27) — TDD seam for #80: approved with three amendments
Verified against main = afbef3d before ruling: flip_switch (core/bridge.py:374-385), live_toggle (:387-410), outlets_changed (:412-425), dispatchCallStarted/CallEnded (:560-569), _session_stopped (:583-630), EscalationPipeline.escalate/sweep/_retain (core/escalation.py:211-247,364-381), ApprovalPipeline.opened (core/approvals.py:175-209), tests/hub.py, tests/fakes.py:151-176.
Seam — approved
Public seam is every outlet transition Bridge Core already owns, not flip_switch alone. Today five call sites do escalation.sweep(): flip_switch (on ∧ ¬previous), live_toggle (call came up), dispatch(CallStarted), dispatch(CallEnded|CallDropped) on a real release, outlets_changed. All five call one private reconciliation instead; none keeps sweep. A transition that reconciles and one that sweeps would be two paths (pre-code note 3). No public reconcile() verb — the transitions are the verbs.
Tests: real SessionRegistry + FakeAgent/FakeCall/FakeCompanionChannel through tests/hub.py, asserting only public effect: what the channel/call received, what agent.inspections lists, approvals.pending(). Approved as proposed. Fact recorded, not #80's to fix: outlets_changed has no production caller (only tests/test_bridge.py:480).
Amendment 1 — the retention ledger is deleted, not reduced
The Shape offered "deleted or reduced to the no-outlet-yet case". Decided: deleted. The no-outlet-yet case is the case reconciliation covers, with fresher words — reconciliation is R4's "retry when an outlet appears", keyed on current state instead of remembered text. Keeping both gives a retained PERMISSION announcement plus a reconciled one for the same dialog: the duplicate-delivery shape EscalationPipeline exists to prevent (escalation.py:205-208). Goes: RelayKind.NOTICE entries, sweep, retained, _retain, _forget, _as_notice, and the retention tests in test_escalation.py, test_bridge.py:101,444-500, test_approvals.py:146, rewritten as reconciliation tests. escalate keeps its matrix; a notice no route delivered is logged with the reason and dropped — the "surfaces on the next available outlet" wording stays true because reconciliation makes it so. Lifecycle.RETAINED must not name a state that no longer exists; rename or remove (not a locked type).
Accepted loss, by #61 V1: a Stop on nothing actionable (end of turn, UNKNOWN) that no outlet took is not re-offered later. "Only what still needs the user now."
Amendment 2 — one door per waiting kind, both triggers, with a delivered-memory
Reconciliation: for each sessions.live() row with child.is_main (a child is never inspected — the roster's classification is the definition, _spawned reads the same registry), await adapter.inspect(target); LaneUnavailable/raise → log, skip the row (never mark ended — #74's rule); feed the row through sessions.observed_one so the roster is current; then, iff state is WAITING and waiting_for.kind ∈ {QUESTION, PERMISSION}, announce through the same door _session_stopped uses. Extract that door (_announce_waiting(session, target, waiting_for)) so the live Stop and reconciliation are one producer per kind, called from two triggers — #103 later flips one place (pre-code note 2). Dedup is required and is not history. Consecutive transitions (Message on, then Voice on) must not announce the same dialog twice; today's _forget did that and Amendment 1 removes it. Bridge Core keeps a private in-Engine memory of waiting instances whose announcement was DELIVERED, keyed (SessionTarget, waiting_for.approval_id or kind); written by both producers on a delivered outcome; an entry is dropped when the row is next observed not WAITING on that instance, or ended. Nothing is replayed from it — it only stops a second telling of a dialog still on screen. No new type; a dict on BridgeCore. Legacy classification: legacy has no such behaviour (its dedup was the durable ledger, R4, left behind).
Amendment 3 — PERMISSION rows reconcile through ApprovalPipeline, never a second opened()
AwaitingApproval is dispatched while Duty is off, so approvals.opened has already started the budget (approvals.py:181-185, "ticks regardless") and its announcement went nowhere. Reconciliation of a PERMISSION row:
approval_id in approvals.pending() → re-offer the existing pending approval's announcement (same request_id, same text, Reach.EVERY_OUTLET, budget untouched) through a small door split out of opened (record vs announce). This is today's behaviour on the retained path, kept.
not pending (expired → released with ask, or never held by this engine) → Stop Notice via stop_notice_for, and the words must carry ANSWER_IT_AT_THE_TERMINAL: _stopped_on currently infers "answerable" from approval_id presence (bridge.py:180-186); after expiry that inference is false. Make answerability an argument decided by the pipeline, not a proxy on the field. A known-wrong user-facing sentence is never shipped (The menu-bar shell gives up on the login shell after 2.0 s and silently starts an engine that finds neither agent #118 rule).
Consequence to record: a dialog raised >600 s before Duty turns on is announced as "answer it in the terminal" — honest, and the existing budget rule unchanged.
Duty off, unchanged from the Shape
_session_stopped/opened keep running while off (registry current, budget ticking); escalate finds no route, logs, drops. Switchboard untouched. Nothing pushes.
Budget and proof
The Shape's ≤60 lines was for a reduction; Amendments 1–3 delete more than they add — state the ast count per file in the PR, prose never cut to hit it. Red first: acceptance step switches as written; unit red-first in test_bridge.py for the four effects the worker listed plus dedup across two transitions and the expired-approval wording. Run of record carries #113's observation item. Not touched: locked types, Agent seam, CONTEXT.md, ADRs.
Verified: core/lifecycle.py:1-52 defines RETAINED as the shared retryable state and core/relays.py:227,275 produce it for Answer Relays waiting on a Reply Window — that is live product behaviour (#27/P9), not the notice ledger. My sentence in Amendment 1 was written off the escalation path alone; withdrawn as stated. REPORTED_FAILED is likewise closed to escalation: lifecycle.py:18-21 says "reported to the user as terminal", and a notice no outlet took while Duty is off was reported to nobody.
Decided, as the worker proposed:
Lifecycle.DROPPED — "this item was not delivered and nothing automatic follows for it."is_terminal → True, is_retryable → False. Terminal for the item: a later reconciliation announces a new notice from current state, never this one, which is exactly the Lifecycle-vs-Delivery distinction the module docstring guards.
EscalationPipeline.escalate answers DROPPED on the undelivered path (escalation.py:233), no ledger write, one log line with the last attempt's reason.
RETAINED stays, Relay-only. Amend the module docstring: the RETAINED bullet's Stop-Notice example ("waits here indefinitely and surfaces on the next available outlet") moves to DROPPED's bullet as "no-loss for a Stop Notice lives in Bridge Core's reconciliation (Build 7/7 — Duty bookkeeping: reconcile current state when Duty turns on #80), not in this state" — the docstring must not describe a path that no longer exists.
Not a seam or locked type; CONTEXT.md/ADRs do not name Lifecycle (grepped). Tests: test_escalation.py retention assertions become DROPPED assertions; test_relay_pipeline.py/test_claude_agent.py untouched.
Evidence checked: tests/acceptance/journey.py:232-239 says the turn "has to end waiting on the user" and then forbids the one tool that makes a Claude Session wait (claude/stop_analysis.py:52 — AskUserQuestion, ported from legacy@1d32845:bridge/transcript.py:1565). A text question ends the turn IDLE with waiting_for.kind = none; under the old blind sweep any Stop was re-offered, so the contradiction was invisible. Under the signed Shape (P12: "announces only Sessions still actionable") that Session has moved on, and run 20260827T082125Z red is the product being right and the step being wrong.
Decided: ASK_SOMETHING asks the Session to call AskUserQuestion (options, DELTA-shaped labels as approval/#103 already do) and drops "Do not use any tools"; product code unchanged. "None for Sessions that moved on" stays unit-covered — the harness never arranges decoy Sessions (#109), so the real run cannot grade it without breaking that rule. Record in the step docstring the Claude Code version measured.
Noted for 2/2, not ruled yet: Codex has no projected question dialog on this map (#77 Q3), so on that lane "still actionable" can only be a PERMISSION.
Ruling (advisor, 2026-08-27) — reconciliation timing: ① approved; ② replaced — reconciliation rides the next discovery pass, not the control-plane verb
Evidence checked: codex/adapter.py::inspect forgets and re-runs the whole discover() (one Codex inspect ≈ one lane read, measured 7.2 s quiet on run 20260827T082125Z); control_plane/client.py:44 gives a verb 10 s; engine/composition.py:351-369 runs core.discover() on DEFAULT_DISCOVERY_SECONDS and states "never two passes at once — overlapping passes would have two readings of one machine racing to write the same rows".
Root cause is lane I/O inside a control-plane verb, and batching only shrinks it. Two lanes in parallel is still ≥ 7.2 s inside a 10 s contract, quiet; Simon's machine at load ≈ 38 slows everything 2–3× (#118's measurement), so ② is red on the next loaded run. And a read started from flip_switch is the overlapping pass the discovery loop's own rule forbids. My earlier "await inspect on every live main row" is withdrawn as a route; the effect stands.
Decided:
① as proposed.adjudicator.outlets() empty → nothing owed. Message on with Duty off is not an outlet becoming available (the adjudicator says so; the old sweep guard, kept).
Reconciliation is a flag consumed by the discovery pass. Each of the five transitions sets reconcile owed (no I/O; flip_switch returns in the time it takes to persist a switch). core.discover() reads the flag at pass start, clears it, does the lane reads it already does, applies rows via observe as today, and — iff the flag was set and outlets() is non-empty — walks sessions.live() main rows that are WAITING on QUESTION/PERMISSION through the one announce door (Amendment 2's delivered-memory dedups; Amendment 3's ApprovalPipeline rule for PERMISSION unchanged). Set mid-pass → consumed next pass, never the current one — the rows it would judge were read before the flip. No new seam, type or verb; progress's single-target inspect untouched; children remain seen, never inspected, never announced.
Latency consequence, stated: a notice follows a flip within one discovery cadence plus delivery, not synchronously. Acceptance switches bounds its wait by discovery_seconds + the round trip it already uses, derived, not written. Unit shape in test_bridge.py: flip_switch alone pushes nothing; the next core.discover() pushes exactly one; a second discover() pushes nothing more; a flip while no outlet is open leaves no owed flag behind after the pass.
Codex switches input (from 1/2): a PERMISSION — write outside the sandbox, the approval step's own instruction — since Codex has no projected question dialog (#77 Q3). Lane-specific words live on the lane, as CHILD_FILE already does.
Ruling (advisor, 2026-08-27) — switches uses a PERMISSION on both lanes; the "AskUserQuestion" instruction from my 1/2 ruling is withdrawn; no disposable Session, no reorder
The worker's facts stand: an AskUserQuestion parks the Claude hook with no answer route until #103 (claude/adapter.py:554-562), it is not ApprovalPipeline-pending so bridgectl approve cannot release it, and whether the dialog is visible while held is unmeasured (#103's first act). My 1/2 ruling therefore asked the harness to put the journey Session into a state nothing can release before child — withdrawn. Both offered options work around that ruling rather than the product; neither is taken:
Decided: ASK_SOMETHING becomes the lane's permission-raising instruction — the approval step's own (OUTSIDE_THE_SANDBOX write) — on both lanes. Sequence per lane: Duty off → the write raises the dialog; AwaitingApproval opens the budget (approvals.py:181-185) and the announcement goes nowhere; assert nothing pushed. Duty on → next discovery pass reconciles → the pending approval's existing announcement is re-offered (Amendment 3's live branch, the one most worth proving on a real machine); assert exactly one message naming that Session, and bridgectl approve <id> allow releases it as the approval step already does, so child follows on a free Session. The approval step's assertions (verdict delivered, file written) stay its own; switches asserts the off/on pair and reuses the approve call only to release.
Consequence: the QUESTION branch stays unit-covered in test_bridge.py; its live proof is #103's question step, where a question first becomes answerable. Lane-specific words are not needed — the instruction is already lane-neutral and green 9/9 on both lanes (20260827T023843Z). Budget: 600 s ≫ the step's off/on interval; state the interval in the step docstring. Wait bound as ruled: DISCOVERY_SECONDS + Telegram round trip, derived.
Final real run: 20260827T085224Z (~/Library/Application Support/GPT-VoiceCoding/acceptance/20260827T085224Z). Claude: 9/9, including switches; realtime probe: PASS; Codex: 4/9. The Codex lane could not exercise #80 because two harness preconditions failed. The mixed run is not claimed as green.
A — repeat-run identity
The daemon still returns test thread 01a04255-ec12-7650-a71c-ab2147f3001f from the earlier worker run 20260827T082125Z. Its TUI process no longer exists, but thread/loaded/list names it workspace-codex · 回复 READY. The new thread received the same Session Name. tests/acceptance/support.py:971-974 hard-codes every run workspace basename to workspace-codex; therefore stop notice attribution and the name-addressed inbound step correctly refused to choose between the two threads.
Proposed harness-only fix: include run_directory.name in the disposable workspace basename. Each run then produces a distinct project component in its Session Name, even while a prior test thread remains loaded. No production behavior changes.
B — deterministic permission setup
The current main rollout is ~/.codex/sessions/2026/08/27/rollout-2026-08-27T20-58-02-01a04270-c62f-7ff0-ad8e-1aae33422dce.jsonl. Both the relay and switches turns record sandbox_policy=workspace-write, approval_policy=on-request, and approvals_reviewer=user. For both outside-workspace writes, the model made no tool call and returned only 无法创建:该路径不在允许写入的工作区内。; consequently there was no permission for the harness to observe.
The green run of record 20260827T023843Z used the same instruction and the same 0.149.1 session-meta/policy. Its main rollout rollout-2026-08-27T14-44-26-01a0411a-bdf8-7010-89d3-93e1fac92490.jsonl emitted a custom_tool_call to exec/tools.apply_patch, raised the permission, and passed. One observed difference is reasoning effort (high then, xhigh now); no causal claim is made. The direct cause is that the current turn never attempted a file-edit tool.
Proposed harness-only fix: make writing_at explicitly tell Codex to use its apply_patch file-edit tool to attempt the write and leave any approval request pending. The assertions remain the real permission receipt and filesystem effect, not model prose. Claude writing stays unchanged. This arranges the state that the step grades, following the #105 acceptance precedent.
C — rerun
After A and B, rerun all three real tests. A mixed run is not sufficient.
Recommendation: approve A+B+C within #80. Decision requested: approve A and B, or place either elsewhere and state what evidence is sufficient to close #80. Scope is tests/acceptance/support.py and tests/acceptance/journey.py only; no product API, seam, ADR, or runtime behavior changes.
Ruling (advisor, 2026-08-27) — A approved, B approved with one guard, C already approved; closing evidence stated
A — per-run workspace name: approved
The refusal was the product being right: two loaded threads named workspace-codex · 回复 READY, one a ghost of a dead TUI, is an ambiguity the name-addressed path must refuse (#78/#109). The harness owes each run a distinct project half; run_directory.name in the disposable workspace basename does that without touching the daemon (the product never stops a thread it did not start — #83's rule binds the harness too, so no cleanup of the stale thread). Setup, not judgment. Observation item for the rerun, recorded not graded: does the engine's roster list the ghost thread (01a04255-…, TUI gone) as a live Session? #74 made the pid a Codex row's identity and #112's keep-list filters by source, not liveness. If it is listed, that is a product finding for a ticket of its own, not #80's.
B — tell Codex to attempt the write with apply_patch: approved, with a guard
Fact: on xhigh the model answered in prose ("该路径不在允许写入的工作区内") without any tool call, so there was no permission to grade; on high (run of record) it called apply_patch, the permission was raised and passed. The step grades the permission receipt and the filesystem effect; an instruction that makes the model attempt the write arranges the input, not the verdict — the #105 precedent the worker cites. Guard: the words may say "use your apply_patch tool and leave any approval request pending"; they must not say anything about the sandbox, danger-full-access, or retrying without the sandbox — steering around the permission would be arranging the judgment. Harness does not pin reasoning effort; it runs the user's own config, as it runs the user's own binary. Claude writing unchanged.
One run on the final tree (rebased, no code change after it): all three tests green — Claude 9/9, Codex 9/9, realtime probe PASS — with the #113 observation item (codex lane's Session Name at its first Stop Notice) and A's ghost-thread observation both recorded on this ticket. A mixed or partial run is not that evidence.
The final-tree run 20260827T093731Z is not green: Claude 9/9 and realtime probe PASS, but Codex 8/9. The only failure is switches: Duty off was silent for 120 s, then Duty on produced no permission announcement in 120 s.
Evidence and root cause
The Codex row remained the same target and the same pending approval throughout the failure window: codex:01a0429a-1338-7220-a9bc-981a69de2a5f:23240, approval item/fileChange/requestApproval:01a0429e-d7ba-7e02-800a-a8d31896cb1e:exec-99ebf549-5821-4f77-af43-cf4a2b2da8a9.
Status snapshots at 09:48:30, 09:48:36, 09:50:37 and 09:52:38 UTC all show state=running with waiting_for.kind=permission and that same approval id. The request neither expired nor changed identity.
Fresh Codex discoveries continued every approximately 6 s. No reconciliation notice was attempted after Duty came on.
core/bridge.py:_announce_current_stops currently requires both session.state is SessionState.WAITING and waiting_for.kind in question/permission, so it skipped this row.
This is the existing seam shape, not a transient adapter error. seams/agent.py:111-123 defines SessionState as the agent native activity. Codex official status maps only active to RUNNING and idle/systemError to IDLE (codex/discovery.py:62-76); it has no WAITING status. A live approval stays inside an active turn.
Codex separately projects the held approval into waiting_for without changing state (codex/adapter.py:510-526,1128-1154). WaitingFor.needs_the_user already defines question/permission as the user-actionable fact (seams/agent.py:194-197).
This conflicts with the earlier ruling phrase that reconciliation should select rows that are WAITING on question/permission. That conjunction is true on Claude but cannot be true for a real Codex permission under the current seam.
Recommendation: approve A
A — make current reconciliation eligibility session.waiting_for.needs_the_user, independent of agent-native SessionState. Keep the existing live/main/fresh-lane filters. Add a red/green unit case for RUNNING + PERMISSION, retain the existing WAITING + QUESTION and moved-on cases, then rebuild and rerun all three real tests.
Impact: one private Bridge Core predicate plus tests and wording; no public seam, adapter, Reply Window, progress, ADR or Switchboard change. It follows the Issue Shape, which says to select Sessions whose waiting_for is still question/permission now.
B — not recommended: make the Codex adapter overwrite an active thread state to WAITING while an approval is pending. That would stop SessionState being the official agent activity and would expand into Reply Window derivation, progress/status surfaces and every consumer of the row.
Decision requested: approve A, or require B. The current run is retained as diagnostic evidence only and will not be claimed green.
Ruling (advisor, 2026-08-27) — A approved; the WAITING ∧ conjunction in my Amendment 2 is withdrawn
Verified: seams/agent.py:111-123 — SessionState is "what a live Session is doing right now, in the agent's own terms"; codex/discovery.py:62-76 maps Codex's active → RUNNING, idle/systemError → IDLE, nothing → WAITING, because Codex has no such status and a held approval lives inside an active turn; seams/agent.py:194-197 — WaitingFor.needs_the_user is the seam's own word for "only the user can answer this". Run 20260827T093731Z shows the row state=running, waiting_for.kind=permission, same approval id, for the whole window — the product read the machine correctly and my predicate refused it.
The Shape says "those whose waiting_for is still question/permission now" — no state condition. I added WAITING ∧ off the Claude roster's three-word cycle; on Codex that pair can never be true. Withdrawn.
Decided: reconciliation eligibility is live ∧ main ∧ waiting_for.needs_the_user, agent-native SessionState not consulted. The delivered-memory key (target, approval_id or kind) and its clearing rule become "cleared when the row is next observed with needs_the_user false, or ended" — same rule, same field. B is refused for the worker's reason: SessionState must stay the official activity, and a fabricated WAITING would leak into derive_reply_window, progress and every surface that renders the row.
Tests: red/green RUNNING + PERMISSION (the Codex shape), keep WAITING + QUESTION (Claude) and moved-on (IDLE + NONE). Then rebuild, rerun all three; closing evidence as ruled above. Add to the resolution comment's legacy line: legacy read "needs the user" off the transcript, never off an activity flag (coordinator.py:356-412) — this is the ported half.
Implemented on branch 80-duty-bookkeeping from fixed point afbef3d9ffbc8c8e441d45b6a6029d6a8e58510c.
Commit: d78ee187ac4775416f6e98ccc20a610e71737ad7 (Implement current-state duty reconciliation). origin/main remained exactly the fixed point at final fetch; the branch is one commit ahead and the worktree is clean.
Delivered behavior
Removed the historical Stop Notice ledger, RelayKind.NOTICE, and blind sweep replay. An undelivered notice attempt is terminal Lifecycle.DROPPED; Answer Relays retain Lifecycle.RETAINED.
Every existing outlet transition marks reconciliation owed without lane I/O. The next ordinary discovery pass consumes the flag, uses only fresh lane rows, and announces live main Sessions whose waiting_for.needs_the_user is true.
The eligibility predicate follows the final Advisor ruling: agent-native SessionState is not part of actionability. This covers real Codex RUNNING + PERMISSION and Claude WAITING + QUESTION without rewriting adapter state.
Pending permissions re-offer the existing Approval Relay request and budget. Expired or unheld permissions say they must be answered in the terminal.
Delivered-wait dedup prevents consecutive outlet transitions from repeating one dialog; it clears when needs_the_user becomes false, the Session ends, or the registry re-keys/removes the target.
Switchboard, public Agent seams, Reply Window, progress, CONTEXT.md and ADRs are unchanged.
Legacy classification
legacy@1d32845:bridge/coordinator.py:356-412 observe while Duty is off: ported.
legacy@1d32845:bridge/store.py:3192-3208 durable accumulated-notice storage: dropped with that replay path.
Delivered-wait dedup keyed by current waiting instance: legacy has no equivalent behavior; its durable ledger was deliberately left behind.
Automated gates
Python: ruff check . PASS; ruff format --check . PASS; 1639 selected tests PASS, 3 acceptance tests deselected from the ordinary suite.
Swift: 135 tests in 14 suites PASS; strict recursive swift format lint PASS.
App: rebuilt from final HEAD with scripts/build-app.sh; strict deep code-signature verification PASS.
Diff check PASS.
Code review
CRG: unavailable — this session exposed no code-review-graph, get_minimal_context_tool, or detect_changes_tool.
Standards axis: stale retained/sweep documentation and a private-state test assertion were found, fixed, and each targeted rereview returned CLOSED.
Spec axis: Codex re-key could leave stale delivered-memory; it was fixed with a regression test. The final needs_the_user increment and public dedup behavior test each returned CLOSED.
Final result: no retained Standards or Spec finding.
Real-machine run of record
Run 20260827T100454Z on the final tree, with no code change after it:
Claude: 9/9 PASS.
Codex: 9/9 PASS.
Realtime contract probe: PASS — 1631 remote audio frames in 30 seconds, first frame observed.
The Codex switches step proved Duty off silence for 120 s, then Duty on produced exactly one existing-permission announcement as Telegram message 3860; the same approval was answered and the Session returned idle.
#113 observation: the Codex lane first Stop Notice used Session Name workspace-codex-20260827T100454Z · 回复 READY in bot message 3853 — generated-title form, not the id-prefix or prompt-cut fallback.
Ghost-thread observation: old thread 01a04255-ec12-7650-a71c-ab2147f3001f was still listed as a live main Session although its original TUI pid 31966 no longer existed. This was recorded, not graded, and filed separately as #123; no thread cleanup was performed.
Whole-file AST counts
Counts use sum(1 for _ in ast.walk(ast.parse(file))) on every changed Python file:
Landed (advisor, 2026-08-27): #80 → PR #124, 50ab60d. Found at Simon's "is #80 done?" that the ticket was closed with its commit still on the local worktree branch and no PR — opened #124 with provenance, read the core diff against the seven rulings above (matches), gate green on the worktree's own venv (note: the repo-root .venv is an editable install of main's src, so running branch tests through it reports phantom failures), CI 4/4 green, squash-merged. Run of record 20260827T100454Z is on this exact tree. The 80-duty-bookkeeping worktree at .claude/worktrees/ is left for whoever owns the machine cleanup (#54).
Activity
Ask Codex whether the boot turn is over, and wait with every outlet o…
Skip the Codex update prompt by starting the TUI with an initial prom…
Simon-AI-coding commentedon Aug 27, 2026
Observation item for this ticket's run of record (advisor, 2026-08-27, from #113): record the codex lane's Session Name at its first Stop Notice — generated title, id-prefix fallback, or prompt-cut. #113 makes the name follow the daemon's rename but the swap time cannot be read back from the daemon; this one observation says whether the user ever hears the fallback name. The
stop noticestep already records the name it matched; no harness change.Simon-AI-coding commentedon Aug 27, 2026
Pre-code notes (advisor, 2026-08-27) — the Shape stands; three things landed since it was written that it must use rather than re-invent:
ChildClassification.is_main. Reconciliation inspects main rows only; a child is never announced (core/bridge.dispatchalready skips it — reuse that door, do not add a second filter).questionis not a Stop Notice with a different word. Since Build 4/7 — Delivery receipts and settlement: inbox receipt, Codex verified, UNKNOWN never resends #77 awaiting_forof kindQUESTIONis announced once as a parked question (no allow/deny menu, "answer it in the terminal"), and since Acceptance:stop noticetakes any bot message as its own Stop, including another Session's #109 every announcement names its Session first. Reconciliation raises the same announcement the live path would have raised for thatwaiting_for— one producer per kind, called from a second trigger — so Build 8/7 — Answer a Claude Session's question from the bridge, over the PermissionRequest hook #103 later flips one place, not two.core/bridge.py:259-270has moved; find the retained-notice sweep by name, not line.core/escalation.py's sweep is deleted or reduced to the in-Engine "no outlet yet" case per Parity audit: legacy as the behaviour spec — the gap table that scopes the repair #61 R4, as the Shape says — do not keep both paths alive.The run of record carries #113's observation item (codex lane's Session Name at its first Stop Notice — comment above). Acceptance runs are standing-authorised (#67). Legacy classification for the close:
coordinator.py:356-412observe-while-off — ported;daemon.py:2582-2612"report what accumulated" — dropped, because v1.0 reconciles current state and replays nothing (#61 V1);store.py:3192-3208— dropped with it.Refuse the prompt codex hands back as a name, and follow the rename (#…
Simon-AI-coding commentedon Aug 27, 2026
Ruling (advisor, 2026-08-27) — TDD seam for #80: approved with three amendments
Verified against
main=afbef3dbefore ruling:flip_switch(core/bridge.py:374-385),live_toggle(:387-410),outlets_changed(:412-425),dispatchCallStarted/CallEnded(:560-569),_session_stopped(:583-630),EscalationPipeline.escalate/sweep/_retain(core/escalation.py:211-247,364-381),ApprovalPipeline.opened(core/approvals.py:175-209),tests/hub.py,tests/fakes.py:151-176.Seam — approved
Public seam is every outlet transition Bridge Core already owns, not
flip_switchalone. Today five call sites doescalation.sweep():flip_switch(on ∧ ¬previous),live_toggle(call came up),dispatch(CallStarted),dispatch(CallEnded|CallDropped)on a real release,outlets_changed. All five call one private reconciliation instead; none keepssweep. A transition that reconciles and one that sweeps would be two paths (pre-code note 3). No publicreconcile()verb — the transitions are the verbs.Tests: real
SessionRegistry+FakeAgent/FakeCall/FakeCompanionChannelthroughtests/hub.py, asserting only public effect: what the channel/call received, whatagent.inspectionslists,approvals.pending(). Approved as proposed. Fact recorded, not #80's to fix:outlets_changedhas no production caller (onlytests/test_bridge.py:480).Amendment 1 — the retention ledger is deleted, not reduced
The Shape offered "deleted or reduced to the no-outlet-yet case". Decided: deleted. The no-outlet-yet case is the case reconciliation covers, with fresher words — reconciliation is R4's "retry when an outlet appears", keyed on current state instead of remembered text. Keeping both gives a retained
PERMISSIONannouncement plus a reconciled one for the same dialog: the duplicate-delivery shapeEscalationPipelineexists to prevent (escalation.py:205-208). Goes:RelayKind.NOTICEentries,sweep,retained,_retain,_forget,_as_notice, and the retention tests intest_escalation.py,test_bridge.py:101,444-500,test_approvals.py:146, rewritten as reconciliation tests.escalatekeeps its matrix; a notice no route delivered is logged with the reason and dropped — the "surfaces on the next available outlet" wording stays true because reconciliation makes it so.Lifecycle.RETAINEDmust not name a state that no longer exists; rename or remove (not a locked type).Accepted loss, by #61 V1: a Stop on nothing actionable (end of turn,
UNKNOWN) that no outlet took is not re-offered later. "Only what still needs the user now."Amendment 2 — one door per waiting kind, both triggers, with a delivered-memory
Reconciliation: for each
sessions.live()row withchild.is_main(a child is never inspected — the roster's classification is the definition,_spawnedreads the same registry),await adapter.inspect(target);LaneUnavailable/raise → log, skip the row (never mark ended — #74's rule); feed the row throughsessions.observed_oneso the roster is current; then, iffstate is WAITINGandwaiting_for.kind ∈ {QUESTION, PERMISSION}, announce through the same door_session_stoppeduses. Extract that door (_announce_waiting(session, target, waiting_for)) so the live Stop and reconciliation are one producer per kind, called from two triggers — #103 later flips one place (pre-code note 2).Dedup is required and is not history. Consecutive transitions (Message on, then Voice on) must not announce the same dialog twice; today's
_forgetdid that and Amendment 1 removes it. Bridge Core keeps a private in-Engine memory of waiting instances whose announcement was DELIVERED, keyed(SessionTarget, waiting_for.approval_id or kind); written by both producers on a delivered outcome; an entry is dropped when the row is next observed notWAITINGon that instance, or ended. Nothing is replayed from it — it only stops a second telling of a dialog still on screen. No new type; a dict onBridgeCore. Legacy classification: legacy has no such behaviour (its dedup was the durable ledger, R4, left behind).Amendment 3 —
PERMISSIONrows reconcile throughApprovalPipeline, never a secondopened()AwaitingApprovalis dispatched while Duty is off, soapprovals.openedhas already started the budget (approvals.py:181-185, "ticks regardless") and its announcement went nowhere. Reconciliation of aPERMISSIONrow:approval_idinapprovals.pending()→ re-offer the existing pending approval's announcement (samerequest_id, same text,Reach.EVERY_OUTLET, budget untouched) through a small door split out ofopened(record vs announce). This is today's behaviour on the retained path, kept.ask, or never held by this engine) → Stop Notice viastop_notice_for, and the words must carryANSWER_IT_AT_THE_TERMINAL:_stopped_oncurrently infers "answerable" fromapproval_idpresence (bridge.py:180-186); after expiry that inference is false. Make answerability an argument decided by the pipeline, not a proxy on the field. A known-wrong user-facing sentence is never shipped (The menu-bar shell gives up on the login shell after 2.0 s and silently starts an engine that finds neither agent #118 rule).Consequence to record: a dialog raised >600 s before Duty turns on is announced as "answer it in the terminal" — honest, and the existing budget rule unchanged.
Duty off, unchanged from the Shape
_session_stopped/openedkeep running while off (registry current, budget ticking);escalatefinds no route, logs, drops.Switchboarduntouched. Nothing pushes.Budget and proof
The Shape's ≤60 lines was for a reduction; Amendments 1–3 delete more than they add — state the ast count per file in the PR, prose never cut to hit it. Red first: acceptance step
switchesas written; unit red-first intest_bridge.pyfor the four effects the worker listed plus dedup across two transitions and the expired-approval wording. Run of record carries #113's observation item. Not touched: locked types, Agent seam, CONTEXT.md, ADRs.Simon-AI-coding commentedon Aug 27, 2026
Ruling (advisor, 2026-08-27) —
Lifecycle.DROPPED: approved; "rename or remove RETAINED" withdrawnVerified:
core/lifecycle.py:1-52definesRETAINEDas the shared retryable state andcore/relays.py:227,275produce it for Answer Relays waiting on a Reply Window — that is live product behaviour (#27/P9), not the notice ledger. My sentence in Amendment 1 was written off the escalation path alone; withdrawn as stated.REPORTED_FAILEDis likewise closed to escalation:lifecycle.py:18-21says "reported to the user as terminal", and a notice no outlet took while Duty is off was reported to nobody.Decided, as the worker proposed:
Lifecycle.DROPPED— "this item was not delivered and nothing automatic follows for it."is_terminal→ True,is_retryable→ False. Terminal for the item: a later reconciliation announces a new notice from current state, never this one, which is exactly theLifecycle-vs-Deliverydistinction the module docstring guards.EscalationPipeline.escalateanswersDROPPEDon the undelivered path (escalation.py:233), no ledger write, one log line with the last attempt's reason.RETAINEDstays, Relay-only. Amend the module docstring: theRETAINEDbullet's Stop-Notice example ("waits here indefinitely and surfaces on the next available outlet") moves toDROPPED's bullet as "no-loss for a Stop Notice lives in Bridge Core's reconciliation (Build 7/7 — Duty bookkeeping: reconcile current state when Duty turns on #80), not in this state" — the docstring must not describe a path that no longer exists.CONTEXT.md/ADRs do not nameLifecycle(grepped). Tests:test_escalation.pyretention assertions becomeDROPPEDassertions;test_relay_pipeline.py/test_claude_agent.pyuntouched.Simon-AI-coding commentedon Aug 27, 2026
Ruling (advisor, 2026-08-27) —
switchesstep instruction: approved, harness-only changeEvidence checked:
tests/acceptance/journey.py:232-239says the turn "has to end waiting on the user" and then forbids the one tool that makes a Claude Session wait (claude/stop_analysis.py:52—AskUserQuestion, ported fromlegacy@1d32845:bridge/transcript.py:1565). A text question ends the turnIDLEwithwaiting_for.kind = none; under the old blind sweep any Stop was re-offered, so the contradiction was invisible. Under the signed Shape (P12: "announces only Sessions still actionable") that Session has moved on, and run20260827T082125Zred is the product being right and the step being wrong.Decided:
ASK_SOMETHINGasks the Session to callAskUserQuestion(options, DELTA-shaped labels asapproval/#103 already do) and drops "Do not use any tools"; product code unchanged. "None for Sessions that moved on" stays unit-covered — the harness never arranges decoy Sessions (#109), so the real run cannot grade it without breaking that rule. Record in the step docstring the Claude Code version measured.Noted for 2/2, not ruled yet: Codex has no projected question dialog on this map (#77 Q3), so on that lane "still actionable" can only be a
PERMISSION.Simon-AI-coding commentedon Aug 27, 2026
Ruling (advisor, 2026-08-27) — reconciliation timing: ① approved; ② replaced — reconciliation rides the next discovery pass, not the control-plane verb
Evidence checked:
codex/adapter.py::inspectforgets and re-runs the wholediscover()(one Codexinspect≈ one lane read, measured 7.2 s quiet on run20260827T082125Z);control_plane/client.py:44gives a verb 10 s;engine/composition.py:351-369runscore.discover()onDEFAULT_DISCOVERY_SECONDSand states "never two passes at once — overlapping passes would have two readings of one machine racing to write the same rows".Root cause is lane I/O inside a control-plane verb, and batching only shrinks it. Two lanes in parallel is still ≥ 7.2 s inside a 10 s contract, quiet; Simon's machine at load ≈ 38 slows everything 2–3× (#118's measurement), so ② is red on the next loaded run. And a read started from
flip_switchis the overlapping pass the discovery loop's own rule forbids. My earlier "awaitinspecton every live main row" is withdrawn as a route; the effect stands.Decided:
adjudicator.outlets()empty → nothing owed. Message on with Duty off is not an outlet becoming available (the adjudicator says so; the oldsweepguard, kept).reconcile owed(no I/O;flip_switchreturns in the time it takes to persist a switch).core.discover()reads the flag at pass start, clears it, does the lane reads it already does, applies rows viaobserveas today, and — iff the flag was set andoutlets()is non-empty — walkssessions.live()main rows that areWAITINGonQUESTION/PERMISSIONthrough the one announce door (Amendment 2's delivered-memory dedups; Amendment 3'sApprovalPipelinerule forPERMISSIONunchanged). Set mid-pass → consumed next pass, never the current one — the rows it would judge were read before the flip. No new seam, type or verb;progress's single-targetinspectuntouched; children remain seen, never inspected, never announced.switchesbounds its wait bydiscovery_seconds+ the round trip it already uses, derived, not written. Unit shape intest_bridge.py:flip_switchalone pushes nothing; the nextcore.discover()pushes exactly one; a seconddiscover()pushes nothing more; a flip while no outlet is open leaves no owed flag behind after the pass.codex.inspectre-reading the whole lane per call is an adapter cost that should be a keyed read; open a ticket only if a later step hits it.Codex
switchesinput (from 1/2): aPERMISSION— write outside the sandbox, theapprovalstep's own instruction — since Codex has no projected question dialog (#77 Q3). Lane-specific words live on the lane, asCHILD_FILEalready does.Simon-AI-coding commentedon Aug 27, 2026
Ruling (advisor, 2026-08-27) —
switchesuses a PERMISSION on both lanes; the "AskUserQuestion" instruction from my 1/2 ruling is withdrawn; no disposable Session, no reorderThe worker's facts stand: an
AskUserQuestionparks the Claude hook with no answer route until #103 (claude/adapter.py:554-562), it is notApprovalPipeline-pending sobridgectl approvecannot release it, and whether the dialog is visible while held is unmeasured (#103's first act). My 1/2 ruling therefore asked the harness to put the journey Session into a state nothing can release beforechild— withdrawn. Both offered options work around that ruling rather than the product; neither is taken:STEPSfor the same reason.Decided:
ASK_SOMETHINGbecomes the lane's permission-raising instruction — theapprovalstep's own (OUTSIDE_THE_SANDBOXwrite) — on both lanes. Sequence per lane: Duty off → the write raises the dialog;AwaitingApprovalopens the budget (approvals.py:181-185) and the announcement goes nowhere; assert nothing pushed. Duty on → next discovery pass reconciles → the pending approval's existing announcement is re-offered (Amendment 3's live branch, the one most worth proving on a real machine); assert exactly one message naming that Session, andbridgectl approve <id> allowreleases it as theapprovalstep already does, sochildfollows on a free Session. Theapprovalstep's assertions (verdict delivered, file written) stay its own;switchesasserts the off/on pair and reuses the approve call only to release.Consequence: the
QUESTIONbranch stays unit-covered intest_bridge.py; its live proof is #103'squestionstep, where a question first becomes answerable. Lane-specific words are not needed — the instruction is already lane-neutral and green 9/9 on both lanes (20260827T023843Z). Budget: 600 s ≫ the step's off/on interval; state the interval in the step docstring. Wait bound as ruled:DISCOVERY_SECONDS+ Telegram round trip, derived.Simon-AI-coding commentedon Aug 27, 2026
Worker wrap-up request — acceptance rerun preconditions (2026-08-27)
Final real run:
20260827T085224Z(~/Library/Application Support/GPT-VoiceCoding/acceptance/20260827T085224Z). Claude: 9/9, includingswitches; realtime probe: PASS; Codex: 4/9. The Codex lane could not exercise #80 because two harness preconditions failed. The mixed run is not claimed as green.A — repeat-run identity
The daemon still returns test thread
01a04255-ec12-7650-a71c-ab2147f3001ffrom the earlier worker run20260827T082125Z. Its TUI process no longer exists, butthread/loaded/listnames itworkspace-codex · 回复 READY. The new thread received the same Session Name.tests/acceptance/support.py:971-974hard-codes every run workspace basename toworkspace-codex; thereforestop noticeattribution and the name-addressed inbound step correctly refused to choose between the two threads.Proposed harness-only fix: include
run_directory.namein the disposable workspace basename. Each run then produces a distinct project component in its Session Name, even while a prior test thread remains loaded. No production behavior changes.B — deterministic permission setup
The current main rollout is
~/.codex/sessions/2026/08/27/rollout-2026-08-27T20-58-02-01a04270-c62f-7ff0-ad8e-1aae33422dce.jsonl. Both the relay and switches turns recordsandbox_policy=workspace-write,approval_policy=on-request, andapprovals_reviewer=user. For both outside-workspace writes, the model made no tool call and returned only无法创建:该路径不在允许写入的工作区内。; consequently there was no permission for the harness to observe.The green run of record
20260827T023843Zused the same instruction and the same 0.149.1 session-meta/policy. Its main rolloutrollout-2026-08-27T14-44-26-01a0411a-bdf8-7010-89d3-93e1fac92490.jsonlemitted acustom_tool_calltoexec/tools.apply_patch, raised the permission, and passed. One observed difference is reasoning effort (highthen,xhighnow); no causal claim is made. The direct cause is that the current turn never attempted a file-edit tool.Proposed harness-only fix: make
writing_atexplicitly tell Codex to use itsapply_patchfile-edit tool to attempt the write and leave any approval request pending. The assertions remain the real permission receipt and filesystem effect, not model prose. Claudewritingstays unchanged. This arranges the state that the step grades, following the #105 acceptance precedent.C — rerun
After A and B, rerun all three real tests. A mixed run is not sufficient.
Recommendation: approve A+B+C within #80. Decision requested: approve A and B, or place either elsewhere and state what evidence is sufficient to close #80. Scope is
tests/acceptance/support.pyandtests/acceptance/journey.pyonly; no product API, seam, ADR, or runtime behavior changes.Simon-AI-coding commentedon Aug 27, 2026
Ruling (advisor, 2026-08-27) — A approved, B approved with one guard, C already approved; closing evidence stated
A — per-run workspace name: approved
The refusal was the product being right: two loaded threads named
workspace-codex · 回复 READY, one a ghost of a dead TUI, is an ambiguity the name-addressed path must refuse (#78/#109). The harness owes each run a distinct project half;run_directory.namein the disposable workspace basename does that without touching the daemon (the product never stops a thread it did not start — #83's rule binds the harness too, so no cleanup of the stale thread). Setup, not judgment.Observation item for the rerun, recorded not graded: does the engine's roster list the ghost thread (
01a04255-…, TUI gone) as a live Session? #74 made the pid a Codex row's identity and #112's keep-list filters by source, not liveness. If it is listed, that is a product finding for a ticket of its own, not #80's.B — tell Codex to attempt the write with
apply_patch: approved, with a guardFact: on
xhighthe model answered in prose ("该路径不在允许写入的工作区内") without any tool call, so there was no permission to grade; onhigh(run of record) it calledapply_patch, the permission was raised and passed. The step grades the permission receipt and the filesystem effect; an instruction that makes the model attempt the write arranges the input, not the verdict — the #105 precedent the worker cites. Guard: the words may say "use yourapply_patchtool and leave any approval request pending"; they must not say anything about the sandbox,danger-full-access, or retrying without the sandbox — steering around the permission would be arranging the judgment. Harness does not pin reasoning effort; it runs the user's own config, as it runs the user's own binary. Claudewritingunchanged.Evidence sufficient to close #80
One run on the final tree (rebased, no code change after it): all three tests green — Claude 9/9, Codex 9/9, realtime probe PASS — with the #113 observation item (codex lane's Session Name at its first Stop Notice) and A's ghost-thread observation both recorded on this ticket. A mixed or partial run is not that evidence.
Simon-AI-coding commentedon Aug 27, 2026
Worker decision request — real-run state/waiting conflict (2026-08-27)
The final-tree run
20260827T093731Zis not green: Claude 9/9 and realtime probe PASS, but Codex 8/9. The only failure isswitches: Duty off was silent for 120 s, then Duty on produced no permission announcement in 120 s.Evidence and root cause
codex:01a0429a-1338-7220-a9bc-981a69de2a5f:23240, approvalitem/fileChange/requestApproval:01a0429e-d7ba-7e02-800a-a8d31896cb1e:exec-99ebf549-5821-4f77-af43-cf4a2b2da8a9.state=runningwithwaiting_for.kind=permissionand that same approval id. The request neither expired nor changed identity.core/bridge.py:_announce_current_stopscurrently requires bothsession.state is SessionState.WAITINGandwaiting_for.kindin question/permission, so it skipped this row.seams/agent.py:111-123definesSessionStateas the agent native activity. Codex official status maps only active to RUNNING and idle/systemError to IDLE (codex/discovery.py:62-76); it has no WAITING status. A live approval stays inside an active turn.waiting_forwithout changingstate(codex/adapter.py:510-526,1128-1154).WaitingFor.needs_the_useralready defines question/permission as the user-actionable fact (seams/agent.py:194-197).This conflicts with the earlier ruling phrase that reconciliation should select rows that are
WAITINGon question/permission. That conjunction is true on Claude but cannot be true for a real Codex permission under the current seam.Recommendation: approve A
A — make current reconciliation eligibility
session.waiting_for.needs_the_user, independent of agent-nativeSessionState. Keep the existing live/main/fresh-lane filters. Add a red/green unit case forRUNNING + PERMISSION, retain the existingWAITING + QUESTIONand moved-on cases, then rebuild and rerun all three real tests.Impact: one private Bridge Core predicate plus tests and wording; no public seam, adapter, Reply Window, progress, ADR or Switchboard change. It follows the Issue Shape, which says to select Sessions whose
waiting_foris still question/permission now.B — not recommended: make the Codex adapter overwrite an active thread state to WAITING while an approval is pending. That would stop
SessionStatebeing the official agent activity and would expand into Reply Window derivation, progress/status surfaces and every consumer of the row.Decision requested: approve A, or require B. The current run is retained as diagnostic evidence only and will not be claimed green.
Simon-AI-coding commentedon Aug 27, 2026
Ruling (advisor, 2026-08-27) — A approved; the
WAITING ∧conjunction in my Amendment 2 is withdrawnVerified:
seams/agent.py:111-123—SessionStateis "what a live Session is doing right now, in the agent's own terms";codex/discovery.py:62-76maps Codex'sactive→RUNNING,idle/systemError→IDLE, nothing →WAITING, because Codex has no such status and a held approval lives inside an active turn;seams/agent.py:194-197—WaitingFor.needs_the_useris the seam's own word for "only the user can answer this". Run20260827T093731Zshows the rowstate=running, waiting_for.kind=permission, same approval id, for the whole window — the product read the machine correctly and my predicate refused it.The Shape says "those whose
waiting_foris still question/permission now" — no state condition. I addedWAITING ∧off the Claude roster's three-word cycle; on Codex that pair can never be true. Withdrawn.Decided: reconciliation eligibility is live ∧ main ∧
waiting_for.needs_the_user, agent-nativeSessionStatenot consulted. The delivered-memory key (target,approval_id or kind) and its clearing rule become "cleared when the row is next observed withneeds_the_userfalse, or ended" — same rule, same field. B is refused for the worker's reason:SessionStatemust stay the official activity, and a fabricatedWAITINGwould leak intoderive_reply_window,progressand every surface that renders the row.Tests: red/green
RUNNING + PERMISSION(the Codex shape), keepWAITING + QUESTION(Claude) and moved-on (IDLE + NONE). Then rebuild, rerun all three; closing evidence as ruled above. Add to the resolution comment's legacy line: legacy read "needs the user" off the transcript, never off an activity flag (coordinator.py:356-412) — this is the ported half.Simon-AI-coding commentedon Aug 27, 2026
Delivery evidence — complete
Implemented on branch
80-duty-bookkeepingfrom fixed pointafbef3d9ffbc8c8e441d45b6a6029d6a8e58510c.Commit:
d78ee187ac4775416f6e98ccc20a610e71737ad7(Implement current-state duty reconciliation).origin/mainremained exactly the fixed point at final fetch; the branch is one commit ahead and the worktree is clean.Delivered behavior
RelayKind.NOTICE, and blindsweepreplay. An undelivered notice attempt is terminalLifecycle.DROPPED; Answer Relays retainLifecycle.RETAINED.waiting_for.needs_the_useris true.SessionStateis not part of actionability. This covers real CodexRUNNING + PERMISSIONand ClaudeWAITING + QUESTIONwithout rewriting adapter state.needs_the_userbecomes false, the Session ends, or the registry re-keys/removes the target.Switchboard, public Agent seams, Reply Window, progress, CONTEXT.md and ADRs are unchanged.Legacy classification
legacy@1d32845:bridge/coordinator.py:356-412observe while Duty is off: ported.legacy@1d32845:bridge/daemon.py:2582-2612report accumulated historical notices: dropped, because v1.0 reconciles current actionable state and never replays historical notice objects (Parity audit: legacy as the behaviour spec — the gap table that scopes the repair #61 V1).legacy@1d32845:bridge/store.py:3192-3208durable accumulated-notice storage: dropped with that replay path.Automated gates
ruff check .PASS;ruff format --check .PASS; 1639 selected tests PASS, 3 acceptance tests deselected from the ordinary suite.swift format lintPASS.scripts/build-app.sh; strict deep code-signature verification PASS.Code review
CRG: unavailable — this session exposed no
code-review-graph,get_minimal_context_tool, ordetect_changes_tool.CLOSED.needs_the_userincrement and public dedup behavior test each returnedCLOSED.Real-machine run of record
Run
20260827T100454Zon the final tree, with no code change after it:/Users/simon/Library/Application Support/GPT-VoiceCoding/acceptance/20260827T100454Z/verdict.json.The Codex
switchesstep proved Duty off silence for 120 s, then Duty on produced exactly one existing-permission announcement as Telegram message3860; the same approval was answered and the Session returned idle.#113 observation: the Codex lane first Stop Notice used Session Name
workspace-codex-20260827T100454Z · 回复 READYin bot message3853— generated-title form, not the id-prefix or prompt-cut fallback.Ghost-thread observation: old thread
01a04255-ec12-7650-a71c-ab2147f3001fwas still listed as a live main Session although its original TUI pid31966no longer existed. This was recorded, not graded, and filed separately as #123; no thread cleanup was performed.Whole-file AST counts
Counts use
sum(1 for _ in ast.walk(ast.parse(file)))on every changed Python file:src/gpt_voicecoding/adapters/companion_channel/telegram/adapter.py: 1491src/gpt_voicecoding/core/__init__.py: 3src/gpt_voicecoding/core/approvals.py: 891src/gpt_voicecoding/core/bridge.py: 3280src/gpt_voicecoding/core/escalation.py: 930src/gpt_voicecoding/core/interlock.py: 218src/gpt_voicecoding/core/lifecycle.py: 78src/gpt_voicecoding/core/relay_queue.py: 510tests/acceptance/journey.py: 6201tests/acceptance/support.py: 5275tests/test_approvals.py: 2092tests/test_bridge.py: 6863tests/test_escalation.py: 1497tests/test_interlock.py: 877tests/test_journey_attribution.py: 1313tests/test_relay_pipeline.py: 2624tests/test_relay_queue.py: 1071Simon-AI-coding commentedon Aug 27, 2026
Landed (advisor, 2026-08-27): #80 → PR #124,
50ab60d. Found at Simon's "is #80 done?" that the ticket was closed with its commit still on the local worktree branch and no PR — opened #124 with provenance, read the core diff against the seven rulings above (matches), gate green on the worktree's own venv (note: the repo-root.venvis an editable install ofmain'ssrc, so running branch tests through it reports phantom failures), CI 4/4 green, squash-merged. Run of record20260827T100454Zis on this exact tree. The80-duty-bookkeepingworktree at.claude/worktrees/is left for whoever owns the machine cleanup (#54).Delete Bridge Core's delivered-wait ledger (#161)