fix(ops-log): a sub-tool must not drop the claim wrapping its caller

deploy-stack.sh claims and releases around its own work. When the agent
already held a longer claim for a multi-step operation, the deploy refreshed
it, then released it on exit -- silently dropping the protection partway
through the very operation it was guarding.

Caught live: a 45-minute claim on nh3-docker/althing-post-office, taken to
cover a build-push-deploy-verify rollout, was gone by the time the rollout
finished. Nothing refused anything, and nothing said so.

`ops-log claim` now exits 10 when the claim was already the caller's, and
leaves the holder file untouched. deploy-stack.sh treats 10 as "not mine to
release". Untouched matters as much as the exit code: a refresh would
overwrite the reason and TTL the original claimant chose, so a deliberate
45-minute "3.7.0 rollout in progress" would degrade into "deploy-stack.sh
<host> <stack>" and whoever got refused would read the wrong story.

Verified in three states: no pre-existing claim -> deploy claims and releases;
caller's own wider claim -> deploy refreshes nothing and leaves it standing
with its original reason; another agent's claim -> still refused with exit 3.
This commit is contained in:
2026-09-19 06:50:17 -07:00
parent 920f37c6c2
commit 3e7d3a3e3d
2 changed files with 26 additions and 1 deletions
+7 -1
View File
@@ -131,7 +131,13 @@ if [ -x "$OPS_LOG" ] && [ "${DEPLOY_NO_CLAIM:-0}" != 1 ]; then
"$OPS_LOG" claim "$HOST" "$STACK" --ttl "${DEPLOY_CLAIM_TTL:-30m}" \
--why "deploy-stack.sh $HOST $STACK" -q || claim_rc=$?
case "$claim_rc" in
0) CLAIMED=1 ;;
0) CLAIMED=1 ;;
10) # We only refreshed a claim this agent already held for a wider
# operation. Do NOT release it on the way out — dropping someone's
# multi-step claim mid-operation is exactly the exposure the claim
# exists to prevent.
CLAIMED=0
echo "note: $HOST/$STACK was already claimed by you — leaving that claim in place." ;;
3) echo "error: refused — see the claim above. Wait for the holder, coordinate" >&2
echo " on althing, or override with DEPLOY_NO_CLAIM=1 if it is dead." >&2
exit 3 ;;
+19
View File
@@ -54,6 +54,8 @@ EXIT CODES
0 success / claim acquired / nothing to report
2 usage error
3 claim refused (held by another agent)
10 claim REFRESHED — you already held it. The caller did NOT acquire it and
must NOT release it on the way out (see deploy-stack.sh).
4 audit found unlogged changes
5 audit could not reach every host (incomplete — NOT the same as clean)
"""
@@ -246,6 +248,17 @@ def cmd_claim(args) -> int:
_drop_claim(args.host, args.target)
record(args.host, "claim-broken", args.target,
outcome="changed" if state == "theirs" else "ok", detail=detail)
if state == "mine":
# Already ours: leave the holder file ALONE. A sub-tool refreshing a
# claim would otherwise overwrite the reason and TTL the original
# claimant chose — so a 45-minute "rollout in progress" becomes
# "deploy-stack.sh <host> <stack>", and whoever gets refused reads the
# wrong story about why. Report 10 and change nothing.
if not args.quiet:
print(f"already claimed by you: {args.host}/{args.target} "
f"({(holder or {}).get('why') or 'no reason given'}, "
f"{holder_age(holder)})")
return 10
d = CLAIMS_DIR / claim_key(args.host, args.target)
try:
d.mkdir(parents=True, exist_ok=False) # atomic: this IS the acquire
@@ -269,6 +282,12 @@ def cmd_claim(args) -> int:
if not args.quiet:
print(f"claimed {args.host}/{args.target} for {args.ttl} "
f"({holder['why'] or 'no reason given'})")
# Exit 10 when this only REFRESHED a claim the caller already held. A tool
# that claims-then-releases around its own work must not drop a longer
# claim wrapping a multi-step operation. Measured 2026-09-19: a 45-minute
# operation claim on nh3-docker/althing-post-office was silently released
# by deploy-stack.sh's exit trap partway through the rollout it was
# protecting.
return 0