diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 91602b5..2a695a6 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -1620,10 +1620,26 @@ ${lib.optionalString (agentsMdPointer != null) '' # nested inspection bash. postmortem=" || exec ${pkgs.bashInteractive}/bin/bash" [ "$agent" = shell ] && postmortem="" + # A delete (settings page / agent-box-session rm: delist THEN kill) can + # land while this function is preparing the spawn — their kill hits the + # OLD session and this spawn would resurrect it as a live, delisted + # session the reconcile loop never kills (it tolerates unmanaged + # sessions on purpose): a permanent leak. Re-check the CURRENT file at + # the last moment, and verify again AFTER the spawn — a delist landing + # before the post-check is honored here by killing what we just + # started; one landing after it sees the session live and kills it + # itself. (Seen as a CI flake in the sessions VM test, run 30740226645.) + $JQ -e --arg s "$sname" '.sessions | has($s)' "$SESSIONS_FILE" >/dev/null 2>&1 || return 0 if $TMUX new-session -d -s "$sname" -c "$wd" ${webhookSessionEnvArgs name} \ "${envExecWrapper name} $cmd$postmortem"; then + if ! $JQ -e --arg s "$sname" '.sessions | has($s)' "$SESSIONS_FILE" >/dev/null 2>&1; then + $TMUX kill-session -t "=$sname" 2>/dev/null || true + return 0 + fi # First spawn only: persist the id + hasRun and consume the kickoff # prompt, so the next respawn resumes instead of redoing the task. + # (Ordered after the delist post-check: mark_started's jq assignment + # would otherwise re-create a just-deleted session as a stub entry.) [ "$resuming" = true ] || mark_started "$sname" "$bid" fi } diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index cea30af..d9c5452 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -1616,10 +1616,26 @@ ${lib.optionalString (agentsMdPointer != null) '' # nested inspection bash. postmortem=" || exec ${pkgs.bashInteractive}/bin/bash" [ "$agent" = shell ] && postmortem="" + # A delete (settings page / agent-box-session rm: delist THEN kill) can + # land while this function is preparing the spawn — their kill hits the + # OLD session and this spawn would resurrect it as a live, delisted + # session the reconcile loop never kills (it tolerates unmanaged + # sessions on purpose): a permanent leak. Re-check the CURRENT file at + # the last moment, and verify again AFTER the spawn — a delist landing + # before the post-check is honored here by killing what we just + # started; one landing after it sees the session live and kills it + # itself. (Seen as a CI flake in the sessions VM test, run 30740226645.) + $JQ -e --arg s "$sname" '.sessions | has($s)' "$SESSIONS_FILE" >/dev/null 2>&1 || return 0 if $TMUX new-session -d -s "$sname" -c "$wd" ${webhookSessionEnvArgs name} \ "${envExecWrapper name} $cmd$postmortem"; then + if ! $JQ -e --arg s "$sname" '.sessions | has($s)' "$SESSIONS_FILE" >/dev/null 2>&1; then + $TMUX kill-session -t "=$sname" 2>/dev/null || true + return 0 + fi # First spawn only: persist the id + hasRun and consume the kickoff # prompt, so the next respawn resumes instead of redoing the task. + # (Ordered after the delist post-check: mark_started's jq assignment + # would otherwise re-create a just-deleted session as a stub entry.) [ "$resuming" = true ] || mark_started "$sname" "$bid" fi } diff --git a/tests/webhook.nix b/tests/webhook.nix index f0c5b85..99d13c4 100644 --- a/tests/webhook.nix +++ b/tests/webhook.nix @@ -329,6 +329,13 @@ # A signed delivery on the watched repo → a fresh hook-* session appears in # sessions.json, primed with the framed event text plus the trusted # preamble, and the supervisor starts it as a real tmux session. + # + # The supervisor is STOPPED for the delivery: it consumes a kickoff prompt + # within ~2s of spawning (mark_started nulls initialPrompt), so asserting + # the prompt via sessions.json is a race otherwise (lost on master run + # 30740226645). With it stopped, the wrapper's write is the only actor; + # restarting it afterwards proves the spawn + consumption half. + machine.succeed("systemctl stop agent-box-agent.service") client.succeed( f"{post} -H 'x-hub-signature-256: sha256={sig}' " f"https://box.test/agent/webhook/github | grep -x 200" @@ -352,11 +359,19 @@ "jq -e '.sessions | keys[] | select(startswith(\"hook-defangdevs-agent-box-\"))'" " /home/agent/.config/agent-box/sessions.json" ) + # Supervisor back up: it starts the hook session and consumes the prompt. + machine.succeed("systemctl start agent-box-agent.service") machine.wait_until_succeeds( "sudo -u agent env TMUX_TMPDIR=/run/agent-box-agent tmux -L agent-box" " list-sessions -F '#S' | grep -q '^hook-'", timeout=60, ) + machine.wait_until_succeeds( + "jq -e '.sessions | to_entries[] | select(.key | startswith(\"hook-\"))" + " | .value | (.hasRun == true and .initialPrompt == null)'" + " /home/agent/.config/agent-box/sessions.json", + timeout=60, + ) # The event that spawned the watch session was NOT also a session delivery # for the peer (its filter has someone else's repo) — dispatch and session