Skip to content

fix: identify Windows/Git Bash harness sessions for the session lock - #6300

Open
cr101 wants to merge 9 commits into
kunchenguid:mainfrom
cr101:fix/windows-session-lock-identity
Open

cr101 wants to merge 9 commits into
kunchenguid:mainfrom
cr101:fix/windows-session-lock-identity

Conversation

@cr101

@cr101 cr101 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Let a Firstmate session on Windows/Git Bash acquire its session lock instead of always dropping to read-only, without changing POSIX behavior.
This is the first small piece split out of #4803. It carries the session-identity work from #3553 by @lmktechnology, rebased onto current main with his authorship preserved, plus follow-up commits that fix review findings.
Walking the real Windows parent chain was rejected as unsafe: MSYS exec emulation leaves dangling parent ids that Windows can reissue to unrelated processes. A harness that publishes no session pid stays read-only.
It does not depend on the #506 platform seam, and either can adapt to the other.

What Changed

  • bin/fm-session-lock-lib.sh gains a Windows/Git Bash identity path, gated on fm_win_boundary_applies: a Cygwin ps fallback (fm_ps_ppid) for when ps -o is unsupported, and a session identity taken from the harness-published session pid, confirmed against the Windows process table and recorded as a tagged win:<pid>. The real Windows parent chain is never walked, and a harness that publishes no pid stays read-only. bin/fm-lock.sh keeps fm_session_lock_anchor_pid and adds a Windows-specific error message. POSIX/Linux/macOS behavior is unchanged: the tagged shape is accepted only where the Windows boundary applies and the part after win: is all digits.
  • Every lock gate (fm-bootstrap, fm-claude-stop-autoarm, fm-session-start, fm-sessionstart-run, fm-startup-network, fm-lock) accepts the tagged identity through fm_session_pid_valid. fm_session_lock_foreign_owner_live and fm_session_lock_inspect now treat a live tagged holder as live. fm_session_lock_inspect also treats an exited tagged holder as stale, because fm_win_command now returns a different code for an absent pid than for an unreadable process table. The sessionstart nudge keeps its own ancestry question and hands a tagged holder to fm_session_lock_owned_by_self.
  • The task lease now records and honors a live tagged lock holder. fm-lease.sh and fm-supervision-host.sh pass the lock value through whole instead of stripping it to digits, and fm_lease_live checks a win: holder against the Windows process table. The session-lock library is loaded only for a win:-shaped value. Docs (docs/scripts.md, docs/sessionstart-nudge.md) describe the tagged identity. tests/fm-session-lock-ancestry.test.sh and tests/fm-supervision-host.test.sh gain Cygwin/Windows and tagged lease-holder regression tests.

Fixes #3396. Refs #4535, #4539 (their remaining numeric-only readers are out of scope here).

Known limitations

  • Identity on Windows comes from the session pid the harness publishes (CLAUDE_PID), confirmed live and harness-shaped in the Windows process table. A separate harness started from inside a Claude window inherits that variable, so it can be treated as the same session and pass the ownership check. On Linux/macOS the same nested harness would not own the lock. Windows exposes no reliable parent chain to tell them apart, so this is accepted as a limitation of the published-pid approach.
  • Lock acquisition on Git Bash still needs native symlink support (for example MSYS=winsymlinks:nativestrict); without it the claim lock never succeeds. That is the existing issue Windows: fm_lock_try_create can never succeed without symlink privilege (ln -s silently degrades to a copy) #3267 and is not changed here.

Risk Assessment

✅ Low: The last fix round changes only tests. The two tests now fail without their fixes: the caller-supplied win:7300 case catches removal of the win:* arm, and the host test asserts holder=win:7204 when driven through the real host. The production lease and host code from earlier rounds is unchanged, and Windows behavior stays gated on win:-shaped values.

Testing

I drove every scenario live on this real Windows 11 Git Bash host. The scripts ran from the gate worktree against disposable lab homes made with fm-lab-home.sh; each lab was removed afterwards, and the worktree is clean. Identities came from real processes: this session's live claude.exe (CLAUDE_PID), another live claude.exe until it exited on its own partway through, and a claude.exe stand-in that this test started and stopped itself. Everything passed. Covered: acquire, refusal, status, inbox ready, stale reclaim, malformed-value rejection, read-only fallbacks, the task lease following the tagged holder, and POSIX inertness under a faked Linux uname. A base-vs-HEAD control shows the fix is real. Two environmental issues, neither a regression from this change. First, lease claims hang when the lab sits under the Git Bash /tmp mount (symlink readlink path mismatch, the same family as upstream #3267), so the lease scenarios used a worktree-local lab; a numeric-lock control behaves the same way. Second, the ancestry suite passes all 15 tests for this change on Git Bash but then stops at a fixture inherited from main that is Linux-only (ps -o ppid=). Base fails earlier on this host, and the suite was already reported to pass in full in a Linux container. There is no UI surface, so the evidence is CLI transcripts.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Session on Windows/Git Bash acquires a free home and records its identity as win:<CLAUDE_PID> ✅ pass live live-lock-transcript.txt S1/S2: 'lock acquired: harness pid win:3268', state/.lock=win:3268, status held, re-acquire ok, owned_by_self=yes
Second session is refused while a live tagged holder owns the lock; status, foreign-owner and inbox ready report it live ✅ pass live live-lock-transcript.txt S3 and live-base-vs-head-control.txt: acquire exit 1 'another live firstmate session holds the lock (pid win:N)', status 'held by live harness', foreign_live=yes, inbox lock s…
Dead tagged holder reads as stale and the home is reclaimed ✅ pass live live-lock-transcript.txt S4 and control tail: 'lock: stale (pid win:999996 ...)' then 'lock acquired: harness pid win:3268'
Malformed tagged values (win:7abc, win:, 'win:7 x') are rejected ✅ pass live live-lock-transcript.txt S5: invalid for all three, valid for win:<live>; status reads them as non-held
Harness with no published pid, or CLAUDE_PID naming a non-harness process, stays read-only ✅ pass live live-lock-transcript.txt S6/S6b: Windows-specific error, exit 1, no state/.lock written (non-harness case used explorer.exe)
Task lease records and honors the tagged holder passed whole, as the supervision host passes it ✅ pass live live-lease-posix-transcript.txt S7/S7b: lease 'branch win:3268 ... live', main claim exit 6; after the holder is dead the lease is stale and main claim succeeds
POSIX unchanged: a win:N lock read with uname=Linux stays inert (rejected like main; Stop auto-arm does not claim) ✅ pass live live-lease-posix-transcript.txt S8: status stale/non-held, pid_valid=no, foreign_live=no, fm-claude-stop-autoarm.sh exit 0, lock untouched
Evidence: Live lock driver transcript (acquire, refusal, status, stale reclaim, malformed, read-only)

Source: Live lock driver transcript (acquire, refusal, status, stale reclaim, malformed, read-only)

== platform: MINGW64_NT-10.0-26200; self=win:3268 foreign=win:6448 dead=win:999996
== ps -o support check:
ps: unknown option -- o

== S1 acquire on free home (fm-lock.sh)
lock acquired: harness pid win:3268
exit=0
state/.lock line1: win:3268
== status
lock: held by live harness pid win:3268
== re-acquire by same session
lock acquired: harness pid win:3268
exit=0

== S2 owned_by_self / foreign_owner_live (own lock)
owned_by_self=yes
foreign_live=no

== S3 second live session holds the lock (win:6448)
error: another live firstmate session holds the lock (pid win:6448); operate read-only until resolved
acquire exit=1 (expect refusal)
lock still: win:6448
lock: held by live harness pid win:6448
owned_by_self=no
foreign_live=yes pid=win:6448
== inbox ready lock fields
{"schema":"fm-primary-ready.v1","home":"Temp/fm-lab.HP7JVP","observed_at":"2026-10-03T10:48:05Z","lock":{"state":"held","pid":null,"live_harness":true},"wake_consumer":{"state":"unknown","reason":"supervision-model-unknown-for-home","beacon_age_seconds":null},"posture":{"state":"present"},"can_receive":"unknown"}

== S4 dead tagged holder (win:999996) -> stale, reclaimed
lock: stale (pid win:999996 dead or not a harness)
lock acquired: harness pid win:3268
acquire exit=0
lock now: win:3268

== S5 malformed tagged values are rejected
invalid [win:7abc]
invalid [win:]
invalid [win:7 x]
valid   [win:6448]
lock: stale (pid win:7abc dead or not a harness)
lock: stale (pid win: dead or not a harness)

== S6 harness publishing no pid stays read-only
error: cannot identify this harness session on Windows: it publishes no session pid this build recognizes (see FM_WIN_HARNESS_PID_VARS in bin/fm-session-lock-lib.sh); operate read-only until resolved
exit=1
ls: cannot access '/tmp/fm-lab.HP7JVP/state/.lock': No such file or directory
== S6b CLAUDE_PID naming a non-harness live process (explorer/bash) stays read-only
error: cannot identify this harness session on Windows: it publishes no session pid this build recognizes (see FM_WIN_HARNESS_PID_VARS in bin/fm-session-lock-lib.sh); operate read-only until resolved
exit=1 (CLAUDE_PID=7960 explorer)
ls: cannot access '/tmp/fm-lab.HP7JVP/state/.lock': No such file or directory

== S7 task lease records and honors tagged holder
claim exit=0
error: claim refused - task 'demo-task' is leased to the branch supervision actor (state/.lease-demo-task)
main claim exit=6 (expect refusal while branch lease live)
== lab removed: yes
Evidence: Live lease + POSIX-inert transcript

Source: Live lease + POSIX-inert transcript

== S7 lease: lock=win:3268, branch claims with the host-style export (whole first line of state/.lock)
claim exit=0
lease file: branch win:3268 1791024897
branch win:3268 1791024897 live
check exit=0
error: claim refused - task 'demo-task' is leased to the branch supervision actor (state/.lease-demo-task)
main claim exit=6 (6 = refused, branch lease live)
== S7b lease held by a dead tagged holder reads stale; main may take it
branch win:3268 1791024897 stale
check exit=0
main claim exit=0

== S8 POSIX unchanged: same live win:6448 lock read with uname reporting Linux
-- uname now: Linux
lock: stale (pid win:6448 dead or not a harness)
pid_valid=no (rejected like main)
foreign_live=no
-- Stop auto-arm hook with a win:N lock on Linux (must stay inert, lock untouched)
hook exit=0
lock after hook: win:6448
-- same hook on real Windows uname (foreign live holder): lock must also remain
hook exit=0
lock after hook: win:6448
== lab removed: yes
Evidence: Base vs HEAD control with a live tagged holder

Source: Base vs HEAD control with a live tagged holder

-- BASE e31bc6e lock: stale (pid win:28020 dead or not a harness) error: cannot locate harness process in ancestry foreign_live=no -- HEAD 926681f lock: held by live harness pid win:28020 error: another live firstmate session holds the lock (pid win:28020); operate read-only until resolved foreign_live=yes pid=win:28020 "lock":{"state":"held","pid":null,"live_harness":true} -- stand-in stopped; HEAD now lock: stale (pid win:28020 dead or not a harness) lock acquired: harness pid win:3268

== live foreign holder: stand-in claude.exe win:28020 (renamed ping.exe started by this test); self=win:3268
-- BASE e31bc6e
lock: stale (pid win:28020 dead or not a harness)
error: cannot locate harness process in ancestry
base acquire exit=1
foreign_live=no
-- HEAD 926681f
lock: held by live harness pid win:28020
error: another live firstmate session holds the lock (pid win:28020); operate read-only until resolved
head acquire exit=1 (refused, lock kept: win:28020)
foreign_live=yes pid=win:28020
"lock":{"state":"held","pid":null,"live_harness":true}
-- stand-in stopped (own process); HEAD now
lock: stale (pid win:28020 dead or not a harness)
lock acquired: harness pid win:3268
reclaim exit=0 lock=win:3268
Evidence: Driver scripts

Source: Driver scripts

#!/usr/bin/env bash
# Live Windows/Git Bash driver for the tagged session-lock identity (run from the gate worktree).
set -u
export MSYS=winsymlinks:nativestrict
unset FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_CONFIG_OVERRIDE FM_PROJECTS_OVERRIDE NO_MISTAKES_GATE FM_GATE_REFUSE_BYPASS
SELF=${CLAUDE_PID:?}      # this live claude.exe session
FOREIGN=$1                # another live claude.exe (observed read-only via ps -W)
DEAD=$2                   # a Windows pid absent from ps -W
LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-lab.XXXXXX"); bin/fm-lab-home.sh create "$LAB" >/dev/null
export FM_HOME=$LAB
T() { timeout 30 "$@"; }
echo "== platform: $(uname -s); self=win:$SELF foreign=win:$FOREIGN dead=win:$DEAD"
echo "== ps -o support check:"; ps -o comm= -p $$ 2>&1 | head -1
echo; echo "== S1 acquire on free home (fm-lock.sh)"; T bin/fm-lock.sh; echo "exit=$?"; echo "state/.lock line1: $(head -1 "$LAB/state/.lock")"
echo "== status"; T bin/fm-lock.sh status
echo "== re-acquire by same session"; T bin/fm-lock.sh; echo "exit=$?"
echo; echo "== S2 owned_by_self / foreign_owner_live (own lock)"
( . bin/fm-session-lock-lib.sh; fm_session_lock_owned_by_self "$LAB/state" && echo owned_by_self=yes || echo owned_by_self=no
  fm_session_lock_foreign_owner_live "$LAB/state" && echo foreign_live=yes || echo foreign_live=no )
echo; echo "== S3 second live session holds the lock (win:$FOREIGN)"
printf 'win:%s\n' "$FOREIGN" > "$LAB/state/.lock"; rm -f "$LAB/state/.lock-session"
T bin/fm-lock.sh; echo "acquire exit=$? (expect refusal)"; echo "lock still: $(head -1 "$LAB/state/.lock")"
T bin/fm-lock.sh status
( . bin/fm-session-lock-lib.sh; fm_session_lock_owned_by_self "$LAB/state" && echo owned_by_self=yes || echo owned_by_self=no
  fm_session_lock_foreign_owner_live "$LAB/state" && echo "foreign_live=yes pid=$FM_SESSION_LOCK_FOREIGN_OWNER_PID" || echo foreign_live=no )
echo "== inbox ready lock fields"; T bin/fm-inbox.sh ready 2>&1 | grep -i lock
echo; echo "== S4 dead tagged holder (win:$DEAD) -> stale, reclaimed"
printf 'win:%s\n' "$DEAD" > "$LAB/state/.lock"
T bin/fm-lock.sh status
T bin/fm-lock.sh; echo "acquire exit=$?"; echo "lock now: $(head -1 "$LAB/state/.lock")"
echo; echo "== S5 malformed tagged values are rejected"
( . bin/fm-session-lock-lib.sh; for v in 'win:7abc' 'win:' 'win:7 x' "win:$FOREIGN"; do fm_session_pid_valid "$v" && echo "valid   [$v]" || echo "invalid [$v]"; done )
for v in 'win:7abc' 'win:'; do printf '%s\n' "$v" > "$LAB/state/.lock"; T bin/fm-lock.sh status; done
echo; echo "== S6 harness publishing no pid stays read-only"
rm -f "$LAB/state/.lock" "$LAB/state/.lock-session"
env -u CLAUDE_PID -u CLAUDE_CODE_SESSION_ID timeout 30 bin/fm-lock.sh; echo "exit=$?"; ls "$LAB/state/.lock" 2>&1
echo "== S6b CLAUDE_PID naming a non-harness live process (explorer/bash) stays read-only"
NONH=$(ps -W | awk '$NF ~ /explorer.exe$/ {print $4; exit}')
CLAUDE_PID=$NONH timeout 30 env -u CLAUDE_CODE_SESSION_ID bin/fm-lock.sh; echo "exit=$? (CLAUDE_PID=$NONH explorer)"; ls "$LAB/state/.lock" 2>&1
echo; echo "== S7 task lease records and honors tagged holder"
printf 'win:%s\n' "$SELF" > "$LAB/state/.lock"
FM_SUPERVISION_ACTOR=branch FM_LEASE_HOLDER_PID="win:$SELF" T bin/fm-lease.sh claim demo-task; echo "claim exit=$?"
cat "$LAB/state"/*lease* 2>/dev/null | head -3; ls "$LAB/state" | grep -i lease
FM_SUPERVISION_ACTOR=main T bin/fm-lease.sh claim demo-task; echo "main claim exit=$? (expect refusal while branch lease live)"
rm -rf "$LAB"; echo "== lab removed: $( [ -e "$LAB" ] && echo no || echo yes)"
Evidence: Driver scripts (lease/POSIX)

Source: Driver scripts (lease/POSIX)

#!/usr/bin/env bash
set -u
export MSYS=winsymlinks:nativestrict
unset FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_CONFIG_OVERRIDE FM_PROJECTS_OVERRIDE NO_MISTAKES_GATE FM_GATE_REFUSE_BYPASS
SELF=${CLAUDE_PID:?}; FOREIGN=$1
LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-lab.XXXXXX"); bin/fm-lab-home.sh create "$LAB" >/dev/null; export FM_HOME=$LAB
echo "== S7 lease: lock=win:$SELF, branch claims with the host-style export (whole first line of state/.lock)"
printf 'win:%s\n' "$SELF" > "$LAB/state/.lock"
HOLDER=$(sed -n '1p' "$LAB/state/.lock" 2>/dev/null)   # same expression as bin/fm-supervision-host.sh handle_wake
FM_SUPERVISION_ACTOR=branch FM_LEASE_HOLDER_PID="$HOLDER" timeout 30 bin/fm-lease.sh claim demo-task; echo "claim exit=$?"
echo "lease file: $(tr '\t' ' ' < "$LAB/state/.lease-demo-task")"
timeout 30 bin/fm-lease.sh check demo-task; echo "check exit=$?"
FM_SUPERVISION_ACTOR=main timeout 30 bin/fm-lease.sh claim demo-task; echo "main claim exit=$? (6 = refused, branch lease live)"
echo "== S7b lease held by a dead tagged holder reads stale; main may take it"
printf 'win:999996\n' > "$LAB/state/.lock"
timeout 30 bin/fm-lease.sh check demo-task; echo "check exit=$?"
FM_SUPERVISION_ACTOR=main timeout 30 bin/fm-lease.sh claim demo-task; echo "main claim exit=$?"
echo; echo "== S8 POSIX unchanged: same live win:$FOREIGN lock read with uname reporting Linux"
FAKE=$(mktemp -d); printf '#!/bin/sh\necho Linux\n' > "$FAKE/uname"; chmod +x "$FAKE/uname"
printf 'win:%s\n' "$FOREIGN" > "$LAB/state/.lock"
echo "-- uname now: $(PATH="$FAKE:$PATH" uname -s)"
PATH="$FAKE:$PATH" timeout 30 bin/fm-lock.sh status
( PATH="$FAKE:$PATH"; . bin/fm-session-lock-lib.sh
  fm_session_pid_valid "win:$FOREIGN" && echo "pid_valid=yes" || echo "pid_valid=no (rejected like main)"
  fm_session_lock_foreign_owner_live "$LAB/state" && echo foreign_live=yes || echo foreign_live=no )
echo "-- Stop auto-arm hook with a win:N lock on Linux (must stay inert, lock untouched)"
printf '{}' | PATH="$FAKE:$PATH" timeout 30 bin/fm-claude-stop-autoarm.sh; echo "hook exit=$?"; echo "lock after hook: $(head -1 "$LAB/state/.lock")"
echo "-- same hook on real Windows uname (foreign live holder): lock must also remain"
printf '{}' | timeout 30 bin/fm-claude-stop-autoarm.sh; echo "hook exit=$?"; echo "lock after hook: $(head -1 "$LAB/state/.lock")"
rm -rf "$FAKE" "$LAB"; echo "== lab removed: $( [ -e "$LAB" ] && echo no || echo yes)"
Evidence: Ancestry suite on Git Bash (HEAD)

Source: Ancestry suite on Git Bash (HEAD)

ok - session-lock: a version-named Claude Code session is identified from its install path and argv[0]
ok - session-lock: a harness that is pid 1 of its own namespace is examined, not skipped
ok - session-lock: ordinary script paths under a harness directory are not harness processes
ok - session-lock: ownership stops at the first non-harness gap above the contiguous run
ok - session-lock: a live version-named session holding the lock is not mistaken for a stale owner
ok - session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does
ok - session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid
ok - session-lock: a Windows session is identified from its published pid across the severed parent link
ok - session-lock: a published Windows pid is confirmed against the process table before it is trusted
ok - session-lock: a tagged Windows pid is never resolved against the Cygwin process table
ok - session-lock: a published identity is accepted by every gate that reads the lock
ok - session-lock: a live tagged lock holder is a live foreign owner to a second Windows session
ok - session-lock: a Windows-tagged lock stays inert off Windows
ok - session-lock: a task lease follows a live tagged lock holder and goes stale when it exits
ok - session-lock: a harness in the local process table resolves untagged when ps has no -o option
not ok - the fixture hook never finished
/tmp/fm-session-lock-ancestry.7AX21I/e2e-version-named/session.sh: line 12: /tmp/fm-session-lock-ancestry.7AX21I/e2e-version-named/state/hook.rc: No such file or directory
Evidence: Ancestry suite on Git Bash (base control)

Source: Ancestry suite on Git Bash (base control)

ok - session-lock: a version-named Claude Code session is identified from its install path and argv[0]
ok - session-lock: a harness that is pid 1 of its own namespace is examined, not skipped
ok - session-lock: ordinary script paths under a harness directory are not harness processes
ok - session-lock: ownership stops at the first non-harness gap above the contiguous run
ok - session-lock: a live version-named session holding the lock is not mistaken for a stale owner
ok - session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does
ok - session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid
not ok - a version-named session must claim its home and rewake: expected exit 2, got 0
- Outcome: ⚠️ 2 infos across 1 run (16m12s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (3) ✅
  • ⚠️ bin/fm-lease.sh:132 - Simplification: fm-lease.sh claim now also accepts a Windows-tagged value through the caller-supplied FM_LEASE_HOLDER_PID (line 132), in addition to the state/.lock fallback (line 138). The approved follow-up only asks that the task lease "record and honor a live tagged lock holder". The lock fallback alone does that: when a caller passes a win: value and this branch is absent, the script falls through to the same lock holder. The callers that pass FM_LEASE_HOLDER_PID are the Pi branch extensions, and those are explicitly deferred to fix: numeric-only session-lock readers still reject a Windows-tagged win:<pid> lock #4539. Suggested remedy: drop the win:* arm at line 132 and keep only the lock-fallback arm at line 138. This needs author confirmation because it narrows an accepted input.

🔧 Fix applied.
1 warning still open:

  • ⚠️ bin/fm-supervision-host.sh:959 - fa3cd63 was meant to make the task lease record and honor a live tagged lock holder. It fixed only the state/.lock fallback in fm-lease.sh claim and missed the main caller that supplies FM_LEASE_HOLDER_PID: the Claude-home supervision host. That caller still sets FM_LEASE_HOLDER_PID=$(sed -n &#39;1p&#39; &#34;$STATE/.lock&#34; | tr -cd &#39;0-9&#39;). Failing sequence on Windows/Git Bash: the lock holds win:7204. host_still_owner passes, because fm_session_lock_owned_by_self accepts the tagged identity (bin/fm-supervision-host.sh:456). The engine subshell then exports FM_LEASE_HOLDER_PID=7204. Since a7ada0b, fm-lease.sh:131 accepts only a bare local number, so it takes 7204 and the lease records branch\t7204. In fm_lease_live (bin/fm-lease-lib.sh:207-223), 7204 is not win:-shaped, so the check falls to kill -0 7204. That either fails or hits an unrelated Cygwin pid, and then lock_pid=win:7204 fails the numeric case. The branch actor's lease therefore always reads stale, and the main actor can clear it and act on the same task. That is the failure the approved follow-up says is fixed. It also contradicts fm-lease.sh's own comment that values must never be reduced to their digits. Neither fix round introduced this: fa3cd63 left the sibling behind, and a7ada0b's narrowing neither causes nor fixes it. Minimal fix: export the whole first line without tr -cd &#39;0-9&#39;. A tagged value then fails fm-lease.sh:131, falls through to the lock fallback at fm-lease.sh:133-137, and records win:7204; a numeric POSIX lock behaves as before. Only one site needs the fix: no other caller derives FM_LEASE_HOLDER_PID from the lock, and the Pi extensions are deferred to fix: numeric-only session-lock readers still reject a Windows-tagged win:<pid> lock #4539.

🔧 Fix applied.
1 warning still open:

  • ⚠️ tests/fm-session-lock-ancestry.test.sh:694 - The round-3 regression test (added with 8e30f5c) cannot fail without that round's fix. The lock in this test already holds win:7204. If the restored win:* arm for FM_LEASE_HOLDER_PID (bin/fm-lease.sh:132) is deleted, the value is cleared and the claim falls through to the state/.lock fallback (bin/fm-lease.sh:137-141). That fallback records the same win:7204, so both assertions (the recorded holder and the main actor's exit 6) still pass. The defect R2-1 actually reported was the host's digit-stripping at bin/fm-supervision-host.sh:959, and the test never exercises it. Reverting that line to | tr -cd &#39;0-9&#39; would also leave the test green. Make the test fail before the fix. One option is to drive the host's lease export, or a minimal extraction of it, against a win:7204 lock and assert the recorded holder is win:7204 and not 7204. Another is to give the caller-supplied arm a case the fallback cannot produce, for example FM_LEASE_HOLDER_PID=win:7204 while the lock names a different live tagged holder, and assert which value is recorded. (The batched env lines at 694/697 also lost their backslash continuations and are now long single lines with runs of spaces. They still work but no longer match the surrounding style.)

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Test** - 2 infos
Scenario Result Live Evidence
Session on Windows/Git Bash acquires a free home and records its identity as win:<CLAUDE_PID> ✅ pass live live-lock-transcript.txt S1/S2: 'lock acquired: harness pid win:3268', state/.lock=win:3268, status held, re-acquire ok, owned_by_self=yes
Second session is refused while a live tagged holder owns the lock; status, foreign-owner and inbox ready report it live ✅ pass live live-lock-transcript.txt S3 and live-base-vs-head-control.txt: acquire exit 1 'another live firstmate session holds the lock (pid win:N)', status 'held by live harness', foreign_live=yes, inbox lock s…
Dead tagged holder reads as stale and the home is reclaimed ✅ pass live live-lock-transcript.txt S4 and control tail: 'lock: stale (pid win:999996 ...)' then 'lock acquired: harness pid win:3268'
Malformed tagged values (win:7abc, win:, 'win:7 x') are rejected ✅ pass live live-lock-transcript.txt S5: invalid for all three, valid for win:<live>; status reads them as non-held
Harness with no published pid, or CLAUDE_PID naming a non-harness process, stays read-only ✅ pass live live-lock-transcript.txt S6/S6b: Windows-specific error, exit 1, no state/.lock written (non-harness case used explorer.exe)
Task lease records and honors the tagged holder passed whole, as the supervision host passes it ✅ pass live live-lease-posix-transcript.txt S7/S7b: lease 'branch win:3268 ... live', main claim exit 6; after the holder is dead the lease is stale and main claim succeeds
POSIX unchanged: a win:N lock read with uname=Linux stays inert (rejected like main; Stop auto-arm does not claim) ✅ pass live live-lease-posix-transcript.txt S8: status stale/non-held, pid_valid=no, foreign_live=no, fm-claude-stop-autoarm.sh exit 0, lock untouched
  • bash live-lock-driver.sh 6448 999996 (evidence dir): fm-lock.sh acquire/status/re-acquire on a free lab home, owned_by_self/foreign_owner_live, refusal under a live foreign win: holder, inbox ready lock fields, stale+reclaim of a dead win: holder, malformed win: values, no-published-pid and non-harness CLAUDE_PID read-only paths, lease claim/refusal
  • bash live-lease-posix-driver.sh 6448 (evidence dir, lab under a worktree-local TMPDIR): lease claim with the host's sed -n 1p state/.lock export records win:<pid> and is live, main actor refused with exit 6, lease turns stale when the holder is dead; fake uname=Linux makes a win:N lock inert in fm-lock.sh status, fm_session_pid_valid, foreign_owner_live and fm-claude-stop-autoarm.sh (lock untouched)
  • Before/after control: base e31bc6e bin/ (git archive) vs HEAD against a live tagged stand-in holder (renamed ping.exe started and stopped by this test), covering fm-lock.sh status/acquire, foreign_owner_live and inbox ready, then reclaim after the stand-in exits
  • MSYS=winsymlinks:nativestrict bash tests/fm-session-lock-ancestry.test.sh on Git Bash for HEAD and for base e31bc6e as a control
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Adds Windows process-identity support to session-lock logic.

The PR appears safe to merge; no new actionable finding or outstanding blocking finding remains.

Reviews (3) · Last reviewed commit: "no-mistakes(review): Make tagged lease-h..."

Comment thread bin/fm-session-lock-lib.sh

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: first stamp vs tip 549e07f37fd73aa01d74cd126b1111c99175abed.

Closes verification: GitHub closing ref + body Fixes #3396 — yes, closes #3396 (Windows/Git Bash session lock always refuses). Queue hint closesReadyForPr=[3267] is a sort hint only (learning #2993): body Known limitations explicitly says #3267 (symlink privilege / fm_lock_try_create) is not changed here. Do not treat this PR as closing #3267.

HEAD 724da265aac5b1dda17073b6ff20f51e4679854c. Attestation MATCH. NM SUCCESS. Behavior portable + Herdr green. Lint 1 FAILURE — tip-wide shellcheck: out of memory / exit 251 (same envelope family as #5620 / #6271). Lint 2 SUCCESS. Greptile FAILURE 3/5 P1 (inherited CLAUDE_PID can grant ownership to a nested harness) — author acknowledged under Known limitations; recorded as security FYI only (not otherwise-ready; FM-LEARN #3168 — no Firstmate flag / no waiting-captain).

contract-class: restore — tip still cannot resolve harness identity across the Cygwin/Windows boundary (ps -o unsupported; parent link severed), so unconfigured Windows/Git Bash sessions stay read-only against the existing session-lock contract. Tagged win:<pid> from published harness pid + Windows process-table confirm restores that path without changing POSIX ancestry.

VISION (compressed): One captain/interface — aligns (restores usable session ownership, not a new captain surface). Authority explicit — aligns (fail-closed when pid unpublished/non-harness; nested-inherit limitation documented). Scripts/judgment — aligns (deterministic ps/pid bridge). Restart non-event — aligns (tagged liveness/stale recovery). Delegation spine — n/a. Fleet outlives vendor — aligns (platform-shaped adapter for Cygwin boundary). Scope — aligns (lock mechanics, not workshop).

Needed: green Lint 1 (or tip envelope landing) before merge consideration; Greptile P1 already documented as accepted Windows limitation. Not auto-merge eligible this pass. Firstmate flag no.

@cr101 cr101 closed this Oct 3, 2026
@cr101 cr101 reopened this Oct 3, 2026
Comment thread bin/fm-lease.sh
Comment thread bin/fm-session-lock-lib.sh Outdated
lmktechnology and others added 9 commits October 3, 2026 22:46
Every session start on Cygwin (Git for Windows) refused the fleet lock and
dropped to read-only with "cannot locate harness process in ancestry", so
spawning, steering, merging, the wake-queue drain, and supervision repair were
skipped on every start. Two independent causes, both on the identity path.

Cygwin's ps has no -o option at all and fails the whole invocation with
"unknown option -- o", so the ancestry walk aborted on its first hop. The walk
now reads comm, args, and ppid through accessors that fall back to Cygwin's
fixed ps columns, leaving the procps/BSD path unchanged.

That alone does not resolve the session: the parent link from a shell the
harness spawns does not cross the Cygwin boundary, and Cygwin reports that
shell's PPID as 1, so no walk can reach a harness that is a native Windows
process. Identity is instead taken from the session pid the harness publishes
and confirmed against the Windows process table before it is used - the pid
must still be live and its executable must independently identify a verified
harness - so an absent, stale, or non-harness value is discarded rather than
bound.

Walking the real Windows parent chain was implemented and then removed as
unsafe. MSYS emulates exec by spawning a fresh Windows process and exiting the
old one, so intermediate shells vanish and a child's recorded parent is
routinely a pid that no longer exists; Windows never reparents an orphan, so
that dangling id stays and can be reissued to an unrelated process. Following
it can bind a home's lock to the wrong process, which is the failure this file
exists to prevent. A harness that publishes nothing stays unresolved, which
leaves the session read-only exactly as before.

Windows pids are tagged rather than stored bare. They are a different namespace:
kill -0 reports a live Windows process as dead, and the number can collide with
an unrelated live Cygwin pid. The tag makes the value non-numeric, so a consumer
that treats it as a local pid - including a future kill - refuses it instead of
acting on the wrong process.

fm-sessionstart-nudge.sh carried a private second copy of the ownership walk and
so stayed wrong after the owner was fixed, nudging a session that already held
the lock. It now asks the owning function.

Verified on Windows 11 (Git Bash, Cygwin ps 3.4.10): the lock is acquired,
reports its holder, is idempotent, and refuses a bogus, dead, or non-harness
published pid. Regressions cover both platform departures behind a fake process
table, so they run on Linux and macOS CI too. The pre-existing e2e failure in
this suite on Windows is unchanged from main; it cannot exec its symlinked
fixture.

Refs kunchenguid#3396

Claude-Session: https://claude.ai/code/session_01F9T29YDqYSkQeDTC2Nfi7h
(cherry picked from commit 8b281b7)
Delegating the nudge to fm_session_lock_owned_by_self changed the question it
asks. The nudge asks whether a process in this ancestry took the lock; the
ownership predicate additionally requires a verified harness in that ancestry,
so a session whose lock was written by a plain shell started being nudged to
run session start again. tests/fm-sessionstart-nudge.test.sh pins the looser
contract deliberately.

The walk stays local, now reading ppid through the portable accessor so it also
survives a ps with no -o option, and defers to the ownership predicate only for
a Windows-tagged holder, which is not in this process table at all.

(cherry picked from commit 0baf64f)
…the lock

The Windows identity added in this branch introduced a second shape into
state/.lock. Six gates read that field, and each one answered "is this value
usable" with its own inline numeric test, so every one of them read a valid
Windows holder as malformed.

The visible cost was concentrated in startup completion: the record was never
written, and the clear/compact check that consumes it could never match, so
every clear or compact on Windows repeated the full startup sequence. The
deferred network sweeps reported ownership as changed when it had not, and the
Stop auto-arm treated a dead Windows session as an unreadable lock rather than
a recoverable one. fm-lease.sh was the outlier: rather than refusing a value it
could not use, `tr -cd '0-9'` reduced the tag to its digits and produced a
number naming an unrelated process in the local table. It happened to fail
closed downstream, but deriving a wrong-namespace pid is precisely what the tag
exists to prevent, so it now takes the value whole and requires a local pid.

fm_session_pid_valid is now the single owner of that question and every gate
delegates to it, so a third identity shape cannot split them again.

Verified against the shipped bytes of each gate with a tagged lock: startup
completion now records and is recognized (main's gate reruns the full startup
on the same input), both network-ownership gates authorize their sweeps, and a
tagged lock yields no lease holder pid instead of 7204.

(cherry picked from commit 9256182)
… every lock reader

Accept win:<digits> only where fm_win_boundary_applies, with an all-digit
remainder, so a Linux/macOS reader of a win:N lock stays inert as before.
The sessionstart nudge routes its tagged case through fm_win_untag_pid.
fm_session_lock_foreign_owner_live and fm_session_lock_inspect now validate
through fm_session_pid_valid and resolve a tagged holder through
fm_harness_pid_alive, so a live win:N holder reads as a live foreign owner
and as held; unverifiable tagged holders stay unknown.
… tagged holder

A Windows session can now hold the session lock as win:<pid>, but the task
lease still accepted only a local pid: the claim recorded the short-lived
fm-lease.sh process instead, and fm_lease_live rejected the tagged lock, so
the lease was never live and the other supervision actor could clear it and
act on the same task. The lease now records a live tagged lock holder whole
and treats it as live while it is still the lock holder and a verified
harness in the Windows process table. The session-lock library is loaded
only for a win:-shaped value, so POSIX lease verdicts are unchanged.

fm_session_lock_inspect left an exited tagged holder unknown, because
fm_win_command could not tell an absent pid from an unreadable table. It now
returns 1 only when a readable table proves the pid absent and 2 when the
table cannot be read, so inspection matches the local-pid contract: absent
is stale, a live non-harness is unknown with no live harness, and an
unreadable table stays unknown.
@cr101
cr101 force-pushed the fix/windows-session-lock-identity branch from 724da26 to 926681f Compare October 3, 2026 11:28
@cr101 cr101 changed the title fix(session-lock): identify a harness session on Windows/Git Bash fix: identify Windows/Git Bash harness sessions for the session lock Oct 3, 2026
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.

Windows/Git Bash: session lock always refuses - harness ancestry cannot be resolved

3 participants