Optional 1-DoF jaw on the hardware gripper - #40
Open
Nick Hehr (HipsterBrown) wants to merge 21 commits into
Open
Optional 1-DoF jaw on the hardware gripper#40Nick Hehr (HipsterBrown) wants to merge 21 commits into
Nick Hehr (HipsterBrown) wants to merge 21 commits into
Conversation
- The fake arm invoked hooks while holding f.mu, so TestGoToInputsAbortsOnStop (whose hook calls Stop) deadlocked. Hooks now run after unlocking. - Constructor priming is gated on articulatedJaw; unconditional, it prepended a servo_position to every command list and broke two existing tests. - TestStopDuringReadDoesNotStampStaleValue must invalidate first, or the primed cache makes the first call a hit and the hook never fires. Also: Stop's latch must precede its existing isMoving clear or it can never latch; stopped is an atomic (the plan said both bool-under-mutex and atomic); prime goes after the struct literal, not after probeServoSupport. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both grippers will need the ULP-overshoot tolerance for GoToInputs input validation; a divergent copy per gripper is the bad outcome. Pure move, no behaviour change.
newTestGripperWithConfig lets a test supply a non-default gripper config. onRead/onMove on fakeServoArm let a test hook a command; both fire after f.mu is released so a hook that issues another command (e.g. calling Stop from within an onMove) doesn't deadlock on the non-reentrant mutex. setErr and countCommands are seams for upcoming tests.
Validates every step, then short-circuits on a no-op batch (the common case: the gripper appears in every trajectory step of every plan carrying its unchanged value), and only then checks IsHoldingSomething -- in that order, so ordinary arm moves made while holding a part are never rejected. Adds an atomic stopped latch so Stop can abort an in-flight GoToInputs without taking g.mu.
calibrate_positions wrote g.openPosition/g.closedPosition without taking g.mu, while IsHoldingSomething read closedPosition unguarded -- now from the planner's goroutine on every jaw-moving GoToInputs batch. Take g.mu around the calibrate_positions writes, and give IsHoldingSomething its own g.mu-guarded read of closedPosition (safe here: this is the standalone public-API path, not called from within GoToInputs's own g.mu hold). Factor the threshold check into isHoldingAt so Grab shares the same policy instead of re-deriving it, and note why Grab's read failure returns true optimistically while IsHoldingSomething returns the error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
GoToInputs read the jaw position and classified the batch (no-op vs. holding-guard) before taking g.mu, then acquired the lock only for the move itself. A concurrent Open can hold g.mu for up to 2000ms inside servo_wait_stop, so the classification could be computed against a pre-Open jaw and then the move executed against a world that had already changed underneath it -- a TOCTOU. Move the lock up so the fresh read, the no-op check, the holding guard, and the move all happen as one critical section; execute-time means after the lock, not before it. g.mu is now held across the holding-guard check, so it can no longer call the public IsHoldingSomething (which itself takes g.mu to read closedPosition safely) without self-deadlocking. Read g.closedPosition directly instead, against the pct already fetched under the same lock. Accepted limitation, called out in comment: a Stop arriving during the read/decide phase isn't latched, since isMoving is still false there; setting it earlier would suppress legitimate cache stores during the read, so this is accepted rather than fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
- Prove the closedPosition threshold is actually measured from closedPosition, not hardcoded to 0: every existing fixture leaves it at its zero default, so "pct - g.closedPosition > threshold" and a buggy "pct > threshold" both pass today. Calibrate a non-zero closed_position and assert a jaw 10pp past it (under the 15pp threshold) reads as not holding. Mutation-checked by hand: dropping "-closedPct" from isHoldingAt fails the new case and nothing else; reverted after confirming. - Pin "default is static" to assert the constructor issues exactly the capabilities probe and nothing else, guarding the flag-off shipping default against an accidental jaw-cache prime. - Add a concurrent CurrentInputs-polling-during-Open/Grab test, race- clean, landing the ad hoc exercise used to validate the locking fixes as a real regression guard. - Drive TestStopDuringReadDoesNotStampStaleValue's hook through the real g.Stop path instead of calling invalidateJawCache directly, so it also proves Stop's servoDo invalidates. - Delete a dead onRead reset in TestGoToInputsAbortsOnStop (already nil on a fresh fake). - Pin the clock in TestJawCacheBoundsBusReads so it no longer depends on 20 calls completing inside a real 50ms window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
- positionPercentCached's moving-guard comment claimed it covers the servo_wait_stop timeout case; it doesn't -- moveToPercent returning on timeout doesn't stamp anything, the caller's own isMoving defer fires microseconds later, and a read landing after that isn't covered here at all (it can stamp a mid-flight value, bounded by the TTL to 50ms). What the guard actually covers is the narrower window between a move's last servoDo and that defer. - docs/gripper.md: "cached and may be up to 50ms stale" doesn't hold when the bus is down, since last-known-good can be arbitrarily stale; and a no-op batch "never touches the bus" should say "never commands the servo" -- it may still issue a servo_position read on a cache miss. - CLAUDE.md: jawLimitEpsilon was renamed to geometry.JawLimitEpsilon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
Sibling of the IsHoldingSomething fix: calibrate_positions writes these fields under g.mu, so every reader must take it too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/geometry components/gripper and components/simulated each re-implemented the same GoToInputs batch check (DoF arity, per-step arity, limit check) and had already drifted cosmetically (%.4f vs %.6f, differing wording). Share it as geometry.ValidateJawSteps, folding the empty-batch short-circuit to the front so it no longer runs after the DoF probe it doesn't need. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
jawAngle called raw positionPercent, so Geometries() -- the only caller, polled continuously by the 3D viewer -- issued an unconditional bus read per poll, defeating the cache even on the default non-articulated config. A dropped read now serves last-known-good instead of snapping the rendered jaw shut; servoDo's invalidation keeps it correct after a move. Document on positionPercent which callers must stay on the raw read (Grab, get_position) because they need to tell a failed read from a stale one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
positionPercentCached's freshness test had a redundant conjunct (jawReadAt only ever goes non-zero alongside jawHaveRead), and its hit path hand-rolled Unlock() instead of the defer everything else uses, a shape a future early-return could silently break. Collapse the three lock/unlock pairs into one locked prologue that snapshots gen, havePrev, prev, and fresh, unlocks once, and returns early on a hit. No behavior change -- the generation-checked store keeps its defer as-is. Document jawPct/jawReadAt/jawHaveRead's split responsibilities, which the compound condition was hiding: jawHaveRead survives invalidation so last-known-good can still serve, and 0 is itself a legal reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
- Grab: inline positionDifference into the Debugf call instead of computing it just to log it, duplicating what isHoldingAt computes on the next line. - GoToInputs's holding-refusal error pointed only at disabling articulated_jaw, which throws the feature away. Point it at the real fix: add the held object to the motion request's WorldState. - gripper_test.go: TestArticulatedJawConfigHardware's assert.Len read the fake's mutex-guarded commands slice directly instead of the issued() helper every other test uses (which filters the capabilities probe); switch to assert.Empty(t, fa.issued(), ...). - Add a newJawGripper(t, pct) helper for the common newFakeServoArm()+fa.percent=X+newTestGripperWithConfig(...ArticulatedJaw) sequence, and use it at the call sites that don't need the fake arm afterward. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
…losed stop isHoldingAt compared the jaw's reading against closedPosition, so any merely open jaw looked "held" (open - closed always clears the threshold). On real hardware this latched the gripper open: once the planner moved the jaw past ~15%, IsHoldingSomething reported holding forever after, and GoToInputs's safety guard refused every subsequent jaw command, including one to close it back up. Grab's own use was valid -- it commands closed immediately before reading, so "stopped short" is real evidence. That inference doesn't hold for a standing query taken at an arbitrary moment. Track the percent moveToPercent last commanded (guarded by g.mu, the choke point every percent-valued command already holds it through) and compare the actual reading against that instead: a jaw that failed to reach where it was told to go, in either direction, is obstructed. With nothing ever commanded, report not holding rather than guessing -- the live failure showed guessing "holding" is the more damaging error. A raw servo_position command (opaque ticks) clears the commanded target instead of leaving a stale percent-valued one behind. TestGoToInputsDoesNotLatchOpenAsHolding reproduces the live sequence and fails against the pre-fix isHoldingAt; verified by temporarily reverting the predicate and the two call sites back to their literal old form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #39. Review that one first — this targets
gripper-inputs, so GitHub shows only the hardware commits and will retarget tomainwhen #39 merges.Why
#39 made the simulated gripper's jaw a real degree of freedom and measured what the motion planner does with it: nothing on an unobstructed plan, and 4.3%-19.9% of the jaw's range under obstruction, in both directions.
On a simulated gripper that's a curiosity. On hardware holding a part, it's the grip loosening mid-trajectory. This adds the same
articulated_jawattribute todevrel:so101:gripper(defaultfalse), plus the three things hardware forces that simulation didn't.The three decisions, and why they went this way
Refusing planner jaw commands while holding — but only ones that move the jaw. The obvious version of this guard breaks pick-and-place.
builtin.go:687-689skips a component in a trajectory step only whenlen(inputs) == 0, never when the inputs are unchanged, so a 1-DoF gripper appears in every step of every plan carrying its current value. An unconditional refusal fails every arm move made while carrying a part. So a batch whose steps are all within 0.01 rad of current returns early without commanding the servo, and only a genuine jaw command is refused.A 50 ms position cache. Every
CurrentInputsis a DoCommand round trip plus a serial read on the bus the five arm servos share — and the 3D viewer polls it continuously to animate the jaw. Measured: 200 polls inside the TTL cost 0 bus reads. Invalidated at the top ofservoDo(a deny-list, so a future servo command defaults to invalidating), with a generation counter because the lock is released across the read.Last-known-good on a failed read.
framesystem.CurrentInputshard-errors the entire machine's frame system if a DoF-bearing component errors, so one dropped serial frame would abort every arm move. A failed read serves the last good value and warns; it only errors if no read has ever succeeded.Also implements
IsHoldingSomething, which was a stub that always answered "not holding" — the guard above reads from it.Where to focus review
GoToInputs's ordering — validate everything, detect a no-op, then check holding. The sequence is the safety property, not an implementation detail.jawMubecauseOpen/Grabholdg.muacrossmoveToPercentandsync.Mutexisn't reentrant.jawMuis never held across aDoCommand, so a viewer poll can't block behind a 2000 msservo_wait_stop. The only order isg.mu -> jawMu.articulated_jaw: falsemust behave exactly as before; a test pins that construction issues only the capabilities probe.Testing
go test ./...green,-raceclean,go vetclean. No hardware required — the gripper's only hardware path isarm.DoCommand, so the existing fake-arm harness covers all of it.Every guard was mutation-tested: broken deliberately, confirmed to fail with the intended diagnostic, reverted. Two worth noting, because both passed for the wrong reason until fixed:
closedPosition = 0, so dropping the subtraction entirely left the suite green. There's now a calibrated-offset case.g.mu, so anOpenholding that lock for up to 2000 ms meant both were decided against a stale jaw. Both now happen inside the critical section.Verified separately and committed as tests: 10 arm moves x 40 trajectory steps while holding all succeed with zero bus writes, while the same fixture still refuses a genuine jaw command.
Known limitations, documented not fixed
Geometries()still reads the bus uncached and falls back to "closed" on failure. Out of scope here; worth a follow-up since it's the weaker policy of the two.GoToInputsisn't constrained to the calibratedopenPosition/closedPosition, so the planner can drive the jaw further open thanOpen()will. Matches the simulated gripper. Flagging for a decision rather than changing it unilaterally.Stoparriving during the pre-move phase isn't latched. Fixing it would suppress legitimate cache stores; commented in place.🤖 Generated with Claude Code