refactor(background): implement event-driven fire-and-forget tasks - #89
Conversation
- BackgroundTaskManager now launches tasks in ~1ms (fire-and-forget) - Uses session.status events instead of deprecated session.idle - Adds start queue with configurable concurrency limit (default: 10) - Optional notification to parent session on completion - Removes sync mode and legacy polling-based approach - Simplified tool API: background_task, background_output, background_cancel BREAKING CHANGE: Removed sync/async mode, session.create, sendPrompt, pollSession, resolveSessionId, extractResponseText exports
Greptile OverviewGreptile SummaryThis PR refactors Key architectural improvements:
Critical issue found:
Previous review issues addressed:
The refactor significantly improves the architecture by eliminating polling and making task launches truly non-blocking. However, the memory leak must be addressed before merging. Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant BackgroundTool as background_task tool
participant Manager as BackgroundTaskManager
participant Queue as Start Queue
participant Client as OpenCode Client
participant EventSystem as Event System
User->>BackgroundTool: Call background_task(prompt, agent)
BackgroundTool->>Manager: launch(opts)
Note over Manager: Phase A (synchronous)
Manager->>Manager: Create task record<br/>(status: 'pending')
Manager->>Queue: enqueueStart(task)
Manager->>Queue: processQueue()
Manager-->>BackgroundTool: Return task immediately<br/>(~1ms)
BackgroundTool-->>User: task_id
Note over Manager,Queue: Phase B (asynchronous)
Queue->>Manager: startTask(task)<br/>(if activeStarts < maxConcurrent)
Manager->>Manager: Set status = 'starting'<br/>activeStarts++
alt Task cancelled during race
Manager->>Manager: completeTask('cancelled')
Manager->>Manager: activeStarts--<br/>processQueue()
else Normal flow
Manager->>Client: session.create()
Client-->>Manager: sessionId
Manager->>Manager: Set status = 'running'<br/>tasksBySessionId[sessionId] = taskId
Manager->>Client: session.prompt()
Manager->>Manager: activeStarts--<br/>processQueue()
Note over Client: Agent executes task...
Client->>EventSystem: session.status event<br/>(type: 'idle')
EventSystem->>Manager: handleSessionStatus(event)
Manager->>Manager: Check task status === 'running'
Manager->>Client: session.messages()
Client-->>Manager: assistant messages
Manager->>Manager: extractAndCompleteTask()
Manager->>Manager: completeTask('completed', result)
opt notifyOnComplete enabled
Manager->>Client: session.prompt(parentSessionId)<br/>[Notification message]
end
Manager->>Manager: Resolve completionResolvers
end
User->>BackgroundTool: background_output(task_id)
BackgroundTool->>Manager: getResult(task_id)
Manager-->>BackgroundTool: task object
BackgroundTool-->>User: Result or status
|
| task.status = 'cancelled'; | ||
| task.error = 'Cancelled by user'; | ||
| task.completedAt = new Date(); | ||
| this.completeTask(task, 'cancelled', 'Cancelled by user'); |
There was a problem hiding this comment.
Redundant status and completedAt assignment before calling completeTask. The completeTask method already sets these fields on lines 323-324, so setting them here creates duplicate logic.
| task.status = 'cancelled'; | |
| task.error = 'Cancelled by user'; | |
| task.completedAt = new Date(); | |
| this.completeTask(task, 'cancelled', 'Cancelled by user'); | |
| this.completeTask(task, 'cancelled', 'Cancelled by user'); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 438:441
Comment:
Redundant status and completedAt assignment before calling `completeTask`. The `completeTask` method already sets these fields on lines 323-324, so setting them here creates duplicate logic.
```suggestion
this.completeTask(task, 'cancelled', 'Cancelled by user');
```
How can I resolve this? If you propose a fix, please make it concise.| task.status = 'cancelled'; | ||
| task.error = 'Cancelled by user'; | ||
| task.completedAt = new Date(); | ||
| this.completeTask(task, 'cancelled', 'Cancelled by user'); |
There was a problem hiding this comment.
Redundant status and completedAt assignment before calling completeTask (same issue as above).
| task.status = 'cancelled'; | |
| task.error = 'Cancelled by user'; | |
| task.completedAt = new Date(); | |
| this.completeTask(task, 'cancelled', 'Cancelled by user'); | |
| this.completeTask(task, 'cancelled', 'Cancelled by user'); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 462:465
Comment:
Redundant status and completedAt assignment before calling `completeTask` (same issue as above).
```suggestion
this.completeTask(task, 'cancelled', 'Cancelled by user');
```
How can I resolve this? If you propose a fix, please make it concise.
Additional Comments (1)
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/background/tmux-session-manager.ts
Line: 141:157
Comment:
Polling is never initiated - `startPolling()` is defined but never called anywhere in the code. This means the fallback reliability mechanism mentioned in the comments (lines 160-161) won't actually work. Sessions will rely entirely on `session.status` events, and timeout/missing session detection won't occur.
How can I resolve this? If you propose a fix, please make it concise. |
There was a problem hiding this comment.
Pull request overview
This pull request refactors the BackgroundTaskManager to implement true fire-and-forget background tasks with event-driven completion detection.
Changes:
- Removes synchronous execution mode from background tasks, simplifying to fire-and-forget only
- Implements event-driven completion detection using
session.statusevents instead of deprecatedsession.idle - Adds a start queue with configurable concurrency limit (default: 10) to avoid overwhelming the system
- Adds optional notification feature to alert parent sessions when background tasks complete
- Adds new
BackgroundTaskConfigschema for configuration
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tools/background.ts | Simplified tool API - removed sync/async modes, updated to fire-and-forget pattern |
| src/tools/background.test.ts | Removed entire test file (591 lines) - tests moved to manager level |
| src/index.ts | Added event hooks for session.status to enable event-driven completion detection |
| src/config/schema.ts | Added BackgroundTaskConfig schema with notifyOnComplete and maxConcurrentStarts options |
| src/background/tmux-session-manager.ts | Added onSessionStatus handler for event-driven pane cleanup |
| src/background/background-manager.ts | Complete refactor with fire-and-forget launch, start queue, and event-driven completion |
| src/background/background-manager.test.ts | Updated tests to match new fire-and-forget behavior and event-driven approach |
Comments suppressed due to low confidence (1)
src/tools/background.test.ts:1
- The entire test file for the background tools was removed (591 lines), eliminating test coverage for the tool layer including
background_task,background_output, andbackground_canceltools. While theBackgroundTaskManagerhas good test coverage, the tools that wrap it are no longer tested. Consider adding tests to verify the tool argument validation, error handling, and integration with the manager.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| task.status = 'cancelled'; | ||
| task.error = 'Cancelled by user'; | ||
| task.completedAt = new Date(); | ||
| this.completeTask(task, 'cancelled', 'Cancelled by user'); |
There was a problem hiding this comment.
The task status, error, and completedAt are being set manually before calling completeTask, which also sets these same fields. This is redundant and could lead to confusion or maintenance issues. Consider calling completeTask directly without pre-setting these fields, as completeTask already handles setting status, error/result, and completedAt.
| return new Promise((resolve) => { | ||
| const resolver = (t: BackgroundTask) => resolve(t); | ||
| this.completionResolvers.set(taskId, resolver); | ||
|
|
||
| if (timeout > 0) { | ||
| setTimeout(() => { | ||
| this.completionResolvers.delete(taskId); | ||
| resolve(this.tasks.get(taskId) ?? null); | ||
| }, timeout); | ||
| } | ||
| }); |
There was a problem hiding this comment.
When timeout is 0 (no timeout), the completion resolver is added to the map but will never be removed if the task never completes (e.g., if it's stuck or events are missed). This creates a memory leak. Consider adding a default maximum timeout or cleaning up resolvers when tasks are explicitly cancelled or the manager is shut down.
| task.status = 'failed'; | ||
| task.error = error instanceof Error ? error.message : String(error); | ||
| task.completedAt = new Date(); | ||
| this.completeTask(task, 'failed', task.error); |
There was a problem hiding this comment.
The task status, error, and completedAt are being set manually before calling completeTask, which also sets these same fields. This is redundant and could lead to confusion or maintenance issues. Consider calling completeTask directly without pre-setting these fields, as completeTask already handles setting status, error/result, and completedAt.
| task.status = 'failed'; | |
| task.error = error instanceof Error ? error.message : String(error); | |
| task.completedAt = new Date(); | |
| this.completeTask(task, 'failed', task.error); | |
| const errorMessage = | |
| error instanceof Error ? error.message : String(error); | |
| this.completeTask(task, 'failed', errorMessage); |
| /** | ||
| * Create the event handler for the plugin's event hook. | ||
| * Create the event handler for session.created events. | ||
| */ | ||
| createSessionCreatedHandler(): (input: { | ||
| event: SessionEvent; | ||
| }) => Promise<void> { | ||
| return async (input) => { | ||
| await this.onSessionCreated(input.event); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Create the event handler for session.status events. | ||
| */ | ||
| createEventHandler(): (input: { | ||
| event: { type: string; properties?: unknown }; | ||
| createSessionStatusHandler(): (input: { | ||
| event: SessionEvent; | ||
| }) => Promise<void> { | ||
| return async (input) => { | ||
| await this.onSessionCreated(input.event as SessionCreatedEvent); | ||
| await this.onSessionStatus(input.event); | ||
| }; | ||
| } |
There was a problem hiding this comment.
The methods createSessionCreatedHandler and createSessionStatusHandler are not used anywhere in the codebase. They appear to be unnecessary wrappers around onSessionCreated and onSessionStatus methods. Consider removing these unused methods to reduce code complexity and maintenance burden.
| task && | ||
| timeout > 0 && | ||
| task.status !== 'completed' && | ||
| task.status !== 'failed' |
There was a problem hiding this comment.
The condition on line 114-115 checks if the task is not 'completed' or 'failed', but doesn't check for 'cancelled'. This means if a task is cancelled while waiting, the code will still try to wait for it. Consider adding task.status !== 'cancelled' to the condition to avoid unnecessary waiting for cancelled tasks.
| task.status !== 'failed' | |
| task.status !== 'failed' && | |
| task.status !== 'cancelled' |
| @@ -57,6 +67,7 @@ export interface LaunchOptions { | |||
| description: string; // Human-readable task description | |||
| parentSessionId: string; // Parent session ID for task hierarchy | |||
| model?: string; // Optional model override | |||
There was a problem hiding this comment.
The model parameter in LaunchOptions (line 69) is defined but never used. The old implementation supported passing a model override to the prompt query, but this functionality has been removed. Either remove the unused model field from the interface, or add support for it by storing it in the task object and using it in startTask when building the prompt query (line 210).
| model?: string; // Optional model override |
| task.status = 'cancelled'; | ||
| task.error = 'Cancelled by user'; | ||
| task.completedAt = new Date(); | ||
| this.completeTask(task, 'cancelled', 'Cancelled by user'); |
There was a problem hiding this comment.
The task status, error, and completedAt are being set manually before calling completeTask, which also sets these same fields. This is redundant and could lead to confusion or maintenance issues. Consider calling completeTask directly without pre-setting these fields, as completeTask already handles setting status, error/result, and completedAt.
| // Main plugin config | ||
| // Background task configuration |
There was a problem hiding this comment.
The comment "Background task configuration" appears to be misplaced. It should be above the BackgroundTaskConfigSchema definition (line 44) rather than after the "Main plugin config" comment. Consider moving it to line 43 for better clarity.
| import type { PluginConfig } from '../config'; | ||
| import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS } from '../config'; | ||
| import type { BackgroundTaskConfig, PluginConfig } from '../config'; | ||
| import { POLL_INTERVAL_BACKGROUND_MS } from '../config'; |
There was a problem hiding this comment.
Unused import POLL_INTERVAL_BACKGROUND_MS.
| import { POLL_INTERVAL_BACKGROUND_MS } from '../config'; |
- TmuxSessionManager: call startPolling() for fallback reliability - Remove redundant assignments before completeTask() in cancel() - Remove unused model parameter from LaunchOptions - Remove unused POLL_INTERVAL_BACKGROUND_MS import - Remove unused createSessionCreatedHandler/createSessionStatusHandler methods - Fix misplaced comment in schema.ts
|
All review comments have been addressed: Fixed:
Verified: All 23 tests pass. |
| if (task.status === 'completed' || task.status === 'failed') { | ||
| return; // Already completed | ||
| } |
There was a problem hiding this comment.
Guard should also check for cancelled status to prevent duplicate completion handling.
| if (task.status === 'completed' || task.status === 'failed') { | |
| return; // Already completed | |
| } | |
| if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { | |
| return; // Already completed | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 316:318
Comment:
Guard should also check for `cancelled` status to prevent duplicate completion handling.
```suggestion
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
return; // Already completed
}
```
How can I resolve this? If you propose a fix, please make it concise.- completeTask: guard now checks cancelled status - background.ts: waitForCompletion skips cancelled tasks
|
Additional fixes applied in commit 1d84c7c:
Verified: 23 tests pass. |
| // Remove from start queue if pending | ||
| if (task.status === 'pending') { | ||
| const idx = this.startQueue.findIndex((t) => t.id === taskId); | ||
| if (idx >= 0) { | ||
| this.startQueue.splice(idx, 1); | ||
| } | ||
| } |
There was a problem hiding this comment.
Race condition: if task status changes from pending to starting between the status check (line 427-429) and queue removal (line 432-436), the task won't be removed from the queue but will still be cancelled. This could leave a stale reference in startQueue.
Consider checking task.status === 'pending' again after acquiring any necessary locks, or remove from queue unconditionally within the status check block.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 431:437
Comment:
Race condition: if task status changes from `pending` to `starting` between the status check (line 427-429) and queue removal (line 432-436), the task won't be removed from the queue but will still be cancelled. This could leave a stale reference in `startQueue`.
Consider checking `task.status === 'pending'` again after acquiring any necessary locks, or remove from queue unconditionally within the status check block.
How can I resolve this? If you propose a fix, please make it concise.| cancel(taskId?: string): number { | ||
| if (taskId) { | ||
| const task = this.tasks.get(taskId); | ||
| if ( | ||
| task && | ||
| (task.status === 'pending' || | ||
| task.status === 'starting' || | ||
| task.status === 'running') | ||
| ) { | ||
| // Remove from start queue if pending | ||
| if (task.status === 'pending') { | ||
| const idx = this.startQueue.findIndex((t) => t.id === taskId); | ||
| if (idx >= 0) { | ||
| this.startQueue.splice(idx, 1); | ||
| } | ||
| } | ||
|
|
||
| this.completeTask(task, 'cancelled', 'Cancelled by user'); | ||
| return 1; | ||
| } | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
When cancelling a task with status === 'running' (line 429), the background session is not terminated. The session will continue executing until it naturally completes, at which point the completion will be ignored (line 252 checks task.status !== 'running').
Consider calling client.session.delete() or similar to terminate the session and free resources immediately.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 422:443
Comment:
When cancelling a task with `status === 'running'` (line 429), the background session is not terminated. The session will continue executing until it naturally completes, at which point the completion will be ignored (line 252 checks `task.status !== 'running'`).
Consider calling `client.session.delete()` or similar to terminate the session and free resources immediately.
How can I resolve this? If you propose a fix, please make it concise.| if (task.status === 'cancelled') { | ||
| this.completeTask(task, 'cancelled', 'Task cancelled before start'); | ||
| return; | ||
| } |
There was a problem hiding this comment.
The early return here prevents activeStarts from being decremented and processQueue() from being called (which happen in the finally block at line 230-231). This means if a task is cancelled while in the queue but after being shifted from it (line 166), the active start slot is never released.
| if (task.status === 'cancelled') { | |
| this.completeTask(task, 'cancelled', 'Task cancelled before start'); | |
| return; | |
| } | |
| // Check if cancelled before starting | |
| if (task.status === 'cancelled') { | |
| this.activeStarts--; | |
| this.completeTask(task, 'cancelled', 'Task cancelled before start'); | |
| this.processQueue(); | |
| return; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 176:179
Comment:
The early return here prevents `activeStarts` from being decremented and `processQueue()` from being called (which happen in the finally block at line 230-231). This means if a task is cancelled while in the queue but after being shifted from it (line 166), the active start slot is never released.
```suggestion
// Check if cancelled before starting
if (task.status === 'cancelled') {
this.activeStarts--;
this.completeTask(task, 'cancelled', 'Task cancelled before start');
this.processQueue();
return;
}
```
How can I resolve this? If you propose a fix, please make it concise.| task.sessionId = session.data.id; | ||
| this.tasksBySessionId.set(session.data.id, task.id); | ||
| task.status = 'running'; |
There was a problem hiding this comment.
If task is cancelled after session creation but before setting running status, handleSessionStatus won't clean up because it checks task.status !== 'running' (line 252). The tasksBySessionId entry will persist even though the task is cancelled.
Consider also checking for cancelled status in handleSessionStatus or cleaning up tasksBySessionId in completeTask.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 198:200
Comment:
If task is cancelled after session creation but before setting `running` status, `handleSessionStatus` won't clean up because it checks `task.status !== 'running'` (line 252). The `tasksBySessionId` entry will persist even though the task is cancelled.
Consider also checking for cancelled status in `handleSessionStatus` or cleaning up `tasksBySessionId` in `completeTask`.
How can I resolve this? If you propose a fix, please make it concise.| private async sendCompletionNotification( | ||
| task: BackgroundTask, | ||
| ): Promise<void> { | ||
| const message = | ||
| task.status === 'completed' | ||
| ? `[Background task "${task.description}" completed]` | ||
| : `[Background task "${task.description}" failed: ${task.error}]`; | ||
|
|
||
| await this.client.session.prompt({ | ||
| path: { id: task.parentSessionId }, | ||
| body: { | ||
| parts: [{ type: 'text' as const, text: message }], | ||
| }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Notification failure is logged but not surfaced to the user. If session.prompt fails (e.g., parent session no longer exists), the task will be marked complete but the user won't be notified as expected.
Consider storing notification failure status on the task or retrying the notification.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/background/background-manager.ts
Line: 355:369
Comment:
Notification failure is logged but not surfaced to the user. If `session.prompt` fails (e.g., parent session no longer exists), the task will be marked complete but the user won't be notified as expected.
Consider storing notification failure status on the task or retrying the notification.
How can I resolve this? If you propose a fix, please make it concise.- Fix activeStarts leak in startTask early return - Add resolver cleanup to prevent memory leak - Fix race condition in cancel() by marking cancelled first
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
|
Thanks @nghyane good one |
…lvinunreal#89) * refactor(background): implement event-driven fire-and-forget tasks - BackgroundTaskManager now launches tasks in ~1ms (fire-and-forget) - Uses session.status events instead of deprecated session.idle - Adds start queue with configurable concurrency limit (default: 10) - Optional notification to parent session on completion - Removes sync mode and legacy polling-based approach - Simplified tool API: background_task, background_output, background_cancel BREAKING CHANGE: Removed sync/async mode, session.create, sendPrompt, pollSession, resolveSessionId, extractResponseText exports * fix(PR#89): address review comments - TmuxSessionManager: call startPolling() for fallback reliability - Remove redundant assignments before completeTask() in cancel() - Remove unused model parameter from LaunchOptions - Remove unused POLL_INTERVAL_BACKGROUND_MS import - Remove unused createSessionCreatedHandler/createSessionStatusHandler methods - Fix misplaced comment in schema.ts * fix(PR#89): add cancelled status checks - completeTask: guard now checks cancelled status - background.ts: waitForCompletion skips cancelled tasks * fix(PR#89): address remaining review comments - Fix activeStarts leak in startTask early return - Add resolver cleanup to prevent memory leak - Fix race condition in cancel() by marking cancelled first * Update src/background/background-manager.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: “nghyane” <“hoangvananhnghia99@gmail.com”> Co-authored-by: Alvin <alvin@cmngoal.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
…lvinunreal#89) * refactor(background): implement event-driven fire-and-forget tasks - BackgroundTaskManager now launches tasks in ~1ms (fire-and-forget) - Uses session.status events instead of deprecated session.idle - Adds start queue with configurable concurrency limit (default: 10) - Optional notification to parent session on completion - Removes sync mode and legacy polling-based approach - Simplified tool API: background_task, background_output, background_cancel BREAKING CHANGE: Removed sync/async mode, session.create, sendPrompt, pollSession, resolveSessionId, extractResponseText exports * fix(PR#89): address review comments - TmuxSessionManager: call startPolling() for fallback reliability - Remove redundant assignments before completeTask() in cancel() - Remove unused model parameter from LaunchOptions - Remove unused POLL_INTERVAL_BACKGROUND_MS import - Remove unused createSessionCreatedHandler/createSessionStatusHandler methods - Fix misplaced comment in schema.ts * fix(PR#89): add cancelled status checks - completeTask: guard now checks cancelled status - background.ts: waitForCompletion skips cancelled tasks * fix(PR#89): address remaining review comments - Fix activeStarts leak in startTask early return - Add resolver cleanup to prevent memory leak - Fix race condition in cancel() by marking cancelled first * Update src/background/background-manager.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: “nghyane” <“hoangvananhnghia99@gmail.com”> Co-authored-by: Alvin <alvin@cmngoal.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Summary
Refactor BackgroundTaskManager to implement true fire-and-forget background tasks with event-driven completion detection.
Key Changes
session.statusevents instead of deprecatedsession.idleBreaking Changes
sync/asyncmode parameters frombackground_taskcreateSession,sendPrompt,pollSession,resolveSessionId,extractResponseTextpending→starting→runningstates)Files Changed
src/background/background-manager.ts- Full refactorsrc/tools/background.ts- Simplified APIsrc/background/tmux-session-manager.ts- Event-driven cleanupsrc/config/schema.ts- AddedBackgroundTaskConfigSchemasrc/index.ts- Registeredsession.statushooks