Files
openclaw/src
Masato Hoshino fa9c3209d3 fix: queued voice replies are lost when the delivery queue retries them (#108502)
* fix(outbound): keep queued TTS/local media alive past its producer process

Outbound TTS synthesizes into a producer-owned temp and the raw path is
persisted into the durable delivery queue with no copy. The temp is removed
when its producer exits (and by a 5-minute timer well inside the queue's
~22-minute retry budget), so a deferred delivery replayed by a fresh recovery
process reads a path whose owner already deleted it: the send burns all five
retries on a missing file and the voice is never delivered.

Give the queue its own copy. Before a deliverable row is published, each
authorized local source is read through the same media access the live send
uses and copied into a process-generation-owned artifact root outside the
media TTL sweep; only the queue payload is rewritten, so the live send stays
copy-free on the original path. Custody is proven by owner identity rather
than by a marker file or lease table: artifacts are grouped under
<pid>-<processStartTime>-<nonce>, and reclaim touches a generation only when
its owner is provably gone. A live owner, an unverifiable owner, or a
recycled PID whose start time no longer matches are all handled without
deleting media that a pending row still needs. Wall-clock age is never an
ownership signal.

Ordering is durable-state-first: rows commit before artifacts are unlinked,
so a crash between the two leaves an orphan for reclaim rather than a
delivery that lost its media. Sensitive media reaches neither the spool nor
the row, so required sends fail closed and best-effort sends go live-only
instead of persisting a row that cannot replay.

No schema change, no public SDK surface, no lease/owner table.

* fix(outbound): resolve queue media owner identity on every platform

Generation ownership read process start time through the Linux/Darwin lock
reader only, so on Windows every generation stayed unverifiable and its
artifacts were never reclaimed. gateway-lock already paired that reader with
the Windows CIM/WMIC creation-time reader behind its own private helper, so
the pairing was duplicated policy waiting to drift.

Move the pair behind one owner-identity helper and route both callers through
it. No new process inspection: Windows still resolves through
readWindowsProcessStartTimeSync, Linux/Darwin still through
getFileLockProcessStartTime, and neither platform's behavior changes.

Also cover the production crash boundary end to end: a real child process
stages media, commits the row, and is killed before dispatch; a fresh process
then reclaims the dead generation while retaining the artifact its pending row
still needs, reads the same bytes after the producer's source is deleted, and
drops the artifact once the row is acked.

* fix(outbound): close reclaim race and partial-stage leak in queue media

Two defects found by external review of the previous commits.

Reclaim built its retain set before proving generation ownership, so a
short-lived producer (`openclaw message send`) that staged, committed its row,
and exited while a sweep was running would have its artifact judged against a
snapshot taken before that commit: the sweep saw a dead owner, missed the new
reference, and unlinked media a pending row still needed. The retain set is now
read after each death proof. That is sufficient rather than merely narrower: a
process only ever stages into its own generation, so once an owner is proven
dead no further references to it can appear, and everything it committed before
exiting is already durable.

Staging also leaked on partial failure. When a later source in an entry failed,
earlier copies were already published, no row would ever reference them, and
reclaim deliberately never touches a live owner's generation, so they
accumulated for the process lifetime. Failed staging now releases what it
published. Per-payload media staging is sequential for the same reason: a
concurrent copy could land after the cleanup ran and orphan itself anyway,
and one source at a time also bounds peak memory.

* fix(outbound): resolve queue media access exactly as the live send does

Queue staging read media through params.mediaAccess directly, while the live
send resolves an agent-scoped capability first. The two gates therefore
disagreed: agent-generated media under the agent workspace is reachable only
through the agent-scoped roots, so staging rejected sources the send itself
would have delivered. A required send failed outright, and a best-effort send
silently lost its write-ahead row for exactly the media class this change is
meant to protect.

Resolve the capability once and share it, so staging can neither reject media
the send would deliver nor read more than the send may. The live send now
routes through the same helper rather than keeping a second copy of the
argument list.

The spool test previously fabricated an explicit localRoots and placeholder
bytes, which hid the gap; it now uses the production shape (no explicit media
access, source in the OpenClaw temp root that TTS writes to) and real
buffer-verifiable audio, because host-local sends are content-verified.

* test(outbound): cover agent-workspace media that only the scoped capability reads

The existing staging test used a source in the OpenClaw temp root, which both
the raw and the agent-scoped capability permit, so it could not tell the two
apart and would have passed against the defect it was meant to guard.

Add a case whose source lives in an agent workspace at
<state>/workspace-<agentId>. The unscoped default roots reject that path by
construction, so a durable row exists only when staging resolves the same
agent-scoped capability the live send resolves. Reverting staging to the raw
media access, or bypassing the shared resolver, fails this test.

* test(outbound): pin agent-workspace staging to the agent-scoped capability

The regression case admitted its source two ways that had nothing to do with
agent scoping: the state dir sat inside the OpenClaw temp root, which is itself
a default media root, and parent-root expansion grants any declared source
directory. Either would keep the test green after the agent identity was
dropped from the resolver.

Move the state dir outside the temp root and set tools.fs.workspaceOnly, which
suppresses source-derived root expansion. The agent workspace is then reachable
only through the agent-scoped capability, so removing the agent identity, the
shared resolver, or reverting to the raw media access each fail the test.

* test(gateway): pin queue media retention across retry backoff

* fix(outbound): retain queued media for retries

Co-authored-by: Masato Hoshino <g515hoshino@gmail.com>

* fix(outbound): retain queued media for retries

Co-authored-by: Masato Hoshino <g515hoshino@gmail.com>

* fix(outbound): retain queued media for retries

Co-authored-by: Masato Hoshino <g515hoshino@gmail.com>

* docs(changelog): avoid queued TTS merge conflict

Co-authored-by: Masato Hoshino <g515hoshino@gmail.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
2026-07-16 01:43:53 -07:00
..