Skip to content

Add the typed microcosm-emitter-interface package and release it with the emitter - #22

Merged
anth-volk merged 16 commits into
mainfrom
feat/emitter-interface-package
Oct 21, 2026
Merged

anth-volk merged 16 commits into
mainfrom
feat/emitter-interface-package

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #18

Second of four stacked pull requests (base: #21). Adds microcosm-emitter-interface, the small typed package Microcosm will depend on with a version range instead of pinning the emitter. Microcosm decides what to hand over; the emitter decides where it goes.

What changes

A second distribution, packages/microcosm-emitter-interface/ (import name microcosm_emitter_interface), with no dependencies outside the standard library:

  • launcher.start_emitter runs the installed microcosm-emitter command — the command argument, then MICROCOSM_EMITTER_COMMAND, then PATH — as the build's own child process: subprocess.Popen with no env argument, configuration on standard input, --parent-pid set to the build's PID. After the readiness ping it sends hello and refuses an emitter that does not list its PROTOCOL_VERSION (unsupported_protocol_version). Startup errors carry only a fixed reason and exit code.
  • EmitterClient offers typed calls: telemetry events (client.telemetry.stage, transition_stage, progress, calibration_progress, fail, refuse, complete, emit), sent as exactly the messages of the emitter's in-process client, and Orrery input hand-overs (orrery_inventory, hand_over_orrery_inputs, orrery_receipt, retry_orrery_inputs) on the existing Orrery module. No call raises into the build: not because the emitter is missing, slow or refusing, and not because an argument cannot be converted to JSON. Each call converts its arguments inside its error handling, reports whether it was accepted, and records why not in EmitterClient.last_failure (emitter_unavailable, invalid_arguments or not_accepted).
    • The telemetry protocol's existing fields blocking_gate_ids, blocking_failure_count and gate_statuses (the identifiers of the validation checks that refused the build, how many failed, and each check's status) are filled from refuse(refusing_check_ids=…, refusing_check_count=…, check_statuses=…).
  • Protocol fixtures, fixtures/protocol-v1/*.json, shipped in the wheel: one per message the package sends.

Compatibility rule (interface README): every emitter release must accept every protocol version that interface releases still in use send. Adding a module or message keeps the version; changing a message's shape needs a new version. Tests enforce it from both sides:

  • the fixtures also record how the command is started (host-launch.json: its arguments and the configuration object on standard input) and each socket request's whole envelope (host-ping, host-message-envelope, host-query-envelope, host-close, host-hello), so an argument-parser or envelope change made on both sides cannot pass unnoticed;
  • the interface's tests check that each typed call, the launcher and the socket transport send exactly their fixture, and that every fixture has a call;
  • the emitter's contract tests check that every fixture's version is in SUPPORTED_PROTOCOL_VERSIONS, that the telemetry and Orrery modules accept each message fixture, that the installed command starts from the launch fixture and a host answers each envelope, that a hello answer has at least the recorded keys (a newer emitter may add fields), and that released fixtures keep their recorded SHA-256 (a released fixture is never edited, only added).

The emitter's in-process telemetry and Orrery clients now build their run identity, configuration, messages and receipts with the interface's code (re-exported at their old import paths), so the two kinds of client cannot drift apart.

Credential detection is preserved. tests/interface/test_launcher.py::test_child_finds_the_build_hugging_face_credential_in_the_same_order starts the real installed command through the interface with the fake collector on loopback. It sets the credential only in the test process's environment (three cases: HF_TOKEN and HUGGINGFACE_TOKEN and a saved login; HUGGINGFACE_TOKEN and a saved login; a saved login only) and checks the bearer token of the child's token exchange: HF_TOKEN first, then HUGGINGFACE_TOKEN, then the saved login. Another test checks the child's parent is the build process and its user is the build's user. All tokens are obvious fakes.

Packaging and release automation

  • The emitter depends on microcosm-emitter-interface==<same version> (a uv workspace member), so the emitter always runs the checks and fixtures of its own release. The two packages share one version.
  • tools/bump-version.py bumps both pyproject.toml version lines and the exact pin, and refuses diverged versions or pins.
  • tools/check-artifacts.sh builds both with uv build --all-packages into a dist/ it clears once beforehand (uv's --clear empties the shared folder for each package, so one build could delete the other's files; seen locally).
  • tools/check-installed.py installs the emitter with its interface into one clean environment, as uv tool install would, and the interface alone into a second, as a build's environment would; from the second it starts the first's command and sends telemetry and an Orrery hand-over (tools/installed-interface-smoke.py).
  • Publication order. ci.yml's build-packages job also uploads each distribution as its own artifact (interface-distribution, emitter-distribution). push.yaml's publish job uploads the interface first, then the emitter, each with skip-existing: true: if the interface upload fails, the emitter is not uploaded, and rerunning the job uploads only the missing files instead of stopping on "File already exists".

Release

Fragment type added: merging releases the next minor version of both distributions, 0.5.0 (tools/bump-version.py --check prints "0.4.0 -> 0.5.0" on 99cf9e2).

Done on 2026-10-21 (d8a95e9, 99cf9e2): required step once #21 merges. #21 released 0.4.0 (release commit 8b3aa29, on PyPI at 02:11 UTC). d8a95e9 merges main into this branch without conflicts, and 99cf9e2 sets the interface version and the exact pin to 0.4.0 and re-runs uv lock. Checked: bump-version.py --check exits 0; tests/test_package_names.py, tests/test_workflows.py and tests/interface/test_launcher.py pass (46). The original instruction: merging #21 moves main to a new version: 0.4.0, or a later one if #29 merges first. This branch's interface version (packages/microcosm-emitter-interface/pyproject.toml) and the emitter's exact pin (microcosm-emitter-interface==… in pyproject.toml), both 0.3.0 now, must then be set to main's version, and uv lock run again; #23, #24 and #27 inherit the change. Otherwise tests/test_package_names.py fails and tools/bump-version.py --check refuses ("Both distributions must have the same version"), so the required check blocks the merge and no release can be made from this branch.

Done on 2026-10-21 (089ae52): AGENTS.md's publishing rule. It said to publish only microcosm-emitter, which this PR contradicts. It now names both distributions, says they are released together at one version (microcosm-emitter==X requires microcosm-emitter-interface==X), and still forbids standalone telemetry packages, optional telemetry installation and further distributions.

Done on 2026-10-21 (reported by the maintainer; pending publishers aren't publicly visible, so not checked here): manual step before merging. Add a pending trusted publisher on PyPI for the new project microcosm-emitter-interface (owner PolicyEngine, repository microcosm-emitter, workflow push.yaml, environment pypi). The name is free on PyPI today. Without it, the release run's publish job fails at the interface upload, before uploading the emitter; once the publisher exists, rerunning the job completes the release.

Environment variables and secrets (release automation changed)

Name Read by Provider Wiring
APP_ID, APP_PRIVATE_KEY push.yaml versioning job GitHub Actions organization secrets (checked 2026-10-20: both listed among the organization secrets this repository can read) unchanged: secrets.* in the create-github-app-token step
PyPI credential push.yaml publish job, both upload steps PyPI trusted publishing (OIDC, id-token: write, environment pypi; checked 2026-10-20: the environment's only branch policy is main) unchanged for microcosm-emitter (it published 0.4.0 on 2026-10-21); for microcosm-emitter-interface, the pending publisher was added on 2026-10-21, and its first upload, at this PR's release, is what proves it (the project is not on PyPI yet: its JSON API answers 404)
Artifact access (ACTIONS_RUNTIME_TOKEN, ACTIONS_RESULTS_URL) ci.yml upload steps, push.yaml download steps the GitHub Actions runner, in every job of the same workflow run unchanged mechanism; the publish job downloads interface-distribution and emitter-distribution, which the verify job's build-packages job uploads in the same run
TEST_DATABASE_URL ci.yml collector job inline job env unchanged
MICROCOSM_EMITTER_COMMAND interface launcher, optional the build's environment (not used in CI); check-installed.py removes it new; optional
HF_TOKEN, HUGGINGFACE_TOKEN, HF_TOKEN_PATH, HF_HOME the emitter child process the build's environment (a Modal secret on Modal) inherited by the child; tests and check-installed.py remove the tokens and point HF_HOME/HF_TOKEN_PATH at empty temporary paths

Validation

Red-green by commit (only the test files this pull request adds or changes):

Commit Red (tests first) Green
fb40e3f Add the typed interface package that starts the installed emitter tests/interface/test_launcher.py: 12 of 12 fail (package missing). tests/test_package_names.py with the old pyproject.toml: 3 failed, 1 passed 16 passed; re-run: 16 passed
1b1c32e Ship protocol fixtures and test the emitter against them test_client_messages.py, test_protocol_fixtures.py: 32 of 32 fail (fixtures and loader missing) 32 passed; re-run: 32 passed
f28c8f7 Build, verify and release both distributions together tests/test_workflows.py, tests/test_boundaries.py: 7 failed, 25 passed (the 3 new import-boundary tests already held) 32 passed; bash tools/check-artifacts.sh exit 0; re-run: 32 passed
f7374aa Clear the shared artifact folder once before building both packages tests/test_workflows.py: 1 failed, 23 passed 24 passed; check-artifacts.sh exit 0 three runs in a row; re-run: 24 passed

ruff check, ruff format --check and bash -n tools/*.sh pass.

Notes for review

Review fixes (2026-10-20)

Each fix was written red, then green; only the test files this pull request adds or changes were run.

Commit Finding Red (tests first) Green
5a27b36 Never let a hand-over's arguments raise into the build emit_telemetry converted its command before checking availability and outside the LocalEmitterError handler, and start converted TelemetrySettings before its try: a multi-element NumPy array or tensor, a self-referencing mapping or deep nesting raised into the build even with no emitter running. tests/interface/test_client_errors.py: 12 failed 12 passed; all of tests/interface: 56 passed
dcdfb42 Publish the interface before the emitter, and let a rerun finish One pypa/gh-action-pypi-publish step without skip-existing uploaded the emitter's files first, so a failed interface upload left microcosm-emitter==X requiring a missing interface, and a rerun stopped on "File already exists". tests/test_workflows.py: 3 failed, 24 passed 27 passed; actionlint 1.7.12 clean
07327b1 Record the command's launch and the socket envelopes as protocol fixtures The launch arguments, the standard-input configuration and the request envelopes were not fixtures; the hello test failed on an added field. test_host_protocol.py, test_protocol_fixtures.py, test_client_messages.py: 5 failed, 36 passed (the envelope tests passed already: they record what the transport sends) 41 passed
5b0ff54 Build the in-process clients' messages with the interface's code The in-process clients duplicated the interface's definitions, and the in-process Orrery client could raise on an unresolvable directory, an inventory without a publication ID or an unexpected transport error. tests/interface/test_in_process_clients.py: 3 failed, 13 passed (the ten message-equivalence checks passed already) 16 passed; with tests/telemetry/test_client.py, tests/orrery/test_client.py and tests/test_boundaries.py: 135 passed; tools/check-artifacts.sh exit 0

Re-run after the last commit: tests/interface, tests/test_workflows.py, tests/test_boundaries.py, tests/test_package_names.py: 120 passed. The other commits' hashes above changed when this branch was restacked on #21.

Review fixes, second round (2026-10-20)

Each fix is a commit that adds a test failing because of the bug, then a commit with the code. Only the test files this pull request adds or changes were run.

Commits (red → green) Finding Red (tests first) Green
e5dbbbe → 3f6fc41 Report each Orrery receipt's error code from its own call After any call failed with invalid_arguments, an Orrery receipt request answered without a receipt reported invalid_arguments: the code came from the shared last_failure, which is never cleared (introduced by 5a27b36). The emitter's in-process client reports local_service_unavailable for the same answer. _request now returns the call's own failure reason; _query keeps its signature. tests/interface/test_client_errors.py: 2 failed, 12 passed (assert 'invalid_arguments' == 'local_service_unavailable') 14 passed
f6a3dc7 → 5427211 Refuse unusable hello values at startup and status-less receipts Review code-quality item 2. A hello answer whose module names are not strings made every later call raise TypeError, and an Orrery receipt without status made the waiting loops raise KeyError. The launcher now refuses such an answer (unsupported_protocol_version), and answered_receipt treats a receipt without a string status as no receipt. test_launcher.py, test_client_errors.py: 4 failed, 26 passed 30 passed
26f9f04 Say how the interface and emitter versions relate Review code-quality item 5: the interface README said the two packages "are versioned separately in practice". Documentation only: no red step —

Re-run after the last commit: tests/interface, tests/test_boundaries.py, tests/test_package_names.py and tests/test_workflows.py, 126 passed. ruff check, ruff format --check and tools/check-changelog.sh pass.

Unchanged, as open decisions: AGENTS.md's publishing rule.

🤖 Generated with Claude Code

anth-volk and others added 13 commits October 21, 2026 02:47
`microcosm-emitter-interface` is a dependency-free distribution, built
from this repository, that Microcosm will depend on with a version range.

- `start_emitter` runs the installed `microcosm-emitter` command (the
  `command` argument, then MICROCOSM_EMITTER_COMMAND, then PATH) as the
  build's own child process with no `env` argument, so the emitter
  inherits the build's environment and user and finds the Hugging Face
  credential in the same order as core/auth.py.
- After readiness it sends `hello` and refuses an emitter that does not
  accept PROTOCOL_VERSION; startup errors carry only a fixed reason.
- `EmitterClient` offers typed telemetry events, sent as exactly the
  messages of the emitter's in-process client, and Orrery input
  hand-overs. No call raises because the emitter is missing or refusing.
- The emitter depends on the interface release of the same version, as a
  uv workspace member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interface package now ships `fixtures/protocol-v1/`: one JSON file
per message it sends (telemetry events, Orrery queries, module
configurations and the host's `hello` answer).

- The interface's tests check that each typed call sends exactly its
  fixture, and that every fixture has such a call.
- The emitter's contract tests check that every fixture uses a protocol
  version in SUPPORTED_PROTOCOL_VERSIONS, that the telemetry and Orrery
  modules and the installed command accept each one, and that released
  fixtures keep their recorded SHA-256.
- The interface README states the compatibility rule: every emitter
  release accepts every protocol version still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `tools/bump-version.py` bumps the version line in both pyproject.toml
  files and the emitter's exact `microcosm-emitter-interface` pin, and
  refuses diverged versions or a pin that differs from the version.
- `tools/check-artifacts.sh` builds both distributions with
  `uv build --all-packages`.
- `tools/check-installed.py` installs the emitter with its interface into
  one clean environment, as `uv tool install` would, and the interface
  alone into another, as a build's environment would. From the second it
  starts the first's `microcosm-emitter` command and sends telemetry and
  an Orrery hand-over (`tools/installed-interface-smoke.py`).
- Import-boundary tests: the interface imports only the standard library
  and has no import side effects, and the emitter host does not import it.
- README and push.yaml describe publishing both distributions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With --all-packages, uv's --clear empties the output folder for each
package's build, so one build could delete the other's artifacts (seen
locally as a build failing on a missing dist/.gitignore). The artifact
check now removes dist once and builds both packages into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`EmitterClient.emit_telemetry` converted its command (`telemetry_message`,
then `wire_value`) before checking that an emitter was running and outside
the handler for `LocalEmitterError`; `EmitterClient.start` called
`TelemetrySettings.configuration` before its `try`. A multi-element NumPy
array or tensor (whose `.item()` raises), a self-referencing mapping or a
deeply nested value then raised into the build, even with no emitter
running. The emitter's in-process client warns and returns instead.

- Every call now builds its message lazily, inside its error handling,
  after checking that the emitter loaded the call's module. A conversion
  failure returns False (or an unavailable receipt with error code
  `invalid_arguments`), records `RequestFailure(kind, reason, error_type)`
  in `EmitterClient.last_failure` and warns once per kind of call with the
  reason and only the error's type name.
- An unexpected error from the transport is reported as `not_accepted`.
- Startup settings or a spool path that cannot be converted start an
  unavailable client with the reason `invalid_configuration`.
- `hand_over_orrery_inputs` no longer indexes an inventory without a
  publication identifier, and an unusable `wait_seconds` means no wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The publish job uploaded every file in `dist/` in one
`pypa/gh-action-pypi-publish` step without `skip-existing`, and the
emitter's files sort before the interface's. If the interface upload failed
(for example before its trusted publisher exists), PyPI held
`microcosm-emitter==X`, which requires a `microcosm-emitter-interface==X`
that does not exist, and rerunning the job stopped on "File already
exists".

- `ci.yml`'s build-packages job also uploads each distribution as its own
  artifact (`interface-distribution`, `emitter-distribution`); the combined
  `distributions` artifact stays for the collector job.
- The publish job downloads and uploads the interface first, then the
  emitter, each with `skip-existing: true`, so a failed interface upload
  leaves no emitter on PyPI and a rerun uploads only the missing files.

No environment variables or secrets change. The publish job still uses
PyPI trusted publishing (`id-token: write`, environment `pypi`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ures

The fixtures recorded each module's message and settings, but not the
arguments the launcher passes to `microcosm-emitter host`, the configuration
object it writes to standard input, or the envelope of each socket request.
An argument-parser or envelope change made on both sides would therefore
pass every test while breaking builds that still run an older interface.

- New protocol-v1 fixtures: `host-launch` (arguments and standard input),
  and the envelopes `host-ping`, `host-message-envelope`,
  `host-query-envelope` and `host-close`. The loader gains the `launch` and
  `envelope` kinds, an envelope's expected `response`, and the
  `{socket_path}` and `{parent_pid}` placeholders.
- Interface tests check that the launcher and the transport send exactly
  these fixtures; the emitter's contract tests start the installed command
  from the launch fixture and send each envelope to a host.
- The `hello` contract test now requires only the recorded keys, so an
  emitter that adds a field still passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emitter's in-process telemetry and Orrery clients duplicated the
interface's run identity, value conversion, receipts and messages, and
their error handling had already diverged: the in-process Orrery client
resolved the directory and indexed the inventory outside its error
handling, and let unexpected transport errors raise.

The emitter depends on the interface of the same version, so it now reuses
it:

- `microcosm_emitter.telemetry`: `wire_value`, `TelemetryRun`, the event
  and status types and the default failure class and heartbeat come from
  `microcosm_emitter_interface.telemetry`. `LocalTelemetryEmitter` builds
  its configuration with `TelemetrySettings` and each message with the
  interface's typed commands and `telemetry_message`, inside its existing
  error handling.
- `microcosm_emitter.orrery`: the publication shapes, `default_spool_path`
  (now `microcosm_emitter_interface.spool`), the unavailable receipt and the
  four Orrery messages come from `microcosm_emitter_interface.orrery`, which
  also gains `publication_id_of`, `answered_inventory` and
  `answered_receipt`. `GraphPublicationClient` builds each message inside
  its error handling and reports `invalid_arguments` instead of raising.

Existing import paths keep working as re-exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an unrelated call fails with invalid_arguments, a receipt request
answered without a receipt reports invalid_arguments instead of
local_service_unavailable, which the in-process client returns for the
same answer.

Red: 2 failed, 12 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EmitterClient read the shared last_failure to choose an unavailable
receipt's error code, and never cleared it after a success. A receipt
request answered without a receipt then reported invalid_arguments
whenever any earlier call had failed that way, or another thread's call
had.

_request now returns the answer together with this call's failure
reason, and _query keeps its signature for every other caller. The
Orrery receipt calls take the code from that reason, so the interface
and the in-process client return the same receipt for the same answer.

Green: 14 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hello answer whose module names are not strings passed the launcher's
check, and every later call then raised TypeError while building the
set of module names. An Orrery answer whose receipt had no status made
the waiting loops raise KeyError. Both reach the build only from a
non-conforming emitter, but no call may raise into the build.

Red: 4 failed, 26 passed. The three hello answers were accepted ("DID
NOT RAISE EmitterStartupError"); the receipt raised "KeyError:
'status'".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The launcher refuses a hello answer unless its protocol versions are
  integers and its module names are strings
  (unsupported_protocol_version), so a later emitter's differently
  shaped answer stops the client at startup instead of raising in every
  call.
- answered_receipt treats a receipt without a string status as no
  receipt. The interface and the in-process Orrery client share it, so
  both return the same unavailable receipt and their waiting loops never
  read a missing status.

Green: 30 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interface README said the two packages "are versioned separately in
practice", but the release tooling gives both one version and pins the
emitter to the same interface release. It now says that, and that a
build can still run with a different installed emitter, which is why the
socket protocol has its own version.

Documentation only; no red step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the feat/emitter-interface-package branch from 26f9f04 to 03a3c2e Compare October 20, 2026 22:47
anth-volk and others added 3 commits October 21, 2026 18:27
Brings in #21's release commit `8b3aa29`: both version lines of the
emitter's pyproject.toml and uv.lock move to 0.4.0, CHANGELOG.md gains the
0.4.0 entry, and #21's consumed changelog fragment is deleted. No
conflicts. The interface package's own version and the emitter's exact pin
on it are still 0.3.0; the next commit sets them to 0.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#21 released 0.4.0, so main's emitter is at 0.4.0 while this branch's
interface package and the emitter's exact pin on it were still 0.3.0.
`tools/bump-version.py --check` refused that ("Both distributions must
have the same version"), and tests/test_package_names.py would fail on CI.

Both are now 0.4.0, and `uv lock` records the interface at 0.4.0. With
this branch's `.added` fragment, the release this merge starts publishes
both distributions as 0.5.0 (`bump-version.py --check` prints
"0.4.0 -> 0.5.0").

Checked: `bump-version.py --check` exits 0; tests/test_package_names.py,
tests/test_workflows.py and tests/interface/test_launcher.py pass (46).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md said to publish only the `microcosm-emitter` distribution,
which this PR contradicts by adding `microcosm-emitter-interface`. The
rule now names both distributions, says they are released together at
one version (`microcosm-emitter==X` requires
`microcosm-emitter-interface==X`), and still forbids standalone telemetry
packages, optional telemetry installation and any further distribution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk merged commit 5377199 into main Oct 21, 2026
10 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.

Publish a typed interface package for Microcosm's hand-overs

1 participant