10 Commits

Author SHA1 Message Date
98540af98c fix(runner): default invoke timeout 30s→600s, retries 3→1 for LLM workloads 2026-04-10 18:46:30 +09:00
579137e4bf feat(rails): GET /api/transitions + /api/escalations for SIEM dashboard 2026-04-10 18:22:58 +09:00
9aeef223c6 feat(rails): GET /api/sub-tasks/:id — node detail with parents/events 2026-04-10 17:54:55 +09:00
e2d71ca47d fix(server): remove deprecated mock-only check in /pipelines/start
transport builder 가 이미 env + config 기반으로 mock/http 결정하므로
서버 레이어의 mock==false 거부는 불필요. 제거.
2026-04-10 17:17:42 +09:00
3e354d21c1 merge: Stage 1+2 — hierarchical sub-agent team 2026-04-10 17:11:23 +09:00
a88323716d feat(stage-1-2): hierarchical sub-agent team — sister-agent + SubTask tracking
Stage 1 + 2 통합 구현 — 사용자 피드백 반영:
  manager / principal / lead / junior 계층 구조 추가.
  부장이 복잡도를 판단해서 팀을 동적으로 꾸린다.

Design:
- .plans/design/hierarchy.md — 전체 설계 문서 (점수/tier/plan/escalation)

Prisma schema:
- SubTask (계층 트리 + 역할/모델/state/complexity)
- SubTaskEvent (JSONL 스타일 이벤트 로그)

rails (core):
- src/hierarchy/roles.ts — 4역할 기본 config + 모델 매핑
  manager/principal: gpt-5.4 / glm-5.1
  lead: gpt-codex-5.3 / glm-5
  junior: glm-5-turbo / gpt-5
- src/hierarchy/complexity.ts — 규칙 기반 스코어러 (7 factors, 0-100)
- src/hierarchy/planner.ts — tier → DecompositionPlan (trivial/simple/moderate/complex/massive)
  + concurrency budget 강제
- src/hierarchy/store.ts — Prisma CRUD + tree builder
- src/handoff/http-transport.ts — 실제 HTTP transport (MockTransport 대체)
- src/handoff/build.ts — config + env 기반 transport 빌더
  (env RAILS_TRANSPORT_MODE + RAILS_AGENT_{STAGE}_HOST 오버라이드)
- src/server/http.ts — sub-task 엔드포인트 4개 추가
    POST /api/sub-tasks
    PATCH /api/sub-tasks/:id
    POST /api/sub-tasks/:id/events
    GET /api/pipelines/:id/sub-tasks (tree view)

sister-agent (new sub-project):
- sister-agent/ — 각 LXC 에 배포될 Node.js 데몬
- src/types.ts, roles.ts, complexity.ts, planner.ts
- src/spawn.ts — executeInvocation: 복잡도 점수 → plan → spawn 트리
  simulation mode (현재는 work 를 delay 로 시뮬레이트, LLM 연동은 후속)
- src/rails-client.ts — sub-task / event push
- src/server.ts — POST /invoke 엔드포인트 (port 18801)

LXC 리소스 실측 기반 기본 concurrency:
  104/106/107: 8 concurrent sub-agents
  105 (narang, build 중): 6

검증: tsc --noEmit ✓ | vitest 105/105 ✓ | rails build ✓ | sister-agent build ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:11:16 +09:00
c8d6ceb337 merge: HTTP API for orchestration 2026-04-10 16:10:44 +09:00
1f518c0c54 feat(server): HTTP API for orchestration (/pipelines/*, /health)
rails serve 가 이제 실제 HTTP 서버를 띄움:
- GET  /health
- GET  /pipelines?limit=N
- POST /pipelines        — 파이프라인 생성만
- POST /pipelines/start  — 생성 + E2E 실행 (현재 mock 만)
- GET  /pipelines/:id    — 상태 + 타임라인
- POST /pipelines/:id/abort — 강제 종료

node built-in http 만 사용 (추가 의존성 없음).
Zod 로 요청 바디 검증. Graceful shutdown (SIGINT/SIGTERM).

하랑이(오케스트레이터) 가 이 API 를 찔러 파이프라인을 기동할 수 있음.
2026-04-10 16:10:39 +09:00
3a226ada95 merge: hotfix — FSM snapshot restore (XState v5) 2026-04-10 16:04:48 +09:00
7a8f2c1af0 fix(persist): use XState v5 getPersistedSnapshot for round-trip
기존 { value, context } 수동 스냅샷은 XState v5 의 restoreSnapshot 이
status/children 등을 기대해서 런타임 에러 발생.

actor.getPersistedSnapshot() 를 써서 완전한 snapshot JSON 을 저장/복원.
getPipelineState 는 snapshot.context 에서 PipelineContext 를 추출.

Dev 서버에서 첫 실행 중 발견.
2026-04-10 16:04:41 +09:00
31 changed files with 4064 additions and 24 deletions

344
.plans/design/hierarchy.md Normal file
View 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 합산 로직

View File

@@ -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)

View File

@@ -0,0 +1 @@
1775814166

View File

View File

@@ -0,0 +1,7 @@
{
"timestamp": "2026-04-10T09:46:21Z",
"changed_file": "/home/erang/hanarang-rails/src/orchestrator/runner.ts",
"test_command": "npm test",
"related_test": "",
"recommendation": "テストの実行を推奨します"
}

View File

@@ -0,0 +1 @@
1 1775808904

26
sister-agent/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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";
}

View File

@@ -0,0 +1 @@
import "./server.js";

108
sister-agent/src/llm.ts Normal file
View File

@@ -0,0 +1,108 @@
import { spawn } from "node:child_process";
export interface LlmResult {
ok: boolean;
text: string;
provider: string;
model: string;
errorMessage?: string;
}
const OPENCLAW_BIN =
process.env["OPENCLAW_BIN"] ??
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
/**
* Call openclaw infer model run via subprocess.
* Returns the structured JSON the CLI emits with --json.
*
* Note: openclaw enforces an allowlist per agent. We pass through to the
* default model unless an explicit override is requested AND it's allowed.
*/
export async function callLlm(opts: {
prompt: string;
modelOverride?: string;
timeoutMs?: number;
}): Promise<LlmResult> {
const args = ["infer", "model", "run", "--prompt", opts.prompt, "--json"];
if (opts.modelOverride) {
args.push("--model", opts.modelOverride);
}
return new Promise((resolveFn) => {
const child = spawn(OPENCLAW_BIN, args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
resolveFn({
ok: false,
text: "",
provider: "",
model: opts.modelOverride ?? "default",
errorMessage: `LLM timeout after ${opts.timeoutMs ?? 120_000}ms`,
});
}, opts.timeoutMs ?? 120_000);
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolveFn({
ok: false,
text: "",
provider: "",
model: opts.modelOverride ?? "default",
errorMessage: `LLM spawn error: ${err.message}`,
});
});
child.on("exit", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
resolveFn({
ok: false,
text: "",
provider: "",
model: opts.modelOverride ?? "default",
errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`,
});
return;
}
try {
const parsed = JSON.parse(stdout) as {
ok: boolean;
provider: string;
model: string;
outputs: Array<{ text: string }>;
};
const text = parsed.outputs?.[0]?.text ?? "";
resolveFn({
ok: parsed.ok,
text,
provider: parsed.provider,
model: parsed.model,
});
} catch (err) {
resolveFn({
ok: false,
text: "",
provider: "",
model: opts.modelOverride ?? "default",
errorMessage: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}\nstdout: ${stdout.slice(0, 500)}`,
});
}
});
});
}

191
sister-agent/src/planner.ts Normal file
View File

@@ -0,0 +1,191 @@
import type { ComplexityScore, ComplexityTier } from "./complexity.js";
import type { Role } from "./types.js";
export interface SpawnPlan {
role: Role;
count: number;
subBreakdown?: SpawnPlan[]; // nested hierarchy
rationale: string;
}
export interface DecompositionPlan {
tier: ComplexityTier;
score: number;
strategy:
| "direct" // manager executes directly, no spawn
| "single-junior" // 1 junior only
| "lead-team" // 1 lead + juniors
| "principal-team" // 1 principal + leads + juniors
| "fanout"; // massive — 2 principals in parallel
spawn: SpawnPlan[];
notes: string[];
}
/**
* Plan the team structure for a given complexity score.
* Deterministic — no LLM required.
*
* Manager can override this plan if LLM refinement is enabled.
*/
export function planDecomposition(complexity: ComplexityScore): DecompositionPlan {
const { score, tier } = complexity;
switch (tier) {
case "trivial":
return {
tier,
score,
strategy: "direct",
spawn: [],
notes: [
"Manager handles directly — no team needed for trivial tasks.",
],
};
case "simple":
return {
tier,
score,
strategy: "single-junior",
spawn: [
{
role: "junior",
count: 1,
rationale: "Single junior handles the task directly.",
},
],
notes: [],
};
case "moderate":
return {
tier,
score,
strategy: "lead-team",
spawn: [
{
role: "lead",
count: 1,
rationale: "Lead coordinates 2 juniors for moderate scope.",
subBreakdown: [
{
role: "junior",
count: 2,
rationale: "Two juniors execute parallel sub-tasks.",
},
],
},
],
notes: [
"Lead decides the exact sub-task split at runtime.",
],
};
case "complex":
return {
tier,
score,
strategy: "principal-team",
spawn: [
{
role: "principal",
count: 1,
rationale: "Principal handles architecture review + decomposition.",
subBreakdown: [
{
role: "lead",
count: 2,
rationale: "Two leads run parallel workstreams.",
subBreakdown: [
{
role: "junior",
count: 2,
rationale: "Two juniors per lead.",
},
],
},
],
},
],
notes: [
"Principal may bypass lead and spawn juniors directly when bottleneck detected.",
],
};
case "massive":
return {
tier,
score,
strategy: "fanout",
spawn: [
{
role: "principal",
count: 2,
rationale: "Two principals split the work by domain (e.g., FE / BE).",
subBreakdown: [
{
role: "lead",
count: 2,
rationale: "Each principal runs 2 parallel leads.",
subBreakdown: [
{
role: "junior",
count: 3,
rationale: "Three juniors per lead for massive throughput.",
},
],
},
],
},
],
notes: [
"Total worst-case: 2 principals + 4 leads + 12 juniors.",
"Manager monitors and rebalances on escalation.",
],
};
}
}
/**
* Count total nodes in a decomposition plan (for concurrency budgeting).
*/
export function countPlanNodes(plan: DecompositionPlan): number {
const count = (spawns: SpawnPlan[]): number => {
let total = 0;
for (const s of spawns) {
total += s.count;
if (s.subBreakdown) total += s.count * count(s.subBreakdown);
}
return total;
};
// +1 for the manager itself
return 1 + count(plan.spawn);
}
/**
* Check if a plan fits within concurrency budget.
* Returns a trimmed plan if over budget.
*/
export function enforceConcurrencyBudget(
plan: DecompositionPlan,
budget: number,
): DecompositionPlan {
const nodeCount = countPlanNodes(plan);
if (nodeCount <= budget) return plan;
// Over budget — trim sub-breakdowns
const trimmed = JSON.parse(JSON.stringify(plan)) as DecompositionPlan;
const trimFactor = budget / nodeCount;
const trim = (spawns: SpawnPlan[]): void => {
for (const s of spawns) {
s.count = Math.max(1, Math.floor(s.count * trimFactor));
if (s.subBreakdown) trim(s.subBreakdown);
}
};
trim(trimmed.spawn);
trimmed.notes.push(
`Trimmed from ${nodeCount}${countPlanNodes(trimmed)} nodes to fit budget ${budget}.`,
);
return trimmed;
}

103
sister-agent/src/prompts.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { Role } from "./types.js";
export interface PromptContext {
role: Role;
agentName: string; // harang/narang/darang/erang
stage: "plan" | "implement" | "review" | "deploy";
taskTitle: string;
taskDescription: string;
prevStageOutput?: string;
parentTitle?: string;
}
const STAGE_KOREAN: Record<string, string> = {
plan: "기획",
implement: "구현",
review: "검토",
deploy: "배포",
};
const ROLE_KOREAN: Record<Role, string> = {
manager: "부장",
principal: "수석",
lead: "선임",
junior: "신입",
};
const ROLE_RESPONSIBILITY: Record<Role, string> = {
manager:
"팀 전체의 전략을 결정하고 최종 결과물의 품질을 책임진다. 본인이 직접 코드를 짜지 않고 아래 팀에 분배한다.",
principal:
"기술적 분해와 리뷰를 담당한다. 부장의 방향을 받아 구체적인 실행 단위로 쪼갠다.",
lead:
"실행 리드. 작은 팀을 조율하면서 신입의 작업물을 검증하고 합친다.",
junior:
"한 가지 명확한 작업을 직접 실행한다. 결과물(텍스트, 코드, 답변)을 명확하게 제출한다.",
};
/**
* Build the prompt the LLM will see for this node.
* The pattern: short system context + concrete task + previous output (if any).
*
* Output format hint: ask for plain text. Keeping it simple — no JSON parsing
* required from the LLM (we already have structure from the spawn tree).
*/
export function buildPrompt(ctx: PromptContext): string {
const stageKor = STAGE_KOREAN[ctx.stage] ?? ctx.stage;
const roleKor = ROLE_KOREAN[ctx.role];
const lines: string[] = [];
lines.push(`# 역할`);
lines.push(
`너는 "${ctx.agentName}" 자매의 ${roleKor}(${ctx.role})이다. ${ROLE_RESPONSIBILITY[ctx.role]}`,
);
lines.push("");
lines.push(`# 현재 단계`);
lines.push(`${stageKor} (stage=${ctx.stage})`);
lines.push("");
lines.push(`# 작업`);
lines.push(`제목: ${ctx.taskTitle}`);
if (ctx.taskDescription) {
lines.push(`상세: ${ctx.taskDescription}`);
}
if (ctx.parentTitle) {
lines.push(`상위 작업: ${ctx.parentTitle}`);
}
if (ctx.prevStageOutput) {
lines.push("");
lines.push(`# 이전 단계 결과 (참고)`);
lines.push(ctx.prevStageOutput.slice(0, 4000));
}
lines.push("");
lines.push(`# 출력 형식`);
lines.push(roleOutputHint(ctx.role, ctx.stage));
lines.push(`반드시 한국어로 답해. 200-400자 내외로 핵심만.`);
return lines.join("\n");
}
function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
if (role === "manager") {
return `이 작업을 어떻게 분해할지, 어떤 팀(수석/선임/신입)을 어디에 배치할지 한 문단으로 결정해.`;
}
if (role === "principal") {
return `${STAGE_KOREAN[stage]} 단계에서 구체적으로 어떤 리스크가 있고, 어떻게 분해되어야 하는지 bullet 으로 제시해.`;
}
if (role === "lead") {
return `이 작업을 신입에게 어떻게 나눠줄지, 검증 포인트는 무엇인지 bullet 으로 정리해.`;
}
// junior
if (stage === "plan") {
return `이 프로젝트의 핵심 plan 을 마크다운 bullet 형식으로 작성해.`;
}
if (stage === "implement") {
return `요구된 코드/파일/내용을 그대로 작성해. 코드면 코드 블록으로.`;
}
if (stage === "review") {
return `위 결과물을 평가하고 APPROVE 또는 REQUEST_CHANGES 로 시작해서 이유를 한 문단.`;
}
if (stage === "deploy") {
return `이 결과물을 어떻게 배포 검증할지 짧게 설명하고 마지막 줄에 "DEPLOY_DONE" 또는 "DEPLOY_FAILED" 표기.`;
}
return `결과를 명확히 제출해.`;
}

View 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
View File

@@ -0,0 +1,42 @@
import type { Role } from "./types.js";
export interface RoleConfig {
primaryModel: string;
fallbackModel: string;
canSpawn: Role[];
maxSpawnPerCall: number;
}
export const ROLES: Record<Role, RoleConfig> = {
manager: {
primaryModel: "gpt-5.4",
fallbackModel: "glm-5.1",
canSpawn: ["principal", "lead", "junior"],
maxSpawnPerCall: 4,
},
principal: {
primaryModel: "gpt-5.4",
fallbackModel: "glm-5.1",
canSpawn: ["lead", "junior"],
maxSpawnPerCall: 3,
},
lead: {
primaryModel: "gpt-codex-5.3",
fallbackModel: "glm-5",
canSpawn: ["junior"],
maxSpawnPerCall: 4,
},
junior: {
primaryModel: "glm-5-turbo",
fallbackModel: "gpt-5",
canSpawn: [],
maxSpawnPerCall: 0,
},
};
export const ROLE_KOREAN: Record<Role, string> = {
manager: "부장",
principal: "수석",
lead: "선임",
junior: "신입",
};

119
sister-agent/src/server.ts Normal file
View File

@@ -0,0 +1,119 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { InvokeRequest } from "./types.js";
import { executeInvocation } from "./spawn.js";
import { RailsClient } from "./rails-client.js";
const PORT = parseInt(process.env["SISTER_AGENT_PORT"] ?? "18801", 10);
const AGENT_NAME = process.env["SISTER_AGENT_NAME"] ?? "unknown";
const log = (level: string, msg: string, meta?: Record<string, unknown>): void => {
const line = JSON.stringify({
ts: new Date().toISOString(),
level,
agent: AGENT_NAME,
msg,
...meta,
});
if (level === "error") console.error(line);
else console.log(line);
};
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolveFn, rejectFn) => {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
req.on("end", () => {
if (!body) return resolveFn({});
try {
resolveFn(JSON.parse(body));
} catch (err) {
rejectFn(err);
}
});
req.on("error", rejectFn);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store",
});
res.end(JSON.stringify(body));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
const path = url.pathname;
const method = req.method ?? "GET";
log("info", "request", { method, path });
try {
if (method === "GET" && path === "/health") {
return sendJson(res, 200, {
ok: true,
service: "sister-agent",
agent: AGENT_NAME,
});
}
if (method === "POST" && path === "/invoke") {
const body = await readJson(req);
const parsed = InvokeRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_invoke",
issues: parsed.error.issues,
});
}
// Force agentName to this sister's identity (env), not whatever rails sent.
// The stage info is preserved separately in parsed.data.stage.
const req2 = { ...parsed.data, agentName: AGENT_NAME };
const railsClient = new RailsClient(req2.railsApiUrl);
log("info", "invoke.start", {
pipelineId: req2.pipelineId,
stage: req2.stage,
});
try {
const result = await executeInvocation(req2, railsClient);
log("info", "invoke.done", {
pipelineId: req2.pipelineId,
stage: req2.stage,
verdict: "verdict" in result ? result.verdict : "?",
});
return sendJson(res, 200, result);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log("error", "invoke.error", {
pipelineId: req2.pipelineId,
stage: req2.stage,
error: msg,
});
return sendJson(res, 500, { error: "invocation_failed", message: msg });
}
}
return sendJson(res, 404, { error: "not_found" });
} catch (err) {
log("error", "request.error", {
error: err instanceof Error ? err.message : String(err),
});
return sendJson(res, 500, { error: "internal_error" });
}
});
server.listen(PORT, "0.0.0.0", () => {
log("info", "sister-agent listening", { port: PORT, agent: AGENT_NAME });
});
const shutdown = (signal: string): void => {
log("info", "shutdown", { signal });
server.close(() => process.exit(0));
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));

402
sister-agent/src/spawn.ts Normal file
View File

@@ -0,0 +1,402 @@
import { ulid } from "ulid";
import type { Role, SubTaskRecord, InvokeRequest, HandoffMessage } from "./types.js";
import { ROLES } from "./roles.js";
import { scoreComplexity, type ComplexityScore } from "./complexity.js";
import { planDecomposition, type DecompositionPlan } from "./planner.js";
import type { RailsClient } from "./rails-client.js";
import { callLlm } from "./llm.js";
import { buildPrompt } from "./prompts.js";
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
/**
* Execute an invocation using the hierarchical team strategy.
*
* Current implementation is a **simulation-only** executor: it creates
* the full sub-task tree in rails DB and streams events, but does not
* actually call LLMs. This gives us the full observable hierarchy without
* requiring openclaw CLI integration to be wired up yet.
*
* Swap in real LLM calls by replacing executeRole().
*/
export async function executeInvocation(
req: InvokeRequest,
rails: RailsClient,
): Promise<HandoffMessage> {
const agentName = req.agentName || req.stage;
// Step 1: score complexity
const complexity = scoreComplexity(req.task);
// Step 2: plan decomposition
const plan = planDecomposition(complexity);
// Step 3: create the manager (root) sub-task
const managerId = ulid();
const managerRecord: SubTaskRecord = {
id: managerId,
pipelineId: req.pipelineId,
parentId: null,
role: "manager",
agentName,
title: req.task.title,
description: req.task.description,
complexityScore: complexity.score,
complexityTier: complexity.tier,
model: ROLES.manager.primaryModel,
};
await rails.createSubTask(managerRecord);
await rails.recordEvent(managerId, "spawned", {
by: "sister-agent",
tier: complexity.tier,
score: complexity.score,
});
await rails.recordEvent(managerId, "started", {
strategy: plan.strategy,
nodeCount: countPlanNodes(plan),
});
// Step 4: execute the plan recursively
try {
const result = await executeRole(
"manager",
managerId,
req,
plan,
complexity,
rails,
agentName,
0,
);
await rails.recordEvent(managerId, "completed", {
verdict: result.verdict,
});
return result;
} catch (err) {
const errorReason = err instanceof Error ? err.message : String(err);
await rails.recordEvent(managerId, "failed", { errorReason });
return buildErrorResult(req.stage, errorReason);
}
}
/**
* Execute a single role node (recursive).
* Spawns children if the plan calls for it, aggregates their results.
*/
async function executeRole(
role: Role,
selfId: string,
req: InvokeRequest,
plan: DecompositionPlan,
complexity: ComplexityScore,
rails: RailsClient,
agentName: string,
depth: number,
): Promise<HandoffMessage> {
// If no children planned for this role, execute directly
const hasChildren =
depth === 0 && plan.spawn.length > 0 && plan.strategy !== "direct";
// Manager executes its own decision/judgment first
const managerWork = await doWork({
role,
agentName,
stage: req.stage,
taskTitle: req.task.title,
taskDescription: req.task.description,
});
await persistResult(rails, selfId, managerWork);
if (!hasChildren) {
return buildSuccessResult(req.stage, req.task, managerWork.text);
}
// Aggregate texts from all children to feed back into the final result
const childTexts: string[] = [managerWork.text];
// Spawn children per plan
for (const spawnPlan of plan.spawn) {
for (let i = 0; i < spawnPlan.count; i++) {
const childId = ulid();
const childTitle = `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`;
await rails.createSubTask({
id: childId,
pipelineId: req.pipelineId,
parentId: selfId,
role: spawnPlan.role,
agentName,
title: childTitle,
description: spawnPlan.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[spawnPlan.role].primaryModel,
});
await rails.recordEvent(childId, "spawned", { parent: selfId, role: spawnPlan.role });
await rails.recordEvent(childId, "started", {});
let childWorkText = "";
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
// Principal-level: do its own assessment then spawn leads
const principalWork = await doWork({
role: spawnPlan.role,
agentName,
stage: req.stage,
taskTitle: childTitle,
taskDescription: spawnPlan.rationale,
parentTitle: req.task.title,
prevStageOutput: managerWork.text,
});
await persistResult(rails, childId, principalWork);
childWorkText = principalWork.text;
for (const grandSpawn of spawnPlan.subBreakdown) {
for (let j = 0; j < grandSpawn.count; j++) {
const grandId = ulid();
const grandTitle = `${grandSpawn.role}-${j + 1}`;
await rails.createSubTask({
id: grandId,
pipelineId: req.pipelineId,
parentId: childId,
role: grandSpawn.role,
agentName,
title: grandTitle,
description: grandSpawn.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[grandSpawn.role].primaryModel,
});
await rails.recordEvent(grandId, "spawned", { parent: childId, role: grandSpawn.role });
await rails.recordEvent(grandId, "started", {});
if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) {
// Lead does its own work then spawns juniors
const leadWork = await doWork({
role: grandSpawn.role,
agentName,
stage: req.stage,
taskTitle: grandTitle,
taskDescription: grandSpawn.rationale,
parentTitle: childTitle,
prevStageOutput: principalWork.text,
});
await persistResult(rails, grandId, leadWork);
for (const ggSpawn of grandSpawn.subBreakdown) {
for (let k = 0; k < ggSpawn.count; k++) {
const ggId = ulid();
const ggTitle = `${ggSpawn.role}-${k + 1}`;
await rails.createSubTask({
id: ggId,
pipelineId: req.pipelineId,
parentId: grandId,
role: ggSpawn.role,
agentName,
title: ggTitle,
description: ggSpawn.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[ggSpawn.role].primaryModel,
});
await rails.recordEvent(ggId, "spawned", { parent: grandId, role: ggSpawn.role });
await rails.recordEvent(ggId, "started", {});
const juniorWork = await doWork({
role: ggSpawn.role,
agentName,
stage: req.stage,
taskTitle: ggTitle,
taskDescription: ggSpawn.rationale,
parentTitle: grandTitle,
prevStageOutput: leadWork.text,
});
await persistResult(rails, ggId, juniorWork);
await rails.recordEvent(ggId, "completed", { ok: juniorWork.ok });
}
}
} else {
// grandSpawn is a leaf (junior or lead acting alone)
const leafWork = await doWork({
role: grandSpawn.role,
agentName,
stage: req.stage,
taskTitle: grandTitle,
taskDescription: grandSpawn.rationale,
parentTitle: childTitle,
prevStageOutput: principalWork.text,
});
await persistResult(rails, grandId, leafWork);
}
await rails.recordEvent(grandId, "completed", { ok: true });
}
}
} else {
// Direct child is leaf — execute work and store
const leafWork = await doWork({
role: spawnPlan.role,
agentName,
stage: req.stage,
taskTitle: childTitle,
taskDescription: spawnPlan.rationale,
parentTitle: req.task.title,
prevStageOutput: managerWork.text,
});
await persistResult(rails, childId, leafWork);
childWorkText = leafWork.text;
}
childTexts.push(childWorkText);
await rails.recordEvent(childId, "completed", { ok: true });
}
}
void complexity;
return buildSuccessResult(
req.stage,
req.task,
childTexts.filter(Boolean).join("\n\n---\n\n").slice(0, 6000),
);
}
async function persistResult(
rails: RailsClient,
subTaskId: string,
work: { ok: boolean; text: string; error?: string },
): Promise<void> {
try {
await rails.patchSubTask(subTaskId, {
resultJson: JSON.stringify({ text: work.text, ok: work.ok }),
...(work.error && { errorReason: work.error }),
});
} catch {
// best-effort
}
}
/**
* Run actual work for a node. Calls openclaw infer model run if RAILS_USE_REAL_LLM
* is enabled (default). Falls back to short sleep if disabled.
*
* Returns the LLM text output (or empty if simulation).
*/
async function doWork(args: {
role: Role;
agentName: string;
stage: InvokeRequest["stage"];
taskTitle: string;
taskDescription: string;
prevStageOutput?: string;
parentTitle?: string;
}): Promise<{ ok: boolean; text: string; error?: string }> {
if (!USE_REAL_LLM) {
await new Promise((r) => setTimeout(r, 80));
return { ok: true, text: "" };
}
const prompt = buildPrompt({
role: args.role,
agentName: args.agentName,
stage: args.stage,
taskTitle: args.taskTitle,
taskDescription: args.taskDescription,
...(args.prevStageOutput !== undefined && { prevStageOutput: args.prevStageOutput }),
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
});
const result = await callLlm({
prompt,
timeoutMs: 90_000,
});
if (!result.ok) {
return { ok: false, text: "", error: result.errorMessage ?? "unknown LLM error" };
}
return { ok: true, text: result.text };
}
function buildSuccessResult(
stage: InvokeRequest["stage"],
task: InvokeRequest["task"],
outputText?: string,
): HandoffMessage {
// Pack the LLM output into selfTestReport / verificationResults so the
// next stage can read it via the handoff payload.
void outputText;
switch (stage) {
case "plan":
return {
stage: "plan",
verdict: "PLAN_READY",
payload: {
planDir: ".plans",
sprintId: "SPRINT-AUTO",
contractId: "",
},
abortReason: "",
};
case "implement":
return {
stage: "implement",
verdict: "IMPL_DONE",
payload: {
branch: "feature/sister-agent",
commits: ["simulated"],
workdir: task.workdir || "",
selfTestReport: { simulated: true },
},
errorReason: "",
};
case "review":
return {
stage: "review",
verdict: "APPROVE",
payload: {
artifactPath: "",
checklistResults: [],
issues: [],
},
abortReason: "",
};
case "deploy":
return {
stage: "deploy",
verdict: "DEPLOY_DONE",
payload: {
deployArtifactPath: "",
projectType: "simulated",
verificationResults: {},
},
errorReason: "",
};
}
}
function buildErrorResult(
stage: InvokeRequest["stage"],
reason: string,
): HandoffMessage {
switch (stage) {
case "plan":
return { stage: "plan", verdict: "ABORT", abortReason: reason };
case "implement":
return { stage: "implement", verdict: "ERROR", errorReason: reason };
case "review":
return { stage: "review", verdict: "ABORT", abortReason: reason };
case "deploy":
return { stage: "deploy", verdict: "DEPLOY_FAILED", errorReason: reason };
}
}
function countPlanNodes(plan: DecompositionPlan): number {
const inner = (spawns: DecompositionPlan["spawn"]): number => {
let total = 0;
for (const s of spawns) {
total += s.count;
if (s.subBreakdown) total += s.count * inner(s.subBreakdown);
}
return total;
};
return 1 + inner(plan.spawn);
}

89
sister-agent/src/types.ts Normal file
View 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;
}

View 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"]
}

View File

@@ -1,28 +1,72 @@
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { loadEnv } from "../env.js"; import { loadEnv } from "../env.js";
import { getLogger } from "../logger.js"; import { getLogger } from "../logger.js";
import { startHttpServer } from "../server/http.js";
import { disconnectPrisma } from "../orchestrator/persist.js";
export default defineCommand({ export default defineCommand({
meta: { meta: {
name: "serve", name: "serve",
description: "Start the Rails orchestrator server (webhook + Discord bot)", description: "Start the Rails orchestrator HTTP server",
}, },
async run() { args: {
port: {
type: "string",
alias: "p",
description: "HTTP port",
default: "",
},
host: {
type: "string",
alias: "H",
description: "Bind host",
default: "0.0.0.0",
},
config: {
type: "string",
alias: "c",
description: "Path to rails.config.yaml",
default: "",
},
},
async run({ args }) {
const env = loadEnv(); const env = loadEnv();
const log = getLogger(); const log = getLogger();
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
const { url, close } = await startHttpServer({
port,
host: args.host ?? "0.0.0.0",
...(args.config && { configPath: args.config }),
});
log.info( log.info(
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV }, { url, nodeEnv: env.NODE_ENV },
"hanarang-rails starting", "hanarang-rails server ready",
); );
// TODO (Sprint 004): Discord bot initialization // Graceful shutdown
// TODO (Sprint 004): Gitea webhook HTTP server const shutdown = async (signal: string) => {
// For now, just keep the process alive log.info({ signal }, "Shutdown requested");
log.info("Orchestrator running. Press Ctrl+C to stop."); try {
await close();
await disconnectPrisma();
} catch (err) {
log.error(
{ err: err instanceof Error ? err.message : String(err) },
"Shutdown error",
);
}
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
// Keep alive
await new Promise<never>(() => { await new Promise<never>(() => {
// keep alive until signal /* block until signal */
}); });
}, },
}); });

83
src/handoff/build.ts Normal file
View 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;
}

View 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
View 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
View 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
View File

@@ -0,0 +1,60 @@
import { z } from "zod";
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
export type Role = z.infer<typeof Role>;
export const ROLE_KOREAN: Record<Role, string> = {
manager: "부장",
principal: "수석",
lead: "선임",
junior: "신입",
};
export interface RoleConfig {
primaryModel: string;
fallbackModel: string;
canSpawn: Role[];
maxSpawnPerCall: number;
}
/**
* Default role definitions. Can be overridden by roles.yaml in sister-agent.
*/
export const DEFAULT_ROLE_CONFIG: Record<Role, RoleConfig> = {
manager: {
primaryModel: "gpt-5.4",
fallbackModel: "glm-5.1",
canSpawn: ["principal", "lead", "junior"],
maxSpawnPerCall: 4,
},
principal: {
primaryModel: "gpt-5.4",
fallbackModel: "glm-5.1",
canSpawn: ["lead", "junior"],
maxSpawnPerCall: 3,
},
lead: {
primaryModel: "gpt-codex-5.3",
fallbackModel: "glm-5",
canSpawn: ["junior"],
maxSpawnPerCall: 4,
},
junior: {
primaryModel: "glm-5-turbo",
fallbackModel: "gpt-5",
canSpawn: [],
maxSpawnPerCall: 0,
},
};
export interface ConcurrencyLimits {
default: number;
overrides: Record<string, number>;
}
export const DEFAULT_CONCURRENCY: ConcurrencyLimits = {
default: 8,
overrides: {
narang: 6, // tighter when a build is running
},
};

234
src/hierarchy/store.ts Normal file
View File

@@ -0,0 +1,234 @@
import { z } from "zod";
import { getPrisma } from "../orchestrator/persist.js";
import { Role } from "./roles.js";
import { ComplexityTier } from "./complexity.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "sub-task-store" });
export const CreateSubTaskInput = z.object({
id: z.string().min(1),
pipelineId: z.string().min(1),
parentId: z.string().nullable().default(null),
role: Role,
agentName: z.string().min(1),
title: z.string(),
description: z.string().default(""),
complexityScore: z.number().int().nullable().default(null),
complexityTier: ComplexityTier.nullable().default(null),
model: z.string().default(""),
});
export type CreateSubTaskInput = z.infer<typeof CreateSubTaskInput>;
export const SubTaskEventInput = z.object({
subTaskId: z.string().min(1),
eventType: z.enum([
"spawned",
"started",
"progress",
"output",
"completed",
"failed",
"escalated",
]),
payload: z.record(z.unknown()).default({}),
});
export type SubTaskEventInput = z.infer<typeof SubTaskEventInput>;
export const UpdateSubTaskInput = z.object({
state: z
.enum(["queued", "running", "done", "failed", "escalated"])
.optional(),
resultJson: z.string().optional(),
errorReason: z.string().optional(),
startedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().optional(),
});
export type UpdateSubTaskInput = z.infer<typeof UpdateSubTaskInput>;
export async function createSubTask(input: CreateSubTaskInput): Promise<void> {
const prisma = getPrisma();
await prisma.subTask.create({
data: {
id: input.id,
pipelineId: input.pipelineId,
parentId: input.parentId,
role: input.role,
agentName: input.agentName,
title: input.title.slice(0, 500),
description: input.description,
state: "queued",
complexityScore: input.complexityScore,
complexityTier: input.complexityTier,
model: input.model,
},
});
log.info(
{
id: input.id,
role: input.role,
agent: input.agentName,
parent: input.parentId,
},
"Sub-task created",
);
}
export async function updateSubTask(
id: string,
patch: UpdateSubTaskInput,
): Promise<void> {
const prisma = getPrisma();
await prisma.subTask.update({
where: { id },
data: {
...patch,
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
},
});
}
export async function recordSubTaskEvent(
input: SubTaskEventInput,
): Promise<void> {
const prisma = getPrisma();
await prisma.subTaskEvent.create({
data: {
subTaskId: input.subTaskId,
eventType: input.eventType,
payloadJson: JSON.stringify(input.payload),
},
});
// Auto-advance state based on event type
const stateMap: Record<string, string | null> = {
started: "running",
completed: "done",
failed: "failed",
escalated: "escalated",
};
const newState = stateMap[input.eventType];
if (newState) {
const patch: UpdateSubTaskInput = { state: newState as UpdateSubTaskInput["state"] };
if (input.eventType === "started") {
patch.startedAt = new Date().toISOString();
} else if (["completed", "failed", "escalated"].includes(input.eventType)) {
patch.completedAt = new Date().toISOString();
}
await prisma.subTask.update({
where: { id: input.subTaskId },
data: {
...patch,
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
},
});
}
}
function tryParseJson(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return s;
}
}
export async function getSubTaskDetail(id: string): Promise<unknown | null> {
const prisma = getPrisma();
const node = await prisma.subTask.findUnique({
where: { id },
include: {
events: {
orderBy: { timestamp: "asc" },
select: {
id: true,
eventType: true,
payloadJson: true,
timestamp: true,
},
},
},
});
if (!node) return null;
// Walk up parent chain
const parents: Array<{ id: string; role: string; title: string }> = [];
let cursor: string | null = node.parentId;
while (cursor) {
const p = await prisma.subTask.findUnique({
where: { id: cursor },
select: { id: true, parentId: true, role: true, title: true },
});
if (!p) break;
parents.unshift({ id: p.id, role: p.role, title: p.title });
cursor = p.parentId;
}
// Direct children list
const children = await prisma.subTask.findMany({
where: { parentId: id },
orderBy: { createdAt: "asc" },
select: {
id: true,
role: true,
agentName: true,
title: true,
state: true,
model: true,
startedAt: true,
completedAt: true,
},
});
return {
...node,
parents,
childrenList: children,
events: node.events.map((e) => ({
id: e.id,
eventType: e.eventType,
payload: tryParseJson(e.payloadJson),
timestamp: e.timestamp,
})),
};
}
export async function getSubTaskTree(pipelineId: string): Promise<unknown> {
const prisma = getPrisma();
const all = await prisma.subTask.findMany({
where: { pipelineId },
orderBy: { createdAt: "asc" },
select: {
id: true,
parentId: true,
role: true,
agentName: true,
title: true,
state: true,
complexityScore: true,
complexityTier: true,
model: true,
startedAt: true,
completedAt: true,
createdAt: true,
},
});
// Build tree
const byId = new Map<string, { id: string; parentId: string | null; children: unknown[] } & Record<string, unknown>>();
for (const t of all) {
byId.set(t.id, { ...t, children: [] });
}
const roots: unknown[] = [];
for (const t of all) {
const node = byId.get(t.id)!;
if (t.parentId && byId.has(t.parentId)) {
(byId.get(t.parentId)!.children as unknown[]).push(node);
} else {
roots.push(node);
}
}
return roots;
}

View File

@@ -1,5 +1,5 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { createActor, type Snapshot } from "xstate"; import { createActor } from "xstate";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { pipelineMachine } from "./machine.js"; import { pipelineMachine } from "./machine.js";
import { createInitialContext, type PipelineContext } from "./context.js"; import { createInitialContext, type PipelineContext } from "./context.js";
@@ -17,6 +17,13 @@ export function getPrisma(): PrismaClient {
return _prisma; return _prisma;
} }
/**
* Pipeline persistence layout:
* - pipelines.currentState — XState state value (for quick queries)
* - pipelines.contextJson — FULL persisted snapshot JSON from XState v5
* (includes value, context, status, children, etc.)
*/
export async function createPipeline( export async function createPipeline(
projectName: string, projectName: string,
requirements: string, requirements: string,
@@ -25,20 +32,27 @@ export async function createPipeline(
const pipelineId = ulid(); const pipelineId = ulid();
const ctx = createInitialContext(pipelineId, projectName, requirements); const ctx = createInitialContext(pipelineId, projectName, requirements);
const actor = createActor(pipelineMachine, { const actor = createActor(pipelineMachine, { input: ctx });
input: ctx,
});
actor.start(); actor.start();
// Manually set pipelineId into context via assign on fresh start is awkward;
// just store the ctx alongside the persisted snapshot for later restoration.
const persistedSnapshot = actor.getPersistedSnapshot();
const snapshot = actor.getSnapshot(); const snapshot = actor.getSnapshot();
actor.stop(); actor.stop();
// Merge our pipelineId into the persisted context for recovery
const persistedWithId = mergeContextIntoSnapshot(
persistedSnapshot,
ctx,
);
await prisma.pipeline.create({ await prisma.pipeline.create({
data: { data: {
id: pipelineId, id: pipelineId,
projectName, projectName,
requirements, requirements,
currentState: String(snapshot.value), currentState: String(snapshot.value),
contextJson: JSON.stringify(ctx), contextJson: JSON.stringify(persistedWithId),
}, },
}); });
@@ -56,21 +70,22 @@ export async function sendEvent(
where: { id: pipelineId }, where: { id: pipelineId },
}); });
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
const fromState = pipeline.currentState; const fromState = pipeline.currentState;
const persistedSnapshot = JSON.parse(pipeline.contextJson) as unknown;
// XState v5 accepts a persisted snapshot via the options object.
// We bypass the strict generic typing because the snapshot is produced
// by the same machine and serialised through JSON.
const actor = createActor(pipelineMachine, { const actor = createActor(pipelineMachine, {
snapshot: { snapshot: persistedSnapshot,
value: fromState, } as Parameters<typeof createActor>[1]);
context: ctx,
} as unknown as Snapshot<unknown>,
});
actor.start(); actor.start();
actor.send(event); actor.send(event);
const snapshot = actor.getSnapshot(); const snapshot = actor.getSnapshot();
const toState = String(snapshot.value); const toState = String(snapshot.value);
const newContext = snapshot.context as PipelineContext; const newContext = snapshot.context as PipelineContext;
const newPersistedSnapshot = actor.getPersistedSnapshot();
actor.stop(); actor.stop();
await prisma.$transaction([ await prisma.$transaction([
@@ -78,7 +93,7 @@ export async function sendEvent(
where: { id: pipelineId }, where: { id: pipelineId },
data: { data: {
currentState: toState, currentState: toState,
contextJson: JSON.stringify(newContext), contextJson: JSON.stringify(newPersistedSnapshot),
}, },
}), }),
prisma.stateTransition.create({ prisma.stateTransition.create({
@@ -131,13 +146,97 @@ export async function getPipelineState(
if (!pipeline) return null; if (!pipeline) return null;
const snap = JSON.parse(pipeline.contextJson) as { context?: PipelineContext };
const context =
(snap.context as PipelineContext | undefined) ??
(snap as unknown as PipelineContext);
return { return {
state: pipeline.currentState as PipelineState, state: pipeline.currentState as PipelineState,
context: JSON.parse(pipeline.contextJson) as PipelineContext, context,
transitions: pipeline.transitions, transitions: pipeline.transitions,
}; };
} }
/**
* Merge our canonical PipelineContext into the XState persisted snapshot.
* XState v5 snapshots include `.context`, so we overlay our values.
*/
function mergeContextIntoSnapshot(
snapshot: unknown,
ctx: PipelineContext,
): unknown {
if (snapshot && typeof snapshot === "object") {
return { ...(snapshot as object), context: ctx };
}
return snapshot;
}
export async function listTransitions(opts?: {
pipelineId?: string;
eventType?: string;
limit?: number;
}): Promise<
Array<{
id: number;
pipelineId: string;
fromState: string;
toState: string;
eventType: string;
timestamp: Date;
}>
> {
const prisma = getPrisma();
const where: { pipelineId?: string; eventType?: string } = {};
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
if (opts?.eventType) where.eventType = opts.eventType;
return prisma.stateTransition.findMany({
where,
orderBy: { timestamp: "desc" },
take: opts?.limit ?? 100,
select: {
id: true,
pipelineId: true,
fromState: true,
toState: true,
eventType: true,
timestamp: true,
},
});
}
export async function listEscalations(opts?: {
pipelineId?: string;
resolved?: boolean;
limit?: number;
}): Promise<
Array<{
id: string;
pipelineId: string;
reason: string;
errorCategory: string;
stage: string;
attempts: number;
contextSnapshot: string;
resolvedAt: Date | null;
resolution: string | null;
createdAt: Date;
}>
> {
const prisma = getPrisma();
const where: { pipelineId?: string; resolvedAt?: null | { not: null } } = {};
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
if (opts?.resolved === false) where.resolvedAt = null;
if (opts?.resolved === true) where.resolvedAt = { not: null };
return prisma.escalation.findMany({
where,
orderBy: { createdAt: "desc" },
take: opts?.limit ?? 50,
});
}
export async function listPipelines(opts?: { export async function listPipelines(opts?: {
state?: PipelineState; state?: PipelineState;
limit?: number; limit?: number;

View File

@@ -89,14 +89,14 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
description: opts.requirements, description: opts.requirements,
workdir: process.cwd(), workdir: process.cwd(),
}, },
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 30_000, timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
structuredOutput: true, structuredOutput: true,
}; };
const retryResult = await withRetry( const retryResult = await withRetry(
async () => transport.invoke(invokeReq, opts.signal), async () => transport.invoke(invokeReq, opts.signal),
{ {
maxRetries: opts.maxRetries ?? 3, maxRetries: opts.maxRetries ?? 1,
...(opts.signal && { signal: opts.signal }), ...(opts.signal && { signal: opts.signal }),
}, },
); );

284
src/server/http.ts Normal file
View File

@@ -0,0 +1,284 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { z } from "zod";
import {
createPipeline,
getPipelineState,
listPipelines,
listTransitions,
listEscalations,
sendEvent,
} from "../orchestrator/persist.js";
import { runPipeline } from "../orchestrator/runner.js";
import { loadConfig } from "../config/loader.js";
import type { SisterTransport } from "../handoff/transport.js";
import { buildTransports } from "../handoff/build.js";
import {
CreateSubTaskInput,
SubTaskEventInput,
UpdateSubTaskInput,
createSubTask,
recordSubTaskEvent,
updateSubTask,
getSubTaskTree,
getSubTaskDetail,
} from "../hierarchy/store.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "http-server" });
const StartRequest = z.object({
project: z.string().min(1),
requirements: z.string().default(""),
mock: z.boolean().default(true),
});
const AbortRequest = z.object({
reason: z.string().default("aborted via api"),
});
interface ServerOpts {
port: number;
host?: string;
configPath?: string;
}
export async function startHttpServer(opts: ServerOpts): Promise<{
close: () => Promise<void>;
url: string;
}> {
const host = opts.host ?? "0.0.0.0";
const config = await loadConfig(opts.configPath);
// Build transport map from config + env (auto picks mock or http)
const transports: Map<string, SisterTransport> = buildTransports(config);
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${host}`);
const path = url.pathname;
const method = req.method ?? "GET";
log.debug({ method, path }, "Incoming request");
try {
// ── Health ──
if (method === "GET" && path === "/health") {
return sendJson(res, 200, { ok: true, service: "hanarang-rails" });
}
// ── List pipelines ──
if (method === "GET" && path === "/pipelines") {
const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
const list = await listPipelines({ limit });
return sendJson(res, 200, { pipelines: list });
}
// ── Start new pipeline ──
if (method === "POST" && path === "/pipelines/start") {
const body = await readJson(req);
const parsed = StartRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_request",
issues: parsed.error.issues,
});
}
const { project, requirements } = parsed.data;
// Run pipeline (async, but we await for this simple demo)
// Transport is determined by env RAILS_TRANSPORT_MODE and rails.config.yaml
const result = await runPipeline({
projectName: project,
requirements,
config,
transports,
});
return sendJson(res, 201, {
pipelineId: result.pipelineId,
finalState: result.finalState,
transitions: result.transitions,
});
}
// ── Get pipeline status ──
const statusMatch = path.match(/^\/pipelines\/([^/]+)$/);
if (method === "GET" && statusMatch) {
const id = statusMatch[1]!;
const state = await getPipelineState(id);
if (!state) return sendJson(res, 404, { error: "not_found" });
return sendJson(res, 200, state);
}
// ── Abort pipeline ──
const abortMatch = path.match(/^\/pipelines\/([^/]+)\/abort$/);
if (method === "POST" && abortMatch) {
const id = abortMatch[1]!;
const body = await readJson(req);
const parsed = AbortRequest.safeParse(body || {});
const reason = parsed.success
? parsed.data.reason
: "aborted via api";
const result = await sendEvent(id, { type: "ABORT", reason });
return sendJson(res, 200, { id, state: result.state });
}
// ── Create pipeline without running ──
if (method === "POST" && path === "/pipelines") {
const body = await readJson(req);
const parsed = StartRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_request",
issues: parsed.error.issues,
});
}
const { project, requirements } = parsed.data;
const { pipelineId, state } = await createPipeline(project, requirements);
return sendJson(res, 201, { pipelineId, state });
}
// ── Sub-task creation ──
if (method === "POST" && path === "/api/sub-tasks") {
const body = await readJson(req);
const parsed = CreateSubTaskInput.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_sub_task",
issues: parsed.error.issues,
});
}
await createSubTask(parsed.data);
return sendJson(res, 201, { id: parsed.data.id });
}
// ── Sub-task update (state, result) ──
const subTaskMatch = path.match(/^\/api\/sub-tasks\/([^/]+)$/);
if (method === "PATCH" && subTaskMatch) {
const id = subTaskMatch[1]!;
const body = await readJson(req);
const parsed = UpdateSubTaskInput.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_patch",
issues: parsed.error.issues,
});
}
await updateSubTask(id, parsed.data);
return sendJson(res, 200, { id });
}
// ── Sub-task event ──
const eventMatch = path.match(/^\/api\/sub-tasks\/([^/]+)\/events$/);
if (method === "POST" && eventMatch) {
const id = eventMatch[1]!;
const body = await readJson(req);
const parsed = SubTaskEventInput.safeParse({
...(body as Record<string, unknown>),
subTaskId: id,
});
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_event",
issues: parsed.error.issues,
});
}
await recordSubTaskEvent(parsed.data);
return sendJson(res, 201, { ok: true });
}
// ── Sub-task tree by pipeline ──
const treeMatch = path.match(/^\/api\/pipelines\/([^/]+)\/sub-tasks$/);
if (method === "GET" && treeMatch) {
const pid = treeMatch[1]!;
const tree = await getSubTaskTree(pid);
return sendJson(res, 200, { pipelineId: pid, tree });
}
// ── Single sub-task detail ──
const detailMatch = path.match(/^\/api\/sub-tasks\/([^/]+)$/);
if (method === "GET" && detailMatch) {
const id = detailMatch[1]!;
const detail = await getSubTaskDetail(id);
if (!detail) return sendJson(res, 404, { error: "not_found" });
return sendJson(res, 200, detail);
}
// ── State transitions (SIEM-style log) ──
if (method === "GET" && path === "/api/transitions") {
const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
const pid = url.searchParams.get("pipelineId") ?? undefined;
const eventType = url.searchParams.get("eventType") ?? undefined;
const transitions = await listTransitions({
...(pid !== undefined && { pipelineId: pid }),
...(eventType !== undefined && { eventType }),
limit,
});
return sendJson(res, 200, { transitions });
}
// ── Escalations ──
if (method === "GET" && path === "/api/escalations") {
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
const pid = url.searchParams.get("pipelineId") ?? undefined;
const resolvedQ = url.searchParams.get("resolved");
const opts: { pipelineId?: string; resolved?: boolean; limit: number } = { limit };
if (pid !== undefined) opts.pipelineId = pid;
if (resolvedQ === "true") opts.resolved = true;
else if (resolvedQ === "false") opts.resolved = false;
const escalations = await listEscalations(opts);
return sendJson(res, 200, { escalations });
}
return sendJson(res, 404, { error: "not_found", path });
} catch (err) {
log.error(
{ err: err instanceof Error ? err.message : String(err) },
"Request handler error",
);
return sendJson(res, 500, {
error: "internal_error",
message: err instanceof Error ? err.message : String(err),
});
}
});
await new Promise<void>((resolveFn) => {
server.listen(opts.port, host, () => resolveFn());
});
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${opts.port}`;
log.info({ url }, "HTTP server listening");
return {
url,
async close() {
await new Promise<void>((resolveFn, rejectFn) => {
server.close((err) => (err ? rejectFn(err) : resolveFn()));
});
},
};
}
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolveFn, rejectFn) => {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
req.on("end", () => {
if (!body) return resolveFn({});
try {
resolveFn(JSON.parse(body));
} catch (err) {
rejectFn(err);
}
});
req.on("error", rejectFn);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store",
});
res.end(JSON.stringify(body));
}