Repository navigation
Harbor's multi-container tasks run: a task whose compose file names services besides main (a database, a judge, an MCP
server) runs as a stack, on Docker as a docker compose project, on Singularity, Apptainer and enroot as a rootless pod
(experimental), on the host network where pods cannot run, and on the process provider, and a fetch now locks such
tasks as runnable. Harbor datasets come from the Harbor Hub, git repositories and upstream adapters; a task may run as
the users it names (agent_user: honor), on Docker and on Singularity with fakeroot; and node services serve every
rollout on a node, as the judge of the new FrontierSmith recipe does. Training gets an extensible trainer API
(experimental): one lifecycle for every trainer, step hooks, supervised fine-tuning (trainer: sft), credit estimators
per environment entry with on-policy distillation from a frozen teacher, per-datum stale masks, several policies in one
run (frozen, trained in lockstep, or each stepping on its own clock), and a tool allowlist per agent role. The training
code moved out of platoon.train.tinker into packages named after the stages of a run; the old module paths keep
working, with a deprecation warning, until 0.6.0. The hpc extra requires hpc-sandbox 0.5.1. The TextCraft extension's
release for platoon 0.5 is platoon-textcraft 0.4.0.
New features
- Harbor datasets from the Harbor Hub, git repositories and adapters. A Harbor environment's
datasetmay name a
package of the Harbor Hub (harbor://org/name@ref, a dataset or a single task, at a revision, asha256:digest
or a tag), a dataset of Harbor's legacy registry (harbor://name@version, from Harbor'sregistry.json, or the
file or URLregistrynames), or a directory of a git repository (git+<url>@<commit>#<path>), andadapter: {repo, commit, path, command, args, env, sparse}in its place generates the tasks with an upstream adapter at a
pinned commit, as Frontier-CS's and Harbor's own adapters do. The Hub's packages come through the Harbor CLI at
platoon's pin (harbor==0.24.0, which platoon runs withuvxand never imports;PLATOON_HARBOR_CLInames
another), resolved to their digest and downloaded by it, and each task's files must hash to the digest the Hub gives
it; a registry's tasks and a git directory come through shallow, blobless fetches that check out only the selected
tasks, and an adapter runs on a checkout of its repository at the commit (whole, or assparsesays), its output
fetched as a local directory. Everything else is as for any fetched dataset: selection, overlays, Harbor's content
hash, the stack plans of compose files and the stacks they run as (below), the lock, which now also records how the
source resolved (source), and rollout workers that read only the lock and the cache. A Hub revision or digest, a
full git commit, an adapter's commit and a version in a registry file are pinned, so thatplatoon trainand
platoon evalfetch them themselves; a tag, a branch, a registry task atHEADand Harbor's own registry move,
andplatoon harbor fetch CONFIG --updateresolves them into the lock (Fetch a dataset by
name). A symbolic link that leaves its task fails the
fetch, so that no file of the fetching host reaches the cache, and a URL with credentials is refused;registryand
a localadapter.repoare relative to the config file, as every input path is. - Harbor compose files are planned when a dataset is fetched, and those that name only
mainrun. A task's
environment/docker-compose.yamlis read as Harbor layers it, with compose's interpolation and Harbor's variables in
platoon's meaning (CONTEXT_DIR, the log directories'HOST_*_PATH,CPUS,MEMORY), into a stack plan that the
lock records with each task (compose, under the lock'scompose_rule), and its kind inflags.stack:single,
trivial, the newmain_only, orsidecars. A file that names onlymainand gives it settings runs as the task's
one sandbox on every provider: its environment, working directory, resources and binds (the task's own files,
read-only from the cache, a writable bind as a copy; volumes below a bind keep what they mask out of the sandbox), its
healthcheck, and its command, started asmain's init, which the kill before grading spares with what it started;
an init that is gone by grading makes the attempt anerror(main_exited), and acopyfork of such a task is
refused. A file outside the subset platoon runs, security-relevant keys such asprivilegedor acap_addof
NET_ADMINincluded, is refused at fetch, naming the service and key (compose: <service>.<key>: ...). A dataset's
overlay may edit compose services ([[compose]]: set, replace or unset their keys, or mark one external) and list
the host variables a compose file may name (compose_host_env), which sessions resolve when they open, so that no
host value enters a lock; a read-only bind may name the path a listed variable names, and no other host path. A
file with services besidesmainruns as a stack (below)
(Compose files). - Sandboxes can keep a task's processes through
kill_processes.Sandbox.keep_processes(entries), optional, spares
processes a session started for the task before any agent ran, with their descendants; the built-in providers have
it (Write a sandbox provider). - Harbor tasks that are multi-container stacks run. A task whose compose file names services besides
main(a
database, a judge, an MCP server) runs as a stack on a sandbox provider with the newstackscapability:mainis
the sandbox a main-only file would give the task, and each sidecar a sandbox of its own, with its image, command
(compose's over its image's), environment, healthcheck, volumes (named ones shared
by the services that name them), binds (writable ones as one copy of the task's files that every service binding it
shares, which the stack owns), ports and names. The services start in dependency order, under compose's three
conditions (service_started,service_healthy, andservice_completed_successfullyfor init jobs, each bounded
bysandbox_start_budget_s), and the session opens once all of them are up, asdocker compose up --waitdoes, and
a last probe finds them so; a sidecar that fails, exits or never turns healthy fails the session's start
(sidecar_start, anerror), and nothing of the stack is left, also when the start is cancelled. Grading runs the
task's[[verifier.collect]]hooks in their services and probes every sidecar before and after the tests (before
and after the artifacts are collected, for a separate verifier, which also receives the artifacts that
artifacts[].servicenames from a sidecar's own files): a sidecar that is down makes the outcomeinvalid
(sidecar_unhealthy, with its name, state and log), whatever the reward files say, unless the environment sets
on_sidecar_down: grade. What a sidecar writes in a log directory it binds (${HOST_VERIFIER_LOGS_PATH}) reaches the
tests after grading made that directory afresh, so the agents can neither erase nor forge it.sharedforks share the
stack andcopyforks are refused; a rollout's deadlines count the services' starts and health waits (their images'
healthchecks too) along their dependencies' longest path; outcomes record each service's run mode and the state
that the grade's probes found (services). On theprocessprovider, which isolates nothing, each declared port is
a port outside the kernel's ephemeral range that the stack holds for its life, which a service learns from
PLATOON_PORT_<port>, and a reference to a service at a port it declares (scheme://<service>:<port>,
<service>:<port>) in environment values, commands, healthchecks, collect hooks,[verifier].envand the task's MCP
servers' URLs reaches it, as does a service's ownlocalhost:<port>.stacks: refuserefuses every stack, and so
does a provider without the capability (singularityandenrootwhere pods cannot run, below); a fetch locks stacks
as runnable unless itsrefusesetssidecars: true(Changes).
Stacks that no rollout could start (a dataset directory's registry sidecar that its compose file gives no command, or
that builds from a context, neither of which a directory's missing image map can give) are skipped by a dataset
directory and stop a run when a fetched dataset's tasks are listed
(Multi-container tasks). - A Harbor environment can run every sandbox without a network.
network_policy: nonegives every sandbox and
stack of the environment no network, in every phase, for datasets whose answers the web holds;task, the default,
gives each phase the network its task asks for, and a stack graded in its ownmaingets the verifier's
(Multi-container tasks). - Write a provider that runs stacks.
platoon.sandbox.stacksdefines the stack spec (StackSpec,ServiceSpec,
Healthcheck,Dependency,Volume), the runningStack(main,services,health(),logs(),
set_network(),connect(),rewrite(),stop()),StackProvider.start_stack()and thestackscapability, and a
generic driver,drive_stack, that starts a stack's services in dependency order over a backend that starts one at a
time, rolls back on a failure or a cancellation, and probes them
(Write a sandbox provider). - Harbor stacks run on Docker, each a
docker composeproject of its own. Thedockerprovider has thestacks
capability: it writes each stack's project file from the stack's spec, as JSON (which compose reads as YAML, so no
value changes its type), into a directory below its newstack_rootoption, which must be a directory of the
user's own, with no:in its path, and each service is a container that compose creates once the service's
dependencies hold, running its command (compose's over its image's) as its image's user, which platoon starts in
order, health-checks and probes as on every provider. The services share aninternalnetwork of the project's,
which gives the host no address, so that the host's own services are out of their reach too, and on which each
answers to its name and aliases; a bridge network is connected for the phases with egress and disconnected for
those without;mainreaches the model gateway through a route, as a container without a network does. Volumes are
directories of the project, filled before any service starts. A start that fails or is given up waits for the
docker commands it ran, which the daemon carries out whatever becomes of their CLI, and teardown removes the
containers and networks that carry the stack's label, with the containers' anonymous volumes, then the directory,
what services wrote there as other users through a container ofmain's image; a reap by label removes a killed
worker's stacks, and a stack whose teardown failed stays registered for that. Stacks need the CLI's compose plugin,
which the provider checks when a run lists its tasks, warning of a daemon older than 25.0.5, whose internal
networks let DNS queries out (Stacks on Docker). platoon preparebuilds the images of stacks' sidecars. A sidecar that builds from a context of the task
(build:, planned whole) is built byplatoon prepareandplatoon harbor images buildwith the provider's
builder, with its build arguments (thedockerbuilder's--build-arg; the others refuse an image that has any),
and recorded in the image map, so that no rollout builds one;platoon harbor images planlists it, with the role
service. A run whose sidecars' images the map lacks stops when it lists its tasks, naming theplatoon prepare
command, as for the tasks' own images (Build task images).- Privileges for the stacks of datasets you trust, on Docker alone. A fetch with
refuse: {privileges: false}keeps
the compose services that ask forprivileged, or for acap_addthat a rootless service's root does not hold
(NET_ADMIN;DAC_READ_SEARCH, with which a root that is the host's, as on a rootful Docker, reads any of the
host's files; ...), which a fetch refuses by default, and plans them as the stack's privileges; such a stack runs
only on a provider with the newprivilegedcapability,docker, and every other refuses it when a run lists its
tasks.platoon.sandbox.privilege_refusalsays why a provider would not give them
(Stacks on Docker). - Harbor stacks run as rootless pods on Singularity, and on Apptainer and enroot (experimental). With hpc-sandbox
0.5, thesingularityandenrootproviders declare thestackscapability and start each stack as an hpc-sandbox
pod: one user and network namespace of its own, whose services share one loopback, so each reaches the others by name
at the ports it declares, as in compose, and no stack reaches another's.mainruns as root (your uid, as a single
sandbox does); a sidecar whose image runs as root runs as root with your subordinate ids, so that the official
database images' own switch to their user works as in Docker, where the runtime gives that mode (not enroot) and your
user has them, else as your own uid, as does a sidecar whose image runs as another user.main's upstreams are the
pod's, which its holder relays, so the gateway's route inmainis out of the agents' reach; a stack's network
switches for the whole pod per phase (slirp4netns for egress); its volumes are directories in the pod's own directory,
with Docker's modes, which the pod's deletion takes, with what a sidecar wrote there as another user. A log directory
that a sidecar with your subordinate ids binds reaches the tests through that sidecar, which reads what its own users
keep from yours, and a file grading cannot read makes the outcome anerror. A sidecar's read-only binds of task
files that not every user may read (a dataset fetched under a umask such as 027) bind a copy that every user reads, as
a Docker host's checkout is, since a sidecar may read them as a user of its own; the stack's other copies get those
modes too. Every connection between a pod's services comes from127.0.0.1, so a sidecar that trusts loopback (the
official postgres image does, for127.0.0.1) is open tomain's agents, where compose givesmainan address of
its own: the docs say so and how a task closes it. Outcomes record each service's run mode (services). Stacks are
refused when the tasks are listed, naming what is missing, with an hpc-sandbox older than 0.5 or on a node that does
not nest unprivileged user namespaces
(Stacks as pods). - Harbor stacks on the host network, where pods cannot run (
stacks: host_network). On a site whose nodes do not
nest unprivileged user namespaces, or without hpc-sandbox 0.5, the host-network fallback runs a fetched dataset's
stacks with each service a single sandbox of the provider (singularity,enroot,process) on the node's own
network, as theprocessprovider runs its stacks: each declared port is a port of the node that the stack holds for
its life, which the service learns from its listen variables, and the references to the services (above) reach them.
Since every process of the node reaches a service there, a dataset's overlays say how each one runs safely
(host_network = {listen, token, bind}in a[[compose]]entry): the variables that set the port it listens on,
carry a token that each stack draws anew and gives to its own services alone (through their environment: a fetch
refuses a rule's variable that the compose file interpolates where the fallback sets it,$$NAMEpassing it on), and
bind it to127.0.0.1. The fallback checks what listens at each port a service declares,main's too: a listener
beyond the loopback, or one that another user's process holds, makes the service unhealthy. Its stacks' directories
are in a root of your uid's alone (0700; a symlink or another user's directory there is refused). A fetch scans each
stack's text files and plan for the references to its services that the fallback could not rewrite
(compose.unrewritable_referencesin the lock: a service named with a port ininstruction.md, say), which an
overlay's[[edit]]passes through a variable instead. Refused, when the tasks are listed (exit 78) and when a
session opens: a sidecar with ports that no overlay authenticates, a declared TCP port without a listen rule, a UDP
port, such a reference, a task MCP server that is a sidecar (an MCP client sends no token), a stack without a network
in a phase, and a provider whose sandboxes run other users (docker,fakeroot); a dataset directory skips every
stack, saying to fetch it with an overlay. Nothing chooses the fallback by itself (the refusal of stacks under
stacks: autonames it where it could run them), and it needs no pods, so hpc-sandbox 0.4 runs it.
platoon.sandbox.host_network_stacks.HostNetworkStackProviderruns it over any provider whose sandboxes share the
host's network as your own uid
(Stacks on the host network). platoon preparemakes what stacks need. Besides the sidecars' images (above), it pins the registry images
that sidecars run, on every provider butprocess: the digest each registry serves, with the configuration it gives
the image for this host's platform (ENTRYPOINT,CMD,ENV,WORKDIR,USER,HEALTHCHECK, ...), which a
sidecar runs with, since platoon gives every runtime a sidecar's whole command and a pod's runtimes apply none of an
image's configuration; and, where stacks run as pods with egress, fetches slirp4netns (hpc-sandbox fetch-tools)
when the node has none. A run whose sidecars' images or pins the image map lacks stops when its tasks are listed
(exit 78), namingplatoon prepare.platoon harbor images pinpins them too,platoon harbor images planlists
them, andplatoon harbor images verifychecks them
(Build task images).- Harbor tasks run as the users they name (
agent_user: honor). As Harbor runs them, the agents' commands (a
harness in the task sandbox, the env tools,platoon-oracle) run as[agent].user, else the image'sUSER, else
root; the tests as[verifier].user, else the image'sUSER, else root; a[[verifier.collect]]hook as its own
user; and platoon's own commands as root, in sandboxes that give root and other users: Docker's (docker exec --user), and Singularity's withfakeroot: trueon the copy layout, where platoon switches each command to its user
with the image'ssetpriv,suor Python, with the user'sHOME,USERandLOGNAMEfrom the image's
/etc/passwd. Any of Docker's--userforms names a user. A session'ssandboxis the agents' view of the sandbox,
while grading and the kills keep root's; their commands run with Docker's umask,/logs/agentand/logs/artifacts
are every user's (777, as Harbor makes them), and so is/logs/verifier, with/teststhe tests' own, where the
tests run as a user other than root (tests that run as root get both as root's,/testswith the host's modes, as
Harbor copies it); what platoon uploads for the agents is theirs, a deferredCOPY --chowngives its owner what it
copies and a deferredWORKDIRthe directories it makes to theUSERin effect there, as BuildKit does (such a
sandbox starts in/, and its commands run in the working directory), and the outcome records the task's users
(users). AUSERthat ends a Dockerfile is the
agents' user (a fetch still refuses it unlessrefuse: {agent_user: false}), expanded against the image's
environment where it names it (USER ${NB_UID}). Refused, when the tasks are listed (exit 78) and when a session
opens: users onenroot,processand Singularity withoutfakerootor on the fuse-overlay layout, multi-container
stacks in which users would run (every stack underagent_user: honor, compose projects and pods alike: stacks that
honor users follow), a user the image lacks, an image with no way to switch to one; acopyfork on Singularity,
whose snapshots keep no owners.
verifier_user: honoralone honors[verifier].user, now any user, and a root verifier graded in its agents' own
sandbox runs where the agents run as their user
(Users in task sandboxes). - Images keep their file owners where users are honored. hpc-sandbox extracts every image as you, so a user that an
image gives a directory would find it root's.platoon preparerecords each image's owner manifest, the paths its
layers give users other than root: theocibuilder lists its OCI-SIF layer's owners before it unpacks it
(<key>.ownersbeside the image,file_ownersin the map;unsquashfs -llnneeds squashfs-tools 4.4, and with an
older one the build records none and says so), and a task'sdocker_imagebecomes a pulled entry of the image map,
the image its registry serves pinned by digest, with itsUSERand the owners its layers' tar headers give (whiteouts
applied), which the sandboxes that honor users run. The record also tells such a sandbox its image'sUSER, which it
cannot read itself.SandboxSpec.file_ownershands a manifest to hpc-sandbox 0.5, whosefakerootsandboxes apply it
before anything runs; with an older one, such as the 0.4.0 of platoon 0.4, a task whose image gives files to
users other than root is refused when it is listed, naming 0.5. A task whose sandbox runs a user other than root, or
anything as its image'sUSER(the agents' underagent_user: honor), is refused where the image has no record:
built by platoon before 0.5, thedefbuilder (Apptainer's) or theocibuilder with a squashfs-tools before 4.4
(platoon preparenames the build), adocker_imagethatplatoon preparehas not recorded, a local image file, or
a dataset directory's, which has no image map. A sandbox in which only root runs needs none. Docker keeps its images'
owners itself (Users in task sandboxes). - Sandboxes can run a command as another user.
Sandbox.exec_as(user, cmd, ...), optional, runs one as a user of
the image (the docker provider's); a provider whose sandboxes lose their images' file owners says so
(loses_file_owners) and whether it can apply a manifest (file_owners_refusal(options);
platoon.sandbox.loses_file_owners,file_owners_refusal)
(Write a sandbox provider). - A Harbor stack's MCP servers are env tools. Each
sseorstreamable-httpserver of a task's
[[environment.mcp_servers]]whosehttp://URL names a service of its stack (http://mcp-server:8000/mcp) serves
its tools through the tool plane, as the session's env tools, each named for the server and the tool in ASCII letters,
digits and underscores (mcp_server__get_secret): read-only where the tool'sreadOnlyHintsays so, filtered by the
agent's access level, recorded with the agent's tool calls, called in their turn with the generic tools within
tool_timeout_s, and their text cut as the generic tools' output is. platoon's own MCP client speaks both transports,
streamable HTTP and the legacy SSE transport, Harbor's default for task servers, with the protocol versions the
gateway serves, reads no answer or listing longer than 16 MiB, follows a server's redirects within its origin alone,
sends each request on a connection of its own (one that a server's keep-alive ends as a call arrives would lose it),
and brings nomcppackage into the rollout workers. The session lists the tools as it opens: a server that cannot be
reached or does not list its tools fails its start (sidecar_start), naming it, rather than run the rollout without
them (Task MCP servers). - This host reaches a stack's services on every stack provider.
Stack.connect(service, port)gives the docker
provider's stacks a relay: a container in the network of the service that listens there, running platoon's forwarder
with the Python 3 of that service's image, else ofmain's, which serves a Unix socket in the project's directory,
whatever the stack's networks, also from a rootless daemon; a pod's port is the socket that hpc-sandbox 0.5 exposes;
theprocessprovider's is the port of the host that the stack holds. A socket path too long for an address is
handed out as a link in node scratch (platoon.sandbox.stacks.SocketLinks)
(Write a sandbox provider). - Node services: services a node runs once for every rollout on it. A Harbor environment declares them
(node_services, by name: an image, a command, the port stacks reach it at, CPUs of the run's that it keeps from the
run's rollouts (whole cores, each node service its own), memory, environment, binds, which a bind withstage: true
copies to node-local disk once per node, a health check and its front's routes), and a dataset's overlay marks the
compose service that one serves ([[compose]]external, withscope, what each rollout's scope of it carries,
${NAME}read from the task's variables), which then no rollout starts.platoon trainandplatoon evalstart a
supervisor per node service before the first rollout (python -m platoon.envs.harbor.node_services, in a session of
its own), which owns the service under a lock in node scratch, in a sandbox of the environment's provider with the
run's labels, checks it between rollouts, starts it again when it fails, backing off, and gives up after failed starts
in a row; the run waits until each one serves, and stops before any rollout when one does not start, replaces a
supervisor that died, fails when a service gave up, and stops them as it ends, while a Ctrl+C or a scheduler's
SIGTERM leaves them serving its last rollouts. Another run on the node that declares the same service shares it, and
takes it over when the first one ends, the scopes its workers open meanwhile waiting for it. A stack reaches the
service by the served service's name at its port, through a scope of its own rollout: a socket of the supervisor's
front that lets only the declared routes through, adds the token the service checks and the scope's values
(X-Platoon-Scope), and drops what the rollout sends in their place, so thatmainreaches neither the service's
other routes nor another rollout's task; a pod relays it (an upstream at the port, the name a host alias of the pod),
and theprocessprovider's stacks relay a port of the host to it. A rollout whose node service failed while it used
it (a request in flight, or one since the service last passed its health check), or could not answer it, isinvalid
(node_service_down), whateveron_sidecar_downsays; the other rollouts go on with the service's next start, and
an outcome records which starts of the service answered it (node_services). A site profile may declare node
services too (node_services), which an entry takes by name ({name: site}). Docker's compose projects, the
host-network fallback and providers without the newexternalscapability reach no node service: such stacks are
refused when the tasks are listed.StackSpec.externals(platoon.sandbox.stacks.ExternalService) is what a stack
provider gets of them (Node services). - A FrontierSmith recipe.
recipes/frontiersmithruns FrontierSmith's 200 synthetic competitive-programming
problems as agentic Harbor tasks, fetched through its own adapter at its release, with the Frontier-CS judge as a
node service in place of each task's privileged judge: go-judge as your own uid (rlimits, not cgroups), on 16 CPUs
that the rollouts' agents leave alone, its parallelism from them, reached through a scope of the rollout's own
problem; a judge that cannot grade makes the rolloutinvalidrather than 0. It has configs for an evaluation and
for a small training check (recipes/frontiersmith). - Harness specs get a task's own MCP servers.
{task_mcp_servers}renders the MCP servers a task offers its agents,
as a JSON list of each one'sname,transportandurlas the task's sandbox reaches it (or astdioserver's
commandandargs), for a harness in the task's sandbox that reaches them itself, as Harbor's agents do; a harness
that runs locally gets[], and reaches only the servers that the env's tools serve. A session gives them as
task_mcp_servers, an optional attribute (Placeholders). - One lifecycle for every trainer, and the trainer API (experimental). Each start of training goes through
platoon.train.trainer.run.TrainerRun, in the order a start always ran: the trainer's checks, the signal
handlers, the policies' service clients and the start point, their training clients, the trainer's preparation,
the launch save, the trainer's readying, its steps, the save at exit and its closing; a trainer's part of each phase
is a hook ofplatoon.experimental.TrainerBase(check,prepare,started,ready,run_steps,close),
andPlatoonTinkerRLTraineris one.platoon.experimentalexportsTrainer,TrainerBase,TrainerSetup,
TrainerNeeds,TaskSources,Workflows,StepPlan,PolicySpec,MAIN,
CheckpointParticipant(a part of the run's state that every full checkpoint holds, after the run's counters, in
the order the parts registered) andObjective, andplatoon.testing.check_trainerchecks a trainer: it trains a
run of two steps on a fake Tinker service, stops one at a step boundary and resumes it. The config'strainer:
selects the trainer:rl, the default, orsft(below;interleavedis reserved). Nothing a run does changes. - Supervised fine-tuning on LLaMA-Factory datasets (
trainer: sft). A run fine-tunes its policy on chat datasets
instead of rollouts, with the loss on the model's turns: the mean negative log-likelihood of the assistant's
replies and tool calls (withmask_history, of each conversation's last one only), rendered by the policy's renderer
as the policy sees them when it samples. The datasets follow LLaMA-Factory's conventions: adataset_info.jsonin
trainer_options.dataset_dirnames each one, on the Hugging Face Hub (hf_hub_url) or in files (file_name:
json, jsonl, csv, parquet or arrow), with itsformatting,split,subset,folder,num_samples,columns
andtags;alpacarows (instruction,input,output,system,history) andsharegptconversations
(from/valueorrole/contentmessages, a system prompt,tools, andfunction_callandobservation
turns) become conversations as LLaMA-Factory's converters align them, and the rows it would skip are skipped.
An entry'scolumnsandtagsreplace LLaMA-Factory's defaults whole, as they do there.trainer_optionssets the
datasets,dataset_dir(relative to the config file; left out,databeside it),batch_size,num_epochsor
max_training_steps,num_microbatches,max_length(shared among a conversation's turns as LLaMA-Factory's
cutoff_lenis),mask_historyandseed, checked when the config loads,dataset_info.jsonwith them;train:
declares the policy, and its keys that onlyrlreads are refused undersftwhen set, naming the option to set
instead. Before the service opens, the run renders the first conversation, the first with tools, the first with a
tool call and the first with neither a system prompt nor tools, and refuses a renderer that cannot render one,
naming its dataset and row (a system message a renderer adds of its own does not train). Each epoch walks every
conversation once in a seeded order, and every full checkpoint saves where the run is (loop_state.sft), so a
resume trains each conversation once an epoch, also with anotherbatch_size; a resume with other conversations or
another seed is refused.checkpoint.keep_recoveryneeds notrain.max_stalenessundersft, since nothing
samples the policy.platoon config validate --tasksloads the datasets of an sft config. Preference and
multimodal datasets, and evaluation during training, are not there yet
(Fine-tune on a dataset, which lists where it differs from
LLaMA-Factory;examples/sft). Its steps are atomic and its updates fail closed, and
each step ends withrun.end_step(sampled=False): the step hooks a subclass registers run there, and since nothing
else samples the policy, a step without a hook, or one that updated nothing, publishes no sampler and saves only the
full checkpoint the cadence asks for. The trainer API has what such a trainer needs: thatsampled=False,
TrainerBase.stats_loggerandtrain_tracker; a trainable policy has its optimizer's parameters once it opens, the
run stops the clock's watchdog afterrun_steps, and a recovery checkpoint that a step's end saves goes to the
trainer's pruning.
platoon.testing.check_trainertakesoptions_for_steps, the options of a run of so many steps, for a trainer
whose options say how long it trains, andplatoon.testing.fake_tinker_serviceis the fake service it runs on, for
a test of a run of one's own: it renders with the toy renderer and records the datums it trains on and the
checkpoints it deletes. - Any trainer draws the run's rollouts, and the workflow contract (experimental). A trainer whose
needs()say it rolls out gets the start's draws from its run,run.rollouts()
(platoon.train.dispatch.source.RolloutSource): the run's task order and samplers, the group admission rules, the
per-task statistics, the dispatcher with its rollout permits and exact resume, and the groups they settle into
(take, which ends on the run's shutdown request,committedandstep_started), which take part in every full
checkpoint; the run builds its workflows from its setup.
platoon.experimentalexports the workflow contract:RolloutWorkflow,WorkflowCapabilities,capabilities_of,
WorkflowSetup,RolloutOutput,OutputReport,report_ofandrows_by_policy.platoon trainbuilds the run's
workflow class from aWorkflowSetup, with itsfrom_setupwhen it has one (GroupRolloutWorkflowdoes), else with
GroupRolloutWorkflow's keyword arguments, as before. Nothing a run does changes. - Step hooks: work at the end of each training step (experimental). A step hook (
platoon.experimental.StepHook,
StepHookBase) runs at the end of every step of any trainer, after the step's own updates and its commit and before
it publishes: each hook drains its side work (drain), adds optimizer updates of the step's policy on the same
training client and Adam state (update, throughend.train(policy, datums, objective=...)or
end.learner(policy, objective=...)), and hears what the step published (published, also when nothing did, and
once after the launch). Platoon counts the hooks' updates from the policy's optimizer steps, and the step publishes
each policy it or a hook updated once, as one new version, so the hooks spend no staleness budget; the step's row
gainsbatch/hook_optimizer_updates, and each hook's metrics go under<name>/. An update takes Adam parameters of
its own (PolicyUpdate.apply(adam_params=...),end.train(..., adam_params=...)). A hook alone is tested on
StepEnd.for_test(learner=...)(Write a step hook,examples/replay-sft-hook). - Attaching step hooks. The rl trainer attaches the hooks its options name, from the config:
trainer_options: {hooks: [pkg.mod:Hook, {hook: pkg.mod:Other, options: {...}}]}, checked when the config loads and
built with the class'sfrom_run(run, options)before the training service opens; a hook that does not import,
build or register stops the run with exit code 78. A trainer of its own registers its hooks incheck()
(run.hooks.register, which also makes a hook withkeysa participant of the run's checkpoints) and ends each step
withrun.end_step(): the hooks, then each policy that the step's updates throughrun.learneror a hook's moved
publishes once (run.publish: a full checkpoint on the cadence, else a weights-only save), and the rollout workers
of the draws the run opened get each new sampler, the launch's first. - Failures and interrupts around step hooks. A hook that raises in
drainorupdatefails the start before
anything publishes; one that is notcriticalonly hears publications, and its errors are logged. A hook's learner
sends and counts during theupdate()that took it only, under another objective too, the trainer'srun.learner
sends nothing while the hooks run, a policy takes one update at a time, and a step's end refuses an update left open
(sent, not applied), with hooks or without. A hook'send.wait(..., interruptible=True)raises
TrainInterrupted(now inplatoon.experimental) on a stop request when the trainer's steps are atomic; when they
are not, the rl trainer's, the step ends first, and an interrupt that comes as the hooks run all the same saves no
checkpoint of the step, so that none saves half a step. - The rules of a run's checkpoints and close. A participant's keys are its own:
run.checkpoints.registerrefuses
the counters', those kept for several policies, the fields that acheckpoints.jsonlrow holds itself (name,
epoch,final,state_pathandsampler_path, read from tinker_cookbook'sCheckpointRecord, which an entry of
the same name would replace or be replaced by), a step hook's of the draws (tasks,next_position,dispatch,
credit) and another participant's, with aConfigError(exit 78) that names the participant and the key. Those
registered incheck()take up the start point before any training client opens, and a key of the resumed row that
nothing claims stops the start (exit 78). The trainer'scloseruns infinally, also after a failure
(run.endedtells them apart), then each hook's and participant'saclose, in reverse order of registration; a start
that failed raises its error as it was, the close's errors noted on it. - What a trainer asks of the run (RFC 0021, rule 10).
TrainerNeedssays it:atomic_steps(an interrupt inside
a step that sent training work saves no<step>_interruptedcheckpoint of it, and the resume starts from the last
full one),fail_closed_updates(an optimizer step of a learner of the run's that the service skipped, or whose
metrics are not finite, raisesUpdateRejectedbefore it counts) anddraw_provenance(each training episode sees
its draw asctx.draw, aplatoon.experimental.DrawInfo, which a step's end names inStepEnd.consumed). A
trainer onTrainerBasehas the first two on by default, and itsneeds()may turn either off; the rl trainer
keeps both off, as before. A step that sends training work by hand, outsiderun.learner, says so
(run.clock.step_open = True), andrun.learneris built once the run's clock started (ready()). - Side inference and warm starts.
Policy.snapshot()(platoon.experimental.PolicySnapshot) samples a policy's
published sampler and refuses a sample once the policy has published again (PolicyMoved);Policy.rendererand
.tokenizerare its model's.checkpoint.load_optimizer_state: truestarts a new run fromload_checkpoint_path's
weights and its optimizer state.platoon.testing.check_trainerruns a step hook of its own at each step's end
(runs step hooks) and stops a run as the hooks drain a step (keeps steps whole). Nothing a run without hooks does
changes. - Per-datum stale masks (experimental).
train.stale_filter: datumdrops only the datums of a group that are more
policy versions old than its bound, wheregroup, the default, drops the whole group by its oldest datum: a rollout
that spans a published policy keeps the datums it sampled since, and the others keep the advantages their group's
credit gave them. Each model call records the policy version it sampled (TinkerLLMInteraction.versionand
ChatResponse.version, defaulted; a rollout worker'sFollowingSamplingClientpins the client and version of each
call together), each datum is judged by the oldest among its own calls, and a group none of whose datums is within
the bound is dropped whole, as before.train/batch/stale_datumscounts the datums dropped. A workflow takes the
setting through the newtakes_stale_filtercapability and itsstale_filter, asGroupRolloutWorkflowdoes
(RFC 0020's P5; Per-datum stale masks). Under
groupnothing a run does changes, but the staleness warning's last advice, which named the masks as planned, now
namestrain.stale_filter: datum. - Credit estimators, chosen per environment entry (experimental).
environments[].credit(withcredit_options)
chooses how an entry's training groups turn rewards into advantages, in place of the workflow's own credit:
group(that credit, as an estimator, value for value),keyed(each agent's rows centered on the mean reward of
the group's agents that share its key, any oftask,policy,roleanddepth, optionally leave-one-out: under
[task, role]a subagent is centered on the group's subagents, not on its rollout's root baseline), a name an entry's
packageregisters with@credit_estimator, ormodule:Class. The training workflow awaits the entry's estimator
once per group, in the trainer, after every member has settled; evaluation never runs one. An estimator that reads
agents (needs_agents) has the rollout workers tag each row with the agent that produced it (worker protocol 3,
PolicyRolloutWorkerRequest, sent only then), and sees every agent of every member with its role, policy, depth,
parent, own reward and rows; the group's output is thenPolicyGroupOutput, its rows with their tags, which a full
checkpoint keeps whole. An estimator with state changes it insettle(group)as the run files each group, and each
full checkpoint saves it (loop_state.credit) with the groups it keeps. An estimator that raises, or whoseassign
does not return within itsassign_timeout_seconds(600 by default), stops the run with aCreditEstimatorError
that names it and its entry. Loading resolves each estimator, checks its
options and runs itscheck(exit 78), refuses one beside a workflow that does not apply it (the new capability
applies_credit; aGroupRolloutWorkflowsubclass that overridescompute_baselinesorassign_advantageskeeps
crediting with them), andlock.jsonrecords it (kind: credit).platoon.experimentalexports the credit API
(CreditEstimator,CreditEstimatorBase,CreditSetup,CreditCheckContext,CreditGroup,MemberRollout,
AgentRun,TrainingSample,PolicyView,credit_estimator), andplatoon.testing.check_credit_estimatorchecks
an estimator: advantages on action tokens only, and a resume that credits as the run would have
(Credit each environment's groups,
Write a credit estimator;
examples/credit-by-rolegives each role of a delegating rollout a baseline of
its own). A run whose entries set nocreditruns as before, request for request. - Several policies in one run: frozen policies that agents sample or a credit estimator scores with
(experimental).policies:declares the run's other policies undertrain:'s names (model_name,
renderer_name,renderer_kwargs,context_window_length,sample_timeout_seconds,tinker_base_url,
load_checkpoint_path) andtrainable;train:still declaresmain, the policy the run trains, as before. A
frozen policy (trainable: false) is sampled or scoring and never stepped, saved or pruned; it opens only the
sampling client it needs, of its base model or of thetinker://sampler itsload_checkpoint_pathnames (a
policy the run trains besidemain: below). A policy withouttinker_base_urluses the run's service; each policy
opens a Tinker session of its own, also on a shared service, so that a fatal error on one policy's session never
ends another's, and an error opening a policy other thanmainnames it and its service.agents:binds agent
roles to policies (agents.roles.<role>.policy): the root agent samplesagents.policy,mainwithout it, and
an agent opened with a role (ctx.agents.spawn(role=...),POST /v1/platoon/agents, a program's judge) samples its
role's policy for its whole life, through the gateway's runtime of that policy, with its own renderer and tokenizer
(ModelGateway.serve_policies); each of its interactions names that policy (TinkerLLMInteraction.policy). A
frozen policy's agents make no rows, but their rewards and tags reach the credit, each row is stamped with its own
policy's version, and rollout workers follow one binding file per sampled policy (model_binding.<id>.pkl, worker
protocol 3), which the trainer publishes before the first rollout and each evaluation (the workflow capability
binds_policies, whichGroupRolloutWorkflowdeclares).identity.jsonrecords each policy's model, renderer and
training, a frozen one's sampler, and each role's policy, never a URL, and a resume that changes them stops with
exit code 78, naming each change. Loading, andplatoon config validate, refuse an id that is no lowercase name,
policies.main, a policy'ssample_timeout_secondsthat is not a positive number (astrain:'s), a role bound to
an undeclared policy, roles bound to several policies beside a workflow that binds one, and either key under
trainer: sft, which rolls nothing out, and warn of a frozen policy nothing uses (Sample several
policies).platoon.experimentalexports
AgentBindings, andTrainerSetuptakesagents. A run withoutpolicies:oragents:runs as before, request
for request. - On-policy distillation from a frozen teacher (experimental):
credit: reverse_kl. For each training group, a
frozen teacher (credit_options.teacher, a policy ofpolicies:) scores every token the student sampled
(compute_logprobs), and each action token's advantage is-kl_coef * (student logprob - teacher logprob), plus
reward_coeftimes the row's reward centered on its member's baseline (0 by default); withreward_coef: 1this is
tinker-cookbook's on-policy distillation advantage, without its discounted KL. The teacher's calls run in the
trainer after the group's rollouts returned their permits, at most 32 in flight, each bounded by the teacher's
sample_timeout_seconds; one that fails or runs out of time, or a response of the wrong length or without a
target token's logprob, fails its group alone (verdictfailed, counted undercredit/failures), which trains
nothing, and the run goes on. An evaluation never calls the teacher, and the step logscredit/reverse_kl/avg.
Loading, andplatoon config validate, refuse a teacher that is no frozen policy ofpolicies:, and one whose
tokenizer differs from the student's (exit 78, before any Tinker session opens; when a tokenizer cannot be loaded
then,platoon traincompares the ones it loaded for the two policies as it builds the estimator). A credit
estimator reads the run's policies fromCreditSetup.policy_viewsandCreditGroup.policies(PolicyView.score:
a policy's logprobs of each datum's target tokens, which raisesScoringFailedto fail the group; itstokenizer
andversion) (Distill a frozen
teacher;
examples/on-policy-distillationdistills Qwen3-32B into the
quickstart's Qwen3-4B, the teacher on the student's service and on its own). - A tool allowlist per agent role (experimental):
agents.roles.<role>.tools. A role's list of tool names or
fnmatchpatterns (read_*; a Harbor task's MCP tools by the names agents see,<server>__<tool>) limits the agents
of that role, and every agent under them, to those tools: a child spawned or minted with the role, a program'sjudge
orverifier(which its spawner's lists limit, not those of the child it judges), and the root agent when the list is
under its role,policy. Their listings of env tools (ctx.list_tools(), MCPtools/list) hold only those, and the
gateway refuses a call to any other where it runs the call, for inline and MCP harnesses alike: the agent gets an
error result that says why, the environment never sees the call, the run goes on, and the agent's trajectory counts
the refusals in its misc (tools_refused: {count, first}). A role withouttoolskeeps every tool, andtools: []
allows none; a role may givetoolswithout apolicy(agents.roles.<role>.policyis optional: its agents then
sampleagents.policy's). Rollout workers get the lists in their requests (worker protocol 3'srole_tools), and
in-process rollouts from the trainer's gateway (ModelGateway.limit_tools). Loading, andplatoon config validate,
refusetoolsthat is no list of non-empty names (exit 78), warn of a role that looks like a misspelling of one of
platoon's own (subagentsforsubagent), and of a pattern that matches no tool of the run's environments when each
declares its tools up front: an ORS environment run in process whose class lists them all, a task table, or a service
class withdeclared_tools(Limit the tools each role may
call;
examples/subagents/read-only-subagents.yamltrains the subagents
example with subagents that may only read). A run withouttools:runs as before, request for request. - Several policies trained in lockstep (experimental). A policy of
policies:trains unless it says
trainable: false, withtrain:'s names, andtrain:'s values when unset, for its ownlora_rank,optimizer,
loss_fn,loss_fn_config(when unset,train:'s only if its loss istrain:'s, else none) andmax_staleness
(when unset, its entry's bound first), andload_optimizer_stateto start from its checkpoint's Adam state too, as
checkpoint.load_optimizer_statedoesmain's;agents.policy, the root agent's, may be any trained policy. Every
step trains every policy (RFC 0021's lockstep, RFC 0003's milestone M2): each group is credited once, over every
agent, then its rows split by the policy that sampled them; each policy's rows are judged stale against its own
version and bound (StaleFilter.for_policy, also per datum undertrain.stale_filter: datum) and dropped alone;
each microbatch sends each policy's rows to its own training client, under its own loss, and each minibatch submits
every optimizer step before awaiting any; each policy that stepped publishes its own sampler (Policy.publish, whose
subscribers,Policy.subscribe, write each sampled policy's binding for the rollout workers), and one with no rows
in a step does not step and keeps its version. The dispatcher's horizon counts the smallest bound of the trained
policies the agents sample. An entry withoutcredit:credits withkeyedon[task, policy, role](RFC 0021's
D5), and every row is tagged with its agent whatever the estimator. Each checkpoint saves every trained policy: the
others first, at the same time, each into<run>/policies/<id>/checkpoints.jsonlon its own service, thenmain,
whose row names each other policy's by itsstate_path(loop_state.policies, a key the run claims on resume) and
commits the checkpoint; a resume takes the newestmainrow whose named rows every policy still has, skipping newer
ones with a warning thatlock.jsonrecords (policies_restore), and stopping with exit code 78 when none holds.
Each policy's recovery checkpoints are pruned on its own service, keeping the rows that the newestmainrow and
the newestkeep_recoverymainrecovery rows name; a checkpoint's save of a policy that did not step holds weights
no rollout samples, and is pruned as any older checkpoint is.main's metrics keep their names, each other policy's
go underpolicies/<id>/, and evaluations record each sampled policy's version (versions);run.clocksmaps
every trained policy to the run's one clock. An error of one policy's service fails the step, with a note that names
the policy and its service. Step hooks compose with lockstep: a hook updates any trained policy,end.trainand
end.learnertaking its id, through that policy's own learner, its metrics under that policy's prefix; each policy
that the step or a hook updated publishes once, the hooks hear every trained policy's publication, and each other
policy's row counts its hooks' updates (policies/<id>/batch/hook_optimizer_updates); a trainer of its own that
trains several policies (run.learner,run.end_step()) saves one checkpoint of them all on the cadence.
Loading, andplatoon config validate, refuse a trained policy that no agent samples, a frozenagents.policy, a
frozen policy with a trained policy's keys,load_optimizer_statewithoutload_checkpoint_path, an out-of-range
key, naming its policy (policies.<id>: ...), and, in lockstep, a policy'sbatch_sizeorcheckpoint, which
are for policies that step on their own clocks (below);identity.jsonrecords each policy's training, and a resume
that changes it stops with exit code 78 (Train several policies in
lockstep;
examples/two-agent-locksteptrains the subagents example's delegator
and its workers as two policies, on one service and on two). A run of one trained policy runs as before, request
for request. - Each trained policy may step on its own clock (experimental).
train.stepping: independent(default:lockstep)
lets each trained policy step, publish and save on its own clock (RFC 0021, section 2.11; RFC 0003's milestone M2),
with its own batch (policies.<id>.batch_size, elsetrain.batch_size), staleness bound and checkpoint cadence in
its own steps (policies.<id>.checkpoint: {every, recovery_every}, elsecheckpoint.*), and no policy waits for
another's training or saves; with one trained policy it is lockstep, and a trainer's needs say which clocks the run
builds (TrainerNeeds.stepping, the rl trainer'strain.stepping). Each group is credited once and then splits into
a share per trained policy, its rows of the group, and always one ofmain's, so that the plan counts every group,
train.batch_sizepermainstep; each policy's steps take its shares in the order they settled from the run's share
ledger (source.ledger; a trainer of its own takes them throughplatoon.experimental.ShareLedger'stake,
judge_version,consumedandlock); the dispatcher keeps a horizon per policy,(k + 1) x batchof its shares,
and starts a group only when every one has room (train/dispatch/blocked_share/horizon:<id>names the one that held
it back). Each policy judges its own shares against its own version and bound, and publishes after each of its steps.
Every full save of any policy is a commit: it captures the run's draws, the groups not yet retired with what each
policy did with its share (its step and its age then) and every other policy's newest row and published version under
a commit lock, which each group's settling and each step's consumption take too, once no step's end runs its step
hooks; then writes them and the policy's weights; then appends the policy's row to its owncheckpoints.jsonl
(another policy's named with its id,recovery-<id>-<step>), withcommit_seqand every policy's row
(loop_state.policies, withpublished_versionandstep). A group stays kept until each policy that consumed its
share has saved after that step (run/retained_groups), and pruners keep the rows a commit in flight, or the newest
commit of some policy's file, names. A resume takes the commit with the highestcommit_seqwhose named rows hold,
each policy going on from the row it names, at one version more than the commit records it published when that is past
the row's, and each share it consumed after its row trains again at the age it had (policies/<id>/retrained_shares);
numbers go on after the highest in any file. Each step logs a row of its own without a W&B step of its own
(StatsLogger.log_row):main's with its names andrun/step, every other policy's underpolicies/<id>/with
policies/<id>/step, andpolicies/<id>/share_wait_sandpolicies/<id>/peer_lag/<q>for each; evaluations count
main's steps and recordversions_endwhen a policy published while they ran. Step hooks run at the end of each
policy's own step (StepEnd.policy) and may update that policy only, through their end (run.learnersends nothing
from them); a commit saves a hook's state as it is then, while every other policy resumes from the older row the
commit names, so a hook keeps what moves at a policy's step ends by that policy's step and takes it up at
start.policies[<id>].step. A stop request ends each policy's loop at its step's end; a step that waits for shares
once a stopped policy's horizon is full closes short. Loading refuses, besides lockstep's, a policy's batch that its
steps' minibatches and microbatches do not divide, a group admission rule other thanall, a
checkpoint.keep_recoverythat a policy's ownrecovery_every(else the run's) does not span past its bound, and
train.steppingundertrainer: sft, which trains one policy, and warns of a trained policy that saves no full
checkpoint before the run's end; a start checks the same against its trainer's needs.identity.jsonrecords
stepping: independent, and a resume that changes the stepping stops with exit code 78 (Step each policy on its own
clock;
examples/two-agent-independentsteps the subagents example's worker
about four times for each of its delegator's steps). Withtrain.steppingunset, every run is as before.
Changes
-
A
processsandbox whose working directory is/runs in its own/tmp, since no path maps to the host's root; the
provider refused such a spec. -
The
processprovider's root (platoon-sandboxes-<uid>in node scratch) is a directory of your uid's alone, as the
dockerprovider'sstack_rootis: made 0700, and refused, with nothing made there or reaped from it, when it is a
symlink or another user's directory, which another user of a node's shared scratch could have put there. -
A
singularityorenrootsandbox whose start is given up (a rollout cancelled while its sandbox starts) is stopped
once its start is done, which then runs to its end: cancelled while Singularity made its instance, hpc-sandbox's own
rollback could fail and leave the instance running until the worker exited. -
Harbor reads only
environment/docker-compose.yaml, and so does platoon now: a task whose compose file is named
compose.yaml,compose.ymlordocker-compose.ymlruns as one container without it, as in Harbor, where platoon
refused it (or ran it, when it only namedmain), and the fetch warns of it (flags.unread_compose); a task with a
prebuilt image, no Dockerfile and such a file gets itsenvironment/uploaded, as Harbor does. -
A lock records each task's stack plan. A dataset pinned to a commit whose lock platoon 0.4.0 wrote keeps answering
its config where the lock refused no task as a stack (stack), and also, with its main-only tasks refused as 0.4.0
refused them, where the config setsrefuse: {sidecars: true}: fetch it again to run them (platoon harbor fetch CONFIG --repair). Where it refused stacks and the config sets norefuse.sidecars, the dataset is fetched again
(below). A lock whose plans follow another compose rule (compose_rule) is not read: fetch the dataset again. -
A fetch locks multi-container tasks as runnable stacks (
refuse.sidecarsdefaults tofalse), where platoon 0.4
refused them by default (stack), and withsidecars: falselocked them refused (task_config). Where the
provider runs no stacks, a run stops when it lists its tasks, naming them: leave them out (exclude_tasks), or set
refuse: {sidecars: true}, which refuses them as before, asstack: the compose file adds sidecar services (refuse: {sidecars: false} fetches it as a stack). A lock written for an entry that sets norefuse.sidecarsand that
refused stacks no longer answers it:platoon prepare,platoon trainandplatoon evalfetch a pinned dataset
again (its cached tasks are reused), and a rollout worker that finds no lock names the old one; a lock that refused
none still answers. A dataset directory's multi-container tasks are listed where the provider runs stacks, and
skipped with a warning, as before, where it runs none. -
The
hpcextra requires hpc-sandbox 0.5.1 (hpc-sandbox>=0.5.1,<0.6), where platoon 0.4 took 0.4 (>=0.4.0,<0.5):
0.5 has the pods that stacks run as and applies owner manifests, and 0.5.1 fixes pods for clusters: a sidecar that
switches users starts where the run's directory or the task's files are on storage that only one of your groups opens,
and reaches its binds under a umask such as 027, and a pod'smainoutlives an agent'skill -9 -1, which with 0.5.0
ended it and made the attempt anerror. With an older hpc-sandbox, which an environment that installs platoon
without the extra may keep, stacks, and the tasks whose sandboxes need an owner manifest, are still refused when the
tasks are listed, naming 0.5 (The hpc extra). -
The
defbuilder's images record their base image's configuration (ENTRYPOINT,CMD,USER,HEALTHCHECK,
EXPOSE, ...), from its registry, under what the Dockerfile sets, since a definition file keeps none of it (a
sidecar's image, whose command may be its base's, fails to build without it); and thesingularityprovider's
images are built withdefwhere its runtime is Apptainer, which has no BuildKit foroci. -
[[verifier.collect]]hooks, which platoon refused, run: inmainfor a single-container task, in their service for
a stack,main's first, best effort (collect_failedin the outcome). A hook that names auseris refused unless
the users policy ignores it:verifier_user: ignore, or, whereverifier_useris unset,agent_user: ignore. -
The capabilities a provider may declare (
platoon.sandbox.base.CAPABILITIES) includestacks, which theprocess
provider declares: a provider that copies another's capabilities, as the provider guide's wrapper did, declares
stacksonly with astart_stackof its own. Theprocessprovider links its mounts before it makes the working
directory, so that a working directory that is a mount's target is that mount, and never makes it inside a read-only
mount: a read-only bind of a task's files at the working directory, which it dropped, applies, and since the
provider enforces no read-only mount, what is written there reaches the task's files. -
Image map entries record an image's
HEALTHCHECK,EXPOSEports,VOLUMEs andSTOPSIGNALtoo, and a
Dockerfile's build plan what its deferred suffix sets in the image's configuration (config:CMD,ENTRYPOINT,
HEALTHCHECK, ...), which a main-only task's init and healthcheck use. The build keys of task images do not change. -
The
dockerprovider removes a sandbox's anonymous volumes with it (docker rm --volumes), which those of an
image'sVOLUMEs outlived, also when it reaps; itsreapremoves the labelled stacks first, and counts them with
the containers. Its sandboxes are reaped through a record that names itsstack_roottoo. -
platoon.sandbox.stack_refusalasks a built provider's class too (itsstack_refusal(options), with the provider's
options), as it asks a provider named by its name. -
A dataset directory's stack whose sidecar builds from a context is refused saying that a fetched dataset's image map
can hold that image (serve the directory as a dataset to fetch).examples/harbor-local'sneeds-a-databaseis
still refused on Docker, which runs stacks now, for itspostgres:16sidecar, which runs its image's own command,
which only a fetched dataset's image map records (platoon preparepins it). -
A deferred
COPYkeeps its--chownin a lock's build plan (["COPY", "--chown=<owner>", source, ..., destination]), and a deferredUSERits place among the deferred steps (["USER", user]), which a platoon before
0.5 does not read: fetch such a dataset again with it. A lock fetched before keeps its copies, and the directories
that aWORKDIRafter aUSERmakes, root's where users are honored, and a run that honors users warns of it. -
A root verifier's sandbox (
verifier_user: honor) gives every user the read and search access to the files of an
image's deferredCOPYsteps that their group has, as a checkout under Docker's umask gives it, where it kept the
host's modes (a file of mode 600 stays so);tests/keeps the host's modes there, and/logs/verifierstays
root's, as before. -
The docker provider runs its own commands in a container, a compose service's too, as root (
docker exec --user 0), whatever the image'sUSER: the kills before grading, the listing of the container's processes, the
directories an upload makes and the look at what a download copies. Commands keep running as the container's user. -
The
edittool reads a file through the sandbox's shell, as the sandbox's commands run, rather than with a download,
which runs as the sandbox's root; a file larger than a command's output holds (8 MiB ondockerandprocess, 16
MiB on hpc-sandbox, in base64) is an error rather than an edit, and so is one that is not a regular file (a FIFO, a
device), at once, and a read that takes longer thantool_timeout_s. -
Staged harness toolchains are readable by every user, their directories and programs runnable by them, so that the
users a sandbox switches to run them. -
The refusals of tasks that name users read
... and the environment refuses tasks that name users; the task is refused unless it sets agent_user: honor, ... or ignore, ...when a task loads, andagent_user: the task sets [agent].user or [verifier].user (refuse: {agent_user: false} fetches it, ...)in a lock.HarborEnvironmentService .root_images_closed()isclosed_images(), which covers every sandbox that honors users. -
The training code lives in packages named after the stages of a run.
platoon/train/tinker/held the trainer,
its config, the rollout workflows and the rollout workers; they are now packages ofplatoon.train:trainer(one
start of training: the trainer, the training loop, signals and the watchdog),policy(the model training samples
and steps: its spec, its state, its training client, its own saves and its learner, through which a step updates
it),objectives(what its rows train under: the loss, the loss inputs only the trainer reads and the training
metrics),step(one training step: the step itself, its metrics, the staleness warning, the batch transforms),
dispatch(what starts the training groups: the dispatcher, the rollout permits, running and settling a group),
workflow(one group of rollouts:GroupRolloutWorkflowand its parts),credit(a group's baselines and
error-token filter),workers(process-per-rollout),checkpoints(what a full checkpoint saves, and resuming) and
evaluation(evaluation during training); the config schema isplatoon.train.config_defs, andplatoon train's
startplatoon.train.launch.docs/training-code.mdmaps them.
Nothing a run does changes: the same datums train in the same order, and the metrics, config keys, messages,
checkpoints and the files a resume reads are as before.platoon.train.tinkerkeeps the policy client (proxy), its
renderers,key_refusalandrollout_metrics, until a later release moves them, and the shims of the old paths
(below). -
The trainer reads the model it trains from its config once, as it is built. Its policy (
platoon.train.policy)
takes the model, the renderer,tinker_base_url,train.lora_rank,train.optimizer,train.loss_fn,
train.loss_fn_configandcheckpoint.load_checkpoint_pathfrom the config whenPlatoonTinkerRLTraineris made,
where it registered its model gateway before: setting one of them in the config after that and beforetrain()no
longer reaches its training client, its updates and theiroptim/lrmetric, or its recovery pruner.
TrainLoopSharedStateholds the run's policies (policies), andmodel_info,training_client,
sampling_client,optimizer_paramsandoptimizer_stepsare properties of the policy it trains (the first two
read-only), sodataclasses.fields()listspoliciesin their place. It is made with keyword arguments only, with
the policies in place of those fields. It no longer hasservice_client(the policy's own) or
sampling_client_step, which nothing read. Acopy.copy()of it shares the original's policies. -
TrainLoopSharedStateholds the run's step clock (clock), andtrain_stepandstep_endedare read-only
properties of it, sodataclasses.fields()listsclockin their place; it is made with the clock in place of those
two.step_endedis platoon 0.4.0'ssampling_client_updated_event, renamed for what it is: the event each step's
end sets, a step's that published no policy too, and the loops' stop.PlatoonTinkerRLTrainersaves its launch,
interrupted and final checkpoints through the run's checkpoints, whose lines (Saving recovery checkpoint ... at launch,Saving permanent checkpoint ...,Training complete successfully!) come fromplatoon.train.trainer.run,
and itsrun_log_path,async withand_await_with_heartbeatareTrainerBase's, whose lines (This run labels its sandboxes ...) come fromplatoon.train.trainer.api. The stats logger a start opens is made of that module's
StatsLogger(TrainerBase._new_stats_logger), so a patch ofplatoon.train.trainer.api.StatsLoggerreaches it. -
The rl trainer's draws are the run's rollout source (
TrainerRun.rollouts, over the trainer's tasks, with
bind_workers=False: its loop and step give the rollout workers each sampler themselves), the trainer's
_rollouts, which the run opens, starts and closes as it does any trainer's: the per-task statistics, the dispatcher
and the group admission rules are the source'srecorder,dispatcherandadmissions(the trainer no longer has
_recorder,_dispatcherand_admissions), its steps commit their groups and move the horizon on through the
source, and its_dispatchstarts it. The source's steps of opening, saving and taking up the draws are its own
methods (begin_task_records,training_tasks,new_dispatcher,started,checkpoint_state,restore), whose
parts are inplatoon.train.dispatch.source,.setup,.runnerandplatoon.train.checkpoints.saving,.resume.
The lines of the per-task statistics' start (This start keeps no per-task statistics ...) come from
platoon.train.dispatch.source. -
PlatoonTinkerRLTrainerkeeps no methods for the code that moved to the stage packages. Platoon 0.4.0's
trainer had them as methods; the code now calls the module functions, so replacing one of these on the trainer's
class or an instance changes nothing, and the trainer no longer has them:- its loops and their stop:
_train_loopandshutdown_loops(platoon.train.trainer.loops, the latter without a
trainer argument), a group's wait,_await_rollout_with_heartbeat(platoon.train.trainer.waiting), and the
step's wait for a group,_next_rollout, which isplatoon.train.trainer.waiting._next_group; - the evaluation:
_eval_loop,_eval_completion_monitor,_wait_for_starting_policy_evaluation,
_wait_for_last_step_evaluation(platoon.train.evaluation.loop, all but_eval_loopwithout a trainer
argument),_evaluates_during_training(.plan), and_rollout_workflow_worker_loop, which is
platoon.train.evaluation.loop.eval_worker, without a trainer argument; - a group's run,
_run_group, which isplatoon.train.dispatch.runner.run_group_guarded; - the step's save,
_save_checkpoint_and_get_sampling_client(platoon.train.checkpoints.saving, which takes the
trainer first), and the publication of each new sampler to the rollout workers,_publish_worker_binding
(platoon.train.policy.binding); - the training client's open,
_open_training_clientand_create_training_client(TrainerBase._open_policies,
which opens each policy's:Policy.open,Policy.create_training_client), the steps of the draws
that only called the rollout source's (_begin_task_records,_training_tasks,_new_dispatcher,_samplers,
_task_records_started,_checkpoint_state,_restore_dispatch) and_model_info(model_infois the
trained policy's).
Its steps save only through the start that
train()orrun()runs: a trainer outside one refuses to save. - its loops and their stop:
-
The rl trainer's training step is
platoon.train.step.step.TrainStep, beside what its steps share (StepContext);
platoon.train.trainer.loopskeeps the training loop. -
Names that misled are renamed, and the old module paths read the old names (below):
- a settled group,
_Settled, is aSettledGroup(platoon.train.dispatch.settled), made byas_settled_group
(_settled), and its fieldrolloutisoutput: an object that stands in for one has anoutput; - a group's output and a member's outcome,
_TaskRolloutOutputand_SingleRolloutOutcome, areGroupOutputand
MemberOutcome(platoon.train.workflow.output); what a step reads of a group's workload,
_TaskWorkloadPayloadand_get_task_workload_payload, is_GroupWorkloadand_group_workload
(platoon.train.step.collect); GroupRolloutWorkflow's startup gate:_admit_groupis_enter_startup_gateand_admission_semaphoreis
_startup_semaphore, and the_GroupAdmissionthe gate gives is aStartupPass, which_run_group_membertakes
asstartup_pass(wasadmission). The workflow no longer calls a subclass's_admit_groupor
_admission_semaphore: defining one warns (PlatoonDeprecationWarning), until platoon 0.6.0; override the new
name.
- a settled group,
-
platoon.train.componentsno longer definesDatasetLoader,TaskLoader,RolloutFn,RewardProcessor,
WorkflowFactory,TrainerConfigClassandLossFn, which nothing used.ClientThroughputMonitorno longer has
record_dequeue_to_first_model_call, which nothing called. -
GroupRolloutWorkflowdeclares at class level the defaults of what its constructor sets (stats_scope,
rollout_isolation,label_by,packages,rollout_fn_ref,subagent_datum_sampler), for a subclass that skips
the constructor. A group reads the settings that a partial workflow config may lack through
platoon.train.workflow.settings.GroupSettings: a missing one reads asWorkflowConfig's default, which is the
default each reader had. -
The rl trainer's step ends through the step hooks' sequence (
platoon.train.trainer.hooks.finish_step), which
publishes and advances its clock as before when no hook is registered (TrainStep.publishtakes the step's end and
returns who published). The barrier of the starting policy's evaluation before the first update is the learner's
(Learner(before_update=...), awaited byPolicyUpdate.apply, whose step tally is optional). The rl trainer's
closestops its recovery pruning after steps that ended (run.ended), as before, and does nothing after a
failure. -
Logger names follow the modules. A log line names the module that logs it (the
%(name)sof platoon's log
format), so the trainer's lines no longer come fromplatoon.train.tinker.rlor
platoon.train.tinker.workflows.group_rollout_workflowbut from, say,platoon.train.step.step(a step's line),
platoon.train.trainer.rl(a start's),platoon.train.workflow.members(a group's stragglers) or
platoon.train.workflow.telemetry(a rollout worker's). A filter or a level set onplatoon.train.tinkerno longer
reaches them: set it onplatoon.train. -
import platoon.train.tinkerno longer imports the trainer, and with it datasets and transformers: it loads about
half the modules it did. It still loads tinker, torch and tinker_cookbook: its init applies the rollout worker
invariants first, fromplatoon.train.workers.env, and the init ofplatoon.train.workersimports the protocol.
platoon.trainexports the trainer, its config classes, the batch transforms,GroupRolloutWorkflowand
current_entrylazily, and neither it norplatoon.train.config_defsloads tinker or torch. -
Rollout workers run
python -m platoon.train.workers.entrypoint. Their fork server preloads the modules it did
(the trainer's among them), so a forked worker starts as it did. A worker started by exec no longer imports the
trainer before its rollout, nor datasets, pandas and requests with it, which a forked worker holds from its server:
underworker_python_env_isolation, a rollout's Python overlay can now replace those in an exec'd worker, not in a
forked one.
Fixes
platoon evalthat gets SIGTERM, such as a scheduler's at the end of a job's time, stops as at Ctrl+C: it starts
no further rollout, cancels those in flight, stops its node services, reaps the sandboxes labelled with its output
directory and exits 143. It died at once, leaving its sandboxes untilplatoon sandbox reap. It also reaps them
before its first rollout, asplatoon traindoes, so that a start on an output directory reaps what an earlier one
that was killed left there.- The
processsandbox provider'sreap(labels), which a run calls before it starts and as it exits, and
platoon sandbox reapwith it, returns only once the processes it killed have exited, or after 10 seconds, with a
warning that names those still running. It returned once it had sent them SIGKILL, while they could still be giving
back their memory, files and ports.
Deprecations
- The module paths under
platoon.train.tinkerthat moved. Each still imports, and each name read through it warns
once with aPlatoonDeprecationWarningthat names its new module, until platoon 0.6.0 removes them. A name read
through an old path is the moved object itself, soisinstancechecks, pickles and themodule:Classa config names
keep working. Importing an old module imports the trainer, as it did before (platoon.train.tinkeritself no longer
does: above), so that no module of the trainer is first imported while a patch made through an old path is active. A
module that was split still has the names it imported,from <old path> import *binds what the new module exports (a
split module's public names that moved), the submodules that an old package's init imported are its attributes, and a
name set or deleted through an old path, asmonkeypatchandmock.patchdo, is set or deleted where the moved code
reads it, and undoing the patch puts back what was there, also for amock.patchmade withcreate=Trueor nested in
another patch, by unittest.mock or by themockpackage. A name that platoon 0.5.0 also renamed reads as the renamed
object, and its warning names its new name:platoon.train.tinker.rl's_Settled,_settled,_TaskWorkloadPayload
and_get_task_workload_payload(SettledGroup,as_settled_group,_GroupWorkload,_group_workload), and
.workflows.group_rollout_workflow's_SingleRolloutOutcome,_TaskRolloutOutputand_GroupAdmission
(MemberOutcome,GroupOutput,StartupPass). A method ofGroupRolloutWorkflowthat moved is a module function
that its class keeps under its name, but for the startup gate's two that platoon 0.5.0 renamed (above): patch the
class or an instance, as before, not the module function (PlatoonTinkerRLTrainerkeeps none: above). What stays
different through an old path: a patch throughplatoon.train.tinker.rlalso reaches the moved code of.permitsand
.censoring, and one through either of them reaches rl's, as they share new modules. Patching an old module's
namespace (mock.patch.dict(vars(module)),monkeypatch.setitem(vars(module), ...)) does not reach the moved code:
patch the attribute instead. An old module's__all__is computed when it is read, not held in the module. Where each
went:platoon.train.tinker's names (PlatoonTinkerRLTrainer,Watchdog, the config classes, the batch transforms):
platoon.train;platoon.train.tinker.rl: by stage,PlatoonTinkerRLTrainerandWatchdogtoplatoon.train(they live in
platoon.train.trainer.rland.signals), the loops' exceptions toplatoon.train.trainer.signals,
TrainLoopSharedStatetoplatoon.train.trainer.state,PlatoonTinkerDataloadertoplatoon.train.dataloader,
compute_training_metricstoplatoon.train.objectives.policy_gradient, and the rest toplatoon.train.trainer,
.policy,.step,.dispatch,.checkpointsand.evaluation;platoon.train.tinker.config_defs,.dataset_order,.task_records:platoon.train.config_defs,
.dataset_order,.task_records;platoon.train.tinker.train:platoon.train.launch;platoon.train.tinker.batch_transforms,.staleness,.censoring:platoon.train.step.batch_transforms,
.staleness,.metrics;platoon.train.tinker.dispatcher,.permits:platoon.train.dispatch.dispatcher(the dispatcher's state file,
save_state,read_stateandplan_resumeamong it, toplatoon.train.checkpoints.dispatch_state) and
platoon.train.dispatch.permits;platoon.train.tinker.kept_groups,.checkpoint_pruning,.evaluation_record:
platoon.train.checkpoints.kept_groups,platoon.train.checkpoints.pruning,platoon.train.evaluation.record;platoon.train.tinker.workflowsand.workflows.group_rollout_workflow:platoon.train.workflow
(GroupRolloutWorkflow,RolloutWorkflow,current_entry,group_baselines) and its modules, and the group's
credit toplatoon.train.credit.group(group_baselineslives there);
platoon.train.tinker.workflows.base:platoon.train.workflow.protocol;platoon.train.tinker.start_limit:
platoon.train.workflow.start_limit;platoon.train.tinker.workersand its modules, andplatoon.train.tinker.worker_env:platoon.train.workers,
its modules, andplatoon.train.workers.env;python -m platoon.train.tinker.workers.entrypointandpython -m platoon.train.tinker.trainstill run, with the
warning: runpython -m platoon.train.workers.entrypointandpython -m platoon.train.launch.
Install and upgrade
pip install -U 'platoon[tinker,ors]>=0.5,<0.6'
# with the TextCraft extension, name it too:
pip install -U 'platoon[tinker,ors]>=0.5,<0.6' 'platoon-textcraft[platoon]'
# with hpc-sandbox, for Singularity, Apptainer and enroot:
pip install -U 'platoon[tinker,ors,hpc]>=0.5,<0.6'- To upgrade a project that pins one minor release, move its range to 0.5, such as
platoon[tinker,ors]>=0.5,<0.6.
Platoon 0.5 refuses the components of an installed extension whose requirement on platoon excludes it, such as
platoon>=0.4,<0.5, with an error that names both versions, until a release of the extension admits 0.5;
meanwhile,PLATOON_IGNORE_EXTENSION_REQUIREMENTS=1loads them anyway
(Compatibility and provenance). - The
hpcextra requires hpc-sandbox 0.5.1 (hpc-sandbox>=0.5.1,<0.6), which an upgrade with the extra installs.
An environment that installs platoon without the extra may keep an older hpc-sandbox: there, stacks, and the tasks
whose sandboxes need an owner manifest, are refused when the tasks are listed, naming 0.5
(The hpc extra). - A fetch locks multi-container Harbor tasks as runnable stacks (
refuse.sidecarsdefaults tofalse). A pinned
dataset whose lock refused its stacks, for an entry that sets norefuse.sidecars, is fetched again by
platoon prepare,platoon trainandplatoon eval, its cached tasks reused. Where the provider runs no stacks, the
run then stops when it lists its tasks, naming them: leave them out (exclude_tasks), or set
refuse: {sidecars: true}, which refuses them as 0.4 did
(Multi-container tasks). - The training code moved out of
platoon.train.tinkerinto packages ofplatoon.train
(docs/training-code.md maps them). Code that imports an old module path keeps working, with
aPlatoonDeprecationWarningthat names the new one, until 0.6.0 (Deprecations): import from the new paths. Set a
log filter or level onplatoon.train, notplatoon.train.tinker, and patch the module functions that replaced the
trainer's methods (Changes). - After upgrading under a run that is training, restart it, so that its rollout workers run the code it does.
- The TextCraft extension's release for platoon 0.5 is
platoon-textcraft0.4.0, whoseplatoonextra requires
platoon[ors]>=0.5,<0.6. Its code and data are those of 0.3.0. The release for platoon 0.4 stays 0.3.0: there, keep
the extension within 0.3 (platoon-textcraft>=0.3,<0.4), since platoon 0.4 refuses the components of 0.4.0, also
when it is installed without its extra. An upgrade of platoon alone leaves 0.3.0 installed, which pip does not flag
and platoon 0.5 refuses: upgrade the two together, as above
(The TextCraft extension).