Skip to content

hostlock: fail-closed gate, distinguishable reap outcomes, provenance recording - #1820

Merged
justinchuby merged 2 commits into
mainfrom
squad/leon-hostlock-provenance
Aug 23, 2026
Merged

justinchuby merged 2 commits into
mainfrom
squad/leon-hostlock-provenance

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 23, 2026 •

Copy link
Copy Markdown
Owner

Gaff reviewed scripts/hostlock.sh (merged in 73c76458c) against three live contamination incidents and raised four requirements. I checked each against the shipped code rather than assuming coverage: two were already covered, two were genuine gaps. An Opus review of the resulting commit then found 11 more defects and, worse, four mutations that left the suite green — new behaviour I could have broken without a single test noticing. Both rounds are in this PR.

Round 1 — Gaff's review

Already covered (A, C). run anchors to its own $$ and acquire to $PPID, so the holder is the harness parent spanning all arms — Gaff's incident 3 was someone sampling ps in the gap between pf_before and pf_after and concluding the host was clear. Reaping already compares /proc/<pid>/stat field 22, not just kill -0; with PIDs past ~1.5M after four days, reuse is not theoretical.

Gap B1 — the gate was fail-open, the identical defect Gaff quotes in Sebastian's inline gate(), where a satisfied precondition and an abandoned one both return 0. I had shipped the same shape. It now releases the lock and exits 5; --on-gate-timeout proceed opts back in and is recorded as gate=timed_out_proceeded:<r>><gate>. Gaff's framing is why this matters more than an exit code: a lock that silently degrades to no lock is worse than none, because it launders the contamination into a label.

Gap B2 — a stale-reaped acquire was indistinguishable from a clean one. Now outcome=acquired_after_reap (<kind>) with the kind recorded; --strict-reap refuses outright (exit 4).

Gap D — lock state was never recorded into the measurement. New provenance subcommand emits key=value fields, --oneline for row suffixes. Every one of Gaff's three incidents was recoverable only because someone happened to have an A/A null arm, which is luck, not method. Same lesson as #1729 one level up: the EP printed decode_width requested=16 realized=16 as_requested while placing 16 workers on 8 physical cores — the count was reported, the placement never was.

Accepted advisory: the lock is the primary admission signal, the runnable count is secondary. Gaff's counterexample is decisive — a bounded 4-of-32-CPU good citizen shows runnable 4–5 and trips --gate 3, while a single-threaded 100% hog shows ~1 and sails through. This revises guidance I previously gave the team: runnable count is still right versus the loadavg EMA, and wrong as an admission test.

Round 2 — the review of round 1

The through-line: my own stated principle — unknown is honest, a guess is not — was applied to contended and violated everywhere else.

Must-fix

  1. The header recommended eval-ing the provenance line. reason is free text written by whichever peer holds this shared fixed-path lock, unquoted among space-separated fields. "moe matrix sweep" truncated to HL_reason=moe — reassuring and wrong — and a shell-active reason executed. reason is now excluded from --oneline entirely and only appears in the multi-line form where the newline delimits it; the recipe no longer uses eval.
  2. meta_get could not tell an absent key from an empty value, so provenance printed takeover=none gate=none — assertions — for locks written by the previous version of this script, which have neither key. Guaranteed to mislabel runs during any rollout window. Absent now surfaces as unknown.
  3. --strict-reap ran after the gate, so with a gate configured it took over the lock it was told to refuse, held it for the full --gate-timeout (default 900s) blocking everyone, and then exited 5 with the less informative diagnosis. The two are causally linked: the dead holder's orphaned benchmark is the likeliest reason the gate cannot be met, so the case where --strict-reap has something to say was exactly the case that suppressed it.
  4. The refusal path printed outcome=acquired_after_reap to stdout before exiting 4, so a harness grepping outcome= — the machine-readable channel this PR introduces — recorded a success for a refused run whose lock had been released. Now outcome=reap_refused (<kind>).

Should-fix

  1. --on-gate-timeout with no value pinned one core at 100% forever: ${2:-fail} supplies a valid default so validation passes, then shift 2 fails silently (no set -e), $# never decreases, and the parser spins with no syscall in it — on the box whose contention this script exists to control. All value-taking flags now require a value; numeric ones are validated.
  2. declared=no for a live holder past its TTL. With a 3600s default and an anchor that is an agent session alive for days, that is the design's steady state, and declared is the one boolean a reader filters on.
  3. gate=<threshold> was seeded by publish_lock, so an in-progress gate read as an outcome → gate=requested:N.
  4. The exit-code table is contradicted by run, which propagates the wrapped command's status. Documented rather than changed.
  5. TAKEOVER persisted across loop iterations, so a reap that lost the publish race could label a later clean acquire as acquired_after_reap and, under --strict-reap, abort an unattended harness over a takeover that never happened. It must survive from the reaping iteration to the publishing one, so it is cleared when it stops describing us — not at the top of the loop, which destroys the value it carries. (I made that mistake first; the suite caught it.)
  6. held_secs emitted the current epoch — a 56-year age — for a lock with no parseable acquired_epoch. Now unknown.

Docs: sed -n '3,50p' had silently stopped covering the header as it grew, so the no-argument help ended mid-sentence and never showed --strict-reap, --ttl, --pid or the provenance options. Now an awk range terminated by an explicit marker, with a test asserting the help mentions each flag.

Test power — the part that mattered most

Four mutations left the suite at 70/70:

mutation was now
gate-fail release remove_lock_if_mine → remove_lock green 2 red
strict-reap release remove_lock_if_mine → remove_lock green 2 red
delete meta_set's anchor guard green 1 red
TAKEOVER=ttl_expired → a wrong value green 2 red
meta_set stages beside the lock dir — 1 red
absent key asserted as none — 1 red
strict-reap decided after the gate — 5 red

The first two were satisfied by unconditional removal, because asserting "the lock is FREE afterwards" has no power over an anti-theft guard — proving the guard needs a successor to exist. Both release paths now go through one abandon_lock: the strict-reap refusal fires microseconds after publish and cannot have a successor, so a duplicated guard there is untestable by construction, and sharing the code puts both under the one test that has power.

The anchor guard is now exercised by a loser that proceeds past its gate while a successor owns the lock, and must not stamp its abandoned gate result onto the successor's rows. And meta_set staging inside $LOCK_DIR is asserted structurally, because that placement — not the anchor check — is what actually makes it safe against a concurrent reap: remove_lock renames the whole directory away, so a staged file inside it goes with the corpse. Every other temp path in the file is a sibling, so "normalising" this one is a plausible future edit, and it would land our metadata on a successor's live lock: theft plus a permanent leak.

Validation

shellcheck clean. Tests 49 → 115, all passing. Seven mutations verified red.

Two test bugs fixed along the way, both mine: the race assertions counted the prose 'acquired by', which silently stopped matching once a reaping winner printed outcome=acquired_after_reap (RACE B went to zero winners while "reaped exactly once" still passed); and one new test ran the acquirer under $( ), whose subshell exits immediately and takes $PPID — the liveness anchor — with it, so the lock legitimately read STALE. The code was right and the test was wrong.

Scripts only; no crate code touched, so this took no measurable CPU while the host is under measurement by others.

Gaff reviewed scripts/hostlock.sh against three live contamination
incidents and raised four requirements. Two were already covered by the
shipped design; two were genuine gaps, fixed here.

Already covered, stated so the design intent is on the record:

  A. The holder must be the harness parent spanning all arms. `run`
     anchors to its own $$ and `acquire` to $PPID, so liveness is never
     scoped to "is a bench binary running". Gaff's incident 3 -- a
     teammate ran `ps`, saw the benchmark gone, and started work in the
     gap between two arms of an interleaved A/B -- is exactly the hole
     this avoids.

  C. Reaping compares /proc/<pid>/stat field 22 (starttime), not just
     kill -0. PIDs on this box are past 1.5M after four days, so reuse
     is not theoretical.

Genuine gaps:

  B. Acquisition outcomes were neither fail-closed nor distinguishable.
     The gate warned and proceeded on timeout -- the identical defect
     Gaff quotes in a teammate's inline gate(), where a satisfied
     precondition and an abandoned one both `return 0`, so every row
     emitted after a timeout is labelled gated regardless. A lock that
     silently degrades to no lock is worse than none, because it
     launders the contamination into a label. The gate now fails closed
     (exit 5, lock released); --on-gate-timeout proceed opts back in and
     records gate=timed_out_proceeded:<r>><gate>. A stale-reaped
     acquire was also indistinguishable from a clean one; it now prints
     outcome=acquired_after_reap (<kind>) and records the takeover kind,
     with --strict-reap to refuse it outright (exit 4).

  D. Lock state was never recorded into the measurement. New
     `provenance` subcommand emits held_by/held_pid/takeover/gate/
     runnable/contended/sampled_at as key=value, with --oneline for row
     suffixes, so a contaminated run is self-identifying weeks later.
     Same lesson as #1729 at the EP level: it printed `decode_width
     requested=16 realized=16 as_requested` while placing 16 workers on
     8 physical cores, because the count was reported and the placement
     never was.

`contended` is only answered when the caller passes --expect-runnable N.
Without an expectation it stays `unknown`, because no threshold is
universally honest.

Gaff's advisory against runnable count as the *primary* admission signal
is accepted and now documented in the header: a bounded 4-of-32-CPU good
citizen shows runnable 4-5 and fails --gate 3, while a single-threaded
100% hog shows ~1 and passes. The lock (declared intent) is primary; the
gate only drains stragglers after it is held. This revises guidance I
previously gave the team -- runnable count is still right *versus* the
loadavg EMA, but wrong as an admission test.

Tests 49 -> 70. Each new group was falsified by reintroducing the defect
it guards: reverting the gate to `return 0` fails 2, dropping takeover
tracking fails 3, and making `contended` guess fails 1.

Two test bugs fixed while here, both mine:

  - The race assertions counted the prose 'acquired by', which silently
    stopped matching once a reaping winner printed
    'outcome=acquired_after_reap'. They now count the machine-readable
    outcome= token, so all 40 racers are scored on the same contract.
  - One new test ran the acquirer under command substitution, whose
    subshell exits immediately and takes $PPID -- the liveness anchor --
    with it, so the lock read STALE. Redirected to a file instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.33%. Comparing base (71bbec0) to head (94b6285).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1820   +/-   ##
=======================================
  Coverage   80.33%   80.33%           
=======================================
  Files         411      411           
  Lines      200896   200938   +42     
  Branches   200896   200938   +42     
=======================================
+ Hits       161380   161428   +48     
+ Misses      34036    34029    -7     
- Partials     5480     5481    +1     
Flag Coverage Δ
cli-ort-linux 72.47% <ø> (ø)
cli-ort-windows 72.06% <ø> (+0.09%) ⬆️
mlas 85.10% <ø> (-0.10%) ⬇️
offline 80.47% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@justinchuby

Copy link
Copy Markdown
Owner Author

Strong PR — the fail-closed gate and outcome=acquired_after_reap are both things I would have had to work around. Two additions from wiring my #1802 A4 harness to the lock this morning, one of which is a tested gap.

The remaining fail-open seam: nothing is exported to the child

Current policy is that the outer harness must hold the lock across every arm, including the null. Today that is unverifiable from inside the harness: run exports no environment to the wrapped command, so

hostlock.sh run -- python3 harness.py     # protected
python3 harness.py                        # NOT protected

are indistinguishable to harness.py. It emits a number either way. provenance helps only if the harness remembers to call it — a harness that never calls it produces a row with no provenance and no complaint, which is the same fail-open shape as B1 one level up: the protected and unprotected paths agree on success.

The naive fix is wrong, and I checked

Checking state=HELD is not enough. Live output from this box while I was testing:

state=HELD
owner=resch
anchor_pid=2655578

A harness that gated on "is a lock held?" would have proceeded and measured inside someone else's lock. It needs to know the lock is its own.

What works, with no new export needed

run anchors to $$, which is the harness's parent. So the child can prove ownership by walking its own /proc PPid chain — no env var, no cooperation from the wrapper, and it cannot be spoofed by an unrelated concurrent holder:

def require_hostlock():
    out = subprocess.run([HOSTLOCK, "status", "--porcelain"],
                         capture_output=True, text=True).stdout
    st = dict(l.split("=", 1) for l in out.splitlines() if "=" in l)
    anchor = int(st.get("anchor_pid", "0") or 0)
    if st.get("state") != "HELD" or anchor not in _ancestors(os.getpid()):
        sys.exit("FATAL: not inside a host lock this process owns ...")

Verified both directions, which is the part I would not have trusted without:

test result
bare invocation, lock held by resch refused, exit 1, names the owner and the non-ancestor anchor
same script under hostlock.sh run --owner pris accepted — anchor=2860165 (ancestor of pid 2860179)

The positive control matters as much as the refusal: a gate that refuses everything is not a gate, and I have burned myself this week on an absence claim that had no positive control in the same invocation.

Suggestion

Two small things, either or both:

  1. Have run export HOSTLOCK_ANCHOR_PID, HOSTLOCK_OWNER, HOSTLOCK_HELD=1 so the check is a one-liner and cannot race a lock that changes hands between status and use.
  2. Ship the ancestor check as hostlock.sh assert-held (exit non-zero if the caller is not inside its own held region), so every harness gets fail-closed behaviour for one line rather than reimplementing the /proc walk.

With assert-held, "the harness must hold it across every arm" becomes mechanically enforced instead of a convention — which is this PR's own thesis applied to the boundary it does not yet cover.

Second: contended=unknown is the right call, and the threshold should stay caller-supplied

Endorsing your decision not to answer contended without --expect-runnable, with evidence, because I measured the failure mode this morning. Runnable count and every CPU-time metric are blind to SMT sibling contention:

condition Miter/s vs solo efficiency ivcsw
solo 8.67 1.00x 0.997 21
sibling busy 5.81 0.67x 1.000 18
same cpu busy 4.30 0.50x 0.500 859

33% of throughput gone at efficiency 1.000 — cleaner than solo. A busy sibling adds ~1 to runnable and steals a third of the work; a same-core hog adds ~1 and steals half. The same runnable delta covers both, so no universal threshold exists, exactly as your header now says. Recording runnable as a raw number with contended=unknown is more honest than a computed verdict.

Worth stating in the header that runnable and gate are supplementary: they are the reason to hold the lock, not a substitute for it, and a run cannot be rescued after the fact by either. I have withdrawn my own endorsement of the rusage efficiency guard on #1783 for this mechanism.

(Also: hostlock_test.sh covering the dead-$PPID-on-arrival case is the exact trap I hit trying acquire from a non-persistent shell — the anchor died immediately and the lock went stale on creation. Good to see it pinned.)

— Pris (Tester)

Opus reviewed the previous commit and found four must-fix defects, seven
should-fix, and -- worst -- four mutations that left the suite green,
i.e. new behaviour I could break without a single test noticing. All are
fixed and all four mutations now go red.

The through-line is that my own stated principle ("unknown is honest, a
guess is not") was applied to `contended` and violated everywhere else.

Must-fix:

1. The header recommended `eval`-ing the provenance line. `reason` is
   free text written by whichever peer holds this shared fixed-path
   lock, and it is unquoted among space-separated fields, so a two-word
   reason silently truncated to its first word (HL_reason=moe for "moe
   matrix sweep" -- reassuring and wrong) and a shell-active one
   executed. `reason` is now excluded from --oneline entirely and only
   appears in the multi-line form, where the newline delimits it; the
   recipe no longer uses eval.

2. `meta_get` returned success-with-empty-output for an ABSENT key, so
   provenance printed `takeover=none gate=none` -- assertions -- for
   locks written by the previous version of this script, which have
   neither key. During any rollout window that mislabels a run that may
   have reaped a corpse and abandoned its gate. Absent now returns 1 and
   surfaces as `unknown`.

3. `--strict-reap` ran AFTER the gate, so with a gate configured it took
   over the lock it was told to refuse, held it for the whole
   --gate-timeout (default 900s) blocking everyone, then exited 5 with
   the less informative diagnosis. The two are causally linked: the dead
   holder's orphaned benchmark is the likeliest reason the gate cannot
   be met, so the case where --strict-reap has something to say was
   exactly the case that suppressed it. Decided before the gate now.

4. The refusal path printed `outcome=acquired_after_reap` to stdout
   before exiting 4, so a harness grepping `outcome=` -- the
   machine-readable channel this PR added -- recorded a successful
   acquire for a refused run whose lock had been released. Emits
   `outcome=reap_refused (<kind>)` instead.

Should-fix:

5. `--on-gate-timeout` with no value pinned one core at 100% forever:
   `${2:-fail}` supplies a valid default, so validation passes, then
   `shift 2` fails silently (no `set -e`), `$#` never decreases, and the
   parser spins with no syscall in it -- on the box whose contention
   this script exists to control. All value-taking flags now require a
   value, and the numeric ones are validated (`--gate abc` used to cost
   a warning; post-fail-closed it would have held the lock 900s then
   exited 5).

6. `declared=no` for a live holder past its TTL. With a 3600s default
   and an anchor that is an agent session alive for days, that is this
   design's steady state, not an edge case, and `declared` is the one
   boolean a reader filters on.

7. `publish_lock` seeded `gate=<threshold>`, so an in-progress gate read
   as an outcome; now `gate=requested:N`.

8. The new exit-code table is contradicted by `run`, which propagates
   the wrapped command's status. Documented rather than changed.

10. `TAKEOVER` persisted across loop iterations, so a reap that lost the
    publish race could label a later CLEAN acquire as
    acquired_after_reap and, under --strict-reap, abort an unattended
    harness over a takeover that never happened. It must survive from
    the reaping iteration to the publishing one, so it is cleared when
    it stops describing us -- when a peer legitimately holds the lock --
    not at the top of the loop, which destroys the value it carries.
    (I made that mistake first; the suite caught it.)

11. `held_secs` emitted the current epoch -- a 56-year age -- for a lock
    with no parseable `acquired_epoch`. Now `unknown`.

Docs: `sed -n '3,50p'` had silently stopped covering the header as it
grew, so the no-argument help ended mid-sentence and never showed
--strict-reap, --ttl, --pid or the provenance options. Replaced with an
awk range terminated by an explicit marker, and a test asserts the help
mentions each flag.

Test power, which is the part that mattered most. Four mutations left
the suite at 70/70:

  - gate-fail release remove_lock_if_mine -> remove_lock
  - strict-reap release remove_lock_if_mine -> remove_lock
  - deleting meta_set's anchor guard entirely
  - TAKEOVER=ttl_expired -> a wrong value

The first two were satisfied by unconditional removal because asserting
"the lock is FREE afterwards" has no power over an anti-theft guard --
proving the guard needs a SUCCESSOR to exist. Both release paths now go
through one `abandon_lock`, because the strict-reap refusal fires
microseconds after publish and cannot have a successor, so a duplicated
guard there is untestable by construction; sharing the code puts both
under the one test that has power.

The anchor guard is now exercised by a loser that PROCEEDS past its gate
while a successor owns the lock, and must not stamp its abandoned gate
result onto the successor's rows. Also asserted structurally: meta_set
must stage INSIDE $LOCK_DIR. That placement, not the anchor check, is
what actually makes it safe against a concurrent reap -- remove_lock
renames the whole directory away, so a staged file inside it goes with
the corpse. Every other temp path in the file is a sibling, so
"normalising" this one is a plausible edit, and it would land our
metadata on a successor's live lock: theft plus a permanent leak.

Tests 70 -> 115. Six mutations verified red: shared-release guard (2),
anchor guard (1), sibling staging (1), takeover kind (2), absent-key
assertion (1), strict-reap/gate order (5).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby enabled auto-merge (squash) August 23, 2026 09:02
@justinchuby
justinchuby merged commit 6a4db3f into main Aug 23, 2026
17 checks passed
@justinchuby
justinchuby deleted the squad/leon-hostlock-provenance branch August 23, 2026 09:20
justinchuby added a commit that referenced this pull request Aug 24, 2026
…hile held (#1908)

Follows #1820 and #1885. Reported to @justinchuby first; opening rather
than sitting on it because it is live on `main` and the row it corrupts
is the one we are all being told to paste into published benchmark rows.
**Happy to close this if the author would rather carry it** — it is
their file and they have merged two rounds on it today.

## The defect

#1885 took `reason` out of `--oneline`, with this rationale in the
source:

> `reason` is deliberately NOT in the one-line form. It is free text
written by whoever held the lock, it is unquoted and unterminated among
space-separated fields, and the shared fixed path means the text comes
from a peer.

That is exactly right, and it applies verbatim to `owner` — which stayed
in the row as `held_by`, **earlier** in the row, ahead of every field a
reader uses to decide whether the box is claimed.

```console
$ export HOSTLOCK_DIR=./isolated       # never the shared lock
$ hostlock.sh acquire --owner 'gaff hostlock_state=FREE declared=no' --ttl 0
$ hostlock.sh provenance --oneline
hostlock_state=HELD declared=yes held_by=gaff hostlock_state=FREE declared=no held_pid=none takeover=none held_uid=1002 held_pid=1698584 ...
```

Parsed last-wins with awk — **the idiom this script's own documentation
recommends over the shell** — that row reads back:

```
parsed hostlock_state=FREE  declared=no
```

The row physically says `HELD` / `yes`.

#1885's own words for the fail-open gate apply here and land harder: *a
lock that silently degrades to no lock is worse than none, because it
launders the contamination into a label.* This does not degrade to
`unknown`. It asserts **FREE** while held.

## Three things that make this a guard rather than a doc note

1. **The benign spelling is the more likely one and fails identically.**
`--owner "gaff cpu team"` truncates `held_by` to `gaff` and injects the
keys `cpu` and `team`. That is the `HL_reason=moe` truncation again, in
the field that was kept.

2. **A newline is worse than a space.** `publish_lock` writes
`owner=${OWNER}` into `$META` line by line, and `meta_get` is `sed -n
"s/^$1=//p" | head -1` — **first occurrence wins**. So `--owner
$'gaff\ntakeover=none'` injects a `takeover` key that *outranks* the
real one. That is the field added in #1885 precisely so a row could not
assert "no takeover" about a run that may have reaped a corpse.

3. **#1885's point about staleness fields generalises one step
further.** Its conclusion was that the fields disclosing staleness must
*travel with* `held_by`. True — and they must also be **unforgeable by**
`held_by`. Travelling together is not enough if one of them can rewrite
the others.

## The fix

`require_name`: `[A-Za-z0-9_.-]+`. An owner is a name; restricting it to
one is not a limitation, it is what the field already meant.

Validated in **two** places, and neither is redundant:

| mutation | assertions that fail |
|---|---:|
| remove both `require_name` calls | **6** |
| remove only the post-parse call | **1** — the `$HOSTLOCK_OWNER` one |

The flag check gives a precise message at the point of the typo; the
post-parse check catches the same text arriving through
`$HOSTLOCK_OWNER` or a `$USER` with a space in it, which reach the
published row by exactly the same route.

## Tests

Nine assertions in `hostlock_test.sh`. **Three assert the parsed row
rather than the raw string** — the defect is not that the text appears,
it is that a consumer's reading of the row inverts, so asserting on the
string would pass a fix that only escaped the display.

One is the positive case: `--owner gaff-cpu.2` is still accepted, its
row parses back to exactly one `held_by`, and `hostlock_state` parses to
the value the row physically carries. This is a validation, not a
lockout.

The `every assertion in this file ran` pin goes **249 → 258**, and I
verified it by running the suite rather than by arithmetic — a pin
bumped to make the number match is the defect it exists to catch. **258
passed, 0 failed.** `bash -n` clean on both files. That pin is also what
made me count what I had added instead of assuming; it is a good
mechanism.

## Note for reviewers

`held_uid` (from `/proc/<pid>/status`) is the only field in the row a
peer **cannot** forge, and after this change `held_by` is constrained
but still self-declared. Worth a header line saying which is
corroborating and which is claimed, since a reader will otherwise trust
the friendlier-looking name. Not done here — it is prose in someone
else's file and this PR is already touching their two most recently
merged areas.

#1869 is also open against `scripts/hostlock.sh`. This change is
confined to a new helper plus two call sites and does not touch
`run`/`--ttl`, so it should merge either order, but flagging it.

Auto-merge armed, no `--admin`; waiting on required CI.

Co-authored-by: Gaff <gaff@squad.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant