diff --git a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js
index 24e15ef3ad..28fb8c2d86 100644
--- a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js
+++ b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp-bridge.js
@@ -614,7 +614,7 @@ function sameRuntimeNodeLeaseExists(runtimeFile, key) {
function cleanupLastDetach(runtimeFile, key, cleanupScript) {
if (sameRuntimeNodeLeaseExists(runtimeFile, key)) return;
const script = cleanupScript || path.join(__dirname, "start-sympp-mcp.ps1");
- const args = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-CleanupRuntimeKey", key, "-PreserveCurrentArtifactRuntime"];
+ const args = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-CleanupRuntimeKey", key];
let result = spawnSync("pwsh.exe", args, { stdio: ["ignore", "ignore", "inherit"] });
if (result.error && result.error.code === "ENOENT") result = spawnSync("powershell.exe", args, { stdio: ["ignore", "ignore", "inherit"] });
if (result.error || result.status !== 0) diagnostic(`Symphony++ last-detach cleanup failed: ${result.error ? result.error.message : `exit ${result.status}`}`);
diff --git a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1 b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1
index fbc9fb41c7..db44803c17 100644
--- a/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1
+++ b/plugins/symphony-plus-plus-mcp/scripts/start-sympp-mcp.ps1
@@ -3,7 +3,6 @@ param(
[switch]$ValidateOnly,
[switch]$PrepareRuntimeOnly,
[switch]$CleanupPreparedRuntime,
- [switch]$PreserveCurrentArtifactRuntime,
[string]$CleanupRuntimeKey
)
@@ -1304,10 +1303,9 @@ function Test-RuntimeStateExternalLoopback($RuntimeState) {
[string]$RuntimeState.frontend.status -eq "external_loopback"
}
-function Test-BackendShouldShutdownOnIdle($BackendPlan, $DashboardPlan, [string]$RuntimeMode) {
+function Test-BackendShouldShutdownOnIdle($BackendPlan, $DashboardPlan) {
return ($BackendPlan.managed -eq $true) -and
- ($DashboardPlan.managed -ne $true) -and
- -not ($RuntimeMode -eq "artifact" -and [string]$DashboardPlan.status -eq "artifact_static")
+ ($DashboardPlan.managed -ne $true)
}
function Test-RuntimeEntryEndpointMatches([string]$Role, $Entry, [string]$Endpoint) {
@@ -1610,7 +1608,7 @@ function Get-ManagedListenerPid([string]$Role, [int]$Port) {
return $null
}
-function Stop-ManagedServersIfUnused([string]$RuntimeFile, [string]$RuntimeKey, [bool]$PreserveCurrentArtifactRuntime = $false) {
+function Stop-ManagedServersIfUnused([string]$RuntimeFile, [string]$RuntimeKey) {
$lock = Enter-FileLock (Resolve-StartupLockFile $RuntimeFile) 30
try {
$activeLeases = @(Get-ActiveBridgeLeases $RuntimeFile)
@@ -1628,11 +1626,7 @@ function Stop-ManagedServersIfUnused([string]$RuntimeFile, [string]$RuntimeKey,
if ((Test-ActiveLegacyBridgeLease $activeLeases) -or (Test-ActiveBridgeLeaseForRuntimeKey $activeLeases $RuntimeKey)) {
return
}
- $preserveCurrent = $PreserveCurrentArtifactRuntime -and
- [string]$state.runtime_kind -eq "artifact" -and
- [string]$state.runtime_mode -eq "artifact" -and
- [string]$state.frontend.status -eq "artifact_static"
- if ([System.StringComparer]::OrdinalIgnoreCase.Equals($stateKey, $RuntimeKey) -and -not $preserveCurrent) {
+ if ([System.StringComparer]::OrdinalIgnoreCase.Equals($stateKey, $RuntimeKey)) {
[void](Stop-CurrentManagedRuntimeStateEntries $state $activeLeases)
}
$supersededStates = Stop-SupersededRuntimeStatesIfUnused $RuntimeFile (Get-SupersededRuntimeStates $state)
@@ -2075,7 +2069,7 @@ function Invoke-WarmAttachFromRuntimeState {
} finally {
Remove-BridgeLease $leasePath
if ($attached) {
- Stop-ManagedServersIfUnused $RuntimeFile $identity.runtime_key $true
+ Stop-ManagedServersIfUnused $RuntimeFile $identity.runtime_key
}
}
}
@@ -2242,7 +2236,7 @@ if ($Help) {
}
if (-not [string]::IsNullOrWhiteSpace($CleanupRuntimeKey)) {
- Stop-ManagedServersIfUnused (Resolve-RuntimeFile) $CleanupRuntimeKey ([bool]$PreserveCurrentArtifactRuntime)
+ Stop-ManagedServersIfUnused (Resolve-RuntimeFile) $CleanupRuntimeKey
exit 0
}
if ($CleanupPreparedRuntime) {
@@ -2549,7 +2543,7 @@ try {
[void](Initialize-ElixirRuntime $elixirDir $launcher $mix $mise $logDir $elixirSetupTimeout)
}
$backendDashboardOrigin = if ($runtimeMode -eq "artifact" -and [string]$dashboardPlan.status -eq "artifact_static") { $null } else { $dashboardPlan.origin }
- $backendShutdownOnIdle = Test-BackendShouldShutdownOnIdle $backendPlan $dashboardPlan $runtimeMode
+ $backendShutdownOnIdle = Test-BackendShouldShutdownOnIdle $backendPlan $dashboardPlan
$startingRuntimeRoot = if ($runtimeMode -eq "artifact") { [string]$artifactRuntime.root } else { [string]$repoRoot }
if ($installedHttpCold) {
$startingState.runtime_mode = $runtimeMode; $startingState.runtime_kind = $runtimeMode
@@ -2695,5 +2689,5 @@ try {
Invoke-HttpMcpBridge $backendPlan.mcp_url $bridgeTimeout (New-McpClientLeaseId) $clientHeartbeatInterval
} finally {
Remove-BridgeLease $bridgeLeasePath
- Stop-ManagedServersIfUnused $runtimeFile $runtimeKey $true
+ Stop-ManagedServersIfUnused $runtimeFile $runtimeKey
}
diff --git a/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js b/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js
index a920fd2ab9..b8d489cce0 100644
--- a/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js
+++ b/plugins/symphony-plus-plus-mcp/tests/launcher/cold-start-singleton-smoke.js
@@ -66,6 +66,14 @@ async function freePort() {
return port;
}
+function portAvailable(port) {
+ return new Promise((resolve) => {
+ const server = net.createServer();
+ server.once("error", () => resolve(false));
+ server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
+ });
+}
+
async function barrierClient() {
const [, , , barrier, launcher] = process.argv;
process.stderr.write("BARRIER_READY\n");
@@ -297,21 +305,21 @@ async function runCase(clientCount, shell, mode = "normal") {
} finally {
clearTimeout(readyTimeout);
}
+ const activeBackend = readJson(backendState);
+ const ownersResult = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", "@(Get-NetTCPConnection -LocalPort $env:FIXTURE_PORT -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique) | ConvertTo-Json -Compress"], { env: { ...process.env, FIXTURE_PORT: String(backendPort) }, encoding: "utf8", windowsHide: true });
+ assert.equal(ownersResult.status, 0, ownersResult.stderr);
+ assert.deepEqual([].concat(JSON.parse(ownersResult.stdout.trim())), [activeBackend.pid]);
for (const client of clients) { try { client.child.stdin.end(); } catch (_) { } }
const results = await Promise.all(clients.map((client) => client.result));
const expectedFailures = mode.endsWith("_death") ? 1 : 0;
assert.equal(results.filter((result) => result.code !== 0).length, expectedFailures, results.map((result) => result.stderr).join("\n"));
assert.equal(results.filter((result) => result.code === 0).length, clientCount);
await waitFor(() => readJson(backendState)?.active_leases === 0, "Backend leases did not drain.");
- const state = readJson(runtimeFile);
const backend = readJson(backendState);
backendPid = backend.pid;
- const ownersResult = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", "@(Get-NetTCPConnection -LocalPort $env:FIXTURE_PORT -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique) | ConvertTo-Json -Compress"], { env: { ...process.env, FIXTURE_PORT: String(backendPort) }, encoding: "utf8", windowsHide: true });
- assert.equal(ownersResult.status, 0, ownersResult.stderr);
- const owners = JSON.parse(ownersResult.stdout.trim());
- assert.deepEqual([].concat(owners), [backendPid]);
+ await waitFor(() => portAvailable(backendPort), "Backend listener did not stop after the final client exited.");
+ const state = await waitFor(() => { const value = readJson(runtimeFile); return value?.backend?.status === "stopped" && value.backend.pid === null && value; }, "Runtime state did not record zero-client backend shutdown.");
assert.equal(state.publication.status, "ready");
- assert.equal(state.backend.pid, backendPid);
assert.equal(backend.starts, 1);
assert.equal(backend.initialize, clientCount);
assert.equal(backend.tools_list, clientCount);
@@ -336,7 +344,7 @@ async function runCase(clientCount, shell, mode = "normal") {
for (const directory of [symppHome, environment.TEMP]) if (fs.existsSync(directory)) for (const entry of fs.readdirSync(directory, { recursive: true })) if (/artifact\.zip\.tmp-|\.extracting-|codex-plugin\.json\.tmp-/.test(String(entry))) leftovers.push(entry);
assert.deepEqual(leftovers, []);
assert.ok(percentile(latencies, 0.95) < 60000 && Math.max(...latencies) < 90000);
- return { mode, shell: path.basename(shell), clients: clientCount, p95_ms: percentile(latencies, 0.95), max_ms: Math.max(...latencies), manifest: channel.counts.manifest_successes, manifest_attempts: channel.counts.manifest_attempts, artifact: channel.counts.archive_successes, artifact_attempts: channel.counts.archive_attempts, preparations: traceCount(traceDir, "artifact_prepare_end"), backends: backend.starts, pids: 1, listeners: 1, initializes: backend.initialize, tools_list: backend.tools_list, lease_peak: backend.lease_peak, leases_after: backend.active_leases, adopted: traceCount(traceDir, "backend_adopted") };
+ return { mode, shell: path.basename(shell), clients: clientCount, p95_ms: percentile(latencies, 0.95), max_ms: Math.max(...latencies), manifest: channel.counts.manifest_successes, manifest_attempts: channel.counts.manifest_attempts, artifact: channel.counts.archive_successes, artifact_attempts: channel.counts.archive_attempts, preparations: traceCount(traceDir, "artifact_prepare_end"), backends: backend.starts, pids: 1, listeners: 0, initializes: backend.initialize, tools_list: backend.tools_list, lease_peak: backend.lease_peak, leases_after: backend.active_leases, adopted: traceCount(traceDir, "backend_adopted") };
} finally {
terminateTrees(clients.filter((client) => client.child.exitCode === null).map((client) => client.child.pid));
if (!backendPid) backendPid = readJson(backendState)?.pid || 0;
diff --git a/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1 b/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1
index e36d1003bd..ddafeab4d2 100644
--- a/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1
+++ b/plugins/symphony-plus-plus-mcp/tests/launcher/persistent-artifact-runtime-smoke.ps1
@@ -1,4 +1,3 @@
-param([int]$IdleMilliseconds = 1500)
$ErrorActionPreference = "Stop"
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "../../../.."))
$pluginRoot = Join-Path $repoRoot "plugins/symphony-plus-plus-mcp"
@@ -29,7 +28,7 @@ function Start-IsolatedProcess([string]$FilePath, [string[]]$Arguments, [hashtab
$psi.RedirectStandardError = $RedirectStreams
foreach ($key in @($psi.Environment.Keys)) {
if ([string]$key -match "(?i)(TOKEN|SECRET|API_KEY|AUTHORIZATION|GITHUB|LINEAR|OPENAI)" -or
- [string]$key -in @("SYMPP_REPO_ROOT", "SYMPP_BACKEND_PORT", "SYMPP_DASHBOARD_PORT", "SYMPP_BACKEND_URL", "SYMPP_DASHBOARD_ORIGIN")) {
+ [string]$key -in @("SYMPP_REPO_ROOT", "SYMPP_DATABASE", "SYMPP_SOURCE_FALLBACK", "SYMPP_ARTIFACT_RUNTIME", "SYMPP_BACKEND_PORT", "SYMPP_DASHBOARD_PORT", "SYMPP_BACKEND_URL", "SYMPP_DASHBOARD_ORIGIN")) {
[void]$psi.Environment.Remove([string]$key)
}
}
@@ -63,8 +62,15 @@ function Invoke-McpBridge([string]$FilePath, [string[]]$Arguments, [hashtable]$E
$process.StandardInput.Flush()
$line = $process.StandardOutput.ReadLineAsync()
if (-not $line.Wait(60000)) { throw "Timed out waiting for MCP response from $FilePath" }
+ if ($null -eq $line.Result) {
+ [void]$process.WaitForExit(5000)
+ throw "$FilePath closed before its MCP response: $($stderr.GetAwaiter().GetResult())"
+ }
$line.Result | ConvertFrom-Json
}
+ $activeState = Get-Content -LiteralPath $Environment.SYMPP_RUNTIME_FILE -Raw | ConvertFrom-Json
+ $activeBackend = Get-Process -Id ([int]$activeState.backend.pid) -ErrorAction Stop
+ $activeBackendStartTicks = $activeBackend.StartTime.ToUniversalTime().Ticks
$process.StandardInput.Close()
if (-not $process.WaitForExit(60000)) { $process.Kill($true); throw "Bridge did not exit after stdin closed: $FilePath" }
$errorText = $stderr.GetAwaiter().GetResult()
@@ -72,7 +78,12 @@ function Invoke-McpBridge([string]$FilePath, [string[]]$Arguments, [hashtable]$E
if ($responses[0].result.protocolVersion -ne "2025-03-26" -or @($responses[1].result.tools).Count -eq 0) {
throw "Bridge did not complete real initialize plus tools/list."
}
- return $errorText
+ return [pscustomobject]@{
+ backend_pid = [int]$activeState.backend.pid
+ backend_start_ticks = $activeBackendStartTicks
+ runtime_mode = [string]$activeState.runtime_mode
+ artifact_root = [string]$activeState.artifact.root
+ }
} finally {
if (-not $process.HasExited) { $process.Kill($true) }
$process.Dispose()
@@ -83,10 +94,18 @@ function Get-Sha256([string]$Value) {
return [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Value))).ToLowerInvariant()
}
-function Wait-ProcessStopped([int]$ProcessIdValue) {
+function Test-PortAvailable([int]$Port) {
+ $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
+ try { $listener.Start(); return $true } catch { return $false } finally { $listener.Stop() }
+}
+
+function Wait-ManagedRuntimeStopped([int]$ProcessIdValue, [int]$Port) {
$deadline = [DateTime]::UtcNow.AddSeconds(30)
- while ((Get-Process -Id $ProcessIdValue -ErrorAction SilentlyContinue) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 }
- if (Get-Process -Id $ProcessIdValue -ErrorAction SilentlyContinue) { throw "Managed backend pid=$ProcessIdValue did not stop." }
+ while ([DateTime]::UtcNow -lt $deadline) {
+ if (-not (Get-Process -Id $ProcessIdValue -ErrorAction SilentlyContinue) -and (Test-PortAvailable $Port)) { return }
+ Start-Sleep -Milliseconds 100
+ }
+ throw "Managed runtime pid=$ProcessIdValue or listener port=$Port did not stop."
}
try {
@@ -116,7 +135,7 @@ try {
$backendStartTicks = (Get-Process -Id $backendProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks
$frontendProcessId = [int]$state.frontend.pid
Stop-Process -Id $frontendProcessId -Force -ErrorAction Stop
- Wait-ProcessStopped $frontendProcessId
+ Wait-ManagedRuntimeStopped $frontendProcessId $dashboardPort
$frontendProcessId = $null
$contract = [string]$state.backend.contract_fingerprint
$backend = ([string]$state.backend.url).TrimEnd("/")
@@ -131,8 +150,41 @@ try {
}
$revision = "b" * 40
New-Item -ItemType Directory -Path (Join-Path $sourceRoot "elixir/priv/symphony_plus_plus") -Force | Out-Null
+ "[]" | Set-Content -LiteralPath (Join-Path $sourceRoot "elixir/mix.exs") -NoNewline
@{ revision = $revision } | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $sourceRoot ".codex-marketplace-install.json")
@{ mcp_contract_fingerprint = $contract } | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $sourceRoot "elixir/priv/symphony_plus_plus/mcp_contract.json")
+ $artifactPayload = Join-Path $tempRoot "artifact-payload"
+ $artifactArchive = Join-Path $tempRoot "artifact.zip"
+ $dashboardHtml = "
Symphony++ Dashboard"
+ New-Item -ItemType Directory -Path (Join-Path $artifactPayload "dashboard") -Force | Out-Null
+ @'
+"use strict";
+const http=require("http"),a=process.argv.slice(2),arg=n=>a[a.indexOf(n)+1],port=Number(arg("--port")),contract=arg("--contract"),revision=arg("--revision"),session="artifact-fixture";
+const body=r=>new Promise(q=>{const c=[];r.on("data",x=>c.push(x));r.on("end",()=>q(Buffer.concat(c).toString("utf8")));});
+const send=(r,s,v,h={})=>{const b=typeof v==="string"?v:JSON.stringify(v);r.writeHead(s,{"Content-Type":"application/json","Content-Length":Buffer.byteLength(b),...h});r.end(b);};
+const server=http.createServer(async(req,res)=>{
+ if(req.url==="/shutdown"){send(res,200,{status:"stopping"});return server.close(()=>process.exit(0));}
+ if(req.url==="/mcp/readiness")return send(res,200,{status:"ok",ledger:{reachable:true},dashboard:{ready:true},source:{revision,mcp_contract:{fingerprint:contract}}});
+ if(req.url==="/sympp/board")return send(res,200,"Symphony++ Dashboard",{"Content-Type":"text/html"});
+ if(req.url==="/mcp/client-lease"){await body(req);return send(res,200,{stale_after_ms:600000});}
+ if(req.url==="/mcp"){const p=JSON.parse(await body(req)),result=p.method==="initialize"?{protocolVersion:"2025-03-26",capabilities:{},serverInfo:{name:"artifact-fixture",version:"1"}}:p.method==="tools/list"?{tools:[{name:"fixture",description:"fixture",inputSchema:{type:"object"}}]}:null;return result?send(res,200,{jsonrpc:"2.0",id:p.id,result},{"Mcp-Session-Id":session}):send(res,404,{error:"missing"});}
+ send(res,404,{error:"not found"});
+});
+server.listen(port,"127.0.0.1");
+'@ | Set-Content -LiteralPath (Join-Path $artifactPayload "backend.js") -NoNewline
+ "@echo off`r`nnode `"%~dp0backend.js`" %*`r`n" | Set-Content -LiteralPath (Join-Path $artifactPayload "start-runtime.cmd") -NoNewline
+ $dashboardHtml | Set-Content -LiteralPath (Join-Path $artifactPayload "dashboard/index.html") -NoNewline
+ Compress-Archive -Path (Join-Path $artifactPayload "*") -DestinationPath $artifactArchive
+ $artifactSha = (Get-FileHash -LiteralPath $artifactArchive -Algorithm SHA256).Hash.ToLowerInvariant()
+ $dashboardFingerprint = Get-Sha256 "index.html $(Get-Sha256 $dashboardHtml)"
+ New-Item -ItemType Directory -Path (Join-Path $installedRoot "assets") -Force | Out-Null
+ @{
+ schema_version = 1; source_revision = $revision; launcher_contract = @{ mcp_contract_fingerprint = $contract }
+ artifacts = @(@{ platform = "windows-x86_64"; source_revision = $revision; mcp_contract_fingerprint = $contract
+ path = $artifactArchive; sha256 = $artifactSha; entrypoint = "start-runtime.cmd"
+ runtime_args = @("--port", "{port}", "--contract", $contract, "--revision", $revision, "start-runtime.cmd")
+ dashboard = @{ asset_root = "dashboard"; fingerprint = $dashboardFingerprint } })
+ } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $installedRoot "assets/sympp-runtime-artifacts.json")
$installedPath = [System.IO.Path]::GetFullPath($installedRoot).ToLowerInvariant()
$generationKey = Get-Sha256 "$installedPath`n$revision`n$contract"
$validationFile = Join-Path $sourceEnvironment.SYMPP_HOME ("runtime/launcher-validation/" + (Get-Sha256 $installedPath).Substring(0, 12) + ".json")
@@ -158,22 +210,34 @@ try {
SYMPP_LOG_DIR = $sourceEnvironment.SYMPP_LOG_DIR; SYMPP_LAUNCHER_TRACE_DIR = Join-Path $tempRoot "trace"
SYMPP_STARTUP_LOCK_TIMEOUT_SEC = "30"; SYMPP_MCP_HTTP_TIMEOUT_SEC = "60"
}
- $node = (Get-Command node.exe -ErrorAction Stop).Source
- $bridge = Join-Path $installedRoot "scripts/start-sympp-mcp-bridge.js"
- $firstBridgeLog = Invoke-McpBridge $node @($bridge) $installedEnvironment
- Start-Sleep -Milliseconds $IdleMilliseconds
- $afterIdle = Get-Process -Id $backendProcessId -ErrorAction SilentlyContinue
- if (-not $afterIdle) { throw "Artifact backend stopped after idle detach. Bridge diagnostics: $firstBridgeLog" }
- if ($afterIdle.StartTime.ToUniversalTime().Ticks -ne $backendStartTicks) { throw "Artifact backend PID identity changed after last detach." }
- Invoke-McpBridge $node @($bridge) $installedEnvironment
- $afterSecondWave = Get-Process -Id $backendProcessId -ErrorAction Stop
- if ($afterSecondWave.StartTime.ToUniversalTime().Ticks -ne $backendStartTicks) { throw "Second bridge wave did not reuse the artifact backend PID." }
+ $cmd = (Get-Command cmd.exe -ErrorAction Stop).Source
+ $installedCommand = Join-Path $installedRoot "scripts/start-sympp-mcp.cmd"
+ $firstWave = Invoke-McpBridge $cmd @("/d", "/c", "call $installedCommand") $installedEnvironment
+ if ($firstWave.backend_pid -ne $backendProcessId) { throw "Installed command attached to unexpected backend pid=$($firstWave.backend_pid)." }
+ Wait-ManagedRuntimeStopped $backendProcessId $backendPort
+ $firstBackendProcessId = $backendProcessId
+ $backendProcessId = $null
- $installedLauncher = Join-Path $installedRoot "scripts/start-sympp-mcp.ps1"
- [void](Invoke-IsolatedCommand $pwsh @("-NoProfile", "-File", $installedLauncher, "-CleanupRuntimeKey", "superseded-$runtimeKey") $installedEnvironment)
- [void](Get-Process -Id $backendProcessId -ErrorAction Stop)
- [void](Invoke-IsolatedCommand $pwsh @("-NoProfile", "-File", $installedLauncher, "-CleanupRuntimeKey", $runtimeKey) $installedEnvironment)
- Wait-ProcessStopped $backendProcessId
+ $laterInstalledEnvironment = $installedEnvironment.Clone()
+ $laterInstalledEnvironment.SYMPP_BACKEND_PORT = [string]$backendPort
+ $laterInstalledEnvironment.SYMPP_AUTOSTART_FRONTEND = "0"
+ $laterInstalledEnvironment.SYMPP_ELIXIR_SETUP_TIMEOUT_SEC = "30"
+ $laterInstalledEnvironment.SYMPP_BACKEND_STARTUP_TIMEOUT_SEC = "60"
+ $laterInstalledEnvironment.SYMPP_BACKEND_PORT_RELEASE_TIMEOUT_SEC = "1"
+ $laterInstalledEnvironment.SYMPP_STARTUP_LOCK_TIMEOUT_SEC = "180"
+ $laterInstalledEnvironment.SYMPP_COLD_START_TIMEOUT_SEC = "90"
+ $laterInstalledEnvironment.SYMPP_POWERSHELL = $pwsh
+ $laterInstalledEnvironment.TEMP = $sourceEnvironment.TEMP
+ $laterInstalledEnvironment.TMP = $sourceEnvironment.TMP
+ $secondWave = Invoke-McpBridge $cmd @("/d", "/c", "call $installedCommand") $laterInstalledEnvironment
+ $backendProcessId = $secondWave.backend_pid
+ $backendStartTicks = $secondWave.backend_start_ticks
+ if ($backendProcessId -eq $firstBackendProcessId) { throw "Later installed command wave reused backend pid=$backendProcessId." }
+ $artifactCacheRoot = [System.IO.Path]::GetFullPath((Join-Path $sourceEnvironment.SYMPP_HOME "artifacts/mcp"))
+ if ($secondWave.runtime_mode -ne "artifact" -or -not ([System.IO.Path]::GetFullPath($secondWave.artifact_root).StartsWith($artifactCacheRoot, [StringComparison]::OrdinalIgnoreCase))) {
+ throw "Later installed command wave was not artifact-backed."
+ }
+ Wait-ManagedRuntimeStopped $backendProcessId $backendPort
$backendProcessId = $null
[void](Invoke-IsolatedCommand $pwsh @("-NoProfile", "-File", $launcher, "-PrepareRuntimeOnly") $sourceEnvironment)
@@ -182,14 +246,15 @@ try {
$frontendProcessId = [int]$sourceState.frontend.pid
$backendProcessId = $sourceProcessId
$backendStartTicks = (Get-Process -Id $sourceProcessId -ErrorAction Stop).StartTime.ToUniversalTime().Ticks
- Invoke-McpBridge $pwsh @("-NoProfile", "-File", $launcher) $sourceEnvironment
- Wait-ProcessStopped $sourceProcessId
+ [void](Invoke-McpBridge $pwsh @("-NoProfile", "-File", $launcher) $sourceEnvironment)
+ Wait-ManagedRuntimeStopped $sourceProcessId $backendPort
+ Wait-ManagedRuntimeStopped $frontendProcessId $dashboardPort
$backendProcessId = $null
$frontendProcessId = $null
[pscustomobject]@{
- artifact_waves = 2; initialize_and_tools_list = 2; artifact_pid_reused = $true
- stale_cleanup_preserved_current = $true; explicit_cleanup_stopped_exact = $true
+ installed_waves = 2; initialize_and_tools_list = 3; installed_pids_distinct = $true
+ artifact_last_detach_stopped = $true; listeners_closed = $true
source_last_detach_stopped = $true; isolated_runtime_ledger_ports = $true
} | ConvertTo-Json -Compress
} finally {
diff --git a/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1 b/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1
index c493add584..dc6449346c 100644
--- a/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1
+++ b/plugins/symphony-plus-plus-mcp/tests/launcher/run-launcher-tests.ps1
@@ -75,8 +75,7 @@ Assert-True ($null -eq (Resolve-LocalWarmAttachIdentity $staleState $pluginRoot
$health = [pscustomobject]@{ healthy = $true; source_revision = $state.backend.source_revision; contract_fingerprint = $fingerprint }
$plan = Resolve-FastAttachRuntimePlan $state $state.backend.source_revision $fingerprint 0 0 $false $false $null $null $health $true $true
Assert-True ($null -ne $plan -and -not $plan.dashboard_plan.managed) "Artifact-static runtime should produce an unmanaged-dashboard fallback plan"
-Assert-True (-not (Test-BackendShouldShutdownOnIdle $state.backend $state.frontend "artifact")) "Artifact-static backends must remain resident after transient bridge churn"
-Assert-True (Test-BackendShouldShutdownOnIdle $state.backend $state.frontend "source") "Source backends without a managed dashboard must remain idle-disposable"
+Assert-True (Test-BackendShouldShutdownOnIdle $state.backend $state.frontend) "Managed backends without a managed dashboard must shut down on idle in source and artifact modes"
Assert-True (Test-SymppBackendCommandLine 'cmd.exe /c C:\cache\artifacts\mcp\windows-x86_64\abc\runtime\start-runtime.cmd') "Supported artifact command wrappers must remain recoverable before binding"
$sourceState = [pscustomobject]@{ runtime_kind = "managed"; backend = [pscustomobject]@{ url = "http://127.0.0.1:20000" } }
$reusedSourcePlan = [pscustomobject]@{ reused = $true; should_start = $false; url = "http://127.0.0.1:20000" }
@@ -182,7 +181,7 @@ try {
Assert-True ([int]$pendingState.publication.backend.pid -eq $ownedPid -and [int]$pendingState.publication.backend.pid -ne $unrelated.Id) "Pending recovery must not adopt an unrelated loopback process"
} finally {
foreach ($processId in @($(if ($unrelated) { $unrelated.Id }), $ownedPid, $wrapperPid, $(if ($leader) { $leader.Id }))) {
- if ($processId) { & taskkill.exe /PID $processId /T /F 2>$null | Out-Null }
+ if ($processId -and (Get-Process -Id $processId -ErrorAction SilentlyContinue)) { & taskkill.exe /PID $processId /T /F 2>$null | Out-Null }
}
Remove-Item Env:SYMPP_TEST_WRAPPER_READY,Env:SYMPP_TEST_WRAPPER_RELEASE,Env:SYMPP_TEST_RUNTIME_CMD,Env:SYMPP_TEST_RUNTIME_ROOT -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $pendingBase -Recurse -Force -ErrorAction SilentlyContinue
@@ -205,7 +204,7 @@ Assert-True ($node -match '(?s)function generationStillValid\(identity\).*?gener
Assert-True ([regex]::Matches($node, 'setTimeout\(resolve, GENERATION_SETTLE_MS\)').Count -eq 2) "Generation validation must retain the post-scan and final exact-generation settle waits"
Assert-True ([regex]::Matches($bridgeSource, 'generationValidForAttachment\(identity\)').Count -eq 2 -and [regex]::Matches($bridgeSource, 'generationValidAtAttachment\(identity\)').Count -eq 1) "Warm bridge attachment must validate its generation before shared readiness and after lease attachment"
Assert-True ($cmd.Contains('cd /d "%SystemRoot%"') -and $cmd.IndexOf('cd /d "%SystemRoot%"') -lt $cmd.IndexOf('start-sympp-mcp-bridge.js') -and -not $cmd.Contains('cd /d "%TEMP%"')) "Shipped launcher must leave the installed plugin working directory without relying on TEMP"
-Assert-True ($node.IndexOf('cleanupScript = prepareCleanupScript(identity);') -lt $watchRelease -and $node.Contains('cleanupLastDetach(runtimeFile, identity.runtimeKey, cleanupScript)')) "Node warm attach must preserve last-detach cleanup outside the invalidatable plugin cache"
+Assert-True ($node.IndexOf('cleanupScript = prepareCleanupScript(identity);') -lt $watchRelease -and $node.Contains('cleanupLastDetach(runtimeFile, identity.runtimeKey, cleanupScript)') -and -not $node.Contains('PreserveCurrentArtifactRuntime')) "Node warm attach must preserve last-detach cleanup outside the invalidatable plugin cache without a residency exception"
Assert-True (-not $node.Contains('confirmedCleanupScript')) "Warm bridge attachment must stage and hash its exact cleanup generation only once"
Assert-True ($node -match '(?s)function prepareCleanupScript\(identity\).*?try \{\s*const names = fs\.readdirSync\(__dirname\).*?\} catch \(_\) \{\s*return null;') "Cleanup staging must fail closed when the invalidatable plugin cache disappears"
Assert-True ((@([regex]::Matches($node, 'require\("([^./][^"]*)"\)') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) -join ",") -eq "child_process,crypto,fs,http,net,os,path,readline") "Node bridge must use standard-library modules only"
@@ -541,11 +540,11 @@ $coldSmokeJson = & (Get-Command node.exe -ErrorAction Stop).Source (Join-Path $P
$coldExitCode = $LASTEXITCODE
Assert-True ($coldExitCode -eq 0) "Installed cold-herd matrix and leader-death suite must pass"
$coldSmoke = $coldSmokeJson | ConvertFrom-Json
-Assert-True ((@($coldSmoke.matrix.clients) -join ",") -eq "30,100,200" -and @($coldSmoke.matrix | Where-Object { $_.manifest -ne 1 -or $_.artifact -ne 1 -or $_.backends -ne 1 }).Count -eq 0) "30/100/200 shipped-command matrices must preserve singleton manifest, artifact, and backend work"
+Assert-True ((@($coldSmoke.matrix.clients) -join ",") -eq "30,100,200" -and @($coldSmoke.matrix | Where-Object { $_.manifest -ne 1 -or $_.artifact -ne 1 -or $_.backends -ne 1 -or $_.listeners -ne 0 }).Count -eq 0) "30/100/200 shipped-command matrices must preserve singleton cold work and release the backend listener after the final client"
Assert-True ($coldSmoke.powershell_5_1 -and $coldSmoke.pwsh -and $coldSmoke.cleanup -and @($coldSmoke.leader_death).Count -eq 4) "Cold-herd coverage must prove both PowerShell shells, cleanup, and all leader-death phases"
Assert-True ($coldSmoke.powershell_fallback.clients -eq 30 -and $coldSmoke.powershell_fallback.preparations -eq 1 -and $coldSmoke.powershell_fallback.backends -eq 1) "Direct PowerShell fallback must elect one cold leader before installed identity and runtime work"
$persistentRuntime = @(& (Join-Path $PSScriptRoot "persistent-artifact-runtime-smoke.ps1"))[-1] | ConvertFrom-Json
-Assert-True ($persistentRuntime.artifact_waves -eq 2 -and $persistentRuntime.initialize_and_tools_list -eq 2 -and $persistentRuntime.artifact_pid_reused) "Installed artifact-static runtime must survive idle detach and serve a second real MCP bridge wave on the same backend PID"
-Assert-True ($persistentRuntime.stale_cleanup_preserved_current -and $persistentRuntime.explicit_cleanup_stopped_exact -and $persistentRuntime.source_last_detach_stopped -and $persistentRuntime.isolated_runtime_ledger_ports) "Cleanup must remain exact, explicit, disposable for source runtimes, and isolated from the main runtime"
+Assert-True ($persistentRuntime.installed_waves -eq 2 -and $persistentRuntime.initialize_and_tools_list -eq 3 -and $persistentRuntime.installed_pids_distinct) "Installed command must stop the artifact-static runtime and start a new backend PID for the next wave"
+Assert-True ($persistentRuntime.artifact_last_detach_stopped -and $persistentRuntime.listeners_closed -and $persistentRuntime.source_last_detach_stopped -and $persistentRuntime.isolated_runtime_ledger_ports) "Source and installed artifact cleanup must stop their managed listeners and remain isolated from the main runtime"
Write-Host "Launcher bootstrap, contract freshness, cold singleton, and lease identity regressions passed."