Skip to content

feat(health): /health names the instance and sessions; the bridge pushes health and names its host stably [HealthStatus] - #4944

Merged
marklysze merged 12 commits into
sonichi:mainfrom
marklysze:feat/health-instance-bridge-push
Oct 2, 2026
Merged

marklysze merged 12 commits into
sonichi:mainfrom
marklysze:feat/health-instance-bridge-push

Conversation

@marklysze

@marklysze marklysze commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

The health feature's Sutando side, consolidated so it can be reviewed once. It covers:

  • /health names the instance and each agent's session;
  • the gateway bridge pushes health to the broker;
  • the agent profile names its host by the stable label, so the Mac that answers /health and the host the broker shows are the same.

It absorbs #4891. Merge order: this PR → the verdicts PR → #4952. The desktop then repins its engine to the main that contains all three.

Part 1: instance, session and the bridge health push (was #4944)

What changed, and why

This is the Sutando part of Agent Health in AG2 Space (the HealthStatus work). AG2 Space needs to show an owner every one of their agents and whether each needs them. It also needs to open the right terminal and not list the same Mac twice.

GET /health (src/health_snapshot.py) gains three fields. All of them come from files that already exist:

  • instance: the agent id the serving gateway lane signed in as. The bridge now writes agent_id into its own state/gateway-status[.<lane>].json while connected. That id is re-read on every write, so a lane switch or re-login shows up at the next poll.
    • It is null when no lane is serving within 180 s, or when two serving lanes name different ids. A wrong id would show this Mac twice.
    • Freshness and serving are judged by gateway_serving.verdict_from_record, the shared owner.
  • session (per agent): the tmux session to open.
    • For the core, it comes from the .alive beat, then core-supervisor.json.
    • For a worker, it comes from its seat core-supervisor.<session>.json.
    • null when no file names one. It is never guessed. Core names no skill.
  • suspended: {reason, at} while state/pool-suspended exists (the pool paused on app quit, feat(worker-pool): the owning host can suspend and resume the pool's remedy #4879). Otherwise null.

Gateway bridge (packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py):

  • Snapshot source. It computes the snapshot in-process through _monorepo_src, the same optional-src pattern the HITL projector uses. So a Sutando without agent-api still reports; cloud Sutandos run no agent-api. A standalone sparrow reports no health.
  • Heartbeat. It gains the core's health: {alive, motion, condition, reason, since} and the worker_health.v1 capability. A change in that row sends the heartbeat at once.
  • Workers report. Each non-retired worker row gains the same health, and the body gains a top-level suspended. Health joins the push-on-change signal, so a change re-pushes an unchanged advertisement.
  • Workers report cadence (backend requirement). The broker marks worker rows stale 120 s after the last report it received, and asks for a report at least every 60 s as well as on change. The report is now also re-sent, changed or not, once the last one is 20 s old.
    • The push only runs between task polls, which are at most POLL_WAIT + 10 = 35 s apart, so the gap stays under 60 s.
    • The profile card keeps its 600 s re-send.
    • The test is test_an_unchanged_pool_is_resent_on_the_cadence_so_a_restarted_broker_heals.
  • Unchanged. The legacy body, which has no worker rows, is unchanged.
  • Wire shape. Only these summary fields leave the Mac: no paths, pane text, session or instance. reason is sent as a slug of [a-z0-9-]{1,40}, as agreed with the backend.
  • Cost. The snapshot is reused for 15 s, so the per-beat cost is bounded.

Review first

  1. src/health_snapshot.py: _instance, _suspended, _session.
  2. remote_gateway_bridge.py: _health_snapshot, _with_health, the _maybe_push_workers_snapshot identity, and _post_heartbeat.
  3. docs/health-snapshot.md and docs/remote-gateway-protocol.md.

Docs

  • docs/health-snapshot.md: the new fields, plus a "Reaching AG2 Space" section.
  • docs/remote-gateway-protocol.md: the heartbeat health field and capability, the workers report health/suspended, and the ≤60 s re-send against the broker's 120 s staleness window.
  • docs/catalog.json: last_verified bumped for both.

Before / after

The same throwaway workspace for both runs:

  • a serving gateway lane that signed in as @mark-desktop.agent:ag2.space
  • a live core
  • one worker at a login prompt
  • a paused pool

agent-api ran on port 7899 from this worktree, with src/health_snapshot.py at origin/main and then at HEAD.

Before (origin/main), curl -s http://127.0.0.1:7899/health:

{
    "checked_at": 1790726855.8,
    "overall": "attention",
    "agents": [
        {
            "id": "core",
            "role": "core",
            "label": null,
            "alive": true,
            "motion": "idle",
            "condition": "healthy",
            "reason": null,
            "since": null
        },
        {
            "id": "40659240fd884f63bcd19fa684b451f1",
            "role": "worker",
            "label": null,
            "alive": true,
            "motion": "idle",
            "condition": "abnormal",
            "reason": "needs-login",
            "since": 1790726847.0308847
        }
    ]
}

After (HEAD):

{
    "checked_at": 1790726872.1,
    "instance": "@mark-desktop.agent:ag2.space",
    "overall": "attention",
    "suspended": {
        "reason": "app-quit",
        "at": 1790726847
    },
    "agents": [
        {
            "id": "core",
            "role": "core",
            "label": null,
            "session": "sutando-core",
            "alive": true,
            "motion": "idle",
            "condition": "healthy",
            "reason": null,
            "since": null
        },
        {
            "id": "40659240fd884f63bcd19fa684b451f1",
            "role": "worker",
            "label": null,
            "session": "sutando-worker-40659240fd884f63bcd19fa684b451f1",
            "alive": true,
            "motion": "idle",
            "condition": "abnormal",
            "reason": "needs-login",
            "since": 1790726847.0308847
        }
    ]
}

What the bridge would POST, captured with _req stubbed and nothing sent. Same workspace, bridge at origin/main and then at HEAD:

Before:

POST /v1/heartbeat
{
  "client": "sutando-gateway-client",
  "protocol_version": 1,
  "provider": "remote",
  "tier": "owner",
  "inflight": 0,
  "capabilities": [
    "task-ack",
    "heartbeat",
    "result-skip-markers",
    "core-status",
    "team-collaborator"
  ]
}
POST /v1/workers
{
  "ts": 1,
  "roster_version": 10,
  "workers": [
    {
      "id": "40659240fd884f63bcd19fa684b451f1",
      "runtime": "claude",
      "state": "live"
    }
  ],
  "applied": {
    "labels": {},
    "bindings": {}
  }
}

After:

POST /v1/heartbeat
{
  "client": "sutando-gateway-client",
  "protocol_version": 1,
  "provider": "remote",
  "tier": "owner",
  "inflight": 0,
  "capabilities": [
    "task-ack",
    "heartbeat",
    "result-skip-markers",
    "core-status",
    "team-collaborator",
    "worker_health.v1"
  ],
  "health": {
    "alive": true,
    "motion": "idle",
    "condition": "healthy",
    "reason": null,
    "since": null
  }
}
POST /v1/workers
{
  "ts": 1,
  "roster_version": 10,
  "workers": [
    {
      "id": "40659240fd884f63bcd19fa684b451f1",
      "runtime": "claude",
      "state": "live",
      "health": {
        "alive": true,
        "motion": "idle",
        "condition": "abnormal",
        "reason": "needs-login",
        "since": 1790726847.0308847
      }
    }
  ],
  "applied": {
    "labels": {},
    "bindings": {}
  },
  "suspended": {
    "reason": "app-quit",
    "at": 1790726847
  }
}

Tests

$ python3 tests/gateway-health-push.test.py                       # new: 15 tests
Ran 15 tests ... OK
$ python3 tests/health-snapshot.test.py                           # +7 tests
Ran 52 tests ... OK
$ python3 packages/ag2-sparrow/tests/test_pool_advertisement_push.py   # health stubbed out; pinned in the new file
ALL PASS test_pool_advertisement_push
$ python3 tests/gateway-workers-report-body.test.py; python3 tests/agent-health-report.test.py; python3 src/remote-gateway-bridge.test.py
OK / OK / PASS — all checks green
$ python3 tests/gateway-serving-shared-owner.test.py; python3 tests/core-never-names-the-worker-pool-skill.test.py; python3 packages/ag2-sparrow/tools/test_no_drift.py
ok ... 4 readers delegating / ok core names no worker-pool skill path / PASS — package in sync with src/
$ diff-cover (the three suites above under .coveragerc)
remote_gateway_bridge.py 97.9%, health_snapshot.py 100% — total 98%
$ bash scripts/review-checks.sh --diff <(git diff origin/main...HEAD)
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)
$ python3 skills/release/scripts/docs_audit.py
docs-audit: PASS
$ ruff check (changed files)
All checks passed!

Live round trip

Done. Bridge restarted on a local AG2 Space stack (broker = ag2space-backend #1582); the broker received health and worker_health.v1 (detail: "full") and the owner-only GET /relay/v1/agents/health read the rows back, including Part 2's host_label. Evidence: #4944 (comment)

Related

Part 2: the stable host id in the agent profile (was #4891)

What changed, and why

The bridge's agent profile (PUT /v1/agents/<mxid>/profile) names the Mac it runs on in host.host_id. That came from socket.gethostname().split(".")[0], which on macOS follows the network: on the owner's Mac today gethostname() is Marks-MBP.localdomain (a DHCP lease), while the stable name is Marks-MacBook-Pro. So the same Mac could be published under two names as it moves between networks.

host_id now comes from workspace_lock._host_label(), the helper the package already uses for its lock records:

  • inside Sutando it returns util_paths._host_label(), the stable per-host label every state/cores/<host>.alive and hosts/<host>/ path is keyed on ($SUTANDO_HOST_LABEL, then scutil --get LocalHostName, then the short hostname);
  • when the package runs standalone it falls back to the short hostname, as before.

The planned owner-facing agent-health panel labels each Sutando with this host_id, so a label that changes with the network would show one Mac as two.

Where to look

packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py _build_agent_profile() (one call changed, one import); packages/ag2-sparrow/tests/test_agent_profile_host_id.py; the new test wired into .github/workflows/ci.yml's out-of-tree list.

Privacy

Unchanged in scope: host is already sent today. Per the broker (origin/dev api/v1.py: get_agent_profile :2189 → _owner_of_caller :1822, owner ceiling host:"owner" :2023), only bearers of agents with the same owner can read it; room members and other users cannot, and there is no room-state copy.

Docs

N/A: no documented behaviour changes; the field's meaning (which Mac) is the same, only its stability improves.

Before / after (on the owner's Mac, real hostname)

gethostname: Marks-MBP.localdomain   scutil LocalHostName: Marks-MacBook-Pro
origin/main  profile host -> {'host_id': 'Marks-MBP', 'kind': 'local'}
this PR      profile host -> {'host_id': 'Marks-MacBook-Pro', 'kind': 'local'}

Live path: post-restart round trip (desktop app, prod lane)

The desktop app's engine checkout was switched to a branch containing this commit, and only the gateway bridge was restarted (the app's keepalive respawned it):

old bridge pid 51192 (started 07:37:44) → killed 13:50:38 local
2026-09-29T03:50:44Z [remote-gateway-bridge] workers-snapshot pushed
2026-09-29T03:50:44Z [remote-gateway-bridge] agent-profile pushed for @mark-desktop.agent:ag2.space
gateway-status: connected=False "starting — not yet connected" until 13:51:07, connected=True from 13:51:12

Not done: reading the profile back from the prod broker. The bridge's bearer isn't in its process environment, and pulling it from the keychain for a readback wasn't worth it for this change. The pushed payload is the one _build_agent_profile() builds, shown above.

Unrelated, pre-existing: the log also shows hitl projection error (isolated): HTTP Error 502 on the prod lane, 464 times since 2026-09-28T20:19Z, before and after the restart. It is not caused by this change and is being raised separately.

Tests

$ python3 packages/ag2-sparrow/tests/test_agent_profile_host_id.py
ALL PASS test_agent_profile_host_id
$ (all 20 files in ci.yml's "Run out-of-tree Python tests", incl. src/remote-gateway-bridge.test.py) → all pass
$ python3 packages/ag2-sparrow/tools/test_no_drift.py
PASS — package in sync with src/
$ ruff check (changed files) → All checks passed!
$ bash scripts/review-checks.sh --diff <(git diff origin/main...HEAD) → PASS

Cases: the stable label wins over a drifting gethostname(); the stable label does not depend on src/ already being on sys.path; an unreadable host (no util_paths and gethostname() raising) still builds a profile (unknown-host); standalone (no util_paths) falls back to the short hostname.

Risks / rollback

On hosts where the stable label differs from the short hostname, the broker sees a new host_id once, on the next profile push. Nothing keys on host_id today (it is a display field). Rollback = revert.

Review follow-ups (round 2)

26883d0b1: one reader for state/pool-suspended (sonichi blocker, liususan #2)

  • New src/pool_suspension.py. read(workspace) returns {reason, at, stopped} normalised, or None when the pool isn't suspended:
    • reason defaults to suspended;
    • at is a number only, never a bool;
    • stopped keeps only strings;
    • a marker that isn't the record still suspends.
  • If the marker exists but can't be read, read raises OSError, and each caller decides what that means.
  • health_snapshot._suspended, health-check.check_pool_suspended and pool_remedy._marker all delegate to it, and each keeps its own rendering. The pool is a skill, so it may import src/; core still names no skill.
  • New test tests/pool-suspension-shared-owner.test.py (8 tests): a contract test of the reader, plus a delegation test per caller.
  • The drift is fixed: a record with no reason now reads suspended everywhere, not null in /health.

693c811cd: bridge

  • liususan Add demo video to README #1: an unreadable host builds a profile with host_id: "unknown-host" again. New test: util_paths unavailable plus gethostname() raising.
  • liususan Inline demo video in README #3: _profile_host_id() puts the monorepo src/ on sys.path itself before reading the stable label. New test: with src/ removed from sys.path and gethostname() drifting, the profile still gets the stable label. The test fails without the fix.
  • liususan README: inline demo video #4: only a workers body that carries health refreshes every 20 s. The legacy snapshot, and a standalone sparrow with no health snapshot, keep the 600 s re-send. New test test_without_health_the_workers_body_keeps_the_slow_cadence, and docs/remote-gateway-protocol.md is updated.
  • sonichi nit 1: _suspended_row drops a bool at.
  • sonichi nit 2: _health_row truncates before stripping dashes, so a reason never ends in -.
  • sonichi nit 3: the test counts above are corrected (gateway-health-push: 15).

8a8f5b65b: pool_suspension.path() and read() take workspace=None and resolve it through workspace_default.resolve_workspace. CI's state-paths-adoption gate requires this of any src/ module that names a runtime-state path.

Checks:

  • All of these pass: state-paths-adoption, pool-suspension-shared-owner, health-check-pool-suspended, pool-suspend-resume, health-snapshot (52), gateway-health-push (15), test_agent_profile_host_id, test_pool_advertisement_push, test_no_drift, gateway-workers-report-body, agent-health-report, gateway-serving-shared-owner, core-never-names-the-worker-pool-skill, src-map.
  • review-checks.sh and docs_audit pass, and ruff is clean.
  • Local diff coverage is 96%.

@github-actions

github-actions Bot commented Sep 30, 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

  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py (94.2%): Missing lines 2628,2678,2681-2682
  • skills/worker-pool/scripts/pool_remedy.py (100%)
  • src/health-check.py (100%)
  • src/health_snapshot.py (100%)
  • src/pool_suspension.py (100%)

Summary

  • Total: 136 lines
  • Missing: 4 lines
  • Coverage: 97%

packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py

Lines 2624-2632

  2624     try:
  2625         src = _monorepo_src("health_snapshot.py")
  2626         if src:
  2627             if src not in sys.path:
! 2628                 sys.path.insert(0, src)
  2629             import health_snapshot
  2630             value = health_snapshot.snapshot(_STATE.parent)
  2631     except Exception as e:  # noqa: BLE001 — health is optional; it never breaks a push
  2632         error = f"{type(e).__name__}: {e}"

Lines 2674-2686

  2674     """The host's stable label: gethostname() drifts with the network (a DHCP lease renames
  2675     it). The monorepo src/ is put on the path first, so the label never depends on call order."""
  2676     src = _monorepo_src("util_paths.py")
  2677     if src and src not in sys.path:
! 2678         sys.path.insert(0, src)
  2679     try:
  2680         return _stable_host_label()
! 2681     except OSError:
! 2682         return "unknown-host"
  2683 
  2684 
  2685 def _maybe_push_workers_snapshot(record) -> bool:
  2686     """Push-on-change relay of the pool's workers snapshot (the worker

⚠️ Not measured

Changed, but absent from coverage.xml — outside [run] source in
.coveragerc, so diff-cover never examined these lines:

  • packages/ag2-sparrow/tests/test_agent_profile_host_id.py
  • packages/ag2-sparrow/tests/test_pool_advertisement_push.py

host_id came from socket.gethostname(), which on macOS follows the network
(Marks-MacBook-Pro became Marks-MBP.localdomain on a DHCP lease), so the same
Mac could appear under two names. It now uses workspace_lock._host_label():
Sutando's stable per-host label, falling back to the short hostname when the
package runs standalone.
…hes health to the broker

/health gains a top-level instance (the agent id the serving gateway lane
signed in as, written to its gateway-status sidecar while connected),
suspended (the pool-suspended marker), and a per-agent session read from
the beat or supervisor file, never guessed.

The gateway bridge computes the snapshot in-process, so a Sutando without
agent-api still reports: the core's row rides the heartbeat with the
worker_health.v1 capability, each worker's row and the suspension ride the
workers report, and a health change sends either without waiting.
The broker marks worker rows stale 120 s after the last report it received
and asks for one at least every 60 s. The push runs between polls (up to
POLL_WAIT + 10 s apart), so a 20 s refresh keeps the gap under 60 s.
@marklysze
marklysze force-pushed the feat/health-instance-bridge-push branch from 8023d1e to d7777aa Compare September 30, 2026 23:06
@marklysze marklysze changed the title feat(health): /health names the instance and sessions; the bridge pushes health to the broker [HealthStatus] feat(health): /health names the instance and sessions; the bridge pushes health and names its host stably [HealthStatus] Sep 30, 2026

@bassilkhilo-ag2 bassilkhilo-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.

Reviewed at d7777aa. The code looks correct and CI is green. I'm leaving this as a comment and not approving yet, for one reason: Part 1 changes a live path (the heartbeat and the /v1/workers push), and the PR body marks its post-restart round trip as "Pending". REVIEW.md lesson 15 says a live-path change isn't approved on harness proof alone. I'll approve once the round trip is posted.

Blocker

  • Live round trip for Part 1. Restart the bridge, show the broker receiving health and worker_health.v1, then read the rows back from the owner-only health call. Part 2 (host_id) does show a prod-lane restart, but its value was never read back from the broker.

Verified

  • Open and MERGEABLE. Required checks green, CLA passes, diff coverage 98% (the only uncovered line is the sys.path.insert at bridge :2628).
  • The new tests really run in CI (lesson 18): the job logs show gateway-health-push, health-snapshot and test_agent_profile_host_id executing.
  • On the PR head, all of these pass locally: gateway-health-push (14), health-snapshot (52), test_pool_advertisement_push (including the 20 s cadence), test_agent_profile_host_id, gateway-workers-report-body, agent-health-report, gateway-serving-shared-owner, test_no_drift, gateway-auto-reenroll (31).
  • src/remote-gateway-bridge.test.py passes in a clean env. The 5 failures I saw with my own shell env also fail at the merge-base, so they come from my environment, not this PR.
  • scripts/review-checks.sh on the diff is clean (hardcoded paths, root artifacts, prose cap).
  • Ran the snapshot read-only against a live workspace. Core session resolves to the core's tmux session. instance comes back null, which is expected there: that bridge predates the PR, so it doesn't write agent_id.
  • The heartbeat capabilities list is built fresh on each call, so nothing duplicates.

Nits (none block merge)

  • docs/remote-gateway-protocol.md:188-190 still says the profile is pushed "on the same change signal and cadence as the workers snapshot". That's no longer true: workers now re-send every 20 s, and the profile stays at 600 s.
  • remote_gateway_bridge.py:2660 sends suspended as-is. When state/pool-suspended isn't JSON, health_snapshot.py:322 uses the raw text as reason, with no slug or length clamp on it. The real writer does write JSON, so this is low risk, but it slightly contradicts "only summary fields leave the Mac".
  • health_snapshot.py:310-323 duplicates the marker parsing in pool_remedy.py:55-69. That's probably forced by the rule that core can't name the skill, but it can drift.
  • remote_gateway_bridge.py:2724: the except OSError is dead code, since _host_label already catches everything.
  • /v1/workers POSTs go from about one per 600 s to about three per minute for every Sutando with a pool, older brokers included. The body names this as a backend requirement, and a failing snapshot fails open.

Not verified: any broker exchange, the desktop and webclient consumers of instance and session, and the case of several serving lanes on one Mac (where instance is deliberately null).

…ence doc, no dead fallback

The suspension's reason leaves as a slug and its time only as a number, like every other
health field; the profile doc no longer claims the workers' cadence; _host_label cannot
raise, so the unknown-host fallback was unreachable.
@marklysze

Copy link
Copy Markdown
Collaborator Author

@bassilkhilo-ag2, thanks for the review. Here's the live round trip for Part 1, plus the readback for Part 2, then the nits.

Live round trip on a local AG2 Space stack

Setup

1. Bridge restart, then the pushes (the bridge's own log on that lane):

2026-09-30T19:12:58Z gateway-keepalive: bridge exited (code 75) — restarting in 5s
2026-09-30T19:13:03Z [remote-gateway-bridge] starting — gateway=http://localhost:9995/relay provider=remote ...
2026-09-30T19:13:03Z [remote-gateway-bridge] workers-snapshot pushed
2026-09-30T19:13:03Z [remote-gateway-bridge] agent-profile pushed for @admin-desktop.agent:ag2space.local
2026-09-30T19:13:29Z [remote-gateway-bridge] workers-snapshot pushed
2026-09-30T19:13:54Z [remote-gateway-bridge] workers-snapshot pushed
...
2026-09-30T20:35:50Z [remote-gateway-bridge] workers-snapshot pushed

The workers report goes out about every 25 s, which is the 20 s refresh bounded by the task poll. The profile goes out every 600 s.

2. Owner-only readback from the broker. GET /relay/v1/agents/health with the owner's Matrix token. It was read after the app quit, so the rows are correctly stale and unknown now. What matters is what the broker stored from the pushes:

"agent_id": "@admin-desktop.agent:ag2space.local",
"host_label": "Marks-MacBook-Pro",
"detail": "full",
"reported_at": "2026-09-30T20:35:50Z",
"core":    {"session": "sutando-core", ...},
"workers": [{"worker_id": "40659240…", "session": "sutando-worker-40659240…", "state": "live", ...},
            {"worker_id": "58f7db3c…", "session": "sutando-worker-58f7db3c…", "state": "live", ...}]
  • detail: "full": the broker reached it from the heartbeat's worker_health.v1 capability and the per-worker health rows. Without them it reads limited.
  • reported_at is the bridge's last workers-snapshot pushed line, to the second.
  • host_label: "Marks-MacBook-Pro" is Part 2's host_id, read back from the broker. Before this PR, the same Mac reported Marks-MBP from gethostname().

3. Fresh rows while the app was running. The desktop's test build showed them live:

  • /health gave instance: @admin-desktop.agent:ag2space.local.
  • The owner health view went from not-answering to crashed to green, through a worker kill and a restart.
  • Those are the same rows this bridge pushes.

Nits, fixed in 9e1fd189a

  • The profile cadence doc: corrected. The profile is re-sent every 600 s when unchanged, not at the workers' 20 s refresh.
  • suspended: it now leaves the Mac as summary fields only.
    • reason is slugged the same way as every health reason.
    • at is passed only if it's a number.
    • New test: test_a_suspension_leaves_as_a_slug_and_a_numeric_time_only.
  • The except OSError around _stable_host_label(): removed, along with its test, since _host_label can't raise.
  • The duplicated marker parsing: kept on purpose. Core may not name or import the worker-pool skill (tests/core-never-names-the-worker-pool-skill.test.py), so health_snapshot reads the marker file itself. The record shape is pinned by both sides' tests.
  • POST rate: about 3 per minute is the broker's freshness requirement (it marks rows stale at 120 s). An older broker that rejects the push backs off for an hour (fail-open).

@marklysze marklysze closed this Oct 1, 2026
@marklysze marklysze reopened this Oct 1, 2026

@bassilkhilo-ag2 bassilkhilo-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.

Approving at 9e1fd18.

The blocker is cleared. The live round trip (comment 5922000939) covers what I asked for:

  • On a local AG2 Space stack, a bridge restart was followed by workers-snapshot and agent-profile pushes. The workers report went out about every 25 s (the 20 s refresh bounded by the task poll) and the profile every 600 s.
  • The owner-only GET /relay/v1/agents/health read the stored rows back with detail: "full", the agent id, and Part 2's stable host_label.

That run used a test merge of an earlier head. The one commit since then (9e1fd189) only touches the suspension row, the protocol doc and a dead fallback, not the push loop, so the round trip still applies.

The nit fixes check out (d7777aa..9e1fd18):

  • suspended now goes out as summary fields only. _suspended_row slugs reason through _health_row, falls back to "suspended", and keeps at only when it's a number. The new test pins a free-text reason with a path in it down to a slug.
  • The protocol doc now gives the profile's 600 s cadence separately from the workers' 20 s refresh.
  • The dead except OSError fallback and its test are gone. _stable_host_label already catches everything.

CI: 28 checks pass, 2 skipped.

The duplicated marker parsing (health_snapshot vs pool_remedy) is still there. It's fine as a known tradeoff of the core/skill boundary.

@marklysze marklysze closed this Oct 1, 2026
@marklysze marklysze reopened this Oct 1, 2026

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Reviewed at 9e1fd189 against merge-base 0c03031b (10 files, +488/-24, matches the body). Not repeating @bassilkhilo-ag2's or the in-room findings; one blocker, which is this repo's architecture rule rather than a behaviour bug, plus nits.

Blocker

This PR adds a second core-side parser of state/pool-suspended. src/health_snapshot.py _suspended() (new, diff L462-475) and src/health-check.py check_pool_suspended() (main, L8330-8346) run the same policy: read_text().strip() → json.loads → isinstance(rec, dict) → reason/at, with the same "a non-record marker still suspends" fallback. The thread already accepted that core may not import skills/worker-pool/scripts/pool_remedy.py:_marker (the third copy); that boundary argument does not apply here, because both of these live in src/. CLAUDE.md "Fix a bug where the policy lives": no shared owner exists → create one; do not add a copy; that is the fix, not a follow-up.

Ask: a dependency-light src/ reader (for example pool_suspension.read(workspace) -> {reason, at} | None) that health_snapshot._suspended and health-check.check_pool_suspended both call, each keeping its own rendering, with a contract test on the reader and a delegation test on the two callers. Whether pool_remedy._marker then delegates to it too is the worker-pool skill's call (the skill may import src/; core may not import the skill), but the two core copies cannot both ship.

Nits (non-blocking)

  1. remote_gateway_bridge.py:2667 _suspended_row: isinstance(True, (int, float)) is true in Python, so {"at": true} reaches the wire in a timestamp slot. Verified: _suspended_row({"reason": True, "at": True}) → {'reason': 'true', 'at': True}. Low risk, the real writer writes a number.
  2. remote_gateway_bridge.py:2643 _health_row: .strip("-")[:40] truncates after stripping, so a long reason can end in -. [:40].strip("-") fixes it.
  3. Body says the new suite is 14 tests; it is 15 (9e1fd189 added test_a_suspension_leaves_as_a_slug_and_a_numeric_time_only).
  4. The live round trip ran at 8023d1e35; d7777aa4..9e1fd189 touches only _suspended_row, the dead except OSError, docs and tests, so it still carries, but the suspended slug is the one wire field pinned by unit test only.

Verified (worktree at 9e1fd189, all exit 0)

python3 tests/gateway-health-push.test.py                        Ran 15 tests ... OK
python3 tests/health-snapshot.test.py                            Ran 52 tests ... OK
python3 packages/ag2-sparrow/tests/test_agent_profile_host_id.py   ALL PASS
python3 packages/ag2-sparrow/tests/test_pool_advertisement_push.py ALL PASS
python3 packages/ag2-sparrow/tools/test_no_drift.py              PASS
python3 tests/gateway-serving-shared-owner.test.py               OK (4 readers delegating)
python3 tests/gateway-workers-report-body.test.py                Ran 3 OK
python3 tests/agent-health-report.test.py                        Ran 12 OK
bash scripts/review-checks.sh --diff <merge-base diff>           PASS
ruff check (4 changed .py)                                       All checks passed!

Discrimination: with src/health_snapshot.py and remote_gateway_bridge.py reverted to 0c03031b and the tests kept, gateway-health-push fails (errors=15), health-snapshot fails (errors=7), and the two sparrow suites raise AttributeError. _now = time.monotonic; capabilities is a fresh literal per beat; .alive carries session (core_heartbeat.py:487). The one /Users/mark in added lines is a redaction fixture in the test file; review-checks passes.

Not verified

Any exchange with a real broker (I read the author's round-trip comment, did not reproduce it); the desktop/webclient consumers of instance/session; the backend's [a-z0-9-]{1,40} validator; the multi-lane case where instance is null.

@liususan091219 liususan091219 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.

Reviewed at 9e1fd18. CI and CLA green; hardcoded-path scan clean. On the wire, only alive/motion/condition/reason-slug/since plus a suspended slug and timestamp are added; session, instance and paths stay in the local /health. Tests call production functions. Two small changes requested (both repo review-rule categories), the rest non-blocking:

Requested

  1. Body claim not backed (false_claim). The body says "an unreadable host still builds a profile (unknown-host)", but test_agent_profile_host_id.py has two tests and neither covers it, and the except OSError → "unknown-host" was removed from _build_agent_profile (remote_gateway_bridge.py ~2729). Repro: with util_paths unavailable and socket.gethostname raising, workspace_lock._host_label() lets OSError escape and the profile push fails ("pool advertisement push failed"). Unlikely on macOS, but either restore the fallback with a test, or correct the body.
  2. Duplicated policy that has already drifted (fragmentation). src/health_snapshot.py:310-325 _suspended re-implements skills/worker-pool/scripts/pool_remedy.py:56-69 _marker: a record with no reason reads null here but "suspended" in the pool (the bridge's _suspended_row masks it on the wire, not in /health). Core can't import the skill, so a small dependency-light src/ reader both call would be the shared owner per CLAUDE.md "Shared adapter policy".

Non-blocking
3. The stable label depends on src/ already being on sys.path (a side effect of _health_snapshot/HITL). With gethostname patched to Drift-MBP.localdomain I get Drift-MBP without it and the stable label with it. Normal loop ordering covers it; a disabled heartbeat plus a backed-off workers push does not.
4. WORKERS_REFRESH_S also re-sends the legacy and standalone bodies every 20 s (was ~600 s). Worth stating in the body, or limiting the refresh to bodies that carry health.

Not verified: broker-side handling of worker_health.v1 / its owner-only check (took the cited lines on trust); whether /health binds loopback-only; I did not run the head suites locally.

— Sutando (susans-MacBook-Pro)

health_snapshot, health-check and the pool's remedy each parsed state/pool-suspended,
and the copies had drifted (a record without a reason read null in /health, suspended
in the pool). src/pool_suspension.read() owns the parse and normalisation; each caller
keeps its own rendering, pinned by a contract test and a delegation test per caller.
…health-only refresh

- An unreadable host builds a profile with host_id unknown-host again.
- The stable label puts the monorepo src/ on the path itself, so call order can't change it.
- Only a workers body carrying health refreshes every 20 s; others keep the 600 s re-send.
- A reason is truncated before its dashes are stripped; suspended.at excludes bool.
@marklysze
marklysze requested a review from keweichen as a code owner October 1, 2026 00:45
@marklysze

Copy link
Copy Markdown
Collaborator Author

Thanks @sonichi and @liususan091219. Both reviews are addressed at 693c811cd, in two commits after 9e1fd189.

@sonichi

  • Blocker (a second core parser of state/pool-suspended): fixed in 26883d0b1.
    • New shared owner: src/pool_suspension.read(workspace) returns {reason, at, stopped} normalised, or None. If the marker exists but can't be read, it raises OSError, and each caller judges that.
    • health_snapshot._suspended and health-check.check_pool_suspended both call it. The duplicated parsing is gone from both.
    • pool_remedy._marker delegates too. The skill may import src/; core still names no skill (core-never-names-the-worker-pool-skill passes).
    • tests/pool-suspension-shared-owner.test.py has a contract test of the reader, plus a delegation test per caller: each patches read and asserts the caller used it.
  • Nit 1: _suspended_row now drops a bool at. {"reason": True, "at": True} → {"reason": "true", "at": None}. Tested.
  • Nit 2: _health_row now truncates and then strips dashes. "a"*39 + " b" → "a"*39, with no trailing -. Tested.
  • Nit 3: the body's counts are corrected (gateway-health-push: 15).
  • Nit 4: noted. The suspended slug is pinned by unit tests. The round trip's push loop is unchanged.

@liususan091219

  1. The unknown-host claim: now true again (693c811cd). _profile_host_id() catches the OSError.
    • New test test_an_unreadable_host_still_builds_a_profile: util_paths unavailable plus gethostname() raising gives host_id: "unknown-host".
  2. The duplicated, drifted policy: the same fix as sonichi's blocker (26883d0b1). A record with no reason now reads suspended in /health, health-check and the pool alike.
  3. The stable label depended on sys.path order: _profile_host_id() now puts the monorepo src/ on the path itself, via _monorepo_src, the same way _health_snapshot does.
    • New test test_the_stable_label_does_not_depend_on_src_already_being_importable: with src/ removed and gethostname() drifting to Drift-MBP.localdomain, it gets the stable label. The test fails without the fix.
  4. The legacy and standalone bodies at 20 s: fixed. Only a workers body that carries health refreshes every 20 s. Everything else keeps the 600 s re-send.
    • New test test_without_health_the_workers_body_keeps_the_slow_cadence. The existing cadence test now uses a body that carries health.
    • docs/remote-gateway-protocol.md is updated.

@bassilkhilo-ag2: your approval was at 9e1fd189. Could you re-check 26883d0b1 and 693c811cd? Neither changes the push loop's shape, but the refresh now applies only to bodies that carry health.

Checks: all the touched suites pass, plus test_no_drift, src-map, review-checks and docs_audit, and ruff is clean. Local diff coverage is 96%.

@sonichi

sonichi commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Read at exact head 693c811cd (merge-base 0c03031b). Posting as a comment rather than a formal review: the standing CHANGES_REQUESTED under the sonichi login is Blue's, and a review from me would post under that same login and replace it.

Both standing blockers are addressed at this head.

  • Blue's (a second core parser of state/pool-suspended): src/pool_suspension.py:39-46 is the one reader; src/health_snapshot.py:311-317, src/health-check.py:8331-8341 and skills/worker-pool/scripts/pool_remedy.py:39-63 all delegate to it, and tests/pool-suspension-shared-owner.test.py pins the contract plus one delegation test per caller. I grepped src/ skills/ packages/ for pool-suspended: the only other hit is src/recovery_issues.py:23, a probe-name list, not a parser. Mutation control: an inline JSON parse put back into health_snapshot._suspended fails test_health_snapshot_renders_the_readers_record.
  • liususan's (unknown-host fallback untested; drifted reason defaults): remote_gateway_bridge.py:2673-2682 catches OSError → "unknown-host", now covered in test_agent_profile_host_id.py (removing the except makes it raise OSError: no host); a record with no reason reads "suspended" in all three consumers.
  • The non-blocking items are done too: _profile_host_id puts src/ on the path itself (removing that fails test_the_stable_label_does_not_depend_on_src_already_being_importable); fresh = body is base or … at :2706 keeps the 600 s re-push for a body without health (reverting fails test_without_health_the_workers_body_keeps_the_slow_cadence); bool at dropped at :2669; reason truncation strips trailing dashes at :2643.

One new nit (verified). pool_suspension.read uses read_text(encoding="utf-8"), and health_snapshot._suspended catches only OSError; a marker with a bad byte raises UnicodeDecodeError (a ValueError), so snapshot() raises and all of /health fails (agent-api.py:1005), while the bridge logs and pushes no health. Repro at this head:

printf '\xff\xfe\x00bad' > $W/state/pool-suspended
pool_suspension.read(W)      -> RAISES UnicodeDecodeError  (isinstance OSError = False)
health_snapshot.snapshot(W)  -> RAISES UnicodeDecodeError

health_snapshot._read_json (:63-69) already catches (OSError, ValueError); the reader's docstring says an unreadable marker raises OSError, which this case doesn't. The real writer writes JSON, so low impact; main's pool_remedy._marker and health-check had the same gap, so it is new only on the /health path. A (OSError, ValueError) catch in read() or in _suspended closes it.

One question I could not answer (worst-case disruption). The new health/suspended fields go out by default from every install with a bridge and a pool. If a broker without backend #1582 answers a validation 4xx (not 404/405/501, which the hour-long backoff covers), _defer_push holds the workers snapshot for 5 minutes per retry and the worker picker stops updating on that broker. Author or backend owner to confirm how such a broker treats unknown fields.

Run at this head: health-snapshot 52/52, gateway-health-push 15/15, pool-suspension-shared-owner 8/8, health-check-pool-suspended 6/6, the four worker-pool remedy/suspend suites OK, core-never-names-the-worker-pool-skill OK, gateway-serving-shared-owner OK, gateway-workers-report-body OK, agent-health-report OK, src-map 27/27; sparrow tests run as scripts (pytest not installed here): test_agent_profile_host_id ALL PASS, test_pool_advertisement_push ALL PASS, tools/test_no_drift.py PASS. git diff origin/main...pr4944 | bash scripts/review-checks.sh → PASS. Not verified: any exchange with a real broker, the desktop/webclient consumers of instance/session, the several-serving-lanes case, and that bassilkhilo's approval at 9e1fd189a holds for the two later commits (the preflight flags it as stale).

… sanctioned helper

state-paths-adoption requires any src/ module that names a runtime-state path to
resolve the workspace through workspace_default; read() and path() now accept none.
@marklysze

Copy link
Copy Markdown
Collaborator Author

One more commit, 8a8f5b65b: CI's state-paths-adoption gate requires any src/ module that names a runtime-state path to resolve the workspace through workspace_default. So pool_suspension.path() and read() now accept workspace=None and resolve it there; there is a test for it. CI is green at 8a8f5b65b (28 pass, 2 skipped), and the CLA check passes.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-reviewed at 8a8f5b65; this replaces my CHANGES_REQUESTED from 9e1fd189 (review 5373601397). The duplicated state/pool-suspended parser is gone: src/pool_suspension.py is the one reader and health_snapshot, health-check and pool_remedy delegate to it (verified by reading all three at this head, and by the mutation runs below).

Status of the earlier blocker: FIXED

src/pool_suspension.py is the only definition of the marker path and its parse. grep -rn 'pool-suspended' src/ skills/worker-pool/scripts/ packages/ returns, outside tests, only that file plus two non-parsers: recovery_issues.py:23 (a probe-name list) and health-check.py:8334,8338 (the probe's name). health_snapshot._suspended, health-check.check_pool_suspended and pool_remedy._marker all delegate to pool_suspension.read. The skill imports core; core imports no skill (core-never-names-the-worker-pool-skill ok).

Discrimination, three runs, one caller mutated back to an inline parse each, tree restored to an empty git diff:

  • FAIL test_health_snapshot_renders_the_readers_record (None != {'reason': 'from-the-reader', 'at': 9})
  • FAIL test_health_check_renders_the_readers_record
  • FAIL test_the_pools_remedy_reads_through_it

liususan's item: deleting except OSError from _profile_host_id makes test_agent_profile_host_id raise OSError: no host. Reverting fresh = body is base or ... fails test_without_health_the_workers_body_keeps_the_slow_cadence: AssertionError: no health: no 20 s refresh.

Blockers

None.

Nits

  1. Non-UTF-8 marker, confirmed here. read() uses read_text(encoding="utf-8") and its docstring promises OSError, but a bad byte raises UnicodeDecodeError (isinstance OSError = False), so every caller's except OSError misses it. /health has no guard (agent-api.py:1005, in do_GET with no enclosing try), so the endpoint fails whole. health-check and pool_remedy have the same gap on main, so only that blast radius is new, and the real writer only writes JSON. _read_json here already catches (OSError, ValueError); matching it makes the docstring true.
  2. pool_suspension.py:16 inserts src/ into sys.path on every import. Cosmetic.

What I verified

Isolated worktree at 8a8f5b65, every command exit 0:

pool-suspension-shared-owner 9/9; health-snapshot 52/52; gateway-health-push 15/15;
health-check-pool-suspended 6/6; agent-health-report 12/12; workers-report-body 3/3;
gateway-serving-shared-owner ok; core-never-names-...-skill ok; src-map up to date
worker-pool: suspend-resume 13, remedy 27, remedy-timer 25, ensures-supervisor 5
sparrow: agent_profile_host_id, pool_advertisement_push, test_no_drift -> ALL PASS

scripts/review-checks.sh --diff <0c03031b...HEAD> -> PASS (hardcoded-paths + root-artifacts + prose-cap clean). ruff check on the 10 changed .py files -> All checks passed!. No hardcoded host paths added. CI: 25 SUCCESS, 2 SKIPPED, 2 running, 0 failures, license/cla: SUCCESS.

Nit repro: after printf '\xff\xfe\x00bad' > $W/state/pool-suspended, pool_suspension.read, health_snapshot.snapshot and pool_remedy.suspension each raise UnicodeDecodeError.

Live push path. The delta since 9e1fd189 does touch it: _health_row, _suspended_row, the new _profile_host_id, and fresh = in _maybe_push_workers_snapshot, so "the round trip's push loop is unchanged" is imprecise. The changed branches sit off the path the 2026-09-30 round trip witnessed: with health present, that run's configuration, fresh evaluates as before and _profile_host_id returns _stable_host_label() unchanged. The new branches, health-absent cadence and host-label failure, are pinned by the mutations above, so the existing witness carries and I am not asking for a fresh one.

What I did not verify

Any exchange with a real broker, including how one without backend #1582 treats the new fields. No round trip of my own, no desktop/webclient consumer of instance/session, no several-serving-lanes case, and the two in-flight CI jobs. Whether bassilkhilo-ag2's approval at 9e1fd189a carries to the three later commits.

… its files say

A quit kills the private tmux server outright, so no seat records its own end and the
inbox beat stays fresh: after a relaunch the stopped workers read alive and healthy for
up to 90 s with no session at all. The suspension already lists them in stopped.
@marklysze

Copy link
Copy Markdown
Collaborator Author

One more commit, 8e477bb71, from live testing on the desktop build: a worker the suspension took down is not alive, whatever its files say.

Found: right after an app quit and relaunch, /health showed both workers alive: true, healthy while no worker tmux session existed.

  • A quit kills the private tmux server outright, so no seat records its own end.
  • The inbox beat stays under 90 s old.
  • The seat verdict files still say idle-ready.

Fix: a worker listed in state/pool-suspended's stopped now reads alive: false · unknown · unknown, reason suspended, since the suspension's at.

  • It reads the record through the new shared pool_suspension.read, so there's no new parser and no new writer.
  • --resume lifts it.
  • The condition stays unknown, so it never alerts.

Tests:

  • New: test_a_worker_the_suspension_took_down_is_not_alive_whatever_its_files_say.
  • The health_snapshot delegation test now goes through snapshot().
  • docs/health-snapshot.md has combining rule 6 and a suspended row in the reasons table.

@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 8e477bb71. I reviewed 8a8f5b65b in full (merge-base 0c03031b), then the one commit since, 8e477bb7, as its own delta.

The new commit, 8e477bb7

A worker named in the suspension's stopped list now reads alive: false, condition: unknown, reason: suspended, whatever its seat files say. This does not turn a failure green:

  • unknown keeps overall at unknown, never ok.
  • The marker never expires, so a pool that was never resumed is still flagged: by suspended in the snapshot and by health-check's pool-suspended warn.
  • Only workers the suspension itself listed are affected.

Revert-control for the delta: with src/health_snapshot.py restored to 8a8f5b65b, test_a_worker_the_suspension_took_down_is_not_alive_whatever_its_files_say fails (53 tests, failures=1). At 8e477bb7 every suite listed below still passes, and review-checks on the new gh pr diff passes.

What I checked against the three asks

  • What /health exposes. The new local fields are instance (the serving lane's agent mxid, from gateway-status*.json), per-agent session (tmux session names) and suspended {reason, at}. /health is served by agent-api.py, whose bind defaults to 127.0.0.1. The bridge sends only alive/motion/condition/reason/since per agent (HEALTH_FIELDS), with reason slugged to [a-z0-9-]{1,40}, plus suspended as a slug and a numeric at. instance, session and paths never go on the wire. _reenroll_identity() (now written as agent_id in the status file) returns only the mxid, never the token. I found no secret, token or host path leaving the Mac.
  • Can "pushes health" spam? No; the rate is bounded. I ran the real _post_heartbeat against a stubbed health_snapshot whose core motion flips on every call, with a simulated clock, for 600 loop iterations at 1 s each. Result: 40 heartbeats in 600 s, minimum gap 15.0 s (main sends 10 at HEARTBEAT_INTERVAL = 60). The bound is the HEALTH_TTL_S = 15 cache, because health can only change when the cache refreshes. The workers POST has the same bound: change-gated through the same cache, plus the 20 s refresh, and only for a body that carries health.
  • Overlap with #4945. Both touch src/health_snapshot.py. At 8e477bb7, a test merge of #4944 + #4945 conflicts textually in two hunks of snapshot() and in docs/health-snapshot.md: this PR adds session, and #4945 adds the second argument to _alive. I resolved it by keeping both. Then I merged #4952 on top. On the combined tree these all pass: health-snapshot 59, gateway-health-push 15, pool-suspension-shared-owner 9, core-input-watch 123, health-check-pool-suspended 6, agent-health-report 12, pool-restart 16, pool-remedy 27, pool-suspend-resume 13, test_pool_advertisement_push, and core-never-names-the-worker-pool-skill. The body's merge order (#4944 → #4945 → #4952) works; whichever PR lands second needs that two-line rebase.

Ran at head (clean env, all rc=0)

gateway-health-push 15/15; health-snapshot 53/53 (52 at `8a8f5b65b`); pool-suspension-shared-owner 9/9
health-check-pool-suspended 6/6; agent-health-report 12/12; gateway-workers-report-body 3/3
gateway-serving-shared-owner ok; core-never-names-the-worker-pool-skill ok
sparrow: test_agent_profile_host_id ALL PASS, test_pool_advertisement_push ALL PASS, test_no_drift PASS
worker-pool: pool-suspend-resume 13, pool-remedy 27

gh pr diff 4944 | scripts/review-checks.sh --diff at head: PASS (hardcoded paths, root artifacts and prose cap all clean). gh pr checks: 29 pass, 2 skipping.

Standing reviews

@liususan091219's CHANGES_REQUESTED (filed at 9e1fd189) has not been re-filed at this head, but I checked both of its items here:

  • unknown-host. _profile_host_id() catches OSError again, and test_agent_profile_host_id (which covers it) passes. Reverting the bridge makes that test fail.
  • Duplicated pool-suspended policy. src/pool_suspension.py is now the one reader. health_snapshot, health-check and pool_remedy delegate to it, and pool-suspension-shared-owner 9/9 pins that.

On that basis I read both items as addressed. @bassilkhilo-ag2's approval at 9e1fd189 is stale by 3 commits.

Revert-control

I reverted only the non-test source (remote_gateway_bridge.py, health_snapshot.py, health-check.py, pool_remedy.py, plus pool_suspension.py removed) to 0c03031b and kept the tests:

  • gateway-health-push: errors=15/15
  • health-snapshot: errors=7
  • pool-suspension-shared-owner: ModuleNotFoundError
  • test_agent_profile_host_id and test_pool_advertisement_push: AttributeError

The new tests discriminate. Tree restored to clean.

Findings (none blocking)

  1. src/pool_suspension.py:48. @sonichi's non-UTF-8 nit is still open at this head. I re-ran it: with \xff\xfe\x00bad as the marker, pool_suspension.read() and health_snapshot.snapshot() both raise UnicodeDecodeError, so local /health fails as a whole. The bridge is safe here because _health_snapshot catches Exception. The real writer writes JSON, so this is low risk. A (OSError, ValueError) catch in read() would make its docstring true.
  2. remote_gateway_bridge.py:2880 and the next few lines. _last_core_health is updated before the POST. If that POST fails, the change is not re-sent until the 60 s interval or the next change. This is benign, but the broker can briefly hold the older verdict.

Live path (lesson 15)

The author's round trip on the local AG2 Space stack (comment of 2026-10-01T00:10Z) ran at 8023d1e35, not this head. I agree with @sonichi's reading of the later delta (_health_row, _suspended_row, _profile_host_id, the fresh = gate). The configuration that run witnessed (health present) takes the same branch, and the new branches are pinned by the mutation-discriminating tests above. So I am accepting that witness, not asking for a new one.

Not verified

  • Any broker exchange of my own, or a broker without backend #1582.
  • The desktop and webclient consumers of instance and session.
  • The multi-lane instance: null case, beyond reading _instance.

@marklysze

Copy link
Copy Markdown
Collaborator Author

@liususan091219: your review at 9e1fd189 is the only open request on this PR. All four points are addressed at the current head 8e477bb71 (full reply: #4944 (comment)):

  1. Body claim / the unknown-host fallback, fixed in 693c811cd.
    • The fallback is restored as _profile_host_id().
    • A new test in packages/ag2-sparrow/tests/test_agent_profile_host_id.py patches both util_paths unavailable and gethostname raising, and gets unknown-host. The body's claim is now true.
  2. Duplicated pool-suspended policy, fixed in 26883d0b1, with 8a8f5b65b for the workspace resolver.
    • There is now one shared reader, src/pool_suspension.read(). It returns {reason, at, stopped}, with reason defaulting to suspended, so the null vs "suspended" drift you found is gone.
    • health_snapshot._suspended, health-check.check_pool_suspended and pool_remedy._marker all call it. The core copies of the parsing are deleted.
    • Tests: tests/pool-suspension-shared-owner.test.py has the contract plus one delegation test per caller.
  3. The stable label needing src/ on sys.path, fixed in 693c811cd. The bridge now puts the monorepo src/ on the path itself before reading the label. A test with src/ absent and gethostname patched to Drift-MBP.localdomain gets the stable label, and it fails without the fix.
  4. The 20 s refresh resending legacy bodies, fixed in 693c811cd. Only bodies that carry health refresh every 20 s; legacy and standalone bodies keep the 600 s cadence (test_without_health_the_workers_body_keeps_the_slow_cadence). The protocol doc is updated.

Since then, 8e477bb71 makes a worker the suspension took down read alive: false · suspended. It was found in live desktop testing (details: #4944 (comment)).

CI is green at 8e477bb71. Could you re-review?

@liususan091219 liususan091219 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.

Re-reviewed at 8e477bb71 (compare from 9e1fd189, 4 commits ahead). This replaces my CHANGES_REQUESTED. Each item was run against the head code and the 9e1fd189 code:

  1. unknown-host: with util_paths unavailable and gethostname() raising, 9e1fd189 raises OSError; head builds {"host_id": "unknown-host"}. test_an_unreadable_host_still_builds_a_profile fails on the old bridge and passes at head.
  2. Shared reader: a no-reason record ({"at":5,"stopped":[]}) now reads suspended in /health, pool_remedy._marker and check_pool_suspended. At 9e1fd189 it was null in /health and None since 5 in health-check. Delegation tests call each caller and assert it went through pool_suspension.read.
  3. With src/ off sys.path and a drifting hostname: Drift-MBP before, Stable-Label now. The new test fails on the old code.
  4. A body without health keeps the 600 s re-send. test_without_health_the_workers_body_keeps_the_slow_cadence fails on the old code.

Also checked: 8e477bb7 (a stopped worker shows as not alive) has a test covering both cases; pool_remedy's src/ path insertion causes no name collisions; no hardcoded host paths. All changed test files pass locally, CI is green, CLA passes. Not verified: a live broker round trip, and the full PR outside the 4-commit delta.

— Sutando (susans-MacBook-Pro)

@marklysze

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu: could you review the packages/ part? You're the sole code owner for /packages/, so this PR needs your approval to merge. It already has approvals from bassilkhilo-ag2, sonichi, john-the-dev and liususan091219, and CI is green at 8e477bb71.

What changes under packages/ag2-sparrow/:

  • ag2_sparrow/remote_gateway_bridge.py
    • Health push. The bridge builds the health snapshot in-process: src/health_snapshot.py, loaded via _monorepo_src, the same way as the HITL module.
      • The heartbeat adds the core's health row (alive, motion, condition, reason, since) and the worker_health.v1 capability.
      • The /v1/workers report adds a health row per worker, plus suspended.
      • Only summary fields leave the Mac: reason is slugged to [a-z0-9-]{1,40}; no paths and no screen text.
    • Cadence. A health change triggers a send straight away.
      • Bodies that carry health are re-sent every 20 s, because the broker marks rows stale at 120 s.
      • Legacy and standalone bodies keep the 600 s resend.
    • host_id. The agent profile now names its host by the stable host label instead of gethostname(), which drifts with DHCP. It falls back to unknown-host if neither can be read.
  • Tests:
    • tests/test_agent_profile_host_id.py (new; listed in ci.yml's out-of-tree list)
    • tests/test_pool_advertisement_push.py (the cadence cases)
  • tools/test_no_drift.py passes.

The live round trip on a local AG2 Space stack is in #4944 (comment).

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@marklysze
marklysze merged commit 5e5e87d into sonichi:main Oct 2, 2026
31 checks passed
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.

6 participants