theory/00_mcp_orchestration.md에는 자연어 명령을 여러 backend 호출로 분해하고 Runner가 그 시퀀스를 순차 실행한다고 적혀 있습니다. 예시도 구체적입니다. “컵을 싱크대에 놔”를 서기, 집기, 걷기, 놓기의 네 단계로 나누고 각 단계에 duration_s를 붙입니다.
그런데 현재 실행 코드를 따라가면 이 시퀀스를 맡을 객체가 없습니다. ClaudePlanner.decompose_task()는 단계 배열을 반환하지만, 사용 예시에 등장하는 runner.step(**step)은 Runner에 구현돼 있지 않습니다. 실제 Runner.run()은 BackendRequest 하나를 만들고 backend.run_step()을 한 번 호출한 뒤 BackendResponse를 JSON으로 저장합니다. 문서에는 장시간 task가 설계돼 있고 코드에는 단발 함수 호출만 있는 셈입니다.
이 차이는 메서드 하나를 추가한다고 닫히지 않습니다. 로봇이 걷기 시작한 뒤 client 연결이 끊기면 누가 그 동작을 소유할까요. 취소 버튼을 눌렀을 때 이미 전송된 관절 명령은 어떻게 멈출까요. 응답을 받지 못한 client가 같은 명령을 다시 보내면 로봇은 한 번 움직일까요, 두 번 움직일까요. ROS 2 service와 action의 선택은 API 취향이 아니라 명령이 살아 있는 시간과 실패 후 책임을 정의하는 일입니다.

현재 공통 인터페이스에는 ‘진행 중인 명령’이라는 객체가 없습니다
BackendRequest가 보존하는 것은 robot profile, task profile, observation, state, instruction입니다. BackendResponse는 backend name, action type, action, ok/error/fallback status와 log를 반환합니다. 이 구조는 같은 입력 형식으로 WBC·RL·VLA를 한 step 호출하는 데에는 유용합니다. 그러나 request ID, goal ID, robot epoch, 접수 시각, deadline, 진행 상태, 취소 상태, terminal result가 없습니다.
run_backend_once.py --n_steps 5도 장시간 명령 실행기는 아닙니다. 20ms 간격을 가정한 policy_step과 time_s를 바꾸며 같은 동기 함수를 다섯 번 부를 뿐입니다. 중간에 네 번째 호출이 실패했을 때 앞의 세 action이 실제 actuator에 적용됐는지, 남은 두 step을 폐기했는지, 재시도할 수 있는지는 기록하지 않습니다. 지금 코드가 implemented한 것은 backend 한 step의 공통 호출이며, multi-step command lifecycle은 문서에 designed된 상태입니다.
H1 description asset에 포함된 ROS 1 catkin package를 제외하면, 현재 router·G1 runtime용 ROS 2 package와 .srv, .action, ActionServer·ActionClient 구현을 찾지 못했습니다. 따라서 이 글은 이미 동작하는 ROS 2 interface의 사용 후기라기보다, 현재 동기식 router를 live G1 명령 계층으로 확장하기 전에 고정해야 할 계약을 적은 구현 기록입니다.
topic·service·action은 데이터 모양보다 수명 주기가 다릅니다
ROS 2의 Topics vs Services vs Actions 가이드는 세 인터페이스를 용도별로 나눕니다. topic은 sensor state처럼 계속 흐르는 데이터, service는 빠르게 끝나는 remote procedure call, action은 시간이 걸리고 실행 중 feedback과 preemption이 필요한 이산 행동에 적합합니다. 공식 action 개념 문서도 action을 feedback과 cancel 또는 preempt가 가능한 장시간 RPC로 설명합니다.
이 구분을 G1 runtime에 옮기면 다음과 같습니다.
| 인터페이스 | G1에서 맡길 일 | 맡기지 않을 일 | 성공을 증명하는 사건 |
|---|---|---|---|
| topic | joint state, IMU, contact, command stream, safety state | 한 번의 요청과 정확히 대응해야 하는 결과 | sequence·timestamp가 있는 연속 표본 |
| service | readiness 조회, active manifest 조회, 짧은 IK 계산, authority 상태 질의 | 걷기·집기처럼 중간 취소가 필요한 동작 | 해당 request에 대응하는 빠른 response |
| action | 2m 걷기, 물체 집기, 자세 복구, 여러 step의 task 실행 | 500Hz 저수준 명령 stream, 긴급 정지의 유일한 경로 | goal별 feedback와 terminal result |
“서기”라는 이름만 보고 service나 action을 정할 수는 없습니다. 현재 자세가 준비됐는지 조회하는 GetPostureState는 service가 될 수 있습니다. 반면 자세를 안정화하고 접촉을 확인한 뒤 authority를 넘기는 StandAndStabilize는 수초 동안 진행되며 취소와 feedback이 필요하므로 action에 가깝습니다. 같은 명사라도 질문인지 행동인지에 따라 수명 주기가 달라집니다.
service timeout은 server가 작업하지 않았다는 증거가 아닙니다
service client가 1초 동안 response를 기다리다가 timeout됐다고 가정해 봅시다. client가 아는 사실은 정해진 시간 안에 response를 받지 못했다는 것뿐입니다. request가 server에 도착하지 않았을 수도 있고, server가 처리 중일 수도 있고, 처리를 끝냈지만 response만 유실됐을 수도 있습니다. state를 바꾸는 긴 작업을 service로 만들면 이 세 상태를 구분할 별도 식별자와 조회 경로가 없어집니다.
현재 OpenVLA 경로에서 이 모호함을 축소해서 볼 수 있습니다. OpenVLAClient는 별도 inference server의 /act endpoint를 호출하며 기본 timeout은 5초입니다. 예외가 나면 7차원 dummy action을 반환하고, 상위 VLABackend는 이 action을 status="ok"로 포장합니다. 여기서 5초 timeout은 “모델이 action을 만들지 못했다”는 transport 관측이지, robot task의 terminal result가 아닙니다. 더구나 현재 구현은 그 경계를 status에 보존하지 않습니다.
이 문제의 상세한 readiness·fallback 경계는 Lifecycle Node 활성화 계약과 launch bringup 계약에 남겨 둡니다. 이번 글에서 가져올 결론은 하나입니다. client가 기다리기를 포기한 사건과 server가 행동을 끝낸 사건을 같은 상태로 저장하면 재시도가 위험해집니다.
action은 goal·result·feedback 세 부분을 하나의 identity로 묶습니다
ROS 2의 .action 정의는 두 개의 ---로 goal, result, feedback을 나눕니다. client는 goal을 보내고, server는 이를 accept 또는 reject합니다. accept된 goal은 실행 중 feedback을 보낼 수 있고 마지막에는 result를 반환합니다. ROS 2 action 설계 문서에 따르면 action server는 accept된 goal마다 상태 기계를 유지하며, 여러 client가 같은 server에 goal을 보낼 때 동시 실행 정책은 server가 결정합니다.
중요한 것은 send_goal response와 최종 result가 분리된다는 점입니다. accept는 “요청을 소유하고 추적하겠다”는 뜻이지, “로봇이 목표를 달성했다”는 뜻이 아닙니다. G1 명령에서는 최소한 다음 사건을 따로 남겨야 합니다.
- goal received: 형식이 도착했습니다.
- goal accepted 또는 rejected: 현재 robot epoch·resource·safety 상태에서 실행 책임을 받을지 결정했습니다.
- execution started: scheduler가 실제 실행 slot과 필요한 authority를 배정했습니다.
- feedback emitted: 어느 phase까지 어떤 관측 근거로 진행됐는지 알렸습니다.
- terminal result committed: succeeded, aborted, canceled 중 하나와 마지막 물리 상태 증거를 저장했습니다.
이 구분이 있으면 client 연결이 끊겨도 goal의 소유권은 server journal에 남습니다. 반대로 accept 전에 연결이 끊겼다면 client는 동일 command identity로 상태를 조회한 뒤에만 재전송할 수 있습니다. 함수 return과 로봇 행동의 끝을 분리하는 것이 action의 핵심입니다.
cancel 요청이 승인돼도 로봇은 아직 움직일 수 있습니다
action 상태 기계의 active state는 ACCEPTED, EXECUTING, CANCELING이고 terminal state는 SUCCEEDED, ABORTED, CANCELED입니다. client가 cancel을 요청해도 server가 이를 받아들여야 CANCELING으로 이동합니다. 설계 문서는 server가 shutdown 또는 cleanup을 수행한 뒤 cancellation result를 반환할 수 있도록 이 중간 상태를 둡니다.
CancelGoal service 정의도 cancel response가 무엇을 뜻하는지 분명히 합니다. ERROR_NONE은 하나 이상의 goal이 CANCELING으로 전환됐다는 뜻입니다. 요청된 goal이 없거나 이미 terminal state이거나 server가 거부하면 별도 오류 코드가 옵니다. cancel response만 받고 곧바로 “정지 완료”를 표시해서는 안 됩니다.
물리 로봇에서는 software cleanup보다 먼저 command authority를 닫아야 합니다. locomotion action의 cancel callback이 요청을 accept하면 다음 순서가 필요합니다.
- 현재 goal이 가진 actuator lease epoch를 폐기하고 새 command가 통과하지 못하게 합니다.
- command guard가 정책 출력 대신 검증된 hold 또는 safe controller로 전환합니다.
- 실행 loop에 cancel token을 전달하고 남은 trajectory·action chunk를 폐기합니다.
- 관절 속도, 접촉, 자세, command queue가 정의된 정지 조건에 들어왔는지 확인합니다.
- 그 증거를 terminal journal에 기록한 뒤에만
CANCELEDresult를 확정합니다.
긴급 정지는 이 경로만 믿으면 안 됩니다. action cancel은 middleware request, executor dispatch, application cleanup을 거치는 협력적 중단입니다. 전원·드라이브 차단이나 독립 safety controller가 필요한 위험에서는 별도의 우선순위 높은 경로가 있어야 합니다. cancel은 E-stop의 다른 이름이 아닙니다.
새 goal이 오면 자동으로 이전 goal을 대체한다고 가정할 수 없습니다
ROS 2 action server에는 여러 client가 goal을 보낼 수 있습니다. 그러나 “새 goal이 오면 기존 goal을 취소한다”, “두 goal을 병렬 실행한다”, “하나는 queue에 둔다” 중 어느 정책도 interface 이름만으로 정해지지 않습니다. action 설계 문서가 밝히듯 여러 goal을 어떻게 다룰지는 server의 책임입니다.
G1처럼 하나의 actuator authority를 공유하는 locomotion command라면 기본 정책은 보수적이어야 합니다. 이전 goal이 EXECUTING 중일 때 새 goal을 바로 실행하면 두 policy가 같은 관절을 소유할 수 있습니다. 제가 먼저 고정할 정책은 다음과 같습니다.
- 같은 resource set을 요구하는 새 goal은 이전 goal이 terminal state가 되기 전까지 reject하거나 명시적 queue에 둡니다.
- replace가 필요하면 이전 goal을
CANCELING으로 옮기고 lease 회수와 safe hold를 확인한 뒤 새 epoch로 새 goal을 시작합니다. - 상체 조작과 보행처럼 resource가 일부 겹치는 goal은 joint 목록만 보고 병렬 허용하지 않습니다. balance·contact·power budget을 함께 소유하는 arbiter가 승인해야 합니다.
- server 재시작 후 남아 있는 old-epoch goal은 자동 재개하지 않습니다. checkpoint나 trajectory가 있어도 현재 robot epoch와 readiness snapshot을 다시 검증합니다.
이 정책은 shadow·canary·rollback 배포 계약의 authority 승격과 연결되지만, 이번 글은 goal 수명과 중복 실행 방지까지만 소유합니다.
goal UUID만으로 application retry가 안전해지지는 않습니다
ROS 2 action protocol은 goal을 UUID로 식별하고, server는 그 identity로 feedback·status·result·cancel을 연결합니다. 이것은 필요한 기반입니다. 하지만 application이 timeout 뒤 새 UUID를 만들어 같은 “2m 전진” 명령을 다시 보내면 server 관점에서는 서로 다른 두 goal입니다. 첫 goal이 실제로 accept됐는지 모르는 상태에서 새 goal을 보내면 중복 동작이 가능합니다.
따라서 외부 명령과 ROS goal 사이에 재시도 계약이 하나 더 필요합니다. 저는 이를 command_id와 attempt_id로 나누려고 합니다.
command_id: 사용자의 한 의도를 나타내는 stable identity입니다. 같은 명령을 재조회하거나 transport 수준에서 재전송해도 유지합니다.attempt_id: 같은 command를 실제 robot epoch에서 몇 번째로 실행하려는지 나타냅니다. 새 물리 실행을 승인할 때만 바뀝니다.goal_uuid: 한 ROS action protocol instance의 identity입니다. command journal이 command·attempt·goal을 연결합니다.
같은 command_id + attempt_id가 다시 오면 server는 새 motion을 시작하지 않고 이전 accept 상태 또는 terminal result를 반환해야 합니다. 다른 attempt_id로 다시 실행하려면 이전 attempt가 terminal이고, 새 robot epoch·config generation·authority lease에 대해 명시적으로 승인돼야 합니다. 이것이 application 수준 idempotency입니다.
반대로 걷기 명령 자체를 수학적으로 idempotent하다고 부를 수는 없습니다. “2m 전진”을 두 번 실행하면 위치가 달라집니다. 여기서 중복 방지의 대상은 동작의 효과가 아니라 같은 실행 attempt를 두 번 시작하지 않는 것입니다.
feedback은 진행률 숫자가 아니라 다음 판단에 필요한 증거입니다
action feedback을 progress_percent 하나로 만들면 UI는 편해 보입니다. 그러나 로봇 task의 진행은 단조롭지 않습니다. 보행 중 장애물을 피해 돌아가면 목표까지 거리가 잠시 늘 수 있고, 물체를 집었다가 grasp validation에서 다시 놓을 수도 있습니다. 70%에서 60%로 내려간 값이 오류인지 정상 복구인지 설명할 수 없습니다.
현재 프로젝트에는 이미 feedback에 넣어야 할 provenance가 흩어져 있습니다. state age와 clock mapping은 timestamp 동기화 계약, callback 지연은 executor·backpressure 계약, joint 의미는 joint order 호환성 계약, incident 전후 trace는 incident bundle 계약이 소유합니다. action feedback은 이 값을 다시 정의하지 않고 현재 goal 관점에서 참조해야 합니다.
예를 들어 WalkTo feedback에는 phase, elapsed monotonic time, remaining displacement, heading error, current controller, safety state, source state age, active lease epoch를 넣을 수 있습니다. PickObject라면 approach, pre-grasp, closure, lift, verify 같은 phase와 grasp/contact evidence가 필요합니다. client는 이 증거로 계속 기다릴지, cancel할지, incident trace를 열지 판단합니다.
자연어 multi-step task에는 parent와 child의 terminal state가 모두 필요합니다
ClaudePlanner가 만드는 “서기 → 집기 → 걷기 → 놓기” 계획을 하나의 action goal로 감싸더라도 각 child step의 identity를 없애면 안 됩니다. parent task가 ABORTED됐다는 결과만으로는 컵을 집기 전인지, 들고 걷던 중인지, 싱크대 앞에서 놓기를 실패했는지 알 수 없습니다. 실패 위치에 따라 안전한 복구가 달라집니다.
parent journal은 최소한 plan hash, child order, 각 child의 backend·resource set·goal identity·terminal result를 append-only로 남겨야 합니다. child가 실패하면 planner가 자동으로 같은 step을 다시 보내지 않습니다. 먼저 그 step이 물리적으로 어느 지점까지 적용됐는지 확인하고 다음 정책 중 하나를 선택해야 합니다.
- stop: authority를 회수하고 parent를 abort합니다.
- compensate: 잡은 물체를 안전 위치에 놓는 등 명시된 보상 동작을 별도 goal로 실행합니다.
- replan: 최신 scene·robot state로 남은 계획을 다시 만들되 이미 완료된 효과를 command journal에서 제외합니다.
현재 decompose_task()는 JSON 배열을 반환하지만 resource, success condition, failure policy, compensation, identity가 없습니다. 또 실제 Runner는 이를 소비하지 않습니다. 그러므로 지금 단계에서 “자연어 명령이 multi-step으로 실행된다”고 쓰는 것은 설계를 구현으로 잘못 승격시키는 표현입니다.
제가 정의할 action contract는 robot epoch까지 묶습니다
다음은 구현을 시작할 때 사용할 최소 스키마입니다. 실제 .action 파일이 이미 있다는 뜻은 아니며, 현재 code gap에서 도출한 설계안입니다.
# Goal
string command_id
string attempt_id
string robot_epoch
string task_type
string task_payload_json
string config_generation
string required_resource_hash
---
# Result
uint8 terminal_code # succeeded / aborted / canceled
string terminal_reason
string final_state_ref
string incident_ref
string command_journal_hash
---
# Feedback
string phase
uint32 child_step
float32 elapsed_s
string safety_state
string controller
string lease_epoch
float32 source_state_age_ms
string evidence_ref
task_payload_json은 첫 prototype에서만 편리한 escape hatch입니다. 장기적으로는 task별 typed action을 나누는 편이 검증에 유리합니다. 중요한 필드는 robot epoch와 config generation입니다. server가 재시작됐거나 parameter bundle이 바뀌었는데 old goal이 새 runtime에서 조용히 이어지면 같은 command ID라도 다른 시스템이 실행한 결과가 됩니다.
goal callback에서는 schema만 보지 않습니다. current lifecycle state, readiness snapshot, resource conflict, command duplication, robot epoch, config generation을 확인한 뒤 accept 또는 reject합니다. execute callback은 accept된 goal을 실행하고 feedback을 내며, cancel callback은 요청을 받을 수 있는지 판단합니다. rclpy.action.server API도 goal, accepted, execute, cancel callback을 별도로 둡니다. 이 분리는 validation·scheduling·execution·cancellation을 한 callback에 섞지 말라는 신호로 읽을 수 있습니다.
service는 상태를 바꾸지 않는 짧은 질문부터 시작합니다
첫 ROS 2 service를 “걷기 시작”으로 만들 필요는 없습니다. 현재 workspace에서 service로 옮기기 좋은 것은 bounded read 또는 계산입니다.
GetRuntimeManifest: process identity, lifecycle epoch, config generation, checkpoint hash를 읽습니다.GetCommandStatus: command ID로 accept·executing·terminal journal을 조회합니다.CheckTaskAdmission: resource·robot epoch·safety 조건상 goal을 받을 수 있는지 계산하되 authority를 바꾸지 않습니다.SolveIKPreview: 주어진 pose에 대해 짧은 계산 결과와 feasibility를 반환하며 actuator에 적용하지 않습니다.
service callback에서 다시 동기 service나 action을 기다리는 구조도 피해야 합니다. rclpy service client API는 callback 안에서 동기 call()을 사용하면 deadlock 또는 timeout이 발생할 수 있다고 경고합니다. callback group과 executor 상세는 #457 글의 책임이지만, interface 설계 단계에서 긴 작업을 service callback 안에 숨기지 않는 것이 첫 방어선입니다.
첫 구현은 실제 actuator가 아니라 authority 0 mock에서 검증합니다
이번 설계의 첫 acceptance는 G1이 실제로 걷는 장면이 아닙니다. authority가 0인 mock executor에서 한 command가 정확히 한 번 시작되고, 취소와 재시작 뒤에도 terminal evidence가 모순 없이 남는지를 보는 것입니다. live actuator를 붙이면 interface 결함과 제어 결함이 동시에 나타나 원인을 분리하기 어렵습니다.
| fault injection | 통과 조건 | 금지되는 오판 |
|---|---|---|
| goal accept response 유실 | 같은 command·attempt 조회 시 기존 goal identity와 상태 반환 | 새 goal로 같은 motion 재시작 |
| client timeout 후 재전송 | dedupe hit를 기록하고 기존 terminal 또는 in-flight 상태 반환 | timeout을 server 미실행으로 간주 |
| EXECUTING 중 cancel | lease 회수→safe hold→queue drain→CANCELED 순서 증거 | cancel response를 정지 완료로 표시 |
| 이미 SUCCEEDED한 goal cancel | terminal 상태를 유지하고 재동작 없음 | 결과를 지우거나 compensation 자동 실행 |
| old goal 실행 중 새 goal | resource policy에 따라 reject·queue·명시적 replace 중 하나 | 두 goal에 같은 actuator authority 부여 |
| server crash 후 재시작 | old robot epoch goal을 fail-closed하고 journal로 reconciliation | process 재시작만으로 자동 resume |
| feedback 정지 | goal 상태·source age·executor trace를 구분해 incident 생성 | feedback 부재를 즉시 task 실패 또는 성공으로 단정 |
| cancel cleanup timeout | action은 ABORTED 또는 unresolved로 남고 독립 safety path 유지 | CANCELED를 강제로 기록 |
구현 순서는 Runner를 action callback에 그대로 넣는 것부터가 아닙니다
Runner.run()을 ActionServer execute callback에서 반복 호출하면 겉으로는 action이 생깁니다. 그러나 goal journal, cancel token, resource arbitration, terminal commit이 없으면 기존 for-loop를 네트워크 뒤로 옮긴 것뿐입니다. 먼저 ROS와 무관한 pure Python command executor로 수명 주기를 고정해야 합니다.
- CommandEnvelope와 journal: command·attempt·goal identity, robot epoch, config generation, resource set, transition을 append-only로 저장합니다.
- Goal admission: duplicate, stale epoch, resource conflict, lifecycle readiness를 authority 0 상태에서 accept/reject합니다.
- Step executor 분리: 현재
Runner.run()은 한 control decision만 맡고, 장시간 task loop·cancel token·terminal state는 상위 executor가 소유합니다. - ROS 2 adapter: 검증된 executor를 ActionServer의 goal·execute·cancel callback에 연결합니다. 조회·preview는 짧은 service로 노출합니다.
- fault suite: lost response, duplicate resend, cancel race, server restart, feedback stall을 자동화합니다.
- Isaac Sim shadow 실행: actuator authority 없이 실제 observation과 policy timing을 연결하고 journal을 검증합니다.
- bounded canary: 별도 승인 절차를 통과한 뒤에만 제한된 actuator lease를 엽니다.
이 순서에서 ROS 2는 수명 주기를 전달하는 transport와 callback framework입니다. exactly-once 실행, 물리 정지, 안전한 재시도는 application contract와 authority supervisor가 완성해야 합니다.
service와 action을 나누는 기준은 ‘얼마나 오래 걸리나’보다 ‘중간 상태를 누가 책임지나’입니다
짧게 끝나는 조회와 계산은 service로 충분합니다. 그러나 걷기·집기·복구처럼 시간이 걸리고 물리 상태를 바꾸는 명령은 goal identity, feedback, cancel, terminal result가 필요하므로 action이 맞습니다. 저수준 sensor와 command는 계속 흐르는 topic으로 남고, 긴급 정지는 정상 action cancel보다 독립적인 safety path를 가져야 합니다.
현재 workspace는 backend 한 step의 공통 호출을 구현했고, 자연어 multi-step plan과 execution lifecycle 사이에는 아직 빈칸이 있습니다. 이 사실을 인정하면 다음 구현도 선명해집니다. 첫 시험은 “G1이 2m 걸었는가”가 아니라 accept response를 잃고 같은 명령을 다시 보냈을 때 motion attempt가 하나만 존재하는가입니다. 그 검증이 통과해야 cancel 버튼과 자연어 명령을 실제 actuator 권한에 연결할 수 있습니다.