diff --git a/scripts/despawn.sh b/scripts/despawn.sh index 9ea2a703a..dfdc59be7 100755 --- a/scripts/despawn.sh +++ b/scripts/despawn.sh @@ -99,24 +99,29 @@ recorded_pane_state() { kill_recorded_placement() { [ -f "$SPAWN_REC" ] || return 1 - local id _proj _type _fence _term _bare + local id _proj _type _fence _term _bare _reason="" IFS=$'\t' read -r id _proj _type _fence < "$SPAWN_REC" [ -n "$id" ] || return 1 _term="$(agmsg_terminal_ref_terminal "$id")" || return 1 # unknown/corrupt ref _bare="$(agmsg_terminal_ref_id "$id")" agmsg_terminal_load "$_term" 2>/dev/null || return 1 # driver would not load - terminal_despawn "$_bare" >/dev/null 2>&1 || return 1 # terminal did not confirm + _reason="$(terminal_despawn "$_bare" "$_fence" 2>&1)" || { + KILL_RECORDED_REASON="$_reason" + return 1 + } + KILL_RECORDED_REASON="" return 0 } if [ "$FORCE" = "1" ]; then + KILL_RECORDED_REASON="" [ -f "$SPAWN_REC" ] || die "no placement record for '$TEAM/$NAME' — nothing to force (was it launched via 'spawn'? graceful despawn does not need this)" IFS=$'\t' read -r _id _proj _type _fence < "$SPAWN_REC" if ! kill_recorded_placement; then # Teardown NOT confirmed. Keep the record (the only retry authority), the # registration and the lock, and say so — never claim a forced teardown that did # not happen (the #625 shape, on the --force side). - echo "despawn: could not confirm '$NAME' was torn down via its placement record ($_id) — the terminal driver did not report the pane closed (unknown/corrupt ref, the driver would not load, or the terminal returned an error). The record is KEPT so you can retry; check the pane manually." >&2 + echo "despawn: could not confirm '$NAME' was torn down via its placement record ($_id) — the terminal driver did not report the pane closed (unknown/corrupt ref, the driver would not load, or the terminal returned an error).${KILL_RECORDED_REASON:+ Reason: $KILL_RECORDED_REASON} The record is KEPT so you can retry; check the pane manually." >&2 echo "status=error name=$NAME team=$TEAM note=force-teardown-unconfirmed" exit 1 fi diff --git a/scripts/drivers/terminals/plain/adapters/iterm.applescript b/scripts/drivers/terminals/plain/adapters/iterm.applescript index 8cb4599a8..f9a4d5ad4 100644 --- a/scripts/drivers/terminals/plain/adapters/iterm.applescript +++ b/scripts/drivers/terminals/plain/adapters/iterm.applescript @@ -15,12 +15,13 @@ on run argv end repeat if (count of matches) is not 1 then - if operation is "probe" then return "unsupported: iTerm tty matched " & (count of matches) & " sessions" + if operation starts with "probe" then return "unsupported: iTerm tty matched " & (count of matches) & " sessions" error "iTerm tty no longer identifies exactly one session" end if set targetSession to item 1 of matches if operation is "probe" then return "supported" + if operation is "probe_despawn" then return "supported" if operation is "peek" then return contents of targetSession if operation is "poke" then set bodyText to item 3 of argv @@ -30,10 +31,14 @@ on run argv end tell return "" end if + if operation is "despawn" then + close targetSession + return "" + end if return "unsupported: unknown iTerm adapter operation" end tell on error errorText - if operation is "probe" then return "unknown: iTerm adapter error: " & errorText + if operation starts with "probe" then return "unknown: iTerm adapter error: " & errorText error errorText end try end run diff --git a/scripts/drivers/terminals/plain/adapters/terminal.applescript b/scripts/drivers/terminals/plain/adapters/terminal.applescript index 4e56b3d43..850bba078 100644 --- a/scripts/drivers/terminals/plain/adapters/terminal.applescript +++ b/scripts/drivers/terminals/plain/adapters/terminal.applescript @@ -13,21 +13,26 @@ on run argv end repeat if (count of matches) is not 1 then - if operation is "probe" then return "unsupported: Terminal tty matched " & (count of matches) & " tabs" + if operation starts with "probe" then return "unsupported: Terminal tty matched " & (count of matches) & " tabs" error "Terminal tty no longer identifies exactly one tab" end if set targetTab to item 1 of matches if operation is "probe" then return "unknown: Terminal.app write-submit has not been measured (only iTerm has)" + if operation is "probe_despawn" then return "supported" if operation is "peek" then return history of targetTab if operation is "poke" then do script (item 3 of argv) in targetTab return "" end if + if operation is "despawn" then + close targetTab + return "" + end if return "unsupported: unknown Terminal adapter operation" end tell on error errorText - if operation is "probe" then return "unknown: Terminal adapter error: " & errorText + if operation starts with "probe" then return "unknown: Terminal adapter error: " & errorText error errorText end try end run diff --git a/scripts/drivers/terminals/plain/ops.sh b/scripts/drivers/terminals/plain/ops.sh index 5f121773a..a19cd11e8 100644 --- a/scripts/drivers/terminals/plain/ops.sh +++ b/scripts/drivers/terminals/plain/ops.sh @@ -51,7 +51,7 @@ _plain_adapter_script() { } _plain_adapter_probe() { - local emulator="$1" tty="$2" script out rc=0 + local emulator="$1" tty="$2" capability="${3:-peek}" operation=probe script out rc=0 [ "$(uname -s)" = Darwin ] || { printf 'unsupported: plain emulator adapter %s is only implemented on macOS\n' "$emulator" >&2 return 1 @@ -65,7 +65,8 @@ _plain_adapter_probe() { printf 'unsupported: no measured adapter is implemented yet for plain emulator %s (may become supported or unknown once one is added)\n' "$emulator" >&2 return 1 } - out="$(osascript "$script" probe "$tty" 2>/dev/null)" || rc=$? + [ "$capability" = despawn ] && operation=probe_despawn + out="$(osascript "$script" "$operation" "$tty" 2>/dev/null)" || rc=$? case "$out" in supported) return 0 ;; unsupported:*) printf '%s\n' "$out" >&2; return 1 ;; @@ -81,17 +82,14 @@ terminal_capability() { local capability="$1" id="${2:-}" case "$capability" in spawn) return 0 ;; - despawn) - printf 'unsupported: plain window teardown needs an owner process witness\n' >&2 - return 1 ;; - peek|poke) ;; + despawn|peek|poke) ;; *) printf 'unsupported: plain capability %s is not implemented\n' "$capability" >&2; return 1 ;; esac _plain_parse_id "$id" || { printf 'unsupported: plain %s needs an emulator-qualified tty reference\n' "$capability" >&2 return 1 } - _plain_adapter_probe "$_PLAIN_EMULATOR" "$_PLAIN_TTY" + _plain_adapter_probe "$_PLAIN_EMULATOR" "$_PLAIN_TTY" "$capability" } terminal_where() { @@ -106,9 +104,25 @@ terminal_arrange() { return 13 } -# record op: the fallback always "matches" but has no addressable pane, so the -# self id is '-'. Detection order puts plain last. -terminal_detect() { printf '%s\n' '-'; return 0; } +# A measured macOS emulator can identify the current seat by its controlling +# tty. Other plain environments retain the explicit legacy '-' sentinel: they +# are structurally present, but this implementation has no addressable locator. +terminal_detect() { + local emulator="" tty="" + case "${TERM_PROGRAM:-}" in + iTerm.app) emulator=iterm ;; + Apple_Terminal) emulator=terminal ;; + *) printf '%s\n' '-'; return 0 ;; + esac + tty="$(command tty 2>/dev/null)" || tty="" + case "$tty" in /dev/ttys[0-9]*) ;; *) + printf 'plain: controlling tty is unavailable; cannot produce an emulator-qualified locator\n' >&2 + printf '%s\n' '-' + return 0 ;; + esac + printf '%s:%s\n' "$emulator" "$tty" + return 0 +} _plain_has_template() { case "$1" in *'{cmd}'*) return 0 ;; *) return 1 ;; esac; } @@ -133,7 +147,7 @@ terminal_spawn() { local q_boot; q_boot="$(printf '%q' "$boot")" local cmd="${tmpl//\{cmd\}/$q_boot}" bash -c "$cmd" 1>&2 || return 13 - printf '%s\n' '-'; return 0 + _plain_spawn_locator; return $? fi case "$(uname -s)" in Darwin) @@ -162,7 +176,7 @@ terminal_spawn() { konsole) konsole --workdir "$project" -e "$boot" 1>&2 || return 13 ;; *) "$term" -e "$boot" 1>&2 || return 13 ;; esac - printf '%s\n' '-'; return 0 + _plain_spawn_locator; return $? done printf 'unsupported: no terminal emulator found; set a {cmd} AGMSG_TERMINAL or run inside tmux/herdr\n' >&2 return 13 ;; @@ -178,20 +192,157 @@ terminal_spawn() { printf 'unsupported: platform %s (run inside tmux/herdr or set a {cmd} AGMSG_TERMINAL)\n' "$(uname -s)" >&2 return 13 ;; esac - printf '%s\n' '-' + _plain_spawn_locator +} + +# The new window is the first process that can observe its tty. spawn.sh embeds +# a one-shot witness writer in the boot script and hands this driver its result +# path. Wait for that positive observation; never turn a timeout or malformed +# row into the legacy '-' sentinel after a window has already been created. +_plain_spawn_locator() { + local witness="${AGMSG_PLAIN_SPAWN_WITNESS:-}" emulator tty pid start extra tries=0 + local limit="${AGMSG_TEST_PLAIN_WITNESS_TRIES:-100}" + case "$limit" in ''|*[!0-9]*) limit=100 ;; esac + [ -n "$witness" ] || { + printf 'plain: spawn witness path is unavailable; the created window cannot be recorded\n' >&2 + return 13 + } + if [ -n "${AGMSG_TEST_PLAIN_WITNESS_ROW:-}" ] && [ ! -s "$witness" ]; then + printf '%s\n' "$AGMSG_TEST_PLAIN_WITNESS_ROW" > "$witness" + fi + while [ "$tries" -lt "$limit" ]; do + [ -s "$witness" ] && break + sleep 0.1 2>/dev/null || true + tries=$((tries + 1)) + done + [ -s "$witness" ] || { + printf 'plain: spawned window did not report its tty and owner before the handshake deadline\n' >&2 + return 13 + } + IFS=$'\t' read -r emulator tty pid start extra < "$witness" + [ -n "$emulator" ] && [ -n "$tty" ] && [ -n "$pid" ] && [ -n "$start" ] && [ -z "$extra" ] || { + printf 'plain: spawned window returned an incomplete owner witness\n' >&2 + return 13 + } + _plain_parse_id "$emulator:$tty" || { + printf 'plain: spawned window returned an unsupported emulator or tty\n' >&2 + return 13 + } + case "$pid" in *[!0-9]*|'') + printf 'plain: spawned window returned an invalid owner pid\n' >&2 + return 13 ;; + esac + case "$start" in *[!0-9A-Za-z_:]*) + printf 'plain: spawned window returned an invalid owner start time\n' >&2 + return 13 ;; + esac + printf '%s:%s\n' "$emulator" "$tty" return 0 } -# control op: an OS terminal window has no addressable handle (the placement id -# is '-'), so there is nothing to kill from here — it closes when its process -# exits, exactly as before the axis (OS-terminal members were never force- -# killable). Report ok (nothing to tear down) rather than a spurious error. -# plain has no addressable pane, so it cannot be asked whether one is still -# there. 13 is that answer, and it is a real answer rather than a failure — the -# caller must not read it as "closed". +# A tty reference does not provide a read-only existence authority for the +# process-bound placement, so pane_state remains unknown. Despawn is stronger: +# it receives the record's owner witness, revalidates it, then closes the exact +# emulator session through the measured adapter. terminal_pane_state() { echo unknown; return 13; } -terminal_despawn() { echo ok; return 0; } +_plain_process_witness_matches() { # + local pid="$1" start="$2" tty="$3" kind="$4" observed current_start + case "$pid" in ''|*[!0-9]*) + printf 'plain: %s process witness is malformed\n' "$kind" >&2 + return 10 ;; + esac + observed="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')" + case "$observed" in /dev/*) ;; ''|'?'|'??'|'-') observed="" ;; *) observed="/dev/$observed" ;; esac + [ "$observed" = "$tty" ] || { + printf 'plain: %s process no longer controls %s\n' "$kind" "$tty" >&2 + return 10 + } + current_start="$(ps -o lstart= -p "$pid" 2>/dev/null | sed 's/^ *//; s/ *$//' | tr ' ' '_')" + [ -n "$current_start" ] && [ "$current_start" = "$start" ] || { + printf 'plain: %s process witness no longer matches pid %s\n' "$kind" "$pid" >&2 + return 10 + } + return 0 +} + +terminal_despawn() { # + local id="$1" fence="${2:-}" emulator tty anchor remaining witness_tty="" pid="" start="" boot="" boot_start="" kv script + local witness_ok=1 cli_why="" boot_why="" + _plain_parse_id "$id" || { + printf 'unsupported: plain window teardown needs an emulator-qualified tty reference\n' >&2 + return 13 + } + emulator="$_PLAIN_EMULATOR"; tty="$_PLAIN_TTY" + case "$fence" in fence=*:*) ;; *) + printf 'unsupported: plain window teardown needs an owner process witness\n' >&2 + return 13 ;; + esac + anchor="${fence#fence=*:}" + [ "${fence#fence=}" = "$emulator:$anchor" ] || { + printf 'plain: owner witness emulator does not match the placement locator\n' >&2 + return 10 + } + remaining="$anchor" + while :; do + kv="${remaining%%,*}" + case "$kv" in + tty=*) [ -z "$witness_tty" ] || { printf 'plain: duplicate tty in owner witness\n' >&2; return 10; }; witness_tty="${kv#tty=}" ;; + pid=*) [ -z "$pid" ] || { printf 'plain: duplicate pid in owner witness\n' >&2; return 10; }; pid="${kv#pid=}" ;; + start=*) [ -z "$start" ] || { printf 'plain: duplicate start in owner witness\n' >&2; return 10; }; start="${kv#start=}" ;; + boot=*) [ -z "$boot" ] || { printf 'plain: duplicate boot pid in owner witness\n' >&2; return 10; }; boot="${kv#boot=}" ;; + boot_start=*) [ -z "$boot_start" ] || { printf 'plain: duplicate boot start in owner witness\n' >&2; return 10; }; boot_start="${kv#boot_start=}" ;; + *) : ;; # Forward-compatible: only known keys are evidence for this reader. + esac + case "$remaining" in *,*) remaining="${remaining#*,}" ;; *) break ;; esac + done + [ "$witness_tty" = "$tty" ] || { + printf 'plain: owner witness tty does not match the placement locator\n' >&2 + return 10 + } + if { [ -n "$pid" ] && [ -z "$start" ]; } || { [ -z "$pid" ] && [ -n "$start" ]; }; then + printf 'plain: CLI process witness is incomplete\n' >&2 + return 10 + fi + if { [ -n "$boot" ] && [ -z "$boot_start" ]; } || { [ -z "$boot" ] && [ -n "$boot_start" ]; }; then + printf 'plain: boot process witness is incomplete\n' >&2 + return 10 + fi + [ -n "$pid" ] || [ -n "$boot" ] || { + printf 'plain: owner process witness carries no known process identity\n' >&2 + return 10 + } + # Either complete pair can prove that this is still the spawned window. The + # CLI pair is newer, but the CLI is expected to exit before a later forced + # despawn; the carried boot-shell pair exists specifically to outlive it. + # Conversely, a replaced boot shell must not block a still-live CLI proof. + if [ -n "$pid" ]; then + cli_why="$(_plain_process_witness_matches "$pid" "$start" "$tty" CLI 2>&1)" && witness_ok=0 + fi + if [ -n "$boot" ]; then + boot_why="$(_plain_process_witness_matches "$boot" "$boot_start" "$tty" boot 2>&1)" && witness_ok=0 + fi + if [ "$witness_ok" -ne 0 ]; then + [ -z "$cli_why" ] || printf '%s\n' "$cli_why" >&2 + [ -z "$boot_why" ] || printf '%s\n' "$boot_why" >&2 + return 10 + fi + [ "$(uname -s)" = Darwin ] || { + printf 'unsupported: plain window teardown adapters are only implemented on macOS\n' >&2 + return 13 + } + command -v osascript >/dev/null 2>&1 || { + printf 'unknown: osascript is unavailable; cannot close plain emulator %s\n' "$emulator" >&2 + return 10 + } + script="$(_plain_adapter_script "$emulator")" + osascript "$script" despawn "$tty" >/dev/null || { + printf 'plain: %s adapter could not close %s\n' "$emulator" "$tty" >&2 + return 10 + } + echo ok + return 0 +} _plain_unsupported() { printf 'unsupported: plain terminal has no addressable pane (%s)\n' "$1" >&2 diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 8fdad52b2..cb9c706a4 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -497,9 +497,23 @@ BOOT="$(mktemp "$BOOT_DIR/boot-XXXXXX")" case "$(uname -s)" in Darwin) mv "$BOOT" "$BOOT.command"; BOOT="$BOOT.command" ;; esac +PLAIN_WITNESS_REQUEST="${BOOT}.plain-witness-request" +PLAIN_WITNESS="${BOOT}.plain-witness" { echo '#!/usr/bin/env bash' printf 'cd %q || exit 1\n' "$PROJECT" + # A plain OS-terminal spawn has no parent-side handle. The child window is the + # first authority that can observe its emulator, controlling tty and owner + # process. Only the plain launcher creates the request file, so tmux/herdr + # boots do no extra work. Publish atomically before starting the CLI. + printf 'if [ -f %q ]; then\n' "$PLAIN_WITNESS_REQUEST" + echo ' case "${TERM_PROGRAM:-}" in iTerm.app) _agmsg_emulator=iterm ;; Apple_Terminal) _agmsg_emulator=terminal ;; *) _agmsg_emulator=unknown ;; esac' + echo ' _agmsg_tty="$(tty 2>/dev/null || true)"' + echo ' _agmsg_start="$(ps -o lstart= -p "$$" 2>/dev/null | sed '\''s/^ *//; s/ *$//'\'' | tr '\'' '\'' '\''_'\'')"' + printf ' _agmsg_witness_tmp=%q.$$\n' "$PLAIN_WITNESS" + echo ' { printf '\''%s\t%s\t%s\t%s'\'' "$_agmsg_emulator" "$_agmsg_tty" "$$" "$_agmsg_start"; echo; } > "$_agmsg_witness_tmp"' + printf ' mv "$_agmsg_witness_tmp" %q\n' "$PLAIN_WITNESS" + echo 'fi' # Mark the launched session as spawn-born (#339): the CLI inherits this, so the # actas flow knows the session is already named - (name_arg) and # suppresses the "rename this session" tip meant for hand-started sessions. @@ -597,8 +611,8 @@ SPAWN_UNREC_REF="" # before the boot was typed (herdr process-info did not answer). A WARNING, distinct # from the post-input startup verdict — see the note where it is emitted. SPAWN_READINESS_UNVERIFIED=0 -_record_placement() { # - local rec ref +_record_placement() { # [fence=...] + local rec ref fence="${3:-}" row rec="$(agmsg_spawn_path "$TEAM" "$NAME")" ref="$(agmsg_terminal_ref "$1" "$2")" mkdir -p "$(dirname "$rec")" 2>/dev/null || true @@ -607,7 +621,9 @@ _record_placement() { # # record — SPAWN_UNRECORDED is reported only AFTER the old record is proven # intact, not on top of one this write just emptied. The helper adds the # trailing newline, so the row is passed without one. - if ! agmsg_write_atomic "$rec" "$(printf '%s\t%s\t%s' "$ref" "$PROJECT" "$AGENT_TYPE")" 2>/dev/null; then + row="$(printf '%s\t%s\t%s' "$ref" "$PROJECT" "$AGENT_TYPE")" + [ -z "$fence" ] || row="${row}$(printf '\t%s' "$fence")" + if ! agmsg_write_atomic "$rec" "$row" 2>/dev/null; then SPAWN_UNRECORDED=1 SPAWN_UNREC_REF="$ref" return 1 @@ -770,17 +786,23 @@ _launch_os_terminal() { # duplicate). plain's terminal_spawn does the OS-terminal launch — a {cmd} template # on any OS, else the current macOS terminal (`open -g -a`) / a Linux emulator / # Windows Terminal, with the same headless + platform guards it moved from here — - # and returns '-' (no addressable pane, so no placement record for plain). It reads - # AGMSG_TERMINAL as the template / macOS app hint; hand it the resolved value. + # and returns the child window's emulator-qualified tty after its boot handshake. + # It reads AGMSG_TERMINAL as the template / macOS app hint; hand it the resolved value. agmsg_terminal_load plain || die "could not load the plain terminal driver" - # CAPTURE the driver's record-op stdout — it is a protocol value ('-' = placed, no - # addressable pane), not something a spawn user should see on stdout. Verify it is - # exactly '-' (a malformed/empty result is NOT a success), and do not echo it. - local _plain_id - _plain_id="$(AGMSG_TERMINAL="$TERMINAL_TMPL" terminal_spawn "$NAME" "$PROJECT" - "$BOOT")" \ + # Request the child's one-shot witness before launching. A window that cannot + # report it is live but unrecordable, so the driver fails loudly instead of + # returning the legacy '-' sentinel as a false success. + : > "$PLAIN_WITNESS_REQUEST" \ + || die "could not create the plain terminal spawn witness request" + local _plain_id _emulator _tty _pid _start _fence + _plain_id="$(AGMSG_TERMINAL="$TERMINAL_TMPL" AGMSG_PLAIN_SPAWN_WITNESS="$PLAIN_WITNESS" terminal_spawn "$NAME" "$PROJECT" - "$BOOT")" \ || die "could not open an OS terminal (see the reason above); run inside tmux/herdr or set a {cmd} AGMSG_TERMINAL" - [ "$_plain_id" = '-' ] \ - || die "the plain terminal driver returned an unexpected placement id ('${_plain_id}') — expected '-' (an OS terminal has no addressable pane)" + terminal_id_ok "$_plain_id" && [ "$_plain_id" != '-' ] \ + || die "the plain terminal driver returned an unexpected placement id ('${_plain_id}') — expected an emulator-qualified tty" + IFS=$'\t' read -r _emulator _tty _pid _start < "$PLAIN_WITNESS" + _fence="fence=${_emulator}:tty=${_tty},boot=${_pid},boot_start=${_start}" + _record_placement plain "$_plain_id" "$_fence" || true + rm -f "$PLAIN_WITNESS_REQUEST" "$PLAIN_WITNESS" 2>/dev/null || true # "launched", NOT "spawned": every placement line below states only that the # pane was created and the boot typed into it — a PLACEMENT fact. It is deliberately # not the word "spawned", because whether the agent actually STARTED is answered diff --git a/tests/test_despawn.bats b/tests/test_despawn.bats index 5111505f1..575f5b38b 100644 --- a/tests/test_despawn.bats +++ b/tests/test_despawn.bats @@ -120,7 +120,7 @@ _stub_tmux_exit() { run bash "$SCRIPTS/despawn.sh" team leader alice --force [ "$status" -eq 0 ] - [[ "$output" == *"status=forced"* ]] + printf '%s\n' "$output" | grep -Fq 'status=forced' [ ! -f "$RUN/spawn.team__alice" ] # placement record cleaned [ ! -f "$RUN/actas.team__alice.session" ] # lock released run bash "$SCRIPTS/identities.sh" "$PROJ" claude-code @@ -146,6 +146,97 @@ _stub_tmux_exit() { [ "$(printf '%s\n' "$output" | grep -c alice)" -eq 0 ] # dropped: the type reached reset intact } +@test "despawn --force: a plain placement closes only after its owner witness matches" { + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + printf '%s\t%s\t%s\t%s\n' 'plain:iterm:/dev/ttys040' "$PROJ" claude-code \ + 'fence=iterm:tty=/dev/ttys040,boot=123,boot_start=Sat_Sep_13_02:10:11_2026' > "$RUN/spawn.team__alice" + local bin="$TEST_SKILL_DIR/plain-bin" + mkdir -p "$bin" + cat > "$bin/ps" <<'EOF' +#!/usr/bin/env bash +case "$*" in *'tty='*) printf 'ttys040\n' ;; *'lstart='*) printf 'Sat Sep 13 02:10:11 2026\n' ;; esac +EOF + cat > "$bin/osascript" <> "$TEST_SKILL_DIR/plain-close.log" +EOF + cat > "$bin/uname" <<'EOF' +#!/usr/bin/env bash +printf 'Darwin\n' +EOF + chmod +x "$bin/ps" "$bin/osascript" "$bin/uname" + + run env PATH="$bin:$PATH" bash "$SCRIPTS/despawn.sh" team leader alice --force + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'status=forced' + [ ! -f "$RUN/spawn.team__alice" ] + grep -Fq 'despawn /dev/ttys040' "$TEST_SKILL_DIR/plain-close.log" +} + +@test "despawn --force: self-write keeps the spawn witness after the CLI exits" { + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + local bin="$TEST_SKILL_DIR/plain-carry-bin" mode="$TEST_SKILL_DIR/plain-carry-mode" + mkdir -p "$bin" + printf 'self-write\n' > "$mode" + cat > "$bin/ps" < "$bin/osascript" <> "$TEST_SKILL_DIR/plain-carry-close.log" +EOF + cat > "$bin/uname" <<'EOF' +#!/usr/bin/env bash +printf 'Darwin\n' +EOF + chmod +x "$bin/ps" "$bin/osascript" "$bin/uname" + export PATH="$bin:$PATH" SKILL_DIR="$TEST_SKILL_DIR" RUN_DIR="$RUN" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/self-write.sh" + agmsg_role_session_record team alice sid-me "$PROJ" claude-code + printf '%s\t%s\t%s\t%s\n' 'plain:iterm:/dev/ttys040' "$PROJ" claude-code \ + 'fence=iterm:tty=/dev/ttys040,boot=123,boot_start=Sat_Sep_13_02:00:00_2026' > "$RUN/spawn.team__alice" + + agmsg_self_write team alice 'plain:iterm:/dev/ttys040' "sid-me.$$" >/dev/null + grep -Fq "pid=$$,start=Sat_Sep_13_02:10:11_2026,boot=123,boot_start=Sat_Sep_13_02:00:00_2026" "$RUN/spawn.team__alice" + + # The CLI process proof is now gone. Only the carried boot pair can prove + # that the recorded tty is still the spawned window. + printf 'cli-gone\n' > "$mode" + run bash "$SCRIPTS/despawn.sh" team leader alice --force + [ "$status" -eq 0 ] + [[ "$output" == *"status=forced"* ]] + [ ! -f "$RUN/spawn.team__alice" ] + grep -Fq 'despawn /dev/ttys040' "$TEST_SKILL_DIR/plain-carry-close.log" +} + +@test "despawn --force: a stale plain owner witness is named and kept for retry" { + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + printf '%s\t%s\t%s\t%s\n' 'plain:iterm:/dev/ttys040' "$PROJ" claude-code \ + 'fence=iterm:tty=/dev/ttys040,pid=123,start=OLD' > "$RUN/spawn.team__alice" + local bin="$TEST_SKILL_DIR/plain-bin-stale" + mkdir -p "$bin" + cat > "$bin/ps" <<'EOF' +#!/usr/bin/env bash +case "$*" in *'tty='*) printf 'ttys040\n' ;; *'lstart='*) printf 'Sat Sep 13 02:10:11 2026\n' ;; esac +EOF + chmod +x "$bin/ps" + + run env PATH="$bin:$PATH" bash "$SCRIPTS/despawn.sh" team leader alice --force + [ "$status" -ne 0 ] + printf '%s\n' "$output" | grep -Fq 'CLI process witness no longer matches' + [ -f "$RUN/spawn.team__alice" ] +} + @test "despawn --force: an UNCONFIRMED teardown keeps the record and reports error (#625, --force side)" { # If the terminal driver does not confirm the pane closed (here: kill-pane exits # non-zero), the pane may still be alive. --force must NOT delete the record (the diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index 7f180c078..fba10f6d0 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -18,6 +18,7 @@ setup() { cat > "$STUB_BIN/record.sh" <> "$CAPTURE" +printf 'iterm\t/dev/ttys040\t123\tSat_Sep_13_02:10:11_2026\n' > "\${1}.plain-witness" EOF chmod +x "$STUB_BIN/record.sh" export PATH="$STUB_BIN:$PATH" @@ -30,6 +31,7 @@ EOF unset TMUX unset HERDR_ENV HERDR_PANE_ID HERDR_SOCKET_PATH HERDR_WORKSPACE_ID export AGMSG_TERMINAL="$STUB_BIN/record.sh {cmd}" + export AGMSG_TEST_PLAIN_WITNESS_ROW=$'iterm\t/dev/ttys040\t123\tSat_Sep_13_02:10:11_2026' export PROJ="$TEST_SKILL_DIR/proj" mkdir -p "$PROJ" @@ -1422,15 +1424,17 @@ T grep -q "tmux:/tmp/fake:%9" <<<"$output" } -@test "spawn: the plain driver's '-' protocol value never leaks to spawn stdout" { - # _launch_os_terminal captures terminal_spawn's record-op stdout ('-' = placed, no - # pane) and verifies it, rather than letting it print. A normal OS-terminal spawn - # must not emit a lone '-' line alongside the human status. +@test "spawn: the plain driver's locator protocol value does not leak, and its witness is recorded" { + # _launch_os_terminal captures terminal_spawn's record-op stdout and verifies + # it rather than letting the protocol value appear beside the human status. bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait [ "$status" -eq 0 ] - refute grep -qx -- '-' <<<"$output" # no line that is just the protocol '-' + refute grep -qx -- 'iterm:/dev/ttys040' <<<"$output" grep -q "terminal template" <<<"$output" # the OS-terminal path did run + local rec="$TEST_SKILL_DIR/run/spawn.myteam__alice" + grep -Fq $'plain:iterm:/dev/ttys040\t' "$rec" + grep -Fq $'\tfence=iterm:tty=/dev/ttys040,boot=123,boot_start=Sat_Sep_13_02:10:11_2026' "$rec" } # --- codex launches through the bundled shim, by path (spawn_wrapper=) --------- diff --git a/tests/test_terminal_registry.bats b/tests/test_terminal_registry.bats index 90b363c05..d36cc20db 100644 --- a/tests/test_terminal_registry.bats +++ b/tests/test_terminal_registry.bats @@ -359,6 +359,19 @@ _fake_herdr_list_scalar_session() { # --- plain driver ----------------------------------------------------------- +@test "plain: detect produces an emulator-qualified controlling tty when observable" { + cat > "$FAKEBIN/tty" <<'EOF' +#!/usr/bin/env bash +printf '/dev/ttys040\n' +EOF + chmod +x "$FAKEBIN/tty" + export PATH="$FAKEBIN:$PATH" TERM_PROGRAM=iTerm.app + agmsg_terminal_load plain + run terminal_detect "" + [ "$status" -eq 0 ] + [ "$output" = 'iterm:/dev/ttys040' ] +} + @test "runtime capability: drivers without a hook fall back to the static ceiling" { run agmsg_terminal_capability tmux peek '%1' [ "$status" -eq 0 ] @@ -429,7 +442,7 @@ _fake_herdr_list_scalar_session() { refute terminal_id_ok 'terminal:/dev/ttysx' } -@test "plain: measured adapter support is checked again by peek and poke" { +@test "plain: measured adapter support is checked again by despawn, peek and poke" { cat > "$FAKEBIN/uname" <<'SH' #!/usr/bin/env bash printf 'Darwin\n' @@ -438,7 +451,7 @@ SH #!/usr/bin/env bash printf '%s\n' "$*" >> "$ARGV_LOG" case "$2" in - probe) printf 'supported\n' ;; + probe*) printf 'supported\n' ;; peek) printf 'visible terminal text\n' ;; poke) : ;; esac @@ -449,6 +462,8 @@ SH run agmsg_terminal_capability plain peek 'iterm:/dev/ttys040' [ "$status" -eq 0 ] + run agmsg_terminal_capability plain despawn 'iterm:/dev/ttys040' + [ "$status" -eq 0 ] run terminal_peek 'iterm:/dev/ttys040' [ "$status" -eq 0 ] [ "$output" = 'visible terminal text' ] @@ -1203,7 +1218,7 @@ OPS done } -@test "plain: spawn runs the boot THROUGH the {cmd} template, and returns '-'; despawn is a no-op ok" { +@test "plain: spawn runs the boot THROUGH the {cmd} template and returns its witnessed locator" { agmsg_terminal_load plain # The fake BOOT records that IT ran — so the test proves the template actually # launched the boot, not merely that the template's own side effect fired. A @@ -1211,34 +1226,81 @@ OPS # ignore {cmd}" template would hide. local ran="$TEST_SKILL_DIR/boot-ran" local boot="$TEST_SKILL_DIR/boot" - printf '#!/usr/bin/env bash\ntouch %q\n' "$ran" > "$boot" + printf '#!/usr/bin/env bash\ntouch %q\nprintf '\''iterm\\t/dev/ttys040\\t123\\tSTART\\n'\'' > "$AGMSG_PLAIN_SPAWN_WITNESS"\n' "$ran" > "$boot" chmod +x "$boot" # The template invokes {cmd} directly (a runnable path), like a real terminal # would run the boot script. export AGMSG_TERMINAL="{cmd}" + export AGMSG_PLAIN_SPAWN_WITNESS="$TEST_SKILL_DIR/plain-witness" run terminal_spawn alice /proj window "$boot" [ "$status" -eq 0 ] - [ "$output" = "-" ] + [ "$output" = "iterm:/dev/ttys040" ] [ -f "$ran" ] # the boot itself ran, reached via the template run terminal_despawn "-" - [ "$status" -eq 0 ] - [ "$output" = "ok" ] + [ "$status" -eq 13 ] + printf '%s\n' "$output" | grep -Fq 'emulator-qualified tty' } -@test "plain: spawn ISOLATES backend stdout — the record-op result is exactly '-'" { - # spawn is a record op: its stdout must be the id ('-') and nothing else. A backend +@test "plain: spawn ISOLATES backend stdout — the record-op result is exactly the locator" { + # spawn is a record op: its stdout must be the id and nothing else. A backend # (here a {cmd} template) that writes to stdout must not pollute the captured result # — otherwise the caller reads '\n-' as the placement id. Capture stdout # ALONE (stderr, where the noise now goes as a diagnostic, is separated). agmsg_terminal_load plain local noisy="$TEST_SKILL_DIR/noisy-boot" - printf '#!/usr/bin/env bash\necho "BACKEND STDOUT NOISE"\nprintf "and more\\n"\n' > "$noisy" + printf '#!/usr/bin/env bash\necho "BACKEND STDOUT NOISE"\nprintf "and more\\n"\nprintf '\''iterm\\t/dev/ttys040\\t123\\tSTART\\n'\'' > "$AGMSG_PLAIN_SPAWN_WITNESS"\n' > "$noisy" chmod +x "$noisy" export AGMSG_TERMINAL="{cmd}" + export AGMSG_PLAIN_SPAWN_WITNESS="$TEST_SKILL_DIR/plain-witness" local out rc=0 out="$(terminal_spawn alice /proj window "$noisy" 2>/dev/null)" || rc=$? [ "$rc" -eq 0 ] - [ "$out" = "-" ] # exactly '-', the backend noise did not leak + [ "$out" = "iterm:/dev/ttys040" ] # backend noise did not leak +} + +@test "plain: a launched window without a witness fails loudly instead of returning '-'" { + agmsg_terminal_load plain + unset AGMSG_TEST_PLAIN_WITNESS_ROW + local launched="$TEST_SKILL_DIR/window-launched" + export AGMSG_TERMINAL="touch $launched; {cmd}" + export AGMSG_PLAIN_SPAWN_WITNESS="$TEST_SKILL_DIR/missing-witness" + export AGMSG_TEST_PLAIN_WITNESS_TRIES=1 + local boot="$TEST_SKILL_DIR/no-witness-boot" + printf '#!/usr/bin/env bash\n:\n' > "$boot" + chmod +x "$boot" + + run terminal_spawn alice /proj window "$boot" + [ "$status" -eq 13 ] + printf '%s\n' "$output" | grep -Fq 'did not report its tty and owner' + [ -f "$launched" ] + refute grep -qx -- '-' <<<"$output" +} + +@test "plain: despawn verifies the owner witness before closing exactly that tty" { + agmsg_terminal_load plain + cat > "$FAKEBIN/ps" <<'EOF' +#!/usr/bin/env bash +case "$*" in *'tty='*) printf 'ttys040\n' ;; *'lstart='*) printf 'Sat Sep 13 02:10:11 2026\n' ;; esac +EOF + cat > "$FAKEBIN/osascript" <> "$ARGV_LOG" +EOF + cat > "$FAKEBIN/uname" <<'EOF' +#!/usr/bin/env bash +printf 'Darwin\n' +EOF + chmod +x "$FAKEBIN/ps" "$FAKEBIN/osascript" "$FAKEBIN/uname" + export PATH="$FAKEBIN:$PATH" + + run terminal_despawn 'iterm:/dev/ttys040' 'fence=iterm:tty=/dev/ttys040,pid=123,start=Sat_Sep_13_02:10:11_2026' + [ "$status" -eq 0 ] + [ "$output" = ok ] + grep -Fq 'despawn /dev/ttys040' "$ARGV_LOG" + + run terminal_despawn 'iterm:/dev/ttys040' 'fence=iterm:tty=/dev/ttys040,pid=123,start=OTHER' + [ "$status" -eq 10 ] + printf '%s\n' "$output" | grep -Fq 'no longer matches' } # --- load failure cleanup: source failure, like missing-function, leaves nothing (review round 2) --- diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats index 6d6f341fb..1db38381f 100644 --- a/tests/test_type_registry.bats +++ b/tests/test_type_registry.bats @@ -425,6 +425,7 @@ nodetype: YAML run env -u TMUX -u HERDR_ENV -u HERDR_PANE_ID AGMSG_TERMINAL="$stub_bin/record.sh {cmd}" \ + AGMSG_TEST_PLAIN_WITNESS_ROW=$'iterm\t/dev/ttys040\t123\tSTART' \ AGMSG_SPAWN_OPTIONS_FILE="$opts" \ bash "$SCRIPTS/spawn.sh" nodetype nodeagent --project "$proj" --no-wait [ "$status" -eq 0 ]