mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
fix(server): fence late voice binding during expiry recovery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f4b99ec0fb
commit
8f5e861dbd
5 files changed
+202
-7
No files matched your search
@@ -0,0 +1,97 @@
|
|||||||
|
# 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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Failure and lifecycle behavior
|
||||||
|
|
||||||
|
Refreshes coalesce by scope, with a full refresh covering already pending narrower
|
||||||
|
hints. 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).
|
||||||
@@ -141,3 +141,26 @@ Outstanding before publication: complete producer/authorization-change audit,
|
|||||||
current-state races, cross-company fairness and failed-scope coverage, affected
|
current-state races, cross-company fairness and failed-scope coverage, affected
|
||||||
shared producer consumers, full workspace verification and remote gates. The
|
shared producer consumers, full workspace verification and remote gates. The
|
||||||
other four voice duties and every remaining original stack item remain required.
|
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.
|
||||||
@@ -19,6 +19,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||||
let enabled = true;
|
let enabled = true;
|
||||||
const errors: unknown[] = [];
|
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 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) => {
|
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)));
|
await withVoiceSessionChange(db, session.id, tx => tx.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date(), updatedAt: new Date() }).where(eq(chatVoiceSessions.id, session.id)));
|
||||||
@@ -28,10 +29,10 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
db = createDb(external ?? temporary!.connectionString, { idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
db = createDb(external ?? temporary!.connectionString, { idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
||||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
beforeEach(async () => {
|
async function fixture() {
|
||||||
companyId = randomUUID(); endpointId = randomUUID(); agentId = randomUUID(); issueId = randomUUID(); conversationId = randomUUID();
|
const companyId = randomUUID(), endpointId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), conversationId = randomUUID();
|
||||||
|
fixtureCompanies.push(companyId);
|
||||||
const applicationId = randomUUID(), connectionId = randomUUID();
|
const applicationId = randomUUID(), connectionId = randomUUID();
|
||||||
enabled = true; errors.length = 0; authorize.mockClear(); inspect.mockReset().mockResolvedValue({ endedAt: true }); close.mockClear();
|
|
||||||
await db.insert(companies).values({ id: companyId, name: "Voice recovery", issuePrefix: `V${companyId.slice(0, 7)}` });
|
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(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(issues).values({ id: issueId, companyId, title: "Voice", assigneeAgentId: agentId, status: "in_progress" });
|
||||||
@@ -39,16 +40,23 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Voice", uid: connectionId, transport: "voice", connectionPurpose: "channel", enabled: true, status: "active" });
|
await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Voice", uid: connectionId, transport: "voice", connectionPurpose: "channel", 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(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 });
|
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 });
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||||
});
|
});
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await coordinator?.stop(); coordinator = undefined; vi.useRealTimers(); vi.restoreAllMocks();
|
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(chatVoiceSessions).where(eq(chatVoiceSessions.companyId, companyId));
|
||||||
await db.delete(chatConversations).where(eq(chatConversations.companyId, companyId));
|
await db.delete(chatConversations).where(eq(chatConversations.companyId, companyId));
|
||||||
await db.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId));
|
await db.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId));
|
||||||
await db.delete(issues).where(eq(issues.companyId, companyId));
|
await db.delete(issues).where(eq(issues.companyId, companyId));
|
||||||
await db.delete(agents).where(eq(agents.companyId, companyId));
|
await db.delete(agents).where(eq(agents.companyId, companyId));
|
||||||
await db.delete(companies).where(eq(companies.id, companyId));
|
await db.delete(companies).where(eq(companies.id, companyId));
|
||||||
|
}
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||||
@@ -127,6 +135,42 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
broken = false; await settled(); errors.length = 0; await quiet();
|
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("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.load;
|
||||||
|
await start(service); service.load = 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.load = 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 () => {
|
it("joins admitted provider inspection at shutdown", async () => {
|
||||||
await start(); await settled();
|
await start(); await settled();
|
||||||
let finish!: () => void; const gate = new Promise<void>(resolve => { finish = resolve; });
|
let finish!: () => void; const gate = new Promise<void>(resolve => { finish = resolve; });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toolAccessService } from "../services/tool-access.js";
|
||||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||||
@@ -71,7 +72,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
return { companyId, endpointId, agentId, connectionId, issueId, conversationId, callerId, sessionId, store, request, reserved, accept, toolInput, publication };
|
return { companyId, endpointId, agentId, connectionId, issueId, conversationId, callerId, sessionId, store, request, reserved, accept, toolInput, publication };
|
||||||
}
|
}
|
||||||
|
|
||||||
it.each(["hangup", "revocation", "feature disable"] as const)("wakes live recovery from a real start and settles after %s", async reason => {
|
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 });
|
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||||
const f = await nativeServiceFixture();
|
const f = await nativeServiceFixture();
|
||||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||||
@@ -96,7 +97,14 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
if (reason === "hangup") f.transport.inspectSession.mockResolvedValue({ status: "ended", endedAt: new Date().toISOString() });
|
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 === "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 === "feature disable") await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false });
|
||||||
else await vi.advanceTimersByTimeAsync(31_000);
|
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);
|
await settle(true);
|
||||||
const [row] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, started.session.id));
|
const [row] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, started.session.id));
|
||||||
expect(row.state).toBe("ended");
|
expect(row.state).toBe("ended");
|
||||||
@@ -128,6 +136,29 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
|||||||
} finally { await coordinator.stop(); f.service.unregisterAgentTools(); vi.useRealTimers(); }
|
} 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 () => {
|
it("reports signing-secret presence for interrupted setup without returning credential refs", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const chat = chatChannelService(db, {heartbeat: {wakeup: vi.fn()}});
|
const chat = chatChannelService(db, {heartbeat: {wakeup: vi.fn()}});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, gt, inArray, sql } from "drizzle-orm";
|
import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { chatEndpoints, chatVoiceSessions, toolConnections, type Db } from "@paperclipai/db";
|
import { chatEndpoints, chatVoiceSessions, toolConnections, type Db } from "@paperclipai/db";
|
||||||
import { HttpError } from "../../errors.js";
|
import { HttpError } from "../../errors.js";
|
||||||
import { instanceSettingsService } from "../instance-settings.js";
|
import { instanceSettingsService } from "../instance-settings.js";
|
||||||
@@ -75,7 +75,7 @@ export function voiceSessionRecovery(db: Db, operations: {
|
|||||||
} else if (session.providerSessionId) await operations.close(session);
|
} else if (session.providerSessionId) await operations.close(session);
|
||||||
else if (session.expiresAt.getTime() + 120_000 < Date.now()) {
|
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() })
|
await withVoiceSessionChange(db, id, tx => tx.update(chatVoiceSessions).set({ state: "expired", endedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(and(eq(chatVoiceSessions.id, id), inArray(chatVoiceSessions.state, [...LIVE_VOICE_STATES]))));
|
.where(and(eq(chatVoiceSessions.id, id), isNull(chatVoiceSessions.providerSessionId), inArray(chatVoiceSessions.state, [...LIVE_VOICE_STATES]))));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await withVoiceSessionChange(db, id, tx => tx.update(chatVoiceSessions).set({ errorCode: "cleanup_pending", updatedAt: new Date() })
|
await withVoiceSessionChange(db, id, tx => tx.update(chatVoiceSessions).set({ errorCode: "cleanup_pending", updatedAt: new Date() })
|
||||||
|
|||||||
Reference in new issue
Block a user