Files
hanarang-rails/.plans/design/handoff.md
이랑이 bac114d469 docs(plans): Sprint 000 — 전체 플랜 문서 세트 작성
- CLAUDE.md 를 .claude/rules/ 4파일로 분할 (project/stack/principles/workflow)
- Plans.md 루트 인덱스 (스프린트 목차 + 참조만)
- hooks/ pre/post-tool.sh 스켈레톤 (no-op, Sprint 002에서 구현)
- .plans/OVERVIEW.md — 목표/범위/성공기준 8개
- .plans/failure-audit.md — F1~F6 실패 감사 (증거 기반)
- .plans/design/ 6개 문서:
  * state-machine.md (XState v5 FSM 설계)
  * sprint-contract.md (Zod schema + validator)
  * skill-enforcement.md (4계층 방어)
  * handoff.md (상태 전이 기반 자매 통신)
  * retry-policy.md (backoff + escalation)
  * qa-template.md (체크리스트 runtime)
- .plans/sprints/ 8개 스프린트 명세 (SPRINT-000~007)
- .plans/migration/from-hanarang-harness.md (자산 매트릭스 + 단계별 가이드)

총 17개 문서, 약 2146 lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:36:49 +09:00

7.1 KiB

Design — Handoff (자매 간 핸드오프)

처방 대상: F3 (QA 자동 라우팅 누락), F4 (핸드오프 멘션 불안정)

기존 방식의 한계

기존 hanarang-harness 는 자매 간 통신을 디스코드 멘션으로 구현했다:

하랑이: [구현해줘] @나랑이
나랑이: [완료] @하랑이
하랑이: [QA 필요할 듯] @다랑이

문제:

  • 멘션 파싱 실패 → 다음 자매가 깨지 않음 (F3)
  • 잘못된 자매 호출 → 파이프라인이 이상한 방향 (F4)
  • 멘션 = 메시지 = LLM 재해석 → 결정성 없음
  • 디스코드 rate limit / 네트워크 장애에 취약

새 방식 — 상태 전이가 곧 핸드오프

핸드오프는 XState state transition 이다. 디스코드 멘션은 사용자 알림 전용이다.

┌──────────────────────────────────────────────────┐
│              hanarang-rails orchestrator        │
│                   (XState FSM)                   │
└──────────────────────────────────────────────────┘
       │              │              │              │
       ▼              ▼              ▼              ▼
    spawn          spawn          spawn          spawn
    (execa)        (execa)        (execa)        (execa)
       │              │              │              │
       ▼              ▼              ▼              ▼
  ┌────────┐     ┌────────┐     ┌────────┐     ┌────────┐
  │ 하랑이  │     │ 나랑이  │     │ 다랑이  │     │ 이랑이  │
  │(spawn) │     │(spawn) │     │(spawn) │     │(spawn) │
  └────────┘     └────────┘     └────────┘     └────────┘
       │              │              │              │
       │ structured   │ structured   │ structured   │ structured
       │ output       │ output       │ output       │ output
       ▼              ▼              ▼              ▼
  ┌──────────────────────────────────────────────────┐
  │          orchestrator receives                  │
  │      Zod-validated HandoffMessage               │
  └──────────────────────────────────────────────────┘
                         │
                         ▼
                  [FSM transition]
                         │
                         ▼
                   next actor spawn

  (병렬: 디스코드 bridge 가 상태 전이 이벤트를 구독 → 사용자 알림)

HandoffMessage 스키마

각 자매가 orchestrator 에 반환하는 구조화 결과:

const HandoffMessage = z.discriminatedUnion('actor', [
  z.object({
    actor: z.literal('harang'),
    verdict: z.enum(['PLAN_READY', 'ABORT']),
    payload: z.object({
      planDir: z.string(),
      sprintId: z.string(),
      contractId: z.string(),
    }).optional(),
    abortReason: z.string().optional(),
  }),
  z.object({
    actor: z.literal('narang'),
    verdict: z.enum(['IMPL_DONE', 'ERROR']),
    payload: z.object({
      branch: z.string(),
      commits: z.array(z.string()),
      workdir: z.string(),
      selfTestReport: z.unknown(),
    }).optional(),
    errorReason: z.string().optional(),
  }),
  z.object({
    actor: z.literal('darang'),
    verdict: z.enum(['APPROVE', 'REQUEST_CHANGES', 'ABORT']),
    payload: z.object({
      checklistResults: z.array(z.object({
        id: z.string(),
        passed: z.boolean(),
        note: z.string().optional(),
      })),
      issues: z.array(ReviewIssue),
      artifactPath: z.string(),
    }),
  }),
  z.object({
    actor: z.literal('erang'),
    verdict: z.enum(['DEPLOY_DONE', 'DEPLOY_FAILED']),
    payload: z.object({
      deployArtifactPath: z.string(),
      projectType: z.string(),
      verificationResults: z.unknown(),
    }),
  }),
])

Spawn 계약

Orchestrator 가 자매를 spawn 할 때 표준화된 계약:

async function spawnSister(opts: {
  sister: SisterName
  contractId: string
  workdir: string
  timeoutMs: number
  signal: AbortSignal
}): Promise<HandoffMessage> {
  const { stdout } = await execa('openclaw', [
    'spawn',
    '--role', opts.sister,
    '--contract', opts.contractId,
    '--workdir', opts.workdir,
    '--output', 'structured-json',
  ], {
    timeout: opts.timeoutMs,
    signal: opts.signal,
    cwd: opts.workdir,
  })

  // 반드시 Zod 로 검증
  return HandoffMessage.parse(JSON.parse(stdout))
}
  • stdout 이 structured JSON 이 아니면 즉시 ERROR 로 처리
  • 타임아웃 시 AbortController 로 프로세스 kill
  • Zod 검증 실패 = 규격 위반 = ERROR

디스코드 브릿지 (알림 전용)

디스코드는 사용자에게 진행 상황을 알리는 용도 로만 사용. 자매 간 통신 아님.

orchestrator state transition
          │
          ▼
    ┌──────────────┐
    │ event emitter│
    └──────┬───────┘
           │
           ▼
    ┌────────────────┐
    │ discord bridge │ (discord.js)
    └──────┬─────────┘
           │
           ▼
  포럼 포스트 업데이트 / 멘션
  (사용자 = 자기야)

브릿지가 보내는 메시지 유형:

이벤트 메시지 예시 사용자 멘션
planning 진입 "🦊 하랑이가 SPRINT-001 기획 시작" X
implementing 진입 "⚙️ 나랑이가 구현 시작 (feature/sprint-001)" X
REQUEST_CHANGES "🔁 다랑이 re-review 요청 (라운드 2)" X
escalated 진입 "🚨 3회 실패. 자기야 확인 필요" O
done 진입 " SPRINT-001 완료. 배포 승인해주세요" O

병렬 자매 호출 (나중에)

v1 은 strict sequential. 동시에 두 자매가 돌지 않음.

v2 후보:

  • 나랑이가 worker 여러 개 spawn (내부 병렬)
  • 다랑이가 static / runtime 리뷰 병렬
  • orchestrator 자체는 여전히 single pipeline lock

실패 시나리오 & 복구

시나리오 감지 복구
자매 spawn 실패 (openclaw 커맨드 에러) execa exit code ≠ 0 ERROR event → retry policy
자매 structured output 파싱 실패 Zod validation 실패 ERROR event → retry + log
자매 타임아웃 (응답 없음) AbortController fired TIMEOUT event → retry policy
자매 정상 종료했는데 verdict 이상 Zod 통과했지만 invalid state transition ERROR event → abort + log

참고

  • principles.md 원칙 2
  • failure-audit.md F3, F4
  • state-machine.md — 전이 정의
  • retry-policy.md — TIMEOUT / ERROR 이벤트 처리