scripts/internal/agent-tasks/isolated-task-launcher.ps1
|
<#
.SYNOPSIS The general isolated-task launcher - the Proposal 139 multi-agent foundation (the Layer-2 headless-process-orchestration seam) arriving early. Review (continuous co-review) is its FIRST consumer. .DESCRIPTION `Start-SpecrewIsolatedTask` always does three things, parameterized by two policies (access + disposition): 1. SPAWN a harness (`-Command`) inside an ISOLATED git worktree materialized from a target tree-id, SUPERVISED with a timeout (the old "watchdog" role - kill on timeout, write a terminal status). It fires a DETACHED supervisor (`isolated-task-supervisor.ps1`) and RETURNS the run metadata immediately - the caller (a Stop-hook provider) never waits for the task. Cross-platform detachment requires redirecting the child's stdio to files at EVERY hop (the T076 spike proved a Windows-only version SHIPS A BUG that blocks the provider ~18s on Unix, where the child inherits the parent pipes); the provider must fire-and-return (NEVER `Start-Process -Wait`, which waits for the whole process tree). 2. ACCESS mode - `read-only` (review) is BUILT. `read-write` (implementation) is a designed seam: it needs a real `git worktree` branched from base for merge-back, so it throws 'not implemented' here (see the comment at the throw). 3. DISPOSITION on completion - what the supervisor does with the worktree: - `discard` delete it (review: nothing to merge). THE ONLY ONE BUILT NOW. - `merge` 3-way merge (base B, worktree, moved-base B') back, then delete. Designed, DEFERRED to the merge-agent (Proposals 010/134/149). Throws here. - `preserve` keep for human inspection (failed/conflicted task). DEFERRED. Throws here. The launcher OWNS the full worktree lifecycle (create -> frozen -> dispose), ENTIRELY on the detached supervisor path so the provider/Stop-hook never pays for it. Orphan-safety is BY CONSTRUCTION: the supervisor self-limits (its own timeout + kill loop) and disposes in a `finally`, so even a timed-out/killed run leaves no orphaned worktree. `Stop-SpecrewIsolatedTask` is the cleanup helper a future reaper calls to reap zombie supervisors + orphaned worktrees from a DEAD launcher (the backstop, not the primary mechanism). THREE-TIER file layout (per the iteration-005 design): - The worktree + the reviewer scratch: ephemeral `$TEMP`, OUTSIDE the repo (throwaway per task; never inside the repo it snapshots). - The pending-task registry (launcher<->reaper signaling): stable `.specrew/review/pending/` (gitignored + digest-stripped) so it survives the fire->reap gap ACROSS a session boundary. - The persistent passing-run records the gate reads: in-repo `.specrew/review/runs/` (digest-stripped). (This file writes the pending registry + the supervisor mirrors a terminal record; promotion of a PASS to `runs/` is the provider/reaper's job, not here.) Because `.specrew/**` is already stripped from the reviewed tree-id, the worktree (materialized from that tree-id) is automatically clean of all bookkeeping - the reviewer sees only source. .NOTES F-184 footprint: NONE. Non-protected script under a GENERAL location (signals shared 139 infrastructure, not co-review-specific). PowerShell 7.x. #> $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # --- shared atomic write (single source) --------------------------------------------------------- $script:IsolatedTaskAtomicWritePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'atomic-write.ps1' if ((Test-Path -LiteralPath $script:IsolatedTaskAtomicWritePath -PathType Leaf) -and -not (Get-Command -Name 'Write-SpecrewFileAtomic' -ErrorAction SilentlyContinue)) { . $script:IsolatedTaskAtomicWritePath } function Get-SpecrewIsolatedTaskSupervisorPath { # The detached supervisor body lives in a SIBLING file (mirrors the spike's fire/launcher # split). It must run ONLY the supervisor loop; keeping it separate means dot-sourcing THIS # launcher to get the functions never runs the supervisor branch. return (Join-Path $PSScriptRoot 'isolated-task-supervisor.ps1') } function Get-SpecrewIsolatedTaskPendingDir { param([Parameter(Mandatory)][string]$RepoRoot) return (Join-Path $RepoRoot '.specrew/review/pending') } function Get-SpecrewIsolatedTaskEphemeralRoot { # The ephemeral worktree root - OUTSIDE the repo, in $TEMP. `[IO.Path]::GetTempPath()` honors # TMPDIR on Unix and TEMP/TMP on Windows, so a caller (or test) that redirects the env var # steers this on BOTH platforms. An explicit override wins for hermetic tests. if ($env:SPECREW_ISOLATED_TASK_TMP) { return $env:SPECREW_ISOLATED_TASK_TMP } return [System.IO.Path]::GetTempPath() } function New-SpecrewIsolatedTaskRunId { # Sortable + unique: UTC stamp + short guid. return ('{0}-{1}' -f (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssfff'), ([guid]::NewGuid().ToString('N').Substring(0, 8))) } function Get-SpecrewIsolatedTaskHost { # Best-effort host label for the registry (which host fired the task). Not load-bearing here. foreach ($var in 'SPECREW_HOST', 'SPECREW_ACTIVE_HOST') { $val = [System.Environment]::GetEnvironmentVariable($var) if ($val) { return $val } } return 'unknown' } function Get-SpecrewIsolatedTaskSessionId { # T100: best-effort session identity for the registry (which SESSION fired the task) - the # session-scoping of the pidfile registry. The dispatcher/provider may export it; absence is # honest ('unknown') and the SessionStart sweep still reaps every stale entry regardless. foreach ($var in 'SPECREW_SESSION_ID', 'CLAUDE_SESSION_ID') { $val = [System.Environment]::GetEnvironmentVariable($var) if ($val) { return $val } } return 'unknown' } function Import-SpecrewIsolatedTaskProcessTree { # Load the co-located process-tree.ps1 sibling (co-deployed via FileList) for the reaper's # child-tree kill. Best-effort: a miss degrades Stop-SpecrewIsolatedTask to single-pid kills. if (Get-Command -Name 'Stop-SpecrewProcessContainment' -ErrorAction SilentlyContinue) { return $true } $helper = Join-Path $PSScriptRoot 'process-tree.ps1' if (Test-Path -LiteralPath $helper -PathType Leaf) { try { . $helper; return $true } catch { $null = $_ } } return $false } function New-SpecrewIsolatedTaskWorktree { <# Materialize a read-only snapshot of $TreeId into an EPHEMERAL dir OUTSIDE the repo. RO path: clean tree-content export via `git archive --output <tar>` + `tar -xf` - NO git worktree machinery needed. CRITICAL: archive to a FILE then extract; do NOT pipe `git archive | tar` in PowerShell - a native->native pipe routes the binary tar stream through .NET text decode/encode and CORRUPTS it (verified). The file hop is byte-exact and identical on bsdtar (Win11 System32) + GNU tar (Linux). CRITICAL (iter-007 real-host dogfood): on Windows, bare `tar` on PATH is typically Git-for-Windows' MSYS tar (C:\Program Files\Git\usr\bin\tar.exe), which is FIRST on PATH on essentially every dev machine. MSYS tar parses the absolute archive path `C:\...\<id>.tar` as an rcp-style REMOTE `host:path` ("tar: Cannot connect to C: resolve failed", exit 128) and cannot extract. THAT is why the navigator reached FIRE but never materialized the review worktree on the real host (the 12 navigator + 2 launcher unit failures shared this one root cause). So pin Windows to System32 bsdtar (ships on Win10/11; handles C:\ paths natively). Unix is unchanged (bare `tar` = GNU/BSD tar, correct on POSIX paths). The `read-write` future path needs a real `git worktree add` branched from base so the supervisor can merge changes back on `merge` disposition - DEFERRED (see Start-...'s throw). #> param( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$TreeId, [Parameter(Mandatory)][string]$EphemeralRoot ) # Resolve the REAL git dir. The governance root may be a SUBDIR of the git repo (a Specrew project # nested in a larger monorepo), where .git is NOT at $RepoRoot. Ask git for the absolute git dir and # fall back to $RepoRoot/.git only if git cannot resolve it. (iter-007 real-host dogfood: the project # WAS a subdir, so $RepoRoot/.git did not exist and `git --git-dir=... archive` failed exit 128 at the # fire step - the navigator reached FIRE but could not materialize the read-only review worktree.) $gitDir = $null $resolvedGitDir = (& git -C $RepoRoot rev-parse --absolute-git-dir 2>$null) if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($resolvedGitDir)) { $gitDir = ([string]$resolvedGitDir).Trim() } if ([string]::IsNullOrWhiteSpace($gitDir)) { $gitDir = Join-Path $RepoRoot '.git' } $worktreeDir = Join-Path $EphemeralRoot ('specrew-itask-' + [guid]::NewGuid().ToString('N')) $tarPath = "$worktreeDir.tar" New-Item -ItemType Directory -Path $worktreeDir -Force | Out-Null # Archive the tree to a file (no cross-process pipe), then extract. & git --git-dir="$gitDir" archive --format=tar --output $tarPath $TreeId 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { Remove-Item -LiteralPath $worktreeDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $tarPath -Force -ErrorAction SilentlyContinue throw "git archive failed for tree-id '$TreeId' (exit $LASTEXITCODE)" } # Pin the tar executable (see the function header): Windows -> System32 bsdtar so an absolute # C:\ archive path is not misread as a remote host:path by Git-for-Windows MSYS tar; Unix -> the # default tar. Fall back to bare `tar` only if System32 tar is somehow absent. Keep stderr (no # `| Out-Null`) so a future failure surfaces its reason in the throw instead of a bare exit code. $tarExe = if ($IsWindows) { $sys32Tar = Join-Path $env:SystemRoot 'System32\tar.exe' if (Test-Path -LiteralPath $sys32Tar -PathType Leaf) { $sys32Tar } else { 'tar' } } else { 'tar' } $tarOut = & $tarExe -xf $tarPath -C $worktreeDir 2>&1 $tarExit = $LASTEXITCODE Remove-Item -LiteralPath $tarPath -Force -ErrorAction SilentlyContinue if ($tarExit -ne 0) { Remove-Item -LiteralPath $worktreeDir -Recurse -Force -ErrorAction SilentlyContinue throw ("tar extract failed for tree-id '{0}' via '{1}' (exit {2}): {3}" -f $TreeId, $tarExe, $tarExit, (($tarOut | Out-String).Trim())) } return $worktreeDir } function Start-SpecrewIsolatedTask { <# The general launcher. Builds the review path; seams the rest. Returns run metadata IMMEDIATELY (fire-and-return); the detached supervisor does the work + disposes. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepoRoot, # The content-addressed reviewed tree-id (a git tree SHA) to materialize. [Parameter(Mandatory)][string]$TreeId, # Belt-and-suspenders: RO also launches the host in its native read-only mode upstream. Only # `read-only` is built; `read-write` (implementation, needs a branched git worktree for # merge-back) is a designed seam. [ValidateSet('read-only', 'read-write')] [string]$Access = 'read-only', # `discard` is built (review: nothing to merge). `merge`/`preserve` are designed seams. [ValidateSet('discard', 'merge', 'preserve')] [string]$Disposition = 'discard', # `code-review` is the first contract; other task kinds are a later contract registration # via the artifact+contract seam (NOT a new launcher). [string]$TaskKind = 'code-review', [Parameter(Mandatory)][int]$TimeoutSec, # The reviewer harness command to run IN the worktree. A single command string the # supervisor invokes via `pwsh -Command` with cwd = the worktree. Passed through the job # JSON (NOT -ArgumentList) to dodge cross-platform quoting hell. [Parameter(Mandatory)][string]$Command, # Where the supervisor writes status.json + result (and the job spec). A stable, caller-owned # directory (e.g. under .specrew/review/pending/<run-id>/) - NOT the ephemeral worktree. [Parameter(Mandatory)][string]$RunDir, # T100: the firing session's identity, recorded on the registry entry (session-scoped pidfile). # Defaults to the env seam (SPECREW_SESSION_ID / CLAUDE_SESSION_ID) -> 'unknown'. [string]$SessionId ) # --- policy seams: build only the review path ------------------------------------------------ if ($Access -eq 'read-write') { # DEFERRED: read-write needs a real `git worktree add -b <task-branch> <dir> <base>` so the # supervisor can capture the worktree's changes and (on `merge` disposition) 3-way merge # them back to the possibly-moved base. That is the implementation/multi-dev path # (Proposals 010/134/149), a feature of its own. Review is read-only. throw "Start-SpecrewIsolatedTask: -Access 'read-write' is not implemented (designed seam: needs a branched git worktree for merge-back; review uses read-only)." } if ($Disposition -eq 'merge') { # DEFERRED: merge -> the merge-agent. Clean -> merge + delete; conflict -> preserve + hand # off; failed -> discard. The conflict path IS the merge-agent (Proposals 010/134/149). throw "Start-SpecrewIsolatedTask: -Disposition 'merge' is not implemented (designed seam: 3-way merge-back to a moved base belongs to the merge-agent)." } if ($Disposition -eq 'preserve') { # DEFERRED: preserve keeps the worktree for human inspection of a failed/conflicted task. throw "Start-SpecrewIsolatedTask: -Disposition 'preserve' is not implemented (designed seam: human-inspection retention)." } # NOTE on -TaskKind: only `code-review` is wired today. Reviewing a plan/tasks/spec (or any # future task) is a later artifact+contract registration on this same seam, NOT a new launcher. $runId = New-SpecrewIsolatedTaskRunId $startedAt = (Get-Date).ToUniversalTime().ToString('o') $deadline = (Get-Date).ToUniversalTime().AddSeconds($TimeoutSec).ToString('o') $hostLabel = Get-SpecrewIsolatedTaskHost if ([string]::IsNullOrWhiteSpace($SessionId)) { $SessionId = Get-SpecrewIsolatedTaskSessionId } New-Item -ItemType Directory -Path $RunDir -Force | Out-Null # 1) Materialize the ephemeral worktree from the tree-id (RO export). Do this BEFORE firing so a # materialization failure surfaces synchronously to the caller (no half-fired supervisor). $ephemeralRoot = Get-SpecrewIsolatedTaskEphemeralRoot $worktreePath = New-SpecrewIsolatedTaskWorktree -RepoRoot $RepoRoot -TreeId $TreeId -EphemeralRoot $ephemeralRoot # 2) Registry entry FIRST (status=running), so a late parent write can never clobber the # supervisor's terminal update. The supervisor does a read-modify-write to a terminal status. $pendingDir = Get-SpecrewIsolatedTaskPendingDir -RepoRoot $RepoRoot New-Item -ItemType Directory -Path $pendingDir -Force | Out-Null $registryPath = Join-Path $pendingDir "$runId.json" $statusPath = Join-Path $RunDir 'status.json' $resultPath = Join-Path $RunDir 'result.out' # the child's stdout = the reviewer result $resultErrPath = Join-Path $RunDir 'result.err' $registry = [ordered]@{ schema_version = '1.0' run_id = $runId supervisor_pid = $null # filled after Start-Process host = $hostLabel session_id = $SessionId # T100: session-scoped pidfile registry task_kind = $TaskKind access = $Access disposition = $Disposition tree_id = $TreeId worktree_path = $worktreePath run_dir = $RunDir status_path = $statusPath result_path = $resultPath started_at = $startedAt deadline = $deadline status = 'running' # running -> done | timed-out | failed } Write-SpecrewFileAtomic -Path $registryPath -Content (($registry | ConvertTo-Json -Depth 8)) # The job spec the supervisor reads (NOT -ArgumentList: an arbitrary -Command string through # Start-Process quoting is cross-platform hell; a JSON file is robust + testable). $jobPath = Join-Path $RunDir 'job.json' $job = [ordered]@{ schema_version = '1.0' run_id = $runId repo_root = $RepoRoot tree_id = $TreeId access = $Access disposition = $Disposition task_kind = $TaskKind command = $Command worktree_path = $worktreePath timeout_sec = $TimeoutSec run_dir = $RunDir registry_path = $registryPath status_path = $statusPath result_path = $resultPath result_err = $resultErrPath } Write-SpecrewFileAtomic -Path $jobPath -Content (($job | ConvertTo-Json -Depth 8)) # 3) Fire the supervisor DETACHED. Redirect ITS stdio to files (cross-platform detachment: the # load-bearing T076 rule). NO -Wait. Return immediately. $supervisor = Get-SpecrewIsolatedTaskSupervisorPath $supOut = Join-Path $RunDir 'supervisor.out.log' $supErr = Join-Path $RunDir 'supervisor.err.log' $spArgs = @{ FilePath = 'pwsh' ArgumentList = @('-NoProfile', '-NonInteractive', '-File', $supervisor, '-JobPath', $jobPath) PassThru = $true RedirectStandardOutput = $supOut RedirectStandardError = $supErr } if ($IsWindows) { $spArgs.WindowStyle = 'Hidden' } # Windows-only; omit on Unix $proc = Start-Process @spArgs # Record the supervisor pid by MERGING into the CURRENT on-disk registry - NOT by rewriting the # stale pre-spawn object (co-review finding f1, run 20260708T112353271): a FAST supervisor may # already have merged child_pid/child_pgid/containment (or even a terminal status) by now, and a # stale-object rewrite would CLOBBER that evidence back to a bare 'running' record, defeating the # session-scoped pidfile registry the orphan reaper relies on (T100/SC-025). The supervisor's # terminal write additionally RE-ASSERTS the child fields, so even the microscopic read-write # window left here self-heals at terminal. try { $cur = Get-Content -LiteralPath $registryPath -Raw | ConvertFrom-Json $cur | Add-Member -NotePropertyName 'supervisor_pid' -NotePropertyValue $proc.Id -Force Write-SpecrewFileAtomic -Path $registryPath -Content (($cur | ConvertTo-Json -Depth 8)) } catch { # Unreadable current state (should not happen - we just wrote it): never lose the pid. $registry.supervisor_pid = $proc.Id Write-SpecrewFileAtomic -Path $registryPath -Content (($registry | ConvertTo-Json -Depth 8)) } # Fire-and-return: hand back the run metadata. The caller does NOT wait. return [pscustomobject]([ordered]@{ run_id = $runId supervisor_pid = $proc.Id host = $hostLabel task_kind = $TaskKind access = $Access disposition = $Disposition tree_id = $TreeId worktree_path = $worktreePath run_dir = $RunDir registry_path = $registryPath status_path = $statusPath result_path = $resultPath started_at = $startedAt deadline = $deadline status = 'running' }) } function Stop-SpecrewIsolatedTask { <# Cleanup/reaper helper the FUTURE reaper calls for zombies + orphaned worktrees from a DEAD launcher. Orphan-safety is BY CONSTRUCTION (the supervisor self-limits + disposes in a finally); this is the BACKSTOP for the case the supervisor itself was killed before it could dispose. Idempotent: kills the supervisor pid if alive, removes the worktree if present, marks the registry entry terminal. Pass either a registry path or the parsed registry object (one of -RegistryPath/-Registry). #> [CmdletBinding()] param( [string]$RegistryPath, [psobject]$Registry, [string]$Reason = 'reaped' ) if (-not $Registry) { if (-not $RegistryPath -or -not (Test-Path -LiteralPath $RegistryPath -PathType Leaf)) { throw "Stop-SpecrewIsolatedTask: provide -Registry or an existing -RegistryPath." } $Registry = Get-Content -LiteralPath $RegistryPath -Raw | ConvertFrom-Json } # 1) Kill the supervisor if still alive (zombie from a dead launcher's orphan, or a stuck loop). # On Windows this ALSO kills the reviewer tree by construction: the dying supervisor's job # handle closes and KILL_ON_JOB_CLOSE reaps every job member (T100). $supPid = $null if ($Registry.PSObject.Properties.Name -contains 'supervisor_pid') { $supPid = $Registry.supervisor_pid } if ($supPid) { $alive = $null try { $alive = Get-Process -Id ([int]$supPid) -ErrorAction Stop } catch { $alive = $null } if ($alive) { try { Stop-Process -Id ([int]$supPid) -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } } # 1b) T100: kill the recorded CHILD tree too - the case this reaper exists for is a supervisor # that DIED without disposing (no job handle left on Unix, nothing watching the pgid). The # registry is the session-scoped pidfile: child_pgid (Unix group kill) / child_pid (tree # walk) recorded by the supervisor at spawn make the orphaned reviewer tree killable here. $childPid = $null $childPgid = $null if ($Registry.PSObject.Properties.Name -contains 'child_pid') { $childPid = $Registry.child_pid } if ($Registry.PSObject.Properties.Name -contains 'child_pgid') { $childPgid = $Registry.child_pgid } if (-not $childPid -and -not $childPgid) { # T091: the detached-ENTRY path (co-review-service) is not the isolated-task supervisor, so its # registry carries no child_*; the reviewer's pids flow via heartbeat TELEMETRY into the run # dir's status.json instead. Probe it so a dead entry's reviewer tree is still killable here. try { $runDir = if ($Registry.PSObject.Properties.Name -contains 'run_dir') { [string]$Registry.run_dir } else { '' } if (-not [string]::IsNullOrWhiteSpace($runDir)) { $stPath = Join-Path $runDir 'status.json' if (Test-Path -LiteralPath $stPath -PathType Leaf) { $st = Get-Content -LiteralPath $stPath -Raw | ConvertFrom-Json $tel = if ($st.PSObject.Properties.Name -contains 'reviewer_telemetry') { $st.reviewer_telemetry } else { $null } if ($null -ne $tel) { if ($tel.PSObject.Properties.Name -contains 'child_pid') { $childPid = $tel.child_pid } if ($tel.PSObject.Properties.Name -contains 'child_pgid') { $childPgid = $tel.child_pgid } } } } } catch { $null = $_ } } if ($childPid -or $childPgid) { $treeLoaded = Import-SpecrewIsolatedTaskProcessTree if ($treeLoaded -and $childPid) { $mode = if ($childPgid -and -not $IsWindows) { 'pgid' } else { 'tree-kill' } $desc = [pscustomobject]@{ mode = $mode; child_pid = [int]$childPid; child_pgid = $childPgid; job_handle = $null } try { Stop-SpecrewProcessContainment -Containment $desc -GraceSeconds 2 } catch { $null = $_ } } elseif ($childPid) { # Degraded (helper missing): at least the direct child dies. try { Stop-Process -Id ([int]$childPid) -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } } # 2) Remove the orphaned worktree if it survived. $wt = $null if ($Registry.PSObject.Properties.Name -contains 'worktree_path') { $wt = $Registry.worktree_path } if ($wt -and (Test-Path -LiteralPath $wt)) { Remove-Item -LiteralPath $wt -Recurse -Force -ErrorAction SilentlyContinue } # 3) Mark the registry terminal so the reaper does not re-process it. if ($RegistryPath -and (Test-Path -LiteralPath $RegistryPath -PathType Leaf)) { try { $reg = Get-Content -LiteralPath $RegistryPath -Raw | ConvertFrom-Json $reg | Add-Member -NotePropertyName 'status' -NotePropertyValue $Reason -Force $reg | Add-Member -NotePropertyName 'terminal_reason' -NotePropertyValue $Reason -Force $reg | Add-Member -NotePropertyName 'reaped_at' -NotePropertyValue ((Get-Date).ToUniversalTime().ToString('o')) -Force Write-SpecrewFileAtomic -Path $RegistryPath -Content (($reg | ConvertTo-Json -Depth 8)) } catch { $null = $_ } } return [pscustomobject]([ordered]@{ run_id = ($Registry.PSObject.Properties.Name -contains 'run_id') ? $Registry.run_id : $null supervisor_pid = $supPid worktree_path = $wt worktree_gone = (-not ($wt -and (Test-Path -LiteralPath $wt))) status = $Reason }) } |