merge: Stage 1+2 — hierarchical sub-agent team
This commit is contained in:
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[]
|
||||
contracts Contract[]
|
||||
escalations Escalation[]
|
||||
subTasks SubTask[]
|
||||
|
||||
@@index([currentState])
|
||||
@@index([createdAt])
|
||||
@@ -74,6 +75,49 @@ model Contract {
|
||||
@@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 {
|
||||
id String @id @db.VarChar(26) // ULID
|
||||
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";
|
||||
import { runPipeline } from "../orchestrator/runner.js";
|
||||
import { loadConfig } from "../config/loader.js";
|
||||
import { MockTransport } from "../handoff/mock-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";
|
||||
|
||||
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 config = await loadConfig(opts.configPath);
|
||||
|
||||
// Build transport map (mock for now; real transports wired in follow-up)
|
||||
const transports = new Map<string, SisterTransport>();
|
||||
const mock = new MockTransport();
|
||||
for (const stage of config.pipeline.stages) {
|
||||
transports.set(stage, mock);
|
||||
}
|
||||
// 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}`);
|
||||
@@ -136,6 +141,63 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
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 });
|
||||
} catch (err) {
|
||||
log.error(
|
||||
|
||||
Reference in New Issue
Block a user