Compare commits
3 Commits
feature/ht
...
3e354d21c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e354d21c1 | |||
| a88323716d | |||
| c8d6ceb337 |
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 @@
|
|||||||
|
1775808589
|
||||||
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-10T08:10:32Z",
|
||||||
|
"changed_file": "/home/erang/hanarang-rails/src/server/http.ts",
|
||||||
|
"test_command": "npm test",
|
||||||
|
"related_test": "",
|
||||||
|
"recommendation": "テストの実行を推奨します"
|
||||||
|
}
|
||||||
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";
|
||||||
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;
|
||||||
|
}
|
||||||
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: "신입",
|
||||||
|
};
|
||||||
117
sister-agent/src/server.ts
Normal file
117
sister-agent/src/server.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const req2 = { ...parsed.data, agentName: 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"));
|
||||||
287
sister-agent/src/spawn.ts
Normal file
287
sister-agent/src/spawn.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
if (!hasChildren) {
|
||||||
|
// Leaf execution — in this simulation we just produce a success result
|
||||||
|
await simulateWork(role);
|
||||||
|
return buildSuccessResult(req.stage, req.task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn children per plan
|
||||||
|
for (const spawnPlan of plan.spawn) {
|
||||||
|
for (let i = 0; i < spawnPlan.count; i++) {
|
||||||
|
const childId = ulid();
|
||||||
|
const childRecord: SubTaskRecord = {
|
||||||
|
id: childId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: selfId,
|
||||||
|
role: spawnPlan.role,
|
||||||
|
agentName,
|
||||||
|
title: `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`,
|
||||||
|
description: spawnPlan.rationale,
|
||||||
|
complexityScore: null,
|
||||||
|
complexityTier: null,
|
||||||
|
model: ROLES[spawnPlan.role].primaryModel,
|
||||||
|
};
|
||||||
|
await rails.createSubTask(childRecord);
|
||||||
|
await rails.recordEvent(childId, "spawned", {
|
||||||
|
parent: selfId,
|
||||||
|
role: spawnPlan.role,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(childId, "started", {});
|
||||||
|
|
||||||
|
// Recursively spawn grandchildren if subBreakdown exists
|
||||||
|
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
|
||||||
|
for (const grandSpawn of spawnPlan.subBreakdown) {
|
||||||
|
for (let j = 0; j < grandSpawn.count; j++) {
|
||||||
|
const grandId = ulid();
|
||||||
|
const grandRecord: SubTaskRecord = {
|
||||||
|
id: grandId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: childId,
|
||||||
|
role: grandSpawn.role,
|
||||||
|
agentName,
|
||||||
|
title: `${grandSpawn.role}-${j + 1}`,
|
||||||
|
description: grandSpawn.rationale,
|
||||||
|
complexityScore: null,
|
||||||
|
complexityTier: null,
|
||||||
|
model: ROLES[grandSpawn.role].primaryModel,
|
||||||
|
};
|
||||||
|
await rails.createSubTask(grandRecord);
|
||||||
|
await rails.recordEvent(grandId, "spawned", {
|
||||||
|
parent: childId,
|
||||||
|
role: grandSpawn.role,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(grandId, "started", {});
|
||||||
|
|
||||||
|
// Third-level (junior) grand-grandchildren
|
||||||
|
if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) {
|
||||||
|
for (const ggSpawn of grandSpawn.subBreakdown) {
|
||||||
|
for (let k = 0; k < ggSpawn.count; k++) {
|
||||||
|
const ggId = ulid();
|
||||||
|
await rails.createSubTask({
|
||||||
|
id: ggId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: grandId,
|
||||||
|
role: ggSpawn.role,
|
||||||
|
agentName,
|
||||||
|
title: `${ggSpawn.role}-${k + 1}`,
|
||||||
|
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", {});
|
||||||
|
await simulateWork(ggSpawn.role);
|
||||||
|
await rails.recordEvent(ggId, "completed", { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await simulateWork(grandSpawn.role);
|
||||||
|
}
|
||||||
|
await rails.recordEvent(grandId, "completed", { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await simulateWork(spawnPlan.role);
|
||||||
|
}
|
||||||
|
await rails.recordEvent(childId, "completed", { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void complexity; // reserved for future LLM-based planning
|
||||||
|
return buildSuccessResult(req.stage, req.task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder "work" — tiny delay per role so timeline looks realistic.
|
||||||
|
* Replace with real openclaw agent CLI or LLM SDK call.
|
||||||
|
*/
|
||||||
|
async function simulateWork(role: Role): Promise<void> {
|
||||||
|
const delayByRole: Record<Role, number> = {
|
||||||
|
manager: 40,
|
||||||
|
principal: 60,
|
||||||
|
lead: 80,
|
||||||
|
junior: 100,
|
||||||
|
};
|
||||||
|
await new Promise((r) => setTimeout(r, delayByRole[role]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuccessResult(
|
||||||
|
stage: InvokeRequest["stage"],
|
||||||
|
task: InvokeRequest["task"],
|
||||||
|
): HandoffMessage {
|
||||||
|
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"]
|
||||||
|
}
|
||||||
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
|
||||||
|
},
|
||||||
|
};
|
||||||
166
src/hierarchy/store.ts
Normal file
166
src/hierarchy/store.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -8,8 +8,17 @@ import {
|
|||||||
} from "../orchestrator/persist.js";
|
} from "../orchestrator/persist.js";
|
||||||
import { runPipeline } from "../orchestrator/runner.js";
|
import { runPipeline } from "../orchestrator/runner.js";
|
||||||
import { loadConfig } from "../config/loader.js";
|
import { loadConfig } from "../config/loader.js";
|
||||||
import { MockTransport } from "../handoff/mock-transport.js";
|
|
||||||
import type { SisterTransport } from "../handoff/transport.js";
|
import type { SisterTransport } from "../handoff/transport.js";
|
||||||
|
import { buildTransports } from "../handoff/build.js";
|
||||||
|
import {
|
||||||
|
CreateSubTaskInput,
|
||||||
|
SubTaskEventInput,
|
||||||
|
UpdateSubTaskInput,
|
||||||
|
createSubTask,
|
||||||
|
recordSubTaskEvent,
|
||||||
|
updateSubTask,
|
||||||
|
getSubTaskTree,
|
||||||
|
} from "../hierarchy/store.js";
|
||||||
import { childLogger } from "../logger.js";
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
const log = childLogger({ module: "http-server" });
|
const log = childLogger({ module: "http-server" });
|
||||||
@@ -37,12 +46,8 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
|||||||
const host = opts.host ?? "0.0.0.0";
|
const host = opts.host ?? "0.0.0.0";
|
||||||
const config = await loadConfig(opts.configPath);
|
const config = await loadConfig(opts.configPath);
|
||||||
|
|
||||||
// Build transport map (mock for now; real transports wired in follow-up)
|
// Build transport map from config + env (auto picks mock or http)
|
||||||
const transports = new Map<string, SisterTransport>();
|
const transports: Map<string, SisterTransport> = buildTransports(config);
|
||||||
const mock = new MockTransport();
|
|
||||||
for (const stage of config.pipeline.stages) {
|
|
||||||
transports.set(stage, mock);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const url = new URL(req.url ?? "/", `http://${host}`);
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
||||||
@@ -136,6 +141,63 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
|||||||
return sendJson(res, 201, { pipelineId, state });
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
return sendJson(res, 404, { error: "not_found", path });
|
return sendJson(res, 404, { error: "not_found", path });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(
|
log.error(
|
||||||
|
|||||||
Reference in New Issue
Block a user