voicecc 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/voicecc.js CHANGED
@@ -740,6 +740,15 @@ if (!commandExists("claude")) {
740
740
  // Runs on every start but skips pip install if requirements.txt hasn't changed.
741
741
  ensurePythonVenv();
742
742
 
743
+ // Hard check: verify the venv actually exists after setup
744
+ const expectedVenvPython = join(PKG_ROOT, "voice-server", ".venv", "bin", "python");
745
+ if (!existsSync(expectedVenvPython)) {
746
+ console.error(`ERROR: Python venv not found at ${expectedVenvPython}`);
747
+ console.error("The voice-server directory or its venv is missing from the installation.");
748
+ console.error("Try reinstalling: npm install -g voicecc");
749
+ process.exit(1);
750
+ }
751
+
743
752
  // If already running, show info and exit
744
753
  if (isRunning()) {
745
754
  showInfo();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voicecc",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Voice Agent Platform running on Claude Code -- create and deploy conversational voice agents with ElevenLabs STT/TTS and VAD",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "bin/",
26
26
  "server/",
27
+ "voice-server/",
27
28
  "dashboard/dist/",
28
29
  "dashboard/server.ts",
29
30
  "dashboard/routes/",
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,333 @@
1
+ """
2
+ Custom Pipecat LLMService wrapping the Python Claude Agent SDK (ClaudeSDKClient).
3
+
4
+ Uses ClaudeSDKClient for persistent multi-turn voice sessions with full tool use.
5
+ Does NOT use Pipecat's built-in context accumulation -- the Claude session maintains
6
+ its own conversation history internally.
7
+
8
+ Responsibilities:
9
+ - Override process_frame to handle LLM context frames from Pipecat aggregators
10
+ - Extract only the last user message from Pipecat context (SDK tracks history)
11
+ - Clear Pipecat context after each turn to prevent unbounded memory growth
12
+ - Support existing_client for heartbeat session handoff
13
+ - Support initial_prompt for agent-speaks-first flows
14
+ """
15
+
16
+ import asyncio
17
+ import logging
18
+ from dataclasses import dataclass
19
+
20
+ from claude_agent_sdk import (
21
+ AssistantMessage,
22
+ ClaudeAgentOptions,
23
+ ClaudeSDKClient,
24
+ ResultMessage,
25
+ TextBlock,
26
+ ToolUseBlock,
27
+ )
28
+ from pipecat.frames.frames import (
29
+ CancelFrame,
30
+ EndFrame,
31
+ Frame,
32
+ FunctionCallsStartedFrame,
33
+ InterruptionFrame,
34
+ LLMContextFrame,
35
+ LLMFullResponseEndFrame,
36
+ LLMFullResponseStartFrame,
37
+ LLMMessagesFrame,
38
+ LLMTextFrame,
39
+ StartFrame,
40
+ TextFrame,
41
+ )
42
+ from pipecat.processors.aggregators.llm_context import LLMContext
43
+ from pipecat.processors.aggregators.openai_llm_context import (
44
+ OpenAILLMContext,
45
+ OpenAILLMContextFrame,
46
+ )
47
+ from pipecat.processors.frame_processor import FrameDirection
48
+ from pipecat.services.llm_service import LLMService
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # ============================================================================
54
+ # TYPES
55
+ # ============================================================================
56
+
57
+ @dataclass
58
+ class ClaudeLLMServiceConfig:
59
+ """Configuration for ClaudeLLMService.
60
+
61
+ Args:
62
+ cwd: Working directory for the Claude Code session
63
+ system_prompt: System prompt for voice mode
64
+ allowed_tools: Tool allowlist (empty list = all tools allowed)
65
+ initial_prompt: Optional first message so the agent speaks first
66
+ existing_client: Pre-existing ClaudeSDKClient (e.g. from heartbeat handoff)
67
+ """
68
+ cwd: str
69
+ system_prompt: str
70
+ allowed_tools: list[str] | None = None
71
+ initial_prompt: str | None = None
72
+ existing_client: ClaudeSDKClient | None = None
73
+
74
+
75
+ # ============================================================================
76
+ # MAIN HANDLERS
77
+ # ============================================================================
78
+
79
+ class ClaudeLLMService(LLMService):
80
+ """Pipecat LLMService that wraps ClaudeSDKClient for voice conversations.
81
+
82
+ Intercepts LLM context frames from the user aggregator, extracts the last
83
+ user message, sends it to Claude via the SDK, and pushes text frames
84
+ downstream for TTS.
85
+ """
86
+
87
+ def __init__(self, config: ClaudeLLMServiceConfig, **kwargs):
88
+ super().__init__(**kwargs)
89
+ self._config = config
90
+ self._client: ClaudeSDKClient | None = config.existing_client
91
+ self._connected = config.existing_client is not None
92
+ self._initial_prompt_sent = False
93
+ self._processing = False
94
+ self._current_task: asyncio.Task | None = None
95
+
96
+ # Initialize LLMSettings fields — Claude SDK manages these internally,
97
+ # so we set them all to None (unsupported).
98
+ self._settings.model = None
99
+ self._settings.system_instruction = None
100
+ self._settings.temperature = None
101
+ self._settings.max_tokens = None
102
+ self._settings.top_p = None
103
+ self._settings.top_k = None
104
+ self._settings.frequency_penalty = None
105
+ self._settings.presence_penalty = None
106
+ self._settings.seed = None
107
+ self._settings.filter_incomplete_user_turns = None
108
+ self._settings.user_turn_completion_config = None
109
+
110
+ async def start(self, frame: StartFrame):
111
+ """Handle pipeline start. Sends initial_prompt if configured."""
112
+ await super().start(frame)
113
+ if self._config.initial_prompt and not self._initial_prompt_sent:
114
+ self._initial_prompt_sent = True
115
+ await self._ensure_client()
116
+ await self._send_to_claude(self._config.initial_prompt)
117
+
118
+ async def stop(self, frame: EndFrame):
119
+ """Handle pipeline stop. Disconnects the Claude session."""
120
+ await self.close()
121
+ await super().stop(frame)
122
+
123
+ async def cancel(self, frame: CancelFrame):
124
+ """Handle pipeline cancel. Disconnects the Claude session."""
125
+ await self.close()
126
+ await super().cancel(frame)
127
+
128
+ async def process_frame(self, frame: Frame, direction: FrameDirection):
129
+ """Process incoming frames.
130
+
131
+ Handles context frames from Pipecat's aggregators by extracting the last
132
+ user message and sending it to Claude. All other frames pass through.
133
+
134
+ Args:
135
+ frame: The incoming frame
136
+ direction: Frame direction (upstream/downstream)
137
+ """
138
+ await super().process_frame(frame, direction)
139
+
140
+ context = None
141
+ if isinstance(frame, OpenAILLMContextFrame):
142
+ context = frame.context
143
+ elif isinstance(frame, LLMContextFrame):
144
+ context = frame.context
145
+ elif isinstance(frame, LLMMessagesFrame):
146
+ context = OpenAILLMContext.from_messages(frame.messages)
147
+ elif isinstance(frame, InterruptionFrame):
148
+ await self.interrupt()
149
+ await self.push_frame(frame, direction)
150
+ return
151
+ else:
152
+ await self.push_frame(frame, direction)
153
+ return
154
+
155
+ if context:
156
+ # Extract the last user message text from the Pipecat context
157
+ user_text = _extract_last_user_message(context)
158
+ if not user_text:
159
+ logger.warning("[claude-llm] No user message found in context")
160
+ return
161
+
162
+ # Clear Pipecat context to prevent unbounded growth
163
+ # (Claude SDK maintains its own conversation history)
164
+ if isinstance(context, OpenAILLMContext):
165
+ context.set_messages([])
166
+ elif isinstance(context, LLMContext):
167
+ context.messages.clear()
168
+
169
+ # Cancel any in-flight query before starting a new one
170
+ await self._cancel_current_task()
171
+
172
+ await self._ensure_client()
173
+
174
+ async def _run_query():
175
+ try:
176
+ await self.push_frame(LLMFullResponseStartFrame())
177
+ await self.start_processing_metrics()
178
+ await self._send_to_claude(user_text)
179
+ except asyncio.CancelledError:
180
+ logger.info("[claude-llm] Query cancelled by new input")
181
+ except Exception as e:
182
+ logger.error(f"[claude-llm] Error during Claude query: {e}")
183
+ await self.push_error(error_msg=f"Claude query error: {e}", exception=e)
184
+ finally:
185
+ await self.stop_processing_metrics()
186
+ await self.push_frame(LLMFullResponseEndFrame())
187
+
188
+ self._current_task = asyncio.create_task(_run_query())
189
+ await self._current_task
190
+
191
+ async def _cancel_current_task(self) -> None:
192
+ """Cancel the in-flight query task if one is running."""
193
+ if self._current_task and not self._current_task.done():
194
+ self._current_task.cancel()
195
+ try:
196
+ await self._current_task
197
+ except (asyncio.CancelledError, Exception):
198
+ pass
199
+ self._current_task = None
200
+
201
+ async def interrupt(self) -> None:
202
+ """Interrupt the current Claude response and cancel the query task."""
203
+ await self._cancel_current_task()
204
+ if self._client and self._connected:
205
+ try:
206
+ await self._client.interrupt()
207
+ except Exception as e:
208
+ logger.warning(f"[claude-llm] Interrupt error: {e}")
209
+
210
+ async def close(self) -> None:
211
+ """Disconnect the Claude session."""
212
+ if self._client and self._connected:
213
+ try:
214
+ await self._client.disconnect()
215
+ except Exception as e:
216
+ logger.warning(f"[claude-llm] Disconnect error: {e}")
217
+ finally:
218
+ self._connected = False
219
+ self._client = None
220
+
221
+ # ============================================================================
222
+ # HELPER FUNCTIONS
223
+ # ============================================================================
224
+
225
+ async def _ensure_client(self) -> None:
226
+ """Create and connect ClaudeSDKClient if not already connected.
227
+
228
+ Uses existing_client if provided in config, otherwise creates a new one.
229
+ """
230
+ if self._client and self._connected:
231
+ return
232
+
233
+ if not self._client:
234
+ options = ClaudeAgentOptions(
235
+ system_prompt=self._config.system_prompt,
236
+ cwd=self._config.cwd,
237
+ allowed_tools=self._config.allowed_tools or [],
238
+ permission_mode="bypassPermissions",
239
+ include_partial_messages=True,
240
+ max_thinking_tokens=10000,
241
+ )
242
+ self._client = ClaudeSDKClient(options=options)
243
+
244
+ await self._client.connect()
245
+ self._connected = True
246
+ logger.info("[claude-llm] Claude session connected")
247
+
248
+ async def _send_to_claude(self, text: str) -> None:
249
+ """Send a user message to Claude and push response text frames downstream.
250
+
251
+ Iterates over the streaming response, extracting text deltas and tool use
252
+ events. Text is pushed as LLMTextFrame for TTS. Tool starts are pushed as
253
+ FunctionCallsStartedFrame for the narration processor.
254
+
255
+ Args:
256
+ text: The user message to send
257
+ """
258
+ if not self._client:
259
+ raise RuntimeError("Claude client not connected")
260
+
261
+ self._processing = True
262
+ has_streamed = False
263
+
264
+ try:
265
+ await self._client.query(text)
266
+
267
+ async for msg in self._client.receive_response():
268
+ if isinstance(msg, AssistantMessage):
269
+ # Process content blocks from the assistant message
270
+ for block in msg.content:
271
+ if isinstance(block, TextBlock) and block.text:
272
+ if not has_streamed:
273
+ has_streamed = True
274
+ await self.start_ttfb_metrics()
275
+ await self.stop_ttfb_metrics()
276
+ await self.push_frame(LLMTextFrame(block.text))
277
+ elif isinstance(block, ToolUseBlock):
278
+ logger.info(f"[claude-llm] Tool use: {block.name}")
279
+ # Push a text frame announcing tool use for narration
280
+ await self.push_frame(TextFrame(f"__tool_start:{block.name}"))
281
+
282
+ elif isinstance(msg, ResultMessage):
283
+ if msg.is_error:
284
+ logger.error(f"[claude-llm] Result error: {msg.subtype}")
285
+ else:
286
+ logger.info("[claude-llm] Turn complete")
287
+ break
288
+
289
+ finally:
290
+ self._processing = False
291
+
292
+
293
+ def _extract_last_user_message(context: OpenAILLMContext | LLMContext | object) -> str | None:
294
+ """Extract the last user message text from a Pipecat LLM context.
295
+
296
+ The context contains OpenAI-format messages. We find the last message
297
+ with role="user" and extract its text content.
298
+
299
+ Args:
300
+ context: Pipecat LLM context (OpenAILLMContext, LLMContext, or other)
301
+
302
+ Returns:
303
+ The last user message text, or None if no user message found
304
+ """
305
+ if isinstance(context, OpenAILLMContext):
306
+ messages = context.get_messages()
307
+ elif isinstance(context, LLMContext):
308
+ messages = context.messages
309
+ else:
310
+ messages = getattr(context, "messages", [])
311
+
312
+ if not messages:
313
+ return None
314
+
315
+ # Walk backwards to find the last user message
316
+ for msg in reversed(messages):
317
+ msg_dict = msg if isinstance(msg, dict) else vars(msg) if hasattr(msg, "__dict__") else {}
318
+ if msg_dict.get("role") == "user":
319
+ content = msg_dict.get("content", "")
320
+ if isinstance(content, str):
321
+ return content.strip() or None
322
+ # Content might be a list of content blocks
323
+ if isinstance(content, list):
324
+ texts = []
325
+ for block in content:
326
+ if isinstance(block, dict) and block.get("type") == "text":
327
+ texts.append(block.get("text", ""))
328
+ elif isinstance(block, str):
329
+ texts.append(block)
330
+ joined = " ".join(texts).strip()
331
+ return joined or None
332
+
333
+ return None
@@ -0,0 +1,312 @@
1
+ """
2
+ Text chat session manager for the Python voice server.
3
+
4
+ Port of chat-server.ts + claude-session.ts. Manages ClaudeSDKClient lifecycle
5
+ for text chat: lazy creation on first message, multi-turn reuse, inactivity
6
+ cleanup after 10 minutes.
7
+
8
+ Responsibilities:
9
+ - Create and reuse ClaudeSDKClient sessions keyed by device token
10
+ - Stream Claude responses as ChatSseEvent async generators
11
+ - Enforce max concurrent sessions
12
+ - Auto-cleanup inactive sessions on a 60-second timer
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+ import time
18
+ from dataclasses import dataclass, field
19
+
20
+ from claude_agent_sdk import (
21
+ AssistantMessage,
22
+ ClaudeAgentOptions,
23
+ ClaudeSDKClient,
24
+ ResultMessage,
25
+ TextBlock,
26
+ ToolUseBlock,
27
+ )
28
+
29
+ from config import build_system_prompt, load_config, DEFAULT_AGENTS_DIR
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # ============================================================================
34
+ # CONSTANTS
35
+ # ============================================================================
36
+
37
+ INACTIVITY_TIMEOUT_SECONDS = 600 # 10 minutes
38
+ CLEANUP_INTERVAL_SECONDS = 60
39
+
40
+
41
+ # ============================================================================
42
+ # TYPES
43
+ # ============================================================================
44
+
45
+ @dataclass
46
+ class ChatSseEvent:
47
+ """SSE event sent to the client during text chat streaming.
48
+
49
+ Attributes:
50
+ type: Event type ("text_delta", "tool_start", "tool_end", "result", "error")
51
+ content: Text content or error message
52
+ tool_name: Tool name (only for tool_start events)
53
+ """
54
+ type: str
55
+ content: str
56
+ tool_name: str | None = None
57
+
58
+ def to_dict(self) -> dict:
59
+ """Serialize to a JSON-safe dict, omitting None fields."""
60
+ d: dict = {"type": self.type, "content": self.content}
61
+ if self.tool_name is not None:
62
+ d["toolName"] = self.tool_name
63
+ return d
64
+
65
+
66
+ @dataclass
67
+ class ChatSession:
68
+ """Tracks an active text chat session.
69
+
70
+ Attributes:
71
+ session_key: Device token used as the session key
72
+ client: Persistent ClaudeSDKClient for multi-turn chat
73
+ agent_id: Optional agent identifier for agent-specific prompts
74
+ streaming: Whether the session is currently streaming a response
75
+ last_activity: Unix timestamp of last activity (for inactivity timeout)
76
+ """
77
+ session_key: str
78
+ client: ClaudeSDKClient
79
+ agent_id: str | None = None
80
+ streaming: bool = False
81
+ last_activity: float = field(default_factory=time.time)
82
+
83
+
84
+ # ============================================================================
85
+ # STATE
86
+ # ============================================================================
87
+
88
+ _active_sessions: dict[str, ChatSession] = {}
89
+ _cleanup_task: asyncio.Task | None = None
90
+
91
+
92
+ # ============================================================================
93
+ # MAIN HANDLERS
94
+ # ============================================================================
95
+
96
+ async def get_or_create_session(session_key: str, agent_id: str | None = None) -> ChatSession:
97
+ """Get an existing chat session or create a new one.
98
+
99
+ On first call for a session_key, creates a ClaudeSDKClient with the
100
+ appropriate system prompt. Subsequent calls return the existing session.
101
+ Enforces max concurrent sessions from config.
102
+
103
+ Args:
104
+ session_key: Device token to key the session on
105
+ agent_id: Optional agent ID for agent-specific prompts
106
+
107
+ Returns:
108
+ The active ChatSession
109
+
110
+ Raises:
111
+ RuntimeError: If max concurrent sessions exceeded
112
+ """
113
+ existing = _active_sessions.get(session_key)
114
+ if existing:
115
+ existing.last_activity = time.time()
116
+ return existing
117
+
118
+ config = load_config()
119
+ if len(_active_sessions) >= config.max_concurrent_sessions:
120
+ raise RuntimeError(
121
+ f"Max concurrent sessions ({config.max_concurrent_sessions}) reached"
122
+ )
123
+
124
+ system_prompt = build_system_prompt(agent_id, "text")
125
+
126
+ # Determine working directory
127
+ import os
128
+ cwd = config.default_cwd
129
+ if agent_id:
130
+ agent_dir = os.path.join(DEFAULT_AGENTS_DIR, agent_id)
131
+ if os.path.isdir(agent_dir):
132
+ cwd = agent_dir
133
+
134
+ options = ClaudeAgentOptions(
135
+ system_prompt=system_prompt,
136
+ cwd=cwd,
137
+ allowed_tools=[],
138
+ permission_mode="bypassPermissions",
139
+ include_partial_messages=True,
140
+ max_thinking_tokens=10000,
141
+ )
142
+
143
+ client = ClaudeSDKClient(options=options)
144
+ await client.connect()
145
+
146
+ session = ChatSession(
147
+ session_key=session_key,
148
+ client=client,
149
+ agent_id=agent_id,
150
+ )
151
+ _active_sessions[session_key] = session
152
+ logger.info(f"[chat] Session created, key: {session_key}")
153
+
154
+ return session
155
+
156
+
157
+ async def stream_message(session_key: str, text: str):
158
+ """Send a user message and yield SSE events from Claude's response.
159
+
160
+ Guards against concurrent streaming on the same session. Yields
161
+ ChatSseEvent objects for each streaming event from Claude.
162
+
163
+ Args:
164
+ session_key: Device token identifying the session
165
+ text: User message text
166
+
167
+ Yields:
168
+ ChatSseEvent objects for each streaming event
169
+
170
+ Raises:
171
+ RuntimeError: If no active session or already streaming
172
+ """
173
+ session = _active_sessions.get(session_key)
174
+ if not session:
175
+ raise RuntimeError("No active session")
176
+
177
+ if session.streaming:
178
+ raise RuntimeError("ALREADY_STREAMING")
179
+
180
+ session.last_activity = time.time()
181
+ session.streaming = True
182
+
183
+ try:
184
+ await session.client.query(text)
185
+
186
+ async for msg in session.client.receive_response():
187
+ if isinstance(msg, AssistantMessage):
188
+ for block in msg.content:
189
+ if isinstance(block, TextBlock) and block.text:
190
+ yield ChatSseEvent(type="text_delta", content=block.text)
191
+ elif isinstance(block, ToolUseBlock):
192
+ yield ChatSseEvent(
193
+ type="tool_start", content="", tool_name=block.name
194
+ )
195
+
196
+ elif isinstance(msg, ResultMessage):
197
+ if msg.is_error:
198
+ yield ChatSseEvent(
199
+ type="error", content=msg.subtype or "Unknown error"
200
+ )
201
+ break
202
+
203
+ yield ChatSseEvent(type="result", content="")
204
+
205
+ except Exception as e:
206
+ logger.error(f"[chat] Stream error for {session_key}: {e}")
207
+ yield ChatSseEvent(type="error", content=str(e))
208
+
209
+ finally:
210
+ session.streaming = False
211
+ session.last_activity = time.time()
212
+
213
+
214
+ async def close_session(session_key: str) -> None:
215
+ """Close a chat session, disconnecting the Claude client.
216
+
217
+ Args:
218
+ session_key: Device token identifying the session
219
+ """
220
+ session = _active_sessions.pop(session_key, None)
221
+ if not session:
222
+ return
223
+
224
+ try:
225
+ await session.client.disconnect()
226
+ except Exception as e:
227
+ logger.warning(f"[chat] Error disconnecting session {session_key}: {e}")
228
+
229
+ logger.info(f"[chat] Session closed, key: {session_key}")
230
+
231
+
232
+ async def interrupt_session(session_key: str) -> bool:
233
+ """Interrupt the current streaming response for a session.
234
+
235
+ Args:
236
+ session_key: Device token identifying the session
237
+
238
+ Returns:
239
+ True if a streaming session was interrupted, False otherwise
240
+ """
241
+ session = _active_sessions.get(session_key)
242
+ if not session or not session.streaming:
243
+ return False
244
+
245
+ try:
246
+ await session.client.interrupt()
247
+ except Exception as e:
248
+ logger.warning(f"[chat] Interrupt error for {session_key}: {e}")
249
+
250
+ session.streaming = False
251
+ session.last_activity = time.time()
252
+ logger.info(f"[chat] Session interrupted, key: {session_key}")
253
+ return True
254
+
255
+
256
+ def has_session(session_key: str) -> bool:
257
+ """Check if a session exists for the given key.
258
+
259
+ Args:
260
+ session_key: Device token to check
261
+
262
+ Returns:
263
+ True if a session exists
264
+ """
265
+ return session_key in _active_sessions
266
+
267
+
268
+ async def cleanup_inactive() -> None:
269
+ """Close sessions that have been inactive for 10+ minutes.
270
+
271
+ Called on a periodic timer. Safe to call concurrently.
272
+ """
273
+ now = time.time()
274
+ stale_keys = [
275
+ key
276
+ for key, session in _active_sessions.items()
277
+ if now - session.last_activity > INACTIVITY_TIMEOUT_SECONDS
278
+ ]
279
+
280
+ for key in stale_keys:
281
+ logger.info(f"[chat] Session timed out due to inactivity, key: {key}")
282
+ await close_session(key)
283
+
284
+
285
+ # ============================================================================
286
+ # HELPER FUNCTIONS
287
+ # ============================================================================
288
+
289
+ async def _cleanup_loop() -> None:
290
+ """Background loop that runs cleanup_inactive every 60 seconds."""
291
+ while True:
292
+ await asyncio.sleep(CLEANUP_INTERVAL_SECONDS)
293
+ try:
294
+ await cleanup_inactive()
295
+ except Exception as e:
296
+ logger.error(f"[chat] Cleanup error: {e}")
297
+
298
+
299
+ def start_cleanup_timer() -> None:
300
+ """Start the background cleanup timer. Call once at server startup."""
301
+ global _cleanup_task
302
+ if _cleanup_task is None:
303
+ _cleanup_task = asyncio.create_task(_cleanup_loop())
304
+ logger.info("[chat] Inactivity cleanup timer started")
305
+
306
+
307
+ def stop_cleanup_timer() -> None:
308
+ """Stop the background cleanup timer."""
309
+ global _cleanup_task
310
+ if _cleanup_task is not None:
311
+ _cleanup_task.cancel()
312
+ _cleanup_task = None