Skip to content

Add ty-on: a placement resolver extension that decides which host a task runs on - #698

Merged
bborn merged 1 commit into
mainfrom
task/5227-add-ty-on-a-placement-resolver-extension
Sep 1, 2026
Merged

Add ty-on: a placement resolver extension that decides which host a task runs on#698
bborn merged 1 commit into
mainfrom
task/5227-add-ty-on-a-placement-resolver-extension

Conversation

@bborn

@bborn bborn commented Aug 31, 2026

Copy link
Copy Markdown
Owner

What

Adds extensions/ty-on/, a standalone Go module that decides which machine a task should run on. It touches nothing in internal/ or cmd/ — core invokes it, it answers.

The policy for remote execution (which hosts exist, what they are provisioned for, which one to pick) is fleet-specific and must not live in core. A normal ty user never encounters any of it.

The contract

One JSON request on stdin, one JSON response on stdout, once per task, before the executor spawns.

$ echo '{"event":"task.placement","task":{"project":"offerlab"}}' | ty-on
{"target":"ol-agents","workdir":"~/projects/engineering","reason":"most free memory of 2 hosts serving offerlab (ol-agents 26.5G, mona 11.3G)"}

An empty target means "run locally", and is the answer to everything the resolver cannot confidently answer — unknown project, missing or malformed inventory, no reachable host, malformed request, on not installed. It never fails a task and never guesses a host: it exits 0 in all cases, and the required reason is written to explain a surprising placement without further digging.

Placement rules

Reads the same inventory as on: $ON_HOSTS, else $XDG_CONFIG_HOME/on/hosts.yaml, else ~/.config/on/hosts.yaml.

  1. Find hosts whose repos map contains the task's project.
  2. None → local.
  3. One → that host. Answers from the file alone, so it works with no on installed.
  4. Several → most free memory, via on ls rather than a reimplemented probe. Unreachable hosts are dropped; ties break on host name so the answer is stable.

on is an optional dependency — missing, failing, or slow all resolve to local with a reason.

Speed

Rules 1–3 are a single file read. Rule 4 costs one on ls bounded by TY_ON_TIMEOUT (default 3s), because a late answer is worth less than a local one in the spawn path. Measured against the real fleet: single-host and unknown-project placements return instantly; the multi-host probe took ~1.0s wall.

Files

File
go.mod / go.sum Separate module; only dependency is gopkg.in/yaml.v3. No dependency on core.
cmd/main.go The stdin/stdout contract, TY_ON_TIMEOUT, --help / --version.
internal/placement/placement.go Request/response types and the resolution rules.
internal/placement/inventory.go on inventory loading and project→host matching.
internal/placement/probe.go Prober interface, the on ls implementation, and its table parser.
README.md Contract, rules, environment, scope.

Tests

Table-driven over the resolution rules with the inventory in a temp file via ON_HOSTS, covering unknown project, single host, multiple hosts, unreachable candidates, missing inventory, malformed inventory, malformed request, and on not installed — every one producing a local placement with a useful reason rather than an error.

Comparison is injected through a Prober interface, so the suite needs no fleet and no on on PATH; the real OnProber keeps shelling out to on ls, and its table parser is pinned against verbatim real output.

$ go test ./...          # ok (also clean under -race)
$ golangci-lint run --config ../../.golangci.yml ./...   # 0 issues (v2.8.0, matching CI)

Note that CI does not currently build or test extensions/, so these were run locally.

Out of scope

No core changes, no hook invocation (core calls this), and no rsync/worktrees/ssh — that is on's job. Host capabilities are parsed but not used for filtering; matching an executor against them is the obvious next lever, noted in the README.

🤖 Generated with Claude Code

ty is gaining the ability to run a task on another machine. The policy for
that — which hosts exist, what they are provisioned for, which one to pick —
is fleet-specific and must not live in core, so it ships as a standalone
extension with its own module. Nothing outside extensions/ty-on is touched.

The binary reads one JSON placement request on stdin and writes one response
on stdout. An empty target means "run locally" and is the answer to every
question it cannot confidently answer: unknown project, missing or malformed
inventory, no reachable host, malformed request, `on` not installed. It never
fails a task and never guesses a host — it exits 0 in all cases, and the
required reason field explains the choice to the user.

Placement reads the same inventory as the `on` CLI (ON_HOSTS, else
XDG_CONFIG_HOME, else ~/.config/on/hosts.yaml): no host serving the project
means local, one means that host, several are ranked by free memory. Ranking
shells out to `on ls` rather than reimplementing the probe, treating `on` as
an optional dependency. Only the multi-host rule probes at all, so the common
paths answer from a single file read; the probe is bounded by TY_ON_TIMEOUT
(3s) because a late answer is worth less than a local one in the spawn path.

Tests are table-driven over the resolution rules with the inventory in a temp
file via ON_HOSTS, and inject a fake prober so the suite needs no fleet and no
`on` on PATH. The `on ls` table parser is pinned against real output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bborn
bborn merged commit 111d749 into main Sep 1, 2026
4 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.

1 participant