Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/sap-dev-core/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
71 changes: 71 additions & 0 deletions plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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\<owner_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
Expand All @@ -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
}

Expand All @@ -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
}

Expand Down
20 changes: 20 additions & 0 deletions plugins/sap-dev-core/shared/scripts/sap_session_broker.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
61 changes: 37 additions & 24 deletions plugins/sap-dev-core/skills/sap-login/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -722,8 +722,9 @@ step errors:
powershell -ExecutionPolicy Bypass -File "<SAP_DEV_CORE_SHARED_DIR>\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
Expand Down Expand Up @@ -779,31 +780,43 @@ Suggested `<CLASS>`: `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\<owner_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\<owner_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.

---

Expand Down
Loading
Loading