Before proceeding, is there an existing issue or discussion for this?
OS and version
Ubuntu 24.04
Open-RMF installation type
Binaries
Other Open-RMF installation methods
No response
Open-RMF version or commit hash
2.7.2-1noble.20260615.164301
ROS distribution
Jazzy
ROS installation type
Binaries
Other ROS installation methods
No response
Package or library, if applicable
No response
Description of the bug
Task::Active::rewind() in rmf_task_sequence/src/rmf_task_sequence/Task.cpp:617-650
contains two independent defects. Together they mean rewind_task has never worked,
and one of them takes the whole fleet adapter down.
void Task::Active::rewind(uint64_t phase_id)
{
std::lock_guard lock(_next_phase_mutex);
assert(_completed_phases.size() == _completed_stages.size());
std::size_t completed_index = 0;
auto stage_it = _completed_stages.begin();
while (stage_it != _completed_stages.end())
{
if ((*stage_it)->id == phase_id)
break;
++completed_index; // <-- (1) stage_it is never incremented
}
if (stage_it == _completed_stages.end())
return;
_pending_stages.insert(
_pending_stages.begin(),
stage_it,
_completed_stages.begin()); // <-- (2) empty range; should be .end()
_pending_stages.push_back(_active_stage);
_generate_pending_phases();
_active_phase->cancel();
}
Defect 1 — the search loop never advances its iterator. _completed_stages is a
std::list; only completed_index is incremented, so stage_it stays pinned to
begin(). The loop can therefore only exit through the break, and only when the
first completed stage matches. For any other phase_id the condition
stage_it != _completed_stages.end() is permanently true and the function spins
forever while holding _next_phase_mutex.
Because this runs on the fleet adapter's executor thread, the entire adapter
livelocks: one core pegged at 100%, task states stop publishing, /fleet_states
goes silent, and newly dispatched tasks fail because the fleet can no longer bid.
Critically the process stays alive, so systemd/supervisors do not restart it —
arguably worse operationally than a crash.
Defect 2 — the insert range is empty. [stage_it, _completed_stages.begin())
is empty whenever stage_it == begin() (which, given defect 1, is always), and
would be a reversed/invalid range if the iterator ever did advance. The bound
should be _completed_stages.end(). Consequently no completed stage is ever put
back into pending, so even the one phase_id that returns does not rewind
anything.
Two supporting signs that this function has never been executed:
completed_index is computed and never read.
_completed_stages / _completed_phases are never trimmed, so the
assert on line 620 would be left inconsistent even if the iteration were fixed.
Also note push_back(_active_stage) appends the active stage to the end of
pending, rather than immediately after the restored stages where it belongs.
Steps to reproduce the bug
Stock rmf_demos office demo, no customisation required.
-
Launch the office demo:
ros2 launch rmf_demos_gz office.launch.xml headless:=1
-
Dispatch a multi-phase compose task:
{
"category": "compose",
"description": {
"category": "patrol_demo",
"phases": [
{ "activity": { "category": "go_to_place", "description": "pantry" } },
{ "activity": { "category": "go_to_place", "description": "lounge" } },
{ "activity": { "category": "go_to_place", "description": "hardware_2" } },
{ "activity": { "category": "go_to_place", "description": "supplies" } },
{ "activity": { "category": "go_to_place", "description": "coe" } }
]
}
}
-
Wait until at least two phases have completed (e.g. completed=[1,2,3],
phase 4 active).
-
Case A — livelock. Rewind to any completed phase that is not the earliest:
curl -X POST 'http://localhost:8000/tasks/rewind_task' \
-H 'Content-Type: application/json' \
-d '{"type":"rewind_task_request","task_id":"<task_id>","phase_id":2}'
No response is ever returned. top -H -p <fleet_adapter_pid> shows one thread
at 100% CPU; /proc/<pid>/task/<tid>/ shows state=R, wchan=0, no syscall
(a userspace spin, not a lock wait). ros2 topic echo /fleet_states goes
silent, and any newly dispatched task ends up failed.
-
Case B — silent no-op. Restart the adapter, repeat to the same point, then
rewind to the earliest completed phase ("phase_id": 1). It returns
{"success":true}, but the task does not rewind: the active phase is merely
cancelled and re-queued at the very end of the task.
Expected behavior
Per rewind_task_request.json:
"Specify the phase that should be rewound to. The task will restart at the
beginning of this phase."
and rmf_task/include/rmf_task/Task.hpp:341:
"Rewind the Task to a specific phase. This can be issued by operators if a phase
did not actually go as intended and needs to be repeated."
For completed=[1,2,3] with phase 4 active, rewind_task(phase_id=2) should
requeue phases 2 and 3 followed by the cancelled phase 4, giving execution order
2 → 3 → 4 → 5, with completed trimmed back to [1]. The request should return
promptly and the fleet adapter should remain fully responsive.
Actual behavior
phase_id = earliest completed phase: returns {"success":true}, but no rewind
occurs. The active phase is cancelled and appended to the tail of pending, so
execution order becomes 4 → 5 → 3 instead of 1 → 2 → 3 → 4 → 5.
phase_id = any other completed phase: no response at all; the fleet adapter
livelocks at 100% CPU and stops serving every robot in the fleet until it is
manually killed. The process does not exit, so it is not auto-restarted.
Additional information or screenshots
Affected branches — both defects are present, unmodified, on every branch:
| branch |
rmf_task_sequence |
| humble |
2.1.8 |
| iron |
2.2.5 |
| jazzy |
2.5.1 |
| kilted |
2.7.0 |
| lyrical |
2.10.0 |
| rolling |
2.1.3 |
| main |
2.10.0 |
Line numbers are identical on jazzy and main (function at 617, faulty insert
bound at 641), i.e. the code is untouched since it was introduced.
Reproduced on ROS 2 Jazzy, Ubuntu 24.04, binaries:
ros-jazzy-rmf-task 2.5.1-1noble, ros-jazzy-rmf-fleet-adapter 2.7.2-1noble.
Candidate fix (verified in simulation — happy to open a PR):
std::size_t completed_index = 0;
auto stage_it = _completed_stages.begin();
while (stage_it != _completed_stages.end())
{
if ((*stage_it)->id == phase_id)
break;
+ ++stage_it;
++completed_index;
}
if (stage_it == _completed_stages.end())
{
// TODO(MXG): Indicate to the user that they asked to rewind to a phase
// that hasn't been reached yet.
return;
}
+ // The currently active stage goes back into pending, to run again after the
+ // rewound stages have been repeated.
+ _pending_stages.push_front(_active_stage);
+
+ // Queue the completed stages from phase_id onwards ahead of it. This must
+ // happen before the erase below, since stage_it points into _completed_stages.
_pending_stages.insert(
_pending_stages.begin(),
stage_it,
- _completed_stages.begin());
+ _completed_stages.end());
+
+ // The rewound phases are no longer completed. _completed_stages and
+ // _completed_phases are appended in lockstep by _finish_phase, so
+ // completed_index applies to both.
+ _completed_stages.erase(stage_it, _completed_stages.end());
+ _completed_phases.erase(
+ _completed_phases.begin() + completed_index, _completed_phases.end());
- // The currently active stage should also be put back into pending
- _pending_stages.push_back(_active_stage);
_generate_pending_phases();
_active_phase->cancel();
Verification with the patch applied, office demo, 5-phase compose task:
| Trigger state |
Request |
Result |
Executed |
completed=[1,2,3], phase 4 active |
rewind→2 |
active=2, pending=[3,4,5], completed=[1] |
2→3→4→5 |
completed=[1,2,4], phase 5 active |
rewind→4 |
active=4, pending=[5], completed=[1,2] |
4→5 |
| phase not yet reached / unknown id |
rewind |
clean no-op |
— |
Every request that previously livelocked now returns in ~2s, no spinning thread
appears, and tasks rewound twice still run to completion.
Related but distinct: rewinding to a phase that has not been reached, and
rewinding to the currently-active phase, are both silent no-ops that still return
{"success":true} (the existing TODO(MXG) acknowledges the first). Reporting
those properly would need a signature change to the void rewind() virtual, so
they are out of scope here.
Cross-reference: open-rmf/rmf_ros2#542 / open-rmf/rmf_ros2#543 fix a different
bug on the same operator-controls path (the phases[] JSON key type in
TaskManager::publish_task_state()). That fix is required to skip/rewind at all
without crashing, but it does not touch the logic reported here.
Before proceeding, is there an existing issue or discussion for this?
OS and version
Ubuntu 24.04
Open-RMF installation type
Binaries
Other Open-RMF installation methods
No response
Open-RMF version or commit hash
2.7.2-1noble.20260615.164301
ROS distribution
Jazzy
ROS installation type
Binaries
Other ROS installation methods
No response
Package or library, if applicable
No response
Description of the bug
Task::Active::rewind()inrmf_task_sequence/src/rmf_task_sequence/Task.cpp:617-650contains two independent defects. Together they mean
rewind_taskhas never worked,and one of them takes the whole fleet adapter down.
Defect 1 — the search loop never advances its iterator.
_completed_stagesis astd::list; onlycompleted_indexis incremented, sostage_itstays pinned tobegin(). The loop can therefore only exit through thebreak, and only when thefirst completed stage matches. For any other
phase_idthe conditionstage_it != _completed_stages.end()is permanently true and the function spinsforever while holding
_next_phase_mutex.Because this runs on the fleet adapter's executor thread, the entire adapter
livelocks: one core pegged at 100%, task states stop publishing,
/fleet_statesgoes silent, and newly dispatched tasks fail because the fleet can no longer bid.
Critically the process stays alive, so systemd/supervisors do not restart it —
arguably worse operationally than a crash.
Defect 2 — the insert range is empty.
[stage_it, _completed_stages.begin())is empty whenever
stage_it == begin()(which, given defect 1, is always), andwould be a reversed/invalid range if the iterator ever did advance. The bound
should be
_completed_stages.end(). Consequently no completed stage is ever putback into pending, so even the one
phase_idthat returns does not rewindanything.
Two supporting signs that this function has never been executed:
completed_indexis computed and never read._completed_stages/_completed_phasesare never trimmed, so theasserton line 620 would be left inconsistent even if the iteration were fixed.Also note
push_back(_active_stage)appends the active stage to the end ofpending, rather than immediately after the restored stages where it belongs.
Steps to reproduce the bug
Stock
rmf_demosoffice demo, no customisation required.Launch the office demo:
Dispatch a multi-phase compose task:
{ "category": "compose", "description": { "category": "patrol_demo", "phases": [ { "activity": { "category": "go_to_place", "description": "pantry" } }, { "activity": { "category": "go_to_place", "description": "lounge" } }, { "activity": { "category": "go_to_place", "description": "hardware_2" } }, { "activity": { "category": "go_to_place", "description": "supplies" } }, { "activity": { "category": "go_to_place", "description": "coe" } } ] } }Wait until at least two phases have completed (e.g.
completed=[1,2,3],phase 4 active).
Case A — livelock. Rewind to any completed phase that is not the earliest:
No response is ever returned.
top -H -p <fleet_adapter_pid>shows one threadat 100% CPU;
/proc/<pid>/task/<tid>/showsstate=R,wchan=0, no syscall(a userspace spin, not a lock wait).
ros2 topic echo /fleet_statesgoessilent, and any newly dispatched task ends up
failed.Case B — silent no-op. Restart the adapter, repeat to the same point, then
rewind to the earliest completed phase (
"phase_id": 1). It returns{"success":true}, but the task does not rewind: the active phase is merelycancelled and re-queued at the very end of the task.
Expected behavior
Per
rewind_task_request.json:and
rmf_task/include/rmf_task/Task.hpp:341:For
completed=[1,2,3]with phase 4 active,rewind_task(phase_id=2)shouldrequeue phases 2 and 3 followed by the cancelled phase 4, giving execution order
2 → 3 → 4 → 5, with
completedtrimmed back to[1]. The request should returnpromptly and the fleet adapter should remain fully responsive.
Actual behavior
phase_id= earliest completed phase: returns{"success":true}, but no rewindoccurs. The active phase is cancelled and appended to the tail of pending, so
execution order becomes 4 → 5 → 3 instead of 1 → 2 → 3 → 4 → 5.
phase_id= any other completed phase: no response at all; the fleet adapterlivelocks at 100% CPU and stops serving every robot in the fleet until it is
manually killed. The process does not exit, so it is not auto-restarted.
Additional information or screenshots
Affected branches — both defects are present, unmodified, on every branch:
Line numbers are identical on
jazzyandmain(function at 617, faulty insertbound at 641), i.e. the code is untouched since it was introduced.
Reproduced on ROS 2 Jazzy, Ubuntu 24.04, binaries:
ros-jazzy-rmf-task 2.5.1-1noble,ros-jazzy-rmf-fleet-adapter 2.7.2-1noble.Candidate fix (verified in simulation — happy to open a PR):
std::size_t completed_index = 0; auto stage_it = _completed_stages.begin(); while (stage_it != _completed_stages.end()) { if ((*stage_it)->id == phase_id) break; + ++stage_it; ++completed_index; } if (stage_it == _completed_stages.end()) { // TODO(MXG): Indicate to the user that they asked to rewind to a phase // that hasn't been reached yet. return; } + // The currently active stage goes back into pending, to run again after the + // rewound stages have been repeated. + _pending_stages.push_front(_active_stage); + + // Queue the completed stages from phase_id onwards ahead of it. This must + // happen before the erase below, since stage_it points into _completed_stages. _pending_stages.insert( _pending_stages.begin(), stage_it, - _completed_stages.begin()); + _completed_stages.end()); + + // The rewound phases are no longer completed. _completed_stages and + // _completed_phases are appended in lockstep by _finish_phase, so + // completed_index applies to both. + _completed_stages.erase(stage_it, _completed_stages.end()); + _completed_phases.erase( + _completed_phases.begin() + completed_index, _completed_phases.end()); - // The currently active stage should also be put back into pending - _pending_stages.push_back(_active_stage); _generate_pending_phases(); _active_phase->cancel();Verification with the patch applied, office demo, 5-phase compose task:
completed=[1,2,3], phase 4 activeactive=2,pending=[3,4,5],completed=[1]completed=[1,2,4], phase 5 activeactive=4,pending=[5],completed=[1,2]Every request that previously livelocked now returns in ~2s, no spinning thread
appears, and tasks rewound twice still run to completion.
Related but distinct: rewinding to a phase that has not been reached, and
rewinding to the currently-active phase, are both silent no-ops that still return
{"success":true}(the existingTODO(MXG)acknowledges the first). Reportingthose properly would need a signature change to the
void rewind()virtual, sothey are out of scope here.
Cross-reference: open-rmf/rmf_ros2#542 / open-rmf/rmf_ros2#543 fix a different
bug on the same operator-controls path (the
phases[]JSON key type inTaskManager::publish_task_state()). That fix is required to skip/rewind at allwithout crashing, but it does not touch the logic reported here.