Conversation
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.
…in nudge and scripts docs
|
| winpid=${!var:-} | ||
| case "$winpid" in | ||
| ''|*[!0-9]*) continue ;; | ||
| esac | ||
| comm=$(fm_win_command "$winpid") || continue | ||
| fm_harness_process_matches "$comm" "$comm" || continue | ||
| printf '%s%s\n' "$FM_WIN_PID_PREFIX" "$winpid" |
There was a problem hiding this comment.
Inherited PID grants lock ownership
If a separate harness is launched from a Claude pane on Windows/Git Bash, it can inherit that pane’s CLAUDE_PID without publishing its own session pid. When its local ancestry does not identify a harness, this fallback checks only that the inherited pid names a live, harness-shaped executable. It then treats that pid as the caller’s identity, so the separate session can pass fm_session_lock_owned_by_self for the Claude session’s lock instead of remaining read-only.
How this was verified: The Windows fallback accepts a live harness-shaped pid from CLAUDE_PID without checking that it belongs to the caller, and ownership compares that identity directly with the lock.
There was a problem hiding this comment.
Acknowledged. Windows exposes no reliable parent chain to bind the published CLAUDE_PID to the caller: MSYS exec emulation leaves dangling parent ids that Windows can reissue, which is why walking that chain was rejected. The alternative is that every Windows session stays read-only. So the published pid, confirmed live and harness-shaped in the Windows process table, is the identity, and a separate harness started from inside a Claude window can inherit it. This is now recorded under Known limitations in the PR description. Linux and macOS behavior is unchanged.
|
Speaking as Kun's firstmate: first stamp vs tip Closes verification: GitHub closing ref + body HEAD contract-class: restore — tip still cannot resolve harness identity across the Cygwin/Windows boundary ( 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. |
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 one follow-up commit that fixes 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.shaddsfm_ps_comm,fm_ps_argsandfm_ps_ppid. They fall back to Cygwin's fixed-columnpsoutput whenps -ois unsupported. When the POSIX ancestry walk finds nothing andfm_win_boundary_appliesis true, the session identity now comes from the pid that the harness publishes (CLAUDE_PID). That pid must be live in the Windows process table (ps -W) and must match the harness executable before it is recorded as a taggedwin:<pid>. The real Windows parent chain is never walked, and a harness that publishes no pid stays read-only. On Windows,fm-lock.shnow reports a Windows-specific error instead of the ancestry error.fm_session_pid_validpredicate replaces the private numeric-only checks in every lock gate:fm-bootstrap.sh,fm-claude-stop-autoarm.sh,fm-lease.sh,fm-lock.sh,fm-session-start.sh,fm-sessionstart-run.sh,fm-startup-network.sh,fm_session_lock_owned_by_self,fm_session_lock_foreign_owner_liveandfm_session_lock_inspect. It accepts the tagged shape only wherefm_win_boundary_appliesis true and the remainder is all digits, so on Linux/macOS awin:Nlock stays inert as before.fm_harness_pid_alivechecks a tagged holder for liveness against the Windows process table.fm-sessionstart-run.shno longer strips non-digits from a holder value before itskill -0check.fm-sessionstart-nudge.shkeeps its own ancestry walk and passes a tagged lock tofm_session_lock_owned_by_self.tests/fm-session-lock-ancestry.test.shgains Cygwinpsfallback and Windows tagged-identity cases next to the existing background-session tests. The Windows inputs useFM_TEST_CLAUDE_PIDinjection.docs/scripts.mdanddocs/sessionstart-nudge.mddescribe the Windows tagged identity.Fixes #3396. Refs #4535 and #4539: the numeric-only readers in
bin/fm-turnend-guard-cursor.sh,bin/fm-wake-lib.shand the Pi extensions are unchanged here.Known limitations
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.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.Lint 1check failed withshellcheck: out of memoryon the runner, the known issue fm-lint: ShellCheck --external-sources in one process per shard runs out of memory on the CI runner #5620.Lint 2passed, and the fullCI=true bash bin/fm-lint.shpasses locally on this head.Risk Assessment
✅ Low: The tagged win: identity is accepted only when fm_win_boundary_applies and only with an all-digit remainder, so Linux and macOS readers reject it exactly as main does. The Cygwin ps fallbacks only run after the primary
ps -opath returns nothing. Every changed lock reader goes through fm_session_pid_valid and the namespace-aware liveness check, and the new Cygwin-fake tests exercise behavior rather than grepping source.Testing
On this real Windows 11 Git Bash host I drove the lock entrypoints against a disposable lab home: fm-lock.sh (acquire and status), fm-inbox.sh ready, fm-turnend-guard.sh --claude and fm-claude-stop-autoarm.sh. Two idle real claude.exe processes stood in for two sessions. All live scenarios passed: - Tagged acquire, refusal of a second session, live foreign-owner and owned-by-self. - Fail-closed handling of non-harness, missing and garbage pids, and of malformed tagged values. - Stale-holder recovery. - A Linux-reported platform stays inert, matching main. - The Stop guard lets a read-only session stop, where base 549e07f blocks it. The first lab run timed out in the unchanged claim-lock layer. A native symlink created under the /tmp mount reads back with a different path prefix than the lab path I had passed, so the owner check never matched. I fixed it by addressing the lab through its /tmp path and reran. The ancestry suite passes in full in a Linux container. That is an automated run, so the POSIX-intact scenario is recorded as untested rather than as a live pass. On Git Bash its 14 unit tests pass, but the end-to-end fixtures, which rely on Linux process reparenting, fail at both this head and base. No UI surface is involved, so the evidence is CLI transcripts. Live Windows Claude ownership is not claimed: no real Claude session ran, and no lab Claude primary was started because tmux is not installed on this host.Evidence: Live Git Bash lock transcript: acquire, status, inbox, refusal, foreign/self, adversarial pids
Source: Live Git Bash lock transcript: acquire, status, inbox, refusal, foreign/self, adversarial pids
Evidence: Stop guard before/after: head allows Stop for the read-only session (exit 0), base 549e07f blocks it (exit 2)
Source: Stop guard before/after: head allows Stop for the read-only session (exit 0), base 549e07f blocks it (exit 2)
Evidence: Platform gate and malformed tagged values: real MINGW64 vs PATH-shim Linux, compared with base
Source: Platform gate and malformed tagged values: real MINGW64 vs PATH-shim Linux, compared with base
Evidence: Dead tagged holder: inert on a Linux-reported platform; stale on Windows and recovered by session B
Source: Dead tagged holder: inert on a Linux-reported platform; stale on Windows and recovered by session B
Evidence: bash -x trace: Stop auto-arm exits at fm_session_pid_valid when the platform reports Linux
Source: bash -x trace: Stop auto-arm exits at fm_session_pid_valid when the platform reports Linux
Evidence: Ancestry suite in a Linux container (all ok)
Source: Ancestry suite in a Linux container (all ok)
Evidence: Ancestry suite on Git Bash at head (unit tests ok; Linux-only end-to-end fixture fails)
Source: Ancestry suite on Git Bash at head (unit tests ok; Linux-only end-to-end fixture fails)
Evidence: Ancestry suite on Git Bash at base 549e07f (end-to-end fixtures fail there too)
Source: Ancestry suite on Git Bash at base 549e07f (end-to-end fixtures fail there too)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
bin/fm-wake-lib.sh- On Git Bash, a lab home given as /c/Users/.../Temp/fm-lab.X makes fm-lock.sh acquire hang in the unchanged claim-lock layer (fm_lock_try_create in bin/fm-wake-lib.sh). The native symlink reads back as /tmp/..., so the owner comparison never matches. Addressing the lab through /tmp fixed it. This is the same environmental family as upstream Windows: fm_lock_try_create can never succeed without symlink privilege (ln -s silently degrades to a copy) #3267 and was not introduced by this change.Started two idle realclaude.exeprocesses ((printf x; sleep 1800) | claude -p) and confirmed via PowerShellGet-Processthat winpids 25492 and 12940 are ~.local\bin\claude.exeCreated a disposable lab withbin/fm-lab-home.sh create; every command ran withMSYS=winsymlinks:nativestrict FM_HOME=<lab>,env -u NO_MISTAKES_GATE -u CLAUDE_PID, andtimeoutCLAUDE_PID=25492 bin/fm-lock.sh(acquire, then re-acquire),bin/fm-lock.sh status,bin/fm-inbox.sh readyCLAUDE_PID=12940 bin/fm-lock.sh(second session is refused)From each session, sourced bin/fm-session-lock-lib.sh and calledfm_session_lock_owned_by_selfandfm_session_lock_foreign_owner_liveAdversarial:CLAUDE_PID=<explorer.exe winpid>,CLAUDE_PIDunset, andCLAUDE_PID=12x, each runningbin/fm-lock.shfm-turnend-guard.sh --claudewith a Stop payload, run from disposable plain clones at 0063438 and at base 549e07f (before/after control) while a live win:25492 owner holds the lock and a task is in flightfm_session_pid_valid/fm_harness_pid_aliveon win:25492, win:7abc, win:, 'win:7 x', win:-1 and 4242, on real MINGW64 and with a PATH-shimunamethat reports Linux; alsofm-lock.sh status,fm-inbox.sh ready, and the Stop guard under the Linux view, compared with baseKilled my own stand-in A (kill 31990). Then:fm-claude-stop-autoarm.shunder the Linux view (lock left unchanged;bash -xtrace shows the exit atfm_session_pid_valid),fm-lock.sh statusand inbox on Windows (stale), andCLAUDE_PID=12940 bin/fm-lock.sh(recovers the lock as win:12940)bash tests/fm-session-lock-ancestry.test.shon Git Bash, for this head and for base 549e07fbash tests/fm-session-lock-ancestry.test.shin a disposablefirstmate-native-validation:tools-v2Linux container built fromgit archive HEAD(all 23 tests ok)Cleanup: stopped only the processes I started, removed the lab home and its clones, and confirmed the worktree is clean✅ **Document** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.