You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
positronic/policy/DESIGN.md defines the target policy API. #652 lands it. The target contract: a session is called synchronously as (obs, time) -> (commands, resume_at), heavy work runs in served functions returning pimm Answers, a Runtime object delivers fns (and later record) to every session, and the framework owns the layer chain.
The current code has a different shape. Session.__call__(obs) -> list[dict] | None returns action chunks, and the harness (positronic/policy/harness.py) owns waypoint playback and runs the whole session chain on a worker thread (_InferenceWorker). #624 describes what that caps: no session runs while inference is in flight, and the stack does not own its schedule. The migration fixes both. The async property moves out of the harness into served functions, and the schedule moves out of the harness into the sessions.
This issue is the hand-off ladder. One PR per stage, in order. Read DESIGN.md before starting any stage. Every PR leaves the full suite green (uv run --locked pytest).
Key decisions
Async lands before the call shape changes. Served functions arrive while the old (obs) -> chunk | None contract stands, and None is the "in flight" signal. The signature flips only when no session call blocks.
Runtime is introduced early as plumbing. It carries fns from the start. The transitional signature is new_session(context, now, rt), and the time stage removes now.
Functions start client-side. The first Fn wraps the existing websocket round-trip. The wire protocol does not change until the wire phase.
time precedes the flip, because resume_at is defined on the clock of time.
The framework-owned chain comes after the flip. It is not a prerequisite.
The wire migrates last, one vendor per PR. Session-serving retires only after every vendor serves functions.
PR 2 — Function executor. A module that wraps a plain callable into an Fn: calling it starts the work on a pool and returns an Answer. Tests included. Landed in Serve a policy's functions on a pool, each call answering an Answer #664: the policy API defines its own Answer rather than reusing pimm's, so positronic/policy/executor.py depends on nothing outside the standard library.
PR 3 — Remote inference as a function.Policy.functions (empty default) and Runtime.fns. new_session(context, now) becomes new_session(context, now, rt) everywhere. RemotePolicy (remote.py) declares the websocket round-trip as its function. RemoteSession invokes it via rt.fns and returns None while the Answer is pending. ChunkedSchedule (layers.py) passes None from its inner through. Harness sim-hold and inference charging switch from measuring the worker call to measuring executor in-flight time. Landed in Run remote inference as a served function, answered while control keeps going #665. The session reads the answer on the call after the one that started it, so a chunk pairs with a later observation than the model ran on. RelativePositionAction and the codec decode context are deleted: nothing consumed them. A runtime closes before the session it serves. A failed call that no caller read is logged at close.
PR 4 — Local models onto functions.LerobotPolicy (vendors/lerobot_0_3_3/policy.py) declares infer, and its session invokes it through rt.fns. It is the only in-process pipeline in the repository. The inference server loads the same policy, so the server builds a runtime for each session and calls the session until it answers. Three call sites do this: the server's infer handler, offboard/server_utils.warmup and probe.py. The "one call in flight" state machine moves out of RemoteSession into one class that both sessions use. After this stage no rig-side session call blocks.
PR 5 — Delete _InferenceWorker. The harness calls the chain on the loop thread. SKIP_REPLY_SEC and the submit/result plumbing die. The golden does not move. The worker waits on the wall clock. The golden runs in a virtual world, and that world charges nothing for inference. The deleted waits take no time in that world. A test on e09ffdcf shows this. The golden passes when the session runs on the loop thread. It passes again when now comes from the live world clock. If the golden moves in this stage, stop and find the cause. Do not write a new golden file. The stage ends the contract that lets a session call block. Six tests in test_harness.py depend on that contract: the in-the-call slow policies, and the tests that abandon a call in flight. Change these six tests. The loop must still wait for a call in flight in an uncharged sim. A trial that charges nothing for inference must not move the clock while a function runs. Landed in Call the policy session on the harness loop thread #673. The golden did not move. A rig-side codec now runs on the loop thread: RestrictImageSize costs 3 ms for one 1280x720 frame and 12 ms for a 4-deep stack, on each round while a round trip is in flight. The count of calls is the same as before. PR 9 moves the transform to the place that decides to send. The uncharged wait ends on should_stop as well, so a model that never answers does not keep the world up.
PR 5b — Answer terminal states and cancel. From the design (Write down the policy API design requirements #652): every failure the framework can see — a lost connection, a dead worker, a value that does not serialize — ends the handle with an error. Only a function that does not return leaves a handle open. Answer.cancel() says the caller will not read the answer; the framework stops retries and drops the queued call, never waiting. A call that already runs may run to its end. Functions are pure, so a dropped call loses nothing. The executor Answer (positronic/policy/executor.py) grows both promises now; the wire stage (PR 9) keeps them over the websocket.
PR 6 — time argument.__call__(obs) becomes __call__(obs, time). now/Now deleted from new_session, make_session, vendors, tests. Behavior-neutral: the chunk anchor already happens on the call where the Answer resolves, and time there equals what now() returned.
PR 7 — The flip, and caller-owned calls. The player layer (successor of ChunkedSchedule) holds the chunk and emits due commands itself. The contract becomes (commands, resume_at). The harness sheds the waypoint deques, the skew check, and _play, emits returned commands immediately, and paces the next call by resume_at, bounded by the episode deadline, a 1 ms floor and a 1 s ceiling. ACTION_TIMESTAMP and the keyless sentinels become layer-internal. The caller owns the calls.ChunkSession.__call__ answers an Answer rather than a chunk-or-None, so the one-call-in-flight state machine leaves RemoteSession and LerobotPolicy._Session and moves to ChunkPlayer, which holds the handle and could hold more than one. A source that runs the work inside the call answers a Done. The chunk tap logs when the caller reads, against the frame of the call that started the work. The golden did not move.
PR 8 — Framework-owned chain. Layers receive Inner handles (no time slot, the framework stamps the time). The framework assembles the chain and closes every session itself. Session.cancel and DelegatingSession are removed. This stage takes the runtime out of the harness's hands. A perform_task call carries a Rollout (harness.py, from Let the episode call carry the session that runs it #690): the task, the session that runs it, and the Executor serving that session. The harness holds the runtime for two reasons — it closes the runtime before the session, and it charges the trial for inference (in_flight, wait). The close order belongs to whatever assembles and closes the chain, so it moves here and Rollout sheds it. The charge stays with the harness by design (ARCHITECTURE.md), and resume_at cannot express it: an uncharged trial must let no virtual time pass while a function runs. So this stage decides what a request still carries — a runtime, or a narrower handle that answers those two questions. One runtime per session, from Write down the policy API design requirements #652.chain.wrap(policy) returns a framework class. Its new_session(context, rt) makes each session in the chain. It derives one runtime for each session from rt, through private methods of the concrete Runtime. The run state — the executor and the recording writer — stays shared. The per-session bindings — the series prefix and the charge identity — change. A derived runtime closes before its session. The public protocols do not change: no factory and no child method.
PR 9 — Functions over the websocket. The server executes declared functions per request. The declaration names functions next to the layer stack. Client Fn stubs are built from the declaration. After this stage the rig holds the only Policy. ModelSource.load returns the served functions and the declaration, not a Policy. ChunkSession, AnySession, DelegatingChunkSession and Done go with it: a layer calls rt.fns for the chunk itself. The blocking adapter and the server's session driving go too. A per-session resource — DreamZero's client, which its session opens and closes — has no home in policy-level functions today, and this stage is where that gets one.
PR 10..N — One vendor each. Each vendor's model session becomes a stateless infer callable plus rig-side layers and codecs: lerobot, gr00t, openpi, then the rest. A served function keeps no state between calls. The vendors differ in how much state they keep. lerobot_0_3_3 calls predict_action_chunk, which keeps none. vendors/lerobot calls select_action, which keeps an action queue in the model. That queue moves to a rig-side layer. gr00t, openpi, dreamzero and molmoact2 are not checked yet.
Final PR — Retire session-serving. The per-websocket session path and the chunk normalizations die. split/SEQ/PAR rework to the final declaration shape.
Invariants
policy/tests/test_golden_pipeline.py regenerates only in the stages marked "golden regen", and each regen's timing delta is explained in its commit.
offboard/tests/test_server.py::test_in_process_equals_remote_for_same_pipeline stays green through every stage.
The wire protocol is untouched until PR 9.
The policy library does not name the harness. The harness knows positronic/policy/, and the knowledge does not go the other way. No comment, docstring or error message in that package names the harness. harness.py is the harness itself, so the rule leaves it alone. Nine mentions break the rule: one in base.py, one in codec.py, two in recording.py, five in layers.py. PR 6 deletes the base.py one together with the now argument. Every stage removes the mentions in the code it touches. State what the code does, not who calls it.
Out of scope
Recording migration (rt.record replacing the recording.py taps).
context to a structured robot description (deferred in DESIGN.md).
Codec adapter vs Layer inheritance is undecided. If the adapter wins, it is one small PR after the rename.
Context
positronic/policy/DESIGN.mddefines the target policy API. #652 lands it. The target contract: a session is called synchronously as(obs, time) -> (commands, resume_at), heavy work runs in served functions returning pimmAnswers, aRuntimeobject deliversfns(and laterrecord) to every session, and the framework owns the layer chain.The current code has a different shape.
Session.__call__(obs) -> list[dict] | Nonereturns action chunks, and the harness (positronic/policy/harness.py) owns waypoint playback and runs the whole session chain on a worker thread (_InferenceWorker). #624 describes what that caps: no session runs while inference is in flight, and the stack does not own its schedule. The migration fixes both. The async property moves out of the harness into served functions, and the schedule moves out of the harness into the sessions.This issue is the hand-off ladder. One PR per stage, in order. Read DESIGN.md before starting any stage. Every PR leaves the full suite green (
uv run --locked pytest).Key decisions
(obs) -> chunk | Nonecontract stands, andNoneis the "in flight" signal. The signature flips only when no session call blocks.Runtimeis introduced early as plumbing. It carriesfnsfrom the start. The transitional signature isnew_session(context, now, rt), and thetimestage removesnow.Fnwraps the existing websocket round-trip. The wire protocol does not change until the wire phase.timeprecedes the flip, becauseresume_atis defined on the clock oftime.The ladder
PolicyWrapper→Layer,wrap_session→make_session, acrosspositronic/policy/and vendors. Mechanical. Wire names inspec.WIRE_LAYERSstay unchanged. Landed in RenamePolicyWrappertoLayerandwrap_sessiontomake_session#663.Fn: calling it starts the work on a pool and returns anAnswer. Tests included. Landed in Serve a policy's functions on a pool, each call answering anAnswer#664: the policy API defines its ownAnswerrather than reusing pimm's, sopositronic/policy/executor.pydepends on nothing outside the standard library.Policy.functions(empty default) andRuntime.fns.new_session(context, now)becomesnew_session(context, now, rt)everywhere.RemotePolicy(remote.py) declares the websocket round-trip as its function.RemoteSessioninvokes it viart.fnsand returnsNonewhile theAnsweris pending.ChunkedSchedule(layers.py) passesNonefrom its inner through. Harness sim-hold and inference charging switch from measuring the worker call to measuring executor in-flight time. Landed in Run remote inference as a served function, answered while control keeps going #665. The session reads the answer on the call after the one that started it, so a chunk pairs with a later observation than the model ran on.RelativePositionActionand the codec decodecontextare deleted: nothing consumed them. A runtime closes before the session it serves. A failed call that no caller read is logged at close.LerobotPolicy(vendors/lerobot_0_3_3/policy.py) declaresinfer, and its session invokes it throughrt.fns. It is the only in-process pipeline in the repository. The inference server loads the same policy, so the server builds a runtime for each session and calls the session until it answers. Three call sites do this: the server's infer handler,offboard/server_utils.warmupandprobe.py. The "one call in flight" state machine moves out ofRemoteSessioninto one class that both sessions use. After this stage no rig-side session call blocks._InferenceWorker. The harness calls the chain on the loop thread.SKIP_REPLY_SECand the submit/result plumbing die. The golden does not move. The worker waits on the wall clock. The golden runs in a virtual world, and that world charges nothing for inference. The deleted waits take no time in that world. A test one09ffdcfshows this. The golden passes when the session runs on the loop thread. It passes again whennowcomes from the live world clock. If the golden moves in this stage, stop and find the cause. Do not write a new golden file. The stage ends the contract that lets a session call block. Six tests intest_harness.pydepend on that contract: thein-the-callslow policies, and the tests that abandon a call in flight. Change these six tests. The loop must still wait for a call in flight in an uncharged sim. A trial that charges nothing for inference must not move the clock while a function runs. Landed in Call the policy session on the harness loop thread #673. The golden did not move. A rig-side codec now runs on the loop thread:RestrictImageSizecosts 3 ms for one 1280x720 frame and 12 ms for a 4-deep stack, on each round while a round trip is in flight. The count of calls is the same as before. PR 9 moves the transform to the place that decides to send. The uncharged wait ends onshould_stopas well, so a model that never answers does not keep the world up.Answerterminal states andcancel. From the design (Write down the policy API design requirements #652): every failure the framework can see — a lost connection, a dead worker, a value that does not serialize — ends the handle with an error. Only a function that does not return leaves a handle open.Answer.cancel()says the caller will not read the answer; the framework stops retries and drops the queued call, never waiting. A call that already runs may run to its end. Functions are pure, so a dropped call loses nothing. The executorAnswer(positronic/policy/executor.py) grows both promises now; the wire stage (PR 9) keeps them over the websocket.timeargument.__call__(obs)becomes__call__(obs, time).now/Nowdeleted fromnew_session,make_session, vendors, tests. Behavior-neutral: the chunk anchor already happens on the call where theAnswerresolves, andtimethere equals whatnow()returned.ChunkedSchedule) holds the chunk and emits due commands itself. The contract becomes(commands, resume_at). The harness sheds the waypoint deques, the skew check, and_play, emits returned commands immediately, and paces the next call byresume_at, bounded by the episode deadline, a 1 ms floor and a 1 s ceiling.ACTION_TIMESTAMPand the keyless sentinels become layer-internal. The caller owns the calls.ChunkSession.__call__answers anAnswerrather than a chunk-or-None, so the one-call-in-flight state machine leavesRemoteSessionandLerobotPolicy._Sessionand moves toChunkPlayer, which holds the handle and could hold more than one. A source that runs the work inside the call answers aDone. The chunk tap logs when the caller reads, against the frame of the call that started the work. The golden did not move.Innerhandles (no time slot, the framework stamps the time). The framework assembles the chain and closes every session itself.Session.cancelandDelegatingSessionare removed. This stage takes the runtime out of the harness's hands. Aperform_taskcall carries aRollout(harness.py, from Let the episode call carry the session that runs it #690): the task, the session that runs it, and theExecutorserving that session. The harness holds the runtime for two reasons — it closes the runtime before the session, and it charges the trial for inference (in_flight,wait). The close order belongs to whatever assembles and closes the chain, so it moves here andRolloutsheds it. The charge stays with the harness by design (ARCHITECTURE.md), andresume_atcannot express it: an uncharged trial must let no virtual time pass while a function runs. So this stage decides what a request still carries — a runtime, or a narrower handle that answers those two questions. One runtime per session, from Write down the policy API design requirements #652.chain.wrap(policy)returns a framework class. Itsnew_session(context, rt)makes each session in the chain. It derives one runtime for each session fromrt, through private methods of the concreteRuntime. The run state — the executor and the recording writer — stays shared. The per-session bindings — the series prefix and the charge identity — change. A derived runtime closes before its session. The public protocols do not change: no factory and no child method.Fnstubs are built from the declaration. After this stage the rig holds the onlyPolicy.ModelSource.loadreturns the served functions and the declaration, not aPolicy.ChunkSession,AnySession,DelegatingChunkSessionandDonego with it: a layer callsrt.fnsfor the chunk itself. Theblockingadapter and the server's session driving go too. A per-session resource — DreamZero's client, which its session opens and closes — has no home in policy-levelfunctionstoday, and this stage is where that gets one.infercallable plus rig-side layers and codecs: lerobot, gr00t, openpi, then the rest. A served function keeps no state between calls. The vendors differ in how much state they keep.lerobot_0_3_3callspredict_action_chunk, which keeps none.vendors/lerobotcallsselect_action, which keeps an action queue in the model. That queue moves to a rig-side layer. gr00t, openpi, dreamzero and molmoact2 are not checked yet.split/SEQ/PARrework to the final declaration shape.Invariants
policy/tests/test_golden_pipeline.pyregenerates only in the stages marked "golden regen", and each regen's timing delta is explained in its commit.offboard/tests/test_server.py::test_in_process_equals_remote_for_same_pipelinestays green through every stage.positronic/policy/, and the knowledge does not go the other way. No comment, docstring or error message in that package names the harness.harness.pyis the harness itself, so the rule leaves it alone. Nine mentions break the rule: one inbase.py, one incodec.py, two inrecording.py, five inlayers.py. PR 6 deletes thebase.pyone together with thenowargument. Every stage removes the mentions in the code it touches. State what the code does, not who calls it.Out of scope
rt.recordreplacing therecording.pytaps).contextto a structured robot description (deferred in DESIGN.md).Layerinheritance is undecided. If the adapter wins, it is one small PR after the rename.Related issues
_build_obssubstance is not covered by any stage here.Harness._bump_schedule_endreaches intoChunkedScheduleprivates and silently no-ops for any other scheduler #473, The server should own inference recording: declare the taps, report into them #534 — closed as superseded by the design and this ladder.