- 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>
199 lines
7.1 KiB
Markdown
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 이벤트 처리
|