Summary
When multiple container runtimes coexist (e.g., podman and go-microvm), running any CLI command (thv list, thv status, thv stop, thv restart) with one runtime active permanently corrupts the status files of workloads managed by the other runtime, marking them as unhealthy.
This happens because the architecture assumes a single runtime owns all workloads. There is no concept of runtime ownership -- status files, labels, RunConfig, and core.Workload carry no information about which runtime created a workload.
Reproduction
- Start MCP servers using the default podman runtime:
thv run some-mcp-server
thv list # shows running
- Switch to the go-microvm runtime:
export TOOLHIVE_RUNTIME=go-microvm
thv list
- Switch back to podman:
unset TOOLHIVE_RUNTIME
thv list # podman servers now show as "unhealthy"
The podman containers are still running fine, but their status files have been overwritten.
Root Cause
The bug lives in the file-based status manager's reconciliation logic during ListWorkloads and GetWorkload.
The chain of events
-
Factory.Create() creates a single runtime (pkg/container/factory.go:109-110). If TOOLHIVE_RUNTIME=go-microvm, only the go-microvm client is initialized.
-
fileStatusManager.ListWorkloads() reads status files for ALL workloads (pkg/workloads/statuses/file_status.go:288-289) regardless of which runtime created them.
-
It queries only the active runtime (file_status.go:283): f.runtime.ListWorkloads(ctx) -- the go-microvm runtime returns zero podman containers.
-
mergeRuntimeAndFileWorkloads() calls handleRuntimeMissing() (file_status.go:1128-1135) for every workload that exists in status files but not in the active runtime's container list.
-
handleRuntimeMissing() writes WorkloadStatusUnhealthy to the status file on disk (file_status.go:967-974):
if fileWorkload.Status == rt.WorkloadStatusRunning || fileWorkload.Status == rt.WorkloadStatusStopped {
contextMsg := fmt.Sprintf("workload %s not found in runtime, marking as unhealthy", workloadName)
if err := f.SetWorkloadStatus(ctx, workloadName, rt.WorkloadStatusUnhealthy, contextMsg); err != nil {
return core.Workload{}, err
}
fileWorkload.Status = rt.WorkloadStatusUnhealthy
}
Why runtime ownership is not tracked
None of the data structures carry runtime type information:
| Data Structure |
Runtime field? |
Notes |
workloadStatusFile (file_status.go:186-192) |
No |
Only status, context, timestamps, PID |
core.Workload (pkg/core/workload.go:15-52) |
No |
Only a Remote bool for remote vs container |
RunConfig (pkg/runner/config.go) |
No |
Deployer field has json:"-" -- not persisted |
Container labels (pkg/labels/labels.go:46-53) |
No |
No runtime-type label |
runtime.ContainerInfo (pkg/container/runtime/types.go:49-69) |
No |
No runtime identifier |
Affected Commands
All commands that call ListWorkloads or GetWorkload have write-on-read side effects:
| Command |
Entry point |
Side effect |
thv list |
file_status.go:275 via mergeRuntimeAndFileWorkloads |
Marks all workloads not in active runtime as UNHEALTHY |
thv status <name> |
file_status.go:195 via validateRunningWorkload |
Marks specific workload as UNHEALTHY if not in active runtime |
thv stop --all / --group |
Lists workloads first |
Marks non-active-runtime workloads UNHEALTHY before stopping |
thv restart --all / --group |
Lists workloads first |
Same as stop |
What is NOT affected
- go-microvm
recoverState() does NOT harm podman processes. The go-microvm state directory (~/.config/toolhive/gomicrovm/vms/) is completely separate from podman state, and cleanupOrphanedRunner() only reads PIDs from go-microvm-specific state files.
thv serve does not do periodic reconciliation that would trigger this -- the damage only happens on explicit CLI commands.
- Marking unhealthy does not kill containers or proxy processes -- it only corrupts the status file. The actual workloads keep running.
Possible Fix Directions
-
Track runtime ownership: Add a runtime-type field to status files (and/or container labels / RunConfig). During reconciliation, skip workloads that belong to a different runtime.
-
Read-only list path: Separate the read-only listing from the write-on-read validation. thv list should not mutate state as a side effect.
-
Multi-runtime aware reconciliation: Query all available runtimes during reconciliation, not just the active one. Only mark a workload unhealthy if it's missing from the runtime it was created with.
Summary
When multiple container runtimes coexist (e.g., podman and go-microvm), running any CLI command (
thv list,thv status,thv stop,thv restart) with one runtime active permanently corrupts the status files of workloads managed by the other runtime, marking them asunhealthy.This happens because the architecture assumes a single runtime owns all workloads. There is no concept of runtime ownership -- status files, labels, RunConfig, and
core.Workloadcarry no information about which runtime created a workload.Reproduction
thv run some-mcp-server thv list # shows runningexport TOOLHIVE_RUNTIME=go-microvm thv listThe podman containers are still running fine, but their status files have been overwritten.
Root Cause
The bug lives in the file-based status manager's reconciliation logic during
ListWorkloadsandGetWorkload.The chain of events
Factory.Create()creates a single runtime (pkg/container/factory.go:109-110). IfTOOLHIVE_RUNTIME=go-microvm, only the go-microvm client is initialized.fileStatusManager.ListWorkloads()reads status files for ALL workloads (pkg/workloads/statuses/file_status.go:288-289) regardless of which runtime created them.It queries only the active runtime (
file_status.go:283):f.runtime.ListWorkloads(ctx)-- the go-microvm runtime returns zero podman containers.mergeRuntimeAndFileWorkloads()callshandleRuntimeMissing()(file_status.go:1128-1135) for every workload that exists in status files but not in the active runtime's container list.handleRuntimeMissing()writesWorkloadStatusUnhealthyto the status file on disk (file_status.go:967-974):Why runtime ownership is not tracked
None of the data structures carry runtime type information:
workloadStatusFile(file_status.go:186-192)core.Workload(pkg/core/workload.go:15-52)Remotebool for remote vs containerRunConfig(pkg/runner/config.go)Deployerfield hasjson:"-"-- not persistedpkg/labels/labels.go:46-53)runtime.ContainerInfo(pkg/container/runtime/types.go:49-69)Affected Commands
All commands that call
ListWorkloadsorGetWorkloadhave write-on-read side effects:thv listfile_status.go:275viamergeRuntimeAndFileWorkloadsthv status <name>file_status.go:195viavalidateRunningWorkloadthv stop --all/--groupthv restart --all/--groupWhat is NOT affected
recoverState()does NOT harm podman processes. The go-microvm state directory (~/.config/toolhive/gomicrovm/vms/) is completely separate from podman state, andcleanupOrphanedRunner()only reads PIDs from go-microvm-specific state files.thv servedoes not do periodic reconciliation that would trigger this -- the damage only happens on explicit CLI commands.Possible Fix Directions
Track runtime ownership: Add a runtime-type field to status files (and/or container labels / RunConfig). During reconciliation, skip workloads that belong to a different runtime.
Read-only list path: Separate the read-only listing from the write-on-read validation.
thv listshould not mutate state as a side effect.Multi-runtime aware reconciliation: Query all available runtimes during reconciliation, not just the active one. Only mark a workload unhealthy if it's missing from the runtime it was created with.