Selaa lähdekoodia

merge: lane/fleet-self-owner-gate

0xDarkMatter 1 kuukausi sitten
vanhempi
sitoutus
c706382af0

+ 16 - 2
skills/fleet-ops/SKILL.md

@@ -144,9 +144,23 @@ The join is `writtenBranches` from the session wrapper, not just the checked-out
 branch — a session working in worktree `claude/foo-bar` routinely commits its real work
 to `lane/thing`, and only `writtenBranches` connects the two.
 
+**Self-ownership is exempt.** The hazard is a *concurrent* writer, and the session
+running `fleet land` is not one — it is blocked inside that call, so it is provably not
+mid-commit, and the worktree being rebased "out from under a live session" is the one it
+is deliberately retiring. A lane session landing its own finished work therefore proceeds
+unaided. Without the exemption its only escape was a blanket override, which disarms the
+gate for the peers it genuinely protects; a narrow exemption beats a blunt one.
+
+It stays conservative in both directions. Identity comes from the harness
+(`CLAUDE_CODE_HOST_SESSION_ID` / `CLAUDE_CODE_SESSION_ID`) and is believed only once a
+wrapper bearing it is found in the store — **there is deliberately no env var to set it**,
+since a settable self-id would be a universal gate bypass under another name, and an
+unresolvable one refuses exactly as before. Self must also be the **only** live owner:
+a second live session writing the same branch refuses, naming the peer.
+
 Override with `session_check=off` in config, or `FLEET_SKIP_SESSION_CHECK=1` for one
-run. `fleet config` states plainly whether the gate is armed — the same observability
-lesson as `test_cmd`.
+run. `fleet config` states plainly whether the gate is armed *and* whether self-identity
+resolved — the same observability lesson as `test_cmd`.
 
 ### Where each channel works (verified 2026-08-03)
 

+ 66 - 0
skills/fleet-ops/scripts/fleet.sh

@@ -762,6 +762,14 @@ cmd_config() {
   if session_enabled; then
     if [[ -n "$(main_session_row)" ]]; then
       echo "# session awareness: ON (store readable)" >&2
+      # Whether THIS session can be recognised decides if it can land its own
+      # lane unaided; unresolvable self is a silent fallback to refusing, so
+      # state it rather than letting it look like a gate misfire.
+      if [[ -n "$(bash "$SESSIONS_SH" self 2>/dev/null)" ]]; then
+        echo "#   self-identity: resolved — this session can land lanes it owns" >&2
+      else
+        echo "#   self-identity: UNRESOLVED — landing a lane this session owns will refuse" >&2
+      fi
     else
       echo "# session awareness: ON but no sessions resolved — store missing, jq missing, or terminal-only host" >&2
     fi
@@ -881,9 +889,52 @@ owner_annotation() {
   fi
 }
 
+# Is session id $1 the session running THIS script? Empty/unresolvable self is
+# always false — an unknown identity must never satisfy an exemption.
+SELF_SESSION_ID=""
+SELF_SESSION_LOADED=0
+session_is_self() {
+  session_enabled || return 1
+  if [[ $SELF_SESSION_LOADED -eq 0 ]]; then
+    SELF_SESSION_LOADED=1
+    SELF_SESSION_ID=$(bash "$SESSIONS_SH" self 2>/dev/null) || SELF_SESSION_ID=""
+  fi
+  [[ -n "$SELF_SESSION_ID" && "$1" == "$SELF_SESSION_ID" ]]
+}
+
+# Every OTHER live session that also owns branch $1 (excluding session $2), as
+# "id<TAB>title" rows. Liveness is re-read per candidate rather than taken from
+# the cached index — same standard as `owner --fresh`, because this decides a
+# refusal, and the index cache has a 15-minute TTL.
+peer_live_owners() {
+  local branch=$1 self=$2 row id
+  load_session_index
+  [[ -z "$SESSION_INDEX_CACHE" ]] && return 0
+  while IFS= read -r row; do
+    [[ -z "$row" ]] && continue
+    id=$(sfield "$row" 2)
+    [[ "$id" == "$self" ]] && continue
+    [[ "$(bash "$SESSIONS_SH" live "$id" 2>/dev/null)" == "1" ]] || continue
+    printf '%s\t%s\n' "$id" "$(sfield "$row" 3)"
+  done < <(printf '%s\n' "$SESSION_INDEX_CACHE" | awk -F'\t' -v w="$branch" '$1 == w')
+  return 0
+}
+
 # The gate itself. Refuses to land a lane whose owning session is still live —
 # landing under a session that is mid-turn means merging a branch it may still
 # be committing to, and then rebasing its worktree out from under it.
+#
+# SELF-OWNERSHIP IS EXEMPT, and the reason is the whole design: that hazard is
+# about a CONCURRENT writer. A session landing its own lane is not one — it is
+# blocked inside this very call, so it is provably not mid-commit, and
+# "rebasing its worktree out from under it" describes the tree it is
+# deliberately retiring. Before this exemption, a lane session that finished
+# its work could only land it with a blanket override, which disarms the gate
+# for the peers it genuinely protects. A narrow exemption beats a blunt one.
+#
+# It stays conservative in both directions: unresolvable self never matches,
+# and self must be the ONLY live owner. A second live session writing the same
+# branch is the real hazard, and refuses exactly as before.
 # Returns 0 = safe to land, 1 = refuse.
 session_land_gate() {
   local branch=$1
@@ -894,6 +945,21 @@ session_land_gate() {
   [[ "$live" != "1" ]] && return 0       # idle → allow
   local title; title=$(sfield "$row" 3)
   local id;    id=$(sfield "$row" 2)
+  if session_is_self "$id"; then
+    local peers pid ptitle
+    peers=$(peer_live_owners "$branch" "$id")
+    if [[ -z "$peers" ]]; then
+      log "landing own lane: $branch is owned by THIS session ($id) — not a concurrent writer"
+      return 0
+    fi
+    # Self plus someone else: the someone else is the hazard, so say who.
+    log "REFUSE LAND: $branch is owned by this session AND another LIVE session:"
+    while IFS=$'\t' read -r pid ptitle; do
+      [[ -n "$pid" ]] && log "    '$ptitle' ($pid)"
+    done <<< "$peers"
+    log "  a peer may still be committing to it — coordinate before landing."
+    return 1
+  fi
   log "REFUSE LAND: $branch is owned by a LIVE session — '$title' ($id)"
   log "  that session was active within ${SESSION_LIVE_SECS}s and may still be committing."
   log "  wait for it to finish, or override with: session_check=off (or FLEET_SKIP_SESSION_CHECK=1)"

+ 44 - 0
skills/fleet-ops/scripts/sessions.sh

@@ -41,6 +41,9 @@ USAGE
                                   any gate that must not act on stale data.
   $SELF main                      The MAIN/coordinator session for this repo
   $SELF live <sessionId>          1 if that session is live, else 0
+  $SELF self                      The CALLING session's own store id, if it can
+                                  be resolved and verified against the store.
+                                  Exit 3 (silent) when it cannot.
   $SELF --help
 
 OUTPUT (TSV columns)
@@ -207,6 +210,46 @@ session_live_now() {
     if (( la > 0 && (now_ms - la) <= LIVE_SECS * 1000 )); then printf '1'; else printf '0'; fi
 }
 
+# --- self --------------------------------------------------------------------
+# Which session is CALLING this script.
+#
+# WHY THIS EXISTS: the live-owner gate protects against landing a lane while a
+# session is still committing to it. When the session running `fleet land` is
+# itself that owner, the hazard is absent — it is blocked inside the land call
+# and cannot be mid-commit — but the gate could not tell the two apart, so a
+# lane session landing its own work always tripped it. Self-identity is what
+# separates "a PEER is writing" (refuse) from "I am the writer" (proceed).
+#
+# DELIBERATELY NOT OVERRIDABLE. There is no FLEET_SELF_SESSION_ID or equivalent:
+# a settable self-id would be a universal gate bypass wearing a different name
+# (export it to the owner's id and every refusal disappears). The id comes from
+# the harness, and is only believed once a wrapper file bearing it is found in
+# the store — so an unset, stale, or invented value resolves to nothing and the
+# gate keeps its full strength. Unresolvable self is the SAFE direction.
+self_session_id() {
+    local sd; sd=$(store_dir) || return 3
+    # EVERY candidate is tried, not just the first one that is set. Inside
+    # Desktop both CLAUDE_CODE_SESSION_ID and CLAUDE_CODE_HOST_SESSION_ID are
+    # populated with DIFFERENT ids — the former is the CLI session, the latter
+    # the host session the store is keyed by — so a first-set-wins chain
+    # resolves nothing on exactly the surface this matters most on.
+    local raw cand f
+    for raw in "${CLAUDE_CODE_HOST_SESSION_ID:-}" "${CLAUDE_CODE_SESSION_ID:-}" \
+               "${CLAUDE_SESSION_ID:-}"; do
+        [[ -n "$raw" ]] || continue
+        # The harness may hand us the bare uuid or the store's `local_<uuid>`
+        # form; the filename is always the latter. Try as-given first so a
+        # future id shape that isn't uuid-based still resolves.
+        for cand in "$raw" "local_$raw"; do
+            f=$(find "$sd" -name "${cand}.json" -type f 2>/dev/null | head -n1)
+            [[ -n "$f" ]] || continue
+            basename "$f" .json
+            return 0
+        done
+    done
+    return 3
+}
+
 # --- owner -------------------------------------------------------------------
 # Newest activity wins; a non-archived session outranks an archived one, since a
 # branch reused after its original session was archived belongs to the new one.
@@ -283,5 +326,6 @@ case "${1:---help}" in
     main)           cmd_main; exit $? ;;
     live)           shift; [[ -z "${1:-}" ]] && { echo "usage: $SELF live <sessionId>" >&2; exit 2; }
                     session_live_now "$1"; echo; exit 0 ;;
+    self)           self_session_id || exit 3; exit 0 ;;
     *)              echo "$SELF: unknown command '$1'" >&2; usage >&2; exit 2 ;;
 esac

+ 45 - 0
skills/fleet-ops/tests/run.sh

@@ -429,6 +429,51 @@ case "$(git -C "$SREPO" log --oneline main)" in
 FLEET_SKIP_SESSION_CHECK=1 bash "$FLEET" land hot-lane >/dev/null 2>&1
 ee "override lands despite live owner" 0 $?
 
+# -- self-ownership exemption --------------------------------------------------
+# The gate protects against a CONCURRENT writer, and the session doing the
+# landing is not one. It must therefore land its own lane WITHOUT an override,
+# or every lane session's only escape is disarming the gate wholesale — which
+# also disarms it for the peers it genuinely protects.
+mk_lane_in "$SREPO" self-lane s.txt
+mk_session local_selfy "This very session" "$SREPO/wt3" 5 claude/selfy self-lane
+bash "$FLEET" track self-lane >/dev/null 2>&1
+
+# Self unresolvable → no exemption. The fail-safe direction: an unknown
+# identity must never satisfy an exemption.
+( unset CLAUDE_CODE_HOST_SESSION_ID CLAUDE_CODE_SESSION_ID CLAUDE_SESSION_ID
+  bash "$FLEET" land self-lane >/dev/null 2>&1 ); lx=$?
+[ "$lx" -ne 0 ] && ok "unresolvable self does not exempt (exit $lx)" || no "unresolved self landed anyway"
+
+# `sessions.sh self` believes an id only when the store has a wrapper for it.
+sid="$(CLAUDE_CODE_HOST_SESSION_ID=local_selfy bash "$SESSIONS" self 2>/dev/null)"
+[ "$sid" = "local_selfy" ] && ok "self resolves from local_<id> form" || no "self did not resolve ($sid)"
+sid="$(CLAUDE_CODE_HOST_SESSION_ID=selfy bash "$SESSIONS" self 2>/dev/null)"
+[ "$sid" = "local_selfy" ] && ok "self resolves from bare id form" || no "bare id did not resolve ($sid)"
+sid="$(CLAUDE_CODE_HOST_SESSION_ID=local_nosuch bash "$SESSIONS" self 2>/dev/null)"; sx=$?
+[ -z "$sid" ] && [ "$sx" -eq 3 ] && ok "unknown id resolves to nothing" || no "unknown id was believed ($sid)"
+# Both vars set to DIFFERENT ids — the Desktop shape exactly. The chain must
+# try each candidate, not stop at the first one that happens to be set.
+sid="$(CLAUDE_CODE_SESSION_ID=cli-only CLAUDE_CODE_HOST_SESSION_ID=local_selfy \
+  bash "$SESSIONS" self 2>/dev/null)"
+[ "$sid" = "local_selfy" ] && ok "resolves when a non-matching id is also set" || no "first-set-wins regression ($sid)"
+
+# The exemption itself: owner live, owner is self, no peers → lands clean.
+CLAUDE_CODE_HOST_SESSION_ID=local_selfy bash "$FLEET" land self-lane >/dev/null 2>&1
+ee "self-owned live lane lands without override" 0 $?
+case "$(git -C "$SREPO" log --oneline main)" in
+  *"merge: self-lane"*) ok "self-owned lane actually merged";; *) no "no merge commit for self-owned lane";; esac
+
+# A LIVE PEER on the same branch is the real hazard — refuses even though self
+# also owns it. This is the line between a narrow exemption and a blunt one.
+mk_lane_in "$SREPO" shared-lane sh.txt
+mk_session local_selfy2 "This very session" "$SREPO/wt4" 5 claude/selfy2 shared-lane
+mk_session local_peer   "A peer session"    "$SREPO/wt5" 5 claude/peer   shared-lane
+bash "$FLEET" track shared-lane >/dev/null 2>&1
+CLAUDE_CODE_HOST_SESSION_ID=local_selfy2 bash "$FLEET" land shared-lane >/dev/null 2>&1; lx=$?
+[ "$lx" -ne 0 ] && ok "live PEER still blocks a self-owned lane (exit $lx)" || no "peer-owned lane landed"
+case "$(git -C "$SREPO" log --oneline main)" in
+  *"merge: shared-lane"*) no "lane with live peer was merged";; *) ok "no merge while a peer is live";; esac
+
 # A lane whose owner went idle (2h ago) lands normally.
 mk_lane_in "$SREPO" cold-lane c2.txt
 mk_session local_cold "Cold session" "$SREPO/wt2" 7200 claude/cold cold-lane