Skip to content

fix(deploy): make orphaned Kit visible to liveness and fail Phase 4c closed (#640) - #653

Merged
monkey1sai merged 3 commits into
mainfrom
fix/orphan-kit-liveness-preflight
Aug 19, 2026
Merged

fix(deploy): make orphaned Kit visible to liveness and fail Phase 4c closed (#640)#653
monkey1sai merged 3 commits into
mainfrom
fix/orphan-kit-liveness-preflight

Conversation

@monkey1sai

@monkey1sai monkey1sai commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Summary

Liveness for the host-native Kit was tracked as the launcher's pid, but the
process holding the streaming ports, the GPU context and the Omniverse user
directory is its Kit child. On the canonical Linux target the launcher died
while the Kit child survived, and every link in the chain then behaved exactly
as written:

  1. Remove-StalePidFile correctly deleted bim-streaming-server.pid - the pid
    it recorded really was gone.
  2. Test-AlreadyRunning correctly reported "not running" - it can only see that
    pid file.
  3. Phase 4c started a second Kit into the live one. The new process
    deadlocked in early startup: S (sleeping), two threads, both in
    futex_wait_queue, no listener, and not one line in its own Omniverse log.
    The deploy found out 480s later (deploy_exit_code = 4).

Phase 1 had already printed the orphan
([skip ] port TCP/49150 occupied by our PID 188705 (kit.exe) - already running, will skip start).
That observation never reached the start decision; the "will skip start" was an
unkept promise. This PR closes both halves.

1. Record the real resource holder. Start-HostNativeService gains
-ListenPorts and writes a <Name>.ports sidecar - the same idiom as the
existing <Name>.job sidecar. It is written before the launch (it is a
claim, not an observation: a child may bind before we could record anything
afterwards), and Remove-StalePidFile deliberately leaves it in place. That
record is the only surviving trace of an orphaned child once the pid file is
gone. Only a Stop-HostNativeService that actually terminated something clears
it; a stop that stopped nothing keeps it. Start-HostNativeKit declares its
signal + spectator signal ports (TCP - the UDP media ports stay out of a record
that only a TCP probe can attribute).

2. Fail the preflight closed. Get-HostNativeOrphanListener answers "is a
process outside our recorded tree still holding these ports?" over the union
of the recorded claim and the ports this run expects, so neither a config change
nor a first-ever launch can hide a holder. "Accounted for" is narrow on purpose:
the pid file must exist, its process must be alive, and the listener must be
that process or one of its descendants. No pid file, a dead recorded pid, an
unrelated holder, or an owner the OS will not reveal
(Get-PlatformTcpListenerPid returns -1) are all unaccounted. Phase 4c
consults it immediately before Start-HostNativeKit and refuses with exit 4,
naming the ports, the pids and scripts/stop-all.ps1 - which stops by port as
well as by pid file, so it reaches an orphan whose pid file is already gone.
That is exactly the manual recovery that made the failing deployment pass
unchanged.

A holder that disappears within a bounded settle window (3s, polled) is
teardown, not an orphan: Phase 4c also reaches this gate immediately after
stopping the previous tree itself when runtime parameters changed, and a
force-killed process does not release its listening socket at the instant the
stop call returns.

Cross-platform: the detector uses only Get-PlatformTcpListenerPid and
Get-PlatformChildProcessIds, which the platform adapter already implements for
both Windows (Get-NetTCPConnection / CIM) and Linux (ss -ltnpH / /proc). No
new platform primitive, and no WMI-only parent walking.

Fixes #640.

AI Coding Governance

Item Result
Change lane G
Behavior contract changed yes
Linked issue #640
Requirement source issue #640
CODEOWNERS / owner review exact-head human review requested; scripts/** is human_critical and the counted approval must come from the fixed owner account
GitNexus evidence GitNexus impact risk=UNKNOWN / unavailable: gitnexus impact Get-HostNativeOrphanListener -d upstream -r AI-BIM-governance returns Target not found because GitNexus does not extract PowerShell function-level symbols (.ps1 files are indexed as File nodes only) - the same coverage gap recorded for PR #647, treated as unavailable and not as a pass. Substitute evidence: manual call-site inventory - Get-HostNativeOrphanListener has exactly two consumers (scripts/deploy.ps1 Phase 4c and scripts/tests/test-host-native-launcher.ps1), and every reader/writer of the new <Name>.ports sidecar is enumerated (Start-HostNativeService, Stop-HostNativeService, Get-HostNativeServiceListenPorts, and Remove-StalePidFile which deliberately does not touch it)
Browser E2E evidence not user-facing; the change is confined to host-native start-path liveness and never reaches a product route
Agent workflow changed? no
Required checks expected CI / Agent Governance / PR Metadata Contract

Deploy Path Verification

Item Result
Affects runtime / docker / Kit / viewer / ports / env? yes: Phase 4c host-native Kit start decision and the host-native run-dir record set (new scripts/.run/<name>.ports sidecar). No port number, Docker, viewer, or env-file change
Canonical deploy path updated? yes; scripts/deploy.ps1 Phase 4c now evaluates Get-HostNativeOrphanListener before Start-HostNativeKit and exits 4 on an unaccounted holder instead of launching a second Kit
New root script added? no
Deploy dry-run command pwsh -NoProfile -NonInteractive -File scripts/deploy.ps1 -DryRun -> exit 0 in 6s on Windows at exact head eba8ca81c05e2f42b34654ffb3d95a1e338e96ad, run detached with stdout/stderr redirected to files (stderr 0 bytes); markers [skip ] Phase 2 auto-fix DRY-RUN (no actions executed) and Storage root: ...\storage (ALIGNED)
Full deploy tested no; no live deployment, no service started, no port bound, no deploy-target mutation. The live-path proof is deliberately deferred to the next canonical deployment - see the bootstrap entry below
Verify command pwsh -NoProfile -NonInteractive -File scripts/tests/test-host-native-launcher.ps1 and pwsh -NoProfile -NonInteractive -File scripts/tests/test-deploy-governance-static.ps1
Frontend URL verified no product route changed
Evidence path scripts/tests/test-host-native-launcher.ps1 (five new executable #640 cases), scripts/tests/test-deploy-governance-static.ps1 (Phase 4c wiring and ordering), docs/evidence/orphan-kit-liveness-preflight/self-referential-bootstrap/

Windows On-Demand Verification

Item Result
Windows verification tier deploy_dryrun
Windows verification evidence head eba8ca81c05e2f42b34654ffb3d95a1e338e96ad; exact-head Windows execution: the rebuild/test-deploy contracts job runs on Windows Server 2025 and executed scripts/tests/test-host-native-launcher.ps1 including all five new #640 cases, every one [PASS], ending === test-host-native-launcher.ps1: ALL PASSED === - run https://github.com/monkey1sai/AI-BIM-governance/actions/runs/32244348942/job/96041843520 (CI run 32244348942 concluded success at this head). Locally on Windows at the same head: pwsh -NoProfile -NonInteractive -File scripts/deploy.ps1 -DryRun -> exit 0 in 6s, run detached with stdout/stderr redirected to files, stderr 0 bytes, markers [skip ] Phase 2 auto-fix DRY-RUN (no actions executed) and Storage root: ...\storage (ALIGNED). No live deployment, no service start, no port bind, and no Kit / GPU / WebRTC / browser evidence is claimed

Self-Referential Bootstrap

scripts/deploy.ps1 and scripts/lib/host-native-launcher.ps1 are both
classified verification-mechanism paths, and this PR changes what "already
running / safe to start" means on the canonical deploy path.

Item Result
Self-referential bootstrap yes
Bootstrap ledger entry orphan-kit-liveness-preflight
Bootstrap reason the canonical deploy path decides "is an instance already running, and is it safe to start one" only while a real deployment is executing against already-merged origin/main content, and the behaviour this PR changes exists only in that moment: the <Name>.ports claim is written by a live Start-HostNativeService, and the Phase 4c refusal only fires against a real orphaned Kit child still holding a real LISTEN socket after its launcher died. Neither state can be produced on a branch by the canonical mechanism before this change reaches origin/main; the unit suite proves the detection and refusal logic with injected port, process and child-enumeration probes and with sidecar fixtures, but a fixture orphan is not a canonical deployment cycle.

Determination made against docs/agents/self-referential-bootstrap.md, not
against convenience. §2.1 class 2 ("canonical deploy path - 部署契約只重建/驗證
已 merge 的內容,branch 上取不到 post-change canonical evidence") applies
directly: both changed mechanism paths sit on that path, and §1's defining
feature - "契約禁止在 merge 前用正規機制對變更後行為取證" - is met, because the
only way to exercise the new gate through the canonical mechanism is a real
deployment against origin/main. This is not the §2.1 exclusion ("新腳本
自行定義自己的報告格式"); nothing here is a telemetry report format. The base
ledger had zero open entries, so the §3 debt gate does not block opening one.
Precedent is unambiguous: every substantive scripts/deploy.ps1 behaviour
change in the ledger era (#467, #484, #513, #573) opened an entry, and #647
opened one for the same "only a live canonical deployment can prove this" shape.

Bootstrap evidence: docs/evidence/orphan-kit-liveness-preflight/self-referential-bootstrap/.
Fixpoint (mainline re-run of the same five-command contract after merge) will
land in a dedicated, ledger-only closure PR per §3 "Closure 單一目的" - the same
treatment #652 gave #647's entry.

Validation

Every command below ran on Windows in this branch's worktree at exact head
eba8ca81c05e2f42b34654ffb3d95a1e338e96ad.

  • pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/tests/test-host-native-launcher.ps1 -> ALL PASSED, exit 0, including the five new #640 cases.
  • pwsh -NoProfile -NonInteractive -File scripts/tests/test-deploy-governance-static.ps1 -> PASS deploy governance static checks, exit 0, including the new Phase 4c assertions.
  • pwsh -NoProfile -NonInteractive -File scripts/tests/test-self-referential-bootstrap.ps1 -> all assertions passed, exit 0 (ledger integrity plus command-id resolvability with the new entry).
  • pwsh -NoProfile -NonInteractive -File scripts/tests/test-pr-body-evidence.ps1 -> exit 0.
  • pwsh -NoProfile -NonInteractive -File scripts/tests/invoke-powershell-static.ps1 -> [invoke-powershell-static] passed, exit 0.
  • pwsh -NoProfile -NonInteractive -File scripts/tests/test-agent-governance-check.ps1 -> # tests 46 / # pass 46 / # fail 0, exit 0 (no pinned literal in that suite covers the changed scripts, so nothing needed syncing).
  • pwsh -NoProfile -NonInteractive -File scripts/tests/test-rebuild-test-deploy.ps1 -> [PASS] rebuild-test-deploy, exit 0.
  • pwsh -NoProfile -NonInteractive -File scripts/tests/test-preflight-ports.ps1 -> ALL PASSED, exit 0.
  • pwsh -NoProfile -NonInteractive -File scripts/deploy.ps1 -DryRun -> exit 0 in 6s, detached, stderr 0 bytes, no deploy-target mutation.
  • git diff --cached --check -> clean.
  • PowerShell AST parse -> 0 errors for scripts/deploy.ps1 and scripts/lib/host-native-launcher.ps1.

New executable coverage, all fixture- and injection-driven (no Kit launched, no
real port bound; the only real processes started are short-lived Python sleeps
that bind nothing):

  • a launch that declares ports records the sidecar de-duplicated and sorted; a portless launch clears a previous claim instead of inheriting it.
  • Remove-StalePidFile removes the dead pid file and keeps the port claim - the exact record deploy: 孤兒 Kit 對 Test-AlreadyRunning 隱形,Phase 4c 會啟動第二個實例並死鎖 #640 lost.
  • orphan matrix: no pid file + live holder -> reported; pid file present but its process dead -> reported (the deploy: 孤兒 Kit 對 Test-AlreadyRunning 隱形,Phase 4c 會啟動第二個實例並死鎖 #640 shape); live launcher + holder is its child -> not reported (the healthy idempotent re-run keeps its existing skip path); live launcher + unrelated holder -> reported; -1 owner-not-visible -> reported (fails closed, never reads as free); every port free -> not reported; recorded and expected ports are both probed; nothing recorded and nothing expected -> no probe at all.
  • settle window: a listener that disappears within the budget is teardown, not an orphan; one that survives it is still reported.
  • only a stop that terminated something releases the claim.
  • scripts/deploy.ps1 statically pinned: the gate is evaluated before Start-HostNativeKit, covers $resolvedKitSignalPort plus $resolvedSpectatorSignalPorts, refuses with stage=4c, names scripts/stop-all.ps1, and exits through Print-FinalSummary -ExitCode 4.

Known Risks and Gaps

  • The real scenario lives on canonical-linux. No live service was started, no port was bound, no deploy target was mutated, and no GPU / Kit / WebRTC / browser evidence is claimed. Local tests prove the detection and refusal logic with fixtures; the live-path proof arrives at the next canonical deployment. That gap is precisely what the bootstrap entry above covers, and what its fixpoint closure will discharge.
  • Behaviour change on a previously-permissive path. A deploy that would previously have started a second Kit now exits 4. That is the intended direction (the previous behaviour produced an 8-minute deadlock and required manual recovery anyway), but it means an operator who hits it must run scripts/stop-all.ps1 before re-running. The refusal message says so explicitly.
  • Auto-cleanup was deliberately not implemented. Issue deploy: 孤兒 Kit 對 Test-AlreadyRunning 隱形,Phase 4c 會啟動第二個實例並死鎖 #640 suggested actively killing the orphan via the existing Stop-HostNativeProcessTreeAndWait. This PR refuses instead: the detector cannot distinguish "our orphan" from "a stranger on our port" with certainty, and killing an unidentified process from the deploy path is a destructive action that belongs behind explicit operator intent. scripts/stop-all.ps1 already performs exactly that cleanup under the operator's hand, and its own ownership check (Test-IsWorkspaceProcess) is stricter than anything this gate could assert.
  • Only the Kit service declares a port claim. Start-HostNativeConversion has the same wrapper/child shape and would benefit from the same record, but issue conversion: 服務把 STORAGE_ROOT 預設成不可能正確的路徑並靜默建目錄,start-all 起的機器會拒收每個 IFC #626 landed in that start path today and the scope here is deliberately surgical; the generic -ListenPorts mechanism is ready for it. governance-service and kit-manager-api do not have the gap at all - their recorded pid is the uvicorn listener, with no wrapper/child split.
  • UDP media ports are not covered. The claim records TCP signal ports only, because Get-PlatformTcpListenerPid is what can attribute an owner on both platforms. An orphan holding only UDP media ports would not be detected; in the observed failure the orphan held a TCP signal port.
  • Ownership attribution is by PID, not identity token. The detector matches listener pids against a freshly walked live tree on every settle pass, so a recycled pid would have to be recycled into our own live tree to be mis-accounted. It does not use Get-PlatformProcessIdentity birth tokens; that is a heavier contract than a start-path gate needs, and the failure direction here is one extra refusal rather than one wrongly permitted second instance.
  • GitNexus impact / detect-changes are unavailable for PowerShell symbols (see the governance table); recorded as UNKNOWN, not as a pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GSyeoncjEm8rcfRDgnS6CB

…closed (#640)

Liveness was tracked as the LAUNCHER's pid, but the process holding the
streaming ports, the GPU context and the Omniverse user directory is its Kit
child. When the launcher died and the child survived, Remove-StalePidFile
correctly deleted the pid file, Test-AlreadyRunning correctly reported "not
running", and Phase 4c started a second Kit into the live one - which
deadlocked in early startup with two futex-waiting threads, no listener and
not one line of its own log, and was only noticed 480s later.

Two halves:

- Start-HostNativeService now records the TCP ports a launch claims in a
  <Name>.ports sidecar, written BEFORE the launch and deliberately left in
  place by Remove-StalePidFile. That record is the surviving trace of an
  orphaned child; only a stop that actually terminated something clears it.
  Start-HostNativeKit declares its signal + spectator signal ports.
- Get-HostNativeOrphanListener answers "is a process outside our recorded tree
  still holding these ports?" over the union of the recorded claim and this
  run's expected ports, and Phase 4c consults it immediately before launching.
  An unaccounted holder is a hard stop naming the ports, the pids and
  scripts/stop-all.ps1 - the manual recovery that made the failing deployment
  pass unchanged - instead of a second instance. A holder that vanishes within
  a bounded settle window is teardown, not an orphan.

Phase 1 already printed this exact observation ("occupied by our PID ...
already running, will skip start"); it was never a gate. Now it is.

Fixes #640.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSyeoncjEm8rcfRDgnS6CB
monkey1sai and others added 2 commits August 19, 2026 18:46
…ntry (#640)

scripts/deploy.ps1 and scripts/lib/host-native-launcher.ps1 are both classified
verification-mechanism paths, and this PR changes what "already running / safe
to start" means on the canonical deploy path. Per
docs/agents/self-referential-bootstrap.md §2.1 class 2, the deploy contract only
ever rebuilds and verifies already-merged origin/main content, so the changed
behaviour - a port claim written by a live launch, and a Phase 4c refusal that
only fires against a real orphaned Kit holding a real LISTEN socket - cannot be
proven by the canonical mechanism before this change reaches origin/main.

Same shape as PR #647. Fixture-driven unit tests prove the detection and refusal
logic; they do not replace a canonical deployment cycle, which is exactly what
the fixpoint closure will re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSyeoncjEm8rcfRDgnS6CB

@monkey1sai-blip monkey1sai-blip left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{"kind":"ai-bim-automated-approve-only","version":1,"automated":true,"repo":"monkey1sai/AI-BIM-governance","prNumber":653,"headOid":"0724397deb2861b06e5280a3aefb1ef8347e68ee","baseOid":"4c521d911ac936c6d6da9613682e28fbc76ab327","action":"approve-only"}

@monkey1sai
monkey1sai merged commit cb92a2b into main Aug 19, 2026
23 of 25 checks passed
@monkey1sai
monkey1sai deleted the fix/orphan-kit-liveness-preflight branch August 19, 2026 11:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

deploy: 孤兒 Kit 對 Test-AlreadyRunning 隱形,Phase 4c 會啟動第二個實例並死鎖

2 participants