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

199 lines
7.1 KiB
Markdown

# 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 에 반환하는 구조화 결과:
```ts
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 할 때 표준화된 계약:
```ts
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 이벤트 처리