background-manager.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. /**
  2. * Background Task Manager
  3. *
  4. * Manages long-running AI agent tasks that execute in separate sessions.
  5. * Background tasks run independently from the main conversation flow, allowing
  6. * the user to continue working while tasks complete asynchronously.
  7. *
  8. * Key features:
  9. * - Fire-and-forget launch (returns task_id immediately)
  10. * - Creates isolated sessions for background work
  11. * - Event-driven completion detection via session.status
  12. * - Start queue with configurable concurrency limit
  13. * - Supports task cancellation and result retrieval
  14. */
  15. import type { PluginInput } from '@opencode-ai/plugin';
  16. import type { BackgroundTaskConfig, PluginConfig } from '../config';
  17. import {
  18. FALLBACK_FAILOVER_TIMEOUT_MS,
  19. SUBAGENT_DELEGATION_RULES,
  20. } from '../config';
  21. import type { TmuxConfig } from '../config/schema';
  22. import {
  23. applyAgentVariant,
  24. createInternalAgentTextPart,
  25. resolveAgentVariant,
  26. } from '../utils';
  27. import { log } from '../utils/logger';
  28. import {
  29. extractSessionResult,
  30. type PromptBody,
  31. parseModelReference,
  32. promptWithTimeout,
  33. } from '../utils/session';
  34. import { SubagentDepthTracker } from './subagent-depth';
  35. type OpencodeClient = PluginInput['client'];
  36. /**
  37. * Represents a background task running in an isolated session.
  38. * Tasks are tracked from creation through completion or failure.
  39. */
  40. export interface BackgroundTask {
  41. id: string; // Unique task identifier (e.g., "bg_abc123")
  42. sessionId?: string; // OpenCode session ID (set when starting)
  43. description: string; // Human-readable task description
  44. agent: string; // Agent name handling the task
  45. status:
  46. | 'pending'
  47. | 'starting'
  48. | 'running'
  49. | 'completed'
  50. | 'failed'
  51. | 'cancelled';
  52. result?: string; // Final output from the agent (when completed)
  53. error?: string; // Error message (when failed)
  54. config: BackgroundTaskConfig; // Task configuration
  55. parentSessionId: string; // Parent session ID for notifications
  56. startedAt: Date; // Task creation timestamp
  57. completedAt?: Date; // Task completion/failure timestamp
  58. prompt: string; // Initial prompt
  59. }
  60. /**
  61. * Options for launching a new background task.
  62. */
  63. export interface LaunchOptions {
  64. agent: string; // Agent to handle the task
  65. prompt: string; // Initial prompt to send to the agent
  66. description: string; // Human-readable task description
  67. parentSessionId: string; // Parent session ID for task hierarchy
  68. }
  69. function generateTaskId(): string {
  70. return `bg_${Math.random().toString(36).substring(2, 10)}`;
  71. }
  72. export class BackgroundTaskManager {
  73. private tasks = new Map<string, BackgroundTask>();
  74. private tasksBySessionId = new Map<string, string>();
  75. // Track which agent type owns each session for delegation permission checks
  76. private agentBySessionId = new Map<string, string>();
  77. private depthTracker: SubagentDepthTracker;
  78. private client: OpencodeClient;
  79. private directory: string;
  80. private tmuxEnabled: boolean;
  81. private config?: PluginConfig;
  82. private backgroundConfig: BackgroundTaskConfig;
  83. // Start queue
  84. private startQueue: BackgroundTask[] = [];
  85. private activeStarts = 0;
  86. private maxConcurrentStarts: number;
  87. // Completion waiting
  88. private completionResolvers = new Map<
  89. string,
  90. (task: BackgroundTask) => void
  91. >();
  92. constructor(
  93. ctx: PluginInput,
  94. tmuxConfig?: TmuxConfig,
  95. config?: PluginConfig,
  96. ) {
  97. this.client = ctx.client;
  98. this.directory = ctx.directory;
  99. this.tmuxEnabled = tmuxConfig?.enabled ?? false;
  100. this.config = config;
  101. this.backgroundConfig = config?.background ?? {
  102. maxConcurrentStarts: 10,
  103. };
  104. this.maxConcurrentStarts = this.backgroundConfig.maxConcurrentStarts;
  105. this.depthTracker = new SubagentDepthTracker();
  106. }
  107. /**
  108. * Look up the delegation rules for an agent type.
  109. * Unknown agent types default to explorer-only access, making it easy
  110. * to add new background agent types without updating SUBAGENT_DELEGATION_RULES.
  111. */
  112. private getSubagentRules(agentName: string): readonly string[] {
  113. return (
  114. SUBAGENT_DELEGATION_RULES[
  115. agentName as keyof typeof SUBAGENT_DELEGATION_RULES
  116. ] ?? ['explorer']
  117. );
  118. }
  119. /**
  120. * Check if a parent session is allowed to delegate to a specific agent type.
  121. * @param parentSessionId - The session ID of the parent
  122. * @param requestedAgent - The agent type being requested
  123. * @returns true if allowed, false if not
  124. */
  125. isAgentAllowed(parentSessionId: string, requestedAgent: string): boolean {
  126. // Untracked sessions are the root orchestrator (created by OpenCode, not by us)
  127. const parentAgentName =
  128. this.agentBySessionId.get(parentSessionId) ?? 'orchestrator';
  129. const allowedSubagents = this.getSubagentRules(parentAgentName);
  130. if (allowedSubagents.length === 0) return false;
  131. return allowedSubagents.includes(requestedAgent);
  132. }
  133. /**
  134. * Get the list of allowed subagents for a parent session.
  135. * @param parentSessionId - The session ID of the parent
  136. * @returns Array of allowed agent names, empty if none
  137. */
  138. getAllowedSubagents(parentSessionId: string): readonly string[] {
  139. // Untracked sessions are the root orchestrator (created by OpenCode, not by us)
  140. const parentAgentName =
  141. this.agentBySessionId.get(parentSessionId) ?? 'orchestrator';
  142. return this.getSubagentRules(parentAgentName);
  143. }
  144. /**
  145. * Launch a new background task (fire-and-forget).
  146. *
  147. * Phase A (sync): Creates task record and returns immediately.
  148. * Phase B (async): Session creation and prompt sending happen in background.
  149. *
  150. * @param opts - Task configuration options
  151. * @returns The created background task with pending status
  152. */
  153. launch(opts: LaunchOptions): BackgroundTask {
  154. const task: BackgroundTask = {
  155. id: generateTaskId(),
  156. sessionId: undefined,
  157. description: opts.description,
  158. agent: opts.agent,
  159. status: 'pending',
  160. startedAt: new Date(),
  161. config: {
  162. maxConcurrentStarts: this.maxConcurrentStarts,
  163. },
  164. parentSessionId: opts.parentSessionId,
  165. prompt: opts.prompt,
  166. };
  167. this.tasks.set(task.id, task);
  168. // Queue task for background start
  169. this.enqueueStart(task);
  170. log(`[background-manager] task launched: ${task.id}`, {
  171. agent: opts.agent,
  172. description: opts.description,
  173. });
  174. return task;
  175. }
  176. /**
  177. * Enqueue task for background start.
  178. */
  179. private enqueueStart(task: BackgroundTask): void {
  180. this.startQueue.push(task);
  181. this.processQueue();
  182. }
  183. /**
  184. * Process start queue with concurrency limit.
  185. */
  186. private processQueue(): void {
  187. while (
  188. this.activeStarts < this.maxConcurrentStarts &&
  189. this.startQueue.length > 0
  190. ) {
  191. const task = this.startQueue.shift();
  192. if (!task) break;
  193. this.startTask(task);
  194. }
  195. }
  196. private resolveFallbackChain(agentName: string): string[] {
  197. const fallback = this.config?.fallback;
  198. const chains = fallback?.chains as
  199. | Record<string, string[] | undefined>
  200. | undefined;
  201. const configuredChain = chains?.[agentName] ?? [];
  202. const primary = this.config?.agents?.[agentName]?.model;
  203. const chain: string[] = [];
  204. const seen = new Set<string>();
  205. // primary may be a string, an array of string|{id,variant?}, or undefined
  206. let primaryIds: string[];
  207. if (Array.isArray(primary)) {
  208. primaryIds = primary.map((m) => (typeof m === 'string' ? m : m.id));
  209. } else if (typeof primary === 'string') {
  210. primaryIds = [primary];
  211. } else {
  212. primaryIds = [];
  213. }
  214. for (const model of [...primaryIds, ...configuredChain]) {
  215. if (!model || seen.has(model)) continue;
  216. seen.add(model);
  217. chain.push(model);
  218. }
  219. return chain;
  220. }
  221. /**
  222. * Calculate tool permissions for a spawned agent based on its own delegation rules.
  223. * Agents that cannot delegate (leaf nodes) get delegation tools disabled entirely,
  224. * preventing models from even seeing tools they can never use.
  225. *
  226. * @param agentName - The agent type being spawned
  227. * @returns Tool permissions object with background_task and task enabled/disabled
  228. */
  229. private calculateToolPermissions(agentName: string): {
  230. background_task: boolean;
  231. task: boolean;
  232. } {
  233. const allowedSubagents = this.getSubagentRules(agentName);
  234. // Leaf agents (no delegation rules) get tools hidden entirely
  235. if (allowedSubagents.length === 0) {
  236. return { background_task: false, task: false };
  237. }
  238. // Agent can delegate - enable the delegation tools
  239. // The restriction of WHICH specific subagents are allowed is enforced
  240. // by the background_task tool via isAgentAllowed()
  241. return { background_task: true, task: true };
  242. }
  243. /**
  244. * Start a task in the background (Phase B).
  245. */
  246. private async startTask(task: BackgroundTask): Promise<void> {
  247. task.status = 'starting';
  248. this.activeStarts++;
  249. // Check if cancelled after incrementing activeStarts (to catch race)
  250. // Use type assertion since cancel() can change status during race condition
  251. if ((task as BackgroundTask & { status: string }).status === 'cancelled') {
  252. this.completeTask(task, 'cancelled', 'Task cancelled before start');
  253. return;
  254. }
  255. try {
  256. // Check subagent spawn depth BEFORE creating session
  257. const parentDepth = this.depthTracker.getDepth(task.parentSessionId);
  258. if (parentDepth + 1 > this.depthTracker.maxDepth) {
  259. log('[background-manager] spawn blocked: max depth exceeded', {
  260. parentSessionId: task.parentSessionId,
  261. parentDepth,
  262. maxDepth: this.depthTracker.maxDepth,
  263. });
  264. this.completeTask(task, 'failed', 'Subagent depth exceeded');
  265. return;
  266. }
  267. // Create session
  268. const session = await this.client.session.create({
  269. body: {
  270. parentID: task.parentSessionId,
  271. title: `Background: ${task.description}`,
  272. },
  273. query: { directory: this.directory },
  274. });
  275. if (!session.data?.id) {
  276. throw new Error('Failed to create background session');
  277. }
  278. task.sessionId = session.data.id;
  279. this.tasksBySessionId.set(session.data.id, task.id);
  280. // Track the agent type for this session for delegation checks
  281. this.agentBySessionId.set(session.data.id, task.agent);
  282. task.status = 'running';
  283. // Register depth after session creation succeeds
  284. this.depthTracker.registerChild(task.parentSessionId, session.data.id);
  285. // Give TmuxSessionManager time to spawn the pane
  286. if (this.tmuxEnabled) {
  287. await new Promise((r) => setTimeout(r, 500));
  288. }
  289. // Calculate tool permissions based on the spawned agent's own delegation rules
  290. const toolPermissions = this.calculateToolPermissions(task.agent);
  291. // Send prompt
  292. const promptQuery: Record<string, string> = { directory: this.directory };
  293. const resolvedVariant = resolveAgentVariant(this.config, task.agent);
  294. const basePromptBody = applyAgentVariant(resolvedVariant, {
  295. agent: task.agent,
  296. tools: toolPermissions,
  297. parts: [{ type: 'text' as const, text: task.prompt }],
  298. } as PromptBody) as unknown as PromptBody;
  299. const fallbackEnabled = this.config?.fallback?.enabled ?? true;
  300. const timeoutMs = fallbackEnabled
  301. ? (this.config?.fallback?.timeoutMs ?? FALLBACK_FAILOVER_TIMEOUT_MS)
  302. : 0; // 0 = no timeout when fallback disabled
  303. const retryDelayMs = this.config?.fallback?.retryDelayMs ?? 500;
  304. const chain = fallbackEnabled
  305. ? this.resolveFallbackChain(task.agent)
  306. : [];
  307. const attemptModels = chain.length > 0 ? chain : [undefined];
  308. const errors: string[] = [];
  309. let succeeded = false;
  310. const sessionId = session.data.id;
  311. const retryOnEmpty = this.config?.fallback?.retry_on_empty ?? true;
  312. for (let i = 0; i < attemptModels.length; i++) {
  313. const model = attemptModels[i];
  314. const modelLabel = model ?? 'default-model';
  315. try {
  316. const body: PromptBody = {
  317. ...basePromptBody,
  318. model: undefined,
  319. };
  320. if (model) {
  321. const ref = parseModelReference(model);
  322. if (!ref) {
  323. throw new Error(`Invalid fallback model format: ${model}`);
  324. }
  325. body.model = ref;
  326. }
  327. if (i > 0) {
  328. log(
  329. `[background-manager] fallback attempt ${i + 1}/${attemptModels.length}: ${modelLabel}`,
  330. { taskId: task.id },
  331. );
  332. }
  333. await promptWithTimeout(
  334. this.client,
  335. {
  336. path: { id: sessionId },
  337. body,
  338. query: promptQuery,
  339. },
  340. timeoutMs,
  341. );
  342. // Detect silent empty responses (e.g. provider rate-limited
  343. // without error). When retry_on_empty is enabled (default),
  344. // treat as failure so the fallback chain continues.
  345. const extraction = await extractSessionResult(this.client, sessionId);
  346. if (retryOnEmpty && extraction.empty) {
  347. throw new Error('Empty response from provider');
  348. }
  349. succeeded = true;
  350. break;
  351. } catch (error) {
  352. const msg = error instanceof Error ? error.message : String(error);
  353. errors.push(`${modelLabel}: ${msg}`);
  354. log(`[background-manager] model failed: ${modelLabel} — ${msg}`, {
  355. taskId: task.id,
  356. });
  357. // Abort the session before trying the next model.
  358. // The previous prompt may still be running server-side;
  359. // without aborting, the session stays busy and rejects
  360. // subsequent prompts, breaking the entire fallback chain.
  361. if (i < attemptModels.length - 1) {
  362. try {
  363. await this.client.session.abort({
  364. path: { id: sessionId },
  365. });
  366. // Allow server time to finalize the abort before
  367. // the next prompt attempt (matches reference impl).
  368. await new Promise((r) => setTimeout(r, retryDelayMs));
  369. } catch {
  370. // Session may already be idle; safe to ignore.
  371. }
  372. }
  373. }
  374. }
  375. if (!succeeded) {
  376. throw new Error(`All fallback models failed. ${errors.join(' | ')}`);
  377. }
  378. log(`[background-manager] task started: ${task.id}`, {
  379. sessionId: session.data.id,
  380. });
  381. } catch (error) {
  382. const errorMessage =
  383. error instanceof Error ? error.message : String(error);
  384. this.completeTask(task, 'failed', errorMessage);
  385. } finally {
  386. this.activeStarts--;
  387. this.processQueue();
  388. }
  389. }
  390. /**
  391. * Handle session.status events for completion detection.
  392. * Uses session.status instead of deprecated session.idle.
  393. */
  394. async handleSessionStatus(event: {
  395. type: string;
  396. properties?: { sessionID?: string; status?: { type: string } };
  397. }): Promise<void> {
  398. if (event.type !== 'session.status') return;
  399. const sessionId = event.properties?.sessionID;
  400. if (!sessionId) return;
  401. const taskId = this.tasksBySessionId.get(sessionId);
  402. if (!taskId) return;
  403. const task = this.tasks.get(taskId);
  404. if (!task || task.status !== 'running') return;
  405. // Check if session is idle (completed)
  406. if (event.properties?.status?.type === 'idle') {
  407. await this.extractAndCompleteTask(task);
  408. }
  409. }
  410. /**
  411. * Handle session.deleted events for cleanup.
  412. * When a session is deleted, cancel associated tasks and clean up.
  413. */
  414. async handleSessionDeleted(event: {
  415. type: string;
  416. properties?: { info?: { id?: string }; sessionID?: string };
  417. }): Promise<void> {
  418. if (event.type !== 'session.deleted') return;
  419. const sessionId = event.properties?.info?.id ?? event.properties?.sessionID;
  420. if (!sessionId) return;
  421. const taskId = this.tasksBySessionId.get(sessionId);
  422. if (!taskId) return;
  423. const task = this.tasks.get(taskId);
  424. if (!task) return;
  425. // Only handle if task is still active
  426. if (task.status === 'running' || task.status === 'pending') {
  427. log(`[background-manager] Session deleted, cancelling task: ${task.id}`);
  428. // Mark as cancelled
  429. (task as BackgroundTask & { status: string }).status = 'cancelled';
  430. task.completedAt = new Date();
  431. task.error = 'Session deleted';
  432. // Clean up session tracking
  433. this.tasksBySessionId.delete(sessionId);
  434. this.agentBySessionId.delete(sessionId);
  435. this.depthTracker.cleanup(sessionId);
  436. // Resolve any waiting callers
  437. const resolver = this.completionResolvers.get(taskId);
  438. if (resolver) {
  439. resolver(task);
  440. this.completionResolvers.delete(taskId);
  441. }
  442. log(
  443. `[background-manager] Task cancelled due to session deletion: ${task.id}`,
  444. );
  445. }
  446. }
  447. /**
  448. * Extract task result and mark complete.
  449. * When retry_on_empty is enabled (default), empty responses are
  450. * treated as failures so the fallback chain can retry.
  451. * When disabled, empty responses succeed with an empty string result.
  452. */
  453. private async extractAndCompleteTask(task: BackgroundTask): Promise<void> {
  454. if (!task.sessionId) return;
  455. const retryOnEmpty = this.config?.fallback?.retry_on_empty ?? true;
  456. try {
  457. const extraction = await extractSessionResult(
  458. this.client,
  459. task.sessionId,
  460. );
  461. if (extraction.empty && retryOnEmpty) {
  462. this.completeTask(task, 'failed', 'Empty response from provider');
  463. } else {
  464. this.completeTask(task, 'completed', extraction.text);
  465. }
  466. } catch (error) {
  467. this.completeTask(
  468. task,
  469. 'failed',
  470. error instanceof Error ? error.message : String(error),
  471. );
  472. }
  473. }
  474. /**
  475. * Complete a task and notify waiting callers.
  476. */
  477. private completeTask(
  478. task: BackgroundTask,
  479. status: 'completed' | 'failed' | 'cancelled',
  480. resultOrError: string,
  481. ): void {
  482. // Don't check for 'cancelled' here - cancel() may set status before calling
  483. if (task.status === 'completed' || task.status === 'failed') {
  484. return; // Already completed
  485. }
  486. task.status = status;
  487. task.completedAt = new Date();
  488. if (status === 'completed') {
  489. task.result = resultOrError;
  490. } else {
  491. task.error = resultOrError;
  492. }
  493. // Clean up session tracking maps as fallback
  494. // (handleSessionDeleted also does this when session.deleted event fires)
  495. if (task.sessionId) {
  496. this.tasksBySessionId.delete(task.sessionId);
  497. this.agentBySessionId.delete(task.sessionId);
  498. }
  499. // Abort session to trigger pane cleanup and free resources
  500. if (task.sessionId) {
  501. this.client.session
  502. .abort({
  503. path: { id: task.sessionId },
  504. })
  505. .catch(() => {});
  506. }
  507. // Send notification to parent session
  508. if (task.parentSessionId) {
  509. this.sendCompletionNotification(task).catch((err) => {
  510. log(`[background-manager] notification failed: ${err}`);
  511. });
  512. }
  513. // Resolve waiting callers
  514. const resolver = this.completionResolvers.get(task.id);
  515. if (resolver) {
  516. resolver(task);
  517. this.completionResolvers.delete(task.id);
  518. }
  519. log(`[background-manager] task ${status}: ${task.id}`, {
  520. description: task.description,
  521. });
  522. }
  523. /**
  524. * Send completion notification to parent session.
  525. */
  526. private async sendCompletionNotification(
  527. task: BackgroundTask,
  528. ): Promise<void> {
  529. const message =
  530. task.status === 'completed'
  531. ? `[Background task "${task.description}" completed]`
  532. : `[Background task "${task.description}" failed: ${task.error}]`;
  533. await this.client.session.prompt({
  534. path: { id: task.parentSessionId },
  535. body: {
  536. parts: [createInternalAgentTextPart(message)],
  537. },
  538. });
  539. }
  540. /**
  541. * Retrieve the current state of a background task.
  542. *
  543. * @param taskId - The task ID to retrieve
  544. * @returns The task object, or null if not found
  545. */
  546. getResult(taskId: string): BackgroundTask | null {
  547. return this.tasks.get(taskId) ?? null;
  548. }
  549. /**
  550. * Wait for a task to complete.
  551. *
  552. * @param taskId - The task ID to wait for
  553. * @param timeout - Maximum time to wait in milliseconds (0 = no timeout)
  554. * @returns The completed task, or null if not found/timeout
  555. */
  556. async waitForCompletion(
  557. taskId: string,
  558. timeout = 0,
  559. ): Promise<BackgroundTask | null> {
  560. const task = this.tasks.get(taskId);
  561. if (!task) return null;
  562. if (
  563. task.status === 'completed' ||
  564. task.status === 'failed' ||
  565. task.status === 'cancelled'
  566. ) {
  567. return task;
  568. }
  569. return new Promise((resolve) => {
  570. const resolver = (t: BackgroundTask) => resolve(t);
  571. this.completionResolvers.set(taskId, resolver);
  572. if (timeout > 0) {
  573. setTimeout(() => {
  574. this.completionResolvers.delete(taskId);
  575. resolve(this.tasks.get(taskId) ?? null);
  576. }, timeout);
  577. }
  578. });
  579. }
  580. /**
  581. * Cancel one or all running background tasks.
  582. *
  583. * @param taskId - Optional task ID to cancel. If omitted, cancels all pending/running tasks.
  584. * @returns Number of tasks cancelled
  585. */
  586. cancel(taskId?: string): number {
  587. if (taskId) {
  588. const task = this.tasks.get(taskId);
  589. if (
  590. task &&
  591. (task.status === 'pending' ||
  592. task.status === 'starting' ||
  593. task.status === 'running')
  594. ) {
  595. // Clean up any waiting resolver
  596. this.completionResolvers.delete(taskId);
  597. // Check if in start queue (must check before marking cancelled)
  598. const inStartQueue = task.status === 'pending';
  599. // Mark as cancelled FIRST to prevent race with startTask
  600. // Use type assertion since we're deliberately changing status before completeTask
  601. (task as BackgroundTask & { status: string }).status = 'cancelled';
  602. // Remove from start queue if pending
  603. if (inStartQueue) {
  604. const idx = this.startQueue.findIndex((t) => t.id === taskId);
  605. if (idx >= 0) {
  606. this.startQueue.splice(idx, 1);
  607. }
  608. }
  609. this.completeTask(task, 'cancelled', 'Cancelled by user');
  610. return 1;
  611. }
  612. return 0;
  613. }
  614. let count = 0;
  615. for (const task of this.tasks.values()) {
  616. if (
  617. task.status === 'pending' ||
  618. task.status === 'starting' ||
  619. task.status === 'running'
  620. ) {
  621. // Clean up any waiting resolver
  622. this.completionResolvers.delete(task.id);
  623. // Check if in start queue (must check before marking cancelled)
  624. const inStartQueue = task.status === 'pending';
  625. // Mark as cancelled FIRST to prevent race with startTask
  626. // Use type assertion since we're deliberately changing status before completeTask
  627. (task as BackgroundTask & { status: string }).status = 'cancelled';
  628. // Remove from start queue if pending
  629. if (inStartQueue) {
  630. const idx = this.startQueue.findIndex((t) => t.id === task.id);
  631. if (idx >= 0) {
  632. this.startQueue.splice(idx, 1);
  633. }
  634. }
  635. this.completeTask(task, 'cancelled', 'Cancelled by user');
  636. count++;
  637. }
  638. }
  639. return count;
  640. }
  641. /**
  642. * Clean up all tasks.
  643. */
  644. cleanup(): void {
  645. this.startQueue = [];
  646. this.completionResolvers.clear();
  647. this.tasks.clear();
  648. this.tasksBySessionId.clear();
  649. this.agentBySessionId.clear();
  650. this.depthTracker.cleanupAll();
  651. }
  652. /**
  653. * Get the depth tracker instance for use by other managers.
  654. */
  655. getDepthTracker(): SubagentDepthTracker {
  656. return this.depthTracker;
  657. }
  658. }