Skip to content

feat(voice): relay agent as a bodhi subagent — work calls return their own results - #5310

Merged
liususan091219 merged 11 commits into
mainfrom
feat/relay-agent-subagent
Oct 10, 2026
Merged

liususan091219 merged 11 commits into
mainfrom
feat/relay-agent-subagent

Conversation

@liususan091219

@liususan091219 liususan091219 commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Review asked for the relay agent to be a bodhi subagent in actor mode, not plain code in the voice process (#5290). #5308 put voice in actor mode; #5309 reverts #5290; this rebuilds the relay agent as the PersistentSubagentInstance behind the work tool.

Stack: #5308 (merged) → #5309 → this. Merge #5309 and this together.

What

  • RelayAgent (src/relay-agent.ts), a bodhi PersistentSubagentInstance, registered as subagentConfigs.work (lifetime: 'persistent_session'). work is a background tool with a pendingMessage:
    • bodhi answers the call at once with the pending message, so the model says "working on it" right away. A queue position ("right after the one I'm on", "N in line") or an offline core is said at submission as a system notice.
    • invoke submits the task through the tool's own submission (dedup, spoken turns, header and origin unchanged) and waits for the task's result. bodhi hands it to the model as a system notification when the model is idle.
    • each result names the request(s) it answers (frameResult): "This answers the user's request: …" or "This answers N of the user's requests: …; cover each".
  • Every waiting call ends, and never with a rejection (bodhi reads a rejection out as "Background task failed"):
    • cancelled by the user → ended quietly. A task still queued is cancelled by the table at once: its file is deleted and no CANCEL_INSTRUCTION goes to the core (whose "never queued" reply contradicted it); if the core took it in that instant, its result comes with a "finished anyway" note;
    • aborted → "still running; the result comes separately", and the result goes the other way below;
    • no client connected → the result watcher sends the result to the DM, and the call ends;
    • a result answering several tasks ends their calls too.
  • The voice task table (state/voice-tasks.json, survives a restart): each task's text, submit time, cancel requested, how its result reached the user (injected / dm), and answeredBy, the task whose result answered it (from the core's [deduped: X]).
  • A result no call can carry (the call is gone, the agent restarted): a system notification when the session can speak, held through a meeting; else, after a minute, one DM copy.
  • Reconcile every 5 s (table vs core): an unheard result is handed over again at most twice; an answeredBy row is judged by its answering result; a task not picked up after 3 min is reported once. Status answers (get_task_status, the cancel list, the count ahead) come from the table.
  • bodhi-realtime-agent 0.4.5 (fix(core): actor mode under hold — agents survive a lost upstream, one redialer, recognized speech for active silence randombet/bodhi_realtime_agent#52): under hold, actor mode keeps agents through a lost upstream.

Known limitations

These are regressions against main once #5309 (the revert of #5290) lands:

Tests

  • tests/relay-agent.test.ts (34): the subagent (status at once for a duplicate/rejection, queue position notice, result returned through the call and naming its request, several tasks answered in one result, abort ends without a failure, cancel ends the call quietly), the table, the reconcile rule and pass, status answers from the table, and pins that voice maps work to the relay agent with a pending message and ends cancelled/forwarded calls.
  • tests/relay-agent-e2e.test.ts (4, own process): a result returns through its waiting call; three requests answered in one result are all named and all recorded heard; a session that cannot take a result leaves one DM copy; with no client connected the result goes to the DM and its waiting call ends.
  • tests/meeting-input-hold.test.ts: results held through a meeting are handed over after it.
  • npm run test:ts: no failures beyond the environment-only set that also fails on origin/main here. tsc clean, eslint 0 errors, src-map up to date.

Live evidence

Test Mac, 2026-10-10, at 2d6e921 (details and log lines: #5310 (comment)):

  • three requests back to back: each work call answered at once ("still checking", "right after the one I'm on", "2 in line"); all three results returned through their calls and were read out;
  • a result that landed during a meeting was said after it ("I've sent the meeting note to your Discord DM");
  • before 2d6e921 (NON_BLOCKING, no pending message) the model said nothing after calling work until the user spoke again; that is why the pending message is kept.

At 19ecda8 (#5310 (comment)): cancel of a queued task decided by the table with no instruction, and a page closed while the call waited (result to the DM, call ended).

A #5288 run at 19ecda8 (#5310 (comment)): two results held through a meeting, said whole after the exit, no interruption.

Not live-verified: an aborted call (not reproducible by voice), and answeredBy dedup (the core answered each task separately in every run); both are covered by the unit and e2e tests.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the design — it's sound. RelayAgent's invoke/offerResult/abort handling is race-safe: an aborted work call correctly falls through to the ordinary delivery path (startResultWatcher's callback calls deliverResult when offerResult returns false), and reconcileVoiceTasks's isInFlight correctly checks relay.isWaiting() instead of a separate queue, so a task still held by an active work call isn't double-delivered by the reconcile pass. The "confirmed delivery never downgraded" rule in the store, the cancel/replay/not-picked bookkeeping, and the bodhi PersistentSubagentInstance wiring (relayAgentSubagentConfig, lifetime: 'persistent_session') all look correct and match the PR body's description.

Two things before this can merge, though:

  1. Real CI failure, not a flake: eslint over first-party TS + JS fails with 1 error (the 249 no-explicit-any warnings are pre-existing noise, not the problem) — src/voice-agent.ts:33:36 'readdirSync' is defined but never used. This is a genuine leftover from removing the old tasks/-directory-reading code this PR replaces with voiceTaskRows(). Easy fix (drop the import), but it's a real failing required check right now, not something to wave through.

  2. Live evidence is explicitly marked Pending in the PR body ("to be added from the test Mac after a restart"). This is a delivery-loop change on the voice live path — per this repo's own standard (the same one Kewei/Rui have held #5285/#5300/#5278 to today), harness-green isn't a witness here; it needs the real post-restart round trip (a work task's result spoken through its call, a result whose call was cancelled, "how many tasks", cancel) before merge, not after.

#5309 is clean and I've approved it. Will re-review #5310 once the lint fix lands and the live evidence is posted.

liususan091219 and others added 3 commits October 9, 2026 22:19
…ore until the user has heard every outcome (#5290)"

This reverts commit e841a92. The relay agent is rebuilt as a bodhi
PersistentSubagentInstance in actor mode in the next PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r own results

The relay agent is rebuilt as the bodhi PersistentSubagentInstance behind the
work tool, in actor mode:
- work is a background tool (pending message: the old "working on it" text);
  RelayAgent.invoke submits the task through the tool's own submission and
  resolves with that task's core result, which bodhi hands to the model when
  it is idle (and holds through a meeting). A queue position or an offline
  core is said at submission.
- A result whose call is gone (cancelled, session closed) takes the ordinary
  delivery path; a voice task that cannot be delivered leaves one DM copy.
- Kept from #5290: the voice task table, planReconcile (an unheard result is
  handed over again at most twice; a task not picked up after 3 min is
  reported once), and the status answers (get_task_status, the cancel list,
  the count ahead) read from the table.
- Not kept: #5290's own result queue and pacing; bodhi's delivery does that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… agents through a lost upstream

0.4.5 (randombet/bodhi_realtime_agent#52) fixes the #48 review items the relay
agent depends on: under upstreamLossPolicy 'hold' a park or provider close is
not a session end in actor mode (no agent stop, one redialer), upstreamRecovery
requires the recovery primitives, and active silence hears recognized input.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@liususan091219
liususan091219 force-pushed the revert/5290-relay-agent branch from dc04722 to 56ca908 Compare October 10, 2026 05:19
@liususan091219
liususan091219 force-pushed the feat/relay-agent-subagent branch from 0393c02 to 62efa07 Compare October 10, 2026 05:19

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 62efa078 (base revert/5290-relay-agent = #5309). Code tree is identical to 0393c02a apart from main's #5266 test lists. Verdict: REQUEST_CHANGES.

Checks I ran:

  • tsc --noEmit clean.
  • npm run test:ts: 1630 tests, 0 failures.
  • relay, cancel, meeting and task-bridge suites: 60/60 pass.
  • Provenance of bodhi 0.4.5 (below).

bodhi 0.4.5 provenance: OK

  • The lockfile integrity (sha512-s7H3Jea…JrwVw==) equals the npm dist.integrity value.
  • npm gitHead 1cc152ab is tag v0.4.5, identical to bodhi main.
  • v0.4.4..v0.4.5 contains exactly #52 (8ff7b635, merged, approved by yixuan-ag2 and sonichi at 8ff7b635) plus #53. git diff 8ff7b635 v0.4.5 is only the package.json version bump, so the missing review on #53 does not matter.

Blockers

  1. CI red, real: eslint reports src/voice-agent.ts:33:36 'readdirSync' is defined but never used. Reproduced locally. tsc does not catch it.

  2. The design drops the result queue I asked to keep inside the instance. invoke holds until the result arrives (relay-agent.ts:151-169). Bodhi then publishes it through its background-completion publishSystemNotification ("Background task work completed… Result: …"). That call is void, and NotificationActor flushes one notification per turn with no pacing. Status of each #5290 guarantee:

    • Every task ends in an outcome the user heard: weakened. offerResult marks 'injected' the moment it hands the result to bodhi (relay-agent.ts:176). Nothing ever writes 'spoken' (grep: no writer). planReconcile treats injected as heard (relay-agent.ts:121), so a result bodhi did not voice is never replayed.
    • Reconcile replays at most twice: kept (relay-agent.ts:110,118-127, task-bridge.ts:599). It is blind to the case above.
    • The "not picked up after 3 min" notice: kept (relay-agent.ts:112,124).
    • One batch per turn, waiting for a pause, including after a reconnect: partly dropped. NotificationActor delivers immediately when the model is not speaking, with no user-speech gate. After a hold is released, drainNotificationsWhenIdle sends one notification at once. conversation-pacing.ts is gone.
    • Re-hand after the user cuts the answer off: dropped. An interrupted turn does not re-deliver, and the row already says injected.
    • Each item names its request: dropped. The notification carries only the framed result, no request text. This is exactly the three-PR attribution bug that #5290 fixed.
    • DM fallback after 60 s, single copy: dropped for work-call results. live-agent-runtime.ts:197 routes them to offerResult and skips deliverWithRetry's onExhausted DM path (:157-161). With the page closed, bodhi parks the upstream (parkUpstreamLost → hold), so the result waits in memory with no DM. A voice restart loses it while the row says injected. The single-copy guards for the non-work path are untested: with if (_hasLiveOfflineCopy(id)) continue removed (task-bridge.ts:604), or with the delivery === 'dm' guard removed (live-agent-runtime.ts:159), all 41 relay/cancel/meeting tests still pass.
    • Meeting hold with paced release (#5288): dropped. Bodhi's DictationController.flushPendingContentTurns drains everything on exit, and Sutando's own hold releases with for (… of heldForMeeting.splice(0)) (live-agent-runtime.ts:113). The #5288 test ("spoken in turn, not over each other") is removed and not replaced.
    • voiceTasksAhead, the cancel list and get_core_status answering from the table: kept (task-bridge.ts:565,629; inline-tools.ts:683,859; voice-agent.ts:490).

    Fix: keep #5290's queue inside RelayAgent. invoke returns a quick ack (no pendingMessage). Batch, wait for a pause, allow one batch per turn, and re-hand after a cut-off. Publish with tryPublishSystemNotification and requeue or DM when it returns false. Take turn-end and interruption signals from the session's event bus (turn.end / turn.interrupted, which #5290 already subscribed to). Then bring back the dropped tests: batching, re-hand after cut-off, spoken on turn end, per-item DM fallback, the #5288 paced burst, and the e2e three PRs → DM → replay.

  3. Cancel does not reach the instance. noteVoiceTaskCancelled deletes the task file, but the waiting invoke for a queued, cancelled task never settles: the core writes no result for that id, so the bodhi workflow and waiter stay open until the session closes. It needs a cancel(taskId) into the instance. Separately, bodhi's backgroundExecutor catch publishes "Background task work failed… aborted. Tell the user the exact error" on any abort, while the task keeps running. Please check whether that path is reachable.

Mutation checks (relay, cancel and meeting suites; the control run passes 41/41):

  • offerResult never matches: 3 failures. Caught.
  • MAX_REPLAYS changed from < to <=: 1 failure. Caught.
  • offerResult stops recording injected: 2 failures. Caught.
  • Offline-copy guard removed: 0 failures. Uncaught.
  • dm double-speak guard removed: 0 failures. Uncaught.

There is no one-batch-per-turn or re-hand code left to mutate.

Live evidence: none posted (the body says "Pending"). This is a live-path change (voice-agent.ts, task-bridge.ts, live-agent-runtime.ts). At the combined head, after a restart, I need:

  • three PRs asked back to back, each spoken and attributed;
  • a DM fallback followed by a replay after an outage;
  • a clean burst when a meeting ends;
  • cancel, both queued and already done;
  • the page closed for more than 60 s (park → redial) with the result still delivered.

Merge order: #5309 and this PR land together, after this PR is approvable (see my comment on #5309). license/cla passes. The eslint failure is the only real red check. Some jobs were still pending when I posted.

liususan091219 and others added 3 commits October 9, 2026 22:27
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n, DM when undeliverable, no pacing

The previous head marked a result heard the moment it was handed to bodhi and
had no fallback when the session could not take it (a result for a closed page
waited in memory, lost on restart). The relay agent now owns #5290's result
queue again, minus pacing:
- invoke returns the submission status at once (queued, the queue position, a
  duplicate, a rejection) as the tool result; no pendingMessage.
- RelayAgent.attachDelivery binds the queue to the session. Results are handed
  over through session.tryPublishSystemNotification; a refusal is retried, then
  the result goes to the DM, one copy per result.
- A result is recorded spoken only when the turn ends, handed over again once
  if the user cuts it off, names the request it answers, and is held through a
  meeting. Phone-call results go through the same queue.
- Not kept: conversation pacing and the 2 s gather window. bodhi delivers
  when the model is idle; the pacer delayed a result 49 s on room noise.
- Tests: the queue, refusal -> DM, the subagent, and the two single-copy
  guards (an offline copy still in results/, a result already sent to the DM),
  each failing when its guard is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eduped request is never left unheard

Live: three PR status requests; the core answered all three in the first
task's result and wrote [deduped: <first>] for the other two. The table did
not know that, so the first result was handed over naming only its own
request (the model spoke only that part) and the other two rows, holding a
skip marker, were owed nothing.

- A row records answeredBy when its result is [deduped: <task>] (local and
  relay-mode result paths; dedupTarget in skip_marker_ownership).
- A result is handed over naming every request it answers (its own and each
  unheard row answered by it) and records all of their deliveries together.
- Reconcile judges an answeredBy row by its answering task's result: if that
  was handed over without it (the dedup landed later), it is handed over
  again, named for this row's request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 6110487. Both prior blockers resolved:

  1. eslint is clean now (confirmed CI green, no errors).
  2. Live evidence is present and substantive — covers all three queue positions, the batch delivery naming multiple requests, and the cut-off/re-hand-over path. The PR body is also honest that the noise-barge-in reconnects are bodhi's client VAD treating room noise as speech, out of this PR's scope, rather than hiding it.

Traced the new answeredBy/dedup mechanism specifically, since the live evidence's own description of finding that bug ("an earlier run... only 5308 was spoken") is exactly the kind of thing worth verifying rather than taking on faith:

  • startResultWatcher's skip-marker branch extracts dedupTarget(result) and calls voiceTaskStore.setAnsweredBy(taskId, answeredIn) — the suppressed task records which task's result actually answers it.
  • answering(taskId) (in live-agent-runtime.ts) looks up voiceTaskStore.answeredBy(taskId) — every task whose answeredBy points at this one — filters to only the unheard ones, and feeds their original request text into requests plus their ids into alsoFor. frameBatch then tells the model to cover every named request, and record() marks all of them (not just the primary taskId) delivered once spoken.
  • This is symmetric and correct, and dedupTarget's own unit tests (including the D7-header-peeled and "no match" cases) are solid.

The simplified invoke() (submit via submitWorkTask, return status immediately — no longer awaiting the result inside the tool call) matches the live evidence's ~64ms call durations, and decoupling delivery into attachDelivery's queue (the same battle-tested createResultQueue/frameBatch pattern from #5290, minus pacing) is a clean design.

No blockers. Approved — ready to merge with #5309 per the stack.

liususan091219 and others added 2 commits October 9, 2026 23:17
…task table covers what the call cannot

Delivery goes back to the work call itself, now declared NON_BLOCKING:
invoke waits for its task's result and returns it as the function response,
so Gemini decides when to say it and which call it answers. The relay's own
result queue is removed.

The task table covers what a pending call cannot carry:
- a fresh provider connection (session.reconnectBoundary): responses to calls
  made on the old one would be fenced off, so those calls end quietly and
  their results go out as a system notice naming the request;
- a cancelled call or a restart: same notice path; one DM copy when the
  session cannot speak; held through a meeting;
- a result answering several tasks (answeredBy) ends their waiting calls and
  records them all; a late dedup is offered to the task's own waiting call
  by reconcile first.
A queue position or an offline core is said at submission.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e while the call waits for the result

Live at 5b046c3: with work declared NON_BLOCKING and no pending message the
model said nothing after calling it (an empty turn) until the user spoke
again; it waited for the function response, which comes only when the core
finishes. The pending message is back: bodhi answers the call at once with
"still in progress" and hands the result over as a system notification when
invoke returns, which a fresh provider connection does not drop, so the
reconnect-boundary detach is gone too. invoke still waits for its task's
result; the task table and reconcile cover a call that is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 2d6e921d (verified). Base is #5309's head 56ca9084. Delta from 62efa078 is five additive commits with no rebase: 9532664b c429d009 61104872 5b046c36 2d6e921d. Verdict: REQUEST_CHANGES. Blocker 1 is fixed and attribution is back. The other blockers come from one decision: a result counts as heard the moment it is handed to the work call.

What I ran at 2d6e921d

  • tsc --noEmit is clean.
  • eslint on the 9 touched files: 0 errors (15 pre-existing any warnings).
  • relay, e2e, meeting-hold, cancel and task-bridge suites: 202/202 pass.
  • CI runs 38074426009 and 38074425994 are both at 2d6e921d and green. license/cla passes.

Blockers from 62efa078

  1. eslint: Fixed. 9532664b drops readdirSync (voice-agent.ts:33). eslint is 0 errors locally and in CI.

  2. The relay's result queue: Partly fixed. c429d009 restored the queue, and 5b046c36 removed it again. Results now return through the waiting invoke (relay-agent.ts:192-229). bodhi publishes them as "Background task work completed… Result: …" (bodhi voice-session.ts:2262-2268, void, in memory). Status of each guarantee:

    • Per-item attribution: Fixed. frameResult puts "This answers the user's request: """ (or "N of the user's requests: …; cover each") ahead of each result (relay-agent.ts:154-163). answering() fills it from the table (live-agent-runtime.ts:105-113). With the request names removed, 5 tests fail (M1 below).
    • Re-hand after a cut-off: Not fixed. offerResult writes injected at hand-off (relay-agent.ts:219). NotificationActor does not re-deliver an interrupted turn: turn_complete after interrupted only skips the next flush (notification-actor.ts:145-157). Nothing re-hands the result.
    • One batch per turn / pause gating: Dropped, by design. When idle, handlePublish delivers each publish at once (notification-actor.ts, if (!this.audioReceived) this.deliver(n)). Three results landing together become three back-to-back synthetic user turns. Nothing gates on user speech.
    • 60 s DM fallback for work results: Not fixed. deliver() sends a result to offerResult first (live-agent-runtime.ts:180-187). The deliverWithRetry → onExhausted DM path (:136-178) runs only when no call is waiting. Example: page closed, call still waiting. The result is handed to bodhi and sits in its in-memory queue. There is no DM, and a voice restart loses it. The row says injected, so reconcile never replays it (relay-agent.ts:133). The fallback works only for results with no waiting call; the e2e test covers that case, not this one.
    • Paced post-meeting release (#5288): Partly. The hold works. Your 19:02 run shows a result held through a meeting and spoken after exit. Release is not paced: bodhi flushes on exit, and Sutando's own hold is for (… of heldForMeeting.splice(0)) deliverElsewhere(held) (live-agent-runtime.ts:131).
  3. spoken is never recorded: Not fixed. Nothing in src/ writes 'spoken'. The only writes are 'injected' (relay-agent.ts:219, live-agent-runtime.ts:140) and 'dm'. planReconcile treats injected as heard (relay-agent.ts:133), and so do cancel_task and the status answers (inline-tools.ts:716,825,861, voice-agent.ts:495). A result bodhi never voiced is never replayed. (c429d009 had "spoken only when the turn ends"; 5b046c36 removed it.)

  4. Cancel: Not fixed. noteVoiceTaskCancelled (task-bridge.ts:643-650) still does not touch the relay agent. The core answers the CANCEL_INSTRUCTION task, not the cancelled id, so the waiting invoke for a queued, cancelled task never settles. The waiter and the bodhi workflow leak until dispose. There is no cancel(taskId) and no test for it. Separately, the abort path is live. A transport.tool_call_cancelled for a work call becomes subagent.cancel_requested (bodhi tool-router-actor.ts:61-69). The relay then rejects with aborted (relay-agent.ts:203-208). Because work has a pendingMessage, bodhi's catch publishes "Background task "work" failed. Exact error details: aborted. Tell the user the exact error details first" (voice-session.ts:2280-2286) while the task keeps running. The user hears a false failure, and later the real result.

  5. Live evidence: Not on the PR. The body's evidence is at 61104872, and it describes the attachDelivery queue, which no longer exists at the head (git grep attachDelivery is empty). No comment carries the 18:15 run (three PRs) or the 18:59 run (meeting hold) at 2d6e921d. I have only a relayed summary of them, not the log. Please post both as a PR comment: head sha, restart line, and transcript lines. On the 18:18:40 run, where three results were "read out together": the mechanism above sends three turns back to back with no pacing. Nothing cut the answer only if the transcript shows each PR named once, with no Interrupted / notification.interrupted between 18:18:40 and the end of the answer. If one was cut, it is lost and still marked injected (see the re-hand bullet). I can't judge that from the summary.

Mutation checks (relay, e2e and meeting suites; the control run passes 41/41):

  • frameResult without request names: 5 failures. Caught.
  • The result watcher stops recording answeredBy: 1 failure (e2e "three requests… all three named"). Caught.
  • Reconcile ignores answeredBy (source = id): 2 failures. Caught.
  • offerResult does not end alsoFor waiters: relay-agent.test.ts hangs (killed after 559 s). It is caught, but as a hang, not as an assertion.

So the dedup / answeredBy path is properly pinned by unit and e2e tests, and I accept it without a live run.

What would make this approvable. Keep the work-call delivery and the pending message: the empty-turn finding at 17:59 is real. Add:

  • (a) record spoken only on turn.end for the turn that carried the result, and re-hand once on turn.interrupted;
  • (b) a waiting call whose session cannot speak (client gone) gets the same 60 s → one DM copy as deliverElsewhere, or reconcile treats an injected row older than N seconds with no spoken as owed;
  • (c) RelayAgent.cancel(taskId), called from noteVoiceTaskCancelled, which resolves the waiter with a cancelled status (not a rejection, so bodhi does not say "failed");
  • (d) a test for each, plus the live comment at the final head.

Not blocking: the PR body is stale. It describes the c429d009 queue design and the 61104872 evidence. Please rewrite it for the head.

Merge order is unchanged: #5309 and this PR land together, back to back. #5309 stays un-approved by me until this PR is approvable.

…ts result goes to the DM

- Cancel: the core never answers a cancelled task, so its waiting call hung
  until the session ended. noteVoiceTaskCancelled now tells the relay agent,
  which ends the call quietly.
- Abort: the relay rejected with "aborted", and because work has a pending
  message bodhi published "Background task failed… aborted. Tell the user"
  while the task kept running. The call now ends normally with "still
  running; the result comes separately", and the result goes the usual way.
- No client connected: the result watcher sends a voice task's result to the
  DM (it never offers it to the relay), so the waiting call also hung. It now
  ends that call and the calls of the tasks answered in the same result.

No call ends with a rejection: bodhi reads a rejection out as a failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@liususan091219

Copy link
Copy Markdown
Collaborator Author

Live evidence at 2d6e921 (test Mac)

Both runs were on the test Mac with this PR's 2d6e921 plus unrelated PRs merged on top for testing:

voice-agent.log is rewritten at restart, so the 18:15 lines below were copied from the log while that process ran.

Three requests back to back: each call answered at once, each result returned through its call

18:15:37.609 [VoiceSession] Background task started: work (toolCallId=call_53971, lifetime=persistent_session)
18:15:37.696 [TaskBridge] Task task-1791656137663: Check the status of PR 5308
18:15:41.656   [assistant] I'm still checking on that. I'll let you know as soon as I have an update.
18:15:47.479 [VoiceSession] Background task started: work (toolCallId=call_54091, lifetime=persistent_session)
18:15:47.533 [TaskBridge] Task task-1791656147528: Check the status of PR 5309
18:15:50.895   [assistant] Got it, right after the one I'm on.
18:15:56.934 [VoiceSession] Background task started: work (toolCallId=call_54187, lifetime=persistent_session)
18:15:57.008 [TaskBridge] Task task-1791656157002: Check the status of PR 5310
18:16:00.332   [assistant] Got it, 2 in line before this one.
18:18:40.268 [RelayAgent] task-1791656137663: result returned to its work call
18:18:40.273 [RelayAgent] task-1791656147528: result returned to its work call
18:18:40.276 [RelayAgent] task-1791656157002: result returned to its work call
18:18:50.897 [VoiceSession] Interrupted by user
18:18:50.897   [assistant] PR fifty-three oh eight is already merged, while fifty-three oh nine is ready to go but needs a second approval. As for PR fifty-three ten, it needs atte

The answer was cut at 18:18:50 by a barge-in (background noise taken as the user). The rows stay injected; re-handing a cut-off answer is out of this PR's scope (the voice layer's barge-in), as agreed with the PR owner.

A result held through a meeting, said after it

18:59:17.940 [TaskBridge] Task task-1791658757932: Send the meeting note to my direct message on Discord.
18:59:20.419   [assistant] Working on that for you right now.
19:00:15.690 [MeetingDictation] transcribing to …/notes/meeting-2026-10-10-1200.md
19:02:05.523 [MeetingDictation] back to agent mode (4 lines in …/notes/meeting-2026-10-10-1200.md)
19:02:12.021   [assistant] I've sent the meeting note to your Discord DM, and the notes from your last meeting have also been saved.

Before 2d6e921 (5b046c3: NON_BLOCKING, no pending message): silent after the call

17:59:25.297 [VoiceSession] Tool calls from LLM: [work] (1774ms after client audio VAD end)
17:59:25.319 [VoiceSession] Turn complete: turn_2
17:59:25.342 [Agent] onTurnCompleted: lastAssistant.length=0 ""
17:59:49.548 … text="Hi, Sandra, what's the status of PR5308?"   ← asked again; only now "Working on it"

Not live-verified

  • af3ebdf (cancel / abort / offline-DM call endings): unit + e2e tests only; a live run is pending.
  • answeredBy dedup: unit + e2e tests.

🤖 Generated with Claude Code

… whose reply contradicts the table

Live on the test Mac: the user cancelled a task still in the queue. cancel_task
deleted its file and also wrote a CANCEL_INSTRUCTION, then told the model to
wait for the core. The core found no file and replied "Nothing to cancel —
task was never queued"; that reply was read out as "the cancellation couldn't
be processed".

A queued task whose file is deleted never runs, so it is cancelled: the user is
told so at once and no instruction is written. If the core took the task in
that instant, its result already comes with CANCELLED_BUT_FINISHED_NOTE. The
cancel_requested state ("waiting for the core to confirm") is gone with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 19ecda86 (verified right before posting). The delta from my last review (2d6e921d) is af3ebdf1 and 19ecda86, with no rebase. Verdict: COMMENT. The three code blockers in scope are fixed and pinned by tests. What still stands between this PR and my approval is evidence and the PR body, not code. My earlier CHANGES_REQUESTED stays until the live run below is posted; after that I will approve this PR and #5309 together.

What I ran at 19ecda86 (in a throwaway clone)

  • tsc --noEmit is clean.
  • eslint on the 8 touched files: 0 errors (15 pre-existing warnings).
  • relay, e2e, meeting-input-hold, voice-task-cancel and task-bridge-* suites: 204/204 pass.
  • CI: 33/33 pass.

Blockers in scope

Status Evidence
a. Page closed, work call waiting → DM Fixed The result watcher's !clientConnected branch now forwards to the DM and calls _onVoiceTaskEnded(id,'delivered_separately') for the task and each answeredBy row (task-bridge.ts:1756-1758). voice-agent.ts:964 wires that to relayAgent.endCall. The result is never offerResult-ed into a session nobody hears. New e2e test: "no client connected: … waiting call ends". Mutation: dropping the two lines makes that test fail (8 s timeout).
b. Cancel reaches the relay Fixed noteVoiceTaskCancelled calls _onVoiceTaskEnded(taskId,'cancelled') first (task-bridge.ts:643). endCall resolves, not rejects. A queued voice task is now cancelled outright, with no CANCEL_INSTRUCTION whose reply could contradict the table. Mutations: removing that call fails "a cancelled task ends its waiting call quietly"; removing the setVoiceTaskEndedListener wiring in voice-agent fails the source pin.
c. Abort → no "failed: aborted" Fixed The abort listener now calls endCall(taskId,'continuing'), which resolves with a framed "still running; result comes separately" (relay-agent.ts:209-212). bodhi's catch, which publishes "Background task … failed", is no longer reached (bodhi dist/index.js:14150-14162). The late result then sees offerResult refuse it and takes deliverElsewhere. Mutation: restoring reject(new Error('aborted')) fails the abort test.
d. PR body + live evidence Body fixed; live evidence not at head The body now describes the subagent design and lists the call endings. The only live comment (#6102273904) is at 2d6e921d, run with #5319/#5324/main merged on top, and it says af3ebdf is "not live-verified".

Approval conditions (#5309 reverts #5290, so the out-of-scope items are regressions against main)

  1. Known limitations in the body: partly met. "Not in this PR, by the PR owner's decision" lists both items. Please title it Known limitations and say plainly that each one is a regression against main once #5309 lands.
  2. A follow-up issue for each: not met. I found none (searched issues since 10-09). Please file (i) re-hand of a cut-off result / record spoken at turn end, and (ii) pacing of results that land together / paced post-meeting release. Link both from the body.
  3. A live #5288 run at 19ecda86 or a later head: not met. Please post a comment with the head sha, the restart line, and transcript lines showing 2+ results held during a meeting, each spoken whole after exit, and no Interrupted by user / notification.interrupted while they are read. Note that the 18:18:50 run in the existing comment shows exactly the failure mode (an Interrupted by user mid-answer, with the rows left injected). Ideally the same run also shows one af3ebdf ending live (a cancel of a queued task, or page-closed → DM).

Nits (non-blocking)

  • The already-aborted early return (relay-agent.ts:206, if (signal?.aborted) return …continuing) has no test. Turning it back into throw leaves all 49 tests passing.
  • bodhi wraps every resolved value in "completed successfully. Result: … Please inform the user now." So delivered_separately ("Say nothing about it now") and cancelled ("Do not mention it again") reach the model next to an instruction to inform the user. For the page-closed case nobody hears it. For the cancel case, expect a possible second "it's cancelled". Worth watching in the live run.
  • Case (a) covers clientConnected === false. When the page is connected but the upstream is reconnecting, the result is still handed to the waiting call. I'm accepting that on the strength of bodhi 0.4.5 keeping agents through a lost upstream under hold, but I did not verify it live.

@liususan091219

Copy link
Copy Markdown
Collaborator Author

Live evidence at 19ecda8 (test Mac)

Build cc3bd285 = this PR's 19ecda8 + #5319 + #5324, restarted 21:33 UTC from a GUI terminal.

Cancel a queued task: decided by the task table, no instruction to the core, the call ends

Before 19ecda8 (af3ebdf), the cancel also wrote a CANCEL_INSTRUCTION; the core found no file and its reply was read out as a failure:

21:23:34.085 [Tool] cancel_task (inline)
14:23:34 [CancelTask] cancel-instruction written for task-1791667407235 → task-1791667414089.txt
21:23:34.090 [VoiceSession] Background task completed: work (toolCallId=call_174746, duration=6930ms)
21:24:34.379 [TaskBridge] Result task-1791667414089.txt: Nothing to cancel — task-1791667407235 was never queued (no file in tasks/, archive/, processed/ or …
21:24:40.111   [assistant] The cancellation request couldn't be processed because that task wasn't actively queued.

At 19ecda8:

21:35:41.937 [TaskBridge] Task task-1791668141929: Check the status of PR 5308
21:35:45.813   [assistant] That request is queued and will be handled shortly.
21:35:47.313 [Tool] cancel_task (inline)
14:35:47 [CancelTask] task-1791668141929 is queued
21:35:47.319 [VoiceSession] Background task completed: work (toolCallId=call_242381, duration=5468ms)
21:35:47.329   [user] cancel it
21:35:50.553   [assistant] I've submitted the cancellation request for you.
21:35:53.413   [assistant] That request has now been cancelled.

No cancel-instruction written, no core reply, tasks/ empty afterwards; the table row has cancelRequested: true. The second line is bodhi's "Background task completed… Please inform the user now" for the ended call; correct, only repeated.

Page closed while the call waits: result to the DM, the call ends

21:36:51.542 [TaskBridge] Task task-1791668211537: Check the status of PR 5309
21:36:54.440   [assistant] I'm working on that for you right now.
21:36:57.936 [VoiceSession] Client disconnected
21:38:35.922 [TaskBridge] Voice offline; forwarded task-1791668211537 result to the owner DM via proactive-result-task-1791668211537-1791668315.txt
21:38:35.923 [VoiceSession] Background task completed: work (toolCallId=call_243436, duration=104434ms)
2026-10-10T21:38:37Z discord_dm "PR 5309 — your revert of the relay …"

Not live-verified

  • An aborted call ending without a "failed" notice (af3ebdf): did not occur in this run; covered by tests/relay-agent.test.ts.

🤖 Generated with Claude Code

@liususan091219

liususan091219 commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Live #5288 run at 19ecda8 (test Mac)

Build cc3bd285 = this PR's 19ecda8 + #5319 + #5324, restarted again at 21:56 UTC (the cancel / page-closed run above was on the 21:33 restart of the same build). Two results land during a meeting, are held, and are said whole after the exit:

22:11:30.456 [TaskBridge] Task task-1791670290451: 查一下 PR 5140 的状态
22:11:32.565   [assistant] Working on it.
22:11:36.792 [TaskBridge] Task task-1791670296787: 查一下 PR 5167 的状态
22:11:40.127   [assistant] Got it, right after the one I'm on.
22:11:42.649 [Meeting] Mode switched to: meeting
22:11:49.435   [assistant] Meeting mode on. To bring me back, say "Sutando, come back". Until then I'll stay silent and take notes.
22:11:55.830 [TaskBridge] Result task-1791670290451.txt: PR 5140 已经合并了,10 月 8 日 18:43 UTC 进的 main。
22:11:55.832 [RelayAgent] task-1791670290451: result returned to its work call
22:12:39.851 [TaskBridge] Result task-1791670296787.txt: PR 5167 还开着,而且是被卡住的状态——GitHub 上显示 MERGEABLE 但 BLOCKED,审核结论是要求修改。
22:12:39.854 [RelayAgent] task-1791670296787: result returned to its work call
22:14:15.509 [MeetingDictation] exit phrase heard: "Sutando come back."
22:14:16.013 [MeetingDictation] back to agent mode (0 lines in …/notes/meeting-2026-10-10-1511.md)
22:14:24.062   [assistant] PR 5140 已经合并,但 PR 5167 目前有被卡住的状态,另外您的会议笔记已经保存完毕。

Nothing was said during the meeting; after the exit both results (and the notes-saved line) were said in one answer. No Interrupted by user from the exit to the end of the answer.

Abort (af3ebdf) was not reproduced live: by voice, talking on after a request makes the model wait and decide not to call work, and a paused request gets its pending reply before Gemini can cancel. It stays covered by tests/relay-agent.test.ts.

🤖 Generated with Claude Code

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 19ecda8. This replaces my CHANGES_REQUESTED at 2d6e921 and my COMMENT at this head.

Code: blockers a/b/c are fixed and pinned by tests that fail when each fix is reverted (details in #5310 (review)):

  • a work call waiting with no page connected ends when its result goes to the DM;
  • cancelling a queued task ends its call quietly, with no CANCEL_INSTRUCTION;
  • an aborted call ends without bodhi's "failed" notice.

Live, at this head (build cc3bd285 = 19ecda8 + #5319 + #5324):

  • #5310 (comment). Cancel of a queued task: no instruction written, call ended, tasks/ empty afterwards. Page closed mid-call: the result was forwarded to the DM after about 104 s and the call ended.
  • #5310 (comment). The #5288 meeting-exit repro, after a restart at 21:56. Two results were held during the meeting, with silence throughout. After the exit both were said in one answer, with no Interrupted by user.
  • Abort was not reproducible by voice (the reason is given in the comment). I'm accepting tests/relay-agent.test.ts for it, since it pins the change and only alters how a call settles.

Scope: the PR body now lists both regressions against main under "Known limitations", with follow-ups #5327 (a cut-off result is not said again) and #5328 (no pacing). These were left out by the PR owner's decision.

Checks: CI 33/33 green at 19ecda8. Mergeable.

Nits, not blocking:

  • After the meeting exit the model summarised both results instead of reading them out ("PR 5167 目前有被卡住的状态" drops "changes requested"). That is model wording, not delivery. Worth watching under #5328.
  • Cancelling is announced twice: once by the model, once from bodhi's "Please inform the user now".
  • The early return for an already-aborted call (relay-agent.ts:206) has no test.

#5309 must merge together with this PR. I'm approving it alongside.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@liususan091219
liususan091219 changed the base branch from revert/5290-relay-agent to main October 10, 2026 22:26
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants