Skip to content

Corpus migration: 40 cells declare env { ST_AGENT "<team>.<seat>" }, which st2 now rejects at boot (the runner owns ST_AGENT) #75

Description

@schickling

st2 now owns ST_AGENT and forces it to the runner-derived bus id <host>.<team>.<seat>. Any authored value that differs is rejected at admission, before any model starts. This is an intentional upstream boundary, not an st2 bug (see Why this is intended below) — but it silently invalidates most of this corpus.

40 of the 59 cells declare the short env { ST_AGENT "<team>.<seat>" } form, across 94 seat declarations, and every one of them now aborts its eval at boot:

Error: agent '<host>.t.alpha' task 'agent' declares conflicting ST_AGENT 't.alpha'; expected runner-owned value '<host>.t.alpha'

The migration is a one-line deletion per seat. The runner injects ST_AGENT unconditionally, so every prompt that uses $ST_AGENT keeps working with no other change.

Reproduction

Free and self-contained — every seat is exec sleep 5, no model is ever launched. Needs only st2 on PATH; does not touch this repo.

#!/usr/bin/env bash
cell=$(mktemp -d); mkdir -p "$cell/fixture/alpha" "$cell/fixture/beta" "$cell/fixture/j"
echo 'Any body.' > "$cell/task.md"
HOST=$(hostname)

run() { # run <label> <team-body> [<extra-eval-body>]
  printf '\n--- %s\n' "$1"
  cat > "$cell/cell.kdl" <<KDL
team "t" {
$2
}
eval { copy "./fixture"; message { from "requester"; to "t.alpha"; content "./task.md" }; max-timeout "8s"
${3:-}
  judges { judge "the team booted" { exec "true" } } }
KDL
  st2 eval "$cell" 2>&1 | grep -E '^(== boot|Error:|  \[|SCORE|VERDICT)' | sed "s/$HOST/<host>/g;s/^/    /"
}

st2 --version
run 'CASE A — a team seat declares the short form ST_AGENT "t.alpha"' \
    '  agent "alpha" { workspace "./alpha"; env { ST_AGENT "t.alpha" }; command "exec sleep 5" }'
run 'CASE B — control: the env block omitted entirely' \
    '  agent "alpha" { workspace "./alpha"; command "exec sleep 5" }'
run "CASE C — control: ST_AGENT declared as the runner-owned <host>.t.alpha" \
    "  agent \"alpha\" { workspace \"./alpha\"; env { ST_AGENT \"$HOST.t.alpha\" }; command \"exec sleep 5\" }"
run 'CASE D — blast radius: alpha valid, beta conflicting' \
    '  agent "alpha" { workspace "./alpha"; command "exec sleep 5" }
  agent "beta"  { workspace "./beta";  env { ST_AGENT "t.beta" }; command "exec sleep 5" }'
run 'CASE E — an agent declared inside eval{} with a bare ST_AGENT' \
    '  agent "alpha" { workspace "./alpha"; command "exec sleep 5" }' \
    '  agent "judge" { workspace "./j"; env { ST_AGENT "judge" }; command "exec sleep 5" }'
rm -rf "$cell"

Actual output (hostname elided as <host>):

st2 0.1.0+b868a07 — committed 4 days ago

--- CASE A — a team seat declares the short form ST_AGENT "t.alpha"
    == boot team (1 agents) ==
    Error: agent '<host>.t.alpha' task 'agent' declares conflicting ST_AGENT 't.alpha'; expected runner-owned value '<host>.t.alpha'

--- CASE B — control: the env block omitted entirely
    == boot team (1 agents) ==
      [PASS] the team booted  (exit 0)
    SCORE: 1 PASS / 0 FAIL / 1 gating judges
    VERDICT: PASS

--- CASE C — control: ST_AGENT declared as the runner-owned <host>.t.alpha
    == boot team (1 agents) ==
      [PASS] the team booted  (exit 0)
    SCORE: 1 PASS / 0 FAIL / 1 gating judges
    VERDICT: PASS

--- CASE D — blast radius: alpha valid, beta conflicting
    == boot team (2 agents) ==
    Error: agent '<host>.t.beta' task 'agent' declares conflicting ST_AGENT 't.beta'; expected runner-owned value '<host>.t.beta'

--- CASE E — an agent declared inside eval{} with a bare ST_AGENT
    == boot team (2 agents) ==
    Error: agent '<host>.judge' task 'agent' declares conflicting ST_AGENT 'judge'; expected runner-owned value '<host>.judge'

What each case establishes:

  • A — the form this corpus uses is rejected at boot. The eval exits non-zero with the reason printed, so it fails closed and loudly (no silent pass).
  • Bthis is the migration. Deleting the env block entirely boots cleanly, and $ST_AGENT is still set in the task environment by the runner.
  • C — declaring the runner-owned value also boots, but it hard-codes a machine hostname into a cell, so it is not portable. Do not migrate this way.
  • D — one conflicting seat aborts the whole eval; the valid sibling is never launched. A partial migration therefore buys nothing: a cell has to be fully clean to run.
  • E — an agent declared inside eval { } rather than team { } goes through the identical check and fails identically. license-mit / license-mit-codex declare env { ST_AGENT "judge" } on exactly this kind of eval-level judge — there is no special case for it, and it needs the same edit.

Why this is intended upstream

  • compoundingtech/st2#64Decide ownership of stable agent identity at PTY creation — poses the choice explicitly. Direction A. Runner-derived identity: "st2 forces ST_AGENT=<host.identity> into every task of the agent; authored conflicts either fail validation or lose to the runner." That is the boundary that shipped.
  • compoundingtech/st2#192One conflicting ST_AGENT aborts the whole reconcile pass, and st2 up --once still exits 0 — records the provenance: "The behaviour arrives with the runner-owned task identity work; the previous revision supervises both specs and does not fail the pass." The open question there is about st2 up --once's exit status and the scope of the abort, not about whether the runner owns the value. st2 eval (Case A/D above) already exits non-zero with a clear diagnostic.

The relevant st2 code, on main at a8dc061485ec38e8aca2f3118a05b131254c9663:

  • src/reconcile.rs:220-242, validate_task_identities — for every running local spec, task.env.get("ST_AGENT") must either be absent or equal spec.bus_id(this_host); otherwise TaskIdentityAdmissionError::Conflict. It is called from reconcile (:395, :505), from src/materialize.rs (:620, :824, :884) and from the eval/up paths in src/run.rs (:1472, :1643, :1746, :1795), so there is no path that skips it.

  • src/reconcile.rs:245-257, runner_task_env — unconditionally inserts ST_AGENT=<bus_id> into the launched task's environment:

    let mut env = task.env.clone();
    env.insert("ST_AGENT".to_owned(), bus_id.to_owned());

    This is why deleting the declaration is safe: the variable the prompts read is supplied by the runner, with the correct host-qualified value that a cell cannot know at authoring time.

st2 validate reports the same conflict, so a migration can be checked without running an eval:

$ st2 validate --catalog <root> --host h1
ERROR  agents/h1/alpha/agent.kdl: agent 'h1.alpha' task 'agent' declares conflicting ST_AGENT 'alpha'; expected runner-owned value 'h1.alpha'
─ 1 error, 0 warnings across 1 agent

Scope in this repo

Measured at 12c96acbbdc654f27ccb27cae064ef4311d4f7f7:

grep -lE 'env[[:space:]]*\{[[:space:]]*ST_AGENT' cells/*/*.kdl | wc -l   # -> 40 cells
grep -hcE 'env[[:space:]]*\{[[:space:]]*ST_AGENT' cells/*/*.kdl | paste -sd+ | bc   # -> 94 seats

Every declared value is short: 92 are two-part <team>.<seat>, 2 are bare (ST_AGENT "judge"), and zero are host-qualified — so all 94 conflict on every host.

The 40 affected cells (seat counts)
  • assignment-contract-cold-assignment (2)
  • assignment-contract-cold-focus (2)
  • assignment-contract-cold-resources (2)
  • assignment-contract-handoff-assignment (4)
  • assignment-contract-handoff-focus (4)
  • assignment-contract-handoff-resources (4)
  • assignment-contract-hot-assignment (3)
  • assignment-contract-hot-focus (3)
  • assignment-contract-hot-resources (3)
  • crash-ding (7)
  • ding-mode (2)
  • ding-reply (1)
  • docs (2)
  • feature-fit (2)
  • fork-in-the-road (4)
  • fork-in-the-road-codex (4)
  • ghost-bug (2)
  • ghost-bug-codex (2)
  • inbox-hygiene (2)
  • incident-response (2)
  • license-mit (3)
  • license-mit-codex (3)
  • migration (2)
  • poisoned-pr (2)
  • poisoned-pr-codex (2)
  • restart-continuity (3)
  • security-audit (2)
  • signal-rename (4)
  • signal-rename-codex (4)
  • skill-inheritance (1)
  • test-writing (2)
  • vrs-cross-file-absent (1)
  • vrs-cross-file-present (1)
  • vrs-definition-of-done-absent (1)
  • vrs-definition-of-done-present (1)
  • vrs-scope-drift-absent (1)
  • vrs-scope-drift-present (1)
  • vrs-scope-pressure-absent (1)
  • vrs-scope-pressure-present (1)
  • weird-git-setup (1)

Not affected: the 7 canonical agent.kdl files inside cell fixtures that declare ST_AGENT (context-resource-continuity, hook-integrity ×2, managed-agent-color-env ×2, st2-network, st2-doctor-structure) all declare an explicit host and a fully host-qualified value matching it — e.g. host "cr" with ST_AGENT "cr.agent", host "color" with ST_AGENT "color.ambient". Those already equal the runner-owned bus id, so they pass validate_task_identities unchanged. managed-agent-color-env in particular appears to exercise env precedence deliberately; nothing in this issue asks for it to change. (render-target-safety's fixture spec only consumes $ST_AGENT in a render target, file ".st2/generated.txt" "AGENT=$ST_AGENT" — it declares nothing, and the runner-injected value keeps that working.)

Separate, overlapping breakage worth knowing about while migrating

Migrating ST_AGENT will not by itself make every affected cell green. compoundingtech/st2#248 reports that a compact-team eval's flat message bus is disabled by any spec-shaped file under the run catalog, and compoundingtech/st2#247 reports that any package.json with a top-level type key qualifies. 22 of the 59 cells here ship such a fixture manifest and all 22 declare ding. That is an upstream defect, not a corpus problem, and it is tracked there — noted only so a failing post-migration run is not mistaken for an incomplete migration.

Suggested action

Delete the env { ST_AGENT "…" } declaration from all 94 seats (including the eval-level judges), keeping any other variables in the same env block. Do not substitute a hostname. Prompts referring to $ST_AGENT need no change.

Versions

  • st2 --version: 0.1.0+b868a07b868a0710a59fc40efd44a3e96bc92903d1376bb. All output above was produced by this binary.
  • st2 source citations: main at a8dc061485ec38e8aca2f3118a05b131254c9663 (2026-08-11). Caveat: that tree was source-inspected only, not built or run; validate_task_identities and runner_task_env are unchanged there relative to the binary under test.
  • Last revision observed booting the short form: st2 9887b2842222def0838c2cd82e6c24c218f7efa6 (2026-07-28), consistent with the provenance note in st2#192.
  • Corpus counts: this repo at 12c96acbbdc654f27ccb27cae064ef4311d4f7f7.
  • OS: Linux 6.18.33.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions