fix(workspace): the audio bridge owns the live-call lease, arms it at connect and always releases it (#2700) - #2775
Merged
Merged
Conversation
…holds (#2700) Trinity Rule #1 — the requirements change lands before the code that falsifies them. `POST …/voice/start` arms the thread's live-call marker before the audio WebSocket that is the only thing able to clear it exists, so a start whose socket never opens strands a ~32-minute `409 voice_call_active` on every typed turn in that thread, naming a call the person cannot end. - public-access.md §48.3 FR-3b: "no reply lands mid-call" becomes "…once the audio bridge is up", and a call whose socket never opens never holds the thread. - runtimes.md §29.10 VOICE-010 (Session lifetime): "this thread is on a call" is a property of the session's connection lifetime — an owned lease armed by the bridge at connect, renewed while it lives, never past the cap, released on every exit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… connect and always releases it (#2700) `POST …/voice/start` armed the thread's live-call marker (TTL = the call cap + slack = 1920 s) before the audio WebSocket that is the only thing able to clear it existed. Both clear paths sit downstream of that socket, and so does the cap watchdog that would end the session — so a start whose socket never opened stranded a ~32-minute `409 voice_call_active` on every typed turn in that thread, from any tab and from the headless `/chat`, naming a call the person cannot end. Three moves; the first is the one the issue asked for, the other two are why it holds. 1. The party that opens the effect closes it. The bridge is now the only writer: it arms the marker as the first statement inside the same `try` whose `finally` releases it, and that release is unconditional, LAST, and keyed on the `portal_session_id` local captured before the `try` — never on `ended.portal_session_id`, where an `ended is None` return (the exact case the REST `/stop` clear was added for) would re-strand it. The reported orphan window goes to zero. 2. The marker is a lease, not a latch. Its TTL is 60 s, renewed every 15 s by a bridge-owned task, bounded at the call's own `max_duration + 120` so it can never outlive one. Without this, move 1 only relocates the harm: a SIGKILL, an OOM or a routine backend deploy mid-call would still strand the thread for 32 minutes with this issue's own symptom. The 4x TTL/tick ratio and its reason are `agent_call_limiter`'s, copied not invented; the renew write goes off-loop via `asyncio.to_thread` for the same reason. 3. The lease has an owner. The value stored is the call's `voice_session_id` and a release deletes only on match (`clear_turn_inflight`'s precedent), so a closing bridge cannot free the thread of a newer call — a reload, a second tab. `owner` is keyword-only and required on both primitives. The legacy `"1"` value is treated as unowned and released, so a marker stranded across the deploy is not immortal. Also, in blast radius: the close-out is wrapped in `except Exception` + `logger.exception` (not `BaseException` — cancellation still propagates), so a raising `end_session`/persist no longer skips the gemini cancel, the `saved` frame and the close. The renew task is cancelled as the first statement of the `finally`, before anything that can await, so no renewal can re-arm after the release. The read stays fail-OPEN and `_refuse_turn_during_voice_call` is byte- unchanged (#2735's `voice_call_id` exemption intact). No new user-visible surface, state, string or default; no frontend file touched. The three `test_2694_voice_delta_context.py` tests that pin the old set site are rewritten in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…n shape end to end (#2700) The issue's third AC — "the existing `test_2694_voice_delta_context.py` marker tests still pass" — cannot hold literally: three of them pin what this fix moves. It is read instead as "the marker's behavioural guarantees stay pinned — armed while a call is live, released when it ends, held only by its owner, fail-OPEN on Redis, the TTL as the backstop — and every test that changes gets stronger". `test_2694_voice_delta_context.py` - the primitives test gains the owner token: `owner` is keyword-only and required on both, a release from a DIFFERENT call does not free a live thread, the legacy `"1"` value is releasable, bytes decode, and the real lease/tick/slack constants plus the >=4x TTL:tick ratio are pinned here (the bridge tests import them from a fake module and cannot). - NEW: the renewer ticks at the lease TTL and STOPS at the call's own cap — a fake `mark` that raises after 10 calls turns a missing bound into a fast red instead of a hang — and a cancelled renewer stops quietly. - `…marks_the_thread_live_for_the_cap_plus_slack` becomes `test_start_workspace_voice_does_not_arm_the_thread_before_a_socket_exists`. The recorder takes `owner=None` by default so a RESTORED two-arg `/start` arm is recorded rather than raising: `marks == []` is what must bite. - NEW, the AC-2 test: start a call, never open a socket, then type into the same thread. The REAL `mark_voice_call_active` runs against a fake Redis on purpose — a recorder stub would swallow the write and pass even with the `/start` arm restored, so `fake.store == {}` is the assertion that bites. The turn genuinely dispatches (`recorder.calls` non-empty), and a positive control arms the lease the way the bridge now does and gets the 409 back. - the 600-char proximity grep around `persist_voice_call_end(` is replaced by `test_the_bridge_resolves_the_real_marker_helpers`: it could never see the `if ended:` nesting that IS this bug. What survives is what a source read can honestly assert — that the real modules resolve each other (every bridge test runs against a fake), that the marker import is hoisted out of the `finally`, and that the release is keyed on the pre-`try` local, not on `ended`. `test_voice_auth.py` — new `TestWorkspaceLiveCallMarker`, here because the behavioural bridge harness (the importlib load of `routers/voice.py`, the stubbed voice service, `_FakeWebSocket`, the #762 restore net) exists in this file and nowhere else. Armed at connect and released on close; released when `end_session` returns None (Trap C); released when the close path raises, with the gemini cancel, the `saved` frame and the socket close all still reached; released under task cancellation; an Agent Detail call never arms a thread marker (labelled a guard — it passes against the old code too); and the REST `/stop` release is owner-matched (an API-only guard, D9). Three harness fixes the tests need: `_FakeVoiceSession` gains `portal_session_id`/`max_duration`/`end_reason`/`end_message` (the close-out reads the last two without getattr defaults), the fake `client_portal.voice` exposes `persist_voice_turn` (imported unconditionally for a portal-bound session), and `_YieldingWebSocket` awaits once in `receive_text` — the stock fake pops its queue without a single `await`, so a bridge driven by it never yields and its `create_task`ed children never start. Red-on-base proof (both source files restored from a scratch copy, never `git checkout --`): 10 of the 11 new/rewritten tests fail against `2c5cfe0e`; the one that passes is exactly the labelled Agent Detail guard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… connect gap is named (#2700) The feature flow said verbatim that the marker is "written by `start_workspace_voice` once the provider session exists (TTL = the cap + slack), cleared by the bridge's `finally` and by the REST `/stop`" — the sentence this fix falsifies. - `workspace-voice-conversation.md`: the claim is retitled "no reply lands mid-call **once the audio bridge is up**", and the mechanism is rewritten — an owned lease armed by the bridge at connect, renewed while it lives, never past the call's cap, released unconditionally and owner-matched on every exit. The rule is named in one clause (a marker whose only closer lives downstream of a connection that may never exist is an orphan generator), and all three load-bearing properties are stated with the failure each prevents. The REST `/stop` is described as what it is: idempotent, owner-matched, and API-only — the Workspace passes `restStop: false` and never calls it. - Known limits gains three bullets: the `/start`→connect gap in #2694's own words (the turn posts and is answered normally; what is lost is the voice model's opening context, which is exactly where "a reply between two spoken rows hides the call's first half" becomes possible again); a half-open peer holding the lease until the renewer's cap+slack lifetime ends, including the cap path's 5 s `saved`-timeout close; and the key being one per thread. - The Files row for `routers/voice.py` names the arm, the renew and the unconditional release. - `feature-flows.md` gains the dated index row. - `learnings.md` gains the durable class entry: arm the effect in the `try` whose `finally` releases it; relocating the setter is only a third of the fix (a marker held by a live process is a lease, and the renewer needs its own bound); an unconditional release needs an owner token; and a test that pins the old write site cannot "still pass" — rewrite it and prove it bites. `architecture/workspace.md` is deliberately NOT edited: its sentence ("a typed turn is refused (409) while a call is on") states no set site and stays true, and the detail's home is the feature flow (one home per feature). Also verified unchanged: the ent#551 `voice_call_id` exemption paragraph. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ives, and say so (#2700) Three claims the #2700 branch makes are stronger than the implementation provides. All three are the same fact — the renewer's Redis write is off-loop — and each one is a comment or a Known-limits line a future reader would build on. 1. The bridge's `finally` claimed that cancelling the renewer first means "a renewal already in flight lands before the release, and the delete wins". It does not: `asyncio.to_thread` raises `CancelledError` in the awaiting coroutine immediately and lets the worker thread run to completion, so a renewal inside its `SET` can land after the release's `DEL`. Verified with a standalone `asyncio.run` reproduction, not by reading the docs. The consequence is bounded and benign — one lease (<= 60 s) of extra 409s after a call ends, self-healing, read still fail-OPEN — and it needs the renewer to be inside a ~1 ms write at that instant AND that write to outlast the whole close-out. 2. The REST `/stop` release is not durable while the bridge is up: the renewer re-arms within a tick, so that path frees the thread only once the socket is gone. An API-only path either way (`restStop: false`). 3. "a closing call cannot free a newer one" overstated the owner match for two concurrent calls on one thread: two live renewers flip-flop the value, so a closing call frees the newer one when its own id was the last write, and the newer call's next tick re-arms within 15 s. The permanent free an unconditional delete would have caused is gone; a <= 15 s window in a deliberately rare shape is not. No behaviour change: comments, one Known-limits bullet, and a fifth clause on the issue's learnings entry, since "cancelling a task does not cancel work it already handed to a worker thread" is the durable class here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
vybe
marked this pull request as ready for review
September 14, 2026 13:16
# Conflicts: # docs/memory/feature-flows.md # tests/registry.json
vybe
approved these changes
Sep 14, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
merge-train: batch validated on train/20260914-1332
Contributor
|
merge-train note — pushed one mechanical commit ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2700
Problem
POST …/voice/startmarked the Workspace thread "a call is on" before the audio WebSocket existed, and every path able to clear that marker — the bridge'sfinally, the REST/stop, and the cap watchdog that would end the session — sits downstream of a socket that may never open. A start whose socket never opened therefore stranded the marker for its full TTL (WORKSPACE_VOICE_MAX_DURATION + 120= 1920 s, ~32 min at defaults), and for that whole window every typed turn in that thread — from any tab and from the headless/chatsurface — was refused409 voice_call_activewith "A voice call is on in this chat — end it, then send.", naming a call the person cannot end. The only recovery, start another call and hang up properly, is undiscoverable.The change
Three moves. The first is the one the issue asked for (option 2); the other two are why it holds.
trywhosefinallyreleases it, and that release is unconditional, LAST, synchronous (so it also runs when thefinallyis entered by cancellation) and keyed on theportal_session_idlocal captured before thetry— never onended.portal_session_id, where anended is Nonereturn (the exact case the REST/stopclear was added for) would re-strand it: the same defect, moved.VOICE_MARKER_LEASE_SECONDS = 60, renewed everyVOICE_MARKER_TICK_SECONDS = 15.0by a bridge-owned task, bounded at the call's ownmax_duration + VOICE_MARKER_SLACK_SECONDSso it can never outlive one call. Without this, move 1 only relocates the harm — a SIGKILL, an OOM or a routine backend deploy mid-call still strands the thread for 32 minutes with this issue's own symptom. Both event-driven variants were rejected on verified grounds: re-arming per spoken turn expires a live-but-quiet call (the person listening to a long answer fires no turn), re-arming per audio frame expires a muted call (useVoiceSession.jsskipsws.sendwhile muted). Either resurrects bug(workspace): a voice call sits at the top of the thread instead of where it happened, and the agent's next typed turn does not know what was said #2694. The 4× TTL:tick ratio and its reason are lifted fromagent_call_limiter.py, copied not invented; the renew write goes off-loop viaasyncio.to_threadfor the same reason it does there.voice_session_id, and a release deletes only on match (client_portal/service.py::clear_turn_inflight's read-then-delete-on-match precedent).owneris keyword-only and required on both primitives, so a future call site cannot silently write an unowned lease. Without this, a closing bridge frees the thread of a newer call — a reload, a second tab — which is the bug(workspace): a voice call sits at the top of the thread instead of where it happened, and the agent's next typed turn does not know what was said #2694 defect re-entered through this fix.Per-producer bounds, before → after:
Also in blast radius, two lines: the close-out is wrapped in
except Exception+logger.exception(notBaseException—CancelledErrorstill propagates), so a raisingend_session/persist no longer skips the gemini cancel, thesavedframe and the socket close. The renew task is cancelled as the first statement of thefinally, before anything that can await.The read (
voice_call_active) stays fail-OPEN and_refuse_turn_during_voice_callis byte-unchanged — #2735'svoice_call_idexemption is intact (client_portal/service.pydoes not appear in this diff, andtests/unit/test_ent551_voice_background_tasks.pypasses unedited). No new endpoint, field, broadcast, model or migration. No frontend file is touched.Acceptance criterion 3, restated behaviourally
The issue's third AC is "the existing
test_2694_voice_delta_context.pymarker tests still pass." Three of them cannot pass unchanged, by construction, because they pin exactly what this fix moves:test_the_live_call_marker_is_set_cleared_and_fails_openowneris now keyword-only and required…marks_the_thread_live_for_the_cap_plus_slack/startis the set site — the thing being removedtest_the_bridge_clears_the_marker_when_the_call_closesinspect.getsourceproximity grep that structurally cannot see theif ended:nesting which is the defectAC 3 is therefore read as: the marker's behavioural guarantees stay pinned — armed while a call is live, released when it ends, held only by its owner, fail-OPEN on Redis, the TTL as the backstop — and every test that changes gets stronger. The first becomes the owner-token test; the second becomes
test_start_workspace_voice_does_not_arm_the_thread_before_a_socket_exists; the third is replaced bytest_the_bridge_resolves_the_real_marker_helpers(which asserts what a source read can honestly assert — that the real modules resolve each other, that the marker import is hoisted out of thefinally, and that the release is not keyed onended) plus four behavioural bridge tests. Every rewrite is proved to bite by the mutation battery below. The other 25 tests intest_voice_auth.pyand the whole regression fence run unedited.Decisions recorded
stop()onws.onerror/beforeunload) is rejected. Primary reason: under option 2 there is nothing left for it to clear — on the never-opened path no marker is armed, and the only residue is the voice session blob, already bounded by its ownmax_duration + 60Redis TTL. A mechanism with no work to do is not belt-and-braces. Secondary: the naive form fires after a successful open too, where a forced REST/stopcosts the call's summary row (end_session+remove_sessionrun first, so the bridge's ownend_sessionreturnsNoneand the "Voice call · N min" row is never written) — which is whatrestStop: falseprotects.VOICE_MARKER_LEASE_SECONDS = 60andVOICE_MARKER_SLACK_SECONDS = 120were ratified at review as engineering bounds, not product settings: they expose no surface and no knob, they govern only crash-recovery latency and the half-open ceiling, and the lease obeys the in-house ≥4× TTL:tick rule (agent_call_limiter.py) so one BGSAVE-class stall cannot expire a live bridge's lease. They are pinned by a test, so a future change to either is a visible edit rather than a drift.docs/memory/architecture/workspace.mdis deliberately NOT edited. Its sentence — "a typed turn is refused (409) while a call is on" — states no set site and stays true; the detail's home is the feature flow (editorial rule: one home per feature). Named here so a reader can tell reviewed from overlooked. Same forarchitecture/backend.md,api-endpoints.md,frontend.mdandbackground-services.md: no endpoint contract, signature, router registration, frontend file or startup loop changes.category/retryablecontract, the client's'Voice connection error'andVOICE_UNAVAILABLE_FALLBACKare untouched, and the owning tab's composer inertness is client-side modal state the marker never drove. The only user-visible change is subtractive: the 409 stops appearing when there is no call.Tests
Red-on-base (recorded by the implementer against
2c5cfe0e, both source files restored from a scratch copy — nevergit checkout --): 10 of the 11 new/rewritten tests fail; the one that passes is exactly the test labelled a guard.2c5cfe0etest_the_live_call_marker_is_set_cleared_and_fails_open(owner cases + real constants)test_the_renewer_holds_the_lease_and_stops_at_the_calls_own_captest_start_workspace_voice_does_not_arm_the_thread_before_a_socket_existstest_the_bridge_resolves_the_real_marker_helperstest_a_start_whose_socket_never_opens_leaves_the_thread_writable(the AC-2 test)test_the_bridge_arms_a_lease_at_connect_and_releases_it_on_closetest_the_marker_is_released_even_when_the_session_already_ended(Trap C)test_the_marker_is_released_when_the_close_path_raisestest_the_marker_is_released_when_the_bridge_is_cancelledtest_an_agent_detail_call_never_arms_a_thread_markertest_the_rest_stop_releases_the_marker_for_a_stop_that_beats_the_socketMutation battery. The plan's must-do 6 named four mutations; review extended the battery to seven and recorded all seven red. Re-run at ship stage on this branch's source (both files copied to a scratch directory first and restored from it afterwards — verified byte-identical, never
git checkout --), each mutation pinned to the named test it must turn red:mark_voice_call_active(...)armtest_the_bridge_arms_a_lease_at_connect_and_releases_it_on_close1 failedended.portal_session_idtest_the_marker_is_released_even_when_the_session_already_ended1 failed/startarmtest_start_workspace_voice_does_not_arm_the_thread_before_a_socket_exists+test_a_start_whose_socket_never_opens_leaves_the_thread_writable2 failedownermatch from the release (unconditional delete)test_the_live_call_marker_is_set_cleared_and_fails_open1 failedwhile elapsed < max_seconds→while True)test_the_renewer_holds_the_lease_and_stops_at_the_calls_own_cap1 failedexcept Exception)test_the_marker_is_released_when_the_close_path_raises1 failedfinallytest_the_bridge_arms_a_lease_at_connect_and_releases_it_on_close1 failedNeighbourhood fence (the six files that own this surface), run before and again after the rebase onto
bf7ab512e:The registry consumer also runs clean after the rebase:
unit/test_2338_journey_catalog.py unit/test_2339_testing_docs_consolidated.py→ 75 passed.Full local verification —
verify-local --skip-agenton the pre-rebase tipb9b52799, projecttrinity-verify-0d1de12b: PASS.unit 15,555 passed / 31 skipped / 0 failed (685 s); integration 70 passed / 13 skipped / 2 deselected / 0 failed; build + import-smoke and boot + health green. That run predates the rebase onto
bf7ab512e, and every file this branch changes insrc/backend/,tests/unit/,docs/memory/requirements/and the feature-flow doc is byte-identical across the rebase (verified by hash); the rebase re-resolved onlytests/registry.jsonanddocs/memory/learnings.md(both append-at-the-tail collisions, both sides kept) and auto-merged one row intodocs/memory/feature-flows.md.Real-path proof (verbatim from the verify-stage run,
.plan/proof-2700/README.md)Every step below ran against the isolated
verify-localstack (backendhttp://localhost:60250, redistrinity-verify-0d1de12b-redis) left up by the run above — never against a live fleet. The live stack was byte-identical before and after (/trinity-backend,/agent-trinity-systemuptimes continuous), and no container was created, started, restarted or deleted.One caveat the reviewer should have, stated rather than glossed: the conditional marker import is inside the WebSocket handler under
if portal_session_id:, so application boot never executes it and a clean boot log is necessary but not sufficient. Step 4b closes that — its first lines import the same six names fromclient_portal.voiceinside the built image and print them resolved.Known limits (accepted, documented in the flow)
/start→ connect gap is unmarked. Between the start returning and the bridge arming its lease, a typed turn from a second tab or the headless/chatcan land. It posts and is answered normally — no new state, string or default, and nothing is lost: spoken rows are written as they happen and the post-call delta still carries everything to the agent's next typed turn. What is lost is that the voice model's opening context does not contain that one message — precisely where "a reply between two spoken rows sits after the cursor and hides the call's first half" becomes possible again, which is why the claim is now scoped to "no reply lands mid-call once the audio bridge is up". Not new in kind:/startalready leaves such a window from itsget_turn_inflightcheck to the mark, spanning the prompt resolve, the thread read and the provider round trip; this roughly doubles it. Closing it needs a grace mark at/start— a setter whose only release is a TTL, i.e. a smaller copy of the dead end being removed.finally, butasyncio.to_threadhands the Redis write to a worker thread cancellation does not reach, so a renewal already inside thatSETcan land after the release'sDEL. Verified with a standalone reproduction, not read off the docs. Bounded and self-healing: the thread keeps refusing typed turns for ≤60 s (one lease) after a call ends, with the read still fail-OPEN, and it needs the renewer to be inside its ~1 ms write at exactly that instant and that write to outlast the whole close-out. The REST/stopsees the same fact at a coarser scale — it releases, a bridge that is still up re-arms within a tick, so that path frees the thread only once the socket is gone (an API-only path either way: the Workspace passesrestStop: false).cap + 120 slifetime plus one 60 s lease — today's bound plus one lease in the worst case, ~0 s in every normal one. The cap path is the one to watch:useVoiceSession.js::_onEndedneither sends{"type":"end"}nor closes, so the socket closes only on the client's 5 ssaved-frame timeout.Mixed-version window
Backend-only: no migration, no config, no feature flag, no frontend build. Takes effect on the next backend restart. New
/start+ old bridge: nothing is armed, nothing strands. Old/start+ new bridge: the old code armed the literal"1", and the new release treats"1"as unowned and releasable by anyone, so a marker stranded across the deploy is not immortal (proved against a real Redis in step 4b). Markers nobody closes expire on their existing 1920 s TTL. No backfill.Docs touched
Six deltas, all decided before implementation:
feature-flows/workspace-voice-conversation.md("No reply lands mid-call")start_workspace_voice… cleared by the bridge'sfinallyand by the REST/stop" — is rewritten to the bridge-held owned lease, and the claim is retitled "once the audio bridge is up". The rule is named in one clause.requirements/public-access.md§48.3 FR-3brequirements/runtimes.md§29.10 VOICE-010 (Session lifetime)routers/voice.pydocs/memory/learnings.mdPlus
tests/registry.json: theunit/test_2694_voice_delta_context.pydescription now names the #2700 additions, andunit/test_voice_auth.py— which had no entry — gets one covering the #600 ownership gates and the #2700 marker lifecycle.Follow-ups (listed here, not filed)
/start→ connect gap — carried as a documented limit rather than closed; closing it needs a grace mark whose only release is a TTL.pagehide+fetch(..., {keepalive: true})), which would shorten [L2] at its source rather than bounding it.src/backend/routers/voice.pycarries no first-line# mcp:header (Invariant feat: SMARTS trading pipeline with Telegram notifications and Miro visualization #13) — pre-existing, untouched here.renew_voice_call_markerswallowsCancelledErrorby design (except asyncio.CancelledError: pass), sotask.cancelled()readsFalseafter cancellation — documented in the proof, worth a second look as a house pattern.to_thread" shape is shared withagent_call_limiter's refresher, and deserves one pattern rather than two local notes.docs/memory/feature-flows.mdRecent Updates length — listed by this run when the table stood at ~140 rows against its own ~20 cap; already addressed ondevby docs(feature-flows): sync flows for #2638/#2670 and bring the index back under its own cap #2753, and after the rebase the table is 21 rows including this one. Recorded so the earlier observation is not read as still-open._claim_save/_save_transcriptinrouters/voice.pyhave had no caller on the Workspace path since refactor(agent-detail): retire the chat-panel voice overlay — the Talk button opens voice in the Workspace #2559 — theelifbranch they sit behind is Agent-Detail-only. Dead-code question, deliberately not answered inside a P2 bug fix..claude/agents/test-runner.mdcatalog row forTestWorkspaceLiveCallMarker(see below).Uncommitted by design
.claudeis a private submodule checked out at a detached HEAD in this worktree, and itsagents/test-runner.mdcarries an uncommitted catalog row for the newTestWorkspaceLiveCallMarkertests. It is not part of this PR and cannot be: the submodule is a separate repository.git statusin the worktree therefore showsm .claudeand nothing else; no file of this PR is left uncommitted.🤖 Generated with Claude Code
https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv