Tools: Background Tasks: The One Actor in the Codebase and the SIGTERM Bug That Only Broke on Linux

Tools: Background Tasks: The One Actor in the Codebase and the SIGTERM Bug That Only Broke on Linux

Why an actor — and only one

Job lifecycle: dispatch, execute, notify

Notification injection: bridging background to model

The Linux SIGTERM saga

Taking it for a spin

The capstone: 14 tools, one loop Our agent can plan multi-step work with a persistent task DAG, compress its own memory, delegate to subagents, and load skills on demand — all driven by the same agent loop from the first guide. But every tool call still blocks. When the model calls bash to run a test suite that takes two minutes, the loop sits idle, waiting for the process to finish before it can do anything else. If someone asks "run the tests and while that's going, create the config file," the agent does them sequentially — tests first, config second. For fast commands this doesn't matter. For builds, installs, and test suites, it's a real bottleneck. The fix is a background execution layer: a way to hand a slow command to a worker, get a job ID back immediately, and keep the loop moving. When the command finishes, its result goes into a notification queue. Before each API call, the loop drains that queue and injects the results as messages — so the model sees them on its next turn without ever having blocked. The loop stays synchronous; only the subprocess I/O runs in parallel. In this guide, let's build BackgroundManager — the first and only actor in the entire codebase — and wire it into the agent loop with a notification injection pattern that keeps background results flowing into the model's context. The complete source code for this stage is available at the 08-background-tasks tag on GitHub. Code blocks below show key excerpts. Every other manager in our codebase — TodoManager, TaskManager, SkillLoader, ContextCompactor — is accessed exclusively from the agent loop's sequential flow. The loop calls a tool handler, the handler calls the manager, the manager returns, the loop continues. There's never a moment where two pieces of code touch the same state simultaneously. BackgroundManager breaks that pattern. When the model calls background_run, the manager spawns a Task {} that runs the shell command asynchronously. That task might finish thirty seconds later — while the main loop is in the middle of an API call, processing other tool results, or even draining the notification queue. The task writes to jobs and notifications; the main loop reads from them. Two isolated execution contexts mutating the same dictionaries. That's a textbook data race, and it's exactly what Swift's actor keyword exists to prevent: The actor keyword means every access to jobs, notifications, and runningTasks is serialized by the compiler. No locks, no dispatch queues — the concurrency safety is structural. And because ShellExecutor is a Sendable struct with only a let stored property, it can be safely captured by the actor without any bridging. The supporting types are straightforward value types. BackgroundJob tracks a command's lifecycle — its ID, preview text, status, and eventual result: BackgroundNotification is the message format that flows from the background into the agent loop — a snapshot of what happened: Both are Sendable and cross the actor isolation boundary cleanly. Let's walk through what happens when the model calls background_run. The run() method creates a job record, spawns a Task {} to execute the command, and returns immediately with a confirmation string: One thing to keep in mind here is that the Task {} inside the actor inherits the actor's isolation — self.executor, self.complete(), and self.runningTasks are all accessible directly. Actors don't support [weak self] captures (and don't need them — the actor's lifetime is managed by its owner, not by individual tasks). The task calls self.complete() when the shell command finishes, which updates the job status and enqueues a notification: The notification carries a truncated preview of the output — enough for the model to understand what happened without flooding the context. The full result is stored in jobs[jobId]?.result for retrieval via background_check. Draining the queue is a single atomic operation — read everything, then clear: Because this runs inside the actor, the read-and-clear is serialized with respect to complete(). A notification can never be half-written when we drain, and it can never be lost between the read and the clear. The background manager accumulates results, but the model can't see them until they're injected into the messages array. That injection happens in drainBackgroundNotifications, which runs in the agent loop before each API call: The <background-results> XML wrapper gives the model a clear signal that these are asynchronous completions, not user input. The if let lastMessage check handles the API's alternation requirement — if the last message is already a user message (which it is after tool results are appended), the background results get appended to that message's content rather than creating a consecutive user turn. A synthetic assistant acknowledgment follows so the next user message has a proper assistant turn before it. The loop wires it alongside compaction, both running before the API call: That config.drainBackground flag is the subagent guard. During development, an early version ran drainBackgroundNotifications in every agentLoop call — including subagent loops. A subagent running a quick research task would consume background notifications meant for the main agent. The results were gone before the main loop ever saw them. The fix: LoopConfig.default sets drainBackground: true, while LoopConfig.subagent sets it to false: The subagent's excluded tools now also include background_run and background_check — a subagent shouldn't be able to spawn background work at all, since it can't drain the results. With that in place, our agent can hand off slow commands and keep working. Two new entries in the dispatch dictionary, one new actor, and a three-line drain check in the loop — the background execution layer is complete. The ShellExecutor gained a timeout parameter for background commands (defaulting to 300 seconds). The timeout mechanism uses DispatchSource.makeTimerSource() — a GCD timer that fires once after the deadline and terminates the process. An earlier design considered Task.sleep, but there's a subtle problem: try? await Task.sleep(for:) swallows CancellationError. When the process finishes normally and the sleep task is cancelled, execution falls through past the sleep, sets a timeout flag, and kills a process that already exited. DispatchSource avoids this entirely — timer.cancel() is synchronous and guaranteed to prevent the handler from firing. The first version called process.terminate() in the timer handler — SIGTERM. On macOS, this worked perfectly: bash received SIGTERM, the child process died, the timeout was detected. Then the Linux build ran, and three timeout scenarios broke. The root cause: macOS and Linux bash handle SIGTERM differently when waiting on a foreground child. macOS bash exits promptly. Linux bash defers the signal until the child process finishes on its own. A bash -c "sleep 10" with a 2-second timeout would run for the full 10 seconds on Linux because bash ignored the SIGTERM while waiting for sleep. The fix came in two parts. First, process.interrupt() replaced process.terminate() — SIGINT instead of SIGTERM. Bash forwards SIGINT to the child process group on both platforms. Second, the timeout detection itself changed from signal-based to elapsed-time: The original detection checked process.terminationReason == .uncaughtSignal && process.terminationStatus == SIGTERM — a check that was fragile across platforms and depended on bash's specific signal-handling behavior. Wall-clock comparison is platform-independent and unambiguous: if the process took longer than the timeout, it was terminated. Try: Run "sleep 5 && echo done" in the background, then create a file called hello.txt with "world" in it. Watch the tool calls — the agent should call background_run, get a job ID back immediately, then proceed to create the file without waiting. A few seconds later, when the sleep finishes, the [background] 1 result(s) injected message should appear as the drain fires before the next API call. For something more realistic: Run the test suite in the background with "swift test" and while it runs, read Package.swift and summarize the dependencies. The agent works on the summary while the tests execute in parallel. When the tests finish, the results appear in the model's next context window. To see the check tool: Start three background tasks: "sleep 2", "sleep 4", "sleep 6". Then check all background jobs. The first check should show a mix of completed and running jobs. A second check a few seconds later should show all three completed. We've reached the end of the series. Let's take stock of what we've built. The agent now has 14 tools across eight stages: bash, read_file, write_file, edit_file, todo, agent, load_skill, compact, task_create, task_update, task_list, task_get, background_run, background_check. It can run shell commands, manipulate files, track its own work, delegate to subagents, load specialized knowledge, compress its memory, plan with a dependency graph, and execute slow commands in the background. The Agent type grew from a placeholder caseless enum in stage 0 to 849 lines — and the agent loop at its center is structurally unchanged from the first guide. That's the thesis we set out to test. Claude Code's effectiveness comes from architectural restraint: a small set of excellent tools, thin orchestration, and heavy reliance on the model. The loop is the invariant — API call, check stop reason, process tool uses, append results, repeat. Every new capability was added the same way: define the tool, write the handler, add an entry to the dispatch dictionary. The loop never needed a new branch, a new state machine, or a different control flow. New behaviors arrived as injection points around the loop — nag reminders after tool processing, compaction before the API call, background drain alongside it — but the kernel itself held steady. The one actor in the codebase exists because one type genuinely needed concurrent access to shared state. Everything else — classes, structs, enums — uses the simplest concurrency model that works. No over-architecture, no speculative abstractions, no framework. Just a loop, a dictionary, and a model that knows what to do with the tools it's given. Thanks for reading! The complete series on ivanmagda.dev: Stack: Swift 6.2, AsyncHTTPClient (not URLSession), raw HTTP to the Anthropic Messages API. No SDK. Source code: github.com/ivan-magda/swift-claude-code That's the series. Eight stages, 14 tools, one loop that never changed. If you made it this far — thank you. Drop a comment about what you'd build next, what surprised you, or what you'd do differently. I read everything. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

// Sources/Core/BackgroundManager.swift public actor BackgroundManager { private let executor: ShellExecutor private var jobs: [String: BackgroundJob] = [:] private var notifications: [BackgroundNotification] = [] private var runningTasks: [String: Task<Void, Never>] = [:] public init(executor: ShellExecutor) { self.executor = executor } } // Sources/Core/BackgroundManager.swift public actor BackgroundManager { private let executor: ShellExecutor private var jobs: [String: BackgroundJob] = [:] private var notifications: [BackgroundNotification] = [] private var runningTasks: [String: Task<Void, Never>] = [:] public init(executor: ShellExecutor) { self.executor = executor } } // Sources/Core/BackgroundManager.swift public actor BackgroundManager { private let executor: ShellExecutor private var jobs: [String: BackgroundJob] = [:] private var notifications: [BackgroundNotification] = [] private var runningTasks: [String: Task<Void, Never>] = [:] public init(executor: ShellExecutor) { self.executor = executor } } public struct BackgroundJob: Sendable, Equatable { public let id: String public let command: String public let commandPreview: String public var status: BackgroundJobStatus public var result: String? } public struct BackgroundJob: Sendable, Equatable { public let id: String public let command: String public let commandPreview: String public var status: BackgroundJobStatus public var result: String? } public struct BackgroundJob: Sendable, Equatable { public let id: String public let command: String public let commandPreview: String public var status: BackgroundJobStatus public var result: String? } public struct BackgroundNotification: Sendable, Equatable { public let jobId: String public let status: BackgroundJobStatus public let command: String public let result: String } public struct BackgroundNotification: Sendable, Equatable { public let jobId: String public let status: BackgroundJobStatus public let command: String public let result: String } public struct BackgroundNotification: Sendable, Equatable { public let jobId: String public let status: BackgroundJobStatus public let command: String public let result: String } public func run( command: String, timeout: TimeInterval = Limits.backgroundTimeout ) -> String { let jobId = String(UUID().uuidString.prefix(8)).lowercased() let commandPreview = String(command.prefix(Limits.backgroundCommandPreview)) jobs[jobId] = BackgroundJob( id: jobId, command: command, commandPreview: commandPreview, status: .running ) let task = Task { let status: BackgroundJobStatus let output: String do { let result = try await self.executor.execute(command, timeout: timeout) if result.exitCode != 0 { status = .error } else { status = .completed } output = result.formatted } catch ShellExecutorError.timeout { status = .timeout output = "Error: Timeout (\(Int(timeout))s)" } catch { status = .error output = "Error: \(error)" } self.complete(jobId: jobId, status: status, output: output) } runningTasks[jobId] = task return "Background job \(jobId) started: \(commandPreview)" } public func run( command: String, timeout: TimeInterval = Limits.backgroundTimeout ) -> String { let jobId = String(UUID().uuidString.prefix(8)).lowercased() let commandPreview = String(command.prefix(Limits.backgroundCommandPreview)) jobs[jobId] = BackgroundJob( id: jobId, command: command, commandPreview: commandPreview, status: .running ) let task = Task { let status: BackgroundJobStatus let output: String do { let result = try await self.executor.execute(command, timeout: timeout) if result.exitCode != 0 { status = .error } else { status = .completed } output = result.formatted } catch ShellExecutorError.timeout { status = .timeout output = "Error: Timeout (\(Int(timeout))s)" } catch { status = .error output = "Error: \(error)" } self.complete(jobId: jobId, status: status, output: output) } runningTasks[jobId] = task return "Background job \(jobId) started: \(commandPreview)" } public func run( command: String, timeout: TimeInterval = Limits.backgroundTimeout ) -> String { let jobId = String(UUID().uuidString.prefix(8)).lowercased() let commandPreview = String(command.prefix(Limits.backgroundCommandPreview)) jobs[jobId] = BackgroundJob( id: jobId, command: command, commandPreview: commandPreview, status: .running ) let task = Task { let status: BackgroundJobStatus let output: String do { let result = try await self.executor.execute(command, timeout: timeout) if result.exitCode != 0 { status = .error } else { status = .completed } output = result.formatted } catch ShellExecutorError.timeout { status = .timeout output = "Error: Timeout (\(Int(timeout))s)" } catch { status = .error output = "Error: \(error)" } self.complete(jobId: jobId, status: status, output: output) } runningTasks[jobId] = task return "Background job \(jobId) started: \(commandPreview)" } private func complete( jobId: String, status: BackgroundJobStatus, output: String ) { jobs[jobId]?.status = status jobs[jobId]?.result = output notifications.append( BackgroundNotification( jobId: jobId, status: status, command: jobs[jobId]?.commandPreview ?? "", result: String(output.prefix(Limits.backgroundResultPreview)) ) ) runningTasks.removeValue(forKey: jobId) } private func complete( jobId: String, status: BackgroundJobStatus, output: String ) { jobs[jobId]?.status = status jobs[jobId]?.result = output notifications.append( BackgroundNotification( jobId: jobId, status: status, command: jobs[jobId]?.commandPreview ?? "", result: String(output.prefix(Limits.backgroundResultPreview)) ) ) runningTasks.removeValue(forKey: jobId) } private func complete( jobId: String, status: BackgroundJobStatus, output: String ) { jobs[jobId]?.status = status jobs[jobId]?.result = output notifications.append( BackgroundNotification( jobId: jobId, status: status, command: jobs[jobId]?.commandPreview ?? "", result: String(output.prefix(Limits.backgroundResultPreview)) ) ) runningTasks.removeValue(forKey: jobId) } public func drainNotifications() -> [BackgroundNotification] { let result = notifications notifications.removeAll() return result } public func drainNotifications() -> [BackgroundNotification] { let result = notifications notifications.removeAll() return result } public func drainNotifications() -> [BackgroundNotification] { let result = notifications notifications.removeAll() return result } func drainBackgroundNotifications(_ messages: [Message]) async -> [Message] { let notifications = await backgroundManager.drainNotifications() guard !notifications.isEmpty else { return messages } let text = notifications .map { "[bg:\($0.jobId)] \($0.status.rawValue): \($0.result)" } .joined(separator: "\n") var result = messages let wrappedText = "<background-results>\n\(text)\n</background-results>" if let lastMessage = result.last, lastMessage.role == .user { var updatedContent = lastMessage.content updatedContent.append(.text(wrappedText)) result[result.count - 1] = Message(role: .user, content: updatedContent) } else { result.append(.user(wrappedText)) } result.append(.assistant("Noted background results.")) return result } func drainBackgroundNotifications(_ messages: [Message]) async -> [Message] { let notifications = await backgroundManager.drainNotifications() guard !notifications.isEmpty else { return messages } let text = notifications .map { "[bg:\($0.jobId)] \($0.status.rawValue): \($0.result)" } .joined(separator: "\n") var result = messages let wrappedText = "<background-results>\n\(text)\n</background-results>" if let lastMessage = result.last, lastMessage.role == .user { var updatedContent = lastMessage.content updatedContent.append(.text(wrappedText)) result[result.count - 1] = Message(role: .user, content: updatedContent) } else { result.append(.user(wrappedText)) } result.append(.assistant("Noted background results.")) return result } func drainBackgroundNotifications(_ messages: [Message]) async -> [Message] { let notifications = await backgroundManager.drainNotifications() guard !notifications.isEmpty else { return messages } let text = notifications .map { "[bg:\($0.jobId)] \($0.status.rawValue): \($0.result)" } .joined(separator: "\n") var result = messages let wrappedText = "<background-results>\n\(text)\n</background-results>" if let lastMessage = result.last, lastMessage.role == .user { var updatedContent = lastMessage.content updatedContent.append(.text(wrappedText)) result[result.count - 1] = Message(role: .user, content: updatedContent) } else { result.append(.user(wrappedText)) } result.append(.assistant("Noted background results.")) return result } while true { try Task.checkCancellation() // ... messages = await applyCompaction(messages) if config.drainBackground { messages = await drainBackgroundNotifications(messages) } let request = APIRequest( model: model, maxTokens: Limits.defaultMaxTokens, system: systemPrompt, messages: messages, tools: config.tools ) let response = try await apiClient.createMessage(request: request) // ... process tools, append results, continue } while true { try Task.checkCancellation() // ... messages = await applyCompaction(messages) if config.drainBackground { messages = await drainBackgroundNotifications(messages) } let request = APIRequest( model: model, maxTokens: Limits.defaultMaxTokens, system: systemPrompt, messages: messages, tools: config.tools ) let response = try await apiClient.createMessage(request: request) // ... process tools, append results, continue } while true { try Task.checkCancellation() // ... messages = await applyCompaction(messages) if config.drainBackground { messages = await drainBackgroundNotifications(messages) } let request = APIRequest( model: model, maxTokens: Limits.defaultMaxTokens, system: systemPrompt, messages: messages, tools: config.tools ) let response = try await apiClient.createMessage(request: request) // ... process tools, append results, continue } static let `default` = LoopConfig( tools: Agent.toolDefinitions, maxIterations: .max, enableNag: true, drainBackground: true, label: "agent" ) static let subagent = LoopConfig( tools: Agent.toolDefinitions.filter { !subagentExcludedTools.contains($0.name) }, maxIterations: 30, enableNag: false, drainBackground: false, label: "subagent" ) static let `default` = LoopConfig( tools: Agent.toolDefinitions, maxIterations: .max, enableNag: true, drainBackground: true, label: "agent" ) static let subagent = LoopConfig( tools: Agent.toolDefinitions.filter { !subagentExcludedTools.contains($0.name) }, maxIterations: 30, enableNag: false, drainBackground: false, label: "subagent" ) static let `default` = LoopConfig( tools: Agent.toolDefinitions, maxIterations: .max, enableNag: true, drainBackground: true, label: "agent" ) static let subagent = LoopConfig( tools: Agent.toolDefinitions.filter { !subagentExcludedTools.contains($0.name) }, maxIterations: 30, enableNag: false, drainBackground: false, label: "subagent" ) let startTime = DispatchTime.now() // ... process runs, timer fires interrupt() if needed ... process.waitUntilExit() timer?.cancel() if let timeout { let elapsedSeconds = Double( DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds ) / 1_000_000_000 if elapsedSeconds >= timeout { throw ShellExecutorError.timeout(seconds: Int(timeout)) } } let startTime = DispatchTime.now() // ... process runs, timer fires interrupt() if needed ... process.waitUntilExit() timer?.cancel() if let timeout { let elapsedSeconds = Double( DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds ) / 1_000_000_000 if elapsedSeconds >= timeout { throw ShellExecutorError.timeout(seconds: Int(timeout)) } } let startTime = DispatchTime.now() // ... process runs, timer fires interrupt() if needed ... process.waitUntilExit() timer?.cancel() if let timeout { let elapsedSeconds = Double( DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds ) / 1_000_000_000 if elapsedSeconds >= timeout { throw ShellExecutorError.timeout(seconds: Int(timeout)) } } swift build && swift run agent swift build && swift run agent swift build && swift run agent - Part 0: Bootstrapping the project - Part 1: The agent loop - Part 2: Tool dispatch - Part 3: Self-managed task tracking - Part 4: Subagents - Part 5: Skill loading - Part 6: Context compaction - Part 7: Task system - Part 8: Background tasks ← you are here