add do command wiring for planning and execution - #927
add do command wiring for planning and execution#927Devin T. Currie (DTCurrie) wants to merge 5 commits into
Conversation
🦋 Changeset detectedLatest commit: 33bb831 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
87e3640 to
925d1ac
Compare
925d1ac to
5388e08
Compare
5388e08 to
5f7fa53
Compare
|
5f7fa53 to
0e3ea61
Compare
0e3ea61 to
b37beb6
Compare
b37beb6 to
d5f0552
Compare
797cb05 to
d60c7ea
Compare
d60c7ea to
fa2cd9a
Compare
01e4a3e to
5758f58
Compare
5758f58 to
bcefc6e
Compare
|
Claude (@claude) review |
|
Claude finished Devin T. Currie (@DTCurrie)'s task in 3s —— View job Review
Clean module and very thorough spec. What's well done
FindingsTwo findings, both minor — see the inline comments for specifics:
Neither is a correctness issue. Both are easy one-liner fixes if you want to clean them up before merge. |
| return 'Motion service returned a trajectory this client cannot read.' | ||
| } | ||
|
|
||
| export class PlanCommandError extends Error { |
There was a problem hiding this comment.
Minor – class declared after first use.
PlanCommandError is exported and referenced at line 44 (inside planCommand) and again inside parsePlanResult, but the class body isn't declared until here. This works at runtime because both usages are inside function bodies that are called only after the module finishes evaluating, so the class is initialised in time. But reading top-down, a reader hits throw new PlanCommandError(...) before ever seeing the class, which is surprising.
Consider moving the class declaration above planCommand (or at least above its first use), which is the conventional spot for exported error types.
| } | ||
|
|
||
| if (step === null || typeof step !== 'object') { | ||
| return 'Motion service returned a null trajectory step.' |
There was a problem hiding this comment.
Nit – message says "null" for any non-object step, not just null.
if (step === null || typeof step !== 'object') {
return 'Motion service returned a null trajectory step.'
}If step is a number (42) or a string ("oops"), the branch fires and the user reads "null trajectory step", which is wrong. These shapes don't come from RDK in practice, but the message will be confusing if one ever does surface. A small tweak covers both cases:
| return 'Motion service returned a null trajectory step.' | |
| if (step === null) { | |
| return 'Motion service returned a null trajectory step.' | |
| } | |
| if (typeof step !== 'object') { | |
| return `Motion service returned a non-object trajectory step (got ${typeof step}).` | |
| } |
bcefc6e to
33bb831
Compare
typeof NaN is 'number', so the trajectory guard let one through, and a single NaN is not a local defect: interpolateTrajectory sums every segment's frame cost, so the total goes NaN and every segment of the plan collapses to one frame with no error raised. planCommand already refuses a non-finite goal on the same reasoning.
33bb831 to
f0fc129
Compare
Adds the two
DoCommandverbs the builtin motion service exposes but the motion proto does not:plan, which plans a move without executing it, andexecute, which runs a trajectory verbatim. Stacks on #926.Neither has a generated client, because neither is in the proto, and only
services/motion/builtinimplements them, so any other motion service errors. Nothing calls this yet; the preview lifecycle two PRs up is the consumer. It lands on its own because it is pure command construction and reply parsing, with no ECS, no Svelte and no I/O, so its spec can cover the whole surface directly.Stack
$lib/motion(make motion utils reusable #917)MoveFrameplugin (Motion plan preview #908)Frontend
planCommandbuilds theplanpayload: a protojsonmotion.v1.MoveRequestas a string, since that is what RDK unmarshals. It refuses a goal that is not a finite pose, and passesworldStateandconstraintsthrough so a preview plans the same problem the subsequentmovewould.executeCommandbuilds theexecutepayload and armsexecuteCheckStart.parsePlanResultreads the reply. Each way it can fail gets its own message rather than one shared diagnosis: an unexpected response, an absent or nil trajectory, an empty one, a joint value that is not a finite number, a step naming no components, and a trajectory in the older joint-value format.isAlreadyAtGoalrecognizes the trajectory RDK returns when the goal is already satisfied.PlanCommandErrornames every failure this module raises, so a caller can tell a bad reply apart from a transport error.Why?
Why send
executeCheckStartat all, and why zero?Its presence is the switch (
builtin.go:376). Omit it and epsilon ismath.MaxFloat64, so the comparison atbuiltin.go:621can never trip and the trajectory runs from wherever the components happen to be. Sinceexecutenever replans, that is exactly the case worth refusing: the plan was validated from one starting configuration, and running it from a different one flies a path nothing checked. A failed check comes back as an RPC error rather than a field in the reply, sinceresp[executeCheckStart]is a constant string echoed back whenever the key was sent and says only that the check was asked for.Zero because RDK reads any value at or below it as "use the default" (
defaultExecuteEpsilon, 0.01). How far an arm may have drifted before its plan is stale is a property of the arm, not something a viewer should be deciding for it.Why is there no conversion between the reply and the replayer's kinematics?
Because there is nothing to convert.
motionplan.Trajectoryis[]referenceframe.FrameSystemInputs, andFrameSystemInputsismap[string][]InputwhereInputis afloat64alias, so on the wire it is exactly thetrajectoryarray a plan dump already carries. The kinematics added in #924 read it unchanged, which is whyTrajectoryStepis re-exported from$lib/motion/jointPosehere rather than redeclared.Why rebuild
worldStateandconstraintsbefore sending them?The SDK exports each of these under one name as both a class and a
PlainMessagetype alias, and it is the alias that lands in a type position. So what arrives atplanCommandis a plain object with notoJsonon it, and rebuilding is what reaches protojson.parseMoveOptionshappens to return real message instances today, for which the rebuild is a no-op, so this is the one thing in the module that the obvious spec input cannot exercise. There is a test that passes the plain shape the signature actually describes.Why refuse a non-finite goal instead of sending it?
JSON.stringifywritesnullforNaN, and Go's protojson skips a JSON null for a scalar field rather than rejecting it, leaving the field at its zero. ANaNin the goal would therefore reach RDK as 0 mm, plan successfully, and preview a move to a destination the user never asked for, with nothing wrong on either side. Only this path can do that:client.movesends a proto double, which carries the NaN and gets refused by the planner.Pose.isFinite()already exists and guards the same way inFrameEditor.ts.Why does a step have to be a non-empty object of finite number arrays?
Because
everysays yes to the two structural degenerate cases by default. An array istypeof 'object', so[[0, 1], [2, 3]]walked as a step; andObject.values({})is empty, so{}passed vacuously. Neither errors downstream either, becausejointValueAtresolves a missing column to0, so a reply this guard exists to reject instead drew a plausible-looking arm at the zero configuration.[[], []]was worse again: it parsed and satisfiedisAlreadyAtGoal, so the panel reported the machine was already there.Finite rather than merely numeric for a different reason.
typeof NaN === 'number', and oneNaNin a reply is not a local defect: the frame budget in #925 sums every segment's cost to decide how finely to sample, so a single non-finite value makes the total non-finite, which survivesMath.ceiluntil the interior loop stops running and every segment of the plan collapses to one frame. That is the raw waypoint teleport interpolation exists to prevent, reached with nothing reporting an error and aNaNon the scrubber's coarsening readout.planCommandalready refuses a non-finite goal on exactly this reasoning, so applying the same rule to the reply is consistency rather than a new position.Whether RDK can send one is unproven, and it is cheap either way.
protojsonrefuses to marshal a non-finitestructpb.Value, but a machine connection is WebRTC binary proto, which has no such objection. This client cannot draw aNaNconfiguration under any reading of it.Why does one malformed shape get a message about RDK's age, and the rest not?
There is no capability or version RPC to probe a machine with, so the shape of the reply is the only evidence available. An RDK older than ~v0.101 serializes
Inputas a struct, so a plan that succeeded comes back as[{"arm": [{"Value": 0.1}]}]. That one shape is worth naming, rather than reporting as "no plan", and the claim behind it is sound as an upper bound:Inputis a struct in v0.90 and afloat64alias in v0.101, and the same older versions takecomponent_nameas aResourceName, so the request would not have unmarshalled either.It is evidence for that shape only. Every other way a reply can fail
isTrajectoryis not something an upgrade explains, sodescribeMalformedTrajectorywalks the value and says what is actually wrong instead of borrowing a diagnosis that fits one cause. The user-facing message names no version at all, only "an older version of RDK", since the exact cutover is somewhere between the two and a version number in a string a user reads goes stale the moment it is wrong. The bound stays in the code comment, where it can be checked against RDK.A Go nil trajectory marshals to JSON
null, which is nothing to draw for the same reason an absent key is, so it reports "no trajectory" rather than sending someone on current RDK off to upgrade it.Why is
isAlreadyAtGoalan exact comparison, and why exactly two steps?RDK seeds the trajectory with the start configuration (
plan_manager.go:51) and appends the IK solution, so a satisfied goal comes back as two steps, never empty and never as an error. The two are bit-identical, but not because the second is a copy: it is an nlopt output, identical because nlopt runs withSetStopVal(defaultGoalThreshold)from exactly the start configuration and short-circuits at x0 when the goal is already met.That makes this a guard which under-fires rather than one which mis-fires. A goal near enough to look identical on screen but far enough to clear the threshold gets a real two-step plan and a preview that does not visibly move. A tolerance would trade that for hiding real short moves, which is the worse mistake.
Length two is load-bearing for a different reason: a longer plan that ends where it began is a real move that goes somewhere and comes back, and hiding it would be worse than showing it.
Testing
pnpm exec vitest --runpasses 1045 tests across 81 files, up 39 tests and one file from the base branch.svelte-checkreports 0 errors and 0 warnings.planDoCommand.spec.tscovers all four exported functions and uses the realparseMoveOptionsandPoserather than mocking either. Every behavior was verified by reverting the line that implements it and confirming the matching test fails, including the mutants a weaker spec survives:sameInputs' per-component loop degraded tosome, the length check loosened to< 2, the trajectory reordered on the way out, and a stray key added to the payload.Three shapes in the spec are deliberate. The payload is asserted as a whole key set, not field by field, because RDK unmarshals it with default
protojsonoptions:DiscardUnknownis false, so any key that is not aMoveRequestfield is a hard RPC error, and a field-by-field spec would stay green while every real plan call failed.executeCommand's output is compared against a literal rather than against the array it was handed, since the command holds the same reference and comparing it to its own source is a tautology that any in-place reordering would survive. And each row of the malformed-reply table asserts its own message rather than just that something threw, so the messages cannot drift into sharing one that fits only a single cause.