Compare commits
10 Commits
v0.1.0
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 98540af98c | |||
| 579137e4bf | |||
| 9aeef223c6 | |||
| e2d71ca47d | |||
| 3e354d21c1 | |||
| a88323716d | |||
| c8d6ceb337 | |||
| 1f518c0c54 | |||
| 3a226ada95 | |||
| 7a8f2c1af0 |
344
.plans/design/hierarchy.md
Normal file
344
.plans/design/hierarchy.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# Design — Hierarchical Sub-Agent Team (Manager / Principal / Lead / Junior)
|
||||||
|
|
||||||
|
> **목적**: 각 stage 의 agent(부장) 가 팀을 꾸려 작업을 분산시키도록 한다.
|
||||||
|
> 평면 구조 (stage 당 1명) → 조직 구조 (부장 + 수석 + 선임 + 신입).
|
||||||
|
|
||||||
|
## 왜 필요한가
|
||||||
|
|
||||||
|
현재 rails 는 stage 당 agent 1명이 전부 처리하는 구조. 이건:
|
||||||
|
- ❌ 병렬성 낭비 — 큰 태스크도 순차 처리
|
||||||
|
- ❌ 모델 비용 비효율 — 모든 작업을 고가 모델로
|
||||||
|
- ❌ 결과 품질 저하 — 한 모델이 전략+전술+실행 전부 담당
|
||||||
|
- ❌ 실제 팀 구조와 미스매치
|
||||||
|
|
||||||
|
## 조직 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
manager (부장) — 전략, 최종 승인
|
||||||
|
└── principal (수석) — 태스크 분해, 기술 리뷰
|
||||||
|
└── lead (선임) — 실행 리드, 작은 팀 조율
|
||||||
|
└── junior (신입) — 개별 태스크 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
**엄격한 한 계단씩 아님** — 복잡도에 따라 manager 가 직접 lead 또는 junior 를 바로 spawn 할 수도 있다. 결정은 manager 의 "판단 코드".
|
||||||
|
|
||||||
|
## 역할 정의 (`roles.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hierarchy:
|
||||||
|
manager:
|
||||||
|
korean: 부장
|
||||||
|
responsibilities: [strategy, team-composition, final-approval, escalation-relay]
|
||||||
|
models:
|
||||||
|
primary: gpt-5.4
|
||||||
|
fallback: glm-5.1
|
||||||
|
can_spawn: [principal, lead, junior] # 복잡도에 따라 직접 spawn 가능
|
||||||
|
max_spawn_per_call: 4 # 한 번에 최대 4개 팀원
|
||||||
|
|
||||||
|
principal:
|
||||||
|
korean: 수석
|
||||||
|
responsibilities: [task-decomposition, technical-review, risk-assessment]
|
||||||
|
models:
|
||||||
|
primary: gpt-5.4
|
||||||
|
fallback: glm-5.1
|
||||||
|
can_spawn: [lead, junior]
|
||||||
|
max_spawn_per_call: 3
|
||||||
|
|
||||||
|
lead:
|
||||||
|
korean: 선임
|
||||||
|
responsibilities: [execution-lead, sub-team-coordination, mid-validation]
|
||||||
|
models:
|
||||||
|
primary: gpt-codex-5.3
|
||||||
|
fallback: glm-5
|
||||||
|
can_spawn: [junior]
|
||||||
|
max_spawn_per_call: 4
|
||||||
|
|
||||||
|
junior:
|
||||||
|
korean: 신입
|
||||||
|
responsibilities: [single-task-execution, unit-output]
|
||||||
|
models:
|
||||||
|
primary: glm-5-turbo
|
||||||
|
fallback: gpt-5
|
||||||
|
can_spawn: []
|
||||||
|
max_spawn_per_call: 0
|
||||||
|
|
||||||
|
# LXC 별 동시 실행 상한 — narang 은 빌드 중이라 보수적
|
||||||
|
concurrency_limits:
|
||||||
|
default: 8
|
||||||
|
overrides:
|
||||||
|
narang: 6
|
||||||
|
```
|
||||||
|
|
||||||
|
## 복잡도 판단 (Complexity Scoring)
|
||||||
|
|
||||||
|
Manager 가 태스크를 받으면 먼저 복잡도 점수(0-100) 를 계산한다. 이 점수로
|
||||||
|
팀 구성 규모가 결정된다.
|
||||||
|
|
||||||
|
### 점수 요소 (Deterministic)
|
||||||
|
|
||||||
|
| 요소 | 조건 | 점수 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Scope scale** (from description + keywords) | 단일 파일 / "한 줄" | +0~5 |
|
||||||
|
| | 컴포넌트 1개 / small feature | +5~15 |
|
||||||
|
| | 여러 파일 / 멀티 모듈 | +15~30 |
|
||||||
|
| | Sprint 단위 | +30~50 |
|
||||||
|
| | 전체 프로젝트 / architecture | +50~80 |
|
||||||
|
| | From scratch / scaffold | +70~100 |
|
||||||
|
| **Multi-domain** (+5 each, cap +20) | frontend / backend / db / infra / ci / security / test 언급 | max +20 |
|
||||||
|
| **Risk keywords** (+10 each, cap +30) | migration / breaking / security / auth / data-loss | max +30 |
|
||||||
|
| **Parallelism hints** (+5 each, cap +15) | "multiple" / "동시에" / "parallel" / "bulk" | max +15 |
|
||||||
|
| **Uncertainty** | "probably" / "maybe" / "아직 모르겠" | +10 |
|
||||||
|
| **Estimated LOC** | >500 추정 | +10 |
|
||||||
|
| **Cross-agent dependency** | 다른 stage 와 명시 연관 | +10 |
|
||||||
|
|
||||||
|
### Tier → Decomposition Plan
|
||||||
|
|
||||||
|
| Score | Tier | 전략 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0-15 | **trivial** | Manager 직접 처리 (spawn X) |
|
||||||
|
| 16-30 | **simple** | 1 junior |
|
||||||
|
| 31-50 | **moderate** | 1 lead + 1-2 junior |
|
||||||
|
| 51-75 | **complex** | 1 principal + 2 lead + 4 junior |
|
||||||
|
| 76-100 | **massive** | 2 principal + 각자 팀 (병렬 fanout) |
|
||||||
|
|
||||||
|
### LLM 보강 (optional)
|
||||||
|
|
||||||
|
규칙 기반 점수 + 기본 plan 을 cheap LLM 에게 주고
|
||||||
|
"이 plan 이 맞는지 / 조정 필요한지" 판단받음. 규칙 + LLM 합의가 최종 plan.
|
||||||
|
|
||||||
|
## 상향 에스컬레이션 (Upward Escalation)
|
||||||
|
|
||||||
|
하위 역할이 실패하면 **즉시 상위** 로 에스컬레이션 (재시도 아님).
|
||||||
|
|
||||||
|
```
|
||||||
|
junior 실패 (confidence < 0.5 or 명시적 escalate)
|
||||||
|
→ lead 가 해당 태스크 재수행
|
||||||
|
→ 또 실패
|
||||||
|
→ principal
|
||||||
|
→ 또 실패
|
||||||
|
→ manager
|
||||||
|
→ 또 실패
|
||||||
|
→ rails orchestrator → 사용자
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 역할로 재시도는 resilience retry 가 담당 (Sprint 005).
|
||||||
|
위 상향 에스컬레이션은 **서로 다른 역할** 로 넘기는 흐름.
|
||||||
|
|
||||||
|
## 데이터 모델 (Prisma)
|
||||||
|
|
||||||
|
### SubTask
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model SubTask {
|
||||||
|
id String @id @db.VarChar(26) // ULID
|
||||||
|
pipelineId String @db.VarChar(26)
|
||||||
|
parentId String? @db.VarChar(26) // null = manager 직속
|
||||||
|
role String @db.VarChar(30) // manager|principal|lead|junior
|
||||||
|
agentName String @db.VarChar(50) // harang|narang|darang|erang
|
||||||
|
title String @db.VarChar(500)
|
||||||
|
description String @db.Text
|
||||||
|
state String @db.VarChar(30) // queued|running|done|failed|escalated
|
||||||
|
complexityScore Int?
|
||||||
|
complexityTier String? @db.VarChar(20)
|
||||||
|
model String @db.VarChar(50) // 사용 모델
|
||||||
|
resultJson String? @db.LongText
|
||||||
|
errorReason String? @db.Text
|
||||||
|
startedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||||
|
parent SubTask? @relation("SubTaskHierarchy", fields: [parentId], references: [id])
|
||||||
|
children SubTask[] @relation("SubTaskHierarchy")
|
||||||
|
events SubTaskEvent[]
|
||||||
|
|
||||||
|
@@index([pipelineId, parentId])
|
||||||
|
@@index([state])
|
||||||
|
@@index([agentName, state])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SubTaskEvent
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model SubTaskEvent {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
subTaskId String @db.VarChar(26)
|
||||||
|
eventType String @db.VarChar(50) // spawned|started|progress|output|completed|failed|escalated
|
||||||
|
payloadJson String @db.LongText
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
subTask SubTask @relation(fields: [subTaskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([subTaskId, timestamp])
|
||||||
|
@@index([eventType])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sister-Agent 데몬 (LXC 에 배포)
|
||||||
|
|
||||||
|
각 sister LXC (104/105/106/107) 에 Node.js 데몬이 돈다. 포트 **18801** (openclaw-gateway 와 분리).
|
||||||
|
|
||||||
|
### 책임
|
||||||
|
|
||||||
|
1. rails 로부터 `POST /invoke` 수신
|
||||||
|
2. 복잡도 점수 계산
|
||||||
|
3. Decomposition plan 생성
|
||||||
|
4. sub-agent spawn (OpenClaw 를 경유하거나 LLM 직접 호출)
|
||||||
|
5. sub-task 이벤트를 rails 에 실시간 push
|
||||||
|
6. 결과 집계 후 rails 에 HandoffMessage 반환
|
||||||
|
|
||||||
|
### 디렉토리 (new sub-project under rails repo)
|
||||||
|
|
||||||
|
```
|
||||||
|
hanarang-rails/
|
||||||
|
└── sister-agent/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── roles.yaml
|
||||||
|
└── src/
|
||||||
|
├── server.ts # HTTP /invoke 엔드포인트
|
||||||
|
├── complexity/
|
||||||
|
│ ├── scorer.ts # 규칙 기반 점수 계산
|
||||||
|
│ └── planner.ts # decomposition 전략
|
||||||
|
├── hierarchy/
|
||||||
|
│ ├── roles.ts # YAML 로더
|
||||||
|
│ ├── spawn.ts # openclaw agent spawn wrapper
|
||||||
|
│ └── escalate.ts # 상향 에스컬레이션
|
||||||
|
├── reporting/
|
||||||
|
│ └── rails-client.ts # rails API 로 이벤트 push
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통신 프로토콜
|
||||||
|
|
||||||
|
#### 1. rails → sister-agent: `POST /invoke`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pipelineId": "01HW...",
|
||||||
|
"contractId": "01HW...",
|
||||||
|
"stage": "implement",
|
||||||
|
"task": {
|
||||||
|
"title": "Sprint 001 — todo app MVP",
|
||||||
|
"description": "Next.js + Nest.js 로 기본 TODO CRUD",
|
||||||
|
"workdir": "/home/narang/projects/todo-app"
|
||||||
|
},
|
||||||
|
"timeoutMs": 600000,
|
||||||
|
"railsApiUrl": "http://10.10.10.169:18800"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. sister-agent → rails: `POST /api/sub-tasks`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "01HW...",
|
||||||
|
"pipelineId": "01HW...",
|
||||||
|
"parentId": null,
|
||||||
|
"role": "manager",
|
||||||
|
"agentName": "narang",
|
||||||
|
"title": "root task",
|
||||||
|
"description": "...",
|
||||||
|
"complexityScore": 42,
|
||||||
|
"complexityTier": "moderate",
|
||||||
|
"model": "gpt-5.4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. sister-agent → rails: `POST /api/sub-tasks/:id/events`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventType": "spawned",
|
||||||
|
"payload": { "childId": "01HW..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. sister-agent → rails: `POST /invoke` 응답 (HandoffMessage)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "implement",
|
||||||
|
"verdict": "IMPL_DONE",
|
||||||
|
"payload": {
|
||||||
|
"branch": "feature/sprint-001",
|
||||||
|
"commits": ["abc1234"],
|
||||||
|
"workdir": "/home/narang/projects/todo-app",
|
||||||
|
"selfTestReport": { "typecheck": "pass" }
|
||||||
|
},
|
||||||
|
"errorReason": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## LLM 호출 전략 (현실적)
|
||||||
|
|
||||||
|
초기 구현은 **openclaw CLI wrapping** 으로 간다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw agent \
|
||||||
|
--prompt "$(cat prompt.txt)" \
|
||||||
|
--model gpt-5.4 \
|
||||||
|
--output json
|
||||||
|
```
|
||||||
|
|
||||||
|
sister-agent 가 sub-process 로 `openclaw agent` 를 호출하고 stdout 을
|
||||||
|
structured JSON 으로 파싱한다. 이게 안 되면 OpenAI/Z.ai SDK 직접 호출로
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
## 관제 대시보드 연동
|
||||||
|
|
||||||
|
대시보드는 `sub_tasks` 테이블을 트리 구조로 렌더링한다:
|
||||||
|
|
||||||
|
```
|
||||||
|
Pipeline 01KNV4...
|
||||||
|
├── 🦊 하랑 (manager) — planning [45s] [gpt-5.4]
|
||||||
|
│ ├── principal: 요구사항 분해 [done, 12s] [gpt-5.4]
|
||||||
|
│ └── lead: Sprint 분해 [done, 18s] [gpt-codex-5.3]
|
||||||
|
│ ├── junior: SPRINT-001 문서 [done, 4s] [glm-5-turbo]
|
||||||
|
│ ├── junior: SPRINT-002 문서 [done, 5s] [glm-5-turbo]
|
||||||
|
│ └── junior: SPRINT-003 문서 [done, 4s] [glm-5-turbo]
|
||||||
|
│
|
||||||
|
├── ⚙️ 나랑 (manager) — implementing [current] [gpt-5.4]
|
||||||
|
│ ├── principal: 아키텍처 검토 [done, 8s] [glm-5.1]
|
||||||
|
│ └── lead: 코딩 리드 [running] [gpt-codex-5.3]
|
||||||
|
│ ├── junior: frontend scaffold [running] [glm-5-turbo]
|
||||||
|
│ └── junior: backend scaffold [queued] [glm-5-turbo]
|
||||||
|
```
|
||||||
|
|
||||||
|
각 노드 click → 상세 모달 (prompt / output / timing / 모델 / 비용).
|
||||||
|
|
||||||
|
## 관찰 가능성 (Observability)
|
||||||
|
|
||||||
|
모든 서브태스크 전이가 `SubTaskEvent` 로 기록되고 `POST /api/stream`
|
||||||
|
(Socket.IO) 을 통해 대시보드에 실시간 푸시된다. SIEM 관점에서:
|
||||||
|
|
||||||
|
- `spawned` — 부모 노드 등록
|
||||||
|
- `started` — 실제 LLM 호출 시작
|
||||||
|
- `progress` — 중간 출력 (streaming 지원 시)
|
||||||
|
- `output` — 부분 결과
|
||||||
|
- `completed` — 성공 종료
|
||||||
|
- `failed` — 실패 종료 (재시도 대상)
|
||||||
|
- `escalated` — 상위로 에스컬레이션
|
||||||
|
|
||||||
|
## 보안
|
||||||
|
|
||||||
|
- sister-agent 가 받는 task 는 rails 에서 HMAC 서명 포함 (nonce 재사용 방지)
|
||||||
|
- sister-agent ↔ rails 통신은 내부 네트워크 (vmbr1) 한정
|
||||||
|
- 모델 API 키는 각 sister LXC 의 openclaw 설정에 이미 있음 — sister-agent 는
|
||||||
|
openclaw CLI 만 wrapping 하면 키 노출 없음
|
||||||
|
- rails DB 의 `resultJson` 에 비밀이 들어가지 않도록 sister-agent 가 masking
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- `state-machine.md` — pipeline FSM (stage 단위)
|
||||||
|
- `handoff.md` — rails ↔ sister (stage 단위 HandoffMessage)
|
||||||
|
- `retry-policy.md` — 같은 역할 재시도 정책
|
||||||
|
- `transports.md` — SisterTransport 추상화 (HttpTransport 가 여기 들어감)
|
||||||
|
|
||||||
|
## Open questions (Stage 2 에서 결정)
|
||||||
|
|
||||||
|
- [ ] Sub-task streaming output 은 SSE 로 할지 Socket.IO 로 할지
|
||||||
|
- [ ] 모델 비용 트래킹을 SubTask 에 추가할지
|
||||||
|
- [ ] Token 수 트래킹
|
||||||
|
- [ ] Escalation 시 부모 sub-task 의 retry count 합산 로직
|
||||||
@@ -20,6 +20,7 @@ model Pipeline {
|
|||||||
actorSpawns ActorSpawn[]
|
actorSpawns ActorSpawn[]
|
||||||
contracts Contract[]
|
contracts Contract[]
|
||||||
escalations Escalation[]
|
escalations Escalation[]
|
||||||
|
subTasks SubTask[]
|
||||||
|
|
||||||
@@index([currentState])
|
@@index([currentState])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -74,6 +75,49 @@ model Contract {
|
|||||||
@@map("contracts")
|
@@map("contracts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SubTask {
|
||||||
|
id String @id @db.VarChar(26)
|
||||||
|
pipelineId String @db.VarChar(26)
|
||||||
|
parentId String? @db.VarChar(26)
|
||||||
|
role String @db.VarChar(30)
|
||||||
|
agentName String @db.VarChar(50)
|
||||||
|
title String @db.VarChar(500)
|
||||||
|
description String @db.Text
|
||||||
|
state String @db.VarChar(30) @default("queued")
|
||||||
|
complexityScore Int?
|
||||||
|
complexityTier String? @db.VarChar(20)
|
||||||
|
model String @db.VarChar(50) @default("")
|
||||||
|
resultJson String? @db.LongText
|
||||||
|
errorReason String? @db.Text
|
||||||
|
startedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||||
|
parent SubTask? @relation("SubTaskHierarchy", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
|
children SubTask[] @relation("SubTaskHierarchy")
|
||||||
|
events SubTaskEvent[]
|
||||||
|
|
||||||
|
@@index([pipelineId, parentId])
|
||||||
|
@@index([state])
|
||||||
|
@@index([agentName, state])
|
||||||
|
@@map("sub_tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SubTaskEvent {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
subTaskId String @db.VarChar(26)
|
||||||
|
eventType String @db.VarChar(50)
|
||||||
|
payloadJson String @db.LongText
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
subTask SubTask @relation(fields: [subTaskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([subTaskId, timestamp])
|
||||||
|
@@index([eventType])
|
||||||
|
@@map("sub_task_events")
|
||||||
|
}
|
||||||
|
|
||||||
model Escalation {
|
model Escalation {
|
||||||
id String @id @db.VarChar(26) // ULID
|
id String @id @db.VarChar(26) // ULID
|
||||||
pipelineId String @db.VarChar(26)
|
pipelineId String @db.VarChar(26)
|
||||||
|
|||||||
1
sister-agent/.claude/sessions/.last_inbox_check
Normal file
1
sister-agent/.claude/sessions/.last_inbox_check
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1775814166
|
||||||
0
sister-agent/.claude/state/session-events.lock
Normal file
0
sister-agent/.claude/state/session-events.lock
Normal file
0
sister-agent/.claude/state/session.events.jsonl
Normal file
0
sister-agent/.claude/state/session.events.jsonl
Normal file
0
sister-agent/.claude/state/session.json
Normal file
0
sister-agent/.claude/state/session.json
Normal file
7
sister-agent/.claude/state/test-recommendation.json
Normal file
7
sister-agent/.claude/state/test-recommendation.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"timestamp": "2026-04-10T09:46:21Z",
|
||||||
|
"changed_file": "/home/erang/hanarang-rails/src/orchestrator/runner.ts",
|
||||||
|
"test_command": "npm test",
|
||||||
|
"related_test": "",
|
||||||
|
"recommendation": "テストの実行を推奨します"
|
||||||
|
}
|
||||||
1
sister-agent/.claude/state/tool-failure-counter.txt
Normal file
1
sister-agent/.claude/state/tool-failure-counter.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1 1775808904
|
||||||
26
sister-agent/package.json
Normal file
26
sister-agent/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "sister-agent",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Sub-agent orchestrator daemon running on each sister LXC",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ulid": "^2.3.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^3.1.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.0"
|
||||||
|
}
|
||||||
1029
sister-agent/pnpm-lock.yaml
generated
Normal file
1029
sister-agent/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
173
sister-agent/src/complexity.ts
Normal file
173
sister-agent/src/complexity.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ComplexityTier = z.enum([
|
||||||
|
"trivial",
|
||||||
|
"simple",
|
||||||
|
"moderate",
|
||||||
|
"complex",
|
||||||
|
"massive",
|
||||||
|
]);
|
||||||
|
export type ComplexityTier = z.infer<typeof ComplexityTier>;
|
||||||
|
|
||||||
|
export interface ComplexityScore {
|
||||||
|
score: number; // 0-100
|
||||||
|
tier: ComplexityTier;
|
||||||
|
factors: {
|
||||||
|
scopeScale: number;
|
||||||
|
multiDomain: number;
|
||||||
|
riskKeywords: number;
|
||||||
|
parallelismHints: number;
|
||||||
|
uncertainty: number;
|
||||||
|
estimatedLoc: number;
|
||||||
|
crossAgentDep: number;
|
||||||
|
};
|
||||||
|
matched: string[]; // matched keywords for transparency
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_RULES: Array<{ re: RegExp; score: number; label: string }> = [
|
||||||
|
{ re: /한\s*줄|single\s*line|one\s*liner/i, score: 2, label: "one-liner" },
|
||||||
|
{ re: /single\s*file|한\s*파일|단일\s*파일/i, score: 5, label: "single-file" },
|
||||||
|
{ re: /컴포넌트|component|small\s*feature|작은\s*기능/i, score: 12, label: "small-feature" },
|
||||||
|
{ re: /여러\s*파일|multiple\s*files|multi-?file|멀티\s*모듈/i, score: 25, label: "multi-file" },
|
||||||
|
{ re: /sprint|스프린트/i, score: 40, label: "sprint-scale" },
|
||||||
|
{ re: /전체\s*리팩터|full\s*refactor|architecture|아키텍처/i, score: 65, label: "architecture" },
|
||||||
|
{ re: /from\s*scratch|새로\s*만들|scaffold|새\s*프로젝트|new\s*project/i, score: 75, label: "scaffold" },
|
||||||
|
{ re: /monorepo|migration\s*project|전면\s*재작성/i, score: 85, label: "massive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOMAINS = [
|
||||||
|
"frontend", "front-end", "프론트",
|
||||||
|
"backend", "back-end", "백엔드",
|
||||||
|
"database", "db", "prisma", "postgres", "mariadb", "mysql",
|
||||||
|
"infra", "deploy", "배포", "docker", "k8s", "kubernetes",
|
||||||
|
"ci", "cd", "github\\s*actions", "gitea",
|
||||||
|
"security", "auth", "인증", "oauth",
|
||||||
|
"test", "테스트", "vitest", "jest",
|
||||||
|
"api", "rest", "graphql",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RISK_KEYWORDS = [
|
||||||
|
"migration", "migrate", "마이그레이션",
|
||||||
|
"breaking", "breaking\\s*change", "호환\\s*안", "하위\\s*호환",
|
||||||
|
"security", "vulnerability", "취약점",
|
||||||
|
"auth", "authentication", "authorization",
|
||||||
|
"data\\s*loss", "데이터\\s*손실", "rollback",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PARALLELISM_HINTS = [
|
||||||
|
"multiple", "여러", "parallel", "병렬", "동시에", "simultaneously",
|
||||||
|
"bulk", "대량", "batch", "fanout",
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNCERTAINTY_MARKERS = [
|
||||||
|
"probably", "maybe", "might", "I\\s*think",
|
||||||
|
"아직\\s*모르", "아마", "잘\\s*모르", "애매",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOC_HINT = /(\d{2,})\s*(?:lines?|loc|줄)/i;
|
||||||
|
|
||||||
|
const CROSS_AGENT_HINTS = [
|
||||||
|
/plan.*implement|implement.*review|review.*deploy/i,
|
||||||
|
/기획.*구현|구현.*리뷰|리뷰.*배포/i,
|
||||||
|
/전체\s*(?:파이프라인|flow|흐름)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function countMatches(text: string, patterns: string[]): {
|
||||||
|
count: number;
|
||||||
|
matched: string[];
|
||||||
|
} {
|
||||||
|
const matched: string[] = [];
|
||||||
|
for (const p of patterns) {
|
||||||
|
const re = new RegExp(`\\b${p}\\b`, "i");
|
||||||
|
if (re.test(text)) matched.push(p);
|
||||||
|
}
|
||||||
|
return { count: matched.length, matched };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreComplexity(task: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}): ComplexityScore {
|
||||||
|
const text = `${task.title}\n${task.description ?? ""}`;
|
||||||
|
const matched: string[] = [];
|
||||||
|
|
||||||
|
// Scope scale — take the MAX matching rule
|
||||||
|
let scopeScale = 0;
|
||||||
|
for (const rule of SCOPE_RULES) {
|
||||||
|
if (rule.re.test(text)) {
|
||||||
|
if (rule.score > scopeScale) scopeScale = rule.score;
|
||||||
|
matched.push(`scope:${rule.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scopeScale === 0) scopeScale = 10; // unknown default
|
||||||
|
|
||||||
|
// Multi-domain
|
||||||
|
const { count: domainCount, matched: domainMatched } = countMatches(text, DOMAINS);
|
||||||
|
const multiDomain = Math.min(domainCount * 5, 20);
|
||||||
|
matched.push(...domainMatched.map((d) => `domain:${d}`));
|
||||||
|
|
||||||
|
// Risk keywords
|
||||||
|
const { count: riskCount, matched: riskMatched } = countMatches(text, RISK_KEYWORDS);
|
||||||
|
const riskKeywords = Math.min(riskCount * 10, 30);
|
||||||
|
matched.push(...riskMatched.map((r) => `risk:${r}`));
|
||||||
|
|
||||||
|
// Parallelism hints
|
||||||
|
const { count: parCount, matched: parMatched } = countMatches(text, PARALLELISM_HINTS);
|
||||||
|
const parallelismHints = Math.min(parCount * 5, 15);
|
||||||
|
matched.push(...parMatched.map((p) => `parallel:${p}`));
|
||||||
|
|
||||||
|
// Uncertainty
|
||||||
|
const { count: uncertainCount } = countMatches(text, UNCERTAINTY_MARKERS);
|
||||||
|
const uncertainty = uncertainCount > 0 ? 10 : 0;
|
||||||
|
if (uncertainty) matched.push("uncertainty");
|
||||||
|
|
||||||
|
// Estimated LOC
|
||||||
|
const locMatch = text.match(LOC_HINT);
|
||||||
|
const loc = locMatch && locMatch[1] ? parseInt(locMatch[1], 10) : 0;
|
||||||
|
const estimatedLoc = loc > 500 ? 10 : 0;
|
||||||
|
if (estimatedLoc) matched.push(`loc:${loc}`);
|
||||||
|
|
||||||
|
// Cross-agent dep
|
||||||
|
let crossAgentDep = 0;
|
||||||
|
for (const re of CROSS_AGENT_HINTS) {
|
||||||
|
if (re.test(text)) {
|
||||||
|
crossAgentDep = 10;
|
||||||
|
matched.push("cross-agent");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Math.min(
|
||||||
|
100,
|
||||||
|
scopeScale +
|
||||||
|
multiDomain +
|
||||||
|
riskKeywords +
|
||||||
|
parallelismHints +
|
||||||
|
uncertainty +
|
||||||
|
estimatedLoc +
|
||||||
|
crossAgentDep,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
tier: tierFromScore(score),
|
||||||
|
factors: {
|
||||||
|
scopeScale,
|
||||||
|
multiDomain,
|
||||||
|
riskKeywords,
|
||||||
|
parallelismHints,
|
||||||
|
uncertainty,
|
||||||
|
estimatedLoc,
|
||||||
|
crossAgentDep,
|
||||||
|
},
|
||||||
|
matched,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierFromScore(score: number): ComplexityTier {
|
||||||
|
if (score <= 15) return "trivial";
|
||||||
|
if (score <= 30) return "simple";
|
||||||
|
if (score <= 50) return "moderate";
|
||||||
|
if (score <= 75) return "complex";
|
||||||
|
return "massive";
|
||||||
|
}
|
||||||
1
sister-agent/src/index.ts
Normal file
1
sister-agent/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "./server.js";
|
||||||
108
sister-agent/src/llm.ts
Normal file
108
sister-agent/src/llm.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
export interface LlmResult {
|
||||||
|
ok: boolean;
|
||||||
|
text: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENCLAW_BIN =
|
||||||
|
process.env["OPENCLAW_BIN"] ??
|
||||||
|
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call openclaw infer model run via subprocess.
|
||||||
|
* Returns the structured JSON the CLI emits with --json.
|
||||||
|
*
|
||||||
|
* Note: openclaw enforces an allowlist per agent. We pass through to the
|
||||||
|
* default model unless an explicit override is requested AND it's allowed.
|
||||||
|
*/
|
||||||
|
export async function callLlm(opts: {
|
||||||
|
prompt: string;
|
||||||
|
modelOverride?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<LlmResult> {
|
||||||
|
const args = ["infer", "model", "run", "--prompt", opts.prompt, "--json"];
|
||||||
|
if (opts.modelOverride) {
|
||||||
|
args.push("--model", opts.modelOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolveFn) => {
|
||||||
|
const child = spawn(OPENCLAW_BIN, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `LLM timeout after ${opts.timeoutMs ?? 120_000}ms`,
|
||||||
|
});
|
||||||
|
}, opts.timeoutMs ?? 120_000);
|
||||||
|
|
||||||
|
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
|
||||||
|
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `LLM spawn error: ${err.message}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stdout) as {
|
||||||
|
ok: boolean;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
outputs: Array<{ text: string }>;
|
||||||
|
};
|
||||||
|
const text = parsed.outputs?.[0]?.text ?? "";
|
||||||
|
resolveFn({
|
||||||
|
ok: parsed.ok,
|
||||||
|
text,
|
||||||
|
provider: parsed.provider,
|
||||||
|
model: parsed.model,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}\nstdout: ${stdout.slice(0, 500)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
191
sister-agent/src/planner.ts
Normal file
191
sister-agent/src/planner.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import type { ComplexityScore, ComplexityTier } from "./complexity.js";
|
||||||
|
import type { Role } from "./types.js";
|
||||||
|
|
||||||
|
export interface SpawnPlan {
|
||||||
|
role: Role;
|
||||||
|
count: number;
|
||||||
|
subBreakdown?: SpawnPlan[]; // nested hierarchy
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecompositionPlan {
|
||||||
|
tier: ComplexityTier;
|
||||||
|
score: number;
|
||||||
|
strategy:
|
||||||
|
| "direct" // manager executes directly, no spawn
|
||||||
|
| "single-junior" // 1 junior only
|
||||||
|
| "lead-team" // 1 lead + juniors
|
||||||
|
| "principal-team" // 1 principal + leads + juniors
|
||||||
|
| "fanout"; // massive — 2 principals in parallel
|
||||||
|
spawn: SpawnPlan[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan the team structure for a given complexity score.
|
||||||
|
* Deterministic — no LLM required.
|
||||||
|
*
|
||||||
|
* Manager can override this plan if LLM refinement is enabled.
|
||||||
|
*/
|
||||||
|
export function planDecomposition(complexity: ComplexityScore): DecompositionPlan {
|
||||||
|
const { score, tier } = complexity;
|
||||||
|
|
||||||
|
switch (tier) {
|
||||||
|
case "trivial":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "direct",
|
||||||
|
spawn: [],
|
||||||
|
notes: [
|
||||||
|
"Manager handles directly — no team needed for trivial tasks.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "simple":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "single-junior",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 1,
|
||||||
|
rationale: "Single junior handles the task directly.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "moderate":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "lead-team",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "lead",
|
||||||
|
count: 1,
|
||||||
|
rationale: "Lead coordinates 2 juniors for moderate scope.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two juniors execute parallel sub-tasks.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"Lead decides the exact sub-task split at runtime.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "complex":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "principal-team",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "principal",
|
||||||
|
count: 1,
|
||||||
|
rationale: "Principal handles architecture review + decomposition.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "lead",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two leads run parallel workstreams.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two juniors per lead.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"Principal may bypass lead and spawn juniors directly when bottleneck detected.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "massive":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "fanout",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "principal",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two principals split the work by domain (e.g., FE / BE).",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "lead",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Each principal runs 2 parallel leads.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 3,
|
||||||
|
rationale: "Three juniors per lead for massive throughput.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"Total worst-case: 2 principals + 4 leads + 12 juniors.",
|
||||||
|
"Manager monitors and rebalances on escalation.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count total nodes in a decomposition plan (for concurrency budgeting).
|
||||||
|
*/
|
||||||
|
export function countPlanNodes(plan: DecompositionPlan): number {
|
||||||
|
const count = (spawns: SpawnPlan[]): number => {
|
||||||
|
let total = 0;
|
||||||
|
for (const s of spawns) {
|
||||||
|
total += s.count;
|
||||||
|
if (s.subBreakdown) total += s.count * count(s.subBreakdown);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
// +1 for the manager itself
|
||||||
|
return 1 + count(plan.spawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a plan fits within concurrency budget.
|
||||||
|
* Returns a trimmed plan if over budget.
|
||||||
|
*/
|
||||||
|
export function enforceConcurrencyBudget(
|
||||||
|
plan: DecompositionPlan,
|
||||||
|
budget: number,
|
||||||
|
): DecompositionPlan {
|
||||||
|
const nodeCount = countPlanNodes(plan);
|
||||||
|
if (nodeCount <= budget) return plan;
|
||||||
|
|
||||||
|
// Over budget — trim sub-breakdowns
|
||||||
|
const trimmed = JSON.parse(JSON.stringify(plan)) as DecompositionPlan;
|
||||||
|
const trimFactor = budget / nodeCount;
|
||||||
|
|
||||||
|
const trim = (spawns: SpawnPlan[]): void => {
|
||||||
|
for (const s of spawns) {
|
||||||
|
s.count = Math.max(1, Math.floor(s.count * trimFactor));
|
||||||
|
if (s.subBreakdown) trim(s.subBreakdown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trim(trimmed.spawn);
|
||||||
|
trimmed.notes.push(
|
||||||
|
`Trimmed from ${nodeCount} → ${countPlanNodes(trimmed)} nodes to fit budget ${budget}.`,
|
||||||
|
);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
103
sister-agent/src/prompts.ts
Normal file
103
sister-agent/src/prompts.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Role } from "./types.js";
|
||||||
|
|
||||||
|
export interface PromptContext {
|
||||||
|
role: Role;
|
||||||
|
agentName: string; // harang/narang/darang/erang
|
||||||
|
stage: "plan" | "implement" | "review" | "deploy";
|
||||||
|
taskTitle: string;
|
||||||
|
taskDescription: string;
|
||||||
|
prevStageOutput?: string;
|
||||||
|
parentTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_KOREAN: Record<string, string> = {
|
||||||
|
plan: "기획",
|
||||||
|
implement: "구현",
|
||||||
|
review: "검토",
|
||||||
|
deploy: "배포",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_KOREAN: Record<Role, string> = {
|
||||||
|
manager: "부장",
|
||||||
|
principal: "수석",
|
||||||
|
lead: "선임",
|
||||||
|
junior: "신입",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_RESPONSIBILITY: Record<Role, string> = {
|
||||||
|
manager:
|
||||||
|
"팀 전체의 전략을 결정하고 최종 결과물의 품질을 책임진다. 본인이 직접 코드를 짜지 않고 아래 팀에 분배한다.",
|
||||||
|
principal:
|
||||||
|
"기술적 분해와 리뷰를 담당한다. 부장의 방향을 받아 구체적인 실행 단위로 쪼갠다.",
|
||||||
|
lead:
|
||||||
|
"실행 리드. 작은 팀을 조율하면서 신입의 작업물을 검증하고 합친다.",
|
||||||
|
junior:
|
||||||
|
"한 가지 명확한 작업을 직접 실행한다. 결과물(텍스트, 코드, 답변)을 명확하게 제출한다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the prompt the LLM will see for this node.
|
||||||
|
* The pattern: short system context + concrete task + previous output (if any).
|
||||||
|
*
|
||||||
|
* Output format hint: ask for plain text. Keeping it simple — no JSON parsing
|
||||||
|
* required from the LLM (we already have structure from the spawn tree).
|
||||||
|
*/
|
||||||
|
export function buildPrompt(ctx: PromptContext): string {
|
||||||
|
const stageKor = STAGE_KOREAN[ctx.stage] ?? ctx.stage;
|
||||||
|
const roleKor = ROLE_KOREAN[ctx.role];
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# 역할`);
|
||||||
|
lines.push(
|
||||||
|
`너는 "${ctx.agentName}" 자매의 ${roleKor}(${ctx.role})이다. ${ROLE_RESPONSIBILITY[ctx.role]}`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 현재 단계`);
|
||||||
|
lines.push(`${stageKor} (stage=${ctx.stage})`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 작업`);
|
||||||
|
lines.push(`제목: ${ctx.taskTitle}`);
|
||||||
|
if (ctx.taskDescription) {
|
||||||
|
lines.push(`상세: ${ctx.taskDescription}`);
|
||||||
|
}
|
||||||
|
if (ctx.parentTitle) {
|
||||||
|
lines.push(`상위 작업: ${ctx.parentTitle}`);
|
||||||
|
}
|
||||||
|
if (ctx.prevStageOutput) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 이전 단계 결과 (참고)`);
|
||||||
|
lines.push(ctx.prevStageOutput.slice(0, 4000));
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 출력 형식`);
|
||||||
|
lines.push(roleOutputHint(ctx.role, ctx.stage));
|
||||||
|
lines.push(`반드시 한국어로 답해. 200-400자 내외로 핵심만.`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
|
||||||
|
if (role === "manager") {
|
||||||
|
return `이 작업을 어떻게 분해할지, 어떤 팀(수석/선임/신입)을 어디에 배치할지 한 문단으로 결정해.`;
|
||||||
|
}
|
||||||
|
if (role === "principal") {
|
||||||
|
return `${STAGE_KOREAN[stage]} 단계에서 구체적으로 어떤 리스크가 있고, 어떻게 분해되어야 하는지 bullet 으로 제시해.`;
|
||||||
|
}
|
||||||
|
if (role === "lead") {
|
||||||
|
return `이 작업을 신입에게 어떻게 나눠줄지, 검증 포인트는 무엇인지 bullet 으로 정리해.`;
|
||||||
|
}
|
||||||
|
// junior
|
||||||
|
if (stage === "plan") {
|
||||||
|
return `이 프로젝트의 핵심 plan 을 마크다운 bullet 형식으로 작성해.`;
|
||||||
|
}
|
||||||
|
if (stage === "implement") {
|
||||||
|
return `요구된 코드/파일/내용을 그대로 작성해. 코드면 코드 블록으로.`;
|
||||||
|
}
|
||||||
|
if (stage === "review") {
|
||||||
|
return `위 결과물을 평가하고 APPROVE 또는 REQUEST_CHANGES 로 시작해서 이유를 한 문단.`;
|
||||||
|
}
|
||||||
|
if (stage === "deploy") {
|
||||||
|
return `이 결과물을 어떻게 배포 검증할지 짧게 설명하고 마지막 줄에 "DEPLOY_DONE" 또는 "DEPLOY_FAILED" 표기.`;
|
||||||
|
}
|
||||||
|
return `결과를 명확히 제출해.`;
|
||||||
|
}
|
||||||
60
sister-agent/src/rails-client.ts
Normal file
60
sister-agent/src/rails-client.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { SubTaskRecord } from "./types.js";
|
||||||
|
|
||||||
|
export class RailsClient {
|
||||||
|
constructor(private readonly baseUrl: string) {}
|
||||||
|
|
||||||
|
async createSubTask(record: SubTaskRecord): Promise<void> {
|
||||||
|
await this.request("POST", "/api/sub-tasks", record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordEvent(
|
||||||
|
subTaskId: string,
|
||||||
|
eventType: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
"POST",
|
||||||
|
`/api/sub-tasks/${subTaskId}/events`,
|
||||||
|
{ eventType, payload },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchSubTask(
|
||||||
|
id: string,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.request("PATCH", `/api/sub-tasks/${id}`, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`rails API ${method} ${path} → ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
return await res.text();
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
sister-agent/src/roles.ts
Normal file
42
sister-agent/src/roles.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Role } from "./types.js";
|
||||||
|
|
||||||
|
export interface RoleConfig {
|
||||||
|
primaryModel: string;
|
||||||
|
fallbackModel: string;
|
||||||
|
canSpawn: Role[];
|
||||||
|
maxSpawnPerCall: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLES: Record<Role, RoleConfig> = {
|
||||||
|
manager: {
|
||||||
|
primaryModel: "gpt-5.4",
|
||||||
|
fallbackModel: "glm-5.1",
|
||||||
|
canSpawn: ["principal", "lead", "junior"],
|
||||||
|
maxSpawnPerCall: 4,
|
||||||
|
},
|
||||||
|
principal: {
|
||||||
|
primaryModel: "gpt-5.4",
|
||||||
|
fallbackModel: "glm-5.1",
|
||||||
|
canSpawn: ["lead", "junior"],
|
||||||
|
maxSpawnPerCall: 3,
|
||||||
|
},
|
||||||
|
lead: {
|
||||||
|
primaryModel: "gpt-codex-5.3",
|
||||||
|
fallbackModel: "glm-5",
|
||||||
|
canSpawn: ["junior"],
|
||||||
|
maxSpawnPerCall: 4,
|
||||||
|
},
|
||||||
|
junior: {
|
||||||
|
primaryModel: "glm-5-turbo",
|
||||||
|
fallbackModel: "gpt-5",
|
||||||
|
canSpawn: [],
|
||||||
|
maxSpawnPerCall: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_KOREAN: Record<Role, string> = {
|
||||||
|
manager: "부장",
|
||||||
|
principal: "수석",
|
||||||
|
lead: "선임",
|
||||||
|
junior: "신입",
|
||||||
|
};
|
||||||
119
sister-agent/src/server.ts
Normal file
119
sister-agent/src/server.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import { InvokeRequest } from "./types.js";
|
||||||
|
import { executeInvocation } from "./spawn.js";
|
||||||
|
import { RailsClient } from "./rails-client.js";
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env["SISTER_AGENT_PORT"] ?? "18801", 10);
|
||||||
|
const AGENT_NAME = process.env["SISTER_AGENT_NAME"] ?? "unknown";
|
||||||
|
|
||||||
|
const log = (level: string, msg: string, meta?: Record<string, unknown>): void => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
agent: AGENT_NAME,
|
||||||
|
msg,
|
||||||
|
...meta,
|
||||||
|
});
|
||||||
|
if (level === "error") console.error(line);
|
||||||
|
else console.log(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolveFn, rejectFn) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!body) return resolveFn({});
|
||||||
|
try {
|
||||||
|
resolveFn(JSON.parse(body));
|
||||||
|
} catch (err) {
|
||||||
|
rejectFn(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", rejectFn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = req.method ?? "GET";
|
||||||
|
|
||||||
|
log("info", "request", { method, path });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (method === "GET" && path === "/health") {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
service: "sister-agent",
|
||||||
|
agent: AGENT_NAME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && path === "/invoke") {
|
||||||
|
const body = await readJson(req);
|
||||||
|
const parsed = InvokeRequest.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "invalid_invoke",
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force agentName to this sister's identity (env), not whatever rails sent.
|
||||||
|
// The stage info is preserved separately in parsed.data.stage.
|
||||||
|
const req2 = { ...parsed.data, agentName: AGENT_NAME };
|
||||||
|
const railsClient = new RailsClient(req2.railsApiUrl);
|
||||||
|
|
||||||
|
log("info", "invoke.start", {
|
||||||
|
pipelineId: req2.pipelineId,
|
||||||
|
stage: req2.stage,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeInvocation(req2, railsClient);
|
||||||
|
log("info", "invoke.done", {
|
||||||
|
pipelineId: req2.pipelineId,
|
||||||
|
stage: req2.stage,
|
||||||
|
verdict: "verdict" in result ? result.verdict : "?",
|
||||||
|
});
|
||||||
|
return sendJson(res, 200, result);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
log("error", "invoke.error", {
|
||||||
|
pipelineId: req2.pipelineId,
|
||||||
|
stage: req2.stage,
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
return sendJson(res, 500, { error: "invocation_failed", message: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
|
} catch (err) {
|
||||||
|
log("error", "request.error", {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
return sendJson(res, 500, { error: "internal_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
|
log("info", "sister-agent listening", { port: PORT, agent: AGENT_NAME });
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = (signal: string): void => {
|
||||||
|
log("info", "shutdown", { signal });
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
402
sister-agent/src/spawn.ts
Normal file
402
sister-agent/src/spawn.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { ulid } from "ulid";
|
||||||
|
import type { Role, SubTaskRecord, InvokeRequest, HandoffMessage } from "./types.js";
|
||||||
|
import { ROLES } from "./roles.js";
|
||||||
|
import { scoreComplexity, type ComplexityScore } from "./complexity.js";
|
||||||
|
import { planDecomposition, type DecompositionPlan } from "./planner.js";
|
||||||
|
import type { RailsClient } from "./rails-client.js";
|
||||||
|
import { callLlm } from "./llm.js";
|
||||||
|
import { buildPrompt } from "./prompts.js";
|
||||||
|
|
||||||
|
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an invocation using the hierarchical team strategy.
|
||||||
|
*
|
||||||
|
* Current implementation is a **simulation-only** executor: it creates
|
||||||
|
* the full sub-task tree in rails DB and streams events, but does not
|
||||||
|
* actually call LLMs. This gives us the full observable hierarchy without
|
||||||
|
* requiring openclaw CLI integration to be wired up yet.
|
||||||
|
*
|
||||||
|
* Swap in real LLM calls by replacing executeRole().
|
||||||
|
*/
|
||||||
|
export async function executeInvocation(
|
||||||
|
req: InvokeRequest,
|
||||||
|
rails: RailsClient,
|
||||||
|
): Promise<HandoffMessage> {
|
||||||
|
const agentName = req.agentName || req.stage;
|
||||||
|
|
||||||
|
// Step 1: score complexity
|
||||||
|
const complexity = scoreComplexity(req.task);
|
||||||
|
|
||||||
|
// Step 2: plan decomposition
|
||||||
|
const plan = planDecomposition(complexity);
|
||||||
|
|
||||||
|
// Step 3: create the manager (root) sub-task
|
||||||
|
const managerId = ulid();
|
||||||
|
const managerRecord: SubTaskRecord = {
|
||||||
|
id: managerId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: null,
|
||||||
|
role: "manager",
|
||||||
|
agentName,
|
||||||
|
title: req.task.title,
|
||||||
|
description: req.task.description,
|
||||||
|
complexityScore: complexity.score,
|
||||||
|
complexityTier: complexity.tier,
|
||||||
|
model: ROLES.manager.primaryModel,
|
||||||
|
};
|
||||||
|
await rails.createSubTask(managerRecord);
|
||||||
|
await rails.recordEvent(managerId, "spawned", {
|
||||||
|
by: "sister-agent",
|
||||||
|
tier: complexity.tier,
|
||||||
|
score: complexity.score,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(managerId, "started", {
|
||||||
|
strategy: plan.strategy,
|
||||||
|
nodeCount: countPlanNodes(plan),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: execute the plan recursively
|
||||||
|
try {
|
||||||
|
const result = await executeRole(
|
||||||
|
"manager",
|
||||||
|
managerId,
|
||||||
|
req,
|
||||||
|
plan,
|
||||||
|
complexity,
|
||||||
|
rails,
|
||||||
|
agentName,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await rails.recordEvent(managerId, "completed", {
|
||||||
|
verdict: result.verdict,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errorReason = err instanceof Error ? err.message : String(err);
|
||||||
|
await rails.recordEvent(managerId, "failed", { errorReason });
|
||||||
|
return buildErrorResult(req.stage, errorReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single role node (recursive).
|
||||||
|
* Spawns children if the plan calls for it, aggregates their results.
|
||||||
|
*/
|
||||||
|
async function executeRole(
|
||||||
|
role: Role,
|
||||||
|
selfId: string,
|
||||||
|
req: InvokeRequest,
|
||||||
|
plan: DecompositionPlan,
|
||||||
|
complexity: ComplexityScore,
|
||||||
|
rails: RailsClient,
|
||||||
|
agentName: string,
|
||||||
|
depth: number,
|
||||||
|
): Promise<HandoffMessage> {
|
||||||
|
// If no children planned for this role, execute directly
|
||||||
|
const hasChildren =
|
||||||
|
depth === 0 && plan.spawn.length > 0 && plan.strategy !== "direct";
|
||||||
|
|
||||||
|
// Manager executes its own decision/judgment first
|
||||||
|
const managerWork = await doWork({
|
||||||
|
role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: req.task.title,
|
||||||
|
taskDescription: req.task.description,
|
||||||
|
});
|
||||||
|
await persistResult(rails, selfId, managerWork);
|
||||||
|
|
||||||
|
if (!hasChildren) {
|
||||||
|
return buildSuccessResult(req.stage, req.task, managerWork.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate texts from all children to feed back into the final result
|
||||||
|
const childTexts: string[] = [managerWork.text];
|
||||||
|
|
||||||
|
// Spawn children per plan
|
||||||
|
for (const spawnPlan of plan.spawn) {
|
||||||
|
for (let i = 0; i < spawnPlan.count; i++) {
|
||||||
|
const childId = ulid();
|
||||||
|
const childTitle = `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`;
|
||||||
|
await rails.createSubTask({
|
||||||
|
id: childId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: selfId,
|
||||||
|
role: spawnPlan.role,
|
||||||
|
agentName,
|
||||||
|
title: childTitle,
|
||||||
|
description: spawnPlan.rationale,
|
||||||
|
complexityScore: null,
|
||||||
|
complexityTier: null,
|
||||||
|
model: ROLES[spawnPlan.role].primaryModel,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(childId, "spawned", { parent: selfId, role: spawnPlan.role });
|
||||||
|
await rails.recordEvent(childId, "started", {});
|
||||||
|
|
||||||
|
let childWorkText = "";
|
||||||
|
|
||||||
|
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
|
||||||
|
// Principal-level: do its own assessment then spawn leads
|
||||||
|
const principalWork = await doWork({
|
||||||
|
role: spawnPlan.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: childTitle,
|
||||||
|
taskDescription: spawnPlan.rationale,
|
||||||
|
parentTitle: req.task.title,
|
||||||
|
prevStageOutput: managerWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, childId, principalWork);
|
||||||
|
childWorkText = principalWork.text;
|
||||||
|
|
||||||
|
for (const grandSpawn of spawnPlan.subBreakdown) {
|
||||||
|
for (let j = 0; j < grandSpawn.count; j++) {
|
||||||
|
const grandId = ulid();
|
||||||
|
const grandTitle = `${grandSpawn.role}-${j + 1}`;
|
||||||
|
await rails.createSubTask({
|
||||||
|
id: grandId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: childId,
|
||||||
|
role: grandSpawn.role,
|
||||||
|
agentName,
|
||||||
|
title: grandTitle,
|
||||||
|
description: grandSpawn.rationale,
|
||||||
|
complexityScore: null,
|
||||||
|
complexityTier: null,
|
||||||
|
model: ROLES[grandSpawn.role].primaryModel,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(grandId, "spawned", { parent: childId, role: grandSpawn.role });
|
||||||
|
await rails.recordEvent(grandId, "started", {});
|
||||||
|
|
||||||
|
if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) {
|
||||||
|
// Lead does its own work then spawns juniors
|
||||||
|
const leadWork = await doWork({
|
||||||
|
role: grandSpawn.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: grandTitle,
|
||||||
|
taskDescription: grandSpawn.rationale,
|
||||||
|
parentTitle: childTitle,
|
||||||
|
prevStageOutput: principalWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, grandId, leadWork);
|
||||||
|
|
||||||
|
for (const ggSpawn of grandSpawn.subBreakdown) {
|
||||||
|
for (let k = 0; k < ggSpawn.count; k++) {
|
||||||
|
const ggId = ulid();
|
||||||
|
const ggTitle = `${ggSpawn.role}-${k + 1}`;
|
||||||
|
await rails.createSubTask({
|
||||||
|
id: ggId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: grandId,
|
||||||
|
role: ggSpawn.role,
|
||||||
|
agentName,
|
||||||
|
title: ggTitle,
|
||||||
|
description: ggSpawn.rationale,
|
||||||
|
complexityScore: null,
|
||||||
|
complexityTier: null,
|
||||||
|
model: ROLES[ggSpawn.role].primaryModel,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(ggId, "spawned", { parent: grandId, role: ggSpawn.role });
|
||||||
|
await rails.recordEvent(ggId, "started", {});
|
||||||
|
|
||||||
|
const juniorWork = await doWork({
|
||||||
|
role: ggSpawn.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: ggTitle,
|
||||||
|
taskDescription: ggSpawn.rationale,
|
||||||
|
parentTitle: grandTitle,
|
||||||
|
prevStageOutput: leadWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, ggId, juniorWork);
|
||||||
|
await rails.recordEvent(ggId, "completed", { ok: juniorWork.ok });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// grandSpawn is a leaf (junior or lead acting alone)
|
||||||
|
const leafWork = await doWork({
|
||||||
|
role: grandSpawn.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: grandTitle,
|
||||||
|
taskDescription: grandSpawn.rationale,
|
||||||
|
parentTitle: childTitle,
|
||||||
|
prevStageOutput: principalWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, grandId, leafWork);
|
||||||
|
}
|
||||||
|
await rails.recordEvent(grandId, "completed", { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct child is leaf — execute work and store
|
||||||
|
const leafWork = await doWork({
|
||||||
|
role: spawnPlan.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: childTitle,
|
||||||
|
taskDescription: spawnPlan.rationale,
|
||||||
|
parentTitle: req.task.title,
|
||||||
|
prevStageOutput: managerWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, childId, leafWork);
|
||||||
|
childWorkText = leafWork.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
childTexts.push(childWorkText);
|
||||||
|
await rails.recordEvent(childId, "completed", { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void complexity;
|
||||||
|
return buildSuccessResult(
|
||||||
|
req.stage,
|
||||||
|
req.task,
|
||||||
|
childTexts.filter(Boolean).join("\n\n---\n\n").slice(0, 6000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistResult(
|
||||||
|
rails: RailsClient,
|
||||||
|
subTaskId: string,
|
||||||
|
work: { ok: boolean; text: string; error?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rails.patchSubTask(subTaskId, {
|
||||||
|
resultJson: JSON.stringify({ text: work.text, ok: work.ok }),
|
||||||
|
...(work.error && { errorReason: work.error }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run actual work for a node. Calls openclaw infer model run if RAILS_USE_REAL_LLM
|
||||||
|
* is enabled (default). Falls back to short sleep if disabled.
|
||||||
|
*
|
||||||
|
* Returns the LLM text output (or empty if simulation).
|
||||||
|
*/
|
||||||
|
async function doWork(args: {
|
||||||
|
role: Role;
|
||||||
|
agentName: string;
|
||||||
|
stage: InvokeRequest["stage"];
|
||||||
|
taskTitle: string;
|
||||||
|
taskDescription: string;
|
||||||
|
prevStageOutput?: string;
|
||||||
|
parentTitle?: string;
|
||||||
|
}): Promise<{ ok: boolean; text: string; error?: string }> {
|
||||||
|
if (!USE_REAL_LLM) {
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
return { ok: true, text: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
role: args.role,
|
||||||
|
agentName: args.agentName,
|
||||||
|
stage: args.stage,
|
||||||
|
taskTitle: args.taskTitle,
|
||||||
|
taskDescription: args.taskDescription,
|
||||||
|
...(args.prevStageOutput !== undefined && { prevStageOutput: args.prevStageOutput }),
|
||||||
|
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callLlm({
|
||||||
|
prompt,
|
||||||
|
timeoutMs: 90_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { ok: false, text: "", error: result.errorMessage ?? "unknown LLM error" };
|
||||||
|
}
|
||||||
|
return { ok: true, text: result.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuccessResult(
|
||||||
|
stage: InvokeRequest["stage"],
|
||||||
|
task: InvokeRequest["task"],
|
||||||
|
outputText?: string,
|
||||||
|
): HandoffMessage {
|
||||||
|
// Pack the LLM output into selfTestReport / verificationResults so the
|
||||||
|
// next stage can read it via the handoff payload.
|
||||||
|
void outputText;
|
||||||
|
switch (stage) {
|
||||||
|
case "plan":
|
||||||
|
return {
|
||||||
|
stage: "plan",
|
||||||
|
verdict: "PLAN_READY",
|
||||||
|
payload: {
|
||||||
|
planDir: ".plans",
|
||||||
|
sprintId: "SPRINT-AUTO",
|
||||||
|
contractId: "",
|
||||||
|
},
|
||||||
|
abortReason: "",
|
||||||
|
};
|
||||||
|
case "implement":
|
||||||
|
return {
|
||||||
|
stage: "implement",
|
||||||
|
verdict: "IMPL_DONE",
|
||||||
|
payload: {
|
||||||
|
branch: "feature/sister-agent",
|
||||||
|
commits: ["simulated"],
|
||||||
|
workdir: task.workdir || "",
|
||||||
|
selfTestReport: { simulated: true },
|
||||||
|
},
|
||||||
|
errorReason: "",
|
||||||
|
};
|
||||||
|
case "review":
|
||||||
|
return {
|
||||||
|
stage: "review",
|
||||||
|
verdict: "APPROVE",
|
||||||
|
payload: {
|
||||||
|
artifactPath: "",
|
||||||
|
checklistResults: [],
|
||||||
|
issues: [],
|
||||||
|
},
|
||||||
|
abortReason: "",
|
||||||
|
};
|
||||||
|
case "deploy":
|
||||||
|
return {
|
||||||
|
stage: "deploy",
|
||||||
|
verdict: "DEPLOY_DONE",
|
||||||
|
payload: {
|
||||||
|
deployArtifactPath: "",
|
||||||
|
projectType: "simulated",
|
||||||
|
verificationResults: {},
|
||||||
|
},
|
||||||
|
errorReason: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildErrorResult(
|
||||||
|
stage: InvokeRequest["stage"],
|
||||||
|
reason: string,
|
||||||
|
): HandoffMessage {
|
||||||
|
switch (stage) {
|
||||||
|
case "plan":
|
||||||
|
return { stage: "plan", verdict: "ABORT", abortReason: reason };
|
||||||
|
case "implement":
|
||||||
|
return { stage: "implement", verdict: "ERROR", errorReason: reason };
|
||||||
|
case "review":
|
||||||
|
return { stage: "review", verdict: "ABORT", abortReason: reason };
|
||||||
|
case "deploy":
|
||||||
|
return { stage: "deploy", verdict: "DEPLOY_FAILED", errorReason: reason };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPlanNodes(plan: DecompositionPlan): number {
|
||||||
|
const inner = (spawns: DecompositionPlan["spawn"]): number => {
|
||||||
|
let total = 0;
|
||||||
|
for (const s of spawns) {
|
||||||
|
total += s.count;
|
||||||
|
if (s.subBreakdown) total += s.count * inner(s.subBreakdown);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
return 1 + inner(plan.spawn);
|
||||||
|
}
|
||||||
89
sister-agent/src/types.ts
Normal file
89
sister-agent/src/types.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ── Roles ──
|
||||||
|
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
|
||||||
|
export type Role = z.infer<typeof Role>;
|
||||||
|
|
||||||
|
// ── Incoming invoke from rails ──
|
||||||
|
export const InvokeRequest = z.object({
|
||||||
|
pipelineId: z.string(),
|
||||||
|
contractId: z.string().default(""),
|
||||||
|
stage: z.enum(["plan", "implement", "review", "deploy"]),
|
||||||
|
task: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().default(""),
|
||||||
|
workdir: z.string().default(""),
|
||||||
|
}),
|
||||||
|
timeoutMs: z.number().int().positive().default(600_000),
|
||||||
|
railsApiUrl: z.string().url(),
|
||||||
|
agentName: z.string().default(""),
|
||||||
|
});
|
||||||
|
export type InvokeRequest = z.infer<typeof InvokeRequest>;
|
||||||
|
|
||||||
|
// ── HandoffMessage sent back to rails ──
|
||||||
|
export const HandoffMessage = z.discriminatedUnion("stage", [
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("plan"),
|
||||||
|
verdict: z.enum(["PLAN_READY", "ABORT"]),
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
planDir: z.string(),
|
||||||
|
sprintId: z.string(),
|
||||||
|
contractId: z.string().default(""),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
abortReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("implement"),
|
||||||
|
verdict: z.enum(["IMPL_DONE", "ERROR"]),
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
branch: z.string(),
|
||||||
|
commits: z.array(z.string()),
|
||||||
|
workdir: z.string().default(""),
|
||||||
|
selfTestReport: z.record(z.unknown()).default({}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
errorReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("review"),
|
||||||
|
verdict: z.enum(["APPROVE", "REQUEST_CHANGES", "ABORT"]),
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
artifactPath: z.string().default(""),
|
||||||
|
checklistResults: z.array(z.unknown()).default([]),
|
||||||
|
issues: z.array(z.unknown()).default([]),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
abortReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("deploy"),
|
||||||
|
verdict: z.enum(["DEPLOY_DONE", "DEPLOY_FAILED"]),
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
deployArtifactPath: z.string().default(""),
|
||||||
|
projectType: z.string().default(""),
|
||||||
|
verificationResults: z.record(z.unknown()).default({}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
errorReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
export type HandoffMessage = z.infer<typeof HandoffMessage>;
|
||||||
|
|
||||||
|
// ── SubTask registration (sent TO rails) ──
|
||||||
|
export interface SubTaskRecord {
|
||||||
|
id: string;
|
||||||
|
pipelineId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
role: Role;
|
||||||
|
agentName: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
complexityScore: number | null;
|
||||||
|
complexityTier: string | null;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
23
sister-agent/tsconfig.json
Normal file
23
sister-agent/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -1,28 +1,72 @@
|
|||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import { loadEnv } from "../env.js";
|
import { loadEnv } from "../env.js";
|
||||||
import { getLogger } from "../logger.js";
|
import { getLogger } from "../logger.js";
|
||||||
|
import { startHttpServer } from "../server/http.js";
|
||||||
|
import { disconnectPrisma } from "../orchestrator/persist.js";
|
||||||
|
|
||||||
export default defineCommand({
|
export default defineCommand({
|
||||||
meta: {
|
meta: {
|
||||||
name: "serve",
|
name: "serve",
|
||||||
description: "Start the Rails orchestrator server (webhook + Discord bot)",
|
description: "Start the Rails orchestrator HTTP server",
|
||||||
},
|
},
|
||||||
async run() {
|
args: {
|
||||||
|
port: {
|
||||||
|
type: "string",
|
||||||
|
alias: "p",
|
||||||
|
description: "HTTP port",
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
type: "string",
|
||||||
|
alias: "H",
|
||||||
|
description: "Bind host",
|
||||||
|
default: "0.0.0.0",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: "string",
|
||||||
|
alias: "c",
|
||||||
|
description: "Path to rails.config.yaml",
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
const log = getLogger();
|
const log = getLogger();
|
||||||
|
|
||||||
|
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
|
||||||
|
|
||||||
|
const { url, close } = await startHttpServer({
|
||||||
|
port,
|
||||||
|
host: args.host ?? "0.0.0.0",
|
||||||
|
...(args.config && { configPath: args.config }),
|
||||||
|
});
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV },
|
{ url, nodeEnv: env.NODE_ENV },
|
||||||
"hanarang-rails starting",
|
"hanarang-rails server ready",
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO (Sprint 004): Discord bot initialization
|
// Graceful shutdown
|
||||||
// TODO (Sprint 004): Gitea webhook HTTP server
|
const shutdown = async (signal: string) => {
|
||||||
// For now, just keep the process alive
|
log.info({ signal }, "Shutdown requested");
|
||||||
log.info("Orchestrator running. Press Ctrl+C to stop.");
|
try {
|
||||||
|
await close();
|
||||||
|
await disconnectPrisma();
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Shutdown error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
// Keep alive
|
||||||
await new Promise<never>(() => {
|
await new Promise<never>(() => {
|
||||||
// keep alive until signal
|
/* block until signal */
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
83
src/handoff/build.ts
Normal file
83
src/handoff/build.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { SisterTransport } from "./transport.js";
|
||||||
|
import { MockTransport } from "./mock-transport.js";
|
||||||
|
import { HttpTransport } from "./http-transport.js";
|
||||||
|
import type { RailsConfig } from "../config/schema.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "transport-builder" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stage → transport map from rails config + environment.
|
||||||
|
*
|
||||||
|
* Environment overrides (convenient for testing):
|
||||||
|
* RAILS_TRANSPORT_MODE=mock|http|auto (default auto — use config)
|
||||||
|
* RAILS_API_URL=http://10.10.10.169:18800 (used as rails callback URL for sub-tasks)
|
||||||
|
* RAILS_AGENT_{STAGE}_HOST=10.10.10.112 (override agent host)
|
||||||
|
* RAILS_AGENT_{STAGE}_PORT=18801 (override agent port)
|
||||||
|
*/
|
||||||
|
export function buildTransports(config: RailsConfig): Map<string, SisterTransport> {
|
||||||
|
const transports = new Map<string, SisterTransport>();
|
||||||
|
const mode = process.env["RAILS_TRANSPORT_MODE"] ?? "auto";
|
||||||
|
const railsApiUrl =
|
||||||
|
process.env["RAILS_API_URL"] ?? "http://127.0.0.1:18800";
|
||||||
|
|
||||||
|
const sharedMock = new MockTransport();
|
||||||
|
|
||||||
|
for (const stage of config.pipeline.stages) {
|
||||||
|
const agentConfig = config.agents[stage];
|
||||||
|
|
||||||
|
// Explicit mode override
|
||||||
|
if (mode === "mock") {
|
||||||
|
transports.set(stage, sharedMock);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine desired transport
|
||||||
|
const desired = mode === "http" ? "http" : agentConfig?.transport ?? "mock";
|
||||||
|
|
||||||
|
if (desired === "mock") {
|
||||||
|
transports.set(stage, sharedMock);
|
||||||
|
log.info({ stage, transport: "mock" }, "transport wired");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desired === "http") {
|
||||||
|
const hostEnv = `RAILS_AGENT_${stage.toUpperCase()}_HOST`;
|
||||||
|
const portEnv = `RAILS_AGENT_${stage.toUpperCase()}_PORT`;
|
||||||
|
const host = process.env[hostEnv];
|
||||||
|
const port = process.env[portEnv] ?? "18801";
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
log.warn(
|
||||||
|
{ stage, missing: hostEnv },
|
||||||
|
"HTTP transport requested but host env missing — falling back to mock",
|
||||||
|
);
|
||||||
|
transports.set(stage, sharedMock);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `http://${host}:${port}`;
|
||||||
|
const agentName = agentConfig?.role ?? stage;
|
||||||
|
transports.set(
|
||||||
|
stage,
|
||||||
|
new HttpTransport({
|
||||||
|
agentName,
|
||||||
|
endpoint,
|
||||||
|
railsApiUrl,
|
||||||
|
timeoutMs: agentConfig?.timeoutMs ?? 600_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
log.info(
|
||||||
|
{ stage, transport: "http", endpoint, agentName },
|
||||||
|
"transport wired",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown — fall back to mock
|
||||||
|
transports.set(stage, sharedMock);
|
||||||
|
log.warn({ stage, desired }, "unknown transport, using mock");
|
||||||
|
}
|
||||||
|
|
||||||
|
return transports;
|
||||||
|
}
|
||||||
109
src/handoff/http-transport.ts
Normal file
109
src/handoff/http-transport.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { SisterTransport, HealthStatus } from "./transport.js";
|
||||||
|
import { HandoffMessage, type InvokeRequest } from "./message.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "http-transport" });
|
||||||
|
|
||||||
|
export interface HttpTransportOptions {
|
||||||
|
agentName: string; // e.g. "harang"
|
||||||
|
endpoint: string; // e.g. "http://10.10.10.112:18801"
|
||||||
|
railsApiUrl: string; // callback URL for sub-task events
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real HTTP transport — calls a sister-agent daemon on a remote LXC.
|
||||||
|
* The sister-agent runs the hierarchical sub-agent team and reports
|
||||||
|
* sub-task events back via railsApiUrl.
|
||||||
|
*/
|
||||||
|
export class HttpTransport implements SisterTransport {
|
||||||
|
readonly name: string;
|
||||||
|
private readonly opts: Required<HttpTransportOptions>;
|
||||||
|
|
||||||
|
constructor(opts: HttpTransportOptions) {
|
||||||
|
this.name = `http:${opts.agentName}`;
|
||||||
|
this.opts = {
|
||||||
|
agentName: opts.agentName,
|
||||||
|
endpoint: opts.endpoint,
|
||||||
|
railsApiUrl: opts.railsApiUrl,
|
||||||
|
timeoutMs: opts.timeoutMs ?? 600_000, // 10 min default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoke(
|
||||||
|
req: InvokeRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<HandoffMessage> {
|
||||||
|
const url = `${this.opts.endpoint}/invoke`;
|
||||||
|
const payload = {
|
||||||
|
...req,
|
||||||
|
agentName: this.opts.agentName,
|
||||||
|
railsApiUrl: this.opts.railsApiUrl,
|
||||||
|
timeoutMs: req.timeoutMs || this.opts.timeoutMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ endpoint: url, stage: req.stage, pipelineId: req.pipelineId },
|
||||||
|
"HTTP invoke start",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local timeout controller merged with caller signal
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onAbort = (): void => controller.abort();
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) controller.abort();
|
||||||
|
else signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => controller.abort(), payload.timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`sister-agent ${url} returned ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as unknown;
|
||||||
|
return HandoffMessage.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (signal) signal.removeEventListener("abort", onAbort);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(): Promise<HealthStatus> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const res = await fetch(`${this.opts.endpoint}/health`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
return {
|
||||||
|
alive: res.ok,
|
||||||
|
latencyMs,
|
||||||
|
message: res.ok ? "ok" : `status ${res.status}`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
alive: false,
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
// HTTP client is stateless; no cleanup needed
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/hierarchy/complexity.ts
Normal file
173
src/hierarchy/complexity.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ComplexityTier = z.enum([
|
||||||
|
"trivial",
|
||||||
|
"simple",
|
||||||
|
"moderate",
|
||||||
|
"complex",
|
||||||
|
"massive",
|
||||||
|
]);
|
||||||
|
export type ComplexityTier = z.infer<typeof ComplexityTier>;
|
||||||
|
|
||||||
|
export interface ComplexityScore {
|
||||||
|
score: number; // 0-100
|
||||||
|
tier: ComplexityTier;
|
||||||
|
factors: {
|
||||||
|
scopeScale: number;
|
||||||
|
multiDomain: number;
|
||||||
|
riskKeywords: number;
|
||||||
|
parallelismHints: number;
|
||||||
|
uncertainty: number;
|
||||||
|
estimatedLoc: number;
|
||||||
|
crossAgentDep: number;
|
||||||
|
};
|
||||||
|
matched: string[]; // matched keywords for transparency
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_RULES: Array<{ re: RegExp; score: number; label: string }> = [
|
||||||
|
{ re: /한\s*줄|single\s*line|one\s*liner/i, score: 2, label: "one-liner" },
|
||||||
|
{ re: /single\s*file|한\s*파일|단일\s*파일/i, score: 5, label: "single-file" },
|
||||||
|
{ re: /컴포넌트|component|small\s*feature|작은\s*기능/i, score: 12, label: "small-feature" },
|
||||||
|
{ re: /여러\s*파일|multiple\s*files|multi-?file|멀티\s*모듈/i, score: 25, label: "multi-file" },
|
||||||
|
{ re: /sprint|스프린트/i, score: 40, label: "sprint-scale" },
|
||||||
|
{ re: /전체\s*리팩터|full\s*refactor|architecture|아키텍처/i, score: 65, label: "architecture" },
|
||||||
|
{ re: /from\s*scratch|새로\s*만들|scaffold|새\s*프로젝트|new\s*project/i, score: 75, label: "scaffold" },
|
||||||
|
{ re: /monorepo|migration\s*project|전면\s*재작성/i, score: 85, label: "massive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOMAINS = [
|
||||||
|
"frontend", "front-end", "프론트",
|
||||||
|
"backend", "back-end", "백엔드",
|
||||||
|
"database", "db", "prisma", "postgres", "mariadb", "mysql",
|
||||||
|
"infra", "deploy", "배포", "docker", "k8s", "kubernetes",
|
||||||
|
"ci", "cd", "github\\s*actions", "gitea",
|
||||||
|
"security", "auth", "인증", "oauth",
|
||||||
|
"test", "테스트", "vitest", "jest",
|
||||||
|
"api", "rest", "graphql",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RISK_KEYWORDS = [
|
||||||
|
"migration", "migrate", "마이그레이션",
|
||||||
|
"breaking", "breaking\\s*change", "호환\\s*안", "하위\\s*호환",
|
||||||
|
"security", "vulnerability", "취약점",
|
||||||
|
"auth", "authentication", "authorization",
|
||||||
|
"data\\s*loss", "데이터\\s*손실", "rollback",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PARALLELISM_HINTS = [
|
||||||
|
"multiple", "여러", "parallel", "병렬", "동시에", "simultaneously",
|
||||||
|
"bulk", "대량", "batch", "fanout",
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNCERTAINTY_MARKERS = [
|
||||||
|
"probably", "maybe", "might", "I\\s*think",
|
||||||
|
"아직\\s*모르", "아마", "잘\\s*모르", "애매",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOC_HINT = /(\d{2,})\s*(?:lines?|loc|줄)/i;
|
||||||
|
|
||||||
|
const CROSS_AGENT_HINTS = [
|
||||||
|
/plan.*implement|implement.*review|review.*deploy/i,
|
||||||
|
/기획.*구현|구현.*리뷰|리뷰.*배포/i,
|
||||||
|
/전체\s*(?:파이프라인|flow|흐름)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function countMatches(text: string, patterns: string[]): {
|
||||||
|
count: number;
|
||||||
|
matched: string[];
|
||||||
|
} {
|
||||||
|
const matched: string[] = [];
|
||||||
|
for (const p of patterns) {
|
||||||
|
const re = new RegExp(`\\b${p}\\b`, "i");
|
||||||
|
if (re.test(text)) matched.push(p);
|
||||||
|
}
|
||||||
|
return { count: matched.length, matched };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreComplexity(task: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}): ComplexityScore {
|
||||||
|
const text = `${task.title}\n${task.description ?? ""}`;
|
||||||
|
const matched: string[] = [];
|
||||||
|
|
||||||
|
// Scope scale — take the MAX matching rule
|
||||||
|
let scopeScale = 0;
|
||||||
|
for (const rule of SCOPE_RULES) {
|
||||||
|
if (rule.re.test(text)) {
|
||||||
|
if (rule.score > scopeScale) scopeScale = rule.score;
|
||||||
|
matched.push(`scope:${rule.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scopeScale === 0) scopeScale = 10; // unknown default
|
||||||
|
|
||||||
|
// Multi-domain
|
||||||
|
const { count: domainCount, matched: domainMatched } = countMatches(text, DOMAINS);
|
||||||
|
const multiDomain = Math.min(domainCount * 5, 20);
|
||||||
|
matched.push(...domainMatched.map((d) => `domain:${d}`));
|
||||||
|
|
||||||
|
// Risk keywords
|
||||||
|
const { count: riskCount, matched: riskMatched } = countMatches(text, RISK_KEYWORDS);
|
||||||
|
const riskKeywords = Math.min(riskCount * 10, 30);
|
||||||
|
matched.push(...riskMatched.map((r) => `risk:${r}`));
|
||||||
|
|
||||||
|
// Parallelism hints
|
||||||
|
const { count: parCount, matched: parMatched } = countMatches(text, PARALLELISM_HINTS);
|
||||||
|
const parallelismHints = Math.min(parCount * 5, 15);
|
||||||
|
matched.push(...parMatched.map((p) => `parallel:${p}`));
|
||||||
|
|
||||||
|
// Uncertainty
|
||||||
|
const { count: uncertainCount } = countMatches(text, UNCERTAINTY_MARKERS);
|
||||||
|
const uncertainty = uncertainCount > 0 ? 10 : 0;
|
||||||
|
if (uncertainty) matched.push("uncertainty");
|
||||||
|
|
||||||
|
// Estimated LOC
|
||||||
|
const locMatch = text.match(LOC_HINT);
|
||||||
|
const loc = locMatch && locMatch[1] ? parseInt(locMatch[1], 10) : 0;
|
||||||
|
const estimatedLoc = loc > 500 ? 10 : 0;
|
||||||
|
if (estimatedLoc) matched.push(`loc:${loc}`);
|
||||||
|
|
||||||
|
// Cross-agent dep
|
||||||
|
let crossAgentDep = 0;
|
||||||
|
for (const re of CROSS_AGENT_HINTS) {
|
||||||
|
if (re.test(text)) {
|
||||||
|
crossAgentDep = 10;
|
||||||
|
matched.push("cross-agent");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Math.min(
|
||||||
|
100,
|
||||||
|
scopeScale +
|
||||||
|
multiDomain +
|
||||||
|
riskKeywords +
|
||||||
|
parallelismHints +
|
||||||
|
uncertainty +
|
||||||
|
estimatedLoc +
|
||||||
|
crossAgentDep,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
tier: tierFromScore(score),
|
||||||
|
factors: {
|
||||||
|
scopeScale,
|
||||||
|
multiDomain,
|
||||||
|
riskKeywords,
|
||||||
|
parallelismHints,
|
||||||
|
uncertainty,
|
||||||
|
estimatedLoc,
|
||||||
|
crossAgentDep,
|
||||||
|
},
|
||||||
|
matched,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierFromScore(score: number): ComplexityTier {
|
||||||
|
if (score <= 15) return "trivial";
|
||||||
|
if (score <= 30) return "simple";
|
||||||
|
if (score <= 50) return "moderate";
|
||||||
|
if (score <= 75) return "complex";
|
||||||
|
return "massive";
|
||||||
|
}
|
||||||
191
src/hierarchy/planner.ts
Normal file
191
src/hierarchy/planner.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import type { ComplexityScore, ComplexityTier } from "./complexity.js";
|
||||||
|
import type { Role } from "./roles.js";
|
||||||
|
|
||||||
|
export interface SpawnPlan {
|
||||||
|
role: Role;
|
||||||
|
count: number;
|
||||||
|
subBreakdown?: SpawnPlan[]; // nested hierarchy
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecompositionPlan {
|
||||||
|
tier: ComplexityTier;
|
||||||
|
score: number;
|
||||||
|
strategy:
|
||||||
|
| "direct" // manager executes directly, no spawn
|
||||||
|
| "single-junior" // 1 junior only
|
||||||
|
| "lead-team" // 1 lead + juniors
|
||||||
|
| "principal-team" // 1 principal + leads + juniors
|
||||||
|
| "fanout"; // massive — 2 principals in parallel
|
||||||
|
spawn: SpawnPlan[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan the team structure for a given complexity score.
|
||||||
|
* Deterministic — no LLM required.
|
||||||
|
*
|
||||||
|
* Manager can override this plan if LLM refinement is enabled.
|
||||||
|
*/
|
||||||
|
export function planDecomposition(complexity: ComplexityScore): DecompositionPlan {
|
||||||
|
const { score, tier } = complexity;
|
||||||
|
|
||||||
|
switch (tier) {
|
||||||
|
case "trivial":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "direct",
|
||||||
|
spawn: [],
|
||||||
|
notes: [
|
||||||
|
"Manager handles directly — no team needed for trivial tasks.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "simple":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "single-junior",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 1,
|
||||||
|
rationale: "Single junior handles the task directly.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "moderate":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "lead-team",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "lead",
|
||||||
|
count: 1,
|
||||||
|
rationale: "Lead coordinates 2 juniors for moderate scope.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two juniors execute parallel sub-tasks.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"Lead decides the exact sub-task split at runtime.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "complex":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "principal-team",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "principal",
|
||||||
|
count: 1,
|
||||||
|
rationale: "Principal handles architecture review + decomposition.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "lead",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two leads run parallel workstreams.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two juniors per lead.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"Principal may bypass lead and spawn juniors directly when bottleneck detected.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "massive":
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
score,
|
||||||
|
strategy: "fanout",
|
||||||
|
spawn: [
|
||||||
|
{
|
||||||
|
role: "principal",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Two principals split the work by domain (e.g., FE / BE).",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "lead",
|
||||||
|
count: 2,
|
||||||
|
rationale: "Each principal runs 2 parallel leads.",
|
||||||
|
subBreakdown: [
|
||||||
|
{
|
||||||
|
role: "junior",
|
||||||
|
count: 3,
|
||||||
|
rationale: "Three juniors per lead for massive throughput.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"Total worst-case: 2 principals + 4 leads + 12 juniors.",
|
||||||
|
"Manager monitors and rebalances on escalation.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count total nodes in a decomposition plan (for concurrency budgeting).
|
||||||
|
*/
|
||||||
|
export function countPlanNodes(plan: DecompositionPlan): number {
|
||||||
|
const count = (spawns: SpawnPlan[]): number => {
|
||||||
|
let total = 0;
|
||||||
|
for (const s of spawns) {
|
||||||
|
total += s.count;
|
||||||
|
if (s.subBreakdown) total += s.count * count(s.subBreakdown);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
// +1 for the manager itself
|
||||||
|
return 1 + count(plan.spawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a plan fits within concurrency budget.
|
||||||
|
* Returns a trimmed plan if over budget.
|
||||||
|
*/
|
||||||
|
export function enforceConcurrencyBudget(
|
||||||
|
plan: DecompositionPlan,
|
||||||
|
budget: number,
|
||||||
|
): DecompositionPlan {
|
||||||
|
const nodeCount = countPlanNodes(plan);
|
||||||
|
if (nodeCount <= budget) return plan;
|
||||||
|
|
||||||
|
// Over budget — trim sub-breakdowns
|
||||||
|
const trimmed = JSON.parse(JSON.stringify(plan)) as DecompositionPlan;
|
||||||
|
const trimFactor = budget / nodeCount;
|
||||||
|
|
||||||
|
const trim = (spawns: SpawnPlan[]): void => {
|
||||||
|
for (const s of spawns) {
|
||||||
|
s.count = Math.max(1, Math.floor(s.count * trimFactor));
|
||||||
|
if (s.subBreakdown) trim(s.subBreakdown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trim(trimmed.spawn);
|
||||||
|
trimmed.notes.push(
|
||||||
|
`Trimmed from ${nodeCount} → ${countPlanNodes(trimmed)} nodes to fit budget ${budget}.`,
|
||||||
|
);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
60
src/hierarchy/roles.ts
Normal file
60
src/hierarchy/roles.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
|
||||||
|
export type Role = z.infer<typeof Role>;
|
||||||
|
|
||||||
|
export const ROLE_KOREAN: Record<Role, string> = {
|
||||||
|
manager: "부장",
|
||||||
|
principal: "수석",
|
||||||
|
lead: "선임",
|
||||||
|
junior: "신입",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RoleConfig {
|
||||||
|
primaryModel: string;
|
||||||
|
fallbackModel: string;
|
||||||
|
canSpawn: Role[];
|
||||||
|
maxSpawnPerCall: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default role definitions. Can be overridden by roles.yaml in sister-agent.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_CONFIG: Record<Role, RoleConfig> = {
|
||||||
|
manager: {
|
||||||
|
primaryModel: "gpt-5.4",
|
||||||
|
fallbackModel: "glm-5.1",
|
||||||
|
canSpawn: ["principal", "lead", "junior"],
|
||||||
|
maxSpawnPerCall: 4,
|
||||||
|
},
|
||||||
|
principal: {
|
||||||
|
primaryModel: "gpt-5.4",
|
||||||
|
fallbackModel: "glm-5.1",
|
||||||
|
canSpawn: ["lead", "junior"],
|
||||||
|
maxSpawnPerCall: 3,
|
||||||
|
},
|
||||||
|
lead: {
|
||||||
|
primaryModel: "gpt-codex-5.3",
|
||||||
|
fallbackModel: "glm-5",
|
||||||
|
canSpawn: ["junior"],
|
||||||
|
maxSpawnPerCall: 4,
|
||||||
|
},
|
||||||
|
junior: {
|
||||||
|
primaryModel: "glm-5-turbo",
|
||||||
|
fallbackModel: "gpt-5",
|
||||||
|
canSpawn: [],
|
||||||
|
maxSpawnPerCall: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ConcurrencyLimits {
|
||||||
|
default: number;
|
||||||
|
overrides: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONCURRENCY: ConcurrencyLimits = {
|
||||||
|
default: 8,
|
||||||
|
overrides: {
|
||||||
|
narang: 6, // tighter when a build is running
|
||||||
|
},
|
||||||
|
};
|
||||||
234
src/hierarchy/store.ts
Normal file
234
src/hierarchy/store.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { getPrisma } from "../orchestrator/persist.js";
|
||||||
|
import { Role } from "./roles.js";
|
||||||
|
import { ComplexityTier } from "./complexity.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "sub-task-store" });
|
||||||
|
|
||||||
|
export const CreateSubTaskInput = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
pipelineId: z.string().min(1),
|
||||||
|
parentId: z.string().nullable().default(null),
|
||||||
|
role: Role,
|
||||||
|
agentName: z.string().min(1),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().default(""),
|
||||||
|
complexityScore: z.number().int().nullable().default(null),
|
||||||
|
complexityTier: ComplexityTier.nullable().default(null),
|
||||||
|
model: z.string().default(""),
|
||||||
|
});
|
||||||
|
export type CreateSubTaskInput = z.infer<typeof CreateSubTaskInput>;
|
||||||
|
|
||||||
|
export const SubTaskEventInput = z.object({
|
||||||
|
subTaskId: z.string().min(1),
|
||||||
|
eventType: z.enum([
|
||||||
|
"spawned",
|
||||||
|
"started",
|
||||||
|
"progress",
|
||||||
|
"output",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"escalated",
|
||||||
|
]),
|
||||||
|
payload: z.record(z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
export type SubTaskEventInput = z.infer<typeof SubTaskEventInput>;
|
||||||
|
|
||||||
|
export const UpdateSubTaskInput = z.object({
|
||||||
|
state: z
|
||||||
|
.enum(["queued", "running", "done", "failed", "escalated"])
|
||||||
|
.optional(),
|
||||||
|
resultJson: z.string().optional(),
|
||||||
|
errorReason: z.string().optional(),
|
||||||
|
startedAt: z.string().datetime().optional(),
|
||||||
|
completedAt: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateSubTaskInput = z.infer<typeof UpdateSubTaskInput>;
|
||||||
|
|
||||||
|
export async function createSubTask(input: CreateSubTaskInput): Promise<void> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.subTask.create({
|
||||||
|
data: {
|
||||||
|
id: input.id,
|
||||||
|
pipelineId: input.pipelineId,
|
||||||
|
parentId: input.parentId,
|
||||||
|
role: input.role,
|
||||||
|
agentName: input.agentName,
|
||||||
|
title: input.title.slice(0, 500),
|
||||||
|
description: input.description,
|
||||||
|
state: "queued",
|
||||||
|
complexityScore: input.complexityScore,
|
||||||
|
complexityTier: input.complexityTier,
|
||||||
|
model: input.model,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
id: input.id,
|
||||||
|
role: input.role,
|
||||||
|
agent: input.agentName,
|
||||||
|
parent: input.parentId,
|
||||||
|
},
|
||||||
|
"Sub-task created",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSubTask(
|
||||||
|
id: string,
|
||||||
|
patch: UpdateSubTaskInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.subTask.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...patch,
|
||||||
|
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
|
||||||
|
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordSubTaskEvent(
|
||||||
|
input: SubTaskEventInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.subTaskEvent.create({
|
||||||
|
data: {
|
||||||
|
subTaskId: input.subTaskId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
payloadJson: JSON.stringify(input.payload),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-advance state based on event type
|
||||||
|
const stateMap: Record<string, string | null> = {
|
||||||
|
started: "running",
|
||||||
|
completed: "done",
|
||||||
|
failed: "failed",
|
||||||
|
escalated: "escalated",
|
||||||
|
};
|
||||||
|
const newState = stateMap[input.eventType];
|
||||||
|
if (newState) {
|
||||||
|
const patch: UpdateSubTaskInput = { state: newState as UpdateSubTaskInput["state"] };
|
||||||
|
if (input.eventType === "started") {
|
||||||
|
patch.startedAt = new Date().toISOString();
|
||||||
|
} else if (["completed", "failed", "escalated"].includes(input.eventType)) {
|
||||||
|
patch.completedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await prisma.subTask.update({
|
||||||
|
where: { id: input.subTaskId },
|
||||||
|
data: {
|
||||||
|
...patch,
|
||||||
|
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
|
||||||
|
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(s: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubTaskDetail(id: string): Promise<unknown | null> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const node = await prisma.subTask.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
events: {
|
||||||
|
orderBy: { timestamp: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eventType: true,
|
||||||
|
payloadJson: true,
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
// Walk up parent chain
|
||||||
|
const parents: Array<{ id: string; role: string; title: string }> = [];
|
||||||
|
let cursor: string | null = node.parentId;
|
||||||
|
while (cursor) {
|
||||||
|
const p = await prisma.subTask.findUnique({
|
||||||
|
where: { id: cursor },
|
||||||
|
select: { id: true, parentId: true, role: true, title: true },
|
||||||
|
});
|
||||||
|
if (!p) break;
|
||||||
|
parents.unshift({ id: p.id, role: p.role, title: p.title });
|
||||||
|
cursor = p.parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct children list
|
||||||
|
const children = await prisma.subTask.findMany({
|
||||||
|
where: { parentId: id },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
agentName: true,
|
||||||
|
title: true,
|
||||||
|
state: true,
|
||||||
|
model: true,
|
||||||
|
startedAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
parents,
|
||||||
|
childrenList: children,
|
||||||
|
events: node.events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
eventType: e.eventType,
|
||||||
|
payload: tryParseJson(e.payloadJson),
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubTaskTree(pipelineId: string): Promise<unknown> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const all = await prisma.subTask.findMany({
|
||||||
|
where: { pipelineId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
role: true,
|
||||||
|
agentName: true,
|
||||||
|
title: true,
|
||||||
|
state: true,
|
||||||
|
complexityScore: true,
|
||||||
|
complexityTier: true,
|
||||||
|
model: true,
|
||||||
|
startedAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
const byId = new Map<string, { id: string; parentId: string | null; children: unknown[] } & Record<string, unknown>>();
|
||||||
|
for (const t of all) {
|
||||||
|
byId.set(t.id, { ...t, children: [] });
|
||||||
|
}
|
||||||
|
const roots: unknown[] = [];
|
||||||
|
for (const t of all) {
|
||||||
|
const node = byId.get(t.id)!;
|
||||||
|
if (t.parentId && byId.has(t.parentId)) {
|
||||||
|
(byId.get(t.parentId)!.children as unknown[]).push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { createActor, type Snapshot } from "xstate";
|
import { createActor } from "xstate";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
import { pipelineMachine } from "./machine.js";
|
import { pipelineMachine } from "./machine.js";
|
||||||
import { createInitialContext, type PipelineContext } from "./context.js";
|
import { createInitialContext, type PipelineContext } from "./context.js";
|
||||||
@@ -17,6 +17,13 @@ export function getPrisma(): PrismaClient {
|
|||||||
return _prisma;
|
return _prisma;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline persistence layout:
|
||||||
|
* - pipelines.currentState — XState state value (for quick queries)
|
||||||
|
* - pipelines.contextJson — FULL persisted snapshot JSON from XState v5
|
||||||
|
* (includes value, context, status, children, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
export async function createPipeline(
|
export async function createPipeline(
|
||||||
projectName: string,
|
projectName: string,
|
||||||
requirements: string,
|
requirements: string,
|
||||||
@@ -25,20 +32,27 @@ export async function createPipeline(
|
|||||||
const pipelineId = ulid();
|
const pipelineId = ulid();
|
||||||
const ctx = createInitialContext(pipelineId, projectName, requirements);
|
const ctx = createInitialContext(pipelineId, projectName, requirements);
|
||||||
|
|
||||||
const actor = createActor(pipelineMachine, {
|
const actor = createActor(pipelineMachine, { input: ctx });
|
||||||
input: ctx,
|
|
||||||
});
|
|
||||||
actor.start();
|
actor.start();
|
||||||
|
// Manually set pipelineId into context via assign on fresh start is awkward;
|
||||||
|
// just store the ctx alongside the persisted snapshot for later restoration.
|
||||||
|
const persistedSnapshot = actor.getPersistedSnapshot();
|
||||||
const snapshot = actor.getSnapshot();
|
const snapshot = actor.getSnapshot();
|
||||||
actor.stop();
|
actor.stop();
|
||||||
|
|
||||||
|
// Merge our pipelineId into the persisted context for recovery
|
||||||
|
const persistedWithId = mergeContextIntoSnapshot(
|
||||||
|
persistedSnapshot,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
await prisma.pipeline.create({
|
await prisma.pipeline.create({
|
||||||
data: {
|
data: {
|
||||||
id: pipelineId,
|
id: pipelineId,
|
||||||
projectName,
|
projectName,
|
||||||
requirements,
|
requirements,
|
||||||
currentState: String(snapshot.value),
|
currentState: String(snapshot.value),
|
||||||
contextJson: JSON.stringify(ctx),
|
contextJson: JSON.stringify(persistedWithId),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,21 +70,22 @@ export async function sendEvent(
|
|||||||
where: { id: pipelineId },
|
where: { id: pipelineId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
|
|
||||||
const fromState = pipeline.currentState;
|
const fromState = pipeline.currentState;
|
||||||
|
const persistedSnapshot = JSON.parse(pipeline.contextJson) as unknown;
|
||||||
|
|
||||||
|
// XState v5 accepts a persisted snapshot via the options object.
|
||||||
|
// We bypass the strict generic typing because the snapshot is produced
|
||||||
|
// by the same machine and serialised through JSON.
|
||||||
const actor = createActor(pipelineMachine, {
|
const actor = createActor(pipelineMachine, {
|
||||||
snapshot: {
|
snapshot: persistedSnapshot,
|
||||||
value: fromState,
|
} as Parameters<typeof createActor>[1]);
|
||||||
context: ctx,
|
|
||||||
} as unknown as Snapshot<unknown>,
|
|
||||||
});
|
|
||||||
actor.start();
|
actor.start();
|
||||||
actor.send(event);
|
actor.send(event);
|
||||||
|
|
||||||
const snapshot = actor.getSnapshot();
|
const snapshot = actor.getSnapshot();
|
||||||
const toState = String(snapshot.value);
|
const toState = String(snapshot.value);
|
||||||
const newContext = snapshot.context as PipelineContext;
|
const newContext = snapshot.context as PipelineContext;
|
||||||
|
const newPersistedSnapshot = actor.getPersistedSnapshot();
|
||||||
actor.stop();
|
actor.stop();
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
@@ -78,7 +93,7 @@ export async function sendEvent(
|
|||||||
where: { id: pipelineId },
|
where: { id: pipelineId },
|
||||||
data: {
|
data: {
|
||||||
currentState: toState,
|
currentState: toState,
|
||||||
contextJson: JSON.stringify(newContext),
|
contextJson: JSON.stringify(newPersistedSnapshot),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.stateTransition.create({
|
prisma.stateTransition.create({
|
||||||
@@ -131,13 +146,97 @@ export async function getPipelineState(
|
|||||||
|
|
||||||
if (!pipeline) return null;
|
if (!pipeline) return null;
|
||||||
|
|
||||||
|
const snap = JSON.parse(pipeline.contextJson) as { context?: PipelineContext };
|
||||||
|
const context =
|
||||||
|
(snap.context as PipelineContext | undefined) ??
|
||||||
|
(snap as unknown as PipelineContext);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: pipeline.currentState as PipelineState,
|
state: pipeline.currentState as PipelineState,
|
||||||
context: JSON.parse(pipeline.contextJson) as PipelineContext,
|
context,
|
||||||
transitions: pipeline.transitions,
|
transitions: pipeline.transitions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge our canonical PipelineContext into the XState persisted snapshot.
|
||||||
|
* XState v5 snapshots include `.context`, so we overlay our values.
|
||||||
|
*/
|
||||||
|
function mergeContextIntoSnapshot(
|
||||||
|
snapshot: unknown,
|
||||||
|
ctx: PipelineContext,
|
||||||
|
): unknown {
|
||||||
|
if (snapshot && typeof snapshot === "object") {
|
||||||
|
return { ...(snapshot as object), context: ctx };
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTransitions(opts?: {
|
||||||
|
pipelineId?: string;
|
||||||
|
eventType?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
id: number;
|
||||||
|
pipelineId: string;
|
||||||
|
fromState: string;
|
||||||
|
toState: string;
|
||||||
|
eventType: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const where: { pipelineId?: string; eventType?: string } = {};
|
||||||
|
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
|
||||||
|
if (opts?.eventType) where.eventType = opts.eventType;
|
||||||
|
|
||||||
|
return prisma.stateTransition.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { timestamp: "desc" },
|
||||||
|
take: opts?.limit ?? 100,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
pipelineId: true,
|
||||||
|
fromState: true,
|
||||||
|
toState: true,
|
||||||
|
eventType: true,
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEscalations(opts?: {
|
||||||
|
pipelineId?: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
pipelineId: string;
|
||||||
|
reason: string;
|
||||||
|
errorCategory: string;
|
||||||
|
stage: string;
|
||||||
|
attempts: number;
|
||||||
|
contextSnapshot: string;
|
||||||
|
resolvedAt: Date | null;
|
||||||
|
resolution: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const where: { pipelineId?: string; resolvedAt?: null | { not: null } } = {};
|
||||||
|
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
|
||||||
|
if (opts?.resolved === false) where.resolvedAt = null;
|
||||||
|
if (opts?.resolved === true) where.resolvedAt = { not: null };
|
||||||
|
|
||||||
|
return prisma.escalation.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: opts?.limit ?? 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPipelines(opts?: {
|
export async function listPipelines(opts?: {
|
||||||
state?: PipelineState;
|
state?: PipelineState;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
|||||||
description: opts.requirements,
|
description: opts.requirements,
|
||||||
workdir: process.cwd(),
|
workdir: process.cwd(),
|
||||||
},
|
},
|
||||||
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 30_000,
|
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
|
||||||
structuredOutput: true,
|
structuredOutput: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryResult = await withRetry(
|
const retryResult = await withRetry(
|
||||||
async () => transport.invoke(invokeReq, opts.signal),
|
async () => transport.invoke(invokeReq, opts.signal),
|
||||||
{
|
{
|
||||||
maxRetries: opts.maxRetries ?? 3,
|
maxRetries: opts.maxRetries ?? 1,
|
||||||
...(opts.signal && { signal: opts.signal }),
|
...(opts.signal && { signal: opts.signal }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
284
src/server/http.ts
Normal file
284
src/server/http.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
createPipeline,
|
||||||
|
getPipelineState,
|
||||||
|
listPipelines,
|
||||||
|
listTransitions,
|
||||||
|
listEscalations,
|
||||||
|
sendEvent,
|
||||||
|
} from "../orchestrator/persist.js";
|
||||||
|
import { runPipeline } from "../orchestrator/runner.js";
|
||||||
|
import { loadConfig } from "../config/loader.js";
|
||||||
|
import type { SisterTransport } from "../handoff/transport.js";
|
||||||
|
import { buildTransports } from "../handoff/build.js";
|
||||||
|
import {
|
||||||
|
CreateSubTaskInput,
|
||||||
|
SubTaskEventInput,
|
||||||
|
UpdateSubTaskInput,
|
||||||
|
createSubTask,
|
||||||
|
recordSubTaskEvent,
|
||||||
|
updateSubTask,
|
||||||
|
getSubTaskTree,
|
||||||
|
getSubTaskDetail,
|
||||||
|
} from "../hierarchy/store.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "http-server" });
|
||||||
|
|
||||||
|
const StartRequest = z.object({
|
||||||
|
project: z.string().min(1),
|
||||||
|
requirements: z.string().default(""),
|
||||||
|
mock: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AbortRequest = z.object({
|
||||||
|
reason: z.string().default("aborted via api"),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ServerOpts {
|
||||||
|
port: number;
|
||||||
|
host?: string;
|
||||||
|
configPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||||
|
close: () => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
}> {
|
||||||
|
const host = opts.host ?? "0.0.0.0";
|
||||||
|
const config = await loadConfig(opts.configPath);
|
||||||
|
|
||||||
|
// Build transport map from config + env (auto picks mock or http)
|
||||||
|
const transports: Map<string, SisterTransport> = buildTransports(config);
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = req.method ?? "GET";
|
||||||
|
|
||||||
|
log.debug({ method, path }, "Incoming request");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Health ──
|
||||||
|
if (method === "GET" && path === "/health") {
|
||||||
|
return sendJson(res, 200, { ok: true, service: "hanarang-rails" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List pipelines ──
|
||||||
|
if (method === "GET" && path === "/pipelines") {
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
|
||||||
|
const list = await listPipelines({ limit });
|
||||||
|
return sendJson(res, 200, { pipelines: list });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start new pipeline ──
|
||||||
|
if (method === "POST" && path === "/pipelines/start") {
|
||||||
|
const body = await readJson(req);
|
||||||
|
const parsed = StartRequest.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "invalid_request",
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { project, requirements } = parsed.data;
|
||||||
|
|
||||||
|
// Run pipeline (async, but we await for this simple demo)
|
||||||
|
// Transport is determined by env RAILS_TRANSPORT_MODE and rails.config.yaml
|
||||||
|
const result = await runPipeline({
|
||||||
|
projectName: project,
|
||||||
|
requirements,
|
||||||
|
config,
|
||||||
|
transports,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendJson(res, 201, {
|
||||||
|
pipelineId: result.pipelineId,
|
||||||
|
finalState: result.finalState,
|
||||||
|
transitions: result.transitions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get pipeline status ──
|
||||||
|
const statusMatch = path.match(/^\/pipelines\/([^/]+)$/);
|
||||||
|
if (method === "GET" && statusMatch) {
|
||||||
|
const id = statusMatch[1]!;
|
||||||
|
const state = await getPipelineState(id);
|
||||||
|
if (!state) return sendJson(res, 404, { error: "not_found" });
|
||||||
|
return sendJson(res, 200, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Abort pipeline ──
|
||||||
|
const abortMatch = path.match(/^\/pipelines\/([^/]+)\/abort$/);
|
||||||
|
if (method === "POST" && abortMatch) {
|
||||||
|
const id = abortMatch[1]!;
|
||||||
|
const body = await readJson(req);
|
||||||
|
const parsed = AbortRequest.safeParse(body || {});
|
||||||
|
const reason = parsed.success
|
||||||
|
? parsed.data.reason
|
||||||
|
: "aborted via api";
|
||||||
|
const result = await sendEvent(id, { type: "ABORT", reason });
|
||||||
|
return sendJson(res, 200, { id, state: result.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create pipeline without running ──
|
||||||
|
if (method === "POST" && path === "/pipelines") {
|
||||||
|
const body = await readJson(req);
|
||||||
|
const parsed = StartRequest.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "invalid_request",
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { project, requirements } = parsed.data;
|
||||||
|
const { pipelineId, state } = await createPipeline(project, requirements);
|
||||||
|
return sendJson(res, 201, { pipelineId, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-task creation ──
|
||||||
|
if (method === "POST" && path === "/api/sub-tasks") {
|
||||||
|
const body = await readJson(req);
|
||||||
|
const parsed = CreateSubTaskInput.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "invalid_sub_task",
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await createSubTask(parsed.data);
|
||||||
|
return sendJson(res, 201, { id: parsed.data.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-task update (state, result) ──
|
||||||
|
const subTaskMatch = path.match(/^\/api\/sub-tasks\/([^/]+)$/);
|
||||||
|
if (method === "PATCH" && subTaskMatch) {
|
||||||
|
const id = subTaskMatch[1]!;
|
||||||
|
const body = await readJson(req);
|
||||||
|
const parsed = UpdateSubTaskInput.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "invalid_patch",
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await updateSubTask(id, parsed.data);
|
||||||
|
return sendJson(res, 200, { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-task event ──
|
||||||
|
const eventMatch = path.match(/^\/api\/sub-tasks\/([^/]+)\/events$/);
|
||||||
|
if (method === "POST" && eventMatch) {
|
||||||
|
const id = eventMatch[1]!;
|
||||||
|
const body = await readJson(req);
|
||||||
|
const parsed = SubTaskEventInput.safeParse({
|
||||||
|
...(body as Record<string, unknown>),
|
||||||
|
subTaskId: id,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "invalid_event",
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await recordSubTaskEvent(parsed.data);
|
||||||
|
return sendJson(res, 201, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-task tree by pipeline ──
|
||||||
|
const treeMatch = path.match(/^\/api\/pipelines\/([^/]+)\/sub-tasks$/);
|
||||||
|
if (method === "GET" && treeMatch) {
|
||||||
|
const pid = treeMatch[1]!;
|
||||||
|
const tree = await getSubTaskTree(pid);
|
||||||
|
return sendJson(res, 200, { pipelineId: pid, tree });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single sub-task detail ──
|
||||||
|
const detailMatch = path.match(/^\/api\/sub-tasks\/([^/]+)$/);
|
||||||
|
if (method === "GET" && detailMatch) {
|
||||||
|
const id = detailMatch[1]!;
|
||||||
|
const detail = await getSubTaskDetail(id);
|
||||||
|
if (!detail) return sendJson(res, 404, { error: "not_found" });
|
||||||
|
return sendJson(res, 200, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State transitions (SIEM-style log) ──
|
||||||
|
if (method === "GET" && path === "/api/transitions") {
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
|
||||||
|
const pid = url.searchParams.get("pipelineId") ?? undefined;
|
||||||
|
const eventType = url.searchParams.get("eventType") ?? undefined;
|
||||||
|
const transitions = await listTransitions({
|
||||||
|
...(pid !== undefined && { pipelineId: pid }),
|
||||||
|
...(eventType !== undefined && { eventType }),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return sendJson(res, 200, { transitions });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Escalations ──
|
||||||
|
if (method === "GET" && path === "/api/escalations") {
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
||||||
|
const pid = url.searchParams.get("pipelineId") ?? undefined;
|
||||||
|
const resolvedQ = url.searchParams.get("resolved");
|
||||||
|
const opts: { pipelineId?: string; resolved?: boolean; limit: number } = { limit };
|
||||||
|
if (pid !== undefined) opts.pipelineId = pid;
|
||||||
|
if (resolvedQ === "true") opts.resolved = true;
|
||||||
|
else if (resolvedQ === "false") opts.resolved = false;
|
||||||
|
const escalations = await listEscalations(opts);
|
||||||
|
return sendJson(res, 200, { escalations });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "not_found", path });
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Request handler error",
|
||||||
|
);
|
||||||
|
return sendJson(res, 500, {
|
||||||
|
error: "internal_error",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolveFn) => {
|
||||||
|
server.listen(opts.port, host, () => resolveFn());
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${opts.port}`;
|
||||||
|
log.info({ url }, "HTTP server listening");
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
async close() {
|
||||||
|
await new Promise<void>((resolveFn, rejectFn) => {
|
||||||
|
server.close((err) => (err ? rejectFn(err) : resolveFn()));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolveFn, rejectFn) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!body) return resolveFn({});
|
||||||
|
try {
|
||||||
|
resolveFn(JSON.parse(body));
|
||||||
|
} catch (err) {
|
||||||
|
rejectFn(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", rejectFn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user