param( [int]$PollSeconds = 15, [string]$Implementer = "", [string[]]$Reviewers = @(), [switch]$DisableCodexMaster, [switch]$DisableAutoFix, [switch]$DryRun, [switch]$Once, [int]$MaxTasks = 0, [string]$InboxDirectory = "" ) . (Join-Path $PSScriptRoot "Common.ps1") $roots = Get-RalphTaskRoots foreach ($path in $roots.Values) { Ensure-Directory -Path $path } if ([string]::IsNullOrWhiteSpace($InboxDirectory)) { $InboxDirectory = $roots.Inbox } Ensure-Directory -Path $InboxDirectory $lockFile = Get-RalphStateFile -Name "inbox_daemon.lock.json" $daemonStateFile = Get-RalphStateFile -Name "inbox_daemon_state.json" $script:daemonRunId = "daemon-" + (Get-Date -Format "yyyyMMdd-HHmmss") $script:processedCount = 0 function Save-DaemonState { param( [string]$Status, [string]$Message, [hashtable]$Extra = @{} ) $state = [ordered]@{ pid = $PID status = $Status message = $Message updated_at = (Get-Date).ToString("o") poll_seconds = $PollSeconds dry_run = [bool]$DryRun inbox_directory = $InboxDirectory processed_count = $script:processedCount } foreach ($key in $Extra.Keys) { $state[$key] = $Extra[$key] } Write-JsonFile -Path $daemonStateFile -Object $state } function Acquire-DaemonLock { if (Test-Path $lockFile) { try { $existing = Read-JsonFile -Path $lockFile $existingPid = 0 try { $existingPid = [int]$existing.pid } catch { $existingPid = 0 } if ($existingPid -gt 0) { $proc = Get-Process -Id $existingPid -ErrorAction SilentlyContinue if ($null -ne $proc) { throw "Ralph inbox daemon already running with PID $existingPid." } } } catch { # stale or malformed lock; overwrite it } } Write-JsonFile -Path $lockFile -Object ([ordered]@{ pid = $PID started_at = (Get-Date).ToString("o") inbox_directory = $InboxDirectory }) } function Release-DaemonLock { if (Test-Path $lockFile) { Remove-Item -LiteralPath $lockFile -Force -ErrorAction SilentlyContinue } } function Get-NextInboxItem { $directories = @(Get-ChildItem -LiteralPath $InboxDirectory -Directory -ErrorAction SilentlyContinue | Sort-Object LastWriteTime, Name) foreach ($dir in $directories) { if (Test-Path (Join-Path $dir.FullName "TASK.md")) { return [ordered]@{ kind = "taskpack" path = $dir.FullName name = $dir.Name } } } $files = @(Get-ChildItem -LiteralPath $InboxDirectory -File -Filter *.md -ErrorAction SilentlyContinue | Sort-Object LastWriteTime, Name) foreach ($file in $files) { return [ordered]@{ kind = "markdown" path = $file.FullName name = $file.Name } } return $null } function Move-InboxItemToProcessing { param([hashtable]$Item) $id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmss"), (Convert-ToRalphSlug -Text ([System.IO.Path]::GetFileNameWithoutExtension($Item.name))) $targetDir = Join-Path $roots.Processing $id Ensure-Directory -Path $targetDir if ($Item.kind -eq "taskpack") { $targetParent = Split-Path -Parent $targetDir if (-not (Test-Path $targetParent)) { Ensure-Directory -Path $targetParent } Remove-Item -LiteralPath $targetDir -Recurse -Force -ErrorAction SilentlyContinue Move-Item -LiteralPath $Item.path -Destination $targetDir return [ordered]@{ id = $id task_directory = $targetDir title = $Item.name source = $Item.path } } $taskInfo = New-RalphTaskPackFromMarkdown -MarkdownPath $Item.path -TaskId $id -TargetDirectory $targetDir Remove-Item -LiteralPath $Item.path -Force return [ordered]@{ id = $taskInfo.id task_directory = $taskInfo.task_directory title = $taskInfo.title source = $Item.path } } function Finalize-ProcessedItem { param( [hashtable]$Processed, [string]$DestinationRoot, [string]$State, [string]$RunId = "", [string]$Summary = "" ) Ensure-Directory -Path $DestinationRoot $destination = Join-Path $DestinationRoot ([System.IO.Path]::GetFileName($Processed.task_directory)) if (Test-Path $destination) { Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue } Move-Item -LiteralPath $Processed.task_directory -Destination $destination $submissionFile = Join-Path $destination "submission.json" $submission = [ordered]@{ id = $Processed.id title = $Processed.title state = $State finished_at = (Get-Date).ToString("o") source = $Processed.source run_id = $RunId summary = $Summary } Write-JsonFile -Path $submissionFile -Object $submission return $destination } Acquire-DaemonLock Save-DaemonState -Status "running" -Message "Inbox daemon started" Add-RalphEvent -RunId $script:daemonRunId -Stage "daemon" -Status "running" -Actor "daemon" -Message "Ralph inbox daemon started" -Data @{ inbox_directory = $InboxDirectory } Send-TelegramNotification ` -EventName "daemon_started" ` -Title "Ralph daemon started" ` -Message ("Inbox: " + $InboxDirectory) ` -RunId $script:daemonRunId ` -Stage "daemon" ` -Status "running" | Out-Null try { while ($true) { $item = Get-NextInboxItem if ($null -eq $item) { if ($Once) { Save-DaemonState -Status "idle" -Message "No tasks in inbox; exiting because -Once was used" break } Save-DaemonState -Status "idle" -Message "Waiting for inbox tasks" Start-Sleep -Seconds $PollSeconds continue } $processed = Move-InboxItemToProcessing -Item $item $script:processedCount += 1 Save-DaemonState -Status "running" -Message ("Processing task " + $processed.id) -Extra @{ current_task = $processed.id current_task_directory = $processed.task_directory } Add-RalphEvent -RunId $script:daemonRunId -Stage "queue" -Status "started" -Actor "daemon" -Message ("Dequeued task " + $processed.id) -Data @{ task_directory = $processed.task_directory } Send-TelegramNotification ` -EventName "task_processing" ` -Title "Task processing" ` -Message ($processed.title + "`n" + $processed.task_directory) ` -RunId $processed.id ` -Stage "queue" ` -Status "started" | Out-Null $runId = "" $summary = "" try { $args = @( "-ExecutionPolicy", "Bypass", "-File", (Join-Path $PSScriptRoot "Start-RalphAutopilot.ps1"), "-TaskDirectory", $processed.task_directory, "-RunLabel", "queue" ) if ($DryRun) { $args += "-DryRun" } if (-not [string]::IsNullOrWhiteSpace($Implementer)) { $args += @("-Implementer", $Implementer) } foreach ($reviewer in $Reviewers) { $args += @("-Reviewers", $reviewer) } if ($DisableCodexMaster) { $args += "-UseCodexMaster:$false" } if ($DisableAutoFix) { $args += "-AutoFix:$false" } & powershell.exe @args if ($LASTEXITCODE -ne 0) { throw "Start-RalphAutopilot.ps1 failed with exit code $LASTEXITCODE" } $currentRunState = Read-JsonFile -Path (Get-RalphStateFile -Name "current_run.json") $runId = [string]$currentRunState.run_id $summary = [string]$currentRunState.latest_message $finalPath = Finalize-ProcessedItem -Processed $processed -DestinationRoot $roots.Completed -State "completed" -RunId $runId -Summary $summary Add-RalphEvent -RunId $script:daemonRunId -Stage "queue" -Status "completed" -Actor "daemon" -Message ("Completed task " + $processed.id) -Data @{ task_directory = $finalPath run_id = $runId } Send-TelegramNotification ` -EventName "task_completed" ` -Title "Task completed" ` -Message ($processed.title + "`n" + $summary) ` -RunId $runId ` -Stage "queue" ` -Status "completed" | Out-Null } catch { $summary = $_.Exception.Message $failedPath = Finalize-ProcessedItem -Processed $processed -DestinationRoot $roots.Failed -State "failed" -RunId $runId -Summary $summary Add-RalphEvent -RunId $script:daemonRunId -Stage "queue" -Status "failed" -Actor "daemon" -Message ("Failed task " + $processed.id + ": " + $summary) -Data @{ task_directory = $failedPath run_id = $runId } Send-TelegramNotification ` -EventName "task_failed" ` -Title "Task failed" ` -Message ($processed.title + "`n" + $summary) ` -RunId $runId ` -Stage "queue" ` -Status "failed" | Out-Null } if ($MaxTasks -gt 0 -and $script:processedCount -ge $MaxTasks) { Save-DaemonState -Status "completed" -Message "MaxTasks limit reached" break } } } finally { Save-DaemonState -Status "stopped" -Message "Inbox daemon stopped" Add-RalphEvent -RunId $script:daemonRunId -Stage "daemon" -Status "stopped" -Actor "daemon" -Message "Ralph inbox daemon stopped" Send-TelegramNotification ` -EventName "daemon_stopped" ` -Title "Ralph daemon stopped" ` -Message "Inbox daemon stopped" ` -RunId $script:daemonRunId ` -Stage "daemon" ` -Status "stopped" | Out-Null Release-DaemonLock }