From ae6090041862ea590893329a6c6eb4e2096d43bd Mon Sep 17 00:00:00 2001 From: defangdevs Date: Fri, 23 Oct 2026 16:52:54 +0000 Subject: [PATCH 1/2] fix(webhook): claim, lease and log every object in a coalesced batch The dispatcher exports only the newest event's meta, so a batch holding several assignments seeded a claim, lease and spawn-log entry for the last object alone (#887). agent-box-webhook-spawn now reads an optional LOCAL_WEBHOOK_SPAWN_METAS array (one meta per surviving event; upstream ask: local-channels#101), claims the union of the objects, names them all in the note, and records an `objects` list in the lease and the spawn log. Without the variable, or with an unusable one, the single meta behaves exactly as before. Not done here: per-object disposition that survives `rm` (the second comment on #887) and split-before-spawn; both need a design decision. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RWWyHRhpJYRa9fKq1Qnpwi --- modules/agent-box.nix | 101 ++++++++++++++---- modules/src/lib/lease.sh | 12 ++- modules/src/webhook-spawn.sh | 53 +++++++-- .../vm/payloads/agent-box-agent-mark-stopped | 12 ++- .../agent-box-session/bin/agent-box-session | 12 ++- .../bin/agent-box-supervisor | 12 ++- .../web/payloads/agent-box-robot-mark-stopped | 12 ++- .../bin/agent-box-webhook-spawn | 65 +++++++++-- tests/test-webhook-spawn-claim.sh | 35 ++++++ 9 files changed, 256 insertions(+), 58 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 3365d12c..1bb5f3a2 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -4226,10 +4226,12 @@ if __name__ == "__main__": } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -4245,9 +4247,10 @@ if __name__ == "__main__": _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "''${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -4378,12 +4381,13 @@ if __name__ == "__main__": } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "''${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } @@ -5769,10 +5773,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -5788,9 +5794,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "''${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -5921,12 +5928,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "''${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } @@ -12017,10 +12025,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -12036,9 +12046,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "''${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -12169,12 +12180,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "''${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } @@ -12829,7 +12841,7 @@ claim_include() { # too little to claim anything (in which case the seed is written without # one, and a rule-less entry claims nothing that a watch would spawn for — # local-channels#16 for the CI half). - "$JQ" -n --argjson meta "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" \ + "$JQ" -n --argjson meta "''${1:-{\}}" \ --arg src "''${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}" ' # Only GitHub has numbers, shas and CI. Another source claims the object # it names itself (meta.identifier, e.g. a Slack team/channel/thread_ts), @@ -12876,7 +12888,7 @@ claim_note() { # session was spawned for — a fresh-context session reads it to know why the # event matters. It used to be one fixed sentence, byte-identical across # every hook session on the box (issue #251). - "$JQ" -rn --argjson meta "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" --arg key "$1" \ + "$JQ" -rn --argjson meta "''${2:-{\}}" --arg key "$1" \ --arg src "''${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}" ' # The SAME test claim_include uses, so the note cannot name an object the # claim does not cover: a meta carrying a non-numeric `number` would @@ -12910,9 +12922,45 @@ claim_note() { end' 2>/dev/null } +# A coalesced batch can hold several objects (agent-box#887), but the pinned +# dispatcher exports only the NEWEST event's meta as LOCAL_WEBHOOK_SPAWN_META. +# A receiver that also exports LOCAL_WEBHOOK_SPAWN_METAS - a JSON array with +# one meta per surviving event - gets every object claimed and leased. An +# older receiver sends none, and the batch is then the single meta, exactly +# as before. The array is payload-derived: anything that is not a short array +# of objects is ignored, never half-trusted. +batch_metas() { + "$JQ" -c --argjson one "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' + (if type == "array" and length > 0 and length <= 100 and all(.[]; type == "object") + then . else [$one] end)' <<< "''${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ + || printf '[%s]' "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" +} +claim_include_batch() { + # One include for the whole batch: the union of each event's own claim, + # de-duplicated, so a batch of one is byte-identical to the old claim. + _ci="" + while IFS= read -r _m; do + _one="$(claim_include "$_m")" || _one="" + [ -n "$_one" ] && _ci="$_ci$_one"$'\n' + done < <(printf '%s' "$1" | "$JQ" -c '.[]') + [ -n "$_ci" ] || return 0 + printf '%s' "$_ci" | "$JQ" -sc 'unique | if length == 1 then .[0] else {any: .} end' 2>/dev/null +} +batch_objects() { + # The object each event names, in arrival order, de-duplicated: a number, + # else a commit sha, else a generic identifier. Same shape tests as the claim. + printf '%s' "$1" | "$JQ" -c ' + [.[] | ((.number // "" | tostring) | if test("^[0-9]+$") then . else "" end) as $n + | ((.sha // "" | tostring) | if test("^[0-9a-f]{7,40}$") then .[0:12] else "" end) as $h + | ((.identifier // "" | tostring) | if test("^[A-Za-z0-9._/-]{1,200}$") then . else "" end) as $i + | if $n != "" then $n elif $h != "" then $h else $i end + | select(. != "")] | unique_by(.)' 2>/dev/null || printf '[]' +} + seeded="" own="" lease_object="" +objects="[]" include="" if [ -n "''${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "''${LOCAL_WEBHOOK_SPAWN_KEY:-}" ]; then own="''${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}:$LOCAL_WEBHOOK_SPAWN_KEY" @@ -12923,8 +12971,13 @@ if [ -n "''${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "''${LOCAL_WEBHOOK_SPAWN_KEY: # Both are best effort: a meta this script cannot parse costs the claim and # the note, never the spawn. An entry with no `include` is not a claim, so # the failure mode is a duplicate agent — never a silenced watch. - include="$(claim_include)" || include="" - note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY")" || note="" + metas="$(batch_metas)" + objects="$(batch_objects "$metas")" + include="$(claim_include_batch "$metas")" || include="" + note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY" "$(printf '%s' "$metas" | "$JQ" -c '.[-1]')")" || note="" + if [ "$(printf '%s' "$objects" | "$JQ" 'length')" -gt 1 ]; then + note="$note; this batch carries $(printf '%s' "$objects" | "$JQ" -r 'join(", ")') and all of them are claimed" + fi [ -n "$note" ] || note="seeded at spawn: this session was started for an event on $own" if "$JQ" -n --arg topic "$own" --arg ts "$ts" --arg note "$note" \ --argjson include "''${include:-null}" \ @@ -12946,7 +12999,7 @@ if [ -n "''${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "''${LOCAL_WEBHOOK_SPAWN_KEY: lease_object="$("$JQ" -rn --argjson meta "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" \ '(($meta.number // "" | tostring) | if test("^[0-9]+$") then . else "" end)' \ 2>/dev/null)" || lease_object="" - lease_create "$name" "$own" "$lease_object" + lease_create "$name" "$own" "$lease_object" "$objects" fi # An assignment is a work request, not a triage request (#253), and the @@ -13052,7 +13105,7 @@ fi # Recorded only once the session really exists: a capacity refusal (75) is # retried by the receiver, and a spawn line per attempt would both report # sessions that never started and push the real ones out of the log. -[ "$rc" = 0 ] && hooklog_spawn "$name" "$own" "''${LOCAL_WEBHOOK_SPAWN_EVENT:-}" "$lease_object" "''${LOCAL_WEBHOOK_SPAWN_COUNT:-}" +[ "$rc" = 0 ] && hooklog_spawn "$name" "$own" "''${LOCAL_WEBHOOK_SPAWN_EVENT:-}" "$lease_object" "''${LOCAL_WEBHOOK_SPAWN_COUNT:-}" "''${objects:-[]}" if [ "$rc" = 75 ]; then # Capacity may change after the refusal; this is a diagnostic snapshot, # never a second admission decision. Lock failures are retryable too. @@ -14853,10 +14906,12 @@ esac } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -14872,9 +14927,10 @@ esac _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "''${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -15005,12 +15061,13 @@ esac } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "''${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } diff --git a/modules/src/lib/lease.sh b/modules/src/lib/lease.sh index 27a02be5..ab6b06e0 100644 --- a/modules/src/lib/lease.sh +++ b/modules/src/lib/lease.sh @@ -28,10 +28,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -47,9 +49,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -180,12 +183,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } diff --git a/modules/src/webhook-spawn.sh b/modules/src/webhook-spawn.sh index 81c83d80..b90fad42 100644 --- a/modules/src/webhook-spawn.sh +++ b/modules/src/webhook-spawn.sh @@ -641,7 +641,7 @@ claim_include() { # too little to claim anything (in which case the seed is written without # one, and a rule-less entry claims nothing that a watch would spawn for — # local-channels#16 for the CI half). - "$JQ" -n --argjson meta "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" \ + "$JQ" -n --argjson meta "${1:-{\}}" \ --arg src "${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}" ' # Only GitHub has numbers, shas and CI. Another source claims the object # it names itself (meta.identifier, e.g. a Slack team/channel/thread_ts), @@ -688,7 +688,7 @@ claim_note() { # session was spawned for — a fresh-context session reads it to know why the # event matters. It used to be one fixed sentence, byte-identical across # every hook session on the box (issue #251). - "$JQ" -rn --argjson meta "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" --arg key "$1" \ + "$JQ" -rn --argjson meta "${2:-{\}}" --arg key "$1" \ --arg src "${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}" ' # The SAME test claim_include uses, so the note cannot name an object the # claim does not cover: a meta carrying a non-numeric `number` would @@ -722,9 +722,45 @@ claim_note() { end' 2>/dev/null } +# A coalesced batch can hold several objects (agent-box#887), but the pinned +# dispatcher exports only the NEWEST event's meta as LOCAL_WEBHOOK_SPAWN_META. +# A receiver that also exports LOCAL_WEBHOOK_SPAWN_METAS - a JSON array with +# one meta per surviving event - gets every object claimed and leased. An +# older receiver sends none, and the batch is then the single meta, exactly +# as before. The array is payload-derived: anything that is not a short array +# of objects is ignored, never half-trusted. +batch_metas() { + "$JQ" -c --argjson one "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' + (if type == "array" and length > 0 and length <= 100 and all(.[]; type == "object") + then . else [$one] end)' <<< "${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ + || printf '[%s]' "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" +} +claim_include_batch() { + # One include for the whole batch: the union of each event's own claim, + # de-duplicated, so a batch of one is byte-identical to the old claim. + _ci="" + while IFS= read -r _m; do + _one="$(claim_include "$_m")" || _one="" + [ -n "$_one" ] && _ci="$_ci$_one"$'\n' + done < <(printf '%s' "$1" | "$JQ" -c '.[]') + [ -n "$_ci" ] || return 0 + printf '%s' "$_ci" | "$JQ" -sc 'unique | if length == 1 then .[0] else {any: .} end' 2>/dev/null +} +batch_objects() { + # The object each event names, in arrival order, de-duplicated: a number, + # else a commit sha, else a generic identifier. Same shape tests as the claim. + printf '%s' "$1" | "$JQ" -c ' + [.[] | ((.number // "" | tostring) | if test("^[0-9]+$") then . else "" end) as $n + | ((.sha // "" | tostring) | if test("^[0-9a-f]{7,40}$") then .[0:12] else "" end) as $h + | ((.identifier // "" | tostring) | if test("^[A-Za-z0-9._/-]{1,200}$") then . else "" end) as $i + | if $n != "" then $n elif $h != "" then $h else $i end + | select(. != "")] | unique_by(.)' 2>/dev/null || printf '[]' +} + seeded="" own="" lease_object="" +objects="[]" include="" if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" ]; then own="${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}:$LOCAL_WEBHOOK_SPAWN_KEY" @@ -735,8 +771,13 @@ if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" # Both are best effort: a meta this script cannot parse costs the claim and # the note, never the spawn. An entry with no `include` is not a claim, so # the failure mode is a duplicate agent — never a silenced watch. - include="$(claim_include)" || include="" - note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY")" || note="" + metas="$(batch_metas)" + objects="$(batch_objects "$metas")" + include="$(claim_include_batch "$metas")" || include="" + note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY" "$(printf '%s' "$metas" | "$JQ" -c '.[-1]')")" || note="" + if [ "$(printf '%s' "$objects" | "$JQ" 'length')" -gt 1 ]; then + note="$note; this batch carries $(printf '%s' "$objects" | "$JQ" -r 'join(", ")') and all of them are claimed" + fi [ -n "$note" ] || note="seeded at spawn: this session was started for an event on $own" if "$JQ" -n --arg topic "$own" --arg ts "$ts" --arg note "$note" \ --argjson include "${include:-null}" \ @@ -758,7 +799,7 @@ if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" lease_object="$("$JQ" -rn --argjson meta "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" \ '(($meta.number // "" | tostring) | if test("^[0-9]+$") then . else "" end)' \ 2>/dev/null)" || lease_object="" - lease_create "$name" "$own" "$lease_object" + lease_create "$name" "$own" "$lease_object" "$objects" fi # An assignment is a work request, not a triage request (#253), and the @@ -864,7 +905,7 @@ fi # Recorded only once the session really exists: a capacity refusal (75) is # retried by the receiver, and a spawn line per attempt would both report # sessions that never started and push the real ones out of the log. -[ "$rc" = 0 ] && hooklog_spawn "$name" "$own" "${LOCAL_WEBHOOK_SPAWN_EVENT:-}" "$lease_object" "${LOCAL_WEBHOOK_SPAWN_COUNT:-}" +[ "$rc" = 0 ] && hooklog_spawn "$name" "$own" "${LOCAL_WEBHOOK_SPAWN_EVENT:-}" "$lease_object" "${LOCAL_WEBHOOK_SPAWN_COUNT:-}" "${objects:-[]}" if [ "$rc" = 75 ]; then # Capacity may change after the refusal; this is a diagnostic snapshot, # never a second admission decision. Lock failures are retryable too. diff --git a/tests/golden/vm/payloads/agent-box-agent-mark-stopped b/tests/golden/vm/payloads/agent-box-agent-mark-stopped index 77d9902d..0fea8f21 100644 --- a/tests/golden/vm/payloads/agent-box-agent-mark-stopped +++ b/tests/golden/vm/payloads/agent-box-agent-mark-stopped @@ -514,10 +514,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -533,9 +535,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -666,12 +669,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } diff --git a/tests/golden/vm/payloads/agent-box-session/bin/agent-box-session b/tests/golden/vm/payloads/agent-box-session/bin/agent-box-session index b4bd6d2b..4d72fffc 100644 --- a/tests/golden/vm/payloads/agent-box-session/bin/agent-box-session +++ b/tests/golden/vm/payloads/agent-box-session/bin/agent-box-session @@ -516,10 +516,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -535,9 +537,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -668,12 +671,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } diff --git a/tests/golden/vm/payloads/agent-box-supervisor/bin/agent-box-supervisor b/tests/golden/vm/payloads/agent-box-supervisor/bin/agent-box-supervisor index 510e87b8..18318e05 100644 --- a/tests/golden/vm/payloads/agent-box-supervisor/bin/agent-box-supervisor +++ b/tests/golden/vm/payloads/agent-box-supervisor/bin/agent-box-supervisor @@ -496,10 +496,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -515,9 +517,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -648,12 +651,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } diff --git a/tests/golden/web/payloads/agent-box-robot-mark-stopped b/tests/golden/web/payloads/agent-box-robot-mark-stopped index ccb5ca50..0d11e250 100644 --- a/tests/golden/web/payloads/agent-box-robot-mark-stopped +++ b/tests/golden/web/payloads/agent-box-robot-mark-stopped @@ -514,10 +514,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -533,9 +535,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -666,12 +669,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } diff --git a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn index c79606cc..8ff8cca6 100644 --- a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn +++ b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn @@ -76,10 +76,12 @@ lease_file() { } lease_create() { - # lease_create NAME TOPIC OBJECT -- called once, at spawn, by + # lease_create NAME TOPIC OBJECT [OBJECTS_JSON] -- called once, at spawn, by # agent-box-webhook-spawn. TOPIC is "source:key" (e.g. # github:defangdevs/agent-box); OBJECT is the numbered issue/PR this # session claims, or empty for a CI-shaped claim with no single number. + # OBJECTS_JSON is every object a coalesced batch handed this session (a JSON + # array of strings, agent-box#887); OBJECT stays the newest numbered one. # Best effort throughout, like every other write in this file: a session # must spawn whether or not this write lands. mkdir -p "$LEASE_DIR" 2>/dev/null || return 0 @@ -95,9 +97,10 @@ lease_create() { _gen="" [ -r /proc/sys/kernel/random/uuid ] && read -r _gen < /proc/sys/kernel/random/uuid if "$LEASE_JQ" -n --arg topic "$2" --arg object "$3" --arg gen "$_gen" \ - --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --argjson objects "${4:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{"//": "Durable claim record for a hook-* session (agent-box#535). Written once at spawn; outcome is set on a crash (mark-stopped.sh) or a silent, epilogue-skipped death (supervisor.sh start_session). Deleted on a clean exit -- absence means never leased, or resolved. gen identifies this instance across a name reuse; a writer that read a different gen must not apply its outcome here.", topic: $topic, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), claimedAt: $at, gen: (if $gen == "" then null else $gen end), outcome: null}' \ > "$_lt" 2>/dev/null; then mv -f "$_lt" "$_lf" 2>/dev/null || rm -f "$_lt" @@ -228,12 +231,13 @@ hooklog_append() { } hooklog_spawn() { - # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT -- called by + # hooklog_spawn NAME TOPIC EVENT OBJECT COUNT [OBJECTS_JSON] -- called by # agent-box-webhook-spawn once the session is being added. "$LEASE_JQ" -cn --arg n "$1" --arg topic "$2" --arg event "$3" --arg object "$4" \ - --arg count "$5" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg count "$5" --argjson objects "${6:-[]}" --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{at: $at, what: "spawn", name: $n, topic: $topic, event: $event, object: (if $object == "" then null else $object end), + objects: (if ($objects | type) == "array" then $objects else [] end), count: (if $count | test("^[0-9]+$") then ($count | tonumber) else null end)}' \ 2>/dev/null | { read -r _hl && hooklog_append "$_hl"; } || true } @@ -888,7 +892,7 @@ claim_include() { # too little to claim anything (in which case the seed is written without # one, and a rule-less entry claims nothing that a watch would spawn for — # local-channels#16 for the CI half). - "$JQ" -n --argjson meta "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" \ + "$JQ" -n --argjson meta "${1:-{\}}" \ --arg src "${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}" ' # Only GitHub has numbers, shas and CI. Another source claims the object # it names itself (meta.identifier, e.g. a Slack team/channel/thread_ts), @@ -935,7 +939,7 @@ claim_note() { # session was spawned for — a fresh-context session reads it to know why the # event matters. It used to be one fixed sentence, byte-identical across # every hook session on the box (issue #251). - "$JQ" -rn --argjson meta "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" --arg key "$1" \ + "$JQ" -rn --argjson meta "${2:-{\}}" --arg key "$1" \ --arg src "${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}" ' # The SAME test claim_include uses, so the note cannot name an object the # claim does not cover: a meta carrying a non-numeric `number` would @@ -969,9 +973,45 @@ claim_note() { end' 2>/dev/null } +# A coalesced batch can hold several objects (agent-box#887), but the pinned +# dispatcher exports only the NEWEST event's meta as LOCAL_WEBHOOK_SPAWN_META. +# A receiver that also exports LOCAL_WEBHOOK_SPAWN_METAS - a JSON array with +# one meta per surviving event - gets every object claimed and leased. An +# older receiver sends none, and the batch is then the single meta, exactly +# as before. The array is payload-derived: anything that is not a short array +# of objects is ignored, never half-trusted. +batch_metas() { + "$JQ" -c --argjson one "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' + (if type == "array" and length > 0 and length <= 100 and all(.[]; type == "object") + then . else [$one] end)' <<< "${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ + || printf '[%s]' "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" +} +claim_include_batch() { + # One include for the whole batch: the union of each event's own claim, + # de-duplicated, so a batch of one is byte-identical to the old claim. + _ci="" + while IFS= read -r _m; do + _one="$(claim_include "$_m")" || _one="" + [ -n "$_one" ] && _ci="$_ci$_one"$'\n' + done < <(printf '%s' "$1" | "$JQ" -c '.[]') + [ -n "$_ci" ] || return 0 + printf '%s' "$_ci" | "$JQ" -sc 'unique | if length == 1 then .[0] else {any: .} end' 2>/dev/null +} +batch_objects() { + # The object each event names, in arrival order, de-duplicated: a number, + # else a commit sha, else a generic identifier. Same shape tests as the claim. + printf '%s' "$1" | "$JQ" -c ' + [.[] | ((.number // "" | tostring) | if test("^[0-9]+$") then . else "" end) as $n + | ((.sha // "" | tostring) | if test("^[0-9a-f]{7,40}$") then .[0:12] else "" end) as $h + | ((.identifier // "" | tostring) | if test("^[A-Za-z0-9._/-]{1,200}$") then . else "" end) as $i + | if $n != "" then $n elif $h != "" then $h else $i end + | select(. != "")] | unique_by(.)' 2>/dev/null || printf '[]' +} + seeded="" own="" lease_object="" +objects="[]" include="" if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" ]; then own="${LOCAL_WEBHOOK_SPAWN_SOURCE:-github}:$LOCAL_WEBHOOK_SPAWN_KEY" @@ -982,8 +1022,13 @@ if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" # Both are best effort: a meta this script cannot parse costs the claim and # the note, never the spawn. An entry with no `include` is not a claim, so # the failure mode is a duplicate agent — never a silenced watch. - include="$(claim_include)" || include="" - note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY")" || note="" + metas="$(batch_metas)" + objects="$(batch_objects "$metas")" + include="$(claim_include_batch "$metas")" || include="" + note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY" "$(printf '%s' "$metas" | "$JQ" -c '.[-1]')")" || note="" + if [ "$(printf '%s' "$objects" | "$JQ" 'length')" -gt 1 ]; then + note="$note; this batch carries $(printf '%s' "$objects" | "$JQ" -r 'join(", ")') and all of them are claimed" + fi [ -n "$note" ] || note="seeded at spawn: this session was started for an event on $own" if "$JQ" -n --arg topic "$own" --arg ts "$ts" --arg note "$note" \ --argjson include "${include:-null}" \ @@ -1005,7 +1050,7 @@ if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" lease_object="$("$JQ" -rn --argjson meta "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" \ '(($meta.number // "" | tostring) | if test("^[0-9]+$") then . else "" end)' \ 2>/dev/null)" || lease_object="" - lease_create "$name" "$own" "$lease_object" + lease_create "$name" "$own" "$lease_object" "$objects" fi # An assignment is a work request, not a triage request (#253), and the @@ -1111,7 +1156,7 @@ fi # Recorded only once the session really exists: a capacity refusal (75) is # retried by the receiver, and a spawn line per attempt would both report # sessions that never started and push the real ones out of the log. -[ "$rc" = 0 ] && hooklog_spawn "$name" "$own" "${LOCAL_WEBHOOK_SPAWN_EVENT:-}" "$lease_object" "${LOCAL_WEBHOOK_SPAWN_COUNT:-}" +[ "$rc" = 0 ] && hooklog_spawn "$name" "$own" "${LOCAL_WEBHOOK_SPAWN_EVENT:-}" "$lease_object" "${LOCAL_WEBHOOK_SPAWN_COUNT:-}" "${objects:-[]}" if [ "$rc" = 75 ]; then # Capacity may change after the refusal; this is a diagnostic snapshot, # never a second admission decision. Lock failures are retryable too. diff --git a/tests/test-webhook-spawn-claim.sh b/tests/test-webhook-spawn-claim.sh index a020bdb5..85266ea3 100755 --- a/tests/test-webhook-spawn-claim.sh +++ b/tests/test-webhook-spawn-claim.sh @@ -80,6 +80,7 @@ spawn() { LOCAL_WEBHOOK_SPAWN_TOPIC='github:defangdevs/*' \ LOCAL_WEBHOOK_SPAWN_EVENT=workflow_run \ LOCAL_WEBHOOK_SPAWN_META="$2" \ + ${SPAWN_METAS+LOCAL_WEBHOOK_SPAWN_METAS="$SPAWN_METAS"} \ "$BASH_BIN" "$work/spawn.sh" >/dev/null 2>&1 <<< 'hi' || return 1 set -- "$LOCAL_WEBHOOK_STATE_DIR"/filter.*.json [ -f "$1" ] || return 1 @@ -271,5 +272,39 @@ else echo "skip webhook.py match (no interpreter/pin given)" fi + +# --- a coalesced batch with several objects (agent-box#887) --- +# +# The dispatcher labels a batch with its NEWEST event only, so an earlier +# object was unclaimed and unleased until the model repaired it. A receiver +# that exports LOCAL_WEBHOOK_SPAWN_METAS (one meta per surviving event) gets +# every object claimed, leased and logged. +SPAWN_METAS='[{"event":"issues","action":"assigned","number":"92"},{"event":"issues","action":"assigned","number":"94"},{"event":"issues","action":"assigned","number":"96"},{"event":"issue_comment","number":"96"}]' +f=$(spawn defangdevs/batch '{"event":"issues","action":"assigned","number":"96"}') +check "a batch claims every numbered object once" "$f" \ + '[.topics[0].include.any[] | .any[0].in[0]] == [92, 94, 96]' +check "the note names every object" "$f" \ + '.topics[0].note | contains("92, 94, 96")' +ses=$(basename "$f" .json); ses=${ses#filter."$SPAWN_USER"-} +check "the lease lists every object, newest number kept as object" \ + "$HOME/.local/state/agent-box/lease/$ses.json" \ + '.object == "96" and .objects == ["92","94","96"]' +check "the spawn log lists every object" "$HOME/.local/state/agent-box/hook-sessions.jsonl" \ + 'select(.what == "spawn" and .objects == ["92","94","96"])' +SPAWN_METAS='[{"number":"7"},{"event":"workflow_run","sha":"da73cb12762a543f83f69996d4c43179df13c5e7"}]' +f=$(spawn defangdevs/mixed '{"event":"workflow_run","sha":"da73cb12762a543f83f69996d4c43179df13c5e7"}') +check "a number and a commit in one batch are both claimed" "$f" \ + '(.topics[0].include.any | map(.path // "any") | index("any")) != null + and ([.. | objects | select(.path? == "sha") | .in[0]] == ["da73cb12762a543f83f69996d4c43179df13c5e7"]) + and ([.. | objects | select(.path? == "issue.number") | .in[0]] == [7])' +# A receiver that sends garbage costs the extra objects, never the spawn. +for bad in 'not json' '{"number":"5"}' '[]' '[1,2]'; do + SPAWN_METAS="$bad" + f=$(spawn defangdevs/badmetas '{"event":"issues","action":"assigned","number":"4242"}') + check "unusable METAS ($bad) falls back to the single meta" "$f" \ + '(.topics[0].include.any | all(.in == [4242]))' +done +unset SPAWN_METAS + [ "$fails" -eq 0 ] || { echo "$fails assertion(s) failed" >&2; exit 1; } echo "all webhook-spawn claim assertions passed" From 08d8accff09518815e50412f80e648ccd2daa32f Mon Sep 17 00:00:00 2001 From: defangdevs Date: Fri, 23 Oct 2026 17:02:53 +0000 Subject: [PATCH 2/2] fix(webhook): accept exactly one METAS value; name an earlier lone object Review findings on #919: whitespace-only or two-array LOCAL_WEBHOOK_SPAWN_METAS bypassed the single-meta fallback, and an earlier object beside a newest event that names none was claimed but not named in the note. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RWWyHRhpJYRa9fKq1Qnpwi --- modules/agent-box.nix | 15 +++++++++++---- modules/src/webhook-spawn.sh | 15 +++++++++++---- .../bin/agent-box-webhook-spawn | 15 +++++++++++---- tests/test-webhook-spawn-claim.sh | 12 ++++++++++++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 1bb5f3a2..3027c233 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -12930,9 +12930,12 @@ claim_note() { # as before. The array is payload-derived: anything that is not a short array # of objects is ignored, never half-trusted. batch_metas() { - "$JQ" -c --argjson one "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' - (if type == "array" and length > 0 and length <= 100 and all(.[]; type == "object") - then . else [$one] end)' <<< "''${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ + # -s: exactly one JSON value or nothing usable. Whitespace-only input slurps + # to [] and two arrays to a length of 2; both take the single-meta fallback. + "$JQ" -cs --argjson one "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' + (if length == 1 and (.[0] | type == "array" and length > 0 and length <= 100 + and all(.[]; type == "object")) + then .[0] else [$one] end)' <<< "''${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ || printf '[%s]' "''${LOCAL_WEBHOOK_SPAWN_META:-{\}}" } claim_include_batch() { @@ -12975,7 +12978,11 @@ if [ -n "''${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "''${LOCAL_WEBHOOK_SPAWN_KEY: objects="$(batch_objects "$metas")" include="$(claim_include_batch "$metas")" || include="" note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY" "$(printf '%s' "$metas" | "$JQ" -c '.[-1]')")" || note="" - if [ "$(printf '%s' "$objects" | "$JQ" 'length')" -gt 1 ]; then + # Say the list whenever it holds anything the newest event's own note does + # not name: more than one object, or one earlier object beside a newest + # event that names none (an issue, then a CI event with no sha). + newest="$(batch_objects "$(printf '%s' "$metas" | "$JQ" -c '[.[-1]]')")" + if [ "$objects" != "[]" ] && [ "$objects" != "$newest" ]; then note="$note; this batch carries $(printf '%s' "$objects" | "$JQ" -r 'join(", ")') and all of them are claimed" fi [ -n "$note" ] || note="seeded at spawn: this session was started for an event on $own" diff --git a/modules/src/webhook-spawn.sh b/modules/src/webhook-spawn.sh index b90fad42..d12f7fd4 100644 --- a/modules/src/webhook-spawn.sh +++ b/modules/src/webhook-spawn.sh @@ -730,9 +730,12 @@ claim_note() { # as before. The array is payload-derived: anything that is not a short array # of objects is ignored, never half-trusted. batch_metas() { - "$JQ" -c --argjson one "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' - (if type == "array" and length > 0 and length <= 100 and all(.[]; type == "object") - then . else [$one] end)' <<< "${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ + # -s: exactly one JSON value or nothing usable. Whitespace-only input slurps + # to [] and two arrays to a length of 2; both take the single-meta fallback. + "$JQ" -cs --argjson one "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' + (if length == 1 and (.[0] | type == "array" and length > 0 and length <= 100 + and all(.[]; type == "object")) + then .[0] else [$one] end)' <<< "${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ || printf '[%s]' "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" } claim_include_batch() { @@ -775,7 +778,11 @@ if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" objects="$(batch_objects "$metas")" include="$(claim_include_batch "$metas")" || include="" note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY" "$(printf '%s' "$metas" | "$JQ" -c '.[-1]')")" || note="" - if [ "$(printf '%s' "$objects" | "$JQ" 'length')" -gt 1 ]; then + # Say the list whenever it holds anything the newest event's own note does + # not name: more than one object, or one earlier object beside a newest + # event that names none (an issue, then a CI event with no sha). + newest="$(batch_objects "$(printf '%s' "$metas" | "$JQ" -c '[.[-1]]')")" + if [ "$objects" != "[]" ] && [ "$objects" != "$newest" ]; then note="$note; this batch carries $(printf '%s' "$objects" | "$JQ" -r 'join(", ")') and all of them are claimed" fi [ -n "$note" ] || note="seeded at spawn: this session was started for an event on $own" diff --git a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn index 8ff8cca6..0a9e2f76 100644 --- a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn +++ b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn @@ -981,9 +981,12 @@ claim_note() { # as before. The array is payload-derived: anything that is not a short array # of objects is ignored, never half-trusted. batch_metas() { - "$JQ" -c --argjson one "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' - (if type == "array" and length > 0 and length <= 100 and all(.[]; type == "object") - then . else [$one] end)' <<< "${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ + # -s: exactly one JSON value or nothing usable. Whitespace-only input slurps + # to [] and two arrays to a length of 2; both take the single-meta fallback. + "$JQ" -cs --argjson one "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" ' + (if length == 1 and (.[0] | type == "array" and length > 0 and length <= 100 + and all(.[]; type == "object")) + then .[0] else [$one] end)' <<< "${LOCAL_WEBHOOK_SPAWN_METAS:-null}" 2>/dev/null \ || printf '[%s]' "${LOCAL_WEBHOOK_SPAWN_META:-{\}}" } claim_include_batch() { @@ -1026,7 +1029,11 @@ if [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ] && [ -n "${LOCAL_WEBHOOK_SPAWN_KEY:-}" objects="$(batch_objects "$metas")" include="$(claim_include_batch "$metas")" || include="" note="$(claim_note "$LOCAL_WEBHOOK_SPAWN_KEY" "$(printf '%s' "$metas" | "$JQ" -c '.[-1]')")" || note="" - if [ "$(printf '%s' "$objects" | "$JQ" 'length')" -gt 1 ]; then + # Say the list whenever it holds anything the newest event's own note does + # not name: more than one object, or one earlier object beside a newest + # event that names none (an issue, then a CI event with no sha). + newest="$(batch_objects "$(printf '%s' "$metas" | "$JQ" -c '[.[-1]]')")" + if [ "$objects" != "[]" ] && [ "$objects" != "$newest" ]; then note="$note; this batch carries $(printf '%s' "$objects" | "$JQ" -r 'join(", ")') and all of them are claimed" fi [ -n "$note" ] || note="seeded at spawn: this session was started for an event on $own" diff --git a/tests/test-webhook-spawn-claim.sh b/tests/test-webhook-spawn-claim.sh index 85266ea3..ec62e2a2 100755 --- a/tests/test-webhook-spawn-claim.sh +++ b/tests/test-webhook-spawn-claim.sh @@ -304,6 +304,18 @@ for bad in 'not json' '{"number":"5"}' '[]' '[1,2]'; do check "unusable METAS ($bad) falls back to the single meta" "$f" \ '(.topics[0].include.any | all(.in == [4242]))' done +# Whitespace-only and two-value METAS take the fallback, and an earlier object +# beside a newest event that names none is still listed. +for bad in ' ' '[{"number":"5"}][{"number":"6"}]'; do + SPAWN_METAS="$bad" + f=$(spawn defangdevs/badmetas '{"event":"issues","action":"assigned","number":"4242"}') + check "METAS ($bad) falls back to the single meta" "$f" \ + '(.topics[0].include.any | all(.in == [4242]))' +done +SPAWN_METAS='[{"event":"issues","number":"92"},{"event":"check_run"}]' +f=$(spawn defangdevs/earlier '{"event":"check_run"}') +check "an earlier lone object is claimed and named" "$f" \ + '.topics[0].note | contains("batch carries 92")' unset SPAWN_METAS [ "$fails" -eq 0 ] || { echo "$fails assertion(s) failed" >&2; exit 1; }