diff --git a/.agents/skills/operational-home-layout/SKILL.md b/.agents/skills/operational-home-layout/SKILL.md index 635239c6649..ef62e6194aa 100644 --- a/.agents/skills/operational-home-layout/SKILL.md +++ b/.agents/skills/operational-home-layout/SKILL.md @@ -88,7 +88,7 @@ state/ runtime records and signals; gitignored branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready .branch-outcomes-tail.jsonl Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches, their recovery marker, and a bounded display copy of the newest rows; bin/fm-branch-outcome.sh owns the formats branch-session/ .branch-session .branch-mirror-cursor the branch's per-main-session conversations, the pointer to the current one, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md) .branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract - .supervision-host* supervision host process record, engine conversation, current turn scope and report receipts, and bounded ledger of every close and engine turn; bin/fm-supervision-host.sh owns them; never touch + .supervision-host* supervision host process record, engine conversation, every engine session id it has launched (read by bin/fm-captain-message-sweep.py to skip those transcripts), current turn scope and report receipts, and bounded ledger of every close and engine turn; bin/fm-supervision-host.sh owns them; never touch .lease- per-task supervision lease naming which actor (main or branch) may change that task; bin/fm-lease-lib.sh owns the contract the guarded scripts enforce x-watch.check.sh generated Relay poll shim; present only when opted in (section 14) tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll diff --git a/bin/fm-captain-message-sweep.py b/bin/fm-captain-message-sweep.py index bf9986c001a..fcf7a62f82b 100755 --- a/bin/fm-captain-message-sweep.py +++ b/bin/fm-captain-message-sweep.py @@ -17,6 +17,8 @@ # and swept by the directory runs too, and until one has, the capture record # says the directory is only inferred so the page never shows a green band over # a list it cannot vouch for. +# A supervision-host engine conversation can land in the same directory and is +# never swept (supervision_engine_sessions). # # WHO RUNS IT. Two callers, each enough for what it can see: # - bin/fm-captain-message-hook.sh, a Claude Stop hook, right as a turn ends, @@ -168,6 +170,37 @@ def utc_now(): return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +def supervision_engine_sessions(home): + """The session ids of every supervision-branch engine conversation this + home has run (bin/fm-supervision-host.sh): the ones + state/.supervision-host-engine-sessions lists, written before each + conversation's first turn starts and never removed, plus the one + state/.supervision-host-engine records. The engine's working notes (stale + wake, seq numbers, MAIN, pane ids) are never his chat, so a transcript + named for one of these sessions is never read, identified by + session identity and never by what its notes say.""" + state = os.path.join(home, "state") + sessions = set() + try: + with open(os.path.join(state, ".supervision-host-engine-sessions"), + encoding="utf-8") as fh: + sessions.update(fh.read().split()) + except (OSError, UnicodeDecodeError): + pass + try: + with open(os.path.join(state, ".supervision-host-engine"), + encoding="utf-8") as fh: + sessions.update(re.findall(r"(?m)^session=(\S+)$", fh.read())) + except (OSError, UnicodeDecodeError): + pass + return sessions + + +def is_engine_transcript(path, sessions): + name = os.path.basename(path) + return name.endswith(".jsonl") and name[:-len(".jsonl")] in sessions + + def default_transcript_dir(home): base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") encoded = re.sub(r"[^A-Za-z0-9]", "-", os.path.abspath(home)) @@ -737,6 +770,8 @@ def sweep(home, since, paths=None, directory=None): and os.path.isfile(os.path.join(directory, f))), key=os.path.getmtime) targets += [p for p in named if p not in targets] + engine_sessions = supervision_engine_sessions(home) + targets = [p for p in targets if not is_engine_transcript(p, engine_sessions)] targets = [p for p in targets if os.path.isfile(p)] if not targets: return {"active": False, "dir": directory, "transcripts": 0, "new": 0, diff --git a/bin/fm-supervision-host.sh b/bin/fm-supervision-host.sh index 26555b69633..c2b0a23f624 100755 --- a/bin/fm-supervision-host.sh +++ b/bin/fm-supervision-host.sh @@ -232,6 +232,7 @@ unset FM_WATCH_PREDECESSOR_ARM_PID FM_SUPERVISION_ACTOR FM_BRANCH_REPORT_TURN HOST_RECORD="$STATE/.supervision-host" ENGINE_RECORD="$STATE/.supervision-host-engine" +ENGINE_SESSIONS="$STATE/.supervision-host-engine-sessions" TURN_FILE="$STATE/.supervision-host-turn" RECEIPTS="$STATE/.supervision-host-receipts" PROMPT_FILE="$STATE/.supervision-host-prompt" @@ -948,6 +949,8 @@ handle_wake() { # errors=$(mktemp "$STATE/.supervision-host-errors.XXXXXX") || errors=/dev/null TURN_RESULT=$result TURN_ERRORS=$errors + grep -qxF -- "$ENGINE_SESSION" "$ENGINE_SESSIONS" 2>/dev/null \ + || printf '%s\n' "$ENGINE_SESSION" >> "$ENGINE_SESSIONS" ENGINE_RUNNING=1 # Backgrounded and waited, so a signal to the host is handled at once # instead of after the whole turn; the cleanup stops the engine. diff --git a/docs/command-center.md b/docs/command-center.md index dcc81549406..471ca0a0e9f 100644 --- a/docs/command-center.md +++ b/docs/command-center.md @@ -47,6 +47,7 @@ The reply box says which of these it is before you send. Your replies appear under the message, so the exchange reads as a conversation. These are captured automatically: `bin/fm-captain-message-sweep.py` reads the Claude conversation record on disk, which holds every message verbatim, and records every reply firstmate gave - no agent chooses or remembers to record anything. Only what firstmate said to you counts: its working narration between tool calls, a subagent's chatter, and the lines the harness wrote itself are not replies and never become messages. +Neither does anything the [supervision host](supervision-host.md)'s background engine writes: its conversations can sit in the same directory as firstmate's, and the sweep skips every one of them by session id. It runs from two places, and they know different things. The Claude Stop hook (`bin/fm-captain-message-hook.sh`) fires as each turn ends and hands over the hook payload, which NAMES the transcript that session writes; the sweep reads that one file and nothing else, so the hook is bounded to a few seconds and never holds a turn end. This server sweeps on its own poll cadence, over the directory it derives from the home's path plus every transcript a payload has named - that is what catches turns that ended unusually (interrupted, errored, killed) once their session moves on, and what backfills at startup. Until some session has named its transcript, the only thing capture has is that derived directory, which is a guess - a session started from somewhere else writes where nothing is looking - so the Messages list says so rather than showing a green band over a list it cannot vouch for. The messages already in the log are the dedupe record, so the two runners can never record the same message twice. diff --git a/docs/supervision-host.md b/docs/supervision-host.md index 1f693b69935..45f45ee68ad 100644 --- a/docs/supervision-host.md +++ b/docs/supervision-host.md @@ -371,6 +371,8 @@ Today the only verified engine is Claude's print mode, measured on Claude Code 2 - The prompt is the first argument and stdin is `/dev/null`, because an open stdin costs a three-second wait. - The engine runs from the tracked code root. So its session files land in Claude's own project store for that directory and appear in that directory's resume list. +- Before a conversation's first turn starts, the host appends its session id to `state/.supervision-host-engine-sessions` and never removes it. + So the captain-message capture (`bin/fm-captain-message-sweep.py`) never reads an engine conversation as firstmate's, whether its turn failed or the host has since moved to a new conversation. **Result and cost** diff --git a/tests/command-center.test.sh b/tests/command-center.test.sh index 76c375f58ad..fa6380678ae 100755 --- a/tests/command-center.test.sh +++ b/tests/command-center.test.sh @@ -2369,6 +2369,71 @@ test_a_transcript_the_payload_names_is_captured_wherever_it_lives() { pass "a transcript the payload names is captured wherever it lives" } +# bin/fm-supervision-host.sh runs a headless branch engine beside main in the +# same per-home transcript directory; its own working notes (stale wake, seq +# numbers, MAIN, pane ids) are not his chat. The sweep identifies an engine +# transcript by session identity - every session id the host listed in +# state/.supervision-host-engine-sessions before that conversation's first +# turn, plus the one state/.supervision-host-engine records - never by what +# it says. +test_the_supervision_engines_own_transcript_is_never_captured() { + local home tdir log at + home="$TMP_ROOT/engine-skip" + tdir="$TMP_ROOT/engine-skip-transcripts" + mkdir -p "$home/state" "$tdir" + at=$(date -u +%Y-%m-%dT%H:%M:%S.000Z) + entry r-main end_turn false "$at" \ + '{"type":"text","text":"The fix landed on main."}' > "$tdir/sess-main.jsonl" + entry r-engine end_turn false "$at" \ + '{"type":"text","text":"stale wake seq 61, holding for jt-metal-process-explained"}' \ + > "$tdir/sess-engine.jsonl" + printf 'engine=claude\nmodel=sonnet\nsession=sess-engine\nkey=k\nturns=3\nconversation_cost=0\n' \ + > "$home/state/.supervision-host-engine" + + python3 "$SWEEP" --home "$home" --transcripts "$tdir" \ + || fail "the directory sweep failed" + log="$home/data/captain-messages.jsonl" + assert_equals 1 "$(wc -l < "$log")" \ + "the supervision engine's own transcript was captured alongside main's" + assert_grep 'fix landed on main' "$log" \ + "the main session's message was lost while excluding the engine's" + assert_not_contains "$(cat "$log")" 'stale wake seq 61' \ + "the supervision engine's working notes were captured as his chat" + + # A payload naming the main session's transcript still records as before. + printf '{"transcript_path":"%s"}' "$tdir/sess-main.jsonl" \ + | python3 "$SWEEP" --home "$home" --from-payload \ + || fail "the sweep failed on a payload naming the main session" + assert_equals 1 "$(jq -r '.named' "$home/state/.captain-message-capture")" \ + "the main session's transcript was not remembered once its payload named it" + + # A conversation the host has only launched - listed, its first turn not + # yet succeeded, or failed - is the engine's from its first byte. + entry r-engine-2 end_turn false "$at" \ + '{"type":"text","text":"stale wake seq 62, a new conversation"}' \ + > "$tdir/sess-engine-2.jsonl" + printf 'sess-engine\nsess-engine-2\n' > "$home/state/.supervision-host-engine-sessions" + python3 "$SWEEP" --home "$home" --transcripts "$tdir" \ + || fail "the directory sweep failed on a listed engine conversation" + assert_equals 1 "$(wc -l < "$log")" \ + "a listed engine conversation was captured before its first turn was recorded" + + # Rotation and a failed turn move or remove the record; every conversation + # once listed stays the engine's. + printf 'engine=claude\nmodel=sonnet\nsession=sess-engine-2\nkey=k\nturns=1\nconversation_cost=0\n' \ + > "$home/state/.supervision-host-engine" + python3 "$SWEEP" --home "$home" --transcripts "$tdir" \ + || fail "the directory sweep failed after the engine record rotated" + assert_equals 1 "$(wc -l < "$log")" \ + "the previous engine conversation was captured once the record rotated" + rm -f "$home/state/.supervision-host-engine" + python3 "$SWEEP" --home "$home" --transcripts "$tdir" \ + || fail "the directory sweep failed after the engine record was removed" + assert_equals 1 "$(wc -l < "$log")" \ + "an engine conversation was captured once a failed turn removed the record" + pass "the supervision engine's own transcript is identified by session identity and never captured" +} + # The server sweeps every few seconds, so it is routinely the one that reads a # turn's bytes first. The hook's run then has nothing new to read - and the # payload it carries is still the only thing that knows where this session @@ -2661,6 +2726,7 @@ test_a_hand_recorded_question_without_its_id_folds_only_when_identical test_a_captured_message_is_labelled_only_by_the_evidence_it_carries test_a_message_recorded_by_hand_is_never_a_second_unlabelled_row test_a_transcript_the_payload_names_is_captured_wherever_it_lives +test_the_supervision_engines_own_transcript_is_never_captured test_a_named_transcript_is_remembered_even_with_nothing_new_to_read test_a_response_read_across_two_sweeps_is_recorded_once test_a_torn_row_costs_itself_and_nothing_after_it diff --git a/tests/fm-supervision-host.test.sh b/tests/fm-supervision-host.test.sh index cab4ae040c6..51304570626 100755 --- a/tests/fm-supervision-host.test.sh +++ b/tests/fm-supervision-host.test.sh @@ -70,6 +70,11 @@ n=$(( $(ls "$FM_HOME"/engine-call.* 2>/dev/null | wc -l) + 1 )) printf 'mode=%s\nactor=%s\nholder=%s\nprimary=%s\nturn=%s\n' "$mode" "${FM_SUPERVISION_ACTOR:-}" \ "${FM_LEASE_HOLDER_PID:-}" "${FM_SUPERVISION_PRIMARY_HARNESS:-}" "${FM_BRANCH_REPORT_TURN:-}" for a in "$@"; do printf 'arg=%s\n' "$a"; done + listed=no + for a in "$@"; do + ! grep -qxF -- "$a" "$STATE/.supervision-host-engine-sessions" 2>/dev/null || listed=yes + done + printf 'listed=%s\n' "$listed" } > "$FM_HOME/engine-call.$n" case "$mode" in held|captain-held) printf 'ready\n' > "$FM_HOME/stub-ready" ;; esac # Like Claude, the reported cost is the conversation's running total. @@ -1933,6 +1938,9 @@ test_attended_latch_keeps_closes_on_main_and_records_recovery_off_main() { assert_re '^supervision-host: the supervision session could not take this wake: the engine turn failed \(exit 3\); this wake is yours$' \ "$home/host.out" "the first engine error must hand the wake back with its reason" assert_no_re 'paused' "$home/host.out" "one engine error must not latch the session" + assert_re '^listed=yes$' "$home/engine-call.1" "a conversation's session id must be listed before a turn that then fails" + assert_grep "$(sed -n '/^arg=--session-id$/{n;s/^arg=//p;}' "$home/engine-call.1")" "$home/state/.supervision-host-engine-sessions" \ + "a failed turn's conversation must stay listed as the engine's" main_drain_and_ack "$home" park_again "$home" @@ -2006,6 +2014,7 @@ test_away_wake_is_handled_on_the_engine_and_never_reaches_main() { assert_re '^arg=dontAsk$' "$first" "the engine must never prompt" assert_re '^arg=sonnet$' "$first" "the engine must default to its default model" assert_re '^arg=--session-id$' "$first" "the first turn must open a new conversation" + assert_re '^listed=yes$' "$first" "a new conversation's session id must be listed before its first turn starts" assert_re '^POSTURE: AWAY\.' "$first" "the wake must carry the away tail" assert_grep 'keep the export worker on low effort' "$home/state/.host-mirror.jsonl" "fixture: the captain's dialog was not mirrored" assert_no_re 'MAIN DIALOG MIRROR|low effort' "$first" "an away wake must carry no dialog mirror"