Skip to content

Answer a waiting caller when the Franka arm stays in error - #743

Open
v-positronic wants to merge 1 commit into
mainfrom
wedge-p2-driver-answers-caller
Open

Answer a waiting caller when the Franka arm stays in error#743
v-positronic wants to merge 1 commit into
mainfrom
wedge-p2-driver-answers-caller

Conversation

@v-positronic

@v-positronic v-positronic commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

The defect

The Franka run loop looped on recover_from_errors() while the arm held an error and reached moves.next_request() only after the error cleared (franka.py, the in_error branch). A sync move that arrived while the arm errored sat in the handler queue, unanswered, for as long as the error stood. On 2026-09-11 a cartesian_reflex stood fourteen minutes under continuous recovery, so a prepare hung with no bound and the console wedged. Diagnosis: Positronic-Robotics/internal#1285.

What this ships

The driver now holds one waiting sync move while it recovers, on _Arm:

  • It recovers each tick and drains a jog issued while the arm cannot move on it.
  • It serves the held move once the error clears, so a fault that clears inside the grace costs no retry.
  • It answers the held move with MoveRefused(reasons, moved_rad) once recovery has not cleared the error in _RECOVERY_GRACE_S (2 s). reasons is the arm's own error message; moved_rad is the largest joint motion during recovery.
  • It logs the recovery result, and whether the error cleared or persists.
  • The world stopping with a move still held answers that move with MoveAbandoned.

MoveRefused is a new exception in drivers/utils.py. It is the retriable signal the harness retry (P1, internal#1285) will catch; this PR only introduces it and raises it.

Scope

  • It needs no type or signal from positronic#678 (the refused-move-reason PR), so it does not branch off it. The two touch nearby lines in run(); positronic#678 landed first and this branch is rebased onto it (below).
  • P2 in the plan. P1 (the harness retry) waits on this.

Tests

Driver-level, no rig, through the FakeArm and a MockClock: a move waiting on an erroring arm is refused after the grace and not before; it is served once recovery clears; a jog issued while a move is held is dropped; a held move is answered when the world stops. The refusal test fails without the fix, because the call is never answered.

uv run --locked pytest positronic/drivers/roboarm/tests/test_franka.py is green (36 passed). pre-commit (ruff, ruff format, basedpyright) passed on commit.

The thresholds in the plan (2 s, and the 0.02 rad path-blocked bound in P1) have no data behind them and are used as given.

The check-rules fan-out is skipped this pass to keep the shared box free while other agents work; the rules check is due before merge.

Rebased onto positronic#678 2026-09-12

positronic#678 merged as 8ea1eb5d and took the run() lines this branch warned it would. Rebased onto that sha; head 2de067c5 -> 418738d3.

Two conflicts, both the same shape — two independent insertions at one point — so both sides are kept: positronic#678's SAFE_INPUT_STATE/_Reading/_SafeInputs beside this branch's _RECOVERY_GRACE_S, and positronic#678's safe_inputs/_refusals/_refused/_quiet_at beside its _deferred/_error_started/_q_at_error.

The composition was checked rather than assumed. note_refusals(goal) runs at the top of every tick, BEFORE the error branch, so the continue inside recover_and_serve does not skip positronic#678's refusal logging; close_if_idle(goal) keeps its new signature. The diff against main deletes exactly five lines, all of them ones this commit deliberately replaces, and every positronic#678 marker is still in the file.

test_franka.py 52 passed on the rebased head (36 this branch's, the rest positronic#678's, now running together). ruff, ruff format and basedpyright clean.

One thing the rebase deliberately did not settle: positronic#678's note_refusals names a move libfranka rejects on a triggered safe input, and this PR's MoveRefused answers a caller when an error outlasts the grace. They are distinct mechanisms that now sit in one file. Whether a safe-input refusal should also answer a waiting caller is a design question, not a merge conflict.

The check-rules fan-out is still owed before merge.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2de067c565

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".


yield arm.limiter.wait()

arm.abandon_deferred() # the world stopped with a move still held

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Abandon deferred calls from a finally block

If robot.state(), recover_from_errors(), or another operation raises after _deferred has been dequeued, control skips this statement and _Arm.__exit__ only stops the robot. The handler shutdown logic can fail only calls it never reached (pimm/calls.py:12-16), so this reached call remains unanswered despite the call contract; move this cleanup into a finally path or _Arm.__exit__.

Useful? React with 👍 / 👎.

Comment on lines 554 to +556
if entered_error:
logger.warning(f'Robot error: {st.error_message}')
arm.note_error_entered(st)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Capture error entry inside recovery

Rule hidden-dependency violated:
recover_and_serve reads _error_started and _q_at_error, but their correctness depends on every caller invoking note_error_entered first. Fold this capture into recover_and_serve when entered is true, or pass a complete recovery-state value, so the ordering is enforced rather than remembered by the caller.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Comment on lines +163 to +165
def recover_and_serve(
self, st: pf.State, in_error: bool, entered: bool, brakes: '_Brakes'
) -> Generator[pimm.Command, None, bool]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return a named recovery outcome

Rule misleading-name violated:
recover_and_serve performs recovery and may serve a move, yet returns a bare bool whose verdict is only explained as “whether it handled the tick.” Return a named enum outcome so the conditional states what the result means while retaining the action name.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Comment on lines +809 to +810
"""The wedge: while the arm errors the driver loops on recovery and never serves a waiting move, so a
prepare hangs with no bound. The driver now answers the caller once recovery has not cleared in time."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Describe the current refusal invariant

Rule diff-comments violated:
This test docstring narrates the former wedge and says what “the driver now” does, so it is written for a reader of the change rather than the file. Replace it with the current invariant: a move waiting through a persistent arm fault is refused after the recovery grace.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

The driver looped on recover_from_errors while the arm held an error and never
reached moves.next_request, so a prepare that waited on the arm hung with no
bound. The driver now holds one waiting sync move while it recovers: it serves
the move once the error clears, and answers it with MoveRefused(reasons,
moved_rad) once recovery has not cleared the error in _RECOVERY_GRACE_S seconds.
It drains a jog issued while the arm errors, and logs the recovery result and
whether the error cleared or persists.

Ticket: none — positronic half of the wedge diagnosis; PR body links internal#1285.
@v-positronic
v-positronic force-pushed the wedge-p2-driver-answers-caller branch from 2de067c to 418738d Compare September 12, 2026 20:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 418738d3d3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

"""Answer a held move with ``MoveRefused`` once recovery has run past the grace without clearing."""
if self._deferred is None or self.clock.now() - self._error_started < _RECOVERY_GRACE_S:
return
moved = float(np.max(np.abs(st.q - self._q_at_error)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track the maximum movement reported by MoveRefused

Rule misleading-name violated:
moved_rad is documented as the largest joint motion during recovery, but _refuse_if_stuck computes only the displacement between error entry and refusal. If a joint moves away and returns, this reports zero and can mislead callers into treating the arm as stationary; track the maximum across recovery samples, or rename the field and contract to describe endpoint displacement.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

_PARK_JOINTS = np.array([0.0, -0.31, 0.0, -1.65, 0.0, 1.522, 0.0])

# Recovery this long into an unclearing error answers a waiting caller instead of holding it.
_RECOVERY_GRACE_S = 2.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move the recovery grace into _Arm

Rule stranded-definition violated:
_RECOVERY_GRACE_S is used only by _Arm in this file but is left at module scope far above that class. Make it an _Arm class constant beside _MOVE_GRACE_S and update its references so the definition stays with the entity that owns it.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Comment on lines +38 to +39
``reasons`` is the arm's own error message. ``moved_rad`` is the largest joint motion the arm made
while the driver tried to recover; a caller reads it to judge whether the path is blocked.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove caller policy from the exception documentation

Rule diff-comments violated:
MoveRefused's docstring says that a caller reads moved_rad to decide whether the path is blocked, but the exception does not depend on that consumer policy and no such caller exists here. Keep only the stable field meaning, such as the joint displacement observed during recovery.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant