Skip to content

fix(bin): resolve the session lock's harness identity on Windows hosts - #2769

Open
jhyunann wants to merge 1 commit into
kunchenguid:mainfrom
jhyunann:fix/windows-session-identity
Open

fix(bin): resolve the session lock's harness identity on Windows hosts#2769
jhyunann wants to merge 1 commit into
kunchenguid:mainfrom
jhyunann:fix/windows-session-identity

Conversation

@jhyunann

Copy link
Copy Markdown

The problem

On an MSYS host (Git Bash, MSYS2, Cygwin) the session lock can never be acquired. fm_harness_ancestry_pids finds no harness in the ancestry, so bin/fm-lock.sh exits with error: cannot locate harness process in ancestry, every session start refuses the fleet lock and stays read-only, and the Claude Stop auto-arm can never claim the home. In practice firstmate cannot spawn, steer, merge, drain the wake queue, or supervise anything on Windows at all.

Three host facts are involved, and each one defeats the obvious fix for the previous one.

The bundled ps has no -o. The POSIX walk asks ps -o comm= -p <pid>, which answers nothing here:

$ ps -o comm= -p $$
ps: unknown option -- o

MSYS reports PPid: 1 for any shell a native harness started, because it tracks parentage only among its own processes. So even with a working ps, the ancestry ends immediately:

$ grep '^PPid' /proc/$$/status
PPid:	1

MSYS has no execve. It implements exec by starting a fresh Windows process and ending the old one, so an exec'd child records a Windows parent that has already exited. Walking the Win32 tree alone therefore ends one hop above the caller — which is exactly where any firstmate script runs:

$ echo "shell msys pid: $$"; bash /tmp/evidence.sh
shell msys pid: 51725
child msys ppid: 51725
child winpid: 36148  recorded win32 parent: 23472
win32 parent GONE

Note the split: the child's MSYS parent is correct, while its Win32 parent is a corpse.

The fix

A platform process-access layer in bin/fm-session-lock-lib.sh becomes the single owner of "what is this process, and who is its parent". Everything above it stays pure identity logic and is unchanged.

  • POSIX is untouched — still ps and kill, same code path, same semantics.
  • The Windows branch reads the Win32 process tree once per shell and indexes it by pid, so a walk costs one PowerShell start and no process per hop. fm_session_lock_owned_by_self primes it in the caller's own shell so the subshell it forks for the walk inherits the table and one lock decision costs one read.
  • The emulation boundary is bridged by preferring each MSYS process's own parent link where it has one, falling back to the Win32 link only at the outermost MSYS process — the hop a native harness actually started. Neither link alone reaches the harness.
  • Recycled parent ids are refused. A Windows ParentProcessId is only the number recorded at creation, and Windows may reassign it once that parent exits. If the stranger it names is a harness, the walk would hand the home's lock to an unrelated session. A real parent cannot start after its own child, so hops violating that ordering are refused.

Every pid this reports on Windows is a WINPID, including the one written to state/.lock, because a native harness process has no other identity to record.

Lock claims

Every fm_lock_* claim is an atomic ln -s. Git Bash's default silently substitutes a directory copy and still exits 0:

$ ln -s owner link; [ -L link ] && echo symlink || echo "plain directory"
plain directory

Left alone this turns the compare-and-swap into two sessions each holding their own private directory and each believing it won. fm_lock_try_create now requests winsymlinks:nativestrict, and the existing readlink verification turns any substitution into a lost race rather than a granted lock. Because the correct outcome is then "refuse forever", it also reports once — previously every caller of fm_lock_acquire_wait simply blocked with no output at all.

This needs Windows Developer Mode enabled; the diagnostic says so.

Fork-free hot path

fm_harness_process_matches and fm_cursor_process_matches ran basename, grep and command substitutions on every hop. Those are free on Linux and cost ~0.1s each under emulated process creation, and this runs at every turn end. They are now shell builtins.

Measured on the host below: ~236ms per matcher call before, ~0ms after; a warm walk went from 1.67s to under 0.01s.

Verdicts are unchanged. I diffed the matcher across 23 process shapes (version-named installs, hook script paths, bare interpreters, pi/pi-signed, cursor's real shapes and lookalikes) before and after: byte-identical on every case.

Tests

tests/fm-session-lock-ancestry.test.sh gains a Windows layer that runs from any host through documented seams (FM_PROC_PLATFORM_OVERRIDE, FM_PROC_SELF_PID_OVERRIDE, FM_PROC_MSYS_PROC_ROOT — nothing in bin/ sets them), because the platform it describes cannot be reached from CI:

  • the walk crosses MSYS's exec boundary to reach the native harness — and asserts the divergence: with the MSYS link removed the walk finds nothing, so the case cannot go quietly vacuous
  • a parent that starts after its own child is refused as a recycled id — and the same shape with a possible start time still resolves, so the refusal is the ordering check and not a broken fixture
  • harness liveness is decided by the Win32 table rather than kill, using ids that do not exist on the host running the test
  • an unreadable process table resolves no owner and claims no lock

The existing POSIX cases now pin their own branch explicitly rather than inheriting the host's, so the file is meaningful on either platform.

docs/verification/runtime-backends.md gains a dated Windows-host section carrying the evidence above.

What was verified, and where

Verified on Windows 11 build 26200, Git for Windows bash (MINGW64_NT-10.0-26200), bash 5.3.15, Claude Code as primary harness:

  • bin/fm-lock.sh acquires the lock and bin/fm-session-start.sh completes a full digest — both previously impossible
  • bin/fm-lint.sh fully green (ShellCheck 0.11.0 pinned, actionlint 1.7.12 pinned, 3 workflows valid)
  • tests/fm-session-lock-ancestry.test.sh unit layer 8/8, including the 4 new Windows cases
  • tests/fm-wake-queue.test.sh and tests/fm-watcher-lock.test.sh produced no assertion failures

I could not run the full suite on this host. Its e2e layers spawn fixture trees that Windows cannot execute (CreateProcessW failed on a symlink to /bin/bash), and those same failures reproduce identically on unmodified main here, so they are a Windows fixture limitation rather than a regression. Linux CI is the authority for the full suite.

Two adjacent Windows gaps I did not touch, to keep this focused — both are recorded in the verification doc:

  • bin/fm-install-shellcheck.sh supports linux/darwin only, so the pinned linter must be fetched by hand on Windows (upstream does publish shellcheck-v0.11.0.zip). Same for bin/fm-install-actionlint.sh.
  • Detect-only bootstrap measures ~82s against the default 120s FM_SESSION_START_TIMEOUT, so the digest truncates intermittently until it is raised. Individual tool version probes cost 0.9–3.2s each; that is the tools' own startup, not firstmate's work.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Mm7UoJ8Jwspaqcm8iZMo8x

On MSYS hosts (Git Bash, MSYS2, Cygwin) the session lock could never be
acquired: fm_harness_ancestry_pids found no harness in the ancestry, so every
session start refused the fleet lock and stayed read-only, and the Claude Stop
auto-arm could never claim the home.

Two independent host facts break the POSIX walk there. The bundled ps has no
-o, so it answers no part of the question; and MSYS reports PPid 1 for any
shell a native harness started, because it tracks parentage only among its own
processes. A third fact breaks the obvious fix: MSYS has no execve, so an
exec'd child records a Windows parent that has already exited, and walking the
Win32 tree alone ends one hop above the caller.

Add a platform process-access layer as the single owner of "what is this
process, and who is its parent". POSIX keeps using ps and kill unchanged. The
Windows branch reads the Win32 process tree once per shell, indexes it by pid,
and prefers each MSYS process's own parent link where it has one, so the walk
crosses the emulation boundary and reaches the native harness. Windows reuses
process ids, so a hop whose claimed parent started after its own child is
refused rather than followed into a stranger.

Lock claims need the same treatment: every fm_lock_* claim is an atomic ln -s,
and Git Bash's default silently substitutes a directory copy while still
exiting 0. Request nativestrict so it refuses instead, and report once, rather
than leaving every caller waiting forever for a lock that can never be granted.

Make the identity matcher and cursor's name check fork-free while here. Both
ran basename, grep and command substitutions per hop, which are free on Linux
but cost about 0.1s each under emulated process creation, and the walk runs at
every turn end. Verdicts are unchanged on every platform.

Tests pin the Windows branch from any host through documented seams, including
that the MSYS link is load-bearing and that the recycled-parent refusal is not
vacuous. The POSIX cases now pin their branch explicitly instead of inheriting
the host's.
@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The repeated Windows ownership checks need a fresh process snapshot before merge so a parked Cursor guard cannot act for a session whose harness has exited.

The new Windows table accurately supports one-shot ancestry decisions, but retaining it for the lifetime of a long-running turn-end guard makes later ownership and liveness decisions operate on obsolete process state.

Files Needing Attention: bin/fm-session-lock-lib.sh and bin/fm-turnend-guard-cursor.sh

Reviews (1): Last reviewed commit: "fix(bin): resolve the session lock's har..." | Re-trigger Greptile

Comment on lines +200 to +203
case "$FM_WIN_PROC_TABLE_STATE" in
ok) return 0 ;;
failed) return 1 ;;
esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Process snapshot stays stale

If a parked Cursor guard outlives its harness ancestor, FM_WIN_PROC_TABLE_STATE=ok prevents subsequent ownership checks from refreshing the Win32 process table, so the stale ancestry can still identify the departed harness as the lock owner and allow the guard to arm the watcher or emit a follow-up wake for a dead session.

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.

1 participant