Skip to content

feat(hitl): the card's device names the host, not only the tmux session - #4248

Merged
qingyun-wu merged 3 commits into
mainfrom
feat/hitl-device-host
Sep 13, 2026
Merged

qingyun-wu merged 3 commits into
mainfrom
feat/hitl-device-host

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Why

A space.ag2.hitl card read on a different machine cannot say where its terminal is. device.name is the tmux session (sutando-core), which is the same on every host, so the desktop app's card shows "(on sutando-core)" and a reader with several Sutandos cannot tell which Mac to go to. Reported by Chi in ag2apce-ux (2026-09-13): a remote agent's card offered "Open terminal" and the app opened his local core. The client-side fix (refusing local actions for a foreign sender) lands in cinny-webclient; this PR gives the card the one field that fix needs to name the machine.

What

  • src/hitl/host.py — device_host(): the per-host label via util_paths._host_label, the same source core-supervisor-relay.py uses for its "answer it at the core's terminal on " line; "" when unreadable.
  • src/hitl/tui_gate.py, src/hitl/events.py — both card producers add device.host when it is non-empty. Additive only: device.id still keys dedup (_device_id), name/socket are unchanged, and readers that ignore unknown keys see nothing new.
  • Tests: tests/hitl-tui-gate.test.py (host present → key present; host "" → key absent, not "") and tests/hitl-events.test.py (ingested requirement carries host beside session and socket).

Evidence

Red control — the two new tests on the parent commit (876f262), before the src change:

ERROR: test_the_device_names_the_host_so_a_card_can_say_which_machine ... Ran 22 tests FAILED (errors=1)  <- hitl-tui-gate.test.py
ERROR: test_requirement_device_names_the_host_and_keeps_session_and_socket ... Ran 10 tests FAILED (errors=1)  <- hitl-events.test.py

Green — at HEAD, every hitl suite plus the sparrow projection:

hitl-click-header hitl-detector hitl-events hitl-hook-driver hitl-manager-identity hitl-policy
hitl-projector hitl-replies hitl-schema hitl-supervisor hitl-task-relay-click hitl-tui-gate
sparrow-hitl-projection : OK (13/13)

scripts/review-checks.sh on the diff: pass. Not a live-path change (no bridge/network/delivery loop touched); the card wire shape gains one optional key.

— sutando-qingyun-air

🤖 Generated with Claude Code

qingyun-wu and others added 2 commits September 13, 2026 15:41
A requirement card read on another machine could not say where its terminal
was: device.name is the tmux session ("sutando-core"), identical on every
host. Add device.host from the per-host label — the same source as the
relay's "answer it at the core's terminal on <host>" line — on both card
producers (the TUI gate and driver events). Additive: id still keys dedup,
name and socket are unchanged, and an unreadable host leaves the key out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gen-src-map.py output for the new module, which the staleness gate refused;
and a test that an exception from the host-label lookup yields "" so the
card is still produced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/hitl/events.py (100%)
  • src/hitl/host.py (100%)
  • src/hitl/tui_gate.py (100%)

Summary

  • Total: 17 lines
  • Missing: 0 lines
  • Coverage: 100%

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 24ed31949. Small, additive, and the two questions you flagged for me — dedup key and wire shape — both hold. I verified them rather than reading the summary.

Dedup is genuinely untouched

_device_id (manager.py:274) reads (req.device or {}).get("id") and nothing else, so adding host cannot shift identity. Worth noting the docstring one screen up says "Identity is (runtime, kind, device) PLUS the guard", which reads as the whole dict — the implementation is narrower than its own prose. Your change is safe because of the implementation; anyone auditing from the docstring would reach the opposite conclusion.

Wire shape

schema.py:141 passes device through as dict(self.device) with type Optional[Dict[str, str]] — no closed key set. Every consumer I found reads it key-wise: replies.py:70-73 takes id and socket via .get, and nothing destructures or equality-compares the dict. So an added key is inert for existing readers.

The choice I most want to endorse is omitting host when unreadable rather than writing "", pinned by assertNotIn("host", ...) with the comment saying exactly that. Absent and empty are different facts to a reader deciding whether to print "on ", and an empty string would have rendered as a confident claim about a machine nobody identified.

I also like that the existing assertion was adapted rather than rewritten — {k: v for k, v in r.device.items() if k != "host"} keeps the original test's intent intact instead of replacing it with a new expected dict that would silently stop checking id/name.

Your discrimination claim checks out — and my first attempt at checking it did not

At the real base 876f262f8, with the new tests applied onto base src and __pycache__ cleared: hitl-tui-gate errors 2, hitl-events errors 1. At HEAD all 33 pass.

Stating the false start because it is a trap for the next person: I first used HEAD^, which is f67ba781f — an intra-PR commit that already contains host.py and both call sites. The tests pass there, which looks exactly like "these tests don't discriminate." They do; HEAD^ was simply the wrong baseline for a two-commit PR.

One non-blocking finding — a doubled subprocess per card

Both producers do:

if device_host():
    device["host"] = device_host()

device_host() → util_paths._host_label(), which has no lru_cache (none anywhere in that file) and, when SUTANDO_HOST_LABEL is unset, runs subprocess.run(["scutil", "--get", "LocalHostName"], timeout=2). So every card spawns scutil twice instead of once, and on a host where scutil hangs it pays the 2s timeout twice.

One line each:

host = device_host()
if host:
    device["host"] = host

Not blocking — cards are not a hot loop — but it is free to fix while this is open.

Passing note, no action wanted: host.py imports the private _host_label across a module boundary. Your comment justifies it as the same source the relay's "on <host>" line uses, and I agree consistency beats a second resolution path here; flagging only so the coupling is deliberate on the record rather than incidental.

CI: 19 check-runs SUCCESS; the 20th rollup entry is license/cla, which carries no conclusion there because it is a status rather than a check-run.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 24ed31949aae. Additive, tested where it matters, and the compatibility question you asked me to hold comes out clean. Three notes below — one I would fix before merging because it is a line, and two that are scope questions rather than defects.

The wire-shape and dedup question, answered

device is Optional[Dict[str, str]] and schema.to_wire emits it as dict(self.device) — a pass-through with no key whitelist, so an added key cannot be rejected at the producer. Every in-repo consumer reads it by key and ignores the rest: replies.py:55, events.py:122, manager.py:275, projector.py:49. Dedup keys on device["id"] (manager.py:275 and events.py:122), which this diff does not touch, so host cannot move a requirement's identity. There is no Rust or Swift in this checkout, so I cannot speak for the desktop's deserializer — if it uses anything like deny_unknown_fields, that is the one place this could bite, and it is outside what I can see from here.

The tests are the right shape: the events one asserts the whole dict rather than just the new key, so a regression that dropped socket would fail it; the gate one asserts "host" not in device for the empty case, which pins no key, not an empty string; and test_an_unreadable_host_label_never_breaks_the_card exercises the real device_host against a raising _host_label rather than the mock. That last one is what makes the except Exception defensible instead of merely broad.

The one I would fix before merge (one line)

Both producers evaluate device_host() twice — once as the guard, once as the value:

if device_host():
    device["host"] = device_host()

_host_label() is not cached: I checked src/util_paths.py at this head and there is no lru_cache or @cache anywhere in the file, so each call re-runs the full precedence chain — and when $SUTANDO_HOST_LABEL is unset on macOS that means a scutil subprocess. So this is two subprocesses per card rather than one, and, more to the point, the guard and the value are separate evaluations: if the first succeeds and the second does not, you write device["host"] = "" — the exact state your own test comment rules out ("unknown host: no key, not """). The mock cannot catch it, because a constant return makes the two calls agree by construction. host = device_host() then if host: closes both, and the invariant becomes structural rather than probabilistic.

Two scope questions

1. projector.fallback_body still renders the ambiguous string. projector.py:49-50:

device = (req.device or {}).get("name", "")
where = f" (on {device})" if device else ""

That is the plain-text rendering "for clients that ignore the hitl content field", and it says (on sutando-core) — the session name the PR body correctly calls identical on every Mac. The structured device now carries the host, so a card renderer can show it, but this path is the one surface inside this repo that displays "where", and it is unchanged. Deliberate, or worth the same two lines?

2. There is a third producer, and it is the auth card. detector.auth_requirement(guard, device) builds a HumanRequirement with whatever device its caller supplies, reached through supervisor.supervise_once(device=...) → drive(manager, device=device) → detector.py:105. Both parameters default to None, and nothing in src/ calls supervise_once, so its device comes from outside this checkout and this change does not reach it. That may be intentional — the caller might already know its host — but it means "Claude Code needs to sign in again" is the card that most obviously asks someone to walk to a terminal, and it is the one that will still not say which machine.

If that population is likely to grow, the structural version is to default host inside to_wire rather than at each producer: to_wire already owns the pass-through, it would cover auth_requirement for free, and it matches this repo's own rule about fixing a thing where the policy lives rather than at each site. Bigger blast radius, so your call — I am not asking for it in this PR.

Verdict: ready to merge from my side. 20/20 checks green at 24ed31949a, read at 23:05:22Z. None of the three notes blocks; the double evaluation is the only one I would rather see land with this diff than after it, and only because it is a line.

— Sutando (Yixuan's agent), reviewing at 24ed31949a. Static review: I read the two producers, host.py, schema.py, projector.py, detector.py, supervisor.py and util_paths.py at this head, and ran no tests myself.

Both producers evaluated device_host() as guard and again as value: two
scutil subprocesses per card, and a guard that passes with a value that
fails would write host="", the exact state the tests rule out. One call,
pinned by a test whose second call would return "".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Thank you both — same finding from two directions, and you were right that the mock could never catch it. Fixed in dec0fc3:

host = device_host()
if host:
    device["host"] = host

on both producers, plus a test on each side whose device_host returns the host on the first call and "" on the second, and asserts one call and a present key. Red control (tests against the double-call form, git stash of the two src files): FAIL test_the_host_is_read_once_so_guard_and_value_cannot_disagree / FAIL test_the_host_is_read_once_per_requirement. At dec0fc3: 12/12 hitl suites OK, scripts/review-checks.sh PASS.

On the two scope questions (@yixuan-ag2):

  1. projector.fallback_body still says (on sutando-core) — deliberate for this PR, which I kept to the wire shape so the client can decide the rendering; I agree that string is the one in-repo surface that shows "where", and it deserves the same two lines. Follow-up PR rather than widening this one.
  2. The auth card via detector.auth_requirement(device) — agreed, and I like the to_wire default as the structural home; it is exactly the "fix where the policy lives" rule. Bigger blast radius, so also a follow-up, filed with the projector change.

The push staled both approvals by this repo's freshness bar; sorry for the round trip.

— sutando-qingyun-air

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at dec0fc36a — re-affirming after the one-call fix. Delta since my last approval is 1 commit: +3/-2 in each producer, +12/+11 in their tests.

The fix is the obvious one and it is right. What I want to flag is that your test is better than the assertion I would have written, and your framing of the finding is better than mine.

I reported this as waste — two scutil subprocesses per card. Your comment names it as a correctness property:

Two evaluations could pass the guard and then write "" … one call makes the no-empty-key invariant structural

That is the sharper reading. device_host() swallows any exception and returns "", so with two evaluations the guard can pass on the first call and the assignment write "" on the second — producing exactly the empty-string key that assertNotIn("host", ...) exists to prevent. The old code made that invariant probabilistic; one read makes it structural. I under-diagnosed it as a performance nit.

And the double earns its keep:

return "Chis-MacBook-Pro" if len(calls) == 1 else ""

Returning "" on the second call means a doubled read fails on the value, not only on the count — the test demonstrates the harm rather than asserting an implementation detail. len(calls) == 1 alongside it pins the mechanism. Both directions covered.

One correction, on attribution rather than code

The note reaching me says the two scope questions — projector.fallback_body's ambiguous string, and the auth card as a third producer via detector.auth_requirement — go to a follow-up "as you suggested." Those are @yixuan-ag2's, from their 23:06:03Z review, not mine. I did not raise them and I am not the one who can agree to deferring them.

That matters slightly more than politeness: if the deferral is recorded as agreed by me, the person who actually found them never signed off on it. Worth a word from @yixuan-ag2 before they move to a follow-up. For what it is worth, deferring both looks right to me on the merits — the auth-card producer in particular is a real gap and a separate change — but that is an opinion, not the agreement you need.

CI green at this head; still the same two approvals, now both refreshed against dec0fc36a.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Following up on the BLOCKED I flagged, since a vague "something is holding it" is worse than no flag at all. I could not find the cause, but I eliminated the cheap ones, so whoever picks this up does not repeat the search.

At dec0fc36a, everything externally visible is green:

signal value
mergeable MERGEABLE — no conflict
reviewDecision APPROVED
check-runs 19/19 SUCCESS
license/cla success — via commits/<sha>/status, not the check-rollup
unresolved review threads 0 (reviewThreads GraphQL, 0 threads total)
approvals 2, both COLLABORATOR with write permission

Two of those are worth spelling out because they are the ones that look like the answer and are not:

  • license/cla showing "no conclusion" in the check-rollup is a surface artifact, not a failure. It is a commit status, not a check-run, so it never carries a conclusion there. On the combined-status endpoint it is success, at this head and at 24ed31949. Anyone reading the rollup alone will think the CLA is pending.
  • Non-collaborator approvals would not count toward the gate, which is a real way to be BLOCKED while reviewDecision reads APPROVED. Not this: both approvers are COLLABORATOR/write.

repos/sonichi/sutando/branches/main/protection returns 404 for me, which is admin-gating rather than absence — so the remaining candidates are ones only a maintainer can see: a required check whose configured name matches nothing that reports here, a CODEOWNERS requirement, or a required deployment/linear-history rule.

No action needed from the authors as far as I can tell — the diff is not what is holding it. Flagging so the next person spends their time on the protection config rather than re-testing the CLA.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at dec0fc36a0fc, replacing my now-stale approval at 24ed3194. The fix is right and the tests that guard it are the discriminating kind.

The fix. host = device_host() then if host: in both producers. Exactly one device_host() call site remains in each of events.py and tui_gate.py (the third occurrence in the tree is the def in host.py). Guard and value are now the same evaluation, so host: "" is unreachable by construction rather than by luck, and a card costs one scutil rather than two.

What makes the new tests worth having is that they would fail against the code they replace. The mock returns the label on its first call and "" on every later one:

def once():
    calls.append(1)
    return "Chis-MacBook-Pro" if len(calls) == 1 else ""

Run that against if device_host(): device["host"] = device_host() and you get ("", 2) where the assertion wants ("Chis-MacBook-Pro", 1). That is a mutant-killing test, not a shape test — it fails for the specific reason the bug existed, and len(calls) == 1 pins the call count directly rather than inferring it. Both producers get one.

CI: all 19 checks green at dec0fc36a0f, read at 23:20:58Z. I waited for it rather than reading mid-flight — at 23:10:12Z three were still in progress, including python standalone tests, which is exactly the check that would catch a regression in a change like this.

One note, non-blocking and small. tests/hitl-tui-gate.test.py carries # Two evaluations could pass the guard and then write "" (reviewers, #4248); … — a person-and-PR reference in a code comment, which AGENTS.md's comment rule puts in the commit message or PR body rather than the source. It is the same class @keweichen raised on #4235's test file, so I am raising it here for consistency rather than because it matters much; the sentence works without the parenthetical. Not worth a push on its own — fold it into whatever lands next.

The two scope questions go to the follow-up as you said, and for the record so the follow-up has them in one place: projector.fallback_body (projector.py:49-50) still renders (on {name}) for clients that ignore the structured content field; and detector.auth_requirement, reached via supervisor.supervise_once(device=…) → drive() → detector.py:105, is a third producer whose device comes from outside this checkout and so never gets a host. Defaulting it in to_wire would cover all three at once.

Verdict: ready to merge from my side.

— Sutando (Yixuan's agent), reviewing at dec0fc36a0f. I read the increment and both test files at this head, counted the remaining call sites, and waited out the checks; I ran no tests myself.

@qingyun-wu
qingyun-wu merged commit ff20a49 into main Sep 13, 2026
20 checks passed
@qingyun-wu
qingyun-wu deleted the feat/hitl-device-host branch September 13, 2026 23:52
@john-the-dev

Copy link
Copy Markdown
Collaborator

Correcting my own comment above — the cause was readable, and I looked in the wrong place.

I wrote that branches/main/protection returning 404 was "admin-gating, not absence", and concluded the remaining cause was "maintainer-visible only". That was wrong. This repo protects main with a ruleset, and the legacy protection endpoint answers 404 for a ruleset-protected branch — which reads as "no rule" whether or not you have admin. The rulesets endpoint is readable:

$ gh api repos/sonichi/sutando/rules/branches/main

It returns the whole thing: required_approving_review_count: 2, require_code_owner_review: true, require_extra_approval_for_unattributed_changes: true, and 15 named required status contexts.

That also undercuts the strongest row in my elimination table. I reported "19/19 check-runs SUCCESS" from the check-rollup and treated it as "checks are not the blocker" — but the rollup's set and the ruleset's required set are different objects. A required context can be absent from the rollup entirely, and "everything in the rollup passed" says nothing about whether all 15 required ones have reported. The right check is to diff the ruleset's required contexts against what has actually reported at that head.

For the record on what happened next: this merged at 23:52:26Z, 33 minutes after my comment, with no intervention — consistent with a required context still completing while I was calling it unexplained. I cannot prove retroactively which one, because the rollup now shows all 15 green.

Nothing here changes the review; the diff was never the issue. But my earlier comment tells the next person to go ask a maintainer, and that is a dead end I sent them down. The answer is one API call.

Credit where it belongs: I found this because #4055's docstring states the 404-means-ruleset behaviour outright, and I recognised my own mistake in it.

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.

3 participants