fix(meetings): hold the opening of a meeting through agent initialization - #6649
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound hold-and-replay design; but a failed Watch
Suggestions
[DESIGN-REVIEWED] 74c46f1 |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS The mic now opens the moment speech can land, pinned in both directions; the only new user-visible string is a near-unreachable overflow marker with fixable copy. Suggestions
[UX-REVIEWED] 74c46f1 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified. Compiling the review. First-Principles-Verdict: PASS Every item traces to the reported ~46s loss (#4610) or to an invariant the hold itself creates; counts and claimed mechanisms all check out in the repo. What this change shipsIntent: stop a meeting's opening speech from being lost while agents initialize — a FIX.
No duplicate mechanism exists for the hold (grepped [FIRST-PRINCIPLES-REVIEWED] 74c46f1 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate's named triggers — "a model turn errors, a slot spawn fails" during No findings. [OPUS-REVIEWED] 74c46f1 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
c4ea94c to
020ccc3
Compare
Round 1 disposition — GPT 5.6 finding accepted in fullBLOCKING — Verified before fixing, by reverting only
Applied the suggested shape:
One deliberate departure from the literal suggestion: the snapshot stores agent names, not queue objects. An agent disabled mid-initialization has its queue removed from Four new regression tests: Screenshot Evidence — resolved with the Also rebased onto |
020ccc3 to
cc393a9
Compare
Round 2 dispositions — head
|
…tion `handle_start_meeting` persists `active`, installs the live session, then awaits `init_agents` — a sequence of model turns the reporter measured at ~46s. Ingress was suspended for that whole span, so every line spoken into it was answered 409 `no_active_meeting` and never reached an agent: the notes and tasks began partway through the first topic, with nothing in either to show a turn had been dropped. Fixes #4610. Speech in that window is now HELD on the session and replayed in arrival order the moment initialization completes. `accepting_dispatches` alone could not carry this, because it was false for two opposite reasons: the meeting is stopping/reviewing/expired (the line has nowhere to go — refuse it, the gate #1981 added) or the meeting is STARTING (the line is wanted — hold it). The holder now records WHICH, as the session being initialized rather than a bare flag, so the state cannot outlive the identity it describes. `suspend_dispatches` grows a `buffer_speech` keyword that only the start path passes; every other caller keeps refusing, unchanged. Each line is normalized, filtered and ADDRESSED at arrival, then stored with the recipient names it had at that moment. Both halves of "what happens to this line" are decided when a live line would have decided them, so the hold only ever shifts delivery in TIME. Deferring either to drain was wrong in a way the live path cannot reproduce: recognizer filler occupied a cap slot until drain and only then was discarded, so a burst of `"uh"` could evict the genuine opening speech the bound exists to protect; and recipients resolved at drain let a mute landing mid-initialization reach backwards and rob a line spoken while that agent was still listening. Names are stored rather than queue objects so an agent disabled mid-initialization is skipped instead of being fed a queue nothing flushes. Both dispatch paths read `muted_agents` after their own awaited transcript write, and `handle_mute_agent` was the one writer that touched that set with no lock — so a mute landing inside that window re-addressed a line to the mute state of a moment AFTER it was spoken. The lock goes on that writer rather than on either reader: guarding one dispatch branch would close one window and leave its twin open, and every other writer already holds `DISPATCH_LOCK`. The hold is bounded at `MAX_INIT_BUFFER_LINES` (200) and counted in LINES, because the producer is one finalized speech segment at a time and each is already capped at `MAX_TRANSCRIPT_CHARS`. A bound is required rather than nice to have: `POST .../dispatch` accepts untrusted text, so an unbounded hold on that path is a memory-exhaustion lever. Overflow drops the OLDEST lines — a slow initialization is one where the newest speech is still in play — and is announced to both readers. The agent-facing marker is addressed to the union of the recipient sets recorded on the lines the cap DROPPED, not to whoever is unmuted at drain: those two audiences diverge under a mute landing between the drop and the drain, and the agent with the gap is precisely the one a drain-time audience omits. It would receive its surviving pre-mute lines with no notice that an opening turn was lost. The transcript gets the same marker under a new `system` source, added to `VALID_TRANSCRIPT_SOURCES` deliberately: `read_transcript_page` drops records whose source it does not recognize, so a marker outside that tuple would be filtered out on read and the gap would be silent again. The transcript append happens at ARRIVAL for held and live lines alike, via one shared `_record_line`, so the human record stays complete and in spoken order even when the hold overflows — an overflow costs the agents context, never the user their transcript. The frontend half is load-bearing, not cosmetic. `canOpenTranscription` gates the microphone on the polled `accepting_dispatches`, so with the server change alone the client would still keep the mic shut for those ~46s and nothing would ever reach the hold. The meeting poll reports a second flag, `buffering_dispatches`, and the gate opens on either — a server that reports neither is still the closed case this gate was written for. Nothing observability-only rides along: the hold's counters and a `buffered` response flag were carried at first and had no reader — not the fix path, not the mic gate, not the client — so they are left out. They belong with the "Preparing agents…" badge, which is a separate follow-up. Red-before proven: 17 backend cases and 1 frontend case fail on the base commit, the headline one with the exact `no_active_meeting` 409.
|
Revalidated after the concurrent head update to #5741 extracts the meetings ingress policy into Please rebase by folding buffering in as a first-class admission result of #5741's extracted seam instead of preserving two dispatch pipelines. The integrated path should keep #6649's recipient-at-arrival semantics, mute serialization, session identity, strict ordering, bounds/drop accounting and targeted marker, while retaining #5741's common live/typed/imported persistence and dispatch behavior. Please add transition coverage for both live/typed dispatch and imported transcript lines across initialization and drain. |
cc393a9 to
74c46f1
Compare
Round 3 disposition — head
|
bolichen97
left a comment
There was a problem hiding this comment.
Approving per triage sweep: readiness: passed, required check PR Readiness green, mergeable, no valid change requests or unresolved threads.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Fixes #4610.
Why no screenshot: this change adds no UI element, style, or layout. Its only frontend effect is that an existing state — the microphone being open — activates ~46s earlier, because the server now holds speech through agent initialization instead of refusing it. There is no new pixel to photograph; the behaviour is pinned by two
canOpenTranscriptiontests instead (mic opens on the hold, stays shut when the server reports neither open state). The "Preparing agents…" badge, which would be a visual delta, is deliberately not in this PR.The bug
handle_start_meetingpersistsactive, installs the live session, then awaitsinit_agents— a sequence of model turns the reporter measured at ~46s. Ingress was suspended across that whole span, so every line spoken into it answered409 no_active_meetingand reached no agent. The notes and tasks began partway through the first topic, with nothing in either to show a turn had been dropped.The fix
Speech in the init window is held on the session and replayed in arrival order the moment initialization completes.
Two closed-ingress reasons, told apart.
accepting_dispatcheswas false for two opposite reasons: the meeting is stopping / reviewing / expired (the line has nowhere to go — refuse it, the gate #1981 added) or the meeting is starting (the line is wanted — hold it). The holder now records which, as the session being initialized rather than a bare flag, so the state cannot outlive the identity it describes.suspend_dispatchesgrows abuffer_speechkeyword that only the start path passes; stop, reviewing, expiry and outgoing-session replacement keep refusing, untouched.Filtered and addressed at arrival. Each line is normalized, noise-filtered, and paired with the recipient names it had at the moment it was spoken, then stored. Both halves of "what happens to this line" are decided when a live line would have decided them, so the hold only ever shifts delivery in time. Names rather than queue objects, so an agent disabled mid-initialization is skipped instead of being fed a queue nothing flushes.
Bounded, by line count.
MAX_INIT_BUFFER_LINES = 200. The producer is one finalized speech segment at a time and each is already capped atMAX_TRANSCRIPT_CHARS, so lines are what the overflow rule has to reason about. A real opening lands 15–25 lines here; the cap bounds the hold at ~800 KB for the one meetingMAX_CONCURRENT_MEETINGSpermits. The bound is required, not a nicety —POST …/dispatchaccepts untrusted text, so an unbounded hold there is a memory-exhaustion lever.Overflow drops the OLDEST, and tells the agents that actually lost something. A slow initialization is one where the newest speech is still in play, and the tail is what an agent needs to pick up a conversation mid-flight. The agent-facing marker is addressed to the union of the recipient sets recorded on the dropped lines — not to whoever is unmuted at drain, because those two audiences diverge under a mute landing between the drop and the drain, and the agent with the gap is exactly the one a drain-time audience omits. The transcript gets the same marker under a new
systemsource, added toVALID_TRANSCRIPT_SOURCESdeliberately:read_transcript_pagedrops records whose source it does not recognize, so a marker outside that tuple would be filtered out on read and the gap would be silent again.The transcript is never truncated. Held and live lines are both appended at arrival, through one shared
_record_line, so the human record stays complete and in spoken order even when the hold overflows. An overflow costs the agents context, never the user their transcript.Nothing observability-only rides along. The hold's counters and a
bufferedresponse flag were carried in the first two rounds and had no reader — see the First Principles disposition below. They are left out; they belong with the "Preparing agents…" badge.Why the frontend change is load-bearing, not cosmetic
canOpenTranscriptiononmaingates the microphone on the polledaccepting_dispatches. That gate was itself a mitigation for this window — it stopped early finals from burning the retry schedule, at the cost of not capturing the opening at all. With the server change alone the mic would still stay shut for those ~46s and nothing would ever reach the hold, so the backend fix would be dead code.The meeting poll now reports a second flag,
buffering_dispatches, and the gate opens on either. A server reporting neither is still the genuinely-closed case the gate was written for (stopping, reviewing, expired, or no live session) — that path is pinned by its own test.Review rounds
Both rounds of findings were accepted in full; neither was rebutted.
Round 1 — GPT: filtering and addressing happened at drain, not arrival. Verified by reverting only
session.pytoc4ea94cdc: 6 cases fail."uh"could evict the genuine opening speech the bound protects_prepare_lineruns at arrival; a filtered line consumes no slot_recipient_names()from its own arrival momentRound 2 — GPT: the overflow marker still used the drain-time audience. The sibling of the same asymmetry, in my own round-1 fix: the lines moved to arrival-time recipients but the marker did not. An agent that lost an opening line and was muted before the drain received its surviving pre-mute lines with no notice of the gap — the silent truncation the marker exists to prevent. Fixed by recording the dropped lines' recipient union (
init_dropped_recipients) and addressing the marker to it. Verified by reverting only the marker audience: 2 cases fail.Round 2 — First Principles: three zero-consumer fields. Correct, and confirmed by grep:
init_buffered/init_droppedon the status payload andbufferedon the dispatch response had no reader — not the fix path, not the mic gate (which readsbuffering_dispatches), not the client (dispatchWithRetryreads onlyresponse.segment). The defect is gone without them and the drain already logs delivered/dropped counts. All three are subtracted, along with theagents.pyidle default and theLiveStatus/DispatchResponsetype members. The pinned/statusidle shape is back to its base form, so this PR no longer touches that contract at all.Opus 4.8 and UX Review returned no findings.
Tests — red-before proven
Against the base commit: 17 backend cases and 1 frontend case fail, the headline one with the exact 409:
Coverage, per the issue's requirements:
test_speech_mid_init_is_buffered_and_delivered_in_ordertest_the_hold_is_bounded_and_drops_the_oldest,test_it_accepts_up_to_the_cap_without_droppingtest_the_line_past_the_cap_displaces_the_oldest,test_a_lowered_cap_sheds_the_whole_excess_at_oncetest_an_overflow_marks_the_gap_for_the_agents_and_the_transcripttest_the_marker_reaches_an_agent_muted_after_its_lines_were_dropped,test_no_marker_reaches_an_agent_that_lost_nothingtest_a_reviewing_meeting_still_refuses_instead_of_buffering,test_an_outgoing_session_being_replaced_does_not_buffer, and the pre-existingtest_stop_closes_dispatch_admission_before_a_slow_agent_flushtest_no_marker_when_the_hold_did_not_overflowtest_a_held_line_is_still_redactedtest_filler_does_not_consume_a_slot_and_evict_real_speech,test_a_mute_during_init_does_not_rob_earlier_speech,test_noise_is_filtered_at_arrival_and_never_occupies_a_slot,test_each_line_keeps_the_recipients_it_had_when_spokenstarts the microphone while the server is HOLDING speech through agent initVerification
test_meetings_*+test_apps_registry_coverage+test_app_manager+test_ci_surface_tests)isort/flake8onsrc/kiro_crew test conftest.py xdist_budget.py— cleanmypy src/kiro_crew/— no issues in 1,157 filestsc --noEmit— cleanpush_guard.py --require-single-on-base— SAFE (single commit on base)Notes for review
DISPATCH_LOCKacquisition. A live dispatch needs that lock too, so nothing spoken after the reopen can overtake the held lines. Releasing between the two would let a meeting's opening land after its second topic.init_agents._init_agents_plancan raise out of itsto_thread, leaving ingress closed and the hold un-drained. That is the behaviour onmaintoo (ingress simply stays shut), and it is exactly why the bound is not optional — the hold stops at 200 lines instead of growing. I deliberately did not add afinallythat reopens ingress: that would fan speech out to agents that were never initialized, which is a worse outcome and wider scope than this issue.broadcastinto_prepare_line+_recipient_names— same behaviour, now shared with the hold so the two cannot drift.41cc5a83b.