diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 3365d12c..3027c233 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,48 @@ 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() { + # -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() { + # 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 +12974,17 @@ 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="" + # 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" if "$JQ" -n --arg topic "$own" --arg ts "$ts" --arg note "$note" \ --argjson include "''${include:-null}" \ @@ -12946,7 +13006,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 +13112,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 +14913,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 +14934,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 +15068,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..d12f7fd4 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,48 @@ 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() { + # -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() { + # 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 +774,17 @@ 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="" + # 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" if "$JQ" -n --arg topic "$own" --arg ts "$ts" --arg note "$note" \ --argjson include "${include:-null}" \ @@ -758,7 +806,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 +912,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..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 @@ -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,48 @@ 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() { + # -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() { + # 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 +1025,17 @@ 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="" + # 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" if "$JQ" -n --arg topic "$own" --arg ts "$ts" --arg note "$note" \ --argjson include "${include:-null}" \ @@ -1005,7 +1057,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 +1163,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..ec62e2a2 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,51 @@ 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 +# 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; } echo "all webhook-spawn claim assertions passed"