Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 86 additions & 22 deletions modules/agent-box.nix

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions modules/src/lib/lease.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
60 changes: 54 additions & 6 deletions modules/src/webhook-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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}" \
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions tests/golden/vm/payloads/agent-box-agent-mark-stopped
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 8 additions & 4 deletions tests/golden/vm/payloads/agent-box-session/bin/agent-box-session
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 8 additions & 4 deletions tests/golden/web/payloads/agent-box-robot-mark-stopped
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading