Skip to content

feat(academy): chemgraph academy launch -- remote launcher for federated campaigns#137

Closed
JinchuLi2002 wants to merge 45 commits into
argonne-lcf:dev-globusfrom
JinchuLi2002:academy-remote-launcher
Closed

feat(academy): chemgraph academy launch -- remote launcher for federated campaigns#137
JinchuLi2002 wants to merge 45 commits into
argonne-lcf:dev-globusfrom
JinchuLi2002:academy-remote-launcher

Conversation

@JinchuLi2002

Copy link
Copy Markdown
Contributor

Adds chemgraph academy launch -- a single local command that brings up a
federated ChemGraph Academy campaign across one or more HPCs, replacing
the four-terminal manual flow (ssh+qsub on each HPC, then bootstrap from
laptop). Stacks on PR #136.

What it does

chemgraph academy launch -- \
  --run-id my-run \
  --campaign federated-chat \
  --bundle-root /flare/$ALCF_PROJECT/$ALCF_USER/ChemGraph \
  --site "aurora:attach=${AURORA_COMPUTE};agents=alpha;pbs_jobid=${AURORA_JOBID}" \
  --site "crux:queue=workq-route;walltime=01:00:00;agents=beta;filesystems=home:eagle;bundle_root=/eagle/$ALCF_PROJECT/$ALCF_USER/ChemGraph" \
  --auto-bootstrap

JinchuLi2002 and others added 30 commits June 18, 2026 15:03
Mirrors the pattern already used on academy-synth-topology. Allows
local journals (e.g. symlinked from ~/.config/chemgraph-journals/)
to coexist in the repo without ever being staged.
Wire Academy's HTTP exchange (default URL: Academy-hosted
https://exchange.academy-agents.org/v1, Globus-Auth gated) as a
fourth exchange type alongside redis/local/hybrid. Validated
end-to-end on an Aurora compute node running example-002:
5 agents register against the hosted exchange, coordinator receives
bootstrap, LM traffic flows through the existing UAN relay. This is
the first time a ChemGraph Academy campaign has run on Aurora
without Redis as the messaging substrate, and the technical
groundwork for cross-HPC (e.g. Mac<->Aurora<->Polaris) campaigns.

Plumbing
- runtime/exchange.py: SUPPORTED_EXCHANGE_TYPES constant covers
  ('redis', 'local', 'hybrid', 'http') so CLI choices and dispatch
  table can't drift. New 'http' branch constructs HttpExchangeFactory
  with optional override URL. exchange_uses_redis() helper lets the
  launcher gate the rank-0 Redis subprocess without inlining the set.
- core/campaign.py: ChemGraphDaemonConfig.http_exchange_url field
  (None = use Academy-hosted default).
- runtime/registration.py: HttpAgentRegistration added to the
  _REGISTRATION_TYPES dispatch so per-rank registration files can
  round-trip through disk for the http exchange.
- runtime/daemon.py, runtime/mpi.py: matching --exchange-type
  choices, --http-exchange-url flag, observability snapshot.

Aurora-specific compute_launcher.py fixes
- _prepare_environment: do NOT strip http_proxy/https_proxy from
  os.environ when exchange_type=='http'. Aurora's profile lists those
  in unset_env for the LM relay path (loopback 127.0.0.1) which is
  correct for redis runs but breaks http exchange. Without this fix
  the parent Python had no proxy vars so the --genv flags never got
  populated, and ranks couldn't reach the public internet.
- mpiexec cmd: append --genvall plus explicit --genv KEY=VAL pairs
  for proxy vars when exchange_type=='http'. PALS's documented
  --genvall default empirically did not forward our parent env;
  explicit per-var flags were required.
- run_allocation: skip rank-0 redis-server subprocess for any
  exchange that doesn't need Redis (was inline 'in {redis,hybrid}',
  now uses exchange_uses_redis helper).

Tests (19 passing across the two suites)
- exchange dispatch parametrized over all four types
- SUPPORTED_EXCHANGE_TYPES integrity vs the dispatch table
- exchange_uses_redis answers pinned per type
- HttpExchangeFactory built with hosted default when url is None,
  with custom URL when provided
- HttpAgentRegistration round-trips through write/load
- run_allocation skips Redis subprocess for http exchange
- --http-exchange-url forwarded to daemon argv when set, omitted
  when None
- compute_launcher tests pass with the new env-prep signature

Operator prerequisites for --exchange-type http on Aurora
- Globus token cached at ~/local/share/academy/storage.db (run any
  HttpExchangeFactory() once interactively to log in via Globus).
- http_proxy / https_proxy set to the ALCF proxy
  (http://proxy.alcf.anl.gov:3128) before invoking
  'chemgraph academy run-compute'.
- ALCF_USER set to the *workspace* username (e.g. jinchu), which
  may differ from the SSH login (e.g. jinchuli).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the federated ``spawn-site`` flow. The daemon can now
launch a named subset of a campaign's agents instead of the whole
roster, and rank 0 can skip its in-process bootstrap dispatch so
kickoff is deferred to a separate operator-driven step. Both
behaviors are opt-in; existing ``run-compute`` single-machine
campaigns are untouched.

core/campaign.py
- ``filter_agents(campaign, names)`` returns a new ``ChemGraphCampaign``
  with only the named agents, preserving order so MPI rank-to-agent
  mapping stays deterministic. Rejects empty selections, duplicate
  names, and names not declared on the campaign. Deliberately does
  NOT rewrite ``initial_agent`` -- in the federated flow that name
  may refer to an agent hosted on another site.
- ``ChemGraphDaemonConfig`` gains two fields with backward-compatible
  defaults: ``agents: tuple[str, ...] = ()`` (empty = launch every
  declared agent) and ``skip_bootstrap: bool = False``.

runtime/daemon.py
- ``--agents <comma-list>`` CLI flag, parsed by ``_parse_agents_arg``
  (whitespace-trimmed, empty-segment-tolerant). When set, the daemon
  applies ``filter_agents`` BEFORE ``validate_campaign`` so the
  downstream ``selected_agent(campaign, rank)`` and
  ``wait_for_agent_statuses_finished(campaign=...)`` both see the
  local slice only.
- ``--no-bootstrap`` flag. Rank 0's bootstrap dispatch is now gated
  by ``not skip_bootstrap AND initial_agent in registrations``; the
  second clause naturally handles the case where ``initial_agent``
  lives on another site. The skipped path emits a new
  ``bootstrap_message_skipped`` system trace recording the reason
  (flag vs. non-local agent) so investigators can tell "deferred
  to operator" apart from "silently forgot".

Tests: 30/30 existing academy tests pass with the new defaults.
Focused tests for filter_agents + slicing arrive with the
``spawn-site`` CLI in the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator-facing piece of the federated flow: ``chemgraph academy
spawn-site`` launches one site of a multi-site campaign. Same
arguments as ``run-compute`` plus the slice selector ``--agents
worker-a,worker-b``; internal bootstrap is always skipped (the
operator triggers kickoff once every site is up, via the dedicated
``bootstrap`` subcommand that lands in a follow-up commit).

UX target (Aurora + Crux + Mac dashboard):
  # Aurora compute node
  chemgraph academy spawn-site -- \\
    --system aurora --campaign federated-demo.jsonc \\
    --agents coordinator-agent --exchange-type http

  # Crux compute node
  chemgraph academy spawn-site -- \\
    --system crux --campaign federated-demo.jsonc \\
    --agents worker-a,worker-b --exchange-type http

  # Mac (later, after both sides are up)
  chemgraph academy bootstrap -- ...

core/campaign.py
- ``parse_agents_selection(raw)`` promotes the comma-list parser to a
  public helper so launcher and daemon agree on whitespace / empty-
  segment handling. Duplicate detection lives in ``filter_agents``
  so the user-facing error appears in one place regardless of the
  input path.

runtime/compute_launcher.py
- ``--agents`` + ``--no-bootstrap`` flags. ``AllocationPlan`` gains
  matching ``agents: tuple[str, ...]`` and ``skip_bootstrap: bool``
  fields, both with backward-compatible defaults so the existing
  ``run-compute`` flow is unchanged.
- ``prepare_compute_launch`` derives ``agent_count`` from the slice
  length when ``--agents`` is given; refuses to mix a contradicting
  explicit ``--agent-count`` rather than silently picking one. Mpi
  ``-n`` therefore always matches the daemon's post-filter agent
  ordering.
- ``run_allocation`` forwards ``--agents`` and ``--no-bootstrap``
  into the daemon argv only when set.

runtime/daemon.py
- Drops the private ``_parse_agents_arg`` helper in favor of the
  shared ``parse_agents_selection`` import.

cli/main.py
- ``academy spawn-site`` subcommand registered. Implementation is a
  thin shell over ``compute_main`` that prepends ``--no-bootstrap``
  if the operator didn't already include it -- ``spawn-site`` is
  semantically ``run-compute`` with bootstrap disabled and an agent
  slice required.

Tests (+11, 41 -> 41 in the two touched suites; 63/63 across full
academy sweep)
- parse_agents_selection: trimming, empty segments, None / "" input
- filter_agents: order preservation, unknown-name rejection,
  empty-selection rejection, duplicate-name rejection
- prepare_compute_launch: derives agent_count from --agents, rejects
  contradicting --agent-count
- run_allocation: --agents and --no-bootstrap are forwarded when
  set, omitted when default (so single-machine flow is byte-
  identical)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…egistration

Replace the shared-filesystem JSON file
(``<run_dir>/academy_registrations.json``) with exchange-mediated
discovery. The old mechanism required rank 0 to register every agent
on the campaign and write the resulting registrations to disk for the
other ranks to pick up. That works for a single allocation on a
single FS; it cannot span machines, which blocks federated
``spawn-site`` campaigns spread across Aurora + Crux + ...

New flow
- Each rank registers ONLY its own local agent via
  ``transport.register_agent(ChemGraphLogicalAgent, name=...)``.
- Each rank discovers cross-rank / cross-site peers by polling
  ``transport.discover()`` with a wall-clock timeout, filtering the
  returned ``AgentId`` tuple client-side by ``AgentId.name``.
- No rank-0 special role for registration. Convergence is per-site:
  each rank exits the discovery loop as soon as its own
  ``allowed_peers`` are all visible on the exchange, regardless of
  what other ranks / sites are doing.
- ``bootstrap_message_dispatched`` rule simplified to
  ``initial_agent == agent_spec.name`` (instead of "name in
  registrations dict"); semantically identical for single-machine
  runs, correct for federated runs.

runtime/registration.py: gutted and rewritten. Old surface area
(``load_academy_registrations``, ``write_academy_registrations``,
``wait_academy_registrations``, ``registration_payload``,
``academy_registration_path``, ``_REGISTRATION_TYPES``,
``_exchange_type_of``) deleted in favor of a single async helper
``discover_peer_agent_ids(transport, peer_names, *, agent_class,
timeout_s, poll_interval_s)``. Returns ``dict[name, AgentId]`` for
``Handle`` construction. Times out with a message listing the
missing peer names so operators can immediately tell which site
failed to register.

runtime/daemon.py: registration block + bootstrap dispatch reworked
to match the new flow. Code shrinks: the rank-0 / rank-N branch is
gone; the post-block "if rank == 0: reload registrations" hack is
gone; ``registrations`` dict and its key lookups replaced with
``registration`` (own) plus ``peer_agent_ids`` (discovered).

observability/run_artifacts.py: ``clear_run_outputs`` no longer
deletes the dead ``academy_registrations.json`` filename.

tests/test_academy_exchange_registration.py: file-based round-trip
tests removed (their target functions no longer exist). Replaced
with discovery-helper tests against a ``_FakeTransport`` whose
``discover()`` returns pre-configured rounds:
  * empty peer list short-circuits without any discover() calls
  * happy path returns name -> AgentId for requested peers only,
    even when discover() also returns other agents (cross-operator
    isolation depends on this filter)
  * waits across multiple polls for late peers (the federated
    convergence story)
  * times out with the missing peer names in the message
  * first-found-wins for a re-seen peer name across polls

Run-id name-prefixing remains an operator-runbook convention until
auto-prefixing lands; without it, two operators running concurrent
demos against the same hosted exchange would see each other's
agents in their ``discover()`` results.

Tests: 62/62 academy sweep (was 63; net -1 because the parametrized
file-round-trip test was 4 cases and the replacement is 4 helpers +
1 short-circuit case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The federated piece the operator runs after every site is up. In a
single-machine campaign, rank 0 of the daemon dispatches the
``campaign`` -> ``initial_agent`` bootstrap message in-process; in a
federated campaign that's impossible because the agent that owns
``initial_agent`` may live on a different machine that hasn't even
come up yet. ``spawn-site`` already skips the inline dispatch
(``--no-bootstrap``). This commit adds the matching standalone
command that triggers the kickoff at the right moment from anywhere
with the cached Globus token.

UX:
  chemgraph academy bootstrap -- \\
    --campaign federated-demo.jsonc \\
    --exchange-type http

  # or override the recipient for partial re-runs / debugging
  chemgraph academy bootstrap -- \\
    --campaign federated-demo.jsonc \\
    --recipient worker-a \\
    --exchange-type http

runtime/bootstrap.py (new)
- ``parse_args``: --campaign (required), --recipient (defaults to
  campaign.initial_agent), --exchange-type (defaults to 'http' since
  that's the main use case), --http-exchange-url override, redis
  triple for the local-broker case, --discover-timeout-s.
- ``dispatch_bootstrap``: opens a user client on the configured
  exchange, discovers the recipient by name via the shared
  ``discover_peer_agent_ids`` helper, sends one
  ``receive_message`` action, closes the client (also on error so
  the aiohttp session backing the http transport doesn't leak).
- ``main``: returns exit code 2 with a stderr message when the
  recipient never appears on the exchange, so wrapping shell
  scripts can branch on "bootstrap didn't actually happen."

cli/main.py
- ``academy bootstrap`` subparser + dispatch in ``_handle_academy``.
- Usage hint updated to include the new command.

Tests (6 new, 68/68 academy sweep)
- parse_args: --campaign required, exchange-type default, recipient
  override
- dispatch_bootstrap: happy-path discovery + handle action, sender /
  recipient / message_id consistency, campaign user_task embedded in
  the dispatched content, client closed on success
- dispatch_bootstrap: client closed on timeout too (no Handle
  construction attempted when discovery fails)
- main: returns 2 on TimeoutError and writes the missing recipient
  name to stderr

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The piece that lets one Mac terminal serve a federated campaign
running across multiple HPC sites. Operator runs ONE dashboard
command with ``--system aurora,crux``; the launcher spins up
per-site SSH tunnels + UAN relays + rsync mirrors, and the server
walks the merged subdir tree to render one timeline tagged by site.

runtime/dashboard_launcher.py
- ``--system`` now takes a comma-list ('aurora,crux'). Single-site
  invocations are unchanged (the value resolves to a 1-tuple and
  the rest of the launcher uses the same per-site setup helper).
- Per-site setup extracted into ``_setup_site`` returning a
  ``_SiteHandle`` carrying everything the cleanup finally needs.
  ``main`` loops over the resolved tuple; failure on any site
  triggers teardown of the partially-set-up sites.
- Each site gets its own reverse-port (base + site_index) so two
  SSH ``-R`` tunnels don't collide on the Mac side.
- Multi-site mode rejects scalar overrides that can't sensibly
  apply to every site (--remote-host, --ssh-control-path,
  --relay-port, --lm-base-url, --local-run-dir). Operators encode
  site differences in the profile JSON instead.
- Single-site mirror layout unchanged
  (``<root>/<run_id>/``); multi-site mirrors under
  ``<root>/<run_id>/<system>/``.

dashboard/server.py
- ``_iter_site_dirs`` detects layout: if ``events.jsonl`` is at the
  top level it's legacy single-site; otherwise walk subdirs and
  treat each as a site if it has ``events.jsonl`` OR
  ``dashboard_metadata.json``. The metadata check covers the
  early-startup window where a site is up but no events have been
  written yet, so federated dashboards don't briefly look like
  "empty single-site".
- ``events_payload``: legacy shape preserved for single-site;
  federated merges sites in timestamp order with a ``site`` tag on
  each event so the UI can color/group per-site.
- ``status_payload``: legacy keys preserved for single-site;
  federated nests per-site status/placement/summary under
  ``sites: {<name>: ...}`` with a top-level ``updated`` reflecting
  the latest per-site update.

Tests (+10, 78/78 academy sweep)
- _iter_site_dirs: recognizes metadata-only sites; falls back to
  single-site for empty dirs
- events_payload: merges + tags by site; timestamp-sorted output
  even when sites are seeded reverse-order
- status_payload: nests under ``sites`` for federated, preserves
  legacy keys for single-site (regression guard against an
  accidental "make them uniform" refactor)
- _parse_systems_list: single name, comma-list with whitespace,
  rejects empty, rejects duplicates

Aurora ⇄ Crux demo runbook (operator runs once both sites have a
system profile in the repo):

  # Mac terminal A
  chemgraph academy dashboard -- federated-demo-001 \\
    --system aurora,crux --campaign federated-demo.jsonc

  # Aurora compute
  chemgraph academy spawn-site -- \\
    --system aurora --campaign federated-demo.jsonc \\
    --agents coordinator-agent --exchange-type http

  # Crux compute
  chemgraph academy spawn-site -- \\
    --system crux --campaign federated-demo.jsonc \\
    --agents worker-a --exchange-type http

  # Mac terminal B
  chemgraph academy bootstrap -- \\
    --campaign federated-demo.jsonc --exchange-type http

(Crux profile JSON is still TODO -- pre-requisite for the actual
demo, not for the dashboard code.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lidation

The non-code artifacts that turn the federation primitives shipped
in B.1-B.4c into an actually runnable Aurora ⇄ Crux demo.

runtime/profiles/crux.template.json (new)
- Mirrors polaris.template.json (/eagle paths) but with Crux-specific
  bits: separate venv directory name (academy-swarm-crux) so it
  doesn't collide with the existing x86_64 Polaris venv on the same
  /eagle workspace; -crux suffix on the relay host file so per-site
  relays in the multi-site dashboard don't fight over the same path.
- Registered in profiles/__init__.py BUILTIN_SYSTEM_PROFILES so
  ``chemgraph academy spawn-site --system crux`` and the multi-site
  dashboard launcher both recognize it.
- Same unset_env policy as Aurora/Polaris -- proxies stripped by
  default for the LM-relay path; the launcher's exchange_type=='http'
  branch already overrides this so http exchange works via the
  ALCF proxy (proxy reachability empirically verified on Crux
  compute today).

campaigns/federated-hello/ (new)
- Two agents (agent-aurora, agent-crux), each declaring the other as
  its only allowed peer. No MCP servers, no resources, no science
  tools -- the smallest possible end-to-end campaign that exercises
  cross-site discovery + cross-site send_message + LM-driven
  decision turns. ~$0.01-0.05 of GPT-5-mini calls per run.
- agent-aurora's mission: send ONE 'hello from aurora' to agent-crux,
  finish_turn, then finish_turn on every subsequent wakeup.
- agent-crux's mission: wait, reply ONCE, finish_turn. Strong
  anti-loop guidance in both missions + the prompt profile.
- prompt_profiles/default.json: tight system + protocol prompts that
  explicitly say "no science tools, only send_message and
  finish_turn." langchain_recursion_limit=32 since neither agent
  should ever loop more than a handful of rounds.
- lm_config.json: GPT-5-mini template (no temperature field, since
  reasoning models reject non-default values -- the launcher's
  auto-strip would handle it but cleaner to just omit).
- Registered under 'federated-hello' in CAMPAIGNS +
  CAMPAIGN_LAUNCH_DEFAULTS so ``--campaign federated-hello`` works
  as a packaged name (no rsync of the campaign dir required).

core/campaign.py: validate_campaign(*, federated=False)
- New keyword-only flag loosens two single-machine assumptions that
  break in federated spawn-site flows:
    * initial_agent may name an agent hosted on another site
    * each agent's allowed_peers may reference cross-site agents
  Both are looked up via the exchange at runtime, so the validator
  legitimately can't pre-check them in a federated slice.
- Intra-slice checks (duplicate names, self-peer, MCP server / tool
  / resource resolvability) still run. Self-peer in particular
  stays a hard error because it would loop messages regardless of
  how many sites the campaign spans.

runtime/daemon.py
- Passes federated=bool(config.agents) to validate_campaign. The
  presence of an --agents slice is the canonical indicator of
  "I'm one site of a federated launch." Single-machine
  run-compute flows pass federated=False (the default), so prior
  behavior is byte-identical.

Tests (+2, 80/80 academy sweep green; was 78)
- validate_campaign federated=True accepts the cross-site peer
  reference in a federated-hello slice that strict validation
  rejects (regression guard for the relaxation).
- validate_campaign federated=True still rejects self-peer
  (regression guard against accidentally relaxing too much).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The minimum-scope UI work that makes the federation story visible
in the dashboard. Without it the merged event payload (B.4c.2)
landed on a UI that displayed everything as if it were a single
machine -- timeline still rendered, agent graph still rendered, but
operators / demo viewers had no visual cue that the campaign was
spanning multiple HPCs.

What the UI now shows in federated runs:
* Header bar: "Sites: aurora (1🤖 / 12📨) · crux (1🤖 / 8📨)" so
  the multi-site nature is immediately legible from the top of
  the dashboard.
* Agent-graph swimlanes labelled by site ("aurora", "crux")
  instead of by individual compute hostnames ("x4708...",
  "x1000...") -- same nodes, same edges, far clearer story.
* Message-flow detail panel: route is labelled "cross-site"
  (federated) or "cross-node" (single-machine) depending on
  context, with "From site" / "To site" rows showing aurora vs
  crux. The literal hostname is still available in each agent's
  detail panel.
* Cross-node-messages metric becomes meaningful as "messages that
  crossed the HPC boundary" in federated runs.

Single-site runs are visually byte-identical to before:
``snapshot.federated`` is false so ``agentGroup`` falls back to
``agentHost``, the sitesBadge stays hidden, route labels stay
"cross-node" / "same-node", detail rows stay "From host" /
"To host". Test suite (80/80) confirms server-side payload shape
is unchanged for single-site.

Implementation
- ``load()``: detect ``snapshot.sites`` (set by server-side
  ``_iter_site_dirs`` in B.4c.2), set ``snapshot.federated``,
  build a flat ``sitesByAgent`` index from
  ``sites[*].status.agents`` and ``sites[*].placement.agents``,
  backfilled from per-event ``site`` tags as authoritative.
- ``agentSite(agent)`` / ``agentGroup(agent)``: the single point
  where federated vs single-site rendering diverges. Every
  renderer that asks "what bucket does this agent belong to" now
  goes through ``agentGroup`` instead of ``agentHost``.
- ``renderSitesBadge()``: header-bar federation indicator with
  per-site agent counts and per-site event counts.
- Three message-route detail panels updated to label by group
  rather than hardcoding "host", and to show "cross-site" in
  federated mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For accounts whose SSH login differs from the workspace directory
name. ALCF_USER drives every path interpolation
(``/flare/${ALCF_PROJECT}/${ALCF_USER}/``) while ALCF_SSH_USER drives
only ``remote_host`` (``${ALCF_SSH_USER}@aurora.alcf.anl.gov``). The
two collided on a single env var until now, forcing operators to
choose: set ALCF_USER for paths and get the wrong SSH login (which
triggered an ALCF Cyber security challenge on Aurora), or set it
right for SSH and have all the run-dir / venv paths point at a
non-existent directory.

The relevant operator on this repo has SSH login ``jinchuli`` but
their Aurora/Crux/Polaris workspace lives under
``/{flare,eagle}/<proj>/jinchu/`` (no trailing 'i'), so the
ALCF_USER=jinchu setting was producing the right paths but the
wrong SSH user. Now they set ALCF_USER=jinchu for paths and
ALCF_SSH_USER=jinchuli for SSH and both work.

Default ALCF_SSH_USER to ALCF_USER when unset, so the majority of
users for whom the two are equal don't have to set both.

system.py
- New ``_expand_with(text, env)`` does ``os.path.expandvars``-style
  substitution against a caller-supplied env dict rather than the
  process environment, so the SSH-USER default doesn't leak into
  ``os.environ`` for subsequent callers.
- ``load_system_profile`` copies the environ, fills in the default,
  and substitutes through ``_expand_with``.

profiles/{aurora,crux,polaris}.template.json
- ``remote_host`` now interpolates ``${ALCF_SSH_USER}``; every
  other field still uses ``${ALCF_USER}`` for the path component.

Tests: 80/80 academy sweep still green. Default-case behavior (both
env vars equal) is byte-identical to the prior single-var setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ide; crux relay port 18187

Two operational fixes from the Aurora<->Crux federated demo.

compute_launcher.py
- Default startup_timeout_s 120s -> 600s. The realistic worst case
  for federated launches is one site's HPC queue wait + Python
  imports outpacing the other site's peer-discovery patience; 120s
  is comfortably too short. 600s comfortably absorbs debug-scaling
  / workq schedule delays. Single-machine launches reach
  discover_peer_agent_ids in seconds so the new ceiling never
  matters for them.
- New --startup-timeout-s CLI flag so operators can extend the
  window further when they know a site will be slow.

profiles/crux.template.json
- Bump relay_port 18186 -> 18187 to dodge a leftover ssh -R
  reverse-tunnel that's still bound to 127.0.0.1:18186 on
  crux-uan-0001 from a prior failed dashboard launch. Follow-up
  cleanup: launcher should probe for a free port instead of
  insisting on the profile's hardcoded one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The daemon was opaque during its slow stretches -- import,
registration, peer discovery, runtime entry, and waiting for the
bootstrap message all happened silently from the operator terminal.
You could not distinguish "still importing" from "stuck on discovery"
from "alive and waiting for bootstrap" without tailing events.jsonl
or checking the dashboard.

Add four landmark prints, all grep-able as ``[daemon]`` or
``[agent <name>]``:

daemon.py
- ``[daemon] rankN registered <name> on the exchange`` -- own-
  registration completed; next step is peer discovery
- ``[daemon] rankN discovering peers [...] (timeout 600s)...`` --
  entering the wait
- ``[daemon] rankN discovered N peer(s): [...]`` -- past discovery,
  about to enter Runtime
- ``[daemon] rankN agent <name> is now running inside Academy
  Runtime`` -- agent is alive and listening
- ``[daemon] rankN dispatched inline bootstrap to <initial>`` /
  ``... skipping inline bootstrap (federated mode); waiting for
  chemgraph academy bootstrap ...`` so the operator knows whether
  to fire the standalone bootstrap subcommand

core/agent.py
- ``[agent <name>] first message arrived from <sender> (kind=...):
  <tldr>`` on the FIRST inbox message. For the federated demo the
  recipient agents both print this -- agent-aurora when bootstrap
  lands, agent-crux when the hello arrives. Concrete "kickoff
  worked" signal without needing the dashboard.

All prints flush=True so they survive PALS/MPICH buffering when
mpiexec is forwarding many ranks stdout simultaneously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dezvous

The B.4 federated demo kept timing out at discovery -- both sites
registered their agents, neither could find the other. Root cause
turned out to be that Academy's hosted HttpExchange strips
``AgentId.name`` from ``discover()`` responses: only ``uid`` and
``role`` round-trip. Our name-based filter ``if agent_id.name in
wanted`` was silently never matching across sites because every
discovered AgentId came back with name=None. (The original ChemGraph
test suite missed this because it used ``AgentId.new('worker-a')``
fakes that preserve the name -- the same fakes the real hosted
exchange does not.)

Replacement: deterministic UIDs.

registration.py
- ``deterministic_agent_uid(run_id, agent_name)`` derives a stable
  uuid5 from a fixed namespace + ``"{run_id}/{agent_name}"``. Same
  inputs on every site produce the same UID, so each rank
  constructs every peer's AgentId locally instead of needing
  ``discover()`` to echo the name back.
- ``deterministic_agent_id(run_id, agent_name)`` builds the full
  AgentId with the local name preserved (for trace events) and
  the deterministic UID.
- ``register_agent_with_uid(transport, agent_class, agent_id)``
  bypasses the SDK's ``register_agent`` (which always generates a
  random UID via ``AgentId.new``) and POSTs the pre-built
  deterministic AgentId directly to the same mailbox endpoint.
- ``wait_for_peers_alive(transport, peer_ids, ...)`` replaces
  ``discover_peer_agent_ids``. Matches on UID (preserved by
  discover()) instead of name (stripped). Times out with a
  message listing missing peer names+UIDs.

daemon.py
- Imports + uses the new helpers. Each rank computes its own
  AgentId deterministically and registers with it, then computes
  every peer's AgentId locally and waits for the peer's mailbox
  to be visible on the exchange. No "discover by name" anywhere.
- Runtime is still handed a real HttpAgentRegistration wrapping
  the deterministic AgentId, so the agent runs unchanged.

bootstrap.py
- New ``--run-id`` required arg. The recipient's mailbox UID is
  derived from (run-id, recipient-name); operator must pass the
  same run-id they used for spawn-site or the bootstrap addresses
  a different mailbox than the daemons registered.
- Bumped ``--discover-timeout-s`` default 120s -> 600s to match
  spawn-site's startup_timeout_s.
- Uses ``deterministic_agent_id`` + ``wait_for_peers_alive``
  instead of name-based discovery.

Side effect: agent names are now campaign-scoped via the run-id.
Two operators running the SAME campaign with the SAME run-id will
collide on the mailbox UIDs and the second registration will fail
with "mailbox already exists" -- correct fail-fast behavior. The
old run-id-prefixing convention from the original docstring is now
load-bearing rather than advisory.

Tests (+5, 85/85 academy sweep green)
- deterministic_agent_uid: stable; differs by run_id; differs by
  agent_name
- deterministic_agent_id: name preserved locally
- wait_for_peers_alive: empty list short-circuits; succeeds when
  all UIDs present (with names stripped, mirroring the real
  exchange response); waits across polls for late peers; times
  out naming missing UIDs; ignores unrelated agents
- bootstrap: requires --run-id; defaults discover-timeout to 600s;
  sends to deterministic recipient AgentId; closes client on
  timeout; main() returns 2 with stderr message

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…event names

The skip-trace I added in 5549dbb (operator-visible daemon
lifecycle prints) writes a system trace with event name
bootstrap_message_skipped, but that name was never added to
the CampaignEvent.event Literal enum. The pydantic validator
rejected it, crashing the daemon RIGHT AFTER the
[daemon] ... is now running inside Academy Runtime print.

Cosmetic-but-fatal regression that the test suite missed because
no test exercises the skip-bootstrap code path through
append_system_trace -- the federated demo is the first place
this code path runs end to end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e context

handle.action reads the outbound exchange from a contextvar that
is only set when a client is active. Runtime sets it for daemon-
side code, but the standalone bootstrap command needs to set it
explicitly via client.register_handle(handle) -- otherwise
Handle.action raises ExchangeClientNotFoundError.

The first federated demo attempt failed here: discovery succeeded,
the message was built, the Handle was constructed -- and the
action call died because the Handle did not know which client to
route through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le finds exchange

UserExchangeClient.__aenter__ is what sets the
academy.handle.exchange_context ContextVar that Handle.action reads
to find the outbound exchange. The prior register_handle-only fix
binds the handle for inbox routing but does NOT set the contextvar,
so the action call still raised ExchangeClientNotFoundError.

Restructure dispatch_bootstrap to run the whole send inside
async with client: -- exchange_context is set on enter, restored on
exit. The aiohttp session gets closed by __aexit__, so the explicit
client.close() became redundant.

Test fixture _FakeClient is now an async-context-manager stand-in;
the two close-on-success / close-on-timeout assertions check
enter_count/exit_count instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…odels

Ported from the synth branch. GPT-5* and o1/o3/o4* reject any non-
default temperature with HTTP 400 'Unsupported value: temperature
does not support 0.0'. Both ChatOpenAI construction sites
(load_openai_model and agent/turn._custom_openai_compatible_kwargs)
now consult is_reasoning_model() and drop temperature + the other
sampling knobs when the model is one of those.

Same module-level is_reasoning_model() helper as on the synth
branch so a future merge stays mechanical.

This was the last bug between the federated-hello demo daemons
making their first LM call and completing the round trip. Both
sites successfully discovered each other, received the bootstrap
message, and entered their first reasoning round; the round
crashed at the LM call because the demo uses GPT-5-mini.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…paign

Federated-hello produced 2-3 events per site and the demo was over
before the dashboard could render anything. Federated-chat is a
back-and-forth counter game between agent-aurora and agent-crux:
each turn one agent increments a counter and sends to the peer,
until the counter hits 10. ~6 reasoning rounds per agent = ~40
events total in the merged dashboard timeline, plus visible
message-flow with cross-site Route labels.

Same two-agent shape as federated-hello so the same operator
runbook works -- only --campaign federated-chat changes.

Registered under 'federated-chat' name with max_decisions=20
slack for retries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tus to top level

The server returns per-site state nested under snapshot.sites[<site>].status /
.placement in federated mode, but agents() reads snapshot.status?.agents, so
the dashboard rendered an empty graph + zeroed metrics for federated runs
even though events were streaming through correctly. Synthesize merged
top-level status/placement during load() so every existing single-site
reader (agents, metrics, workflow mode detection) works unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…he port

The relay had no signal handlers, so SIGTERM hit the default disposition
and bash kill(1) calls were silently ignored. The python kept running,
the port stayed bound, and the next launch failed with "Address already
in use" -- requiring a manual UAN sweep to recover. Install SIGTERM/SIGINT
handlers that close the listen socket and exit cleanly, with try/except
around accept() so the close-from-handler path returns instead of
tracebacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…race and self-kill guard

Aurora's login alias round-robins across uan-0001..uan-0010, so a single
pid file on the shared FS was meaningless: the pid only exists on the
UAN that ran the relay, and the next launch usually lands on a different
UAN where that pid is either absent or belongs to someone else. As a
result every crashed launch left an orphan relay holding the port, and
manual ssh-into-each-UAN cleanup was the only recovery path.

Replace the single-pid bookkeeping with per-UAN cleanup that scans
ps for python processes whose argv contains the relay script path,
owned by $USER, excluding $$ and $PPID. The self-exclusion is load-
bearing: pgrep -f matched our own bash script (the relay script path
appears in our argv as well), so the previous attempt killed the
caller instead of the orphan.

Also drop set -e (pgrep returning 1 was triggering silent exits with
no log output) and add exec 2>&1 + set -x so the local relay log
contains a full trace when something fails -- previously failures
produced empty logs and "Local relay log:" with nothing after it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Conflicts (tests only, no src/ conflicts):
- tests/test_academy_dashboard.py
- tests/test_academy_exchange_registration.py

Both resolved by keeping dev-globus's `pytest.importorskip("academy")`
guard and dropping the academy.exchange.{local,hybrid,redis} imports
that this branch no longer uses (the deterministic-UID rendezvous
replaced them).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed-chat

federated-chat covers the same Aurora<->Crux federation smoke path
with more dashboard material to render, so the hello campaign is
dead weight. Remove the campaign files + registry entries, and
re-point the validate_campaign(federated=True) regression test
+ registration.py docstring example to federated-chat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eholder

Two related ergonomics fixes for spawn-site / run-compute launches:

* When --lm-user is omitted, fall back to $ARGO_USER from the env.
  HPC users already export ARGO_USER for the rest of the ChemGraph
  workflow, so requiring a duplicate --lm-user flag was busywork.
* _write_lm_config now refuses to ship lm_config.json with the
  template's literal "<argo-user>" placeholder. Argo would otherwise
  silently accept the launch and only reject at first LM call time,
  after the daemon + relay stack was already running -- expensive to
  debug. The hard error names the fix directly.

Tests: stub HttpExchangeFactory in test_academy_exchange_registration
so the http-dispatch tests don't try to authenticate against the real
hosted exchange.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four-terminal walkthrough for the cross-HPC demo: dashboard on Mac
+ spawn-site on Aurora + spawn-site on Crux + bootstrap kickoff.
Mirrors the existing example-002 guide's shape, swaps in the federated
flow (deterministic peer UIDs, HTTP exchange, ALCF proxy passthrough,
Globus device-flow login). README links to the e2e guide and points at
the packaged campaign location.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* dashboard_launcher.py: split `import os, shlex, ...` onto separate
  lines (E401).
* mpi.py: drop unused `write_json_atomic` import (F401), left over
  from the file-based registration scheme that was deleted in 52fa7b5.

Pre-existing ruff failures elsewhere in the repo (parsl_tools,
mace_mcp_parsl, etc.) are not from this PR and untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e site

New ``launch`` subcommand drives a federated launch from the
operator's laptop by ssh'ing straight into a compute node inside
an interactive PBS allocation the operator has already opened.
Replaces the 'ssh in, source env, run spawn-site, watch it' loop
with one local command per campaign.

Scope (phase 1): exactly one ``--site``, attach-mode only. Submit-
mode (fresh qsub), multi-site, preflight, auto-bootstrap, Ctrl-C
affordances, and status/stop subcommands land in phases 2-5 per
plan.private-local.md.

Implementation notes:
- ``--site NAME:attach=HOST;agents=a,b`` grammar uses ``;`` between
  KV pairs so comma stays free as the agents CSV separator.
- Readiness is polled off the existing ``placement.json`` that
  ``write_status_snapshot`` already writes after registration --
  no new sentinel protocol.
- The rendered remote command uses ``exec`` so SIGTERM from the
  ssh process propagates to the python process instead of being
  caught by the bash wrapper. The unit test guards against
  accidentally dropping ``exec`` in a future refactor.
- Stays out of the compute_launcher / daemon / exchange code
  paths; the daemon and spawn-site invocation run unchanged.

Tests: 12 new units (parser, command rendering). 81/81 in the
academy sweep.
Add submit-mode to ``chemgraph academy launch`` so each --site can
either attach to a running interactive allocation (phase 1) or
qsub a new PBS job via the login node. Mode is inferred from the
--site flag: attach=HOST -> attach, queue=Q;walltime=... -> submit,
both or neither -> error.

Files:
- site_backend.py: SiteBackend Protocol (start / wait_ready / stop).
  Earned its keep now that two backends exist; the orchestrator
  loop is one ``await backend.start()`` regardless of mode.
- submit_backend.py: render PBS script, scp to login node, qsub,
  poll ``qstat -x -f -F json`` for state transitions, qdel on stop.
  PBS script rendering stays inline -- one caller, ~30 lines, a
  separate job_script.py would be theater.
- attach_backend.py: new AttachSiteBackend wrapping the existing
  start/wait_ready/stop functions to implement the Protocol.
- site_spec.py: parse submit-mode keys (queue, walltime, nodes,
  project, filesystems). Reject mixed/incomplete combinations.
- remote_launcher.py: dispatch on site.mode, resolve login_host
  from the system profile's remote_host for submit-mode.

Once the PBS job reaches R, submit-mode reuses attach-mode's
placement.json polling for the registration wait, splitting the
overall ready-timeout-s budget 80/20 between queue-wait and
registration-wait. Submit-mode-specific PBS submission and qstat
parsing run in asyncio.to_thread so a future multi-site
orchestrator (phase 3) can submit to several HPCs concurrently
without ssh blocking the event loop.

Per-site project= wins over global --project. Lets a multi-site
invocation set one default and override per HPC.

Tests: 8 new units covering submit-mode parsing (positive +
incomplete-input rejection), PBS script rendering (with/without
filesystems, project required, per-site project override). 20/20
in the launcher suite; 105/105 in the academy sweep.
Drop the single-site cap and add --auto-bootstrap. --site can be
passed multiple times; each entry chooses its own mode. Mixed
attach + submit in the same invocation is supported (case C from
plan.private-local.md: operator has an Aurora interactive
allocation already open, lets Crux auto-qsub).

End-to-end with two pre-existing interactive allocations now works:

  chemgraph academy launch \
    --run-id run-008 \
    --campaign federated-chat \
    --bundle-root /flare/$ALCF_PROJECT/$USER/ChemGraph \
    --site aurora:attach=<aurora-compute>;agents=alpha \
    --site crux:attach=<crux-compute>;agents=beta \
    --auto-bootstrap

Orchestrator phases:
1. build_backends(): parse every --site into a (SiteSpec, backend)
   pair. Errors surface as exit 2 before any remote work runs.
2. asyncio.gather(*(b.start() for b in backends)): parallel start.
   If any raises, stop() every backend and exit 1.
3. asyncio.gather(*(b.wait_ready(...) for b in backends)): parallel
   ready-wait. First exception cancels the rest; teardown follows.
4. If --auto-bootstrap, call bootstrap.main() in a thread (it runs
   its own asyncio loop) with --campaign / --run-id / --exchange-
   type / --recipient propagated from the launcher's args.
5. Print per-site lifecycle reminders (inside the operators
   allocation vs as a PBS job) and exit 0 leaving compute processes
   running.

Bootstrap-after-ready failures intentionally do NOT tear down the
sites: agents are still useful, operator can rerun bootstrap.

Tests: 9 new units using a _FakeBackend test double for the
SiteBackend Protocol. Covers: all-ready happy path, wait_ready
failure stops siblings, start failure short-circuits before
wait_ready, auto-bootstrap argv generation (default + --recipient
override), bootstrap-skipped-without-flag, bootstrap-failure-keeps-
agents-running, --site is required, multi-site parsing accepts
mixed-mode. 29/29 in launcher suite; 114/114 in academy sweep.
Same family as the old UAN relay self-cleaning bug -- different
layer, same lesson: Ctrl-C on the launcher must reliably tear down
whatever the launcher just spawned, with no orphans on the HPC.

1. attach-mode: ssh now uses ``-tt`` to force PTY allocation.
   Without it, Ctrl-C kills the local ssh but the remote python
   keeps running inside the operators allocation -- the SSH
   channel closes, no SIGHUP propagates without a controlling
   terminal, and the python keeps waiting on its inbox forever.
   With -tt, channel close hangs up the PTY which delivers
   SIGHUP to the python (combined with the existing ``exec``
   that makes python the foreground process group leader).

2. submit-mode: submit_job() now persists the qsub-returned
   job_id to <local_run_dir>/<site>.job_id BEFORE returning.
   Threads cant be cancelled by asyncio, so a Ctrl-C arriving
   during ``await asyncio.to_thread(submit_job, ...)`` would
   complete the qsub but never reach the assignment to
   self.job_id -- stranding a PBS job with no recorded id.
   On-disk persistence lets a future status/stop subcommand
   (or operator manual qdel) find the orphan. Stderr still
   prints the id so operator can grep terminal scrollback.

Tests: 2 new regression guards. attach test mocks Popen and
asserts ``-tt`` appears in the ssh argv; submit test mocks
subprocess.run and asserts the job_id file exists with the right
contents after submit_job returns.

31/31 launcher; 116/116 academy.
…tem HPCs

Add a per-site bundle_root override so a single launch invocation
can target HPCs that store user data on different filesystems --
specifically Aurora (/flare) and Crux (/eagle). Previously the
global --bundle-root was applied to every site, which only worked
if all sites happened to share a path prefix.

Same shape as the existing per-site project= override from phase 2:
- bundle_root=PATH inside --site wins
- --bundle-root global serves as the default
- error (exit 2) if neither is set for a given site

--bundle-root flag is now optional; required only when at least one
--site lacks its own bundle_root=.

Concrete example that now works:

  chemgraph academy launch -- \
    --run-id r1 --campaign federated-chat \
    --bundle-root /flare/$ALCF_PROJECT/$ALCF_USER/ChemGraph \
    --site aurora:attach=h1;agents=alpha \
    --site crux:attach=h2;agents=beta;bundle_root=/eagle/$ALCF_PROJECT/$ALCF_USER/ChemGraph

Tests: 7 new units. Cover per-site override, global fallback,
neither-set error, submit-mode bundle_root, and launch-time exit-2
propagation when no resolution is possible. 38/38 launcher;
123/123 academy.
…failures untraceable

When the operator ran a launch with bad Crux config, both the
local launcher AND the per-site attach.log produced zero output --
ssh died on the remote (no source code at the wrong bundle_root)
but the launcher polled placement.json for 300s with no progress
indication and no error surface.

Three independent invisibility bugs, each fixed:

1. attach.log was opened on the spawn-site exec line via `>> log
   2>&1`. So anything failing before that point (mkdir, source,
   cd) produced zero output -- the log file never even got
   created. Now the remote bash redirects its own stdout+stderr
   to attach.log BEFORE attempting source/cd, plus `set -x` so
   the trace shows what the shell tried.

2. ssh Popen used stderr=DEVNULL, so ssh-layer failures (host
   unreachable, auth denied, "permission denied") went nowhere.
   Now stderr is inherited; the operator sees ssh errors
   immediately on the launcher's terminal.

3. wait_ready() didn't check whether the ssh process had died.
   So when ssh exited within seconds, wait_ready kept polling
   placement.json for the full 300s timeout. Now wait_ready
   accepts the Popen handle, short-circuits with RuntimeError
   the moment ssh exits, and includes the tail of attach.log
   in the error message.

Also: periodic "still waiting for <agents>" log every 30s during
wait_ready so the operator can tell the launcher isn't hung.

Tests: 2 new regression guards. One asserts the log redirect
appears before source/cd in the rendered remote script; one
verifies wait_ready raises RuntimeError (not TimeoutError) the
instant the ssh proc reports a non-None returncode. 40/40
launcher; 125/125 academy.
…ute nodes

The phase-1 attach backend assumed the laptop could ssh directly
to a compute node (`ssh x4610c7s2b0n0 ...`). That fails on ALCF
HPCs (Aurora, Crux) for two compounding reasons:

1. Compute node hostnames don't resolve from the public internet.
2. Even with a working transport, compute nodes accept only
   hostbased auth from login nodes -- ProxyJump's bytes-forwarding
   doesn't satisfy that trust relationship.

Switch attach mode to nested ssh: laptop -> login -> compute. The
inner ssh runs on the login node, where compute-node trust holds.
We verified ProxyJump (`ssh -J`) fails on Aurora even though
nested ssh succeeds.

AttachConfig gains a `login_host` field; empty string means
"direct ssh to compute_host" (back-compat for on-prem clusters).
The launcher reads system_profile.remote_host to populate it for
ALCF systems.

Read-only probes (cat placement.json, tail attach.log) target the
login node directly: placement.json lives on the shared FS, login
can read it without bouncing through compute, one less hop, no
agent-forwarding concerns.

Tests: 2 new units. One asserts the rendered ssh argv nests
properly when login_host is set; one asserts the direct-ssh path
still works when login_host is empty. 42/42 launcher; 127/127
academy.
…nv vars

Two problems hit during the first live smoke against Aurora.

1. The launcher tried to source ``{bundle_root}/env.{system}.sh``,
   which doesn't exist in the repo -- I invented that pattern.
   The existing manual runbook does:
     source /flare/.../venvs/academy-swarm/bin/activate
   So switch to sourcing the venv's activate script directly. The
   path is already in the system profile as ``venv_python``; we
   derive ``$(dirname venv_python)/activate``.

2. Without manually-typed env vars (ALCF_USER, ALCF_SSH_USER,
   ALCF_PROJECT, ARGO_USER, http_proxy, ...), spawn-site fails:
   ARGO_USER -> lm_config rejects <argo-user> placeholder,
   http_proxy -> compute node can't reach the hosted exchange,
   ALCF_USER mismatch -> path resolution to wrong workspace.
   The operator used to type these inside qsub -I. The launcher
   now reads them from the operator's local shell and forwards
   them via ``export K=V; ...`` inside the remote bash.

Heavily commented the var list because the names are genuinely
confusing: ALCF_USER (workspace path component, e.g. "jinchu"),
ALCF_SSH_USER (login, e.g. "jinchuli"), ARGO_USER (LM api user,
e.g. "jinchu.li") can ALL be variants of the same person's name
but are NOT interchangeable. Got bitten by this twice during
PR argonne-lcf#136 debugging.

AttachConfig:
  env_script (made-up) -> venv_activate (real)
  + remote_env dict for ssh-side exports

Launcher:
  --env-script -> --venv-activate
  + _collect_remote_env() snapshot at launch time

Empty values filtered out so a missing ARGO_USER produces the
familiar spawn-site error rather than overwriting a value the
login node might've inherited from a different source.

Tests: 3 new units (renders exports, empty env doesn't break the
shell, _collect_remote_env drops empties + picks up the right
names). 45/45 launcher; 130/130 academy.
Surface a generic ``--spawn-arg`` (repeatable) that the launcher
appends verbatim to the remote ``chemgraph academy spawn-site``
invocation. Saves adding a dedicated --flag every time we discover
a spawn-site option the launcher should expose.

Wired through both backends:
- AttachConfig.extra_args (existing field, was unwired) <- args.spawn_arg
- SubmitConfig.extra_spawn_args (new field) <- args.spawn_arg
  ...placed AFTER --exchange-type so the operator's values win
  against the launcher's defaults.

Concrete example unblocking the first live smoke -- when the
operator's qsub -I allocation can fit all agents on fewer nodes
than the default agents_per_node=1 would require:

  chemgraph academy launch -- ... \
    --spawn-arg --agents-per-node --spawn-arg 2

Tests: 1 new unit. Verifies passthrough values appear in the
rendered remote command in argv-order, after the launcher's own
defaults so they can override.
…allocation

Nested ssh into a compute node inherits ZERO PBS env from the
operator's qsub -I shell. So when spawn-site invokes mpiexec
inside the attach-mode session, PBS_NODEFILE is empty and PALS
falls back to "this host only" -- ``Cannot place all ranks on
node list`` for any --agent-count > 1 even though the allocation
actually has multiple nodes.

Verified on Aurora:
  qsub -I shell: PBS_NODEFILE has 2 hostnames
  ssh into same compute: PBS_NODEFILE is empty

Fix: --site accepts ``pbs_jobid=<id>`` (copy from
``echo $PBS_JOBID`` inside qsub -I). The remote bash reconstructs
both PBS_JOBID and PBS_NODEFILE before invoking spawn-site:

  export PBS_JOBID=8568282.aurora-pbs-0001....
  export PBS_NODEFILE=/var/spool/pbs/aux/$PBS_JOBID

The nodefile path is deterministic ( /var/spool/pbs/aux/$PBS_JOBID )
and any process inside the allocation can read it, so no need to
copy contents around -- just point PBS_NODEFILE at the right file.

Optional field: single-node attach runs don't need it and skip
the exports entirely.

Usage:
  --site "aurora:attach=${AURORA_COMPUTE};agents=alpha,beta;pbs_jobid=${PBS_JOBID}"

Tests: 3 new units. Parser accepts pbs_jobid=. Backend exports
both PBS_JOBID and PBS_NODEFILE when set. Skips exports entirely
when unset. 49/49 launcher; 134/134 academy.
…ype=http

Aurora compute nodes need an outbound HTTP proxy
(http://proxy.alcf.anl.gov:3128) to reach the hosted Academy
exchange at exchange.academy-agents.org. The launcher already
forwards env vars from the operator's laptop shell to the remote
bash -- but operators almost never have http_proxy set on their
laptop, so the forwarded set was always empty for the proxy keys.

Result: compute ranks under mpiexec saw no http_proxy/https_proxy
even though the launcher's --genv path inside compute_launcher.py
was wired correctly. ConnectionTimeoutError from aiohttp to
exchange.academy-agents.org/v1/mailbox.

Fix: when exchange_type=http, _collect_remote_env() defaults
http_proxy / https_proxy / no_proxy to the ALCF values if the
operator hasn't supplied them. setdefault semantics -- operator's
explicit values still win, so corporate-network users with their
own proxy aren't overridden.

Same proxy values that aurora.template.json's no_proxy already
encodes; centralised here as constants because they apply to
every ALCF system identically.

Tests: 3 new units. http_exchange injects defaults; redis/local
skip injection; operator-set values win.
…he bash command

The previous commit injected NO_PROXY=127.0.0.1,localhost,.alcf.anl.gov,*.alcf.anl.gov
into remote_env when exchange_type=http. shlex.quote correctly
wraps comma-containing values in single quotes, but the launcher
then wraps the WHOLE bash script in single quotes for the
inner ssh, then wraps THAT in single quotes for the outer
laptop->login ssh. Three layers of single-quote escape produced
4-8 consecutive single quotes around the NO_PROXY value, which
bash's -c parser choked on -- killing the whole script before
mkdir/exec ran, leaving no log behind.

Symptom: ssh exit 255, attach.log empty ("no log written --
bash failed before opening it").

Fix: don't inject NO_PROXY at all. compute_launcher's
_prepare_environment already writes the correct value from
profile.no_proxy, so the rank process has it even without
remote_env injection. The launcher's job was only to put
http_proxy/https_proxy in the bash env so compute_launcher
(running INSIDE the bash) sees them and propagates via mpiexec
--genv to the ranks; NO_PROXY was bonus belt-and-braces that
isn't actually needed.

http_proxy values (URLs without shell-special chars) survive the
triple-shell round trip without escape damage. Verified zero
quote runs >= 4 in the rendered command.

Tests: updated _collect_remote_env_injects_alcf_proxy_for_http
to assert NO_PROXY is NOT in env (was previously asserting
presence). 52/52 launcher; 137/137 academy.
…s don't SIGHUP the agent

After ``-tt`` was added to make Ctrl-C propagate cleanly, idle SSH
connections during spawn-site's wait_for_peers_alive (no output
for minutes while waiting for the other federated site to come
up) could close the PTY and SIGHUP the remote python -- agent
dying as signal 1.

Add ``-o ServerAliveInterval=30 -o ServerAliveCountMax=999`` to
both the outer (laptop->login) and inner (login->compute) ssh
invocations. Matches the cadence the dashboard launcher already
uses for its UAN relay tunnel.

Belt-and-braces -- many such hangs would have other causes, but
this removes one common one and costs us nothing.

Tests updated: argv-position assertions loosened to "contains -tt"
instead of "argv[1] == -tt", since the keepalive flags now sit
between -tt and the host.
… launch

Document the new launch subcommand UX validated end-to-end against
Aurora today. Sits alongside the existing four-terminal manual
e2e_guide.md (which still covers cross-HPC since Crux attach-mode
isn't yet supported).

launcher_guide.md covers:
- Prereqs (existing ChemGraph staging + cached Globus token)
- One-time setup per HPC: just `qsub -I` then copy
  $(hostname) + $PBS_JOBID to the laptop shell
- Three look-alike ALCF usernames (ALCF_USER vs ALCF_SSH_USER vs
  ARGO_USER) and which gets used where
- Dashboard-first launch order (relay must be up before
  spawn-site reads relay_host_file)
- Full --site flag reference (attach vs submit, all KV pairs)
- --spawn-arg passthrough syntax (with the `=` form so argparse
  doesn't eat the inner flag)
- Ctrl-C cleanup semantics (kills agents, leaves allocation)
- Failure surfacing -> per-site attach.log
- Crux limitation section: ssh login->compute blocked by missing
  hostbased trust; three forward options enumerated for future
  work
…ssages

The launcher reads ALCF_PROJECT, ALCF_USER, ARGO_USER (and optional
ALCF_SSH_USER) from the operator's shell and forwards them to the
remote bash. When any required var is missing the operator used
to discover this only after the daemon failed deep in spawn-site
30+ seconds in -- the error trail across run-001..009 was
mostly variations of this.

Add `_preflight_env` that runs BEFORE any ssh/backend construction
in `_launch`. Prints per-var status to stderr (so the operator can
verify what the launcher will forward) and exits 2 if any required
var is missing.

Each missing-var error includes:
- WHAT the var means in concrete terms tied to something the operator
  can verify (e.g. ALCF_PROJECT is the directory under /flare, check
  with `ls /flare`)
- WHY it's needed
- HOW to set it (the literal `export NAME=example` line)

The descriptions intentionally include the three look-alike usernames
(jinchu / jinchuli / jinchu.li) and what distinguishes them. Got
bitten by ARGO_USER vs ALCF_USER vs ALCF_SSH_USER confusion several
times during the e2e debug.

Escape hatch: --skip-preflight for the operator who arranges remote
env from elsewhere (e.g. login-node /etc/profile.d defaults).

Tests: 5 new units. Required-vars-present passes; all-missing
returns 3 errors; optional ALCF_SSH_USER doesn't appear in errors;
each error message contains the literal `export ` fix; --skip-
preflight bypasses the check entirely AND --no-skip exits 2 before
build_backends runs. 58/58 launcher; 143/143 academy.
…ield name

SubmitSiteBackend.wait_ready() builds an AttachConfig-shaped view
of its SubmitConfig to reuse attach-mode's placement.json polling.
The construction still passed ``env_script=...`` -- which was
AttachConfig's field name BEFORE the env-forwarding commit
renamed it to ``venv_activate``. Live qsub'd successfully, both
sites reached state R or Q, then wait_ready blew up with
``TypeError: AttachConfig.__init__() got an unexpected keyword
argument 'env_script'``.

Caught in the first live submit-mode smoke (submit-smoke-002).
Renamed the kwarg to ``venv_activate`` and also added
``login_host=self.cfg.login_host`` so the read-only placement.json
probes target the login node (which is what makes sense in
submit-mode -- the compute host isn't even known until the PBS
job lands).

Tests: 1 new regression guard that touches each AttachConfig field
wait_ready actually reads through a faked _placement_wait_ready,
so future AttachConfig renames surface in tests instead of after
a real qsub. 59/59 launcher; 144/144 academy.
Operators were missing the preflight results and PBS state
transitions in a wall of plain stderr text. Add minimal ANSI
helpers (~20 lines, no new dependency) and color the lines that
carry decision-time information:

- preflight per-var rows: ok=green, MISS=red, skip=yellow.
  Var name bold; resolved value cyan.
- preflight FAIL summary: red.
- PBS qsub job id: cyan+bold so it stands out for copy/paste.
- PBS state transitions: R=green+bold, Q/H/W/...=yellow,
  F/E/X=red+bold. Lets the operator see "Q -> R" as a clear
  positive signal at a glance.
- launch ready / bootstrap dispatched: green.
- bootstrap failed: red.

Auto-disabled when stderr isn't a TTY OR when NO_COLOR is set
(https://no-color.org) so log files don't fill with escape
codes. Uses raw ANSI escape sequences rather than pulling rich
into module-load time -- rich is already a transitive dep for
the main CLI's banner/table rendering but it's heavy and not
needed for what amounts to one-letter status badges on a few
stderr lines.

submit_backend imports the helpers lazily inside the functions
that print, avoiding a circular import with remote_launcher.

Tests: 2 new units. Helpers are no-ops when _USE_COLOR=False;
emit \033 escape sequences when True. 61/61 launcher; 146/146
academy.
…rd relay

Two bugs surfaced by the submit-mode smoke (submit-smoke-003).

Bug 1: wait_ready's diagnostic message hardcoded `<site>.attach.log`.
Submit-mode writes spawn-site stdout/stderr to `<site>.pbs.log`
(via #PBS -o ...). Operator gets ``(no log written)`` chasing a
file that doesn't exist when the real log is sitting next to it
under a different name. Made the log basename a parameter; submit
backend passes `<site>.pbs.log`, attach defaults to `<site>.attach.log`.

Bug 2: operator can launch a site whose UAN relay was never set
up because the dashboard's --system list didn't include it. The
daemon then crashes at `_relay_host_from_profile` ("Could not
determine UAN relay host") -- which surfaces to the launcher as
a silent placement.json polling timeout, not an actionable error.

Add `_preflight_dashboard`: per --site, ssh the login node and
test for profile.relay_host_file presence + non-empty content
(that's where the dashboard writes after successfully staging
start_relay.sh). MISS -> fail with a copyable suggestion
including ALL sites in the launch, not just the failing one
(operators usually re-run the whole dashboard, not piecemeal).
ssh failures are warnings, not fatal -- might be transient and
the launcher will get a clearer error if it tries to use the
host later.

Same --skip-preflight escape hatch as the env check.

Tests: 4 new units. Relay file present -> pass. Missing -> error
with --system suggestion that lists every site. Multi-site
partial-fail -> suggestion includes both sites. ssh transport
failure -> warn-not-fail. 65/65 launcher; 150/150 academy.
…script

PBS batch jobs only inherit a tiny default env (PATH, PBS_*),
NOT ALCF_USER / ALCF_PROJECT / ARGO_USER from the operator's
login shell. So when the system profile loader does
${ALCF_USER}/${ALCF_PROJECT} substitution, it fails with
"unresolved environment variables" -- caught live in
submit-smoke-004 where the daemon died inside the PBS job.

Mirror attach-mode's solution: SubmitConfig.remote_env carries
the operator's forwarded env, render_pbs_script emits
`export KEY=value` lines BEFORE `source <env_script>` so the
env_script can also reference them and so spawn-site itself
sees them.

Launcher wires remote_env from _collect_remote_env() into both
attach and submit backends symmetrically.

Tests: 1 new unit (export rendering + order vs source). 66/66
launcher; 151/151 academy.
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