From 677ba85be39463a053ecd822d8987c6bd8abf180 Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Fri, 7 Aug 2026 12:55:07 +0200 Subject: [PATCH] fix: hand off custom agent path changes safely --- .../scripts/start-monk-agent.ps1 | 23 +- .../scripts/start-monk-agent.sh | 10 +- .github/workflows/install-e2e.yml | 8 + plugins/monk/scripts/start-monk-agent.ps1 | 23 +- plugins/monk/scripts/start-monk-agent.sh | 10 +- scripts/start-monk-agent.ps1 | 23 +- scripts/start-monk-agent.sh | 10 +- tests/start-monk-agent-path-handoff.ps1 | 252 ++++++++++++++++++ tests/start-monk-agent-path-handoff.sh | 126 +++++++++ 9 files changed, 479 insertions(+), 6 deletions(-) create mode 100644 tests/start-monk-agent-path-handoff.ps1 create mode 100755 tests/start-monk-agent-path-handoff.sh diff --git a/.antigravity-plugin/scripts/start-monk-agent.ps1 b/.antigravity-plugin/scripts/start-monk-agent.ps1 index 5522a63..d240faf 100644 --- a/.antigravity-plugin/scripts/start-monk-agent.ps1 +++ b/.antigravity-plugin/scripts/start-monk-agent.ps1 @@ -282,6 +282,23 @@ function Test-SameFilePath { } } +function Get-RecordedAgentPath { + param([string]$FallbackPath) + if (-not (Test-Path $StateFile)) { + return $FallbackPath + } + $State = Get-Content -Raw $StateFile -ErrorAction SilentlyContinue + foreach ($Line in ($State -split "`r?`n")) { + if ($Line.StartsWith("agent_path=", [StringComparison]::Ordinal)) { + $RecordedPath = $Line.Substring("agent_path=".Length) + if ($RecordedPath) { + return $RecordedPath + } + } + } + return $FallbackPath +} + function Stop-ManagedAgent { if (-not (Test-Path $PidFile)) { return @@ -313,7 +330,11 @@ function Stop-ManagedAgent { $ProcessPath = "" } - if (Test-SameFilePath $ProcessPath $AgentPath) { + # The recorded PID belongs to the executable from the previous launch. When + # MONK_AGENT_PATH changes from A to B, validate ownership against A so it can + # be stopped before B is started. + $ExpectedProcessPath = Get-RecordedAgentPath $AgentPath + if (Test-SameFilePath $ProcessPath $ExpectedProcessPath) { Stop-Process -Id $OldProcess.Id -Force -ErrorAction SilentlyContinue try { Wait-Process -Id $OldProcess.Id -Timeout 10 -ErrorAction SilentlyContinue diff --git a/.antigravity-plugin/scripts/start-monk-agent.sh b/.antigravity-plugin/scripts/start-monk-agent.sh index 8faa5ce..fe377cb 100755 --- a/.antigravity-plugin/scripts/start-monk-agent.sh +++ b/.antigravity-plugin/scripts/start-monk-agent.sh @@ -463,7 +463,15 @@ EOF start_with_background_process() { if [ -f "$pid_file" ]; then old_pid="$(cat "$pid_file" 2>/dev/null || true)" - if pid_matches_executable "$old_pid" "$agent_path" && kill -0 "$old_pid" >/dev/null 2>&1; then + # The PID belongs to the executable recorded by the previous launch, not + # necessarily the newly selected MONK_AGENT_PATH. Authenticate ownership + # against that prior path so an A -> B switch can stop A before starting B. + old_agent_path="$agent_path" + if [ -f "$state_file" ]; then + recorded_agent_path="$(sed -n 's/^agent_path=//p' "$state_file" 2>/dev/null | head -n 1)" + [ -n "$recorded_agent_path" ] && old_agent_path="$recorded_agent_path" + fi + if pid_matches_executable "$old_pid" "$old_agent_path" && kill -0 "$old_pid" >/dev/null 2>&1; then if kill "$old_pid" >/dev/null 2>&1; then stop_tries=0 while kill -0 "$old_pid" >/dev/null 2>&1 && [ "$stop_tries" -lt 10 ]; do diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml index 1f9190b..64fdbb7 100644 --- a/.github/workflows/install-e2e.yml +++ b/.github/workflows/install-e2e.yml @@ -58,6 +58,10 @@ jobs: shell: bash run: ./tests/start-monk-agent-fastpath.sh + - name: Check custom agent path handoff + shell: bash + run: ./tests/start-monk-agent-path-handoff.sh + - name: Check launcher readiness deadline shell: bash run: ./tests/start-monk-agent-readiness-timeout.sh @@ -82,6 +86,10 @@ jobs: shell: pwsh run: .\tests\start-monk-agent-readiness-timeout.ps1 + - name: Check custom agent path handoff + shell: pwsh + run: .\tests\start-monk-agent-path-handoff.ps1 + - name: Install monk-agent shell: pwsh env: diff --git a/plugins/monk/scripts/start-monk-agent.ps1 b/plugins/monk/scripts/start-monk-agent.ps1 index 5522a63..d240faf 100644 --- a/plugins/monk/scripts/start-monk-agent.ps1 +++ b/plugins/monk/scripts/start-monk-agent.ps1 @@ -282,6 +282,23 @@ function Test-SameFilePath { } } +function Get-RecordedAgentPath { + param([string]$FallbackPath) + if (-not (Test-Path $StateFile)) { + return $FallbackPath + } + $State = Get-Content -Raw $StateFile -ErrorAction SilentlyContinue + foreach ($Line in ($State -split "`r?`n")) { + if ($Line.StartsWith("agent_path=", [StringComparison]::Ordinal)) { + $RecordedPath = $Line.Substring("agent_path=".Length) + if ($RecordedPath) { + return $RecordedPath + } + } + } + return $FallbackPath +} + function Stop-ManagedAgent { if (-not (Test-Path $PidFile)) { return @@ -313,7 +330,11 @@ function Stop-ManagedAgent { $ProcessPath = "" } - if (Test-SameFilePath $ProcessPath $AgentPath) { + # The recorded PID belongs to the executable from the previous launch. When + # MONK_AGENT_PATH changes from A to B, validate ownership against A so it can + # be stopped before B is started. + $ExpectedProcessPath = Get-RecordedAgentPath $AgentPath + if (Test-SameFilePath $ProcessPath $ExpectedProcessPath) { Stop-Process -Id $OldProcess.Id -Force -ErrorAction SilentlyContinue try { Wait-Process -Id $OldProcess.Id -Timeout 10 -ErrorAction SilentlyContinue diff --git a/plugins/monk/scripts/start-monk-agent.sh b/plugins/monk/scripts/start-monk-agent.sh index 8faa5ce..fe377cb 100755 --- a/plugins/monk/scripts/start-monk-agent.sh +++ b/plugins/monk/scripts/start-monk-agent.sh @@ -463,7 +463,15 @@ EOF start_with_background_process() { if [ -f "$pid_file" ]; then old_pid="$(cat "$pid_file" 2>/dev/null || true)" - if pid_matches_executable "$old_pid" "$agent_path" && kill -0 "$old_pid" >/dev/null 2>&1; then + # The PID belongs to the executable recorded by the previous launch, not + # necessarily the newly selected MONK_AGENT_PATH. Authenticate ownership + # against that prior path so an A -> B switch can stop A before starting B. + old_agent_path="$agent_path" + if [ -f "$state_file" ]; then + recorded_agent_path="$(sed -n 's/^agent_path=//p' "$state_file" 2>/dev/null | head -n 1)" + [ -n "$recorded_agent_path" ] && old_agent_path="$recorded_agent_path" + fi + if pid_matches_executable "$old_pid" "$old_agent_path" && kill -0 "$old_pid" >/dev/null 2>&1; then if kill "$old_pid" >/dev/null 2>&1; then stop_tries=0 while kill -0 "$old_pid" >/dev/null 2>&1 && [ "$stop_tries" -lt 10 ]; do diff --git a/scripts/start-monk-agent.ps1 b/scripts/start-monk-agent.ps1 index 5522a63..d240faf 100644 --- a/scripts/start-monk-agent.ps1 +++ b/scripts/start-monk-agent.ps1 @@ -282,6 +282,23 @@ function Test-SameFilePath { } } +function Get-RecordedAgentPath { + param([string]$FallbackPath) + if (-not (Test-Path $StateFile)) { + return $FallbackPath + } + $State = Get-Content -Raw $StateFile -ErrorAction SilentlyContinue + foreach ($Line in ($State -split "`r?`n")) { + if ($Line.StartsWith("agent_path=", [StringComparison]::Ordinal)) { + $RecordedPath = $Line.Substring("agent_path=".Length) + if ($RecordedPath) { + return $RecordedPath + } + } + } + return $FallbackPath +} + function Stop-ManagedAgent { if (-not (Test-Path $PidFile)) { return @@ -313,7 +330,11 @@ function Stop-ManagedAgent { $ProcessPath = "" } - if (Test-SameFilePath $ProcessPath $AgentPath) { + # The recorded PID belongs to the executable from the previous launch. When + # MONK_AGENT_PATH changes from A to B, validate ownership against A so it can + # be stopped before B is started. + $ExpectedProcessPath = Get-RecordedAgentPath $AgentPath + if (Test-SameFilePath $ProcessPath $ExpectedProcessPath) { Stop-Process -Id $OldProcess.Id -Force -ErrorAction SilentlyContinue try { Wait-Process -Id $OldProcess.Id -Timeout 10 -ErrorAction SilentlyContinue diff --git a/scripts/start-monk-agent.sh b/scripts/start-monk-agent.sh index 8faa5ce..fe377cb 100755 --- a/scripts/start-monk-agent.sh +++ b/scripts/start-monk-agent.sh @@ -463,7 +463,15 @@ EOF start_with_background_process() { if [ -f "$pid_file" ]; then old_pid="$(cat "$pid_file" 2>/dev/null || true)" - if pid_matches_executable "$old_pid" "$agent_path" && kill -0 "$old_pid" >/dev/null 2>&1; then + # The PID belongs to the executable recorded by the previous launch, not + # necessarily the newly selected MONK_AGENT_PATH. Authenticate ownership + # against that prior path so an A -> B switch can stop A before starting B. + old_agent_path="$agent_path" + if [ -f "$state_file" ]; then + recorded_agent_path="$(sed -n 's/^agent_path=//p' "$state_file" 2>/dev/null | head -n 1)" + [ -n "$recorded_agent_path" ] && old_agent_path="$recorded_agent_path" + fi + if pid_matches_executable "$old_pid" "$old_agent_path" && kill -0 "$old_pid" >/dev/null 2>&1; then if kill "$old_pid" >/dev/null 2>&1; then stop_tries=0 while kill -0 "$old_pid" >/dev/null 2>&1 && [ "$stop_tries" -lt 10 ]; do diff --git a/tests/start-monk-agent-path-handoff.ps1 b/tests/start-monk-agent-path-handoff.ps1 new file mode 100644 index 0000000..6fab2ee --- /dev/null +++ b/tests/start-monk-agent-path-handoff.ps1 @@ -0,0 +1,252 @@ +$ErrorActionPreference = "Stop" + +# Regression coverage for switching between two custom MONK_AGENT_PATH values. +# The launcher must authenticate the recorded PID against the executable that +# started it, stop that process, and then hand ownership to the new executable. + +$Repo = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$Root = Join-Path ([IO.Path]::GetTempPath()) ("monk-path-handoff-" + [guid]::NewGuid().ToString("N")) +$SourcePath = Join-Path $Root "fake-agent.cs" +$OldAgentPath = Join-Path $Root "old-agent.exe" +$NewAgentPath = Join-Path $Root "new-agent.exe" +$MonkHome = Join-Path $Root "home" +$RunDir = Join-Path $MonkHome "agent\launcher\run" +$PidFile = Join-Path $RunDir "monk-agent.pid" +$StateFile = Join-Path $RunDir "monk-agent.state" +$NewPidFile = Join-Path $Root "new-agent.pid" +$StdoutPath = Join-Path $Root "launcher.out.log" +$StderrPath = Join-Path $Root "launcher.err.log" +$OldProcess = $null +$NewProcess = $null +$Launcher = $null + +$EnvironmentNames = @( + "MONK_AGENT_HOME", + "MONK_AGENT_PATH", + "MONK_AGENT_PORT", + "MONK_AGENT_READY_TIMEOUT", + "MONK_AGENT_SKIP_ENSURE", + "MONK_AGENT_SKIP_SIGNIN_NUDGE", + "MONK_PLUGIN_VERSION", + "NEW_AGENT_PID_FILE", + "NO_COLOR" +) +$OriginalEnvironment = @{} +foreach ($Name in $EnvironmentNames) { + $OriginalEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process") +} + +function Test-ProcessAlive { + param([int]$ProcessId) + return $null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) +} + +try { + New-Item -ItemType Directory -Force -Path $Root, $RunDir | Out-Null + + # Both files contain the same small companion. The first occupies the health + # endpoint. The replacement retries that endpoint briefly, then exits cleanly + # if its predecessor was not stopped, matching the real companion's behavior. + @" +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +class Program +{ + static int Main(string[] args) + { + bool oldMode = args.Length > 0 && args[0] == "old"; + int port = 0; + if (oldMode) + { + port = int.Parse(args[1]); + } + else + { + for (int i = 0; i + 1 < args.Length; i++) + { + if (args[i] == "--port") + { + port = int.Parse(args[i + 1]); + break; + } + } + string pidFile = Environment.GetEnvironmentVariable("NEW_AGENT_PID_FILE"); + if (!String.IsNullOrEmpty(pidFile)) + { + File.WriteAllText(pidFile, Process.GetCurrentProcess().Id.ToString()); + } + } + + TcpListener listener = null; + for (int attempt = 0; attempt < 10; attempt++) + { + try + { + listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + break; + } + catch (SocketException) + { + if (listener != null) listener.Stop(); + listener = null; + Thread.Sleep(250); + } + } + if (listener == null) return 0; + + string body = "{\"resource\":\"http://127.0.0.1:" + port + "/mcp\"}"; + byte[] payload = Encoding.UTF8.GetBytes(body); + while (true) + { + using (TcpClient client = listener.AcceptTcpClient()) + using (NetworkStream stream = client.GetStream()) + { + byte[] request = new byte[4096]; + stream.Read(request, 0, request.Length); + string headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + payload.Length + "\r\nConnection: close\r\n\r\n"; + byte[] headerBytes = Encoding.ASCII.GetBytes(headers); + stream.Write(headerBytes, 0, headerBytes.Length); + stream.Write(payload, 0, payload.Length); + } + } + } +} +"@ | Set-Content -Encoding UTF8 $SourcePath + + $Csc = Join-Path $env:WINDIR "Microsoft.NET\Framework64\v4.0.30319\csc.exe" + if (-not (Test-Path $Csc)) { + $Csc = (Get-ChildItem "$env:WINDIR\Microsoft.NET\Framework64" -Filter csc.exe -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName) + } + if (-not $Csc) { + throw "csc.exe not found; cannot build the custom path handoff fixture" + } + & $Csc /nologo /out:$OldAgentPath $SourcePath | Out-Null + if (-not (Test-Path $OldAgentPath)) { + throw "failed to compile old-agent.exe" + } + Copy-Item -LiteralPath $OldAgentPath -Destination $NewAgentPath + + $PortProbe = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $PortProbe.Start() + $Port = ([Net.IPEndPoint]$PortProbe.LocalEndpoint).Port + $PortProbe.Stop() + + $OldProcess = Start-Process -FilePath $OldAgentPath -ArgumentList @("old", $Port) -PassThru + $HealthUrl = "http://127.0.0.1:$Port/.well-known/oauth-protected-resource" + $Ready = $false + for ($Attempt = 0; $Attempt -lt 20; $Attempt++) { + try { + $Response = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -NoProxy -TimeoutSec 1 + if ($Response.Content -match '"resource"') { + $Ready = $true + break + } + } catch { + Start-Sleep -Milliseconds 100 + } + } + if (-not $Ready) { + throw "old custom companion did not become ready" + } + + $OldProcess.Id | Set-Content -NoNewline -Encoding ASCII $PidFile + . (Join-Path $Repo "scripts\plugin-version.ps1") + @( + "agent_path=$OldAgentPath", + "auth_url=https://auth.monk.io", + "auth_client_id=UW84YWcJME3buMSLfqLX8IbBsYdNWi47", + "auth_audience=oaknode.com", + "autospin_url=wss://api.app.monk.io/autospin/", + "plugin_version=$env:MONK_PLUGIN_VERSION" + ) -join "`n" | Set-Content -NoNewline -Encoding ASCII $StateFile + + $env:MONK_AGENT_HOME = $MonkHome + $env:MONK_AGENT_PATH = $NewAgentPath + $env:MONK_AGENT_PORT = [string]$Port + $env:MONK_AGENT_READY_TIMEOUT = "10" + $env:MONK_AGENT_SKIP_ENSURE = "1" + $env:MONK_AGENT_SKIP_SIGNIN_NUDGE = "1" + $env:NEW_AGENT_PID_FILE = $NewPidFile + $env:NO_COLOR = "1" + + $LauncherPath = Join-Path $Repo "scripts\start-monk-agent.ps1" + $ChildCommand = "`$ErrorView = 'NormalView'; & '$LauncherPath'" + $Launcher = Start-Process -FilePath (Get-Process -Id $PID).Path ` + -ArgumentList @("-NoLogo", "-NoProfile", "-Command", $ChildCommand) ` + -PassThru -RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath + $Finished = $Launcher.WaitForExit(20000) + if (-not $Finished) { + throw "launcher did not exit within the bounded test wait" + } + $Output = (Get-Content -Raw $StdoutPath -ErrorAction SilentlyContinue) + + (Get-Content -Raw $StderrPath -ErrorAction SilentlyContinue) + if ($Launcher.ExitCode -ne 0) { + throw "expected launcher exit 0, got $($Launcher.ExitCode): $Output" + } + + for ($Attempt = 0; $Attempt -lt 20 -and -not (Test-Path $NewPidFile); $Attempt++) { + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $NewPidFile)) { + throw "replacement custom companion did not record its PID" + } + $NewPid = [int](Get-Content -Raw $NewPidFile) + $NewProcess = Get-Process -Id $NewPid -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + + if (Test-ProcessAlive $OldProcess.Id) { + if (-not (Test-ProcessAlive $NewPid)) { + throw "launcher returned success against the old custom companion while the replacement exited" + } + throw "old custom companion remained alive after MONK_AGENT_PATH changed" + } + if (-not (Test-ProcessAlive $NewPid)) { + throw "replacement custom companion is not running" + } + if ([int](Get-Content -Raw $PidFile) -ne $NewPid) { + throw "launcher PID file does not belong to the replacement companion" + } + $State = Get-Content -Raw $StateFile + if (($State -split "`r?`n") -cnotcontains "agent_path=$NewAgentPath") { + throw "launcher state does not record the replacement custom path" + } + + $ShippedCopies = @( + ".antigravity-plugin\scripts\start-monk-agent.ps1", + "plugins\monk\scripts\start-monk-agent.ps1" + ) + $ExpectedHash = (Get-FileHash -Algorithm SHA256 $LauncherPath).Hash + foreach ($RelativePath in $ShippedCopies) { + $CopyHash = (Get-FileHash -Algorithm SHA256 (Join-Path $Repo $RelativePath)).Hash + if ($CopyHash -ne $ExpectedHash) { + throw "shipped PowerShell launcher differs: $RelativePath" + } + } + + Write-Host "custom_agent_path_handoff_status=pass" +} finally { + foreach ($Process in @($Launcher, $NewProcess, $OldProcess)) { + if ($null -ne $Process) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + } + } + if (Test-Path $PidFile) { + $RecordedPid = (Get-Content -Raw $PidFile).Trim() + if ($RecordedPid -match "^[0-9]+$") { + Stop-Process -Id ([int]$RecordedPid) -Force -ErrorAction SilentlyContinue + } + } + foreach ($Name in $EnvironmentNames) { + [Environment]::SetEnvironmentVariable($Name, $OriginalEnvironment[$Name], "Process") + } + Remove-Item -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/tests/start-monk-agent-path-handoff.sh b/tests/start-monk-agent-path-handoff.sh new file mode 100755 index 0000000..ee906de --- /dev/null +++ b/tests/start-monk-agent-path-handoff.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env sh +# Regression coverage for switching between two custom MONK_AGENT_PATH values. +# The launcher must authenticate the recorded PID against the executable that +# started it, stop that process, and then hand ownership to the new executable. +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +fixture_bin="$repo_root/tests/fixtures/start-monk-agent" +work_dir="$(mktemp -d)" +helper_bin="$work_dir/bin" +agent_home="$work_dir/monk" +run_dir="$agent_home/agent/launcher/run" +new_pid_file="$work_dir/new-agent.pid" +old_pid="" +new_pid="" + +cleanup() { + if [ -z "$new_pid" ] && [ -f "$run_dir/monk-agent.pid" ]; then + new_pid="$(cat "$run_dir/monk-agent.pid" 2>/dev/null || true)" + fi + for candidate in "$new_pid" "$old_pid"; do + case "$candidate" in + ''|*[!0-9]*) continue ;; + esac + kill "$candidate" >/dev/null 2>&1 || true + done + rm -rf "$work_dir" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "$helper_bin" "$run_dir" "$work_dir/home" + +# The shipped PID ownership check is Linux-only. This fixture selects that +# branch on every CI host and models the two readlink operations it performs: +# /proc//exe resolves to the old custom executable, while readlink -f +# canonicalizes the recorded path. +cat >"$helper_bin/readlink" <<'EOF' +#!/usr/bin/env sh +case "${1:-}" in + -f) printf '%s\n' "$2" ;; + /proc/*/exe) printf '%s\n' "$OLD_AGENT_PATH" ;; + *) exit 1 ;; +esac +EOF +chmod +x "$helper_bin/readlink" + +# The replacement stays alive long enough for the launcher to complete its +# readiness check and for this test to verify that the PID/state handoff won. +new_agent="$work_dir/new-monk-agent" +cat >"$new_agent" <<'EOF' +#!/usr/bin/env sh +printf '%s\n' "$$" >"$NEW_AGENT_PID_FILE" +# Match monk-agent's healthy-port-conflict behavior: briefly allow a stopping +# predecessor to release the endpoint, then defer with a clean exit if it is +# still alive. +attempt=0 +while kill -0 "$OLD_COMPANION_PID" >/dev/null 2>&1 && [ "$attempt" -lt 2 ]; do + sleep 1 + attempt=$((attempt + 1)) +done +if kill -0 "$OLD_COMPANION_PID" >/dev/null 2>&1; then + exit 0 +fi +while :; do sleep 1; done +EOF +chmod +x "$new_agent" + +old_agent=/usr/bin/yes +"$old_agent" >/dev/null 2>&1 & +old_pid="$!" + +cat >"$run_dir/monk-agent.state" <"$run_dir/monk-agent.pid" + +HOME="$work_dir/home" \ +PATH="$helper_bin:$fixture_bin:/usr/bin:/bin" \ +OLD_AGENT_PATH="$old_agent" \ +OLD_COMPANION_PID="$old_pid" \ +NEW_AGENT_PID_FILE="$new_pid_file" \ +MONK_AGENT_PATH="$new_agent" \ +MONK_AGENT_HOME="$agent_home" \ +MONK_AGENT_SKIP_SIGNIN_NUDGE=1 \ + "$repo_root/scripts/start-monk-agent.sh" + +attempt=0 +while [ ! -s "$new_pid_file" ] && [ "$attempt" -lt 5 ]; do + sleep 1 + attempt=$((attempt + 1)) +done +if [ ! -s "$new_pid_file" ]; then + echo "replacement custom companion did not record its PID" >&2 + exit 1 +fi +new_pid="$(cat "$new_pid_file")" + +# Allow the replacement's bounded handoff check to finish before inspecting +# which process owns the lifecycle. +sleep 3 +if kill -0 "$old_pid" >/dev/null 2>&1; then + if ! kill -0 "$new_pid" >/dev/null 2>&1; then + echo "launcher returned success against the old custom companion while the replacement exited" >&2 + else + echo "old custom companion remained alive after MONK_AGENT_PATH changed" >&2 + fi + exit 1 +fi +if ! kill -0 "$new_pid" >/dev/null 2>&1; then + echo "replacement custom companion is not running" >&2 + exit 1 +fi +if [ "$(cat "$run_dir/monk-agent.pid")" != "$new_pid" ]; then + echo "launcher PID file does not belong to the replacement companion" >&2 + exit 1 +fi +if ! grep -Fxq "agent_path=$new_agent" "$run_dir/monk-agent.state"; then + echo "launcher state does not record the replacement custom path" >&2 + exit 1 +fi + +echo "custom agent path handoff test passed."