From 3643e3df4b26dd0b7e7c6c8e3cb2ec7d3053f022 Mon Sep 17 00:00:00 2001 From: suiren <51693307+suiren@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:04:44 +0900 Subject: [PATCH] fix(delivery): preserve exact identity pairs in check-inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-inbox.sh derived its delivery targets from whoami.sh's human-readable output, which flattens the exact (team, agent) rows into independent agents= and teams= lists. With multiple identities the hook then polled the first agent against every team — including (team, agent) pairs that were never registered — and marked their inboxes read. Consume identities.sh's team/agent TSV directly instead: keep the existing first-agent policy, but subscribe only to that agent's actual team rows, so no cross-product pair is ever queried or marked read. The whoami.sh path also resolved the invocation path to the registered project root before the lookup; keep that by calling agmsg_resolve_project first — identities.sh itself is an exact registry lookup by design. Empty or malformed identity output delivers nothing, matching the previous not_joined/suggest behavior. Regressions: with (alpha, alice) and (beta, bob) registered for the same project and type, only (alpha, alice) is displayed and marked read — the nonexistent cross pairs and the second agent's row stay unread. A nested-subdirectory invocation still resolves to the registered project root and delivers. Co-Authored-By: Claude Fable 5 --- scripts/check-inbox.sh | 55 +++++++++++++++++++++++++++--------------- tests/test_inbox.bats | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index d13476f39..24595bd72 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -98,25 +98,43 @@ SESSION_ID=$(printf '%s' "$INPUT" \ # Deferral was an optimisation, not a correctness requirement. The read state # is the correctness requirement, and it was already there. -# Identify agent and teams -WHOAMI=$("$SCRIPT_DIR/whoami.sh" "$PROJECT" "$TYPE") -# suggest=true means this identity is registered only under a DIFFERENT -# project, so it is not joined here -> deliver nothing (mirror not_joined). -# Without this the else-branch extracts "agents=" as the agent name. -if echo "$WHOAMI" | grep -Eq "not_joined=true|suggest=true"; then - exit 0 -fi +# Resolve the invocation path to the registered project root (session marker / +# nearest ancestor / sibling worktree) before the identity lookup — the +# whoami.sh path did this resolution, and identities.sh itself is an exact +# registry lookup by design (its other callers rely on that). +PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")" -# Handle multiple identities: use first agent name -if echo "$WHOAMI" | grep -q "multiple=true"; then - AGENT=$(echo "$WHOAMI" | sed -n 's/.*agents=\([^,]*\).*/\1/p') -else - # Anchor on a leading "agent=" so "agents=" (multiple/suggest) cannot match. - AGENT=$(echo "$WHOAMI" | sed -n 's/^agent=\([^ ]*\).*/\1/p') -fi -TEAMS=$(echo "$WHOAMI" | sed -n 's/.*teams=\([^ ]*\).*/\1/p') +# Consume exact (team, agent) TSV rows instead of independently flattened +# agent/team lists. For multiple agents, preserve the existing first-agent +# policy, but subscribe only to that agent's actual team rows. +IDENTITIES=$("$SCRIPT_DIR/identities.sh" "$PROJECT" "$TYPE") +[ -n "$IDENTITIES" ] || exit 0 + +AGENT="" +TEAM_LIST=() +IDENTITIES_VALID=1 +while IFS=$'\t' read -r identity_team identity_agent identity_extra; do + if [ -z "$identity_team" ] || [ -z "$identity_agent" ] || [ -n "$identity_extra" ]; then + IDENTITIES_VALID=0 + break + fi + + [ -n "$AGENT" ] || AGENT="$identity_agent" + [ "$identity_agent" = "$AGENT" ] || continue + + team_seen=0 + # ${arr[@]+...} guards the empty-array expansion: under `set -u` bash 3.2 + # (macOS default) treats "${TEAM_LIST[@]}" on an empty array as unbound. + for selected_team in ${TEAM_LIST[@]+"${TEAM_LIST[@]}"}; do + if [ "$selected_team" = "$identity_team" ]; then + team_seen=1 + break + fi + done + [ "$team_seen" -eq 1 ] || TEAM_LIST+=("$identity_team") +done <<< "$IDENTITIES" -if [ -z "$AGENT" ] || [ -z "$TEAMS" ]; then +if [ "$IDENTITIES_VALID" -ne 1 ] || [ -z "$AGENT" ] || [ "${#TEAM_LIST[@]}" -eq 0 ]; then exit 0 fi @@ -173,7 +191,6 @@ agmsg_storage_load OUTPUT="" LOOP_RC=0 LOOP_FAILED_TEAM="" -IFS=',' read -ra TEAM_LIST <<< "$TEAMS" for team in "${TEAM_LIST[@]}"; do storage_store_exists "$team" || continue @@ -214,7 +231,7 @@ for team in "${TEAM_LIST[@]}"; do # session, that session owns that role's inbox — don't deliver here. # Mirrors watch.sh's per-pair filtering (#62). # - # AGENT comes from whoami.sh: the first registered agent for + # AGENT comes from identities.sh: the first registered agent for # (project, type), NOT the session's in-memory actas role — the Codex # caveat documented in README. state=$(actas_lock_state "$team" "$AGENT" "${SESSION_ID:-}") diff --git a/tests/test_inbox.bats b/tests/test_inbox.bats index 1987ec2a5..6e4c407cb 100644 --- a/tests/test_inbox.bats +++ b/tests/test_inbox.bats @@ -24,6 +24,14 @@ unread_count() { ' _ "$1" | grep -c . } +pair_unread_count() { + bash -c ' + source "'"$SCRIPTS"'/lib/storage.sh" + agmsg_storage_load + storage_list_unread "$1" "$2" + ' _ "$1" "$2" | grep -c . +} + # Wait until the script under test has displayed and is paused before its # mark UPDATE (barrier .reached appears), with a bounded wait. await_barrier_reached() { @@ -218,6 +226,42 @@ delivered_to_operator() { [[ "$output" == *"plain=1"* ]] } +@test "check-inbox: multiple identities poll only the first agent's exact team rows" { + local project="/tmp/exact-pair-project" + bash "$SCRIPTS/join.sh" alpha alice claude-code "$project" + bash "$SCRIPTS/join.sh" beta bob claude-code "$project" + + bash "$SCRIPTS/send.sh" alpha system alice "alpha-alice-exact" --force >/dev/null + bash "$SCRIPTS/send.sh" alpha system bob "alpha-bob-cross" --force >/dev/null + bash "$SCRIPTS/send.sh" beta system alice "beta-alice-cross" --force >/dev/null + bash "$SCRIPTS/send.sh" beta system bob "beta-bob-exact" --force >/dev/null + + run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$project'" + [ "$status" -eq 0 ] + grep -q -F -- 'alpha-alice-exact' <<<"$output" + refute grep -q -F -- 'alpha-bob-cross' <<<"$output" + refute grep -q -F -- 'beta-alice-cross' <<<"$output" + refute grep -q -F -- 'beta-bob-exact' <<<"$output" + + [ "$(pair_unread_count alpha alice)" -eq 0 ] + [ "$(pair_unread_count alpha bob)" -eq 1 ] + [ "$(pair_unread_count beta alice)" -eq 1 ] + [ "$(pair_unread_count beta bob)" -eq 1 ] +} + +@test "check-inbox: a subdirectory invocation resolves to the registered project root" { + # Registration lives at the root; the Stop hook bakes in whatever path the + # session was started from, so a nested invocation must still find it. + bash "$SCRIPTS/join.sh" gamma carol claude-code /tmp/exact-pair-root + + bash "$SCRIPTS/send.sh" gamma system carol "root-resolved" --force >/dev/null + + run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code /tmp/exact-pair-root/nested/subdir" + [ "$status" -eq 0 ] + grep -q -F -- 'root-resolved' <<<"$output" + [ "$(pair_unread_count gamma carol)" -eq 0 ] +} + @test "check-inbox: a message arriving between display and mark is NOT marked read unseen" { bash "$SCRIPTS/send.sh" testteam bob alice "early" AGMSG_TEST_MARK_BARRIER="$BARRIER" bash "$SCRIPTS/check-inbox.sh" claude-code /tmp/project-a \