Skip to content

Session lock identifies its holder by harness-command-name ancestry — unreliable in a multi-harness process tree #219

Description

@OCPdev25

Summary

The per-home session lock (bin/fm-lock.sh) identifies the lock-holding process by walking ps ancestry and matching a fixed harness command-name regex. Because the test is "does a process in my ancestry (or the recorded pid) look like a harness," and not "is this the specific process that acquired the fleet lock," any process descended from — or sharing a command name with — one of the known harnesses is indistinguishable from the true fleet session. In a multi-harness process tree (e.g. a codex- or opencode-harnessed crewmate running alongside the main claude session), holder identification can latch onto the wrong harness, and the liveness check that gates the read-only-second-session safety cannot reliably tell the real owner from an unrelated harness-descended process.

Root cause

bin/fm-lock.sh recognizes harnesses purely by command name, via one regex:

# bin/fm-lock.sh:17-18
# Known harness command names; extend when a new adapter is verified.
HARNESS_RE='claude|codex|opencode|grok|^pi$'

Holder discovery walks up to 8 ancestors from the current shell and returns the first pid whose command basename (or, for a bare node/python interpreter, whose args) matches that regex:

# bin/fm-lock.sh:20-36
harness_pid() {
  local pid=$$ comm args
  for _ in 1 2 3 4 5 6 7 8; do
    comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1
    args=$(ps -o args= -p "$pid" 2>/dev/null)
    if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then
      echo "$pid"; return 0
    fi
    # Bare interpreter (e.g. node): match the harness name in its script path.
    case "$comm" in
      *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;;
    esac
    pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
    [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1
  done
  return 1
}

Liveness of the recorded holder is decided by the same "looks like a harness" test — a pid that is alive and whose comm/args match the regex:

# bin/fm-lock.sh:38-43
holder_alive() {  # true if $1 is a live process that looks like a harness
  local pid=$1 comm
  kill -0 "$pid" 2>/dev/null || return 1
  comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1
  printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE"
}

Acquisition then refuses only when the recorded pid differs from the caller's discovered harness_pid and that recorded pid is holder_alive:

# bin/fm-lock.sh:52-61
me=$(harness_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; }
if [ -f "$LOCK" ]; then
  old=$(cat "$LOCK")
  if [ "$old" != "$me" ] && holder_alive "$old"; then
    echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2
    exit 1
  fi
fi
echo "$me" > "$LOCK"

The heuristic never stores or checks any per-session identity token. Both the discovery walk and the liveness test answer a category question ("is this a harness?"), not an identity question ("is this the fleet session that owns this home?"). Two independent failure surfaces follow:

  1. Ambiguous discovery. harness_pid returns the first harness-named ancestor. In a nested or mixed process tree — a tool subshell whose ancestry passes through a codex- or opencode-harnessed crewmate before (or instead of) reaching the true fleet-session harness — the discovered me can be a harness that is not the fleet session. The lock file is then written with, or compared against, the wrong pid.

  2. Non-identifying liveness. holder_alive(old) returns true for any live process whose comm/args match HARNESS_RE. It cannot confirm that old is still the same session that wrote the lock. A recorded pid that has died and been reused by the OS for any harness-named process would read as "held by a live harness," and, conversely, the real owner is only ever confirmed as "some harness," never as "this home's session."

Concrete example (tonight's real process mix — realistic failure surface, not an observed incident)

To be clear up front: no lock theft or mis-acquisition actually occurred tonight — the lock correctly holds the fleet session. This is the real process mix that shows why the identification cannot be relied on, not a report of a failure event.

Tonight (2026-07-03) this firstmate home's lock file state/.lock records a single pid:

$ cat state/.lock
87247
$ ps -o pid=,comm=,args= -p 87247
87247 claude  claude --model claude-fable-5 --dangerously-skip-permissions

At the same moment, the process table held several other processes that all match HARNESS_RE:

$ ps -eo pid,comm,args | grep -E 'claude|codex|opencode|grok'
 2992  claude    claude
69043  claude    claude --resume 6ded525a-...
79122  claude    claude
26825  opencode  opencode
...     (Codex.app app-server subtree, comm/args containing "codex")

In addition, this home dispatched a codex-harnessed crewmate earlier tonight (task herdr-claude-state-m7, since merged), i.e. a live process subtree whose command name matches codex running concurrently with the main claude fleet session.

So processes descended from / named after both claude and codex/opencode were live simultaneously, and the ancestry regex matches every one of them. holder_alive(87247) returns true — but it would also return true for pid 2992, 69043, 79122, or 26825, none of which is the fleet session. Nothing in fm-lock.sh distinguishes the true owner (87247) from these unrelated harness-descended processes beyond "its command name matches the regex," which they all do. If a second session's harness_pid walk had latched onto a different harness ancestor, or if pid 87247 had exited and its number been reused by any harness-named process, the holder-identification and liveness logic would give a confidently wrong answer.

Impact

The read-only-second-session guard — a core safety property (a second live session must detect the first and operate read-only so two sessions never mutate one home's fleet state) — rests entirely on this identification being trustworthy. Because the check verifies harness category rather than session identity:

  • A second session can mis-identify who holds the lock in a multi-harness tree, either false-refusing (treating an unrelated live harness as the owner) or, in the pid-reuse / wrong-ancestor case, failing to recognize a genuinely live owner.
  • The guarantee that "at most one session mutates this home" degrades from a reliable invariant to a best-effort heuristic whose correctness depends on the ambient process mix.

This is a correctness/safety issue in the lock's identity model, independent of any single reproduction, and it grows more likely as multi-harness fleets (codex/opencode/grok crewmates alongside a claude primary) become common.

Suggested direction

Identify the holder by a recorded session-identity token rather than by harness-command-name ancestry:

  • Have the fleet session mint a token at session start (e.g. the harness pid plus its process start time, or a random session UUID written once) and store it in the lock file. Compare identity against that token on acquire and on status, so "same session" is an exact match, not a category match.
  • Pairing pid with start-time (available from ps -o lstart=/ps -o etimes= or /proc/<pid>/stat starttime) closes the pid-reuse hole: a reused pid will have a different start time and fail the identity check.
  • Keep the harness regex, if useful at all, only as a soft sanity hint — not as the thing that decides ownership or liveness.

This preserves the current UX (one lock file per home, status still prints holder + liveness) while making holder identification an identity question the multi-harness case can answer correctly.


Line references are to bin/fm-lock.sh at commit 66f12e9 (main as of filing): https://github.com/kunchenguid/firstmate/blob/66f12e9/bin/fm-lock.sh

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions