From 08b4608eed55e12ca311bf607a8bede043ddcc2e Mon Sep 17 00:00:00 2001 From: HKX BDDEV001 Date: Sat, 20 Jun 2026 18:04:45 +0900 Subject: [PATCH] feat: Implement liveness breadcrumb for AI sessions to enhance parallel-session isolation --- plugins/sap-dev-core/settings.json | 2 +- .../shared/scripts/sap_connection_lib.ps1 | 71 +++++ .../shared/scripts/sap_session_broker.ps1 | 20 ++ .../sap-dev-core/skills/sap-login/SKILL.md | 61 ++-- scripts/test-ai-session-isolation.ps1 | 282 ++++++++++++++++++ 5 files changed, 411 insertions(+), 25 deletions(-) create mode 100644 scripts/test-ai-session-isolation.ps1 diff --git a/plugins/sap-dev-core/settings.json b/plugins/sap-dev-core/settings.json index 3faad22..a2cdb9d 100644 --- a/plugins/sap-dev-core/settings.json +++ b/plugins/sap-dev-core/settings.json @@ -118,7 +118,7 @@ "log_file_pattern": { "description": "Log filename template. Placeholders: {YYYYMMDD} {YYYYMM} {HHMMSS} {HHMM} {RUN_ID} {SKILL} {USER} {SYSTEM}. Default: 'sap-dev-{YYYYMMDD}.log' (one file per day, all runs merged). For per-run files: 'sap-dev-{YYYYMMDD}-{HHMMSS}-{SKILL}.log' or 'sap-dev-{YYYYMMDD}-{RUN_ID}.log'.", "sensitive": false, - "value": "" + "value": "{YYYYMMDD}-{SID}-{CLIENT}-{AI_SESSION}.log" }, "log_retention_days": { "description": "Delete log files older than N days. 0 = keep forever. Default: 30.", diff --git a/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 b/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 index 98faccc..45e507c 100644 --- a/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 +++ b/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 @@ -1206,6 +1206,64 @@ function _Invoke-AiSessionGc { } } +function _Set-AiLivenessBreadcrumb { + <# + .SYNOPSIS + Write/refresh the pid->ai_session_id liveness breadcrumb at + {RuntimeDir}\ai_session_by_pid\.txt that the SAP session + broker's contention check reads (Get-LiveAiSessionIds / + Get-PidForAiSession in sap_session_broker.ps1). + .DESCRIPTION + When the conversation id is supplied by an env var + (CLAUDE_CODE_SESSION_ID / SAPDEV_AI_SESSION_ID) the id itself needs no + file -- but the broker still needs this breadcrumb to know the + conversation is ALIVE. Without it the directory stays empty, + Get-LiveAiSessionIds returns @{}, ensure-own-session never sees that a + parallel conversation already holds ses[0], and every conversation + adopts the SAME session -- parallel-session isolation silently collapses + (and the conversations then also share the work_dir / memory dir). + + The owner PID (parent-process walk past script hosts) is the liveness + anchor: it is the long-lived Claude conversation process, so it stays + alive for the whole conversation and dies with it (PID-death GC then + reclaims the file). The short-lived per-skill PowerShell process ($PID) + would be useless here -- it exits the instant the skill returns. + + Fully best-effort: ANY failure is swallowed so id resolution is never + made more fragile than a plain env-var read. The env id is + authoritative, so the file is overwritten unconditionally -- it + supersedes any stale GUID a prior (env-less) run wrote for this same, + possibly OS-reused, owner PID. + #> + param([string]$RuntimeDir, [string]$AiId) + if ([string]::IsNullOrWhiteSpace($AiId)) { return } + try { + if ([string]::IsNullOrWhiteSpace($RuntimeDir)) { $RuntimeDir = Get-SapWorkRuntimeDir } + $dir = Join-Path $RuntimeDir 'ai_session_by_pid' + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + $ownerPid = _Get-AiOwnerPid -StartPid $PID + $file = Join-Path $dir "$ownerPid.txt" + + $mutex = [System.Threading.Mutex]::new($false, $script:_SapAi_MutexName) + $acquired = $false + try { + try { $acquired = $mutex.WaitOne(5000) } + catch [System.Threading.AbandonedMutexException] { $acquired = $true } + if (-not $acquired) { return } # another writer holds it; breadcrumb is best-effort + + $existing = '' + if (Test-Path $file) { try { $existing = (Get-Content $file -Raw -Encoding UTF8).Trim() } catch {} } + if ($existing -ne $AiId) { + [System.IO.File]::WriteAllText($file, $AiId, [System.Text.UTF8Encoding]::new($false)) + } + _Invoke-AiSessionGc -Dir $dir + } finally { + if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } + try { $mutex.Dispose() } catch {} + } + } catch {} +} + function Get-SapAiSessionId { <# .SYNOPSIS @@ -1222,6 +1280,10 @@ function Get-SapAiSessionId { # Honor an explicit env var if set (tests + manual overrides). if (-not [string]::IsNullOrWhiteSpace($env:SAPDEV_AI_SESSION_ID)) { + # Drop the pid->id liveness breadcrumb (see _Set-AiLivenessBreadcrumb) + # so the broker can still tell this conversation apart under an + # explicit-id override too. + _Set-AiLivenessBreadcrumb -RuntimeDir $RuntimeDir -AiId $env:SAPDEV_AI_SESSION_ID return $env:SAPDEV_AI_SESSION_ID } @@ -1237,6 +1299,15 @@ function Get-SapAiSessionId { # to the parent-PID walk when unset (non-Claude-Code invocations, older # hosts) -- behaviour outside Claude Code is unchanged. if (-not [string]::IsNullOrWhiteSpace($env:CLAUDE_CODE_SESSION_ID)) { + # CRITICAL for parallel-session isolation: this early return used to skip + # the pid->id breadcrumb write further below, leaving + # {RuntimeDir}\ai_session_by_pid empty. The broker's ensure-own-session + # then read an EMPTY live-session set (Get-LiveAiSessionIds), never + # detected that another conversation already held ses[0], and every + # parallel conversation adopted the SAME session -- silently collapsing + # isolation. Drop the breadcrumb here so the stable env id and the + # broker's liveness detection coexist. + _Set-AiLivenessBreadcrumb -RuntimeDir $RuntimeDir -AiId $env:CLAUDE_CODE_SESSION_ID return $env:CLAUDE_CODE_SESSION_ID } diff --git a/plugins/sap-dev-core/shared/scripts/sap_session_broker.ps1 b/plugins/sap-dev-core/shared/scripts/sap_session_broker.ps1 index d5db3eb..ad6c6a1 100644 --- a/plugins/sap-dev-core/shared/scripts/sap_session_broker.ps1 +++ b/plugins/sap-dev-core/shared/scripts/sap_session_broker.ps1 @@ -421,6 +421,16 @@ function Is-SessionAtEasyAccess { function Spawn-NewSession { param([Parameter(Mandatory)][string] $TargetConnectionPath) + # Test seam (mirrors SAPDEV_BROKER_FAKE_INFO): SAPDEV_BROKER_FAKE_SPAWN lets + # an offline regression test simulate a successful spawn without a live SAP + # GUI / COM helper. Value = the new session path (e.g. /app/con[0]/ses[1]); + # session_number is derived from its trailing ses[N]. Never set in production. + if (-not [string]::IsNullOrWhiteSpace($env:SAPDEV_BROKER_FAKE_SPAWN)) { + $fakePath = $env:SAPDEV_BROKER_FAKE_SPAWN + $sn = 0 + if ($fakePath -match 'ses\[(\d+)\]') { $sn = [int]$Matches[1] + 1 } + return @{ connection_path = $TargetConnectionPath; path = $fakePath; session_number = $sn } + } $result = Invoke-ComHelper -Args @('SPAWN', $TargetConnectionPath) Invalidate-SapStateCache if (-not $result.ok) { return $null } @@ -1505,6 +1515,16 @@ function Invoke-EnsureOwnSession { $owner = "$($target.ai_session_id)" $takenByLiveOther = ($owner -ne '' -and $owner -ne $AiSessionId -and $liveAi.ContainsKey($owner)) if (-not $takenByLiveOther) { + if ($owner -ne '' -and $owner -ne $AiSessionId) { + # Adopting a session whose recorded owner is a DIFFERENT + # ai-session not currently detected as live. Either that + # conversation ended (correct to reuse its session) OR its + # liveness breadcrumb under runtime\ai_session_by_pid is + # missing -- in which case isolation is silently degrading + # (the 2026-06-20 parallel-session collapse). Surface it + # instead of formalizing quietly. + Write-Host "INFO: formalizing $($target.path) previously claimed by ai_session '$owner' (not detected live; if that conversation is still active, its ai_session_by_pid breadcrumb is missing)" + } $target.status = 'claimed' $target.task_id = $stableTask $target.ai_session_id = $AiSessionId diff --git a/plugins/sap-dev-core/skills/sap-login/SKILL.md b/plugins/sap-dev-core/skills/sap-login/SKILL.md index b52420b..6f321a6 100644 --- a/plugins/sap-dev-core/skills/sap-login/SKILL.md +++ b/plugins/sap-dev-core/skills/sap-login/SKILL.md @@ -722,8 +722,9 @@ step errors: powershell -ExecutionPolicy Bypass -File "\scripts\sap_session_broker.ps1" -Action ensure-own-session -WorkTemp "{WORK_TEMP}" -TtlSeconds 2592000 -OwnerSkill sap-login ``` -The broker auto-resolves this conversation's AI-session id (parent-PID -walk) and prints one of: +The broker auto-resolves this conversation's AI-session id (from +`CLAUDE_CODE_SESSION_ID` when set — stable across a Claude host restart — +else a parent-PID walk; see "AI-Session Identity" below) and prints one of: - `OWN_SESSION: … formalized=true` — **first / sole** conversation on the connection: it claims the session it already resolves to (usually @@ -779,31 +780,43 @@ Suggested ``: `LOGIN_FAILED`, `RFC_LOGON_FAILED`, `GUI_TIMEOUT`. --- -## AI-Session Identity (automatic, parent-PID-based) - -The Phase-4 AI-session pin scope is identified by an id derived from -the **parent-process PID** of the running skill. `Get-SapAiSessionId` in -`sap_connection_lib.ps1` walks up the process tree from the current -PowerShell/cscript, skipping script-host processes (`powershell`, `pwsh`, -`cscript`, `wscript`, `cmd`, `conhost`), and stops at the first -non-script-host ancestor — that's the Claude Code conversation process. -Subagents launched from the same conversation share that ancestor and -therefore share the id; parallel conversations have different parent -PIDs and therefore get different ids. - -State lives at `{work_dir}\runtime\ai_session_by_pid\.txt` -(one file per conversation). Opportunistic GC drops files for PIDs that -are no longer alive, so the directory stays small. +## AI-Session Identity (automatic; stable conversation id + liveness breadcrumb) + +The Phase-4 AI-session pin scope is identified by `Get-SapAiSessionId` in +`sap_connection_lib.ps1`, which resolves the id from the most stable source +available: + +1. `SAPDEV_AI_SESSION_ID` (env override) — tests / manual one-offs only. +2. **`CLAUDE_CODE_SESSION_ID`** (env, provided by the Claude Code host) — the + normal production source. STABLE for the whole conversation and survives a + Claude host-process restart (unlike a PID, which changes on restart). +3. **Parent-PID walk** (fallback for non-Claude-Code hosts) — walks up the + process tree from the current PowerShell/cscript, skipping script-host + processes (`powershell`, `pwsh`, `cscript`, `wscript`, `cmd`, `conhost`, + `bash`, `sh`, …) to the first non-script-host ancestor (the conversation + process), minting a GUID keyed to that owner PID. + +In every case the function ALSO writes a **liveness breadcrumb** at +`{work_dir}\runtime\ai_session_by_pid\.txt` (content = the id), +keyed to the long-lived conversation process. This is what lets the broker +tell parallel conversations apart: `ensure-own-session` reads that directory +(`Get-LiveAiSessionIds`) to see which conversations are still alive, and +reverse-maps an id to its owner PID (`Get-PidForAiSession`) so a claim's +`owner_pid` is the conversation's process and the PID-death sweep +auto-releases it. Opportunistic GC drops files for dead PIDs, so the +directory stays small. Subagents inherit the same id (shared env var, or +shared ancestor in the fallback); parallel conversations get different ids. + +> Regression (fixed 2026-06-20): the `CLAUDE_CODE_SESSION_ID` path used to +> return the id WITHOUT writing the breadcrumb, leaving the directory empty — +> so the broker saw zero live conversations and every parallel conversation +> collapsed onto a shared `ses[0]`. The write is now unconditional; regression +> test at `sap-dev/scripts/test-ai-session-isolation.ps1`. **No SessionStart hook needed.** Earlier drafts of Phase 4 used a write- once-if-missing `ai_session_id.txt` file written by a hook, but that -silently shared one id across parallel conversations. The parent-PID -mechanism is automatic, scoped correctly, and has no external setup -requirement. - -**Override:** the env var `SAPDEV_AI_SESSION_ID`, if set non-empty, takes -precedence over the walked id. Tests and manual one-offs use it; normal -operation doesn't. +silently shared one id across parallel conversations. The resolution above is +automatic, scoped correctly, and has no external setup requirement. --- diff --git a/scripts/test-ai-session-isolation.ps1 b/scripts/test-ai-session-isolation.ps1 new file mode 100644 index 0000000..db38cb2 --- /dev/null +++ b/scripts/test-ai-session-isolation.ps1 @@ -0,0 +1,282 @@ +# ============================================================================= +# test-ai-session-isolation.ps1 +# ----------------------------------------------------------------------------- +# Offline regression test for parallel-AI-session isolation. +# +# Reproduces the 2026-06-20 bug found while testing parallel sessions on a +# second PC: two conversations on the same SAP connection both ended up +# driving ses[0] (broker reported formalized=true instead of spawned=true), +# silently collapsing isolation. +# +# Root cause: Get-SapAiSessionId (sap_connection_lib.ps1) returns early when +# CLAUDE_CODE_SESSION_ID is set and -- before the fix -- skipped writing the +# pid->id liveness breadcrumb at {runtime}\ai_session_by_pid\.txt. +# With that directory empty, the broker's Get-LiveAiSessionIds returned @{}, +# so ensure-own-session never saw that another LIVE conversation already held +# ses[0] and just adopted it. +# +# Two halves: +# Part A -- the fix: Get-SapAiSessionId now drops the breadcrumb in the +# env-id path (CLAUDE_CODE_SESSION_ID and SAPDEV_AI_SESSION_ID), +# and the legacy GUID path still works. +# Part B -- the consumer: given a breadcrumb proving another conversation is +# live on ses[0], the broker SPAWNS a fresh session (spawned=true) +# instead of formalizing the shared one. Without the breadcrumb it +# reproduces the collapse (formalized=true). +# +# Drives the REAL shipped scripts as subprocesses (the lib for Part A, the +# broker for Part B), using the SAPDEV_BROKER_FAKE_INFO / SAPDEV_BROKER_FAKE_SPAWN +# test seams so no live SAP GUI is required. Each scenario uses an isolated +# temp workspace. +# +# Exit 0 = all scenarios pass; 1 = at least one failure. +# Run: pwsh -File sap-dev/scripts/test-ai-session-isolation.ps1 +# ============================================================================= +[CmdletBinding()] +param( + [string] $Broker = '', + [string] $ConnLib = '' +) + +$ErrorActionPreference = 'Stop' + +# Resolve in the body (not param defaults): $PSScriptRoot is empty inside the +# param block under Windows PowerShell 5.1, so fall back to the invocation path. +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } + elseif ($MyInvocation.MyCommand.Path) { Split-Path -Parent $MyInvocation.MyCommand.Path } + else { (Get-Location).Path } +if (-not $Broker) { $Broker = (Join-Path $scriptDir '..\plugins\sap-dev-core\shared\scripts\sap_session_broker.ps1') } +if (-not $ConnLib) { $ConnLib = (Join-Path $scriptDir '..\plugins\sap-dev-core\shared\scripts\sap_connection_lib.ps1') } +$script:Failures = 0 +$script:Total = 0 + +function Assert-Eq { + param($Actual, $Expected, [string] $Msg) + $script:Total++ + if ("$Actual" -ne "$Expected") { + Write-Host " FAIL: $Msg (expected '$Expected', got '$Actual')" + $script:Failures++ + } else { + Write-Host " ok : $Msg = '$Actual'" + } +} + +function Assert-True { + param([bool] $Cond, [string] $Msg) + $script:Total++ + if (-not $Cond) { + Write-Host " FAIL: $Msg (expected condition true)" + $script:Failures++ + } else { + Write-Host " ok : $Msg" + } +} + +function New-Workspace { + $root = Join-Path $env:TEMP ("aisesstest_" + [guid]::NewGuid().ToString('N').Substring(0, 10)) + New-Item -ItemType Directory -Force -Path (Join-Path $root 'temp') | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $root 'runtime') | Out-Null + return $root +} + +# The probe runs the SHIPPED Get-SapAiSessionId in a clean child process and +# reports the resolved id plus every breadcrumb file (pid|content|alive). Takes +# the lib path as an arg so the here-string needs no interpolation/escaping. +$script:ProbeBody = @' +param([string]$RuntimeDir, [string]$ConnLib) +$ErrorActionPreference = 'Stop' +. $ConnLib +$id = Get-SapAiSessionId -RuntimeDir $RuntimeDir +Write-Output "RESULTID=$id" +$dir = Join-Path $RuntimeDir 'ai_session_by_pid' +if (Test-Path $dir) { + foreach ($f in (Get-ChildItem $dir -Filter '*.txt' -File)) { + $content = (Get-Content $f.FullName -Raw -Encoding UTF8).Trim() + $stem = [System.IO.Path]::GetFileNameWithoutExtension($f.Name) + $alive = $false + $n = 0 + if ([int]::TryParse($stem, [ref]$n)) { + try { Get-Process -Id $n -ErrorAction Stop | Out-Null; $alive = $true } catch {} + } + Write-Output "CRUMB=$stem|$content|$alive" + } +} +'@ + +function Invoke-Probe { + # Runs the probe with a controlled env. $CcId / $SdId of '' clear the var. + param($Root, [string] $CcId, [string] $SdId) + $probePath = Join-Path $Root 'probe.ps1' + [System.IO.File]::WriteAllText($probePath, $script:ProbeBody, [System.Text.UTF8Encoding]::new($false)) + $rt = Join-Path $Root 'runtime' + $savedCc = $env:CLAUDE_CODE_SESSION_ID + $savedSd = $env:SAPDEV_AI_SESSION_ID + try { + if ([string]::IsNullOrEmpty($CcId)) { Remove-Item Env:CLAUDE_CODE_SESSION_ID -ErrorAction SilentlyContinue } + else { $env:CLAUDE_CODE_SESSION_ID = $CcId } + if ([string]::IsNullOrEmpty($SdId)) { Remove-Item Env:SAPDEV_AI_SESSION_ID -ErrorAction SilentlyContinue } + else { $env:SAPDEV_AI_SESSION_ID = $SdId } + $out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $probePath $rt $ConnLib 2>&1 + return ($out | Out-String) + } finally { + if ($null -eq $savedCc) { Remove-Item Env:CLAUDE_CODE_SESSION_ID -ErrorAction SilentlyContinue } else { $env:CLAUDE_CODE_SESSION_ID = $savedCc } + if ($null -eq $savedSd) { Remove-Item Env:SAPDEV_AI_SESSION_ID -ErrorAction SilentlyContinue } else { $env:SAPDEV_AI_SESSION_ID = $savedSd } + } +} + +function Get-Crumbs { + param([string] $ProbeOut) + $crumbs = @() + foreach ($line in ($ProbeOut -split "`r?`n")) { + if ($line -match '^CRUMB=([^|]*)\|([^|]*)\|(.*)$') { + $crumbs += [pscustomobject]@{ Pid = $Matches[1]; Content = $Matches[2]; Alive = $Matches[3].Trim() } + } + } + return ,$crumbs +} + +function Get-ResultId { + param([string] $ProbeOut) + $m = ($ProbeOut -split "`r?`n" | Where-Object { $_ -match '^RESULTID=' } | Select-Object -First 1) + if ($m -match '^RESULTID=(.*)$') { return $Matches[1].Trim() } + return '' +} + +Write-Host "Lib under test: $ConnLib" +Write-Host "Broker under test: $Broker" +Write-Host "" + +# ============================================================================= +# PART A -- Get-SapAiSessionId writes the liveness breadcrumb (the fix). +# ============================================================================= +Write-Host "Part A: Get-SapAiSessionId drops the pid->id liveness breadcrumb" +Write-Host "" + +# --- A1: CLAUDE_CODE_SESSION_ID -> id returned AND a matching live breadcrumb. +Write-Host "A1: CLAUDE_CODE_SESSION_ID writes a live breadcrumb (the bug fix)" +$wsA = New-Workspace +$outA1 = Invoke-Probe -Root $wsA -CcId 'CC-AAA-111' -SdId '' +$idA1 = Get-ResultId -ProbeOut $outA1 +$crumbA1 = @(Get-Crumbs -ProbeOut $outA1) +Assert-Eq $idA1 'CC-AAA-111' 'returns the CLAUDE_CODE_SESSION_ID' +Assert-Eq $crumbA1.Count 1 'exactly one breadcrumb written' +if ($crumbA1.Count -eq 1) { + Assert-Eq $crumbA1[0].Content 'CC-AAA-111' 'breadcrumb content = the id' + Assert-Eq $crumbA1[0].Alive 'True' 'breadcrumb pid is a live process' +} + +# --- A2: idempotent -- a second call keeps a single breadcrumb (same owner pid). +Write-Host "A2: second call is idempotent (still one breadcrumb)" +$outA2 = Invoke-Probe -Root $wsA -CcId 'CC-AAA-111' -SdId '' +$crumbA2 = @(Get-Crumbs -ProbeOut $outA2) +Assert-Eq $crumbA2.Count 1 'still exactly one breadcrumb after re-call' +if ($crumbA2.Count -eq 1) { Assert-Eq $crumbA2[0].Content 'CC-AAA-111' 'content unchanged' } + +# --- A3: SAPDEV_AI_SESSION_ID wins AND refreshes the breadcrumb to that id. +Write-Host "A3: SAPDEV_AI_SESSION_ID overrides + refreshes the breadcrumb" +$outA3 = Invoke-Probe -Root $wsA -CcId 'CC-AAA-111' -SdId 'SD-BBB-222' +$idA3 = Get-ResultId -ProbeOut $outA3 +$crumbA3 = @(Get-Crumbs -ProbeOut $outA3) +Assert-Eq $idA3 'SD-BBB-222' 'SAPDEV_AI_SESSION_ID takes precedence' +Assert-True ([bool]($crumbA3 | Where-Object { $_.Content -eq 'SD-BBB-222' })) 'breadcrumb refreshed to the override id' +Assert-True (-not ($crumbA3 | Where-Object { $_.Content -eq 'CC-AAA-111' })) 'stale CC id overwritten (same owner pid)' +Remove-Item -Recurse -Force $wsA -ErrorAction SilentlyContinue +Write-Host "" + +# --- A4: no env id -> legacy parent-PID + minted-GUID path still works. +Write-Host "A4: no env id -> legacy GUID path still mints + records an id" +$wsA4 = New-Workspace +$outA4 = Invoke-Probe -Root $wsA4 -CcId '' -SdId '' +$idA4 = Get-ResultId -ProbeOut $outA4 +$crumbA4 = @(Get-Crumbs -ProbeOut $outA4) +Assert-True ($idA4 -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-') 'legacy path returns a GUID' +Assert-Eq $crumbA4.Count 1 'legacy path writes one breadcrumb' +if ($crumbA4.Count -eq 1) { Assert-Eq $crumbA4[0].Content $idA4 'breadcrumb content = the minted GUID' } +Remove-Item -Recurse -Force $wsA4 -ErrorAction SilentlyContinue +Write-Host "" + +# ============================================================================= +# PART B -- the broker spawns (isolates) iff a live breadcrumb proves +# contention; without it, it reproduces the collapse. +# ============================================================================= +Write-Host "Part B: ensure-own-session spawns vs. collapses based on the breadcrumb" +Write-Host "" + +$nowTs = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss') + +# Registry: con[0] pinned by BOTH AI sessions; ses[0] currently claimed by the +# OTHER conversation. ai_sessions has both ids so MINE resolves its pin. +$Registry = @" +{"updated_at":"$nowTs","ai_sessions":{"AISESS-MINE":{"connection_id":"CID-1","pinned_at":"$nowTs","pin_reason":"test","last_seen_at":"$nowTs"},"AISESS-OTHER":{"connection_id":"CID-1","pinned_at":"$nowTs","pin_reason":"test","last_seen_at":"$nowTs"}},"connections":[{"connection_path":"/app/con[0]","connection_id":"CID-1","system_name":"S4D","client":"100","user":"MICHAELLI","language":"ZH","description":"S4HANA_1909_TEST","logon_id":"LID-X","message_server":"","logon_group":"","system_id":"S4D","application_server":"s4sapdev","system_number":"70","entries":[{"path":"/app/con[0]/ses[0]","session_number":1,"task_id":"","ai_session_id":"AISESS-OTHER","owner_pid":0,"owner_skill":"","status":"claimed","claim_time":"$nowTs","ttl_seconds":600,"discovered":false,"stuck_program":"","stuck_screen":"","was_created":false}]}]} +"@ + +# Live INFO: con[0] identity matches the stored block (so the sweep keeps the +# entry). For B2 it also exposes the freshly-spawned ses[1] sitting at Easy +# Access (S000) so the post-spawn reset is skipped (no COM call needed). +$LiveInfoSes0 = '{"ok":true,"connections":[{"connection_path":"/app/con[0]","description":"S4HANA_1909_TEST","system_name":"S4D","client":"100","user":"MICHAELLI","language":"ZH","logon_id":"LID-X","message_server":"","logon_group":"","system_id":"S4D","application_server":"s4sapdev","system_number":"70","sessions":[{"path":"/app/con[0]/ses[0]","session_number":1,"transaction":"SMEN","program":"SAPLSMTR_NAVIGATION","screen":101,"has_popup":false}]}]}' +$LiveInfoSes01 = '{"ok":true,"connections":[{"connection_path":"/app/con[0]","description":"S4HANA_1909_TEST","system_name":"S4D","client":"100","user":"MICHAELLI","language":"ZH","logon_id":"LID-X","message_server":"","logon_group":"","system_id":"S4D","application_server":"s4sapdev","system_number":"70","sessions":[{"path":"/app/con[0]/ses[0]","session_number":1,"transaction":"SMEN","program":"SAPLSMTR_NAVIGATION","screen":101,"has_popup":false},{"path":"/app/con[0]/ses[1]","session_number":2,"transaction":"S000","program":"SAPMSYST","screen":40,"has_popup":false}]}]}' + +function Invoke-EnsureOwn { + param($Root, [string] $FakeInfoJson, [string] $FakeSpawnPath, [string[]] $LiveCrumbs) + # Pre-seed any "other conversation is alive" breadcrumbs. Each entry is + # "="; pid must be a live process for Is-ProcessAlive to count it. + $crumbDir = Join-Path $Root 'runtime\ai_session_by_pid' + if ($LiveCrumbs) { + New-Item -ItemType Directory -Force -Path $crumbDir | Out-Null + foreach ($c in $LiveCrumbs) { + $parts = $c -split '=', 2 + [System.IO.File]::WriteAllText((Join-Path $crumbDir ($parts[0] + '.txt')), $parts[1], [System.Text.UTF8Encoding]::new($false)) + } + } + $fi = Join-Path $Root 'fake_info.json' + [System.IO.File]::WriteAllText($fi, $FakeInfoJson, [System.Text.UTF8Encoding]::new($false)) + $env:SAPDEV_BROKER_FAKE_INFO = $fi + if ($FakeSpawnPath) { $env:SAPDEV_BROKER_FAKE_SPAWN = $FakeSpawnPath } + try { + $wt = Join-Path $Root 'temp' + # Pass -AiSessionId explicitly so the decision is driven purely by the + # seeded breadcrumbs, independent of this runner's real env id. + $out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Broker ` + -Action 'ensure-own-session' -WorkTemp $wt -AiSessionId 'AISESS-MINE' 2>&1 + return ($out | Out-String) + } finally { + Remove-Item Env:SAPDEV_BROKER_FAKE_INFO -ErrorAction SilentlyContinue + Remove-Item Env:SAPDEV_BROKER_FAKE_SPAWN -ErrorAction SilentlyContinue + } +} + +# --- B1: NO breadcrumb for the other conversation -> the collapse (the bug). +Write-Host "B1: no live breadcrumb -> broker formalizes the shared ses[0] (collapse repro)" +$wsB1 = New-Workspace +[System.IO.File]::WriteAllText((Join-Path $wsB1 'runtime\session_registry.json'), $Registry, [System.Text.UTF8Encoding]::new($false)) +$outB1 = Invoke-EnsureOwn -Root $wsB1 -FakeInfoJson $LiveInfoSes0 -FakeSpawnPath '' -LiveCrumbs @() +$lineB1 = ($outB1 -split "`r?`n" | Where-Object { $_ -match 'OWN_SESSION:' } | Select-Object -First 1) +Assert-True ($outB1 -match 'formalized=true') "without a breadcrumb it adopts ses[0] ('$($lineB1.Trim())')" +Assert-True (-not ($outB1 -match 'spawned=true')) 'and does NOT spawn' +Assert-True ($outB1 -match "INFO: formalizing .* ai_session 'AISESS-OTHER'") 'emits a diagnostic instead of collapsing silently' +Remove-Item -Recurse -Force $wsB1 -ErrorAction SilentlyContinue +Write-Host "" + +# --- B2: live breadcrumb for the other conversation -> spawn a fresh session. +Write-Host "B2: live breadcrumb present -> broker spawns ses[1] (isolation preserved)" +$wsB2 = New-Workspace +[System.IO.File]::WriteAllText((Join-Path $wsB2 'runtime\session_registry.json'), $Registry, [System.Text.UTF8Encoding]::new($false)) +# Seed the other conversation as alive: map THIS test runner's pid (definitely +# alive) to AISESS-OTHER. +$outB2 = Invoke-EnsureOwn -Root $wsB2 -FakeInfoJson $LiveInfoSes01 -FakeSpawnPath '/app/con[0]/ses[1]' -LiveCrumbs @("$PID=AISESS-OTHER") +$lineB2 = ($outB2 -split "`r?`n" | Where-Object { $_ -match 'OWN_SESSION:' } | Select-Object -First 1) +Assert-True ($outB2 -match 'spawned=true') "with a live breadcrumb it spawns a new session ('$($lineB2.Trim())')" +Assert-True ($outB2 -match 'ses\[1\]') 'the claimed session is the newly spawned ses[1]' +Assert-True (-not ($outB2 -match 'formalized=true')) 'and does NOT formalize the shared ses[0]' +Remove-Item -Recurse -Force $wsB2 -ErrorAction SilentlyContinue +Write-Host "" + +# --- Summary ----------------------------------------------------------------- +Write-Host "================================================================" +if ($script:Failures -eq 0) { + Write-Host "PASS: $($script:Total)/$($script:Total) assertions passed." + exit 0 +} else { + Write-Host "FAIL: $($script:Failures)/$($script:Total) assertions failed." + exit 1 +}