Skip to content

fix(bin): record the harness-declared session pid as the session lock identity - #3122

Closed
tomglenn wants to merge 11 commits into
kunchenguid:mainfrom
tomglenn:fix/session-lock-declared-session-pid
Closed

tomglenn wants to merge 11 commits into
kunchenguid:mainfrom
tomglenn:fix/session-lock-declared-session-pid

Conversation

@tomglenn

Copy link
Copy Markdown

The failure

A background agent could write a session lock naming a process that was not its own, then permanently fail to recognise that lock as its own once that process exited. From then on it behaved as though another session held the home.

The cause is that the lock writer identified "this session" by walking to the outermost process in the contiguous harness ancestry. Two arrangements produce ancestries the process table cannot tell apart:

  • a session launched directly, where the outermost harness process is the session, and
  • a session hosted by a shared harness daemon, where the outermost harness process is the daemon and the session is below it.

The walk answers "outermost" in both cases. In the first that is right. In the second it records the shared daemon, which outlives no single session and eventually exits, stranding the agent below it.

The change

bin/fm-session-lock-lib.sh now prefers the harness's own declaration of which process is its session (CLAUDE_PID) over the ancestry walk, because the harness knows what the process table cannot infer.

That declaration is trusted only when both hold:

  • the declared pid is alive, and
  • it is a member of this session's contiguous harness ancestry.

An inherited value from an unrelated session, a stale pid, a live process outside this ancestry, and a malformed value are all inert: each falls through to the previous behaviour. tests/fm-session-lock-ancestry.test.sh covers each of those rejection paths with the accepted and rejected answers driven to deliberately different pids, so an assertion cannot pass for both branches.

The ancestry fallback is unchanged for every harness that declares nothing. For those, the walk stops at the first match, so outermost and innermost are the same process and the overreach never existed.

Measurement

Claude Code 2.1.241, a print-mode session and a real background agent, each launched with a deliberately wrong CLAUDE_PID:

  • both reported their own process, not the planted value;
  • without the declaration, the background agent would have recorded the shared process above it.

Recorded in docs/verification/supervision.md, refreshed by the opt-in guard tests/fm-session-lock-declaration-live-e2e.test.sh. That guard needs a real harness, so it is env-gated and self-skipping rather than part of the default suite.

What to look at

  • bin/fm-session-lock-lib.sh â�� the two guards that make the declaration safe to trust.
  • tests/fm-session-lock-ancestry.test.sh â�� the rejection-path coverage, and the three-level launcher/daemon/session shape that reproduces the original failure.
  • bin/fm-test-run.sh â�� the changed-path to family map keeps its over-selecting contract for the lock library.

A daemon-hosted background session sits several harness-named hops below the
session that launched it, with no non-harness process in between, so the whole
chain reads as one contiguous harness run. Resolving this session's identity as
the outermost pid of that run therefore records the LAUNCHING session's pid.
When the daemon above the session later exits, that recorded pid stops being an
ancestor, and because it still names a live harness the Stop-owned auto-arm
reads it as a competing session and stays inert for the rest of the session.

These cases build that real three-level process shape and drive the real
bin/fm-lock.sh, the real Stop auto-arm, and the real turn-end guard through it,
alongside the negative cases that must keep behaving as they do today: a live
launching session that genuinely owns the home, a demonstrably dead owner, away
mode, and an inherited session declaration.
Under a session-hosting daemon, two different arrangements produce an identical
process chain: an async hook or tool call run for session X inside a daemon-hosted
worker, and a background session launched BY X through that same daemon. No
process-table fact separates them, so resolving identity as the outermost pid of
the contiguous harness run records X in both - which is right for the worker and
wrong for the background session.

Prefer the harness's own declaration of which process is this session, accepted
only when it names a live harness process inside this contiguous run. Membership
is what makes an untrustworthy value harmless: a pid inherited from an unrelated
session is not in this ancestry and is ignored, and a dead one cannot be recorded
as a live owner. Everything else falls back to the outermost pid unchanged, which
is what every harness that declares nothing keeps using.

The self-ownership predicate is deliberately untouched. It already accepts the
recorded pid anywhere in the ancestry; the defect was that the pid it was given
only belonged to the session transiently.
The writer now depends on a vendor-controlled surface: if Claude Code stopped
setting CLAUDE_PID, or started letting an inherited value through, identity would
silently fall back to the launching session's pid and a background session's
supervision would go inert mid-session again. A stub agent cannot see that.

The guard launches a real session with a deliberately wrong CLAUDE_PID planted in
its environment and asserts the value reported inside that session's own hook is
its own live process instead, so it proves the declaration is authoritative rather
than merely present. It self-skips without the opt-in, refuses a pass that checked
nothing, and fails naming the harness and version.

Also records the dated result and re-selects the families a change to the identity
library must re-run.
A print-mode session runs its hook directly beneath itself, so it cannot show
what the writer would have recorded without the declaration. A real background
agent can: its hook sits two harness-named hops below the shared session-hosting
daemon, and the outermost pid of that chain is the daemon itself - a process every
background session in the home shares and which outlives any one of them.

The guard now exercises both shapes, refuses to pass if the background chain
turned out to be a single hop (which would prove nothing about the case the fix
exists for), reports the pid the old resolution would have recorded, and stops the
agent it started.
The live session-declaration guard printed the resolved `claude` executable
path alongside its version, so the measurement recorded in
docs/verification/supervision.md carried an absolute home directory.
The version is the identifying fact for that evidence, so the note now prints
only the version and the recorded block matches what a real run emits.
The guard still resolves and requires an executable `claude`, so an absent
harness continues to fail loudly instead of verifying nothing.
@greptile-apps

greptile-apps Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (2): Last reviewed commit: "no-mistakes(document): point declaration..." | Re-trigger Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Reviewed HEAD 7223eb894360849ae4850fee235ed929bcb9a152 vs main 9ce69acf95b901cb6dc38dd5fa55f37a8038e16c. Whole thread read (Greptile success). Full diff reviewed: bin/fm-session-lock-lib.sh, bin/fm-lock.sh, bin/fm-test-run.sh, docs/verification, and the ancestry + live-declaration tests. No .github/workflows/*. Not disguised security. tomglenn is not blocked.

Class: corrective. Under a session-hosting daemon the ancestry walk's outermost pid can record the launching session; this prefers harness-declared CLAUDE_PID only when numeric, live, and a member of this contiguous harness ancestry, otherwise the previous outermost fallback. Self-ownership predicate is untouched.

VISION (per rule, inspected evidence):

  • One captain, one interface — aligns. Stops a background session permanently misreading its own lock as a competitor (body + three-level fixture).
  • Authority is explicit — aligns. Declaration is inert unless live+in-ancestry; no new captain-facing capability.
  • Scripts own the mechanics — aligns. Deterministic identity resolution in fm_harness_ancestry_pid.
  • A restart is a non-event — aligns. Lock identity that outlives the wrong ancestor is the durability fix.
  • Delegation with a spine — aligns. Restores Stop auto-arm self-ownership so supervision can claim.
  • The fleet outlives any vendor — aligns. Uses Claude's declared session pid; harnesses that declare nothing keep the walk.
  • Scope — aligns. Session-lock identity library, not workshop.

Lock-identity class (classified, not a standing pair-hold): told apart from open #2929 (session-cohort / HERDR_PANE_ID ownership + suspended holder + turn-end fail-open), open #2964 (omp Claude identity), closed #3013 (case-insensitive basename), and ready-for-pr issue #1933 (Codex Desktop). This PR is the narrow declared-pid writer fix. Not landing only because attestation/CI gates are not met — not because of an untellable overlap hold.

First-time fork CI: after that full-diff review I approved workflow runs 32990820159 (CI) and 32990820110 (Require no-mistakes) for this HEAD.

Not merging. Waiting on the author: body has no no-mistakes-pipeline-attestation:v1 matching this HEAD (Require-no-mistakes failed). Re-run git push no-mistakes so the structured stamp lands. Not with the captain.

Merge-eligible: NO.

The declared-pid guard sources the session-lock library through a variable,
which ShellCheck cannot follow. Annotate that one site with the same
source=/dev/null directive the rest of the suite uses for non-constant
sources, so bin/fm-lint.sh is clean again.
@devin-ai-integration

Copy link
Copy Markdown

Closed as superseded — this work already landed on main via #4894.

— Kun's Firstmate

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.

2 participants