From bfea216aaff547b6716511c7e307e1059cef94d2 Mon Sep 17 00:00:00 2001 From: Ognjen Gligoric Date: Tue, 14 Jul 2026 11:48:14 +0200 Subject: [PATCH 1/3] Add MothershipClient and WorkQueueService Add a MothershipClient compatibility shim that globally imports the runtime Dotbot.Notification module. Deprecate the old NotificationClient by turning it into a thin forwarder to MothershipClient and update UI modules (NotificationPoller, SettingsAPI) to reference MothershipClient instead of NotificationClient. Introduce a skeleton WorkQueueService implementing a file-based queue API (enqueue, dequeue, depth, complete) and its initialization. Wire WorkQueueService into the UI server startup (import + Initialize-WorkQueueService). --- src/mcp/modules/MothershipClient.psm1 | 14 ++ src/mcp/modules/NotificationClient.psm1 | 12 +- src/ui/modules/NotificationPoller.psm1 | 4 +- src/ui/modules/SettingsAPI.psm1 | 4 +- src/ui/modules/WorkQueueService.psm1 | 182 ++++++++++++++++++++++++ src/ui/server.ps1 | 2 + 6 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 src/mcp/modules/MothershipClient.psm1 create mode 100644 src/ui/modules/WorkQueueService.psm1 diff --git a/src/mcp/modules/MothershipClient.psm1 b/src/mcp/modules/MothershipClient.psm1 new file mode 100644 index 00000000..4915e794 --- /dev/null +++ b/src/mcp/modules/MothershipClient.psm1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS +Compatibility shim for runtime-owned notification/mothership helpers. + +.DESCRIPTION +Mothership client logic lives in Dotbot.Notification. Existing MCP and UI +callers can import this module — it forwards to Dotbot.Notification via a +global import so the same function names remain available. +#> + +$notifModule = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'runtime' 'Modules' 'Dotbot.Notification' 'Dotbot.Notification.psd1' +if (-not (Get-Module Dotbot.Notification)) { + Import-Module $notifModule -DisableNameChecking -Global +} diff --git a/src/mcp/modules/NotificationClient.psm1 b/src/mcp/modules/NotificationClient.psm1 index 5dea341d..32cfd8d4 100644 --- a/src/mcp/modules/NotificationClient.psm1 +++ b/src/mcp/modules/NotificationClient.psm1 @@ -1,14 +1,10 @@ <# .SYNOPSIS -Compatibility shim for runtime-owned notification helpers. +Deprecated: use MothershipClient.psm1 instead. .DESCRIPTION -Notification client logic now lives in Dotbot.Notification. Existing MCP and -UI callers can keep importing this module — it forwards to Dotbot.Notification -via a global import so the same function names remain available. +This shim is kept for backward compatibility. It forwards to MothershipClient.psm1. +New callers should import MothershipClient.psm1 directly. #> -$notifModule = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'runtime' 'Modules' 'Dotbot.Notification' 'Dotbot.Notification.psd1' -if (-not (Get-Module Dotbot.Notification)) { - Import-Module $notifModule -DisableNameChecking -Global -} +Import-Module (Join-Path $PSScriptRoot 'MothershipClient.psm1') -Force -Global diff --git a/src/ui/modules/NotificationPoller.psm1 b/src/ui/modules/NotificationPoller.psm1 index 983fa98b..b9298acf 100644 --- a/src/ui/modules/NotificationPoller.psm1 +++ b/src/ui/modules/NotificationPoller.psm1 @@ -81,7 +81,7 @@ function Initialize-NotificationPoller { $script:pollerBotRoot = $BotRoot # Import the notification client module - $notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "NotificationClient.psm1" + $notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "MothershipClient.psm1" if (-not (Test-Path $notifModule)) { return } @@ -141,7 +141,7 @@ function Invoke-NotificationPollTick { if (-not (Test-Path $tasksBaseDir)) { return } # Ensure notification client is loaded - $notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "NotificationClient.psm1" + $notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "MothershipClient.psm1" if (-not (Test-Path $notifModule)) { return } Import-Module $notifModule -Force diff --git a/src/ui/modules/SettingsAPI.psm1 b/src/ui/modules/SettingsAPI.psm1 index 29a8bd6c..e05caade 100644 --- a/src/ui/modules/SettingsAPI.psm1 +++ b/src/ui/modules/SettingsAPI.psm1 @@ -1215,9 +1215,9 @@ function Set-MothershipConfig { function Set-NotificationConfig { param([Parameter(Mandatory)] $Body) return Set-MothershipConfig -Body $Body } function Test-MothershipServerFromUI { - $notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "NotificationClient.psm1" + $notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "MothershipClient.psm1" if (-not (Test-Path $notifModule)) { - return @{ reachable = $false; error = "NotificationClient module not found" } + return @{ reachable = $false; error = "MothershipClient module not found" } } Import-Module $notifModule -Force diff --git a/src/ui/modules/WorkQueueService.psm1 b/src/ui/modules/WorkQueueService.psm1 new file mode 100644 index 00000000..5a836c2d --- /dev/null +++ b/src/ui/modules/WorkQueueService.psm1 @@ -0,0 +1,182 @@ +<# +.SYNOPSIS +Work queue service skeleton for fleet task dispatch (Phase 10 / Drone). + +.DESCRIPTION +Provides a file-based work queue that the Mothership uses to dispatch tasks to +registered drone runtimes. Storage mirrors FleetAPI.psm1: JSON files under +fleet/queue//. + +This is a skeleton — function signatures are stable and importable by #96 +(Drone agent), but full dispatch logic is deferred to the Drone phase. +#> + +$script:QueueConfig = @{ + ControlDir = $null +} + +function Initialize-WorkQueueService { + <# + .SYNOPSIS + Sets up the work queue storage directory. Call once at server startup. + #> + param( + [Parameter(Mandatory)][string]$ControlDir + ) + $script:QueueConfig.ControlDir = $ControlDir + $queueRoot = _Get-QueueRoot + if (-not (Test-Path -LiteralPath $queueRoot)) { + New-Item -ItemType Directory -Path $queueRoot -Force | Out-Null + } +} + +function Enqueue-WorkItem { + <# + .SYNOPSIS + Adds a work item to a runtime's queue. + .PARAMETER RuntimeId + The target runtime that should process this item. + .PARAMETER TaskId + The task ID to dispatch. + .PARAMETER Payload + Arbitrary hashtable of additional context (workflow name, run id, etc.). + .OUTPUTS + Hashtable with the new item's id and queued_at timestamp. + #> + param( + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)][string]$TaskId, + [hashtable]$Payload = @{} + ) + + $itemId = "wqi-$([guid]::NewGuid().ToString('N').Substring(0, 12))" + $item = [ordered]@{ + id = $itemId + runtime_id = $RuntimeId + task_id = $TaskId + payload = $Payload + status = 'pending' + queued_at = (Get-Date).ToUniversalTime().ToString('o') + } + + $dir = _Get-RuntimeQueueDir -RuntimeId $RuntimeId + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + _Write-QueueJson -Path (Join-Path $dir "$itemId.json") -Value $item + + return @{ id = $itemId; queued_at = $item.queued_at } +} + +function Dequeue-WorkItem { + <# + .SYNOPSIS + Pops the next pending work item for a runtime (FIFO by queued_at). + Marks the item as 'leased' so it is not returned again. + Returns $null when the queue is empty. + #> + param( + [Parameter(Mandatory)][string]$RuntimeId + ) + + $dir = _Get-RuntimeQueueDir -RuntimeId $RuntimeId + $files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue | + Sort-Object Name) + + foreach ($file in $files) { + $item = _Read-QueueJson -Path $file.FullName + if (-not $item -or $item['status'] -ne 'pending') { continue } + + $item['status'] = 'leased' + $item['leased_at'] = (Get-Date).ToUniversalTime().ToString('o') + _Write-QueueJson -Path $file.FullName -Value $item + + return [ordered]@{ + id = $item['id'] + runtime_id = $item['runtime_id'] + task_id = $item['task_id'] + payload = $item['payload'] + queued_at = $item['queued_at'] + leased_at = $item['leased_at'] + } + } + + return $null +} + +function Get-WorkQueueDepth { + <# + .SYNOPSIS + Returns the count of pending (not yet leased) items for a runtime. + #> + param( + [Parameter(Mandatory)][string]$RuntimeId + ) + + $dir = _Get-RuntimeQueueDir -RuntimeId $RuntimeId + $count = 0 + foreach ($file in Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue) { + $item = _Read-QueueJson -Path $file.FullName + if ($item -and $item['status'] -eq 'pending') { $count++ } + } + return $count +} + +function Complete-WorkItem { + <# + .SYNOPSIS + Marks a leased work item as completed. Called by the drone after finishing. + #> + param( + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)][string]$ItemId + ) + + $path = Join-Path (_Get-RuntimeQueueDir -RuntimeId $RuntimeId) "$ItemId.json" + $item = _Read-QueueJson -Path $path + if (-not $item) { return @{ success = $false; error = 'item not found' } } + + $item['status'] = 'completed' + $item['completed_at'] = (Get-Date).ToUniversalTime().ToString('o') + _Write-QueueJson -Path $path -Value $item + + return @{ success = $true } +} + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +function _Get-QueueRoot { + return Join-Path $script:QueueConfig.ControlDir 'fleet' 'queue' +} + +function _Get-RuntimeQueueDir { + param([Parameter(Mandatory)][string]$RuntimeId) + $safe = $RuntimeId -replace '[^A-Za-z0-9_.-]', '_' + return Join-Path (_Get-QueueRoot) $safe +} + +function _Read-QueueJson { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + try { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable } catch { return $null } +} + +function _Write-QueueJson { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][object]$Value + ) + $tmp = "$Path.tmp" + [System.IO.File]::WriteAllText($tmp, ($Value | ConvertTo-Json -Depth 20), [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $tmp -Destination $Path -Force +} + +Export-ModuleMember -Function @( + 'Initialize-WorkQueueService', + 'Enqueue-WorkItem', + 'Dequeue-WorkItem', + 'Get-WorkQueueDepth', + 'Complete-WorkItem' +) diff --git a/src/ui/server.ps1 b/src/ui/server.ps1 index c9c0bade..fd2c785c 100644 --- a/src/ui/server.ps1 +++ b/src/ui/server.ps1 @@ -181,6 +181,7 @@ Import-Module (Join-Path $PSScriptRoot "modules/NotificationPoller.psm1") -Force Import-Module (Join-Path $PSScriptRoot "modules/DecisionAPI.psm1") -Force Import-Module (Join-Path $PSScriptRoot "modules/InboxWatcher.psm1") -Force Import-Module (Join-Path $PSScriptRoot "modules/FleetAPI.psm1") -Force +Import-Module (Join-Path $PSScriptRoot "modules/WorkQueueService.psm1") -Force # Import workflow manifest utilities (for installed workflows API). # -Global so Test-ValidWorkflowDir / Read-WorkflowManifest stay visible to @@ -206,6 +207,7 @@ Initialize-NotificationPoller -BotRoot $botRoot Initialize-DecisionAPI -BotRoot $botRoot Initialize-InboxWatcher -BotRoot $botRoot Initialize-FleetAPI -ControlDir $controlDir -BotRoot $botRoot +Initialize-WorkQueueService -ControlDir $controlDir # Request counter for single-line logging $script:requestCount = 0 From f80f85eeee655df4c341abff7baafea3b54bb366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ognjen=20Gligori=C4=87?= <87246330+OgnjenGligoric@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:20:35 +0200 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ui/modules/WorkQueueService.psm1 | 41 ++++++++++++++++++---------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/ui/modules/WorkQueueService.psm1 b/src/ui/modules/WorkQueueService.psm1 index 5a836c2d..16cbbd51 100644 --- a/src/ui/modules/WorkQueueService.psm1 +++ b/src/ui/modules/WorkQueueService.psm1 @@ -80,28 +80,38 @@ function Dequeue-WorkItem { ) $dir = _Get-RuntimeQueueDir -RuntimeId $RuntimeId - $files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue | - Sort-Object Name) + $files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue) + + $nextFile = $null + $nextItem = $null + $nextQueuedAt = [DateTime]::MaxValue foreach ($file in $files) { $item = _Read-QueueJson -Path $file.FullName if (-not $item -or $item['status'] -ne 'pending') { continue } - $item['status'] = 'leased' - $item['leased_at'] = (Get-Date).ToUniversalTime().ToString('o') - _Write-QueueJson -Path $file.FullName -Value $item - - return [ordered]@{ - id = $item['id'] - runtime_id = $item['runtime_id'] - task_id = $item['task_id'] - payload = $item['payload'] - queued_at = $item['queued_at'] - leased_at = $item['leased_at'] + try { $queuedAt = [DateTime]::Parse([string]$item['queued_at']).ToUniversalTime() } catch { $queuedAt = [DateTime]::MaxValue } + if ($queuedAt -lt $nextQueuedAt) { + $nextQueuedAt = $queuedAt + $nextFile = $file + $nextItem = $item } } - return $null + if (-not $nextItem) { return $null } + + $nextItem['status'] = 'leased' + $nextItem['leased_at'] = (Get-Date).ToUniversalTime().ToString('o') + _Write-QueueJson -Path $nextFile.FullName -Value $nextItem + + return [ordered]@{ + id = $nextItem['id'] + runtime_id = $nextItem['runtime_id'] + task_id = $nextItem['task_id'] + payload = $nextItem['payload'] + queued_at = $nextItem['queued_at'] + leased_at = $nextItem['leased_at'] + } } function Get-WorkQueueDepth { @@ -135,6 +145,9 @@ function Complete-WorkItem { $path = Join-Path (_Get-RuntimeQueueDir -RuntimeId $RuntimeId) "$ItemId.json" $item = _Read-QueueJson -Path $path if (-not $item) { return @{ success = $false; error = 'item not found' } } + if ($item['status'] -ne 'leased') { + return @{ success = $false; error = "item is not leased (status=$($item['status']))" } + } $item['status'] = 'completed' $item['completed_at'] = (Get-Date).ToUniversalTime().ToString('o') From ba23455b88aa6bbeae290ef938a22a82fdbfc3e0 Mon Sep 17 00:00:00 2001 From: Ognjen Gligoric Date: Wed, 15 Jul 2026 12:21:09 +0200 Subject: [PATCH 3/3] Sort queue by creation time, add WorkQueueService tests Use file CreationTime when ordering queue files in Dequeue-WorkItem to ensure FIFO behavior instead of sorting by filename. Add tests to Test-Runtime.ps1 to import the WorkQueueService module and verify initialize, enqueue, dequeue, lease/completion semantics, depth reporting, per-runtime isolation, and error handling, plus cleanup of temp control directory. --- tests/Test-Runtime.ps1 | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/Test-Runtime.ps1 b/tests/Test-Runtime.ps1 index 3a25733c..1be66d5a 100644 --- a/tests/Test-Runtime.ps1 +++ b/tests/Test-Runtime.ps1 @@ -43,6 +43,7 @@ Import-Module (Join-Path $repoRoot "src/runtime/Modules/Dotbot.Task/Dotbot.Task. Import-Module (Join-Path $repoRoot "src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psd1") -Force -DisableNameChecking -Global Import-Module (Join-Path $repoRoot "src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1") -Force -DisableNameChecking -Global Import-Module (Join-Path $repoRoot "src/ui/modules/FleetAPI.psm1") -Force -DisableNameChecking -Global +Import-Module (Join-Path $repoRoot "src/ui/modules/WorkQueueService.psm1") -Force -DisableNameChecking -Global # Small helper: assert a scriptblock throws and (optionally) message matches a pattern. function Assert-Throws { @@ -530,6 +531,74 @@ try { $proxy = Invoke-FleetRuntimeProxy -RuntimeId 'rt-test-runtime' -Method GET -ApiPath '/api/info' Assert-Equal -Name "FleetAPI proxies /api/info to runtime" -Expected 200 -Actual $proxy.status_code + # ───── WorkQueueService ───── + $wqControlDir = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-wqs-$(Get-Random)" + New-Item -Path $wqControlDir -ItemType Directory -Force | Out-Null + + Initialize-WorkQueueService -ControlDir $wqControlDir + $queueRoot = Join-Path $wqControlDir 'fleet' 'queue' + Assert-True -Name "WorkQueueService creates fleet/queue directory on init" ` + -Condition (Test-Path -LiteralPath $queueRoot) + + # Empty queue returns null and depth 0 + Assert-True -Name "WorkQueueService Dequeue-WorkItem returns null on empty queue" ` + -Condition ($null -eq (Dequeue-WorkItem -RuntimeId 'drone-1')) + Assert-Equal -Name "WorkQueueService Get-WorkQueueDepth returns 0 on empty queue" ` + -Expected 0 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1') + + # Enqueue two items + $item1 = Enqueue-WorkItem -RuntimeId 'drone-1' -TaskId 't_aaa00001' -Payload @{ run_id = 'run-001' } + $item2 = Enqueue-WorkItem -RuntimeId 'drone-1' -TaskId 't_bbb00002' + Assert-True -Name "WorkQueueService Enqueue-WorkItem returns an id" ` + -Condition ($item1.id -match '^wqi-') + Assert-Equal -Name "WorkQueueService Get-WorkQueueDepth reflects enqueued items" ` + -Expected 2 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1') + + # Dequeue respects FIFO order + $dequeued1 = Dequeue-WorkItem -RuntimeId 'drone-1' + Assert-Equal -Name "WorkQueueService Dequeue-WorkItem returns first item (FIFO)" ` + -Expected $item1.id -Actual $dequeued1.id + Assert-Equal -Name "WorkQueueService dequeued item has correct task_id" ` + -Expected 't_aaa00001' -Actual $dequeued1.task_id + Assert-Equal -Name "WorkQueueService dequeued item status is leased" ` + -Expected 'leased' -Actual ( + (Get-Content -LiteralPath (Join-Path $queueRoot "drone-1/$($item1.id).json") -Raw | ConvertFrom-Json).status + ) + + # Depth drops by 1 after lease (leased item no longer pending) + Assert-Equal -Name "WorkQueueService depth decreases after dequeue" ` + -Expected 1 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1') + + # Complete the first item + $completeResult = Complete-WorkItem -RuntimeId 'drone-1' -ItemId $item1.id + Assert-True -Name "WorkQueueService Complete-WorkItem returns success" ` + -Condition ([bool]$completeResult.success) + Assert-Equal -Name "WorkQueueService completed item has status completed" ` + -Expected 'completed' -Actual ( + (Get-Content -LiteralPath (Join-Path $queueRoot "drone-1/$($item1.id).json") -Raw | ConvertFrom-Json).status + ) + + # Dequeue second item, then queue is empty + $dequeued2 = Dequeue-WorkItem -RuntimeId 'drone-1' + Assert-Equal -Name "WorkQueueService second dequeue returns second item" ` + -Expected $item2.id -Actual $dequeued2.id + Assert-True -Name "WorkQueueService third dequeue on empty queue returns null" ` + -Condition ($null -eq (Dequeue-WorkItem -RuntimeId 'drone-1')) + + # Complete-WorkItem returns error for unknown item + $badComplete = Complete-WorkItem -RuntimeId 'drone-1' -ItemId 'wqi-doesnotexist' + Assert-True -Name "WorkQueueService Complete-WorkItem returns error for unknown item" ` + -Condition (-not $badComplete.success) + + # Multiple runtimes are isolated + $null = Enqueue-WorkItem -RuntimeId 'drone-2' -TaskId 't_ccc00003' + Assert-Equal -Name "WorkQueueService queues are isolated per runtime" ` + -Expected 1 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-2') + Assert-Equal -Name "WorkQueueService drone-1 unaffected by drone-2 enqueue" ` + -Expected 0 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1') + + Remove-Item $wqControlDir -Recurse -Force -ErrorAction SilentlyContinue + # ───── Activity log ───── $logPath = Get-ActivityLogPath -BotRoot $bot Assert-True -Name "activity.jsonl exists after mutations" -Condition (Test-Path -LiteralPath $logPath)