Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-otters-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Fix AgentSession shutdown when its active activity changes during asynchronous cleanup.
24 changes: 14 additions & 10 deletions agents/src/voice/agent_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1281,15 +1281,18 @@ export class AgentSession<
}
}

if (this.closing && newActivity === 'start') {
if (this.closing) {
this.logger.warn(
{ agentId: this.nextActivity?.agent.id },
'Session is closing, skipping start of next activity',
{ agentId: this.nextActivity?.agent.id, transition: newActivity },
'Session is closing, skipping activity transition',
);
if (reusableResources) {
await cleanupReusableResources(reusableResources, this.logger);
reusableResources = undefined;
}
if (newActivity === 'resume') {
await this.nextActivity?.close();
Comment on lines +1293 to +1294

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid closing the paused activity from its own task

When an AgentTask completes after shutdown has set closing, this resume transition runs from the task that is still registered in the paused activity's speechTasks. Awaiting that activity's close() here makes AgentActivity.close() cancel and wait for the calling task itself; the task cannot finish until _updateActivity() returns, so shutdown stalls for the five-second REPLY_TASK_CANCEL_TIMEOUT (and may exceed the host's shutdown grace period). Defer this close until the caller has unwound, or exclude the current task from the close wait.

Useful? React with 👍 / 👎.

}
this.nextActivity = undefined;
this.activity = undefined;
return;
Expand Down Expand Up @@ -1704,25 +1707,26 @@ export class AgentSession<
this._onAecWarmupExpired();
this.off(AgentSessionEventTypes.UserInputTranscribed, this._onUserInputTranscribed);

if (this.activity) {
const activity = this.activity;
if (activity) {
if (!drain) {
try {
await this.activity.interrupt({ force: true }).await;
await activity.interrupt({ force: true }).await;
} catch (error) {
this.logger.warn({ error }, 'Error interrupting activity');
}
}

await this.activity.drain();
await activity.drain();
// wait any uninterruptible speech to finish
await this.activity.currentSpeech?.waitForPlayout();
await activity.currentSpeech?.waitForPlayout();

if (reason !== CloseReason.ERROR) {
this.activity.commitUserTurn({ audioDetached: true, throwIfNotReady: false });
activity.commitUserTurn({ audioDetached: true, throwIfNotReady: false });
}

try {
this.activity.detachAudioInput();
activity.detachAudioInput();
} catch (error) {
// Ignore detach errors during cleanup - source may not have been set
}
Expand All @@ -1738,7 +1742,7 @@ export class AgentSession<
this.output.audio = null;
this.output.transcription = null;

await this.activity?.close();
await activity?.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Close an activity resumed during shutdown

When an AgentTask completes while shutdown is awaiting cleanup, agent.ts:761-765 can call _updateActivity(..., { newActivity: 'resume' }); the closing guard at agent_session.ts:1284 blocks only start, so this transition can install and start the resumed activity. This line then closes only the earlier captured activity, and the following assignment clears the reference to the live resumed activity without closing it, allowing model/audio pipelines and background tasks to survive the session's Close event. Shutdown must either prevent resume transitions or also close any replacement activity.

Useful? React with 👍 / 👎.

this.activity = undefined;

const sessionToolsets = this._toolCtx.toolsets;
Expand Down
49 changes: 49 additions & 0 deletions agents/src/voice/agent_session_close_race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it, vi } from 'vitest';
import { initializeLogger } from '../log.js';
import { Agent } from './agent.js';
import type { AgentActivity } from './agent_activity.js';
import { AgentSession } from './agent_session.js';
import { AgentSessionEventTypes } from './events.js';

describe('AgentSession close activity race', () => {
initializeLogger({ pretty: false, level: 'silent' });

it('finishes closing the captured activity when the session activity changes during drain', async () => {
const session = new AgentSession();
await session.start({ agent: new Agent({ instructions: 'test' }) });

const internals = session as unknown as { activity?: AgentActivity };
const activity = internals.activity!;

let releaseDrain!: () => void;
const drainGate = new Promise<void>((resolve) => {
releaseDrain = resolve;
});
let markDrainStarted!: () => void;
const drainStarted = new Promise<void>((resolve) => {
markDrainStarted = resolve;
});

vi.spyOn(activity, 'drain').mockImplementation(async () => {
markDrainStarted();
await drainGate;
return undefined;
});
const activityClose = vi.spyOn(activity, 'close');
const onClose = vi.fn();
session.on(AgentSessionEventTypes.Close, onClose);

const closePromise = session.close();
await drainStarted;

internals.activity = undefined;
releaseDrain();

await expect(closePromise).resolves.toBeUndefined();
expect(activityClose).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
});
41 changes: 41 additions & 0 deletions agents/src/voice/agent_session_handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,45 @@ describe('AgentSession reusable resources handoff', () => {
startSpy.mockRestore();
}
});

it('skips resuming a paused activity while the session is closing and closes it', async () => {
const closeFn = vi.fn(async () => {});
const resources: ReusableResources = {
sttPipeline: { close: closeFn } as any,
};
const taskAgent = new Agent({ instructions: 'task' });
const previousActivity = {
agent: taskAgent,
drain: vi.fn(async () => resources),
close: vi.fn(async () => {}),
pause: vi.fn(async () => resources),
};
const resumedAgent = new Agent({ instructions: 'resumed' });
const resumedActivity = {
agent: resumedAgent,
resume: vi.fn(async () => {}),
start: vi.fn(async () => {}),
close: vi.fn(async () => {}),
attachAudioInput: vi.fn(),
_onEnterTask: undefined,
};
resumedAgent._agentActivity = resumedActivity as any;

const session = createFakeSession();
(session as any).activity = previousActivity as any;
(session as any).closing = true;

await AgentSession.prototype._updateActivity.call(session, resumedAgent, {
newActivity: 'resume',
waitOnEnter: false,
});

expect(previousActivity.drain).toHaveBeenCalledTimes(1);
expect(previousActivity.close).toHaveBeenCalledTimes(1);
expect(closeFn).toHaveBeenCalledTimes(1);
expect(resumedActivity.resume).not.toHaveBeenCalled();
expect(resumedActivity.close).toHaveBeenCalledTimes(1);
expect((session as any).activity).toBeUndefined();
expect((session as any).nextActivity).toBeUndefined();
});
});