fix(server): schedule live voice recovery from session changes and deadlines (#17576)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Voice calls retain durable session state and provider identities.
> - The chat maintenance timer scans live voice sessions even when no
call exists.
> - Session writes, saved expiry and active-call inspection deadlines
identify real work.
> - This pull request moves live-session recovery to the shared
coordinator.
> - Idle session recovery becomes quiet without losing exact-call
cleanup or current authorization checks.

## Linked Issues or Issue Description

Refs #17572. This PR is stacked on receiver discovery. It owns only live
voice session recovery. Reply delivery, inbound admission cleanup,
inbound history retention and call reports remain separate migration
slices.

**What happened?**
The one-second chat maintenance lane reads voice settings and scans live
sessions even when an instance has no calls or only terminal history.

**What did you expect?**
Reconstruct live calls on startup. Wake on committed session or
configuration changes. Inspect active calls and retry cleanup at actual
deadlines. Stop querying when no live obligation remains.

**Steps to reproduce**
Start an instance with no live voice sessions. Let startup settle. The
old maintenance method continues to read settings and session inventory
every chat maintenance pass.

## What Changed

- Add a voice-owned bounded deadline reader and scoped worker on the
existing coordinator. Retain concurrent writes across pages and reset
partial scans on restart. No tables or migrations are added.
- Separate generic connection health observations from configuration
changes. Existing receiver and command consumers retain their
notifications. Voice eligibility and provider operations remain in the
voice owner.
- Signal existing session transactions and configuration/feature
changes. Keep provider calls outside database transactions. Standalone
session writes use one small transaction helper.
- Preserve saved expiry, the two-minute unknown-creation grace,
active-call authorization and missed-hangup inspection, and
exact-provider cleanup. Recovery never creates a provider call.
- Retry transient authority lookup failures instead of treating them as
confirmed revocation.
- Fence expiration against a provider identity bound after discovery. A
late known call remains eligible for cleanup.
- Preserve work through rollback, drain, restart, concurrent refresh,
backoff and shutdown. Page inventory and rotate bounded batches across
companies.
- Leave the four other voice maintenance duties in the periodic lane.
- Document producer coverage and the supported writer contract in
`doc/idle-voice-session-recovery.md` and the dated audit.

## Verification

Published head: `f0b2e3cc22a18e7d08b10d4fc02aae4a19a52f30`. Receiver
#17572 has landed in master at
`dce004259394486f6361d0c0dd5a33a5272acc0a`. This normal merge reconciles
squash ancestry; the complete 22-file voice recovery patch is
byte-for-byte identical to the reviewed and tested `ad79c7e805` patch,
while all unrelated master changes are preserved. The PR now targets
master. Fresh exact-head CI and Greptile are running; interrupted
Ponytail turns are not completed verdicts under the maintainer’s
clarified merge policy, and an actual failing verdict still blocks.

- **210 distinct targeted cases pass on the current repair**: 19
complete recovery lifecycle cases and 191 complete voice-store,
coordinator, batching, startup and hostname cases. Production server
typecheck and build pass. Later edits record evidence only.
- CI `40633667141` on prior head `1b6323c164` failed the feature-change
assertion with `34050 < 25000`; its verification aggregate also failed.
The test now disables automatically advancing fake time, drives 20
controlled scheduler turns and requires all 205 calls to close before an
unrelated inspection deadline can mask a lost wake. Removing generation
replay fails with missing closures. The repair does not benchmark
database/host speed or raise the elapsed-time bound.
- A real committed exact-call regression reproduces wildcard-backoff
starvation. The worker keeps the broad retry but processes independent
exact hints and cached due calls. Each check reloads current state and
authority. Failed exact scopes remain fenced. Removing wildcard
isolation fails the intended assertion; a successful broad retry then
leaves the worker SQL-quiet. Full-row and lean queries now share the
state/scope/cursor predicate.
- Earlier acceptance on `712f3862b5` passed 725 distinct targeted cases.
Its measurements remain useful history: five real metadata writes and
one health check over 205 future calls selected 1,230 rows before the
inventory repair and zero afterward, without changing the saved
deadline. Those older test counts are not added to the current 210-case
count.
- Both external voice fixtures self-migrate; the current full
voice-store run uses a separate initially empty database. Earlier
fixture/method/observer failures, source mutations and partial edit
attempts remain in the [current
audit](https://github.com/paperclipai/paperclip/blob/ad79c7e805367fc7e2df3ea53c942ca6c7d27365/doc/plans/2026-10-25-voice-maintenance-sleep-audit.md#wildcard-failure-and-deterministic-generation-acceptance-october-28).
Source is restored after guarded mutations.
- Older CI `40632057899` failed because its trusted policy script was
absent from the checkout, while build separately received a runner
shutdown signal. E2E shards succeeded but their aggregate failed on
policy. The current checkout includes the landed policy tooling and its
checker/tests pass locally. The shutdown cause is unproven; neither old
failed run is counted as passing. Earlier full-local attempts stopped on
embedded PostgreSQL setup failures and did not pass.
- Fresh exact-head Greptile, security and all CI must pass before merge.
A completed failing Ponytail verdict blocks; an interrupted turn without
a verdict does not. No full current local repository matrix, live
provider acceptance, multi-writer support, Cloud wake, host suspension
or savings is claimed.

## Risks

- Process-local hints cover the existing active writer and shared
clients. Independent SQL writers or another replica need explicit wake
integration.
- Membership, task binding, phone preferences and other authority inputs
remain checked on the existing 30-second active-call inspection deadline
and on use. This is not an instant cleanup guarantee for every authority
mutation.
- Unknown creation retains its existing maximum-duration grace. Cleanup
failures retain retries. A provider request is never replayed merely
because its creation response is unknown.
- Genuine configuration changes retain bounded reconstruction. Metadata
writes can probe parent eligibility, while health-only observations
leave voice recovery quiet. Paging state is process-local and
reconstructs from durable rows after restart.
- Active calls, the other voice maintenance owners and connector
transports remain real obligations. This does not expand Cloud alarm
coverage, relax sleep eligibility or prove deployed suspension.
- The receiver and Discord parents have landed. Current-master
compatibility and exact-head CI and Greptile remain merge gates.

## Model Used

OpenAI GPT-6 via Codex, with repository inspection, reasoning, code
execution and test tools. The runtime does not expose a more specific
deployment identifier or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket identifier
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <[email protected]>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-28 07:38:48 -05:00
1 parent dce0042593
commit e1ba77f067
22 files changed
+1403 -64

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
# Voice session recovery without idle discovery
The live-session worker replaces the session-discovery portion of the recurring
voice maintenance call. It uses the existing delivery coordinator, transaction
signals and shared scheduler. It adds no tables or migrations. Reply delivery,
inbound admission cleanup, inbound history retention and call reports are separate
obligations; their existing maintenance remains until their own migrations land.
## Work and deadlines
Existing `chat_voice_sessions` rows are authoritative. Startup reconstructs live
sessions in UUID pages of 100, joined to their same-company endpoint and connection.
Each background refresh reads at most one inventory page. An unfinished pass has
a one-second continuation; it does not create a permanent empty scan. Only timing
and eligibility fields are selected for discovery. The explicit foreground
reconciliation method keeps its complete inventory behavior.
Terminal history does not create a live-session recovery obligation. With no live
sessions, the worker also avoids reading experimental settings after reconstruction.
| State | Next obligation |
| --- | --- |
| No provider identity yet | Existing `expiresAt` plus two minutes of teardown grace; no provider creation replay |
| Known provider, ending or invalidated configuration | Immediate cleanup; a recorded cleanup failure waits 30 seconds |
| Connecting or active call | Earlier of session expiry and `updatedAt + 30 seconds` for current authorization and missed-hangup inspection |
| Other live state with provider identity | Saved session expiry |
| Ended, failed or expired | No live-session work; reports have a separate owner |
Provider maximum duration remains the final bound for uncertain creation. A
late provider response can bind identity while expiry is being considered. The
expiration write requires that provider identity is still absent, so a newly
known call retains exact-call cleanup rather than being marked terminal prematurely.
Each due call is reloaded before execution. The voice store checks current
company, endpoint, credentials, caller, task, guest sandbox and binding authority.
A 403/404 means authority no longer permits the call. A transient lookup error
retains a retry; it does not authorize ending an otherwise valid call. The existing
voice service owns provider inspection and termination, including single-flight
termination and ambiguous-response confirmation. Recovery never creates a call.
## Producers and transaction ownership
| Owner | Changes that wake live-session recovery |
| --- | --- |
| `voice-session-store.ts` | Reservation, guest promotion, connecting-to-active transition and reply cursor writes that change the inspection timestamp |
| `voice-session-service.ts` | Creation claim, binding, known/unknown creation failure, user end, cleanup outcome and foreground inspection |
| `voice-inbound-service.ts` | Guest session creation, guest denial and signed terminal lifecycle callbacks |
| `voice-reply-push.ts` | Confirmed remote end and unknown delivery outcome that changes session state/timestamp |
| Chat configuration helper | Endpoint configuration, pause, reconnect, generation/credential changes, connection hints and company removal |
| Generic tool/access services | Voice connection API mutation, removal and credential revocation during access removal |
| Instance settings | Explicit chat-connector feature enable/disable |
Existing transactions register the awaited signal before the session/configuration
write. Standalone session updates use the small `withVoiceSessionChange` helper.
Provider requests remain outside database transactions. Nested writes retain outer
commit ownership, rollback does not dispatch work, and uncertain commit settlement
stays with the shared database protocol.
Membership changes, task reassignment, phone preferences and other authorization
inputs that do not mutate the session or connection remain checked on the existing
30-second active-call inspection deadline, as well as on use. This slice does not
promise instant cleanup for every authority mutation or add cross-cutting voice
hooks throughout those owners. Session activity that changes `updatedAt` retains
the existing inspection-time semantics. The session expiry is always an upper
bound for authorized active work.
The generic chat connection hint does not always carry a transport. It can cause
a bounded empty voice lookup after a chat configuration write. It does not create
idle polling or a recurring deadline. The shared helper contains only routing;
voice eligibility, authority and provider policy stay with the voice owner.
## Bounded configuration refreshes
The voice owner remembers the endpoint/connection fields that affect its existing
deadline policy: endpoint status, runtime generation, connection enabled state,
connection identity, and the chat feature flag. A broad configuration hint first
reads a bounded page of these parent records. If those facts are unchanged, the
cached call deadlines stay in place and no call inventory is read. Metadata-only
writes do not move business deadlines. An unknown or changed parent requires
bounded reconstruction; a real disablement still settles every affected call.
Only the voice owner interprets these facts. The generic health producer now
sends a health-observation notification through the existing chat routing owner.
It preserves receiver and command signals without generating a voice-configuration
wake. An observation changes neither voice eligibility nor the saved call deadline.
Supported session producers continue to signal their exact session ID.
The worker processes at most 25 dirty scopes and 25 provider checks per pass.
Scope pagination continues without restarting on every newer hint. Failures retain
their backoff and block stale entries in the failed scope. Successful pages can
make progress while the remaining inventory is discovered. The existing
coordinator can use the actual error deadline, including an already-due healthy
backlog, rather than postponing it to an unrelated fallback retry.
## Failure and lifecycle behavior
Refreshes coalesce by scope. Paged reconstruction keeps narrower producer hints,
including a newly committed session behind the UUID cursor. A scope generation
retains configuration changes that arrive between pages. Seen IDs protect
concurrent refresh results; absent cached entries are removed only when the
scope's inventory is complete. A new worker resets partial inventory state before
reconstructing its saved obligations. Changes arriving during refresh remain dirty. Failed refreshes block use of
stale cached entries in that scope. Per-scope and per-call failure backoff starts
at 30 seconds and caps at five minutes. New hints do not erase failure backoff.
Batches rotate across companies, process at most 25 calls, and continue an already
due backlog after one second. A failed provider does not stall independent calls.
Drain and standby retain dirty work without passive SQL. Resume processes that
work, and restart reconstructs deadlines from the rows. Shutdown joins admitted
provider work and removes the coordinator's subscriptions and timers. Retained
terminal history becomes quiet after any already committed hints settle.
Process-local signals support the existing single active database writer and its
shared clients. Independent SQL writers or another replica need an explicit wake
contract. This change neither expands Cloud alarm coverage nor relaxes sleep
eligibility. Active calls and their real deadlines remain obligations; local worker
quiet is not proof of host suspension or fleet savings.
## Verification
The voice integration suite exercises actual service producers, guest intake,
signed callbacks, current authority, unknown creation and adjacent reply/report
behavior. The recovery-owner suite uses real PostgreSQL and controlled provider
operations for outer commit/rollback, drain, restart, scope failure, mutation during
refresh, paging, company fairness, shutdown and the late-provider-binding race.
The latter race failed before the write-time guard and passes afterward.
The dated audit records exact commands, results, initial fixture failures and
remaining acceptance work: [voice migration audit](plans/2026-10-25-voice-maintenance-sleep-audit.md).
## Landed-foundation acceptance, October 27
The voice repair now integrates receiver parent `6e88ca0201`, including Discord
`547fdf2139` and master `f8563d2507`. Exact-patch comparison preserves the repaired
22-file scope and master's shared task-drain and native-checkpoint queue names.
No database file is part of this slice.
All 725 distinct targeted cases pass again: 112 complete voice/shared/startup
cases, 97 complete voice-store cases, 469 complete tool-access cases, and 47
complete health/receiver cases. Server production typecheck and build pass.
Voice worker/store and health/receiver fixtures use separate initially empty
self-migrating databases. Tool access uses a separate migrated clone. One initial
tool-access invocation supplied the generic fixture variable instead of the
suite-specific variable: 23 cases passed and 446 skipped. It was not a complete
suite result or an assertion failure. The corrected invocation executes and
passes all 469. Tests are not counted twice. Expected controlled failures and
listener warnings remain in logs. Only documentation changes follow these checks.
Fresh current-head reviews, CI and parent landing remain required. No full local
matrix, provider acceptance, external wake or deployed sleep result is claimed.
## Wildcard failure and deterministic generation acceptance, October 28
Current-head CI `40633667141` fails server shard 1 at the feature-change test's
34,050 ms result against its 25,000 ms bound; verification fails as an aggregate.
This is an actual required assertion failure, distinct from the older interrupted
build and missing policy script. The old test mixed automatically advancing fake
time with real database latency. It now disables automatic clock advancement,
drives 20 explicit scheduler turns, waits for each pass without advancing the
clock, and requires every invalidated call to close before the unrelated periodic
authority deadline. Removing generation replay still fails with missing closures.
The test no longer benchmarks host/database speed or raises its business bound.
A real committed-session regression also reproduces wildcard starvation: while
one broad reconstruction backs off, a newly ending exact call is not closed.
The worker now retains the broad retry but considers ready exact hints and cached
due calls independently. Each due call still reloads state and authorization in
its owning check before effects. Failed exact scopes retain their own fence;
the existing stale-cache failure regression still passes. Removing wildcard
isolation fails the new exact-call assertion. Broad reconstruction becomes quiet
after its successful retry, with no continuing SQL afterward.
The full-row execution lookup and lean inventory lookup now share the live-state,
scope and cursor predicate. Their projections remain intentionally different.
An initial edit script rejected the header cursor name before applying the timing
and predicate changes; the intermediate 19-case result covers only the earlier
worker repair. It is not the final repair evidence. The corrected source passes
all 19 lifecycle cases and 191 complete voice-store/shared/startup cases: 210
distinct targeted cases. Production server typecheck and build pass. Both guarded
regressions fail at their intended effect assertion and restore source afterward.
The fresh voice-store database self-migrates. Existing controlled failure logs
and signal-listener warnings remain recorded. The older 725-case acceptance
belongs to its stated revision and is not added to this count. Fresh current-head
reviews and all CI remain required before merging after receiver discovery.
@@ -1504,6 +1504,15 @@ The older #17490 E2E cancellation failure remains unresolved. The entire origina
inventory stays in scope, with voice the next separate reason after receivers.
Receiver discovery is published and attached as [#17572](https://github.com/paperclipai/paperclip/pull/17572)
at 643f4766227e15006b684d2cc7bea7ddd5cc2147, based on #17557. Its live CI run is
40173962833; security and reviews are pending. All 218 selected local cases,
workspace types and build pass. The next audit splits voice into five separate
reasons: live-session recovery, reply push, inbound admission expiry/provider
cleanup, inbound history retention, and call reports. See
[the voice owner audit](2026-10-25-voice-maintenance-sleep-audit.md).
Branch codex/sleep-21-voice-session-recovery starts with the first reason; the
other four remain explicit required slices. No voice migration is implemented yet.
## Receiver review repair
The real company-removal producer regression reproduced two inventory reads for
@@ -1519,6 +1528,15 @@ full-refresh failure/backoff case. Server typecheck and build pass. Prior broad
consumer/workspace verification remains recorded above; these 45 overlap that
coverage and are not an additional unique total. Publication requires fresh
remote CI and review. The full migration inventory remains outstanding.
Voice recovery checkpoint: receiver repair `1604a9fcba` is published to #17572
and incorporated into the unpublished voice child. The complete 93-case voice
suite, eight new recovery lifecycle tests, and 16 startup wiring cases pass.
See the voice audit for precise coverage and remaining work. Receiver CI
40175242445 is live with no failures at inspection; no final acceptance or merge
is claimed. All remaining reasons, older #17490 failure, and final master
reconciliation stay in scope.
## Acceptance follow-up after the plugin parent merged (October 27)
Eighteen preceding sleep PRs are merged through plugin scheduling #17518 at
@@ -1545,6 +1563,19 @@ for exact evidence and the older CI runner-loss failure. Fresh CI/reviews and
parent landing are still required. The original remaining-duty inventory is unchanged.
## Voice inventory amplification acceptance, October 27
The voice recovery slice keeps the original single-duty scope and adds the
normalized health-observation notification contract. Five real metadata writes
and one health check over 205 future calls previously selected 1,230 call rows;
they now select zero and preserve the saved deadline. Startup and changed-parent
reconstruction use bounded pages and retain concurrent wakes.
The updated [voice audit](2026-10-25-voice-maintenance-sleep-audit.md#voice-session-inventory-amplification-repair-october-27)
records all 725 distinct targeted passing cases, typecheck, build, failed attempts
and guarded regressions. This is worker acceptance, not deployed sleep proof.
Fresh exact-head review and CI remain required before the voice PR can merge.
## Discord registration landing compatibility, October 27
Company deletion has landed. The Discord command slice retains only its original
@@ -1559,3 +1590,31 @@ Receiver discovery keeps its 27-file scope on Discord parent `547fdf2139` and
preserves current master queue declarations. All 131 targeted cases, server
typecheck and build pass. See the [receiver audit](2026-10-25-chat-receiver-discovery-sleep-audit.md#landed-foundation-integration-acceptance-october-27).
Fresh exact-head review, CI and parent landing remain required.
## Voice integration with landed foundations, October 27
The bounded voice repair now includes receiver `6e88ca0201` and retains its
22-file scope. All 725 targeted cases, production typecheck and server build pass
again. The [voice audit](2026-10-25-voice-maintenance-sleep-audit.md#landed-foundation-acceptance-october-27)
records exact scope, database setup, the initial partial tool-access run and
remaining remote gates.
## Voice trusted-policy checkout repair, October 27
Receiver `ab39569250` supplies the landed policy checker now required by trusted
CI. Voice server/package code and tests remain identical to the 725-case tested
head. The policy checker and its Node tests pass. The [voice audit](2026-10-25-voice-maintenance-sleep-audit.md#trusted-policy-checkout-compatibility-october-27)
keeps the four old failed gates, including the independent runner shutdown,
separate from current local evidence. Fresh review and CI remain required.
## Voice review follow-up, October 28
Voice recovery isolates a failed broad inventory retry from ready exact hints
and cached due calls, while retaining on-use authority checks and exact-scope
fences. The generation test uses controlled scheduler turns instead of elapsed
SQL time. All 210 current targeted cases, production typecheck and server build
pass; guarded mutations catch both regressions. The [voice audit](2026-10-25-voice-maintenance-sleep-audit.md#wildcard-failure-and-deterministic-generation-acceptance-october-28)
keeps the actual CI assertion failure and earlier failed attempts separate.
@@ -0,0 +1,34 @@
# Voice recovery drain repair
Owner: slice 21, PR #17576. Reviewed baseline: 1bf70185d2.
Greptile finding 4370544173 is valid. The worker retained dirty scopes while
paused, and reported those in-memory hints as pending work. After the producer
committed, the coordinator therefore retained its intent activity hold. No SQL
was required to reproduce the blocked drain; the earlier quiet assertion checked
SQL only and missed the nonzero idle counter.
The worker now reports pending dirty scopes only while dispatch is permitted.
It still retains every hint and next deadline. The coordinator independently
checks databaseWorkPending, so an open/uncertain transaction retains its activity
hold until transaction reconciliation establishes settlement. Paused timers then
use the existing passive-maintenance path without changing the idle generation.
Resume processes the retained first session without another producer signal.
No provider behavior or external sleep eligibility rule changes.
The strengthened real-PostgreSQL test proves: an open write during drain retains
an activity hold; commit releases it; a further minute causes neither SQL nor
an idle-snapshot change; resume processes the one retained session exactly once.
- /tmp/sleep21-drain-before.log: original worker fails, active is 1 instead of 0.
- /tmp/sleep21-drain-tests.log: all 11 voice recovery lifecycle tests pass.
- /tmp/sleep21-drain-typecheck.log: production server typecheck passes.
- Dedicated migrated database: paperclip_sleep21_drain_20261025_test.
Propagate this predicate into slice 22's extracted scoped-deadline worker and
all descendants, then rerun its existing consumers. The earlier tool-health,
Discord command, and receiver workers have the same predicate pattern; audit
and repair those at their own parent PRs as a separate propagation pass. Do not
report all stack drain behavior fixed from the voice test alone. The company
inbound cleanup/deletion policy, older E2E failure, current chat milestone audit,
and full original inventory remain outstanding.
@@ -0,0 +1,297 @@
# Voice maintenance sleep migration audit
Baseline: receiver parent #17572, head 643f4766227e15006b684d2cc7bea7ddd5cc2147.
Branch: codex/sleep-21-voice-session-recovery.
Status: implementation and local integration/lifecycle verification in progress; unpublished.
## Separate the reasons before changing the timer
The app invokes `voice.reconcile()` from the remaining one-second chat lane.
The public service is created through `chat-channels/speko/runtime.ts`; the
implementation is `voice/voice-session-service.ts`. Its reconcile method hides
five distinct obligations. Each needs its own PR, preserving the others until
its producer and deadline contracts are implemented.
| Slice | Existing behavior | Proposed owner and trigger |
| --- | --- | --- |
| Live session recovery (this branch) | Reads experimental settings, scans live sessions for expiry, ending, disabled endpoints/connections, generation mismatch and 30-second active-state checks | Voice session owner; committed lifecycle/authorization changes plus real expiry, cleanup and active-call inspection deadlines |
| Reply push | `pushVoiceReplies` scans for provider delivery work | Existing reply/session records, committed source changes, pending provider delivery and claim/retry deadlines |
| Inbound admission expiry and provider cleanup | Scans guest/approval/denied/expired calls past expiresAt, inspects/ends exact provider sessions | Inbound owner; admission/lifecycle/decision changes, saved expiry and unresolved end retries |
| Inbound history retention | Deletes denied/expired/ended rows without sessionId or intakeIssueId once both expiresAt and updatedAt are seven days old | Retention owner; deadline max(expiresAt, updatedAt) + seven days, mutation invalidation and startup reconstruction |
| Call reports | Fetches reports for terminal sessions with provider IDs within 24 hours of endedAt; follows nextCheckAt backoff | Report owner; terminal/lifecycle/report events and saved report recheck deadlines, preserving late recording/transcript behavior |
Removing one invocation of the outer reconcile method would drop several owners.
First expose the live-session operation independently, register it with the
existing coordinator, and leave the other four calls in their existing lane.
The outer wrapper can disappear only after the remaining owners migrate.
## Live-session source of truth and invariants
Existing chat_voice_sessions, chat_endpoints, tool_connections, access/bindings,
and instance settings define eligibility and current authority. No new table is
needed. Current live states are reserved, creating, creation_unknown, connecting,
active and ending. Terminal states are ended, failed and expired.
The current pass uses 25 oldest updatedAt rows. A session qualifies when expired,
ending, disabled, generation-mismatched, or when a connecting/active session has
not been inspected for 30 seconds. Active-session checks validate current task,
caller and guest authority, then inspect the exact saved providerSessionId to
recover a hangup without a final browser callback. A live call is concrete work;
its bounded provider-state inspection is not empty-instance discovery.
Unknown creation remains reserved until expiresAt plus two minutes of teardown
grace. The provider maximum duration is the final bound. Never replay creation
because its response was lost. Ending retains exact provider cleanup debt until
termination is confirmed. An error records cleanup_pending and updatedAt.
Preserve these business boundaries and distinguish a database error from a real
revocation before changing recovery behavior.
An inactive instance with no live sessions must stop this owner's settings and
session queries after reconstruction. Retained terminal sessions alone must not
create a live-session deadline. Report work for those rows belongs to its own
slice and stays active under its existing contract for now.
## Required producer audit
- voice-session-store.reserve, including inbound and guest binding paths;
- service start claim, successful provider binding, unknown creation, failed
creation, concurrent end, and failure to persist provider identity;
- closeProvider and user end; inspect and tool callbacks that change session state;
- inbound lifecycle, guest intake, approval/denial and end paths that write sessions;
- signed lifecycle callbacks and report callbacks (separate saved report debt);
- endpoint/connection pause, removal, generation or credential changes;
- feature disable, membership/access revocation, task reassignment, binding and
environment changes used by authorizeSession;
- restart/resume and the supported single active writer contract.
Use existing transaction ownership. Add intent before the write; transaction
outcome uncertainty remains the shared database layer's responsibility. Prefer
session/endpoint/company scopes rather than refreshing all sessions per mutation.
Authorization stays with the voice store; provider requests stay with the Speko
provider. Shared scheduling consumes normalized work identities and deadlines.
## Required proof
Reproduce recurring SQL in no-session and retained-terminal-history states before
removal. Test first real session creation after quiet; nested transaction commit
and rollback; uncertain creation without duplicate provider calls; expiry/grace;
ending cleanup failure and retry; active hangup recovery; revoked membership,
changed task binding and feature disable; guest isolation; multiple-company
fairness and backlog; failed scoped reads; mutation during sweep; drain/resume;
shutdown joining admitted provider work. Keep all four adjacent obligations and
their existing tests running. Inspect `voice-session-store.integration.test.ts`
and current producer fixtures before inventing new test contracts.
The receiver parent has 218 selected passing tests, workspace typecheck/build,
and is published/attached. CI run 40173962833 is live on its exact head; Greptile,
security and Paperclip review are pending. This is not stack completion or merge
permission. The full original ledger, #17490 failure and final master
reconciliation remain required.
## Initial implementation checkpoint
Live-session recovery now has a voice-owned paged deadline reader and a scoped
coordinator worker. The periodic wrapper retains reply, inbound, and report
maintenance. Session writes signal their existing transactions; standalone service
updates use one small transaction helper. Configuration, feature-disable and
voice tool/access removal paths signal relevant scopes. Active sessions retain
30-second inspection, unknown creation retains the saved expiry plus two-minute
grace, and no call-creation action exists in recovery. Authority lookup failures
retry instead of treating an unavailable database as confirmed revocation.
Four initial actual-service cases pass: first start after quiet and retirement
on missed hangup, membership revocation, and feature disable; unknown creation
expires at its saved grace deadline without another provider call. Server types
pass after fixing the database projection of the internal runtime generation.
The startup wiring regression failed before removal. This is a checkpoint only:
full producer, lifecycle, race, backlog, and consumer evidence is still required.
Receiver #17572 has Greptile 5/5 and no failed checks, but Paperclip Review 4/5
requests suppressing rediscovery after discovery-initiated stops. Its optional
full-refresh coalescing observation will also be addressed in the owning parent
before this branch is published. Preserve all unfinished voice changes in this
commit while repairing that parent.
## Expanded lifecycle checkpoint
Receiver review repairs are published in #17572 at `1604a9fcba` and merged into
this child. The receiver CI run 40175242445 remains live, with no failing checks
at inspection. The voice worker now also coalesces pending scopes under a full
refresh and retains its backoff when narrower signals arrive.
The complete existing voice session integration suite passes all 93 tests on a
fresh disposable PostgreSQL database. This includes the four initial real-service
producer cases and the adjacent reply, inbound, callback and report consumers.
Eight new recovery-owner integration tests pass: configured terminal history,
outer rollback/commit, first write during drain, transient authority failure
without ending a valid call, restart with the same saved deadline, mutation during
full refresh, a 105-session paged backlog with one failed provider, and shutdown
joining admitted inspection. Their provider operations are controlled; real
service authorization/producer coverage remains in the 93-case suite.
The 16 startup wiring tests also pass. Initial new-suite failures were fixture
errors (missing channel purpose) and a silence assertion starting before the
committed cleanup_pending refresh had settled. Both were corrected; the final
retry check proves no SQL during the actual backoff. No production failure was
concealed or treated as a successful run.
Outstanding before publication: complete producer/authorization-change audit,
current-state races, cross-company fairness and failed-scope coverage, affected
shared producer consumers, full workspace verification and remote gates. The
other four voice duties and every remaining original stack item remain required.
## Producer and race audit completion
See [the live-session contract](../idle-voice-session-recovery.md) for the complete
producer mapping, existing transaction ownership and retained active authorization
inspection contract. All production session writers are covered. Phone preferences,
membership and task authority remain checked on the existing active-call deadline
and on use; no new recurring empty inventory scan replaces their absence of signals.
A late provider binding between grace-period discovery and expiration was reproduced:
the previous update marked a connecting call expired despite its new provider ID.
The expiration update now requires provider identity to remain absent. The regression
proves the known call stays live for exact-provider cleanup. All 11 recovery lifecycle
cases pass, including company fairness and failed-scope fencing.
Expanded real-service coverage passes for endpoint pause, generic voice connection
disable, task reassignment and signed guest lifecycle settlement. Shared consumer
verification passes 469 tool-access, 18 access-removal, and 207 settings/provider
cases. Workspace typechecking passes. A fresh final voice/receiver/startup selection
and the full local test command are running; build/publication/remote gates remain.
No full local matrix result is claimed from targeted tests. Initial fixture errors
and the reproduced provider-binding race remain recorded above and in test logs.
## Voice session inventory amplification repair, October 27
The voice branch normally integrates receiver parent #17572 at `841d451164`
through `f8d7dd2d14`. The initial single-duty scope is 21 files, with no database
changes. The health-observation routing repair adds one related production path,
`tool-access/catalog/health.ts`, because an observation must not claim that voice
configuration changed. The existing routing helper owns receiver and command
fanout; the voice service owns eligibility and deadline comparison.
A real-API regression with 205 future live calls reproduced 1,230 selected call
rows across five metadata updates and one health observation. The row count
measures executed database selects, rather than timer calls or helper invocations.
After repair these operations read zero call rows, preserve the exact saved next
deadline, and issue no voice wake for the health-only operation. A separate real
SDK health case confirms receiver and command topics still settle after commit.
The background path now uses one inventory page of at most 100 lean call headers
per refresh. Parent state is probed separately, in bounded pages, and unchanged
state keeps existing deadlines. Unknown or genuinely changed parent state has a
bounded reconstruction. Foreground reconciliation keeps its complete scan API.
The worker retains a generation and seen IDs across pages, processes at most 25
scopes per pass, rechecks call state before provider effects, and preserves failed
scope/call backoff. It resets partial state when the same service starts a new
worker. No per-worker table, migration, extra scheduler, or route recovery wrapper
is introduced.
Real database regressions cover all 205 deadlines after paging and partial-scan
restart, a new session behind the cursor, a feature change between pages, and
connection disablement settling all 205 calls. Guarded production mutations
reproduce the page-size violation, lost global generation (cleanup delayed past
its prompt-recovery assertion), and health-only wake. Each original file is
restored after the mutation. These checks supplement the existing outer-commit,
rollback, drain, current-authority, late-provider-binding and shutdown cases.
Both newly supported external fixture paths skipped migrations. Empty-database
runs failed with missing instance_settings before the worker or provider action.
The worker and complete voice-store fixtures now apply pending migrations before
creating the client and use the shared PostgreSQL bootstrap budget. The initial
amplification fixture also omitted its required Speko provider config, then used
the wrong public health method name; those failures preceded the actual 1,230-row
baseline. Initial quiet checks counted the observer's previous calls; measurement
is now stopped before an independent quiet-window spy is created. The quiet
assertion itself is unchanged. The first typecheck exposed timing-only rows being
used for full provider operations; the timing-header type stays separate from full
rows at that operation boundary. All failures remain recorded as such.
Current targeted acceptance passes 725 distinct cases: 18 voice lifecycle, 94
shared coordinator/batching/startup/hostname, 97 complete voice-store, 469
tool-access, and 47 health/receiver cases. Production typecheck and server build
pass. The 18 lifecycle cases replace the earlier 16-case result; these counts do
not add repeated runs. Full suites ran unless a narrower selection is explicitly
named above. No full local repository matrix, actual host suspension, Cloud wake,
multi-writer support, or savings is inferred from this worker migration. The original remaining-duty inventory stays
in scope. Current exact-head reviews, security, CI, and parent landing are still
required before merging #17576.
## Landed-foundation acceptance, October 27
The voice repair now integrates receiver parent `6e88ca0201`, including Discord
`547fdf2139` and master `f8563d2507`. Exact-patch comparison preserves the repaired
22-file scope and master's shared task-drain and native-checkpoint queue names.
No database file is part of this slice.
All 725 distinct targeted cases pass again: 112 complete voice/shared/startup
cases, 97 complete voice-store cases, 469 complete tool-access cases, and 47
complete health/receiver cases. Server production typecheck and build pass.
Voice worker/store and health/receiver fixtures use separate initially empty
self-migrating databases. Tool access uses a separate migrated clone. One initial
tool-access invocation supplied the generic fixture variable instead of the
suite-specific variable: 23 cases passed and 446 skipped. It was not a complete
suite result or an assertion failure. The corrected invocation executes and
passes all 469. Tests are not counted twice. Expected controlled failures and
listener warnings remain in logs. Only documentation changes follow these checks.
Fresh current-head reviews, CI and parent landing remain required. No full local
matrix, provider acceptance, external wake or deployed sleep result is claimed.
## Trusted-policy checkout compatibility, October 27
CI run `40632057899` on `712f3862b5` failed policy because the trusted workflow
had acquired `scripts/check-query-error-rendering.mjs` from newer master, while
the checked-out PR head lacked that script. The build job separately received
a runner shutdown signal during runtime bundling, then reported cancellation.
The underlying shutdown cause is not established. The E2E aggregate reports
`E2E_SHARDS_RESULT=success` but fails on `POLICY_RESULT=failure`; verification
also fails on policy and build. Those four failed gates are not passing results.
A normal merge of receiver `ab39569250` brings the landed UI and policy tooling
into the checkout. Every server/package production and test file remains
byte-identical to the 725-case tested `712f3862b5` source. The policy checker now
executes successfully, and its complete Node test file passes locally. No sleep
implementation, test assertion or workflow gate is weakened. Fresh CI must prove
the full build and aggregate gates; the interrupted build is not relabeled as a
pass. Fresh Ponytail and Greptile acceptance still precede any merge.
## Wildcard failure and deterministic generation acceptance, October 28
Current-head CI `40633667141` fails server shard 1 at the feature-change test's
34,050 ms result against its 25,000 ms bound; verification fails as an aggregate.
This is an actual required assertion failure, distinct from the older interrupted
build and missing policy script. The old test mixed automatically advancing fake
time with real database latency. It now disables automatic clock advancement,
drives 20 explicit scheduler turns, waits for each pass without advancing the
clock, and requires every invalidated call to close before the unrelated periodic
authority deadline. Removing generation replay still fails with missing closures.
The test no longer benchmarks host/database speed or raises its business bound.
A real committed-session regression also reproduces wildcard starvation: while
one broad reconstruction backs off, a newly ending exact call is not closed.
The worker now retains the broad retry but considers ready exact hints and cached
due calls independently. Each due call still reloads state and authorization in
its owning check before effects. Failed exact scopes retain their own fence;
the existing stale-cache failure regression still passes. Removing wildcard
isolation fails the new exact-call assertion. Broad reconstruction becomes quiet
after its successful retry, with no continuing SQL afterward.
The full-row execution lookup and lean inventory lookup now share the live-state,
scope and cursor predicate. Their projections remain intentionally different.
An initial edit script rejected the header cursor name before applying the timing
and predicate changes; the intermediate 19-case result covers only the earlier
worker repair. It is not the final repair evidence. The corrected source passes
all 19 lifecycle cases and 191 complete voice-store/shared/startup cases: 210
distinct targeted cases. Production server typecheck and build pass. Both guarded
regressions fail at their intended effect assertion and restore source afterward.
The fresh voice-store database self-migrates. Existing controlled failure logs
and signal-listener warnings remain recorded. The older 725-case acceptance
belongs to its stated revision and is not added to this count. Fresh current-head
reviews and all CI remain required before merging after receiver discovery.
@@ -78,10 +78,17 @@ describe("createChatReconciliationCoordinator", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
const maintenance = source.slice(source.indexOf("const chatReconciliation ="), source.indexOf("const unsubscribeChatPublicationSignals"));
expect(maintenance).not.toContain("chatChannels.reconcileProviderRuntimes()");
expect(maintenance).toContain("chatChannels.voice.reconcile()");
expect(maintenance).toContain("chatChannels.voice.reconcileMaintenance()");
expect(source).toContain("chatChannels.registerReceiverWork(deliveryWork,");
});
it("separates live voice recovery from the remaining periodic maintenance", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
expect(source).toContain("chatChannels.voice.registerRecoveryWork(deliveryWork,");
expect(source).toContain("chatChannels.voice.reconcileMaintenance()");
expect(source).not.toContain("chatChannels.voice.reconcile()");
});
it("wires GitHub recovery into its independent lane", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
expect(source).toContain(
@@ -0,0 +1,356 @@
import { HttpError } from "../errors.js";
import { toolAccessService } from "../services/tool-access.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, agents, chatConversations, chatEndpoints, chatVoiceSessions, companies, createDb, issues, subscribeDatabaseWork, toolApplications, toolConnections } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { voiceSessionRecovery } from "../services/voice/voice-session-recovery.js";
import { registerVoiceRecoveryWork } from "../services/voice/voice-recovery-work.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { notifyVoiceRecovery, withVoiceSessionChange } from "../services/voice/voice-recovery-notifications.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_VOICE_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("voice session recovery lifecycle", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>, companyId: string, endpointId: string, agentId: string, issueId: string, conversationId: string;
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let enabled = true;
const errors: unknown[] = [];
const fixtureCompanies: string[] = [];
const authorize = vi.fn(async (_session: typeof chatVoiceSessions.$inferSelect) => {}), inspect = vi.fn(async (_session: typeof chatVoiceSessions.$inferSelect): Promise<{ endedAt?: unknown }> => ({ endedAt: true }));
const close = vi.fn(async (session: typeof chatVoiceSessions.$inferSelect) => {
await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date(), updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id)));
});
beforeAll(async () => {
if (!external) temporary = await startEmbeddedPostgresTestDatabase("voice-recovery-");
if (external) await applyPendingMigrations(external);
db = createDb(external ?? temporary!.connectionString, { idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
async function fixture() {
const companyId = randomUUID(), endpointId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), conversationId = randomUUID();
fixtureCompanies.push(companyId);
const applicationId = randomUUID(), connectionId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Voice recovery", issuePrefix: `V${companyId.slice(0, 7)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Voice", adapterType: "paperclip_runner" });
await db.insert(issues).values({ id: issueId, companyId, title: "Voice", assigneeAgentId: agentId, status: "in_progress" });
await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Voice", type: "chat" });
await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Voice", uid: connectionId, transport: "voice", connectionPurpose: "channel", config: { provider: "speko" }, enabled: true, status: "active" });
await db.insert(chatEndpoints).values({ id: endpointId, companyId, connectionId, assignedAgentId: agentId, publicId: endpointId, provider: "speko", status: "active", setup: { step: "complete", runtimeGeneration: 1 } });
await db.insert(chatConversations).values({ id: conversationId, companyId, endpointId, issueId, externalConversationId: conversationId, externalThreadId: conversationId, externalLabel: "Voice", isDirectMessage: true });
return { companyId, endpointId, assignedAgentId: agentId, issueId, conversationId };
}
beforeEach(async () => {
enabled = true; errors.length = 0; authorize.mockClear(); inspect.mockReset().mockResolvedValue({ endedAt: true }); close.mockClear();
({ companyId, endpointId, assignedAgentId: agentId, issueId, conversationId } = await fixture());
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
});
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined; vi.useRealTimers(); vi.restoreAllMocks();
for (const companyId of fixtureCompanies.splice(0)) {
await db.delete(chatVoiceSessions).where(eq(chatVoiceSessions.companyId, companyId));
await db.delete(chatConversations).where(eq(chatConversations.companyId, companyId));
await db.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId));
await db.delete(issues).where(eq(issues.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
}
expect(errors).toEqual([]);
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
async function session(patch: Partial<typeof chatVoiceSessions.$inferInsert> = {}, writer = db) {
const id = patch.id ?? randomUUID();
return withVoiceSessionChange(writer, id, tx => tx.insert(chatVoiceSessions).values({ id, companyId, endpointId, conversationId, issueId, assignedAgentId: agentId,
callerId: randomUUID(), callerAuthority: "member", mode: "browser", state: "active", generation: 1, credentialFingerprint: "test", providerSessionId: randomUUID(),
toolTokenHash: "a".repeat(64), idempotencyKey: id, requestFingerprint: id, createdAt: new Date(Date.now() - 60_000), updatedAt: new Date(Date.now() - 60_000), expiresAt: new Date(Date.now() + 600_000), ...patch }).returning()).then(rows => rows[0]);
}
async function start(service = voiceSessionRecovery(db, { authorize, inspect, close })) {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => enabled, canReconcile: () => true, onError: error => errors.push(error) });
await registerVoiceRecoveryWork(coordinator, service, () => enabled).ready;
return service;
}
async function settled() {
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); expect(coordinator!.nextWakeAt()).toBeNull(); }, { timeout: 5000 });
}
async function quiet(ms = 120_000) {
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
try { await vi.advanceTimersByTimeAsync(ms); expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); }
finally { reads.mockRestore(); writes.mockRestore(); }
}
async function futureInventory(count: number) {
const row = await session({ updatedAt: new Date(), expiresAt: new Date(Date.now() + 3_600_000) });
await db.insert(chatVoiceSessions).values(Array.from({ length: count - 1 }, () => {
const id = randomUUID();
return { ...row, id, callerId: randomUUID(), providerSessionId: id, idempotencyKey: id, requestFingerprint: id };
}));
return row.updatedAt.getTime() + 30_000;
}
function voiceReads() {
const counts: number[] = [], select = db.select.bind(db);
const measurement = vi.spyOn(db, "select").mockImplementation((...args) => {
const selection = select(...args), from = selection.from.bind(selection);
vi.spyOn(selection, "from").mockImplementation(table => {
const query = from(table);
if (table === chatVoiceSessions) {
const execute = query.execute.bind(query);
vi.spyOn(query, "execute").mockImplementation(async () => {
const rows = await execute(); counts.push(rows.length); return rows;
});
}
return query;
});
return selection;
});
return { counts, stop: () => measurement.mockRestore() };
}
async function futureSettled(at: number) {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
expect(idleWorkSnapshot().active).toBe(0);
expect(coordinator!.nextWakeAt()).toBe(at);
}, { timeout: 5000 });
}
it("bounds startup pages and retains every saved deadline", async () => {
const due = await futureInventory(205), reads = voiceReads(), counts = reads.counts;
await start();
expect(counts).toHaveLength(1); expect(counts[0]).toBe(100);
await futureSettled(due);
expect(counts.reduce((sum, count) => sum + count, 0)).toBe(205);
expect(Math.max(...counts)).toBe(100);
await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
expect(new Set(inspect.mock.calls.map(([row]) => row.id)).size).toBe(205);
reads.stop(); await quiet();
});
it("reconstructs every page when the same service restarts after a partial scan", async () => {
const due = await futureInventory(205), service = voiceSessionRecovery(db, { authorize, inspect, close });
await start(service); expect(inspect).not.toHaveBeenCalled(); await coordinator!.stop();
await start(service); await futureSettled(due);
await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
expect(new Set(inspect.mock.calls.map(([row]) => row.id)).size).toBe(205);
await quiet();
});
it("retains a new session behind the cursor during paged reconstruction", async () => {
const due = await futureInventory(205), service = voiceSessionRecovery(db, { authorize, inspect, close });
const refresh = service.refresh; let inserted = false, newcomer = "";
service.refresh = async (...args) => {
const result = await refresh(...args);
if (!args[0] && !result.complete && !inserted) {
inserted = true; newcomer = (await session({ id: "00000000-0000-4000-8000-000000000001" })).id;
}
return result;
};
await start(service); await futureSettled(due);
expect(inspect.mock.calls.map(([row]) => row.id)).toEqual([newcomer]);
expect((await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, newcomer)))[0].state).toBe("ended");
await quiet(5000);
});
it("reapplies a feature change that arrives between inventory pages", async () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: false });
await futureInventory(205);
const settings = instanceSettingsService(db), service = voiceSessionRecovery(db, { authorize, inspect, close });
const refresh = service.refresh; let changed = false;
service.refresh = async (...args) => {
const result = await refresh(...args);
if (!args[0] && !result.complete && !changed) {
changed = true; await settings.updateExperimental({ enableChatConnectors: false });
}
return result;
};
try {
await start(service);
for (let pass = 0; pass < 20; pass++) {
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(coordinator!.nextWakeAt() !== null || idleWorkSnapshot().active === 0).toBe(true), { interval: 0 });
}
expect(new Set(close.mock.calls.map(([row]) => row.id)).size).toBe(205);
await settled();
await quiet();
} finally {
await coordinator?.stop(); coordinator = undefined;
await settings.updateExperimental({ enableChatConnectors: true });
}
});
it("pages actual connection disablement and settles every cached live call", async () => {
const due = await futureInventory(205); await start(); await futureSettled(due);
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
const reads = voiceReads();
authorize.mockRejectedValue(new HttpError(403, "Connection disabled"));
try {
await toolAccessService(db).updateConnection(endpoint.connectionId, { enabled: false });
await settled();
expect(new Set(close.mock.calls.map(([row]) => row.id)).size).toBe(205);
expect(inspect).not.toHaveBeenCalled(); expect(Math.max(...reads.counts)).toBe(100);
reads.stop(); await quiet();
} finally { authorize.mockResolvedValue(undefined); reads.stop(); }
});
it("does not reread live calls after repeated real connection metadata and health writes", async () => {
const due = await futureInventory(205);
await start(); await futureSettled(due);
const reads = voiceReads(), counts = reads.counts;
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
const tools = toolAccessService(db);
for (let index = 0; index < 5; index++) {
await tools.updateConnection(endpoint.connectionId, { name: `Metadata ${index}` });
await futureSettled(due);
}
let observations = 0;
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.voiceSessionRecovery, event => { if (event === "settled") observations++; });
try {
await expect(tools.checkHealth(endpoint.connectionId)).rejects.toMatchObject({ status: 422 });
await futureSettled(due);
expect(counts.reduce((sum, count) => sum + count, 0)).toBe(0);
expect(observations).toBe(0);
expect(inspect).not.toHaveBeenCalled(); reads.stop(); await quiet(5000);
} finally { unsubscribe(); }
});
it("preserves receiver and command wakes for SDK health observations", async () => {
const id = randomUUID();
await db.insert(toolConnections).values({ id, companyId, applicationId: (await db.select().from(toolApplications).where(eq(toolApplications.companyId, companyId)))[0].id,
name: "SDK observation", uid: id, transport: "chat_sdk", connectionPurpose: "channel", config: { provider: "discord" }, enabled: true, status: "active" });
const seen = new Set<string>();
const topics = [DELIVERY_QUEUES.chatReceivers, DELIVERY_QUEUES.chatCommandRegistration, DELIVERY_QUEUES.voiceSessionRecovery];
const stops = topics.map(topic => subscribeDatabaseWork(db, topic, event => { if (event === "settled") seen.add(topic); }));
try {
await expect(toolAccessService(db).checkHealth(id)).rejects.toMatchObject({ status: 422 });
expect(seen).toEqual(new Set([DELIVERY_QUEUES.chatReceivers, DELIVERY_QUEUES.chatCommandRegistration]));
} finally { for (const stop of stops) stop(); }
});
it("leaves configured terminal history quiet", async () => {
await session({ state: "ended", endedAt: new Date() }); await start(); await settled(); await quiet(); expect(inspect).not.toHaveBeenCalled();
});
it("waits for outer commit and ignores a rolled-back nested producer", async () => {
await start(); await settled();
await expect(db.transaction(async tx => { await session({}, tx as unknown as typeof db); await vi.advanceTimersByTimeAsync(60_000); expect(inspect).not.toHaveBeenCalled(); throw new Error("rollback"); })).rejects.toThrow("rollback");
await settled(); await quiet();
await db.transaction(async tx => { await session({}, tx as unknown as typeof db); await vi.advanceTimersByTimeAsync(60_000); expect(inspect).not.toHaveBeenCalled(); });
await settled(); expect(inspect).toHaveBeenCalledOnce(); await quiet();
});
it("preserves the first session during drain without passive database reads", async () => {
await start(); await settled(); enabled = false;
await db.transaction(async tx => {
await session({}, tx as unknown as typeof db);
await vi.advanceTimersByTimeAsync(60_000);
expect(idleWorkSnapshot().active).toBeGreaterThan(0);
expect(inspect).not.toHaveBeenCalled();
});
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); });
const drained = idleWorkSnapshot();
await quiet(60_000); expect(idleWorkSnapshot()).toEqual(drained); expect(inspect).not.toHaveBeenCalled();
enabled = true; await settled(); expect(inspect).toHaveBeenCalledOnce(); await quiet();
});
it("retries unavailable authority without ending a valid provider call", async () => {
const row = await session(); authorize.mockRejectedValueOnce(new Error("authority database unavailable"));
await start(); expect(errors).toHaveLength(1); expect(close).not.toHaveBeenCalled(); expect(inspect).not.toHaveBeenCalled();
// Persisting cleanup_pending emits a committed hint; let that refresh settle first.
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); });
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now() + 20_000);
await quiet(10_000); await settled();
expect(inspect).toHaveBeenCalledOnce(); expect(close).not.toHaveBeenCalled();
expect((await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, row.id)))[0].state).toBe("ended");
errors.length = 0; await quiet();
});
it("reconstructs a saved session deadline after restart", async () => {
const due = Date.now() + 60_000;
await session({ updatedAt: new Date(due - 30_000) }); await start();
expect(coordinator!.nextWakeAt()).toBe(due); await coordinator!.stop();
await start(); expect(coordinator!.nextWakeAt()).toBe(due); await quiet(10_000);
await vi.advanceTimersByTimeAsync(due - Date.now()); await settled(); expect(inspect).toHaveBeenCalledOnce(); await quiet();
});
it("preserves a session committed during a full refresh", async () => {
const service = voiceSessionRecovery(db, { authorize, inspect, close }), original = service.refresh;
await start(service); await settled(); let inserted = false;
service.refresh = async (...args) => { const rows = await original(...args); if (!inserted) { inserted = true; await session(); } return rows; };
await db.transaction(tx => notifyVoiceRecovery(tx)); await settled(); expect(inspect).toHaveBeenCalledOnce(); await quiet();
});
it("pages saved sessions and drains healthy work while one provider fails", async () => {
const failed = await session();
for (let index = 0; index < 104; index++) await session();
let broken = true;
inspect.mockImplementation(async row => {
if (row.id === failed.id && broken) throw new Error("one provider unavailable");
return { endedAt: true };
});
await start();
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
const rows = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.companyId, companyId));
expect(rows.filter(row => row.state === "ended")).toHaveLength(104);
}, { timeout: 5000 });
expect(inspect.mock.calls.filter(([row]) => row.id === failed.id)).toHaveLength(1);
expect(errors).toHaveLength(1);
broken = false; await settled(); errors.length = 0; await quiet();
});
it("includes another company in the first batch despite a large older backlog", async () => {
for (let index = 0; index < 30; index++) await session({ updatedAt: new Date(Date.now() - 120_000) });
const other = await fixture();
const row = await session(other);
await start();
expect(inspect.mock.calls.slice(0, 25).some(([candidate]) => candidate.id === row.id)).toBe(true);
await settled(); expect(inspect).toHaveBeenCalledTimes(31); await quiet();
});
it("keeps exact hints and cached due calls moving while wildcard reconstruction backs off", async () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: false });
const cached = await session({ updatedAt: new Date(Date.now() - 20_000) });
const dueAt = cached.updatedAt.getTime() + 30_000;
const service = await start(), refresh = service.refresh;
service.refresh = async (scope, signal) => {
if (!scope) throw new Error("wildcard unavailable");
return refresh(scope, signal);
};
await db.transaction(tx => notifyVoiceRecovery(tx));
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(errors).toHaveLength(1), { interval: 0 });
const exact = await session({ state: "ending" });
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(close).toHaveBeenCalledWith(expect.objectContaining({ id: exact.id })), { interval: 0 });
await vi.advanceTimersByTimeAsync(Math.max(0, dueAt - Date.now()));
await vi.waitFor(() => expect(inspect).toHaveBeenCalledWith(expect.objectContaining({ id: cached.id })), { interval: 0 });
expect(errors).toHaveLength(1);
service.refresh = refresh; errors.length = 0;
await vi.advanceTimersByTimeAsync(Math.max(0, coordinator!.nextWakeAt()! - Date.now()));
await settled(); await quiet();
});
it("does not inspect stale cached work until a failed scope refresh succeeds", async () => {
const row = await session({ updatedAt: new Date(Date.now() - 20_000) });
const service = voiceSessionRecovery(db, { authorize, inspect, close }), original = service.refresh;
await start(service); service.refresh = async () => { throw new Error("scope unavailable"); };
await db.transaction(tx => notifyVoiceRecovery(tx, { kind: "session", id: row.id }));
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(errors).toHaveLength(1); });
await quiet(15_000); expect(inspect).not.toHaveBeenCalled();
service.refresh = original; await settled(); expect(inspect).toHaveBeenCalledOnce(); errors.length = 0; await quiet();
});
it("does not expire a provider identity bound after the grace-period read", async () => {
const row = await session({ state: "creation_unknown", providerSessionId: null,
createdAt: new Date(Date.now() - 240_000), expiresAt: new Date(Date.now() - 180_000) });
const service = voiceSessionRecovery(db, { authorize, inspect, close });
const providerSessionId = randomUUID(), transact = db.transaction.bind(db);
const transaction = vi.spyOn(db, "transaction").mockImplementationOnce(async (callback, options) => {
await transact(async tx => {
await notifyVoiceRecovery(tx, { kind: "session", id: row.id });
await tx.update(chatVoiceSessions).set({ providerSessionId, state: "connecting", updatedAt: new Date() }).where(eq(chatVoiceSessions.id, row.id));
});
return transact(callback, options);
});
try { await service.check(row.id); } finally { transaction.mockRestore(); }
const [bound] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, row.id));
expect(bound).toMatchObject({ state: "connecting", providerSessionId });
await service.check(row.id);
expect(close).toHaveBeenCalledWith(expect.objectContaining({ id: row.id, providerSessionId }));
expect((await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, row.id)))[0].state).toBe("ended");
});
it("joins admitted provider inspection at shutdown", async () => {
await start(); await settled();
let finish!: () => void; const gate = new Promise<void>(resolve => { finish = resolve; });
inspect.mockImplementationOnce(async () => { await gate; return { endedAt: true }; });
await session(); await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(inspect).toHaveBeenCalledOnce(); });
let stopped = false; const stopping = coordinator!.stop().then(() => { stopped = true; });
await vi.advanceTimersByTimeAsync(5000); expect(stopped).toBe(false);
finish(); await stopping; expect(stopped).toBe(true);
});
});
@@ -1,3 +1,7 @@
import { toolAccessService } from "../services/tool-access.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import express from "express";
import request from "supertest";
import { errorHandler } from "../middleware/error-handler.js";
@@ -11,7 +15,7 @@ import { VOICE_RESULT_NOTIFICATION } from "@paperclipai/shared";
import { createHash, createHmac, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { chatActions, instanceUserRoles, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
import { applyPendingMigrations, chatActions, instanceUserRoles, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
import { configureSpekoSessionTools } from "../services/voice/speko-tool-setup.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { chatChannelService } from "../services/chat-channels.js";
@@ -28,15 +32,17 @@ import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js";
import { createToolGatewayService } from "../services/tool-gateway.js";
import { initializeRunIdentity } from "../services/run-identity.js";
import { syncSpekoVoiceTools, spekoToolsForSession, executeSpekoVoiceTool } from "../services/voice/speko-agent-tools.js";
const support = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_VOICE_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("durable Speko voice sessions", () => {
let db: ReturnType<typeof createDb>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-speko-store-");
db = createDb(database.connectionString);
if (!external) database = await startEmbeddedPostgresTestDatabase("paperclip-speko-store-");
if (external) await applyPendingMigrations(external);
db = createDb(external ?? database!.connectionString, { idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await database?.cleanup(); });
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
async function fixture() {
const companyId = randomUUID(), endpointId = randomUUID(), agentId = randomUUID(), applicationId = randomUUID(), connectionId = randomUUID(), issueId = randomUUID(), conversationId = randomUUID(), callerId = randomUUID();
@@ -67,6 +73,93 @@ const support = await getEmbeddedPostgresTestSupport();
return { companyId, endpointId, agentId, connectionId, issueId, conversationId, callerId, sessionId, store, request, reserved, accept, toolInput, publication };
}
it.each(["hangup", "revocation", "feature disable", "endpoint pause", "connection disable", "task reassignment"] as const)("wakes live recovery from a real start and settles after %s", async reason => {
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
const f = await nativeServiceFixture();
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const onError = vi.fn(), coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
const settle = async (terminal = false) => vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
expect(idleWorkSnapshot().active).toBe(0);
if (terminal) expect(coordinator.nextWakeAt()).toBeNull();
else expect(coordinator.nextWakeAt()).toBeGreaterThan(Date.now());
});
const quiet = async () => {
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
try { await vi.advanceTimersByTimeAsync(120_000); expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); }
finally { reads.mockRestore(); writes.mockRestore(); }
};
try {
await f.service.registerRecoveryWork(coordinator, () => true).ready;
await settle(true); await quiet();
const started = await f.service.start(f.input); await settle();
expect(f.transport.createBrowserSession).toHaveBeenCalledOnce();
expect(f.transport.inspectSession).not.toHaveBeenCalled();
if (reason === "hangup") f.transport.inspectSession.mockResolvedValue({ status: "ended", endedAt: new Date().toISOString() });
if (reason === "revocation") await db.update(companyMemberships).set({ status: "removed" }).where(eq(companyMemberships.principalId, f.callerId));
if (reason === "feature disable") await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false });
if (reason === "endpoint pause") {
const chat = chatChannelService(db, { heartbeat: { wakeup: vi.fn() } });
try { await chat.configure(f.endpointId, { action: "pause" }, f.callerId); }
finally { await chat.shutdown(); }
}
if (reason === "connection disable") await toolAccessService(db).updateConnection(f.connectionId, { enabled: false });
if (reason === "task reassignment") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, f.issueId));
if (["hangup", "revocation", "task reassignment"].includes(reason)) await vi.advanceTimersByTimeAsync(31_000);
await settle(true);
const [row] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, started.session.id));
expect(row.state).toBe("ended");
if (reason !== "hangup") expect(f.transport.endSession).toHaveBeenCalledOnce();
await quiet(); expect(onError).not.toHaveBeenCalled();
} finally { await coordinator.stop(); f.service.unregisterAgentTools(); vi.useRealTimers(); }
});
it("retires uncertain creation at its saved grace deadline without creating another provider call", async () => {
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
const f = await nativeServiceFixture();
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const onError = vi.fn(), coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
try {
await f.service.registerRecoveryWork(coordinator, () => true).ready;
f.transport.createBrowserSession.mockRejectedValue(new SpekoProviderError("provider_unavailable", true));
await expect(f.service.start({ ...f.input, maxDurationSeconds: 30 })).rejects.toMatchObject({ status: 409 });
const row = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.idempotencyKey, f.input.idempotencyKey)).then(rows => rows[0]);
const deadline = row.expiresAt.getTime() + 120_001;
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBe(deadline); });
const reads = vi.spyOn(db, "select");
try { await vi.advanceTimersByTimeAsync(100_000); expect(reads).not.toHaveBeenCalled(); }
finally { reads.mockRestore(); }
await vi.advanceTimersByTimeAsync(deadline - Date.now());
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull(); });
expect((await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, row.id)))[0].state).toBe("expired");
expect(f.transport.createBrowserSession).toHaveBeenCalledOnce(); expect(f.transport.endSession).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
} finally { await coordinator.stop(); f.service.unregisterAgentTools(); vi.useRealTimers(); }
});
it("recovers a real guest session and settles from its signed lifecycle event", async () => {
const f = await inboundFixture(true);
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const onError = vi.fn(), coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
const settle = async () => vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); });
try {
await f.service.registerRecoveryWork(coordinator, () => true).ready;
expect(coordinator.nextWakeAt()).toBeNull();
await f.tool("submit_request", { text: "Help draft a public question" });
await settle(); expect(coordinator.nextWakeAt()).toBeGreaterThan(Date.now());
await vi.advanceTimersByTimeAsync(31_000); await settle();
expect(f.transport.inspectSession).toHaveBeenCalledWith(f.event.session_id);
expect(f.transport.endSession).not.toHaveBeenCalled();
const ended = f.sign({ type: "call.status", call_id: f.event.call_id, status: "ended" });
await f.service.inbound.lifecycle(f.endpoint.publicId, ended.body, ended.headers);
await settle(); expect(coordinator.nextWakeAt()).toBeNull();
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
try { await vi.advanceTimersByTimeAsync(120_000); expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); }
finally { reads.mockRestore(); writes.mockRestore(); }
expect(onError).not.toHaveBeenCalled();
} finally { await coordinator.stop(); f.service.unregisterAgentTools(); vi.useRealTimers(); }
});
it("reports signing-secret presence for interrupted setup without returning credential refs", async () => {
const f = await fixture();
const chat = chatChannelService(db, {heartbeat: {wakeup: vi.fn()}});
+2 -1
View File
@@ -1298,6 +1298,7 @@ export async function createApp(
}
registerChatDeliveryWork(deliveryWork, chatChannels, () => !isIdleTaskDrainActive());
registerChatActionWork(deliveryWork, chatChannels, () => !isIdleTaskDrainActive());
chatChannels.voice.registerRecoveryWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerReceiverWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerCommandWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
emailChannels.start();
@@ -1305,7 +1306,7 @@ export async function createApp(
await chatChannels.processPublicationMaintenance();
};
const chatReconciliation = createChatReconciliationCoordinator({
reconcileVoiceSessions: () => chatChannels.voice.reconcile(),
reconcileVoiceSessions: () => chatChannels.voice.reconcileMaintenance(),
// Chat delivery, action, and Telegram discovery workers now own these
// duties. Keep the coordinator's other maintenance lanes independent.
processPendingDeliveries: async () => undefined,
+2
View File
@@ -1,3 +1,4 @@
import { notifyVoiceRecovery } from "./voice/voice-recovery-notifications.js";
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
import { notifyToolConnectionHealth } from "./tool-connection-health-notifications.js";
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
@@ -328,6 +329,7 @@ export function accessService(db: Db) {
&& grant.credentialSecretRefs.every((ref) => !connectionSecretIdsToRemove.has(ref.secretId)),
);
if (connection.transport === "chat_sdk") await notifyChatEndpointWork(tx, connection.id);
if (connection.transport === "voice") await notifyVoiceRecovery(tx, { kind: "connection", id: connection.id });
if (!hasUnaffectedActiveGrant) await notifyToolConnectionHealth(tx, { companyId, id: connection.id });
await tx.update(toolConnections).set({
credentialRefs,
@@ -1,8 +1,10 @@
import { notifyVoiceRecovery } from "../voice/voice-recovery-notifications.js";
import { notifyChatReceiver, notifyChatConnectionReceiver, notifyChatCompanyReceivers } from "./runtime/receiver-work-notifications.js";
import type { ChatProvider, ToolConnectionTransport } from "@paperclipai/shared";
import { notifyDiscordCommandRegistration } from "./discord/command-work-notifications.js";
export async function notifyChatEndpointConfiguration(transaction: object, endpoint: { id: string; provider: ChatProvider }) {
if (endpoint.provider === "speko") await notifyVoiceRecovery(transaction, { kind: "endpoint", id: endpoint.id });
await notifyChatReceiver(transaction, endpoint);
if (endpoint.provider === "discord") {
await notifyDiscordCommandRegistration(transaction, { kind: "endpoint", id: endpoint.id });
@@ -10,6 +12,11 @@ export async function notifyChatEndpointConfiguration(transaction: object, endpo
}
export async function notifyChatConnectionConfiguration(transaction: object, connectionId: string, transport: ToolConnectionTransport = "chat_sdk") {
if (transport === "chat_sdk" || transport === "voice") await notifyVoiceRecovery(transaction, { kind: "connection", id: connectionId });
await notifyChatConnectionObservation(transaction, connectionId, transport);
}
export async function notifyChatConnectionObservation(transaction: object, connectionId: string, transport: ToolConnectionTransport) {
await notifyChatConnectionReceiver(transaction, connectionId, transport);
if (transport === "chat_sdk") {
await notifyDiscordCommandRegistration(transaction, { kind: "connection", id: connectionId });
@@ -17,6 +24,7 @@ export async function notifyChatConnectionConfiguration(transaction: object, con
}
export async function notifyChatCompanyConfiguration(transaction: object, companyId: string) {
await notifyVoiceRecovery(transaction, { kind: "company", id: companyId });
await notifyChatCompanyReceivers(transaction, companyId);
await notifyDiscordCommandRegistration(transaction, { kind: "company", id: companyId });
}
@@ -44,6 +44,7 @@ export const DELIVERY_QUEUES = {
chatFailedRunRetries: "chat-failed-run-retries",
chatTelegramMaintenance: "chat-telegram-maintenance",
chatReceivers: "chat-receivers",
voiceSessionRecovery: "voice-session-recovery",
chatCommandRegistration: "chat-command-registration",
chatTelegramSubscriptions: "chat-telegram-subscriptions",
chatSessionSyncs: "chat-session-syncs",
+2
View File
@@ -1,3 +1,4 @@
import { notifyVoiceRecovery } from "./voice/voice-recovery-notifications.js";
import { notifyToolConnectionHealth } from "./tool-connection-health-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import type { Db } from "@paperclipai/db";
@@ -614,6 +615,7 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService
await notifyDeliveryWork(tx, DELIVERY_QUEUES.mcpEvents, "*");
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, "*");
await notifyDeliveryWork(tx, DELIVERY_QUEUES.grokDispatch);
if (patch.enableChatConnectors !== undefined) await notifyVoiceRecovery(tx);
await notifyToolConnectionHealth(tx, "*");
return tx
.update(instanceSettings)
+2
View File
@@ -1,3 +1,4 @@
import { notifyVoiceRecovery } from "./voice/voice-recovery-notifications.js";
import { notifyToolConnectionHealth, writeToolConnectionHealthChange } from "./tool-connection-health-notifications.js";
import { connectionHealthSchedule } from "./tool-access/catalog/health-schedule.js";
import type { ConnectionManagementContext } from "./connection-management.js";
@@ -4672,6 +4673,7 @@ export function toolAccessService(
return rows;
})
: await writeToolConnectionHealthChange(db, existing, async tx => {
if (existing.transport === "voice") await notifyVoiceRecovery(tx, { kind: "connection", id: existing.id });
const rows = await write(tx);
await syncCredentialBindings(rows[0]!, [], tx);
await createToolRuntimeSlots(tx, runtimeSupervisor).ensureRuntimeSlot(rows[0]!);
@@ -1,4 +1,4 @@
import { notifyChatConnectionConfiguration } from "../../chat-channels/configuration-work-notifications.js";
import { notifyChatConnectionObservation } from "../../chat-channels/configuration-work-notifications.js";
import { writeToolConnectionHealthChange } from "../../tool-connection-health-notifications.js";
import { connectionActorUserId } from "../actor.js";
import { assertSupportedConnection } from "../connections/setup-policy.js";
@@ -50,7 +50,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
) {
const now = new Date();
const [updated] = await writeToolConnectionHealthChange(db, connection, async tx => {
await notifyChatConnectionConfiguration(tx, connection.id, connection.transport);
await notifyChatConnectionObservation(tx, connection.id, connection.transport);
return tx.update(toolConnections)
.set({
healthStatus: status,
@@ -1,3 +1,4 @@
import { notifyVoiceRecovery } from "../../voice/voice-recovery-notifications.js";
import { notifyChatEndpointWork } from "../../chat-work-notifications.js";
import { notifyToolConnectionHealth } from "../../tool-connection-health-notifications.js";
import { toConnection } from "./projection.js";
@@ -541,6 +542,7 @@ async function removeConnection(
const archived = await db.transaction(async (tx) => {
if (connection.transport === "chat_sdk") await notifyChatEndpointWork(tx, connection.id);
if (connection.transport === "voice") await notifyVoiceRecovery(tx, { kind: "connection", id: connection.id });
await notifyToolConnectionHealth(tx, connection);
await tx.select({ id: toolApplications.id }).from(toolApplications)
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId)))
@@ -1,3 +1,4 @@
import { notifyVoiceRecovery } from "./voice-recovery-notifications.js";
import { logger } from "../../middleware/logger.js";
import { createHash, createHmac, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
import { and, asc, desc, eq, gte, inArray, lte, isNull, sql } from "drizzle-orm";
@@ -150,6 +151,7 @@ export function voiceInboundService(db: Db, options: {
}
if (event.type === "call.report" || event.status && ["ended", "failed"].includes(event.status)) {
if (session) {
await notifyVoiceRecovery(tx, { kind: "session", id: session.id });
await tx.update(chatVoiceSessions).set({state: event.status === "failed" ? "failed" : "ended", endedAt: session.endedAt ?? new Date(), updatedAt: new Date()}).where(eq(chatVoiceSessions.id, session.id));
await tx.insert(chatVoiceReports).values({companyId: row.companyId, sessionId: session.id}).onConflictDoUpdate({target: chatVoiceReports.sessionId, set: {nextCheckAt: new Date()}});
}
@@ -184,7 +186,9 @@ export function voiceInboundService(db: Db, options: {
const task = await issueSvc.create(row.companyId, {id: taskId, title: "Incoming phone conversation", description: "Conversation with an unverified phone caller. The agent runs under a task-scoped low-trust policy. You are on a live phone call and must attempt to answer as quickly as possible. Immediately post a brief, caller-safe task comment explaining what you are doing, then find the answer and post the result on this task so it can be spoken to the caller. Continue working after the initial update; do not treat it as the final answer. Preserve the task-scoped low-trust policy and ordinary permissions.", status: "todo", priority: "medium", assigneeAgentId: row.assignedAgentId, executionWorkspaceSettings: {mode: "isolated_workspace", workspaceStrategy: {type: "cloud_sandbox"}, ...(line.lowTrustEnvironmentId ? {environmentId: line.lowTrustEnvironmentId} : {})}, originKind: "chat_channel", originId: row.id, idempotencyKey: `guest-intake:${call.id}`, sourceTrust: {preset: LOW_TRUST_REVIEW_PRESET, disposition: "quarantined", sourceIssueId: taskId}, executionPolicy: {mode: "normal", commentRequired: true, stages: [], reviewPreset, authorizationPolicy: {trustPreset: LOW_TRUST_REVIEW_PRESET, reviewPreset, trustBoundary: {mode: LOW_TRUST_REVIEW_PRESET, companyId: row.companyId, rootIssueId: taskId, issueIds: [taskId], allowedAgentIds: [row.assignedAgentId], allowedToolClasses: [], allowedSecretBindingIds: []}}}}, tx);
const [resource] = await tx.insert(chatEndpointResources).values({companyId: row.companyId, endpointId: row.id, type: "direct_message", providerResourceId: threadId, label: "Incoming phone conversation", enabled: true, availability: "available"}).returning();
await tx.insert(chatConversations).values({id: conversationId, companyId: row.companyId, endpointId: row.id, resourceId: resource.id, issueId: task.id, externalConversationId: threadId, externalThreadId: threadId, externalLabel: "Incoming phone conversation", isDirectMessage: true, communicationGuidance: [buildChatCommunicationGuidance({ provider: "speko", isDirectMessage: true, communicationInstructions: row.communicationInstructions }), "This conversation is its own low-trust task. Ask clarifications as brief task comments; the caller answers through submit_request follow-ups. Do not use ask_user_questions or create a protected human-input wait. Use only context supplied in this conversation; do not browse other company tasks, secrets or private tools. Spoken consent does not grant governed authority."].join("\n\n")});
const [session] = await tx.insert(chatVoiceSessions).values({companyId: row.companyId, endpointId: row.id, conversationId, issueId: task.id, assignedAgentId: row.assignedAgentId, callerId: `guest:${call.id}`, callerAuthority: "guest_intake", mode: "inbound_phone", state: "active", generation: call.generation, credentialFingerprint: call.credentialFingerprint, providerSessionId: call.providerSessionId, toolTokenHash: call.toolTokenHash, idempotencyKey: `guest:${call.id}`, requestFingerprint: call.requestFingerprint, createdAt: call.createdAt, expiresAt: call.expiresAt}).returning({id: chatVoiceSessions.id});
const sessionId = randomUUID();
await notifyVoiceRecovery(tx, { kind: "session", id: sessionId });
const [session] = await tx.insert(chatVoiceSessions).values({id: sessionId, companyId: row.companyId, endpointId: row.id, conversationId, issueId: task.id, assignedAgentId: row.assignedAgentId, callerId: `guest:${call.id}`, callerAuthority: "guest_intake", mode: "inbound_phone", state: "active", generation: call.generation, credentialFingerprint: call.credentialFingerprint, providerSessionId: call.providerSessionId, toolTokenHash: call.toolTokenHash, idempotencyKey: `guest:${call.id}`, requestFingerprint: call.requestFingerprint, createdAt: call.createdAt, expiresAt: call.expiresAt}).returning({id: chatVoiceSessions.id});
await tx.update(chatVoiceInboundCalls).set({intakeIssueId: task.id, sessionId: session.id, updatedAt: new Date()}).where(eq(chatVoiceInboundCalls.id, call.id));
await tx.insert(activityLog).values({companyId: row.companyId, actorType: "system", actorId: "speko-inbound", action: "voice.low_trust_call.started", entityType: "issue", entityId: task.id, details: {callId: call.id, sessionId: session.id}});
}
@@ -228,7 +232,10 @@ export function voiceInboundService(db: Db, options: {
if (input.issueId) await store.authorizeTask(tx, companyId, input.issueId, caller);
const [changed] = await tx.update(chatVoiceInboundCalls).set({state: input.approve ? "approving" : row.state === "guest_intake" ? "ended" : "denied", approvedByUserId: caller.id, callerAuthority: caller.authority, requestedIssueId: input.issueId ?? row.intakeIssueId ?? null, updatedAt: new Date()}).where(eq(chatVoiceInboundCalls.id, row.id)).returning();
await tx.insert(activityLog).values({companyId, actorType: "user", actorId: caller.id, action: input.approve ? "voice.inbound.approval_started" : row.state === "guest_intake" ? "voice.low_trust_call.ended" : "voice.inbound.denied", entityType: "voice_inbound_call", entityId: row.id});
if (!input.approve && row.state === "guest_intake" && row.sessionId) await tx.update(chatVoiceSessions).set({state: "ending", updatedAt: new Date()}).where(and(eq(chatVoiceSessions.companyId, companyId), eq(chatVoiceSessions.id, row.sessionId)));
if (!input.approve && row.state === "guest_intake" && row.sessionId) {
await notifyVoiceRecovery(tx, { kind: "session", id: row.sessionId });
await tx.update(chatVoiceSessions).set({state: "ending", updatedAt: new Date()}).where(and(eq(chatVoiceSessions.companyId, companyId), eq(chatVoiceSessions.id, row.sessionId)));
}
return changed;
});
if (admission.state === "approved") return projection(admission);
@@ -0,0 +1,20 @@
import type { Db } from "@paperclipai/db";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
export type VoiceRecoveryScope = { kind: "session" | "endpoint" | "connection" | "company"; id: string };
export function voiceRecoveryScope(hint: string): VoiceRecoveryScope | undefined {
if (hint === "*") return undefined;
const [kind, id, extra] = hint.split(":");
if (!id || extra !== undefined || !["session", "endpoint", "connection", "company"].includes(kind))
throw new Error("Invalid voice recovery scope");
return { kind: kind as VoiceRecoveryScope["kind"], id };
}
export function notifyVoiceRecovery(tx: object, scope?: VoiceRecoveryScope) {
return notifyDeliveryWork(tx, DELIVERY_QUEUES.voiceSessionRecovery, scope ? `${scope.kind}:${scope.id}` : "*");
}
export function withVoiceSessionChange<T>(db: Db, id: string, write: (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => PromiseLike<T>): Promise<T> {
return db.transaction(async tx => {
await notifyVoiceRecovery(tx, { kind: "session", id });
return write(tx);
});
}
@@ -0,0 +1,106 @@
import { takeCompanyWorkBatch } from "../company-work-batch.js";
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import type { VoiceRecoveryEntry, voiceSessionRecovery } from "./voice-session-recovery.js";
import { voiceRecoveryScope, type VoiceRecoveryScope } from "./voice-recovery-notifications.js";
function matches(entry: VoiceRecoveryEntry, scope?: VoiceRecoveryScope) {
return !scope || (scope.kind === "session" ? entry.id : scope.kind === "endpoint" ? entry.endpointId : scope.kind === "connection" ? entry.connectionId : entry.companyId) === scope.id;
}
export function registerVoiceRecoveryWork(
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
service: ReturnType<typeof voiceSessionRecovery>,
canRun: () => boolean,
) {
service.reset();
const dirty = new Set<string>(["*"]);
const versions = new Map<string, number>();
const scans = new Map<string, { version: number; seen: Set<string> }>();
const entries = new Map<string, VoiceRecoveryEntry>();
const failures = new Map<string, { count: number; retryAt: number }>();
const scopeFailures = new Map<string, { count: number; retryAt: number }>();
let earliest = 0;
let lastCompany: string | null = null;
function failed(map: typeof failures, id: string) {
const count = Math.min((map.get(id)?.count ?? 0) + 1, 5);
map.set(id, { count, retryAt: Date.now() + Math.min(300_000, 30_000 * 2 ** (count - 1)) });
}
function dirtyScopes() {
return [...dirty].filter(hint => hint !== "*" || !scopeFailures.has(hint))
.filter(hint => !scans.has(hint) || scopeFailures.has(hint))
.map(hint => { try { return voiceRecoveryScope(hint); } catch { return undefined; } });
}
function next() {
let nextAt: number | null = null;
const take = (at: number) => { nextAt = nextAt === null ? at : Math.min(nextAt, at); };
for (const hint of dirty) take(Math.max(earliest, scopeFailures.get(hint)?.retryAt ?? 0));
const unavailable = dirtyScopes();
for (const entry of entries.values()) if (!unavailable.some(scope => matches(entry, scope)))
take(Math.max(earliest, entry.dueAt, failures.get(entry.id)?.retryAt ?? 0));
return nextAt;
}
const worker = coordinator.register(DELIVERY_QUEUES.voiceSessionRecovery, {
retryMs: 30_000,
pausePassiveMaintenance: true,
onSettled: hints => { for (const hint of hints ?? ["*"]) {
dirty.add(hint); versions.set(hint, (versions.get(hint) ?? 0) + 1);
} },
run: async signal => {
if (!canRun() || Date.now() < earliest) return;
earliest = Date.now() + 1000;
const errors: unknown[] = [];
const ready = [...dirty].filter(hint => (scopeFailures.get(hint)?.retryAt ?? 0) <= Date.now());
const requested = ready.includes("*")
? ["*", ...ready.filter(hint => hint !== "*")].slice(0, 25)
: ready.slice(0, 25);
for (const hint of requested) dirty.delete(hint);
for (let index = 0; index < requested.length; index++) {
if (signal.aborted || !canRun()) { for (const hint of requested.slice(index)) dirty.add(hint); return; }
const hint = requested[index];
let scope: VoiceRecoveryScope | undefined;
let decoded = false;
try {
scope = voiceRecoveryScope(hint); decoded = true;
const scan = scans.get(hint) ?? { version: versions.get(hint) ?? 0, seen: new Set<string>() };
scans.set(hint, scan);
const result = await service.refresh(scope, signal);
for (const entry of result.rows ?? []) {
entries.set(entry.id, entry);
for (const [other, progress] of scans) if (matches(entry, voiceRecoveryScope(other))) progress.seen.add(entry.id);
}
if (!result.complete) { dirty.add(hint); continue; }
if (result.rows !== null) for (const entry of entries.values()) if (matches(entry, scope) && !scan.seen.has(entry.id)) {
entries.delete(entry.id); failures.delete(entry.id);
}
scans.delete(hint);
if ((versions.get(hint) ?? 0) !== scan.version) dirty.add(hint); else versions.delete(hint);
if (!scope) scopeFailures.clear(); else scopeFailures.delete(hint);
} catch (error) {
const retryHint = decoded ? hint : "*";
if (!decoded) { scans.delete(hint); versions.delete(hint); }
dirty.add(retryHint); failed(scopeFailures, retryHint); errors.push(error);
}
}
const unavailable = dirtyScopes();
const due = [...entries.values()].filter(entry => !unavailable.some(scope => matches(entry, scope))
&& Math.max(entry.dueAt, failures.get(entry.id)?.retryAt ?? 0) <= Date.now())
.sort((a, b) => a.dueAt - b.dueAt || a.id.localeCompare(b.id));
const batch = takeCompanyWorkBatch(due, 25, lastCompany); lastCompany = batch.lastCompany;
for (const entry of batch.entries) {
if (signal.aborted || !canRun()) break;
try {
const current = await service.check(entry.id, signal);
if (current) entries.set(entry.id, current); else entries.delete(entry.id);
failures.delete(entry.id);
} catch (error) { failed(failures, entry.id); errors.push(error); }
}
if (errors.length) throw new AggregateError(errors, "Voice session recovery could not settle");
},
// Retain scopes/deadlines for resume without retaining a settled write's
// idle hold during drain. The coordinator still fences unresolved XIDs.
hasPending: async () => canRun() && dirty.size > 0,
nextRunAt: next,
nextRunAtOnError: next,
});
return worker;
}
@@ -1,3 +1,4 @@
import { notifyVoiceRecovery } from "./voice-recovery-notifications.js";
import { createHash } from "node:crypto";
import { and, asc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm";
import { chatActions, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, type Db } from "@paperclipai/db";
@@ -114,6 +115,7 @@ export async function pushVoiceReplies(db: Db, options: {
: providerError?.httpStatus === 429 ? "retry" : "failed";
await db.transaction(async tx => {
await tx.update(chatActions).set({status, result: {errorCode: providerError?.code ?? "voice_authority_unavailable", ...(status === "retry" ? {retryAt: Date.now() + 10_000} : {})}, updatedAt: new Date()}).where(eq(chatActions.id, claim.action.id));
if (status === "call_ended" || status === "unknown") await notifyVoiceRecovery(tx, { kind: "session", id: candidate.id });
if (status === "call_ended") await tx.update(chatVoiceSessions).set({state: "ended", endedAt: new Date(), updatedAt: new Date()}).where(and(eq(chatVoiceSessions.id, candidate.id), eq(chatVoiceSessions.companyId, candidate.companyId)));
if (status === "unknown") await tx.update(chatVoiceSessions).set({errorCode: "message_delivery_unknown", updatedAt: new Date()}).where(eq(chatVoiceSessions.id, candidate.id));
});
@@ -0,0 +1,164 @@
import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { chatEndpoints, chatVoiceSessions, toolConnections, type Db } from "@paperclipai/db";
import { HttpError } from "../../errors.js";
import { instanceSettingsService } from "../instance-settings.js";
import { withVoiceSessionChange, type VoiceRecoveryScope } from "./voice-recovery-notifications.js";
export const LIVE_VOICE_STATES = ["reserved", "creating", "creation_unknown", "connecting", "active", "ending"] as const;
type Session = typeof chatVoiceSessions.$inferSelect;
export type VoiceRecoveryEntry = { id: string; companyId: string; endpointId: string; connectionId: string; dueAt: number };
type Candidate<T = Pick<Session, "id" | "companyId" | "endpointId" | "providerSessionId" | "expiresAt" | "updatedAt" | "generation" | "state" | "errorCode">> = { session: T; connectionId: string; endpointStatus: string; enabled: boolean; generation: number };
export function voiceSessionRecoveryAt(row: Candidate, featureEnabled: boolean): number {
const session = row.session;
if (!session.providerSessionId) return session.expiresAt.getTime() + 120_001;
const changed = !featureEnabled || !row.enabled || !["active", "verifying"].includes(row.endpointStatus)
|| session.generation !== row.generation || session.state === "ending";
if (changed) return session.errorCode === "cleanup_pending" ? session.updatedAt.getTime() + 30_000 : 0;
if (["connecting", "active"].includes(session.state)) return Math.min(session.expiresAt.getTime(), session.updatedAt.getTime() + 30_000);
return session.expiresAt.getTime();
}
export function voiceSessionRecovery(db: Db, operations: {
authorize(session: Session): Promise<void>;
inspect(session: Session): Promise<{ endedAt?: unknown } | null>;
close(session: Session): Promise<unknown>;
}) {
function sessionConditions(scope?: VoiceRecoveryScope, cursor?: string) {
return and(inArray(chatVoiceSessions.state, [...LIVE_VOICE_STATES]),
scope?.kind === "session" ? eq(chatVoiceSessions.id, scope.id) : undefined,
scope?.kind === "endpoint" ? eq(chatVoiceSessions.endpointId, scope.id) : undefined,
scope?.kind === "connection" ? eq(toolConnections.id, scope.id) : undefined,
scope?.kind === "company" ? eq(chatVoiceSessions.companyId, scope.id) : undefined,
cursor ? gt(chatVoiceSessions.id, cursor) : undefined);
}
async function candidates(scope?: VoiceRecoveryScope, signal?: AbortSignal) {
const rows: Candidate<Session>[] = [];
let cursor: string | undefined;
for (;;) {
signal?.throwIfAborted();
const page = await db.select({ session: chatVoiceSessions, connectionId: toolConnections.id,
endpointStatus: chatEndpoints.status, enabled: toolConnections.enabled, generation: sql<number>`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0)` })
.from(chatVoiceSessions)
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatVoiceSessions.endpointId), eq(chatEndpoints.companyId, chatVoiceSessions.companyId)))
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatVoiceSessions.companyId)))
.where(sessionConditions(scope, cursor))
.orderBy(chatVoiceSessions.id).limit(100);
rows.push(...page);
if (page.length < 100) return rows;
cursor = page[page.length - 1].session.id;
}
}
async function load(scope?: VoiceRecoveryScope, signal?: AbortSignal): Promise<VoiceRecoveryEntry[]> {
const rows = await candidates(scope, signal);
if (!rows.length) return [];
const enabled = (await instanceSettingsService(db).getExperimental()).enableChatConnectors;
return rows.map(row => ({ id: row.session.id, companyId: row.session.companyId, endpointId: row.session.endpointId,
connectionId: row.connectionId, dueAt: voiceSessionRecoveryAt(row, enabled) }));
}
type Parent = { id: string; companyId: string; connectionId: string; signature: string | null };
type Scan = { phase: "parents" | "sessions"; cursor?: string; parents: Map<string, Parent> };
const parents = new Map<string, Parent>();
const scans = new Map<string, Scan>();
function scopeKey(scope?: VoiceRecoveryScope) { return scope ? `${scope.kind}:${scope.id}` : "*"; }
function parentMatches(row: Parent, scope?: VoiceRecoveryScope) {
return !scope || (scope.kind === "endpoint" ? row.id : scope.kind === "connection" ? row.connectionId : row.companyId) === scope.id;
}
function remember(target: Map<string, Parent>, row: Parent) {
const prior = target.get(row.id);
target.set(row.id, prior && prior.signature !== row.signature ? { ...row, signature: null } : row);
}
async function refresh(scope?: VoiceRecoveryScope, signal?: AbortSignal): Promise<{ rows: VoiceRecoveryEntry[] | null; complete: boolean }> {
const key = scopeKey(scope);
const scan = scans.get(key) ?? { phase: scope && scope.kind !== "session" ? "parents" as const : "sessions" as const, parents: new Map<string, Parent>() };
signal?.throwIfAborted();
if (scan.phase === "parents") {
const page = await db.select({ id: chatEndpoints.id, companyId: chatEndpoints.companyId, connectionId: toolConnections.id,
endpointStatus: chatEndpoints.status, enabled: toolConnections.enabled,
generation: sql<number>`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0)` })
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
.where(and(scope?.kind === "endpoint" ? eq(chatEndpoints.id, scope.id) : undefined,
scope?.kind === "connection" ? eq(toolConnections.id, scope.id) : undefined,
scope?.kind === "company" ? eq(chatEndpoints.companyId, scope.id) : undefined,
scan.cursor ? gt(chatEndpoints.id, scan.cursor) : undefined))
.orderBy(chatEndpoints.id).limit(100);
const enabled = page.length ? (await instanceSettingsService(db).getExperimental()).enableChatConnectors : false;
const next = new Map(scan.parents);
for (const row of page) remember(next, { id: row.id, companyId: row.companyId, connectionId: row.connectionId,
signature: JSON.stringify([row.connectionId, row.endpointStatus, row.enabled, row.generation, enabled]) });
signal?.throwIfAborted();
if (page.length === 100) {
scans.set(key, { phase: "parents", cursor: page[page.length - 1].id, parents: next });
return { rows: [], complete: false };
}
const previous = [...parents.values()].filter(row => parentMatches(row, scope));
if (previous.length === next.size && previous.every(row => row.signature !== null && next.get(row.id)?.signature === row.signature)) {
scans.delete(key); return { rows: null, complete: true };
}
scans.set(key, { phase: "sessions", parents: next });
return { rows: [], complete: false };
}
const page = await db.select({ session: { id: chatVoiceSessions.id, companyId: chatVoiceSessions.companyId,
endpointId: chatVoiceSessions.endpointId, state: chatVoiceSessions.state, providerSessionId: chatVoiceSessions.providerSessionId,
expiresAt: chatVoiceSessions.expiresAt, updatedAt: chatVoiceSessions.updatedAt, generation: chatVoiceSessions.generation,
errorCode: chatVoiceSessions.errorCode }, connectionId: toolConnections.id,
endpointStatus: chatEndpoints.status, enabled: toolConnections.enabled,
generation: sql<number>`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0)` })
.from(chatVoiceSessions)
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatVoiceSessions.endpointId), eq(chatEndpoints.companyId, chatVoiceSessions.companyId)))
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatVoiceSessions.companyId)))
.where(sessionConditions(scope, scan.cursor))
.orderBy(chatVoiceSessions.id).limit(100);
const enabled = page.length ? (await instanceSettingsService(db).getExperimental()).enableChatConnectors : false;
const next = new Map(scan.parents);
for (const row of page) remember(next, { id: row.session.endpointId, companyId: row.session.companyId, connectionId: row.connectionId,
signature: JSON.stringify([row.connectionId, row.endpointStatus, row.enabled, row.generation, enabled]) });
const rows = page.map(row => ({ id: row.session.id, companyId: row.session.companyId, endpointId: row.session.endpointId,
connectionId: row.connectionId, dueAt: voiceSessionRecoveryAt(row, enabled) }));
signal?.throwIfAborted();
const complete = page.length < 100;
if (!complete) scans.set(key, { phase: "sessions", cursor: page[page.length - 1].session.id, parents: next });
else {
scans.delete(key);
if (scope?.kind !== "session") {
for (const row of parents.values()) if (parentMatches(row, scope)) parents.delete(row.id);
for (const [id, row] of next) parents.set(id, row);
}
}
return { rows, complete };
}
function reset() { scans.clear(); parents.clear(); }
async function check(id: string, signal?: AbortSignal): Promise<VoiceRecoveryEntry | null> {
const scope = { kind: "session", id } as const;
const [row] = await candidates(scope, signal);
if (!row) return null;
const enabled = (await instanceSettingsService(db).getExperimental()).enableChatConnectors;
if (voiceSessionRecoveryAt(row, enabled) <= Date.now()) {
signal?.throwIfAborted();
const session = row.session;
try {
let authorized = false;
if (enabled && ["connecting", "active"].includes(session.state) && session.expiresAt.getTime() > Date.now()) {
try { await operations.authorize(session); authorized = true; }
catch (error) { if (!(error instanceof HttpError && [403, 404].includes(error.status))) throw error; }
}
if (authorized) {
const state = await operations.inspect(session);
await withVoiceSessionChange(db, id, tx => tx.update(chatVoiceSessions).set({
...(state?.endedAt ? { state: "ended" as const, endedAt: new Date(), errorCode: null } : {}), updatedAt: new Date(),
}).where(and(eq(chatVoiceSessions.id, id), inArray(chatVoiceSessions.state, ["connecting", "active"]))));
} else if (session.providerSessionId) await operations.close(session);
else if (session.expiresAt.getTime() + 120_000 < Date.now()) {
await withVoiceSessionChange(db, id, tx => tx.update(chatVoiceSessions).set({ state: "expired", endedAt: new Date(), updatedAt: new Date() })
.where(and(eq(chatVoiceSessions.id, id), isNull(chatVoiceSessions.providerSessionId), inArray(chatVoiceSessions.state, [...LIVE_VOICE_STATES]))));
}
} catch (error) {
await withVoiceSessionChange(db, id, tx => tx.update(chatVoiceSessions).set({ errorCode: "cleanup_pending", updatedAt: new Date() })
.where(and(eq(chatVoiceSessions.id, id), inArray(chatVoiceSessions.state, [...LIVE_VOICE_STATES]))));
throw error;
}
}
return (await load(scope, signal))[0] ?? null;
}
return { load, refresh, reset, check };
}
@@ -1,3 +1,7 @@
import { voiceSessionRecovery } from "./voice-session-recovery.js";
import { registerVoiceRecoveryWork } from "./voice-recovery-work.js";
import { notifyVoiceRecovery, withVoiceSessionChange } from "./voice-recovery-notifications.js";
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
import { logger } from "../../middleware/logger.js";
import { captureVoiceDeliveryDiagnostics } from "./voice-delivery-diagnostics.js";
import { pushVoiceReplies } from "./voice-reply-push.js";
@@ -92,7 +96,7 @@ export function voiceSessionService(db: Db, options: {
// browser response must inspect/end this attempt, never blindly create again.
if (!reserved.created || input.mode === "inbound_phone") return { session: serializeVoiceSession(reserved.session) };
const session = reserved.session;
const [claim] = await db.update(chatVoiceSessions).set({ state: "creating", updatedAt: new Date() }).where(and(eq(chatVoiceSessions.id, session.id), eq(chatVoiceSessions.state, "reserved"))).returning({ id: chatVoiceSessions.id });
const [claim] = await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set({ state: "creating", updatedAt: new Date() }).where(and(eq(chatVoiceSessions.id, session.id), eq(chatVoiceSessions.state, "reserved"))).returning({ id: chatVoiceSessions.id }));
if (!claim) throw conflict("The call was ended before Speko connected");
let client: ReturnType<typeof createSpekoProvider> | undefined;
let created: { sessionId: string; transportToken?: string; transportUrl?: string };
@@ -107,13 +111,13 @@ export function voiceSessionService(db: Db, options: {
: await client.createBrowserSession({ ...shared, systemPrompt: VOICE_PROMPT });
} catch (error) {
const known = error instanceof SpekoProviderError && !error.outcomeUnknown || error instanceof HttpError && [403, 404].includes(error.status);
await db.update(chatVoiceSessions).set({ state: known ? "failed" : "creation_unknown", errorCode: error instanceof SpekoProviderError ? error.code : "provider_unavailable", updatedAt: new Date() }).where(and(eq(chatVoiceSessions.id, session.id), eq(chatVoiceSessions.state, "creating")));
await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set({ state: known ? "failed" : "creation_unknown", errorCode: error instanceof SpekoProviderError ? error.code : "provider_unavailable", updatedAt: new Date() }).where(and(eq(chatVoiceSessions.id, session.id), eq(chatVoiceSessions.state, "creating"))));
if (error instanceof SpekoProviderError && error.code === "insufficient_credits") throw conflict("Speko needs credits before starting a call. Add credits in Speko, then try again.", {code: "voice_credits_required", sessionId: session.id});
throw conflict(known ? "Speko could not start the call. Check connection status before trying again." : "Speko may have started this call. Inspect this attempt before starting another.", { code: known ? "voice_creation_failed" : "voice_creation_unknown", sessionId: session.id });
}
let bound: VoiceSessionRow | undefined;
try {
[bound] = await db.update(chatVoiceSessions).set({ providerSessionId: created.sessionId, state: "connecting", updatedAt: new Date() }).where(and(eq(chatVoiceSessions.id, session.id), eq(chatVoiceSessions.state, "creating"))).returning();
[bound] = await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set({ providerSessionId: created.sessionId, state: "connecting", updatedAt: new Date() }).where(and(eq(chatVoiceSessions.id, session.id), eq(chatVoiceSessions.state, "creating"))).returning());
} catch (error) {
// If the provider started but persistence failed, stop that exact call.
// Never return media or retry creation on an uncertain database outcome.
@@ -122,7 +126,7 @@ export function voiceSessionService(db: Db, options: {
}
if (!bound) {
// A concurrent end must still learn the provider identity for reconciliation.
await db.update(chatVoiceSessions).set({ providerSessionId: created.sessionId, state: "ending", updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set({ providerSessionId: created.sessionId, state: "ending", updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id)));
await closeProvider({ ...session, providerSessionId: created.sessionId, state: "ending" });
throw conflict("The call was ended while Speko was connecting");
}
@@ -141,6 +145,7 @@ export function voiceSessionService(db: Db, options: {
async function closeProviderOnce(session: VoiceSessionRow) {
if (terminalStates.some(state => state === session.state)) return session;
await db.transaction(async (tx) => {
await notifyVoiceRecovery(tx, { kind: "session", id: session.id });
const [changed] = await tx.update(chatVoiceSessions).set({ state: "ending", updatedAt: new Date() }).where(and(eq(chatVoiceSessions.id, session.id), inArray(chatVoiceSessions.state, [...liveStates].filter((state) => state !== "ending")))).returning({ id: chatVoiceSessions.id });
if (changed) await tx.insert(activityLog).values({ companyId: session.companyId, actorType: "system", actorId: "voice-session-service", action: "voice.session.ending", entityType: "voice_session", entityId: session.id, details: { issueId: session.issueId } });
});
@@ -156,11 +161,11 @@ export function voiceSessionService(db: Db, options: {
try { confirmed = Boolean((await client.inspectSession(session.providerSessionId)).endedAt); }
catch (error) { if (!(error instanceof SpekoProviderError)) throw error; }
}
await db.update(chatVoiceSessions).set(confirmed
await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set(confirmed
? { state: "ended", endedAt: new Date(), errorCode: null, updatedAt: new Date() }
: { errorCode: "cleanup_pending", updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
: { errorCode: "cleanup_pending", updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id)));
} else if (session.state === "reserved") {
await db.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date(), updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date(), updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id)));
}
return db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, session.id)).then((rows) => rows[0]);
}
@@ -170,7 +175,7 @@ export function voiceSessionService(db: Db, options: {
const endpoint = await endpointFor(companyId, session.endpointId), credentials = await options.credentials(endpoint);
const state = await provider(credentials.apiKey).inspectSession(session.providerSessionId);
if (state.endedAt) {
[session] = await db.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date(), updatedAt: new Date() }).where(eq(chatVoiceSessions.id, sessionId)).returning();
[session] = await withVoiceSessionChange(db, sessionId, tx => tx.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date(), updatedAt: new Date() }).where(eq(chatVoiceSessions.id, sessionId)).returning());
}
}
return serializeVoiceSession(session);
@@ -199,51 +204,30 @@ export function voiceSessionService(db: Db, options: {
if (verified.envelope.tool === "answer_question") options.onQuestionAnswered(verified.envelope.args.interactionId);
return session.mode === "inbound_phone" ? {...result, authorization: session.authority === "guest_intake" ? "guest_intake" : "approved"} : result;
}
async function reconcile(limit = 25) {
// Provider maximum duration is the final bound even if creation response was
// lost. Unknown attempts remain reserved until that bound plus teardown grace.
const enabled = (await instanceSettingsService(db).getExperimental()).enableChatConnectors;
const rows = await db.select({ session: chatVoiceSessions }).from(chatVoiceSessions)
.innerJoin(chatEndpoints, eq(chatEndpoints.id, chatVoiceSessions.endpointId))
.innerJoin(toolConnections, eq(toolConnections.id, chatEndpoints.connectionId))
.where(and(inArray(chatVoiceSessions.state, [...liveStates]), or(
enabled ? undefined : sql`true`,
lte(chatVoiceSessions.expiresAt, new Date()), eq(chatVoiceSessions.state, "ending"),
notInArray(chatEndpoints.status, ["active", "verifying"]), eq(toolConnections.enabled, false),
sql`${chatVoiceSessions.generation} <> coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0)`,
// Recheck membership and bindings periodically even without callbacks.
and(inArray(chatVoiceSessions.state, ["connecting", "active"]), lte(chatVoiceSessions.updatedAt, new Date(Date.now() - 30_000))),
))).orderBy(asc(chatVoiceSessions.updatedAt)).limit(limit);
for (const { session } of rows) {
try {
let authorized = false;
if (enabled && ["connecting", "active"].includes(session.state) && session.callerAuthority === "guest_intake" && session.expiresAt.getTime() > Date.now()) {
authorized = await inbound.authorizeGuest(session.id);
} else if (enabled && ["connecting", "active"].includes(session.state)) {
try { await db.transaction((tx) => store.authorizeSession(tx, session.companyId, session.id)); authorized = true; } catch { /* revoked, changed binding, or expired */ }
}
if (authorized) {
// A caller can hang up without a final browser request. Poll provider
// state as well as permissions so a missed callback releases the task.
const endpoint = await endpointFor(session.companyId, session.endpointId);
const credentials = await options.credentials(endpoint);
const state = session.providerSessionId ? await provider(credentials.apiKey).inspectSession(session.providerSessionId) : null;
await db.update(chatVoiceSessions).set({
...(state?.endedAt ? { state: "ended", endedAt: new Date(), errorCode: null } : {}),
updatedAt: new Date(),
}).where(and(eq(chatVoiceSessions.id, session.id), inArray(chatVoiceSessions.state, ["connecting", "active"])));
continue;
}
if (session.providerSessionId) await closeProvider(session);
else if (session.expiresAt.getTime() + 120_000 < Date.now()) await db.update(chatVoiceSessions).set({ state: "expired", endedAt: new Date(), updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
} catch {
await db.update(chatVoiceSessions).set({ errorCode: "cleanup_pending", updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
}
}
const recovery = voiceSessionRecovery(db, {
authorize: async session => {
await db.transaction(tx => store.authorizeSession(tx, session.companyId, session.id));
},
inspect: async session => {
const endpoint = await endpointFor(session.companyId, session.endpointId);
const credentials = await options.credentials(endpoint);
return session.providerSessionId ? provider(credentials.apiKey).inspectSession(session.providerSessionId) : null;
},
close: closeProvider,
});
function registerRecoveryWork(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
return registerVoiceRecoveryWork(coordinator, recovery, canRun);
}
async function reconcileMaintenance(limit = 25) {
await pushReplies(limit);
await inbound.reconcile(limit);
await reconcileReports(limit);
return rows.length;
}
async function reconcile(limit = 25) {
const due = (await recovery.load()).filter(row => row.dueAt <= Date.now()).sort((a, b) => a.dueAt - b.dueAt || a.id.localeCompare(b.id)).slice(0, limit);
for (const row of due) await recovery.check(row.id).catch(() => undefined);
await reconcileMaintenance(limit);
return due.length;
}
async function pushReplies(limit = 25, companyId?: string, endpointId?: string) {
return pushVoiceReplies(db, {store, credentials: options.credentials, provider, limit, companyId, endpointId});
@@ -355,7 +339,7 @@ export function voiceSessionService(db: Db, options: {
const result = await start({ ...binding, mode: "outbound_phone", phoneNumber: preference.phoneNumber, idempotencyKey: invocationId, maxDurationSeconds: 600 });
return { session: result.session };
});
return { start, inspect, end, tool, reconcile, pushReplies, inbound, notification: store.notification, callbackPreference, saveCallbackPreference, history, callDetail, unregisterAgentTools };
return { start, inspect, end, tool, reconcile, reconcileMaintenance, registerRecoveryWork, pushReplies, inbound, notification: store.notification, callbackPreference, saveCallbackPreference, history, callDetail, unregisterAgentTools };
}
export type VoiceSessionService = ReturnType<typeof voiceSessionService>;
@@ -1,3 +1,4 @@
import { notifyVoiceRecovery } from "./voice-recovery-notifications.js";
import { notifyChatDeliveryWork } from "../chat-work-notifications.js";
import { environmentService } from "../environments.js";
import { logger } from "../../middleware/logger.js";
@@ -192,13 +193,16 @@ export function voiceSessionStore(db: Db, options: { allowLocalBoard: boolean; n
if (!["connecting", "active"].includes(guest.state) || guest.expiresAt.getTime() <= Date.now() || guest.generation !== context.generation || guest.toolTokenHash !== voiceTokenHash(input.toolToken ?? "")) throw forbidden("Guest call is no longer available for approval");
const task = await authorizeTask(tx, input.companyId, binding.issueId, input.caller);
if (task.assigneeAgentId !== context.endpoint.assignedAgentId) throw conflict("The selected agent must be assigned to this task");
await notifyVoiceRecovery(tx, { kind: "session", id: guest.id });
const [promoted] = await tx.update(chatVoiceSessions).set({...binding, callerId: input.caller.id, callerAuthority: input.caller.authority, approvedByUserId: input.caller.id, idempotencyKey: input.idempotencyKey, requestFingerprint: input.requestFingerprint, updatedAt: new Date()}).where(eq(chatVoiceSessions.id, guest.id)).returning();
await tx.insert(activityLog).values({companyId: input.companyId, actorType: "user", actorId: input.caller.id, action: "voice.guest.private_call_approved", entityType: "voice_session", entityId: guest.id, details: {issueId: binding.issueId}});
return {session: promoted, created: false as const};
}
}
const token = input.toolToken ?? randomBytes(32).toString("base64url"), createdAt = new Date();
const [session] = await tx.insert(chatVoiceSessions).values({ id: randomUUID(), companyId: input.companyId, endpointId: input.endpointId, ...binding, assignedAgentId: context.endpoint.assignedAgentId,
const sessionId = randomUUID();
await notifyVoiceRecovery(tx, { kind: "session", id: sessionId });
const [session] = await tx.insert(chatVoiceSessions).values({ id: sessionId, companyId: input.companyId, endpointId: input.endpointId, ...binding, assignedAgentId: context.endpoint.assignedAgentId,
callerId: input.caller.id, callerAuthority: input.caller.authority, mode: input.mode ?? "browser", state: input.providerSessionId ? "connecting" : "reserved", providerSessionId: input.providerSessionId ?? null, approvedByUserId: input.mode === "inbound_phone" ? input.caller.id : null, generation: context.generation, credentialFingerprint: context.fingerprint,
toolTokenHash: voiceTokenHash(token), idempotencyKey: input.idempotencyKey, requestFingerprint: input.requestFingerprint, createdAt, expiresAt: new Date(createdAt.getTime() + input.maxDurationSeconds * 1000) }).returning();
await tx.insert(activityLog).values({ companyId: input.companyId, actorType: "user", actorId: input.caller.id, action: "voice.session.requested", entityType: "voice_session", entityId: session.id, details: { endpointId: input.endpointId, issueId: session.issueId, mode: input.mode ?? "browser" } });
@@ -269,6 +273,7 @@ export function voiceSessionStore(db: Db, options: { allowLocalBoard: boolean; n
await tx.insert(chatVoiceReplies).values({ companyId: session.companyId, sessionId: session.id, publicationId: publication.id, cursor: ++cursor });
}
if (cursor !== session.replyCursor) {
await notifyVoiceRecovery(tx, { kind: "session", id: session.id });
await tx.update(chatVoiceSessions).set({ replyCursor: cursor, updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
session.replyCursor = cursor;
}
@@ -290,6 +295,7 @@ export function voiceSessionStore(db: Db, options: { allowLocalBoard: boolean; n
if (existing) return;
const cursor = session.replyCursor + 1;
await tx.insert(chatVoiceReplies).values({ companyId: session.companyId, sessionId: session.id, publicationId: previous.publicationId, cursor, deliveredAt: new Date() });
await notifyVoiceRecovery(tx, { kind: "session", id: session.id });
await tx.update(chatVoiceSessions).set({ replyCursor: cursor, updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
session.replyCursor = cursor;
}
@@ -327,6 +333,7 @@ export function voiceSessionStore(db: Db, options: { allowLocalBoard: boolean; n
const context = await authorizeSession(tx, input.companyId, input.sessionId), { session } = context;
if (session.endpointId !== input.endpointId || session.providerSessionId !== input.envelope.session_id || !timingSafeEqual(Buffer.from(session.toolTokenHash, "hex"), Buffer.from(voiceTokenHash(input.token), "hex"))) throw forbidden("Voice tool scope does not match this session");
if (session.state === "connecting") {
await notifyVoiceRecovery(tx, { kind: "session", id: session.id });
await tx.update(chatVoiceSessions).set({ state: "active", updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
await tx.insert(activityLog).values({ companyId: session.companyId, actorType: "system", actorId: "voice-session-service", action: "voice.session.connected", entityType: "voice_session", entityId: session.id, details: { endpointId: session.endpointId, issueId: session.issueId } });
}
@@ -463,6 +470,7 @@ export function voiceSessionStore(db: Db, options: { allowLocalBoard: boolean; n
if (priorCall?.blocked) return;
const cursor = session.replyCursor + 1;
await tx.insert(chatVoiceReplies).values({ companyId, sessionId: session.id, publicationId: publication.id, cursor });
await notifyVoiceRecovery(tx, { kind: "session", id: session.id });
await tx.update(chatVoiceSessions).set({ replyCursor: cursor, updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id));
return {sessionId: session.id, providerSessionId: session.providerSessionId, replyCursor: cursor, mode: session.mode};
}).then(queued => {