Compare commits
29 Commits
feature/st
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c58bc311a6 | |||
| ece52f9dd5 | |||
| fb90abc8e3 | |||
| fc646894d9 | |||
| 185320f1b9 | |||
| 6c6a0a50dc | |||
| 6c43cccca5 | |||
| fe9ec0d9db | |||
| 294abdbc25 | |||
| ae2d4b1d3e | |||
| 6627ad709f | |||
| 13b2c00048 | |||
| 809d5b94c4 | |||
| 4a77f43a32 | |||
| e4f8e6ef53 | |||
| 08ea92f540 | |||
| 8c123cb03a | |||
| c2e89ec5da | |||
| be8715a7f0 | |||
| 17b2f2b232 | |||
| 1c25fb3b5b | |||
| 6a8599e0b3 | |||
| a7d5a2bdec | |||
| 9f238f570d | |||
| 98540af98c | |||
| 579137e4bf | |||
| 9aeef223c6 | |||
| e2d71ca47d | |||
| 3e354d21c1 |
135
.env.example
135
.env.example
@@ -1,17 +1,130 @@
|
||||
# hanarang-rails environment variables
|
||||
# Copy to .env and fill in values.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# hanarang-rails — environment configuration
|
||||
# Copy this file to `.env` and fill in the values you need.
|
||||
#
|
||||
# The file is grouped into:
|
||||
# 1. required (must set to run any pipeline)
|
||||
# 2. LLM provider (pick one)
|
||||
# 3. transport / deployment topology
|
||||
# 4. optional — Gitea push
|
||||
# 5. optional — Discord bridge
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Database (MariaDB / MySQL) ──
|
||||
# ==== 1. REQUIRED =====================================================
|
||||
|
||||
# MariaDB / MySQL connection string used by Prisma.
|
||||
# For docker-compose, use:
|
||||
# mysql://rails:rails@mariadb:3306/hanarang_rails
|
||||
DATABASE_URL="mysql://rails:CHANGE_ME@localhost:3306/hanarang_rails"
|
||||
|
||||
# ── Discord ──
|
||||
DISCORD_TOKEN=""
|
||||
DISCORD_GUILD_ID=""
|
||||
|
||||
# ── Gitea Webhook ──
|
||||
GITEA_WEBHOOK_SECRET=""
|
||||
|
||||
# ── Rails ──
|
||||
# Rails HTTP server port
|
||||
RAILS_PORT=18800
|
||||
RAILS_LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
|
||||
|
||||
# ==== 2. LLM PROVIDER =================================================
|
||||
#
|
||||
# Pick exactly one provider for LLM_PROVIDER. Supported values:
|
||||
# mock — deterministic fake responses. No network, no money.
|
||||
# openai — OpenAI / OpenRouter / Azure OpenAI / any OpenAI-compatible API
|
||||
# anthropic — Anthropic Messages API
|
||||
# ollama — local Ollama server (https://ollama.com)
|
||||
# openclaw — hanarang-internal OpenClaw runtime (most external users won't have this)
|
||||
|
||||
LLM_PROVIDER=mock
|
||||
|
||||
# Per-role model override. Leave empty to use the defaults baked into roles.ts
|
||||
# (which are OpenClaw-flavored names — you probably need to set these for
|
||||
# openai / anthropic / ollama).
|
||||
#
|
||||
# Good starting points:
|
||||
# OpenAI: gpt-4o / gpt-4o-mini
|
||||
# Anthropic: claude-opus-4-6 / claude-haiku-4-5
|
||||
# Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b
|
||||
#
|
||||
# LLM_MODEL_MANAGER=gpt-4o
|
||||
# LLM_MODEL_PRINCIPAL=gpt-4o
|
||||
# LLM_MODEL_LEAD=gpt-4o-mini
|
||||
# LLM_MODEL_JUNIOR=gpt-4o-mini
|
||||
# LLM_MODEL_FALLBACK=gpt-4o-mini
|
||||
|
||||
# ── OpenAI (and OpenAI-compatible) ────────────────────────────────────
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# (also works for OpenRouter, Azure OpenAI, local llama.cpp servers, etc.)
|
||||
|
||||
# ── Anthropic ─────────────────────────────────────────────────────────
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
|
||||
# ── Ollama (local) ────────────────────────────────────────────────────
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# ── OpenClaw (internal) ───────────────────────────────────────────────
|
||||
# OPENCLAW_BIN=/home/you/.npm-global/bin/openclaw
|
||||
|
||||
|
||||
# ==== 3. TRANSPORT / TOPOLOGY =========================================
|
||||
#
|
||||
# rails supports three deployment topologies:
|
||||
#
|
||||
# 1. in-process — everything in one Node process. The simplest. The 4
|
||||
# sister agents are just function calls inside rails.
|
||||
# Requires sister-agent to be built under
|
||||
# ./sister-agent/dist/.
|
||||
#
|
||||
# 2. http — rails calls each sister-agent over HTTP. The sister
|
||||
# agents run as separate daemons (potentially on separate
|
||||
# machines/containers). Production topology.
|
||||
#
|
||||
# 3. mock — no LLM, no files, no push. Just exercises the FSM.
|
||||
#
|
||||
# Set via RAILS_TRANSPORT globally, or per-stage via RAILS_TRANSPORT_PLAN etc.
|
||||
|
||||
RAILS_TRANSPORT=in-process
|
||||
|
||||
# For http mode — each sister-agent daemon's HTTP endpoint:
|
||||
# SISTER_ENDPOINT_PLAN=http://harang-lxc:18801
|
||||
# SISTER_ENDPOINT_IMPLEMENT=http://narang-lxc:18801
|
||||
# SISTER_ENDPOINT_REVIEW=http://darang-lxc:18801
|
||||
# SISTER_ENDPOINT_DEPLOY=http://erang-lxc:18801
|
||||
|
||||
# Loopback URL that sister-agent uses to report sub-task events back to rails.
|
||||
# Usually the same as your rails HTTP URL as seen from the sister.
|
||||
RAILS_API_URL=http://127.0.0.1:18800
|
||||
|
||||
# For in-process mode, optional override of where to load the compiled
|
||||
# sister-agent core module from. Defaults to ./sister-agent/dist/core.js
|
||||
# SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js
|
||||
|
||||
# Where sister-agent writes per-pipeline workspaces on disk.
|
||||
# SISTER_WORKSPACE_DIR=/home/you/rails-projects
|
||||
|
||||
|
||||
# ==== 4. OPTIONAL — Gitea auto-push ===================================
|
||||
#
|
||||
# When enabled, each pipeline run auto-creates a public repo and pushes its
|
||||
# workspace to Gitea, giving you a shareable URL for the generated files.
|
||||
# Leave GITEA_TOKEN empty to skip the push step entirely.
|
||||
|
||||
# GITEA_BASE_URL=https://git.example.com
|
||||
# GITEA_ORG=my-org
|
||||
# GITEA_TOKEN=
|
||||
# GIT_USER_NAME=rails-agent
|
||||
# GIT_USER_EMAIL=rails@example.com
|
||||
# GIT_PUSH_ENABLED=true # force on/off; default is auto (on iff GITEA_TOKEN set)
|
||||
|
||||
|
||||
# ==== 5. OPTIONAL — Gitea webhook receiver ============================
|
||||
# GITEA_WEBHOOK_SECRET=
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Discord integration is NOT handled inside rails. The hanarang 4-sister
|
||||
# deployment uses OpenClaw's built-in Discord gateway, and the slash
|
||||
# command (`/hanarang_rails ...`) is exposed via an OpenClaw skill whose
|
||||
# SKILL.md frontmatter has `user-invocable: true`. The skill's handler
|
||||
# script POSTs to rails HTTP API just like any other caller.
|
||||
#
|
||||
# See ~/.openclaw/skills/hanarang-rails/SKILL.md for the wiring.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Dockerfile — builds rails + sister-agent + CLI into one image
|
||||
#
|
||||
# The image starts `rails serve` in single-process (in-process) mode.
|
||||
# For distributed mode, see docker-compose.full.yml which uses the same
|
||||
# image but overrides CMD / env vars per container.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
|
||||
RUN corepack enable \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for better layer caching
|
||||
COPY package.json pnpm-lock.yaml tsconfig.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY sister-agent/package.json ./sister-agent/
|
||||
COPY sister-agent/tsconfig.json ./sister-agent/
|
||||
|
||||
# Install deps (root + sister-agent workspace — sister-agent has its own lockfile)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
WORKDIR /app/sister-agent
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy sources
|
||||
COPY src ./src
|
||||
COPY sister-agent/src ./sister-agent/src
|
||||
|
||||
# Generate prisma client + build both
|
||||
RUN npx prisma generate \
|
||||
&& pnpm build \
|
||||
&& cd sister-agent && pnpm build
|
||||
|
||||
# ────────────── runtime image ──────────────
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN corepack enable \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates openssl wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/sister-agent/package.json ./sister-agent/
|
||||
COPY --from=builder /app/sister-agent/node_modules ./sister-agent/node_modules
|
||||
COPY --from=builder /app/sister-agent/dist ./sister-agent/dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV RAILS_PORT=18800
|
||||
ENV RAILS_TRANSPORT=in-process
|
||||
ENV SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js
|
||||
ENV SISTER_WORKSPACE_DIR=/app/rails-projects
|
||||
|
||||
EXPOSE 18800
|
||||
|
||||
# Default command: run migrations then start the HTTP server
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/cli/index.js serve"]
|
||||
179
README.md
179
README.md
@@ -1,97 +1,139 @@
|
||||
# hanarang-rails
|
||||
|
||||
> **4자매가 달릴 결정론적 레일** — HaNaRang Rails
|
||||
> **4 자매가 달릴 결정론적 레일** — HaNaRang Rails
|
||||
>
|
||||
> _사용자는 출발 버튼만 누른다. 나머지는 자매들이 자동으로 달린다._
|
||||
|
||||
`hanarang-harness`의 후계작. 기존 하네스가 "권고 기반 파이프라인"이라 자매들이 레일을 벗어나 끊기고 엇갈리던 문제를 **강제 기반 결정론 파이프라인**으로 재설계한다.
|
||||
`hanarang-rails` 는 4 개의 AI "자매" 에이전트 (하랑 / 나랑 / 다랑 / 이랑) 가 하나의 요청을 받아 **기획 → 구현 → 리뷰 → 배포**를 자동으로 완주하는 결정론적 파이프라인 오케스트레이터다. 전임자 `hanarang-harness` 가 권고 기반이라 자매들이 중간에 길을 잃던 문제를, XState 유한 상태 기계 (FSM) 와 Sprint Contract 로 물리적으로 강제한다.
|
||||
|
||||
## 왜 다시?
|
||||
- **처음 보는 사람을 위한 완전 가이드**: [`docs/GUIDE.md`](docs/GUIDE.md) / [`docs/GUIDE.pdf`](docs/GUIDE.pdf)
|
||||
- **설계 문서**: [`.plans/design/`](.plans/design/)
|
||||
- **스프린트 명세**: [`.plans/sprints/`](.plans/sprints/)
|
||||
- **실패 감사 (F1–F6)**: [`.plans/failure-audit.md`](.plans/failure-audit.md)
|
||||
|
||||
[`hanarang-harness`](https://git.nabomhalang.co.kr/hanarang/openclaw-harness)에서 발견된 6가지 실패 모드:
|
||||
---
|
||||
|
||||
## 한 문단 요약
|
||||
|
||||
사용자가 `"todo 앱 만들어 줘"` 한 줄을 던지면, rails 오케스트레이터가 **하랑이 (기획) → 나랑이 (구현) → 다랑이 (리뷰) → 이랑이 (배포)** 순서로 파이프라인을 돌린다. 각 자매는 내부에서 **부장/수석/선임/신입** 4 단계 계층으로 태스크를 쪼개서 병렬 실행하고, 만들어낸 코드 파일은 자동으로 Gitea 에 public repo 로 push 되어 즉시 접근 가능한 URL 로 바뀐다. 대시보드에서는 이 모든 과정이 실시간으로 트리 형태로 보인다.
|
||||
|
||||
## 왜 다시 만들었는가
|
||||
|
||||
[`hanarang-harness`](https://git.nabomhalang.co.kr/hanarang/openclaw-harness) 에서 4 개월 운영하며 발견한 6 가지 고질 실패 모드:
|
||||
|
||||
| 코드 | 증상 | 원인 |
|
||||
|---|---|---|
|
||||
| F1 | 하네스 skill bypass — 자매가 worker 혼자 스폰하고 처리 | skill 진입 강제 없음 |
|
||||
| F2 | DoD 자동 강제 실패 — build 통과 = 완료로 간주 | sprint contract / validator 없음 |
|
||||
| F1 | 하네스 skill bypass — 자매가 혼자 worker 스폰 | skill 진입 강제 없음 |
|
||||
| F2 | DoD 자동 강제 실패 — `build` 통과 = 완료로 판정 | sprint contract / validator 없음 |
|
||||
| F3 | QA 단계 누락 — 사용자가 수동으로 다랑이 호출 | 자동 라우팅 없음 |
|
||||
| F4 | 핸드오프 멘션 불안정 — 잘못된 자매 호출 | Lobster 분기가 LLM에 의존 |
|
||||
| F5 | 중간 끊김 — request-timed-out 반복, xhigh 무한대기 | 재시도/fallback 정책 없음 |
|
||||
| F6 | 환경 검증 누락 — "서버에 Docker 없음" 으로 skip 용인 | 환경 전제 검사 없음 |
|
||||
| F4 | 핸드오프 멘션 불안정 — 잘못된 자매 호출 | 분기가 LLM 판단에 의존 |
|
||||
| F5 | 중간 끊김 — `request-timed-out` 반복 | 재시도/에스컬레이션 정책 없음 |
|
||||
| F6 | 환경 검증 누락 — "Docker 없음" 으로 skip 허용 | 환경 전제 검사 없음 |
|
||||
|
||||
## 6가지 원칙
|
||||
본질 한 줄: **"자매가 하네스를 안 타고 본인이 처리한다."**
|
||||
|
||||
1. **결정론적 라우터** — LLM 판단이 아니라 XState FSM으로 자매 간 전이
|
||||
2. **Sprint Contract 강제** — DoD를 Zod 스키마로 정의, validator가 pass/fail 판정
|
||||
3. **Skill 강제 진입** — skill bypass를 hook으로 감지해 차단
|
||||
4. **상태 전이 기반 핸드오프** — 멘션은 사용자 알림 전용, 자매 간 통신은 FSM 상태
|
||||
5. **재시도/에스컬레이션** — timeout 자동 재시도, N회 실패 시 사용자 에스컬레이션
|
||||
6. **QA 체크리스트 강제** — 스프린트 타입별 템플릿, 다랑이가 체크박스 다 채워야 pass
|
||||
## 6 가지 설계 원칙 (하드 룰)
|
||||
|
||||
1. **강제 > 권고** — 모든 파이프라인 전이는 코드로 강제한다.
|
||||
2. **결정론적 FSM** — 자매 간 핸드오프는 XState 상태 전이다.
|
||||
3. **Sprint Contract = 불변 계약** — DoD 를 Zod 스키마로 정의, validator 가 pass/fail 판정.
|
||||
4. **Skill 강제 진입** — skill bypass 를 hook 이 감지해 차단.
|
||||
5. **QA 체크리스트 의무** — 다랑이가 체크박스 전부 채워야 PASS.
|
||||
6. **환경 검증 선행** — 실기동 검증 환경 없으면 스프린트 시작 자체를 거부.
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
```
|
||||
사용자 (디스코드)
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────┐
|
||||
│ hanarang-rails orchestrator │
|
||||
│ (XState FSM + SQLite + validator) │
|
||||
└──────────────────┬─────────────────┘
|
||||
│
|
||||
┌───────────┼───────────┬───────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
|
||||
│ 하랑 │ │ 나랑 │ │ 다랑 │ │ 이랑 │
|
||||
│Planner│ │ Impl │ │ QA │ │Deploy│
|
||||
└──────┘ └──────┘ └──────┘ └──────┘
|
||||
│ │ │ │
|
||||
└───────────┴─ OpenClaw spawn ──────┘
|
||||
│
|
||||
▼
|
||||
┌────────────┐
|
||||
│ Discord 알림│ ← 사용자 알림 전용
|
||||
└────────────┘
|
||||
사용자 (Discord / Dashboard Web)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ hanarang-dashboard │ Next.js 16 + NestJS
|
||||
│ /rails, /office, /sisters │
|
||||
└────────────┬────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ hanarang-rails orchestrator │ XState + Prisma + MariaDB
|
||||
│ FSM ─ Contract ─ Hierarchy │
|
||||
└────────────┬────────────────┘
|
||||
│ HTTP invoke
|
||||
┌──────────┼──────────┬──────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
|
||||
│하랑 │ │나랑 │ │다랑 │ │이랑 │ sister-agent × 4 LXC
|
||||
│plan │ │impl │ │review│ │deploy│
|
||||
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
|
||||
└─────────┴─ openclaw CLI ────┘ (LLM: gpt-5.4 등)
|
||||
│
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ Gitea SSOT │ git.nabomhalang.co.kr
|
||||
│ (auto-push) │ public repo per pipeline
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 레이어 | 선택 |
|
||||
|---|---|
|
||||
| 런타임 | Node 22 + TypeScript (strict) |
|
||||
| 런타임 | Node 22 + TypeScript strict |
|
||||
| 상태 머신 | XState v5 |
|
||||
| 스키마 | Zod |
|
||||
| 영속화 | SQLite (better-sqlite3) |
|
||||
| DB | MariaDB (Prisma) |
|
||||
| 프로세스 | execa + AbortController |
|
||||
| CLI | citty |
|
||||
| 로그 | pino |
|
||||
| 디스코드 | discord.js v14 |
|
||||
| 테스트 | Vitest |
|
||||
| 프론트엔드 (대시보드) | Next.js 16 + styled-components |
|
||||
| 백엔드 (대시보드) | NestJS + Socket.IO |
|
||||
|
||||
## 상태
|
||||
|
||||
**v0.1.0** — Sprint 000~007 완료. 6가지 실패 모드 전부 코어에서 해결.
|
||||
- **v0.1.0** — Sprint 000~007 완료. FSM / Contract / QA / Migration 코어. 105 테스트 통과.
|
||||
- **v0.1.1** — 실 LLM 통합 (OpenClaw infer), 4 계층 재귀 스폰, 파일 추출, Gitea auto-push, deploy URL.
|
||||
- **v0.1.2** — 대시보드 아티팩트 뷰, FileViewerModal (MD 파일 클릭 → 모달).
|
||||
- **v0.1.3** — LLM 제공자 어댑터 (OpenAI / Anthropic / Ollama / OpenClaw / mock), in-process 단일 프로세스 모드, docker-compose, Gitea 호스트 완전 외부화, 외부 배포 친화 .env.example.
|
||||
|
||||
105 테스트 통과. CLI 13 서브커맨드. 마이그레이션 도구 + QA 6 템플릿 포함.
|
||||
## 빠른 시작 (Docker, 5 분)
|
||||
|
||||
## 빠른 시작
|
||||
가장 짧은 경로. 로컬에 `docker` 와 `docker compose` 만 있으면 된다.
|
||||
|
||||
```bash
|
||||
# 설치
|
||||
bash install.sh --repo <repo-url> --dir /path/to/rails
|
||||
cd /path/to/rails
|
||||
|
||||
# 환경 확인
|
||||
pnpm rails doctor
|
||||
|
||||
# .env 설정 후 DB 마이그레이션
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
|
||||
cd hanarang-rails
|
||||
cp .env.example .env
|
||||
# DATABASE_URL 등 채우기
|
||||
pnpm prisma migrate deploy
|
||||
# .env 에서 LLM_PROVIDER=mock 으로 시작 (또는 openai/anthropic/ollama)
|
||||
|
||||
# Mock 모드로 E2E 스모크 테스트
|
||||
pnpm rails run hello-world --mock -r "Try a pipeline"
|
||||
pnpm rails status
|
||||
docker compose up --build
|
||||
# → http://localhost:18800/health 확인
|
||||
|
||||
# 다른 터미널
|
||||
curl -X POST http://localhost:18800/pipelines/start \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"project":"hello","requirements":"Say hi"}'
|
||||
```
|
||||
|
||||
이게 끝. MariaDB + rails + sister-agent 4 개가 한 컨테이너 안에서 **in-process 모드** 로 돈다. 자세한 설정 옵션 (실제 LLM 키 연결, 네이티브 설치, 분산 토폴로지, 대시보드) 은 [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) 참조.
|
||||
|
||||
### 배포 토폴로지
|
||||
|
||||
| 모드 | 설명 | 파일 |
|
||||
|---|---|---|
|
||||
| **in-process** | 모든 것을 하나의 Node 프로세스에서. 로컬 개발 기본값 | `docker-compose.yml` · `rails.config.local.yaml` |
|
||||
| **http 분산** | rails + 4 개 독립 sister-agent 컨테이너. 운영 토폴로지 | `docker-compose.full.yml` · `rails.config.distributed.yaml` |
|
||||
| **mock** | FSM 만 검증 (LLM/파일/push 없음) | `RAILS_TRANSPORT=mock` 또는 `rails run --mock` |
|
||||
|
||||
### LLM 제공자
|
||||
|
||||
어댑터가 있어 다음 중 하나를 선택할 수 있다. `.env` 의 `LLM_PROVIDER` 로 지정:
|
||||
|
||||
- `mock` — API 키 없이 결정론 스켈레톤만 확인 (기본값)
|
||||
- `openai` — OpenAI / OpenRouter / Azure OpenAI / OpenAI-호환 로컬 서버
|
||||
- `anthropic` — Anthropic Messages API
|
||||
- `ollama` — 로컬 Ollama 서버
|
||||
- `openclaw` — hanarang 내부 전용 런타임
|
||||
|
||||
## CLI 서브커맨드
|
||||
|
||||
| 명령 | 용도 |
|
||||
@@ -103,23 +145,40 @@ pnpm rails status
|
||||
| `rails abort <id>` | 강제 종료 |
|
||||
| `rails contract generate/freeze/validate/show` | Sprint Contract 관리 |
|
||||
| `rails qa run/show/templates` | QA 템플릿 실행 |
|
||||
| `rails skill-context create/show/clear` | 스킬 강제 진입 |
|
||||
| `rails skill-context create/show/clear` | Skill 강제 진입 |
|
||||
| `rails skill-trace show/blocked` | 도구 사용 감사 로그 |
|
||||
| `rails doctor` | 환경 헬스체크 |
|
||||
| `rails scaffold` | 신규 프로젝트 `.plans/` 생성 |
|
||||
| `rails migrate from-hanarang-harness <path>` | 레거시 하네스 스캔 |
|
||||
| `rails serve` | 오케스트레이터 서버 (v0.2 완성 예정) |
|
||||
| `rails serve` | 오케스트레이터 HTTP 서버 |
|
||||
|
||||
## HTTP API (orchestrator, 18800)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/health` | 헬스체크 |
|
||||
| GET | `/pipelines?limit=N` | 파이프라인 리스트 |
|
||||
| GET | `/pipelines/:id` | 파이프라인 상세 |
|
||||
| POST | `/pipelines/start` | 새 파이프라인 실행 |
|
||||
| POST | `/pipelines/:id/abort` | 강제 종료 |
|
||||
| GET | `/api/pipelines/:id/sub-tasks` | SubTask 트리 |
|
||||
| GET | `/api/sub-tasks/:id` | 서브태스크 상세 |
|
||||
| GET | `/api/transitions` | 상태 전이 이력 |
|
||||
| GET | `/api/escalations` | 에스컬레이션 큐 |
|
||||
|
||||
## 문서
|
||||
|
||||
- [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) — **30 분 퀵스타트** (본인 환경에서 처음 돌려 보기)
|
||||
- [`docs/GUIDE.md`](docs/GUIDE.md) — **완전 가이드** (전 구간 해설, 처음 보는 사람용)
|
||||
- [`docs/GUIDE.pdf`](docs/GUIDE.pdf) — 위 문서의 PDF 버전
|
||||
- [`docs/migration-guide.md`](docs/migration-guide.md) — 레거시 → rails 이전 가이드
|
||||
- [`docs/operations.md`](docs/operations.md) — 운영 가이드 (PM2, 로그, DB)
|
||||
- [`docs/discord-setup.md`](docs/discord-setup.md) — Discord 봇 연동 + marker 프로토콜
|
||||
- [`.plans/OVERVIEW.md`](.plans/OVERVIEW.md) — 프로젝트 전체 개요
|
||||
- [`.plans/failure-audit.md`](.plans/failure-audit.md) — F1–F6 실패 감사
|
||||
- [`.plans/design/`](.plans/design/) — 설계 문서 9종
|
||||
- [`.plans/design/`](.plans/design/) — 설계 문서 9 종
|
||||
- [`.plans/sprints/`](.plans/sprints/) — 스프린트 상세
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
MIT — 나봄하랑 / hanarang
|
||||
|
||||
141
docker-compose.full.yml
Normal file
141
docker-compose.full.yml
Normal file
@@ -0,0 +1,141 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# docker-compose.full.yml — production-style distributed topology
|
||||
#
|
||||
# Brings up:
|
||||
# - mariadb
|
||||
# - rails (orchestrator only)
|
||||
# - sister-harang (plan)
|
||||
# - sister-narang (implement)
|
||||
# - sister-darang (review)
|
||||
# - sister-erang (deploy)
|
||||
#
|
||||
# All 6 services share the same image but each sister container runs the
|
||||
# sister-agent HTTP daemon instead of the rails orchestrator, and rails
|
||||
# is configured to talk to them over HTTP.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env
|
||||
# docker compose -f docker-compose.full.yml up --build
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
x-sister-env: &sister-env
|
||||
NODE_ENV: production
|
||||
RAILS_API_URL: http://rails:18800
|
||||
SISTER_WORKSPACE_DIR: /app/rails-projects
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-mock}
|
||||
LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-}
|
||||
LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-}
|
||||
LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-}
|
||||
LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-}
|
||||
LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-}
|
||||
GITEA_BASE_URL: ${GITEA_BASE_URL:-}
|
||||
GITEA_ORG: ${GITEA_ORG:-}
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
|
||||
x-sister-service: &sister-service
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
command:
|
||||
["node", "sister-agent/dist/server.js"]
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: rootpw
|
||||
MARIADB_DATABASE: hanarang_rails
|
||||
MARIADB_USER: rails
|
||||
MARIADB_PASSWORD: rails
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
- "healthcheck.sh"
|
||||
- "--connect"
|
||||
- "--innodb_initialized"
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- rails-db:/var/lib/mysql
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
rails:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
sister-harang: { condition: service_started }
|
||||
sister-narang: { condition: service_started }
|
||||
sister-darang: { condition: service_started }
|
||||
sister-erang: { condition: service_started }
|
||||
environment:
|
||||
DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails
|
||||
RAILS_PORT: "18800"
|
||||
RAILS_LOG_LEVEL: info
|
||||
NODE_ENV: production
|
||||
RAILS_TRANSPORT: http
|
||||
RAILS_API_URL: http://rails:18800
|
||||
SISTER_ENDPOINT_PLAN: http://sister-harang:18801
|
||||
SISTER_ENDPOINT_IMPLEMENT: http://sister-narang:18801
|
||||
SISTER_ENDPOINT_REVIEW: http://sister-darang:18801
|
||||
SISTER_ENDPOINT_DEPLOY: http://sister-erang:18801
|
||||
SISTER_NAME_PLAN: harang
|
||||
SISTER_NAME_IMPLEMENT: narang
|
||||
SISTER_NAME_REVIEW: darang
|
||||
SISTER_NAME_DEPLOY: erang
|
||||
ports:
|
||||
- "18800:18800"
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
sister-harang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: harang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
sister-narang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: narang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
sister-darang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: darang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
sister-erang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: erang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
volumes:
|
||||
rails-db:
|
||||
|
||||
networks:
|
||||
rails-net:
|
||||
driver: bridge
|
||||
96
docker-compose.yml
Normal file
96
docker-compose.yml
Normal file
@@ -0,0 +1,96 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# docker-compose.yml — single-host / in-process mode
|
||||
#
|
||||
# Spins up:
|
||||
# - mariadb (10.11)
|
||||
# - rails (orchestrator + 4 sister agents all in one process)
|
||||
#
|
||||
# Everything runs in one container so the 4 sisters are just function
|
||||
# calls instead of 4 separate daemons. Pick this file when you want
|
||||
# "docker compose up and try it".
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # set LLM_PROVIDER and API keys
|
||||
# docker compose up --build
|
||||
#
|
||||
# Then hit http://localhost:18800/health to confirm.
|
||||
#
|
||||
# For the production topology with 4 separate sister daemons, see
|
||||
# docker-compose.full.yml instead.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: rootpw
|
||||
MARIADB_DATABASE: hanarang_rails
|
||||
MARIADB_USER: rails
|
||||
MARIADB_PASSWORD: rails
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
- "healthcheck.sh"
|
||||
- "--connect"
|
||||
- "--innodb_initialized"
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- rails-db:/var/lib/mysql
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
rails:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails
|
||||
RAILS_PORT: "18800"
|
||||
RAILS_LOG_LEVEL: info
|
||||
NODE_ENV: production
|
||||
RAILS_TRANSPORT: in-process
|
||||
RAILS_API_URL: http://127.0.0.1:18800
|
||||
SISTER_AGENT_CORE_PATH: /app/sister-agent/dist/core.js
|
||||
SISTER_WORKSPACE_DIR: /app/rails-projects
|
||||
# LLM — read from .env
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-mock}
|
||||
LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-}
|
||||
LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-}
|
||||
LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-}
|
||||
LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-}
|
||||
LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-}
|
||||
# Gitea — leave empty to disable auto-push
|
||||
GITEA_BASE_URL: ${GITEA_BASE_URL:-}
|
||||
GITEA_ORG: ${GITEA_ORG:-}
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
ports:
|
||||
- "18800:18800"
|
||||
volumes:
|
||||
- rails-workspace:/app/rails-projects
|
||||
networks:
|
||||
- rails-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:18800/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
rails-db:
|
||||
rails-workspace:
|
||||
|
||||
networks:
|
||||
rails-net:
|
||||
driver: bridge
|
||||
906
docs/GUIDE.md
Normal file
906
docs/GUIDE.md
Normal file
@@ -0,0 +1,906 @@
|
||||
---
|
||||
title: "hanarang-rails 완전 가이드"
|
||||
subtitle: "4자매 AI가 달리는 결정론적 파이프라인 — 처음 보는 사람을 위한 전 구간 해설"
|
||||
author: "나봄하랑 / hanarang"
|
||||
date: "2026-04-10"
|
||||
geometry: margin=22mm
|
||||
mainfont: "Noto Sans CJK KR"
|
||||
monofont: "JetBrains Mono"
|
||||
fontsize: 11pt
|
||||
linkcolor: "NavyBlue"
|
||||
urlcolor: "NavyBlue"
|
||||
toc: true
|
||||
toc-depth: 3
|
||||
numbersections: true
|
||||
---
|
||||
|
||||
\newpage
|
||||
|
||||
# 0. 이 문서는 누구를 위한 문서인가
|
||||
|
||||
이 문서는 **hanarang-rails 프로젝트를 처음 보는 사람**이 한 번 읽고 다음 세 가지를 완전히 이해할 수 있게 하는 것이 목표다.
|
||||
|
||||
1. **이 시스템이 무엇이고**, 왜 만들었으며, 어떤 문제를 해결하는지
|
||||
2. **코드 한 줄부터 사용자 요청까지** 어떤 경로로 흐르는지
|
||||
3. 직접 클론해서 **E2E 로 돌려보려면** 무엇이 필요한지
|
||||
|
||||
기존 AI 코딩 도구 (Claude Code, Cursor, Codex, OpenClaw) 를 써 본 경험이 있다면 이해가 빠르겠지만, 없어도 모든 용어는 문서 안에서 정의한다. LLM / 에이전트 / 파이프라인이라는 단어만 대충 알면 된다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 1. 한 문단 요약
|
||||
|
||||
**hanarang-rails 는 4 개의 AI "자매" 에이전트가 하나의 요청을 받아서 기획 → 구현 → 리뷰 → 배포를 자동으로 끝내는 결정론적 파이프라인 오케스트레이터다.** 기존 하네스는 "이 단계가 끝나면 다음 자매를 호출해 줘" 라고 LLM 에게 부탁하는 방식이었고, 그래서 자매가 중간에 길을 잃으면 사용자가 끼어들어 중재해야 했다. hanarang-rails 는 그 흐름을 XState 유한 상태 기계 (FSM) 와 Sprint Contract (DoD 의 기계 검증본) 로 물리적으로 강제한다. 자매는 "권고"를 받는 것이 아니라 **레일 위를 달리는 열차**처럼, 갈 수 있는 다음 상태가 코드로 고정되어 있다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 2. 배경 — 왜 다시 만들었는가
|
||||
|
||||
## 2.1 전임자 hanarang-harness 의 실패 모드
|
||||
|
||||
이전 프로젝트 `hanarang-harness` (Gitea 에 private archive 로 보존) 는 **권고 기반** 파이프라인이었다. 각 단계가 끝나면 LLM 이 "다음에 누구를 부르면 좋을지" 판단했고, 핸드오프는 Discord 멘션으로 전달되었다. 4 개월 운영하면서 다음 6 가지 고질 문제가 반복됐다.
|
||||
|
||||
| 코드 | 증상 | 원인 |
|
||||
|---|---|---|
|
||||
| F1 | 하네스 skill 우회 — 자매가 혼자 worker 스폰해서 처리 | skill 진입 강제 부재 |
|
||||
| F2 | DoD 자동 강제 실패 — `build` 통과만 보고 완료 판정 | sprint contract / validator 부재 |
|
||||
| F3 | QA 단계 누락 — 사용자가 수동으로 다랑이 호출 필요 | 자동 라우팅 없음 |
|
||||
| F4 | 핸드오프 멘션 불안정 — 잘못된 자매 호출 | 분기가 LLM 판단에 의존 |
|
||||
| F5 | 중간 끊김 — `request-timed-out` 반복, `xhigh` 무한 대기 | 재시도/에스컬레이션 정책 없음 |
|
||||
| F6 | 환경 검증 누락 — "Docker 없음" 으로 작업 skip 허용 | 환경 전제 검사 없음 |
|
||||
|
||||
본질은 단 한 줄로 요약된다: **"자매가 하네스를 안 타고 본인이 처리한다."**
|
||||
|
||||
## 2.2 해결 전략 — 6 가지 설계 원칙
|
||||
|
||||
`.claude/rules/principles.md` 에 명시된 하드 룰이다. 이 원칙은 타협하지 않는다.
|
||||
|
||||
1. **강제 > 권고.** 모든 파이프라인 전이는 코드로 강제한다. LLM 판단에 맡기지 않는다.
|
||||
2. **결정론적 FSM.** 자매 간 핸드오프는 XState 상태 전이다. 멘션은 사용자 알림 전용이다.
|
||||
3. **Sprint Contract = 불변 계약.** 모든 스프린트는 시작 전에 `sprint-contract.json` 을 생성하고, DoD 를 Zod 스키마로 표현한 validator 가 pass / fail 을 판정한다. `build` 통과 = 완료는 금지다.
|
||||
4. **Skill 강제 진입.** OpenClaw 자매가 하네스 skill 을 우회하면 post-hook 이 감지해 작업을 revert 한다.
|
||||
5. **QA 체크리스트 의무.** 다랑이는 스프린트 타입별 체크리스트를 전부 체크해야 PASS 를 낼 수 있다.
|
||||
6. **환경 검증 선행.** 실기동 검증 환경이 없으면 스프린트를 시작하지 않는다. "Docker 없음 → skip" 같은 escape hatch 는 contract 에서 사전 차단한다.
|
||||
|
||||
## 2.3 레일 메타포
|
||||
|
||||
왜 이름이 "rails" 인가?
|
||||
|
||||
- **레일 (rail) = XState FSM**: 갈 수 있는 경로를 물리적으로 제한
|
||||
- **신호등 = Sprint Contract**: 다음 역으로 갈 수 있는 조건
|
||||
- **역 (station) = 자매 작업 단계**: Plan / Implement / Review / Deploy
|
||||
- **차단봉 = Skill 강제 진입 hook**
|
||||
- **긴급 정차 버튼 = 에스컬레이션 policy**
|
||||
- **중앙 통제소 = MariaDB orchestrator state**
|
||||
|
||||
사용자는 출발 버튼만 누르고, 긴급 상황에서만 호출된다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 3. 4 자매는 누구인가
|
||||
|
||||
4 자매는 4 개의 서로 다른 LLM 에이전트다. 각자 성격/말투/역할이 다르고, OpenClaw 런타임 위에서 독립된 LXC 컨테이너에 돌아간다.
|
||||
|
||||
| 자매 | 영문 | 역할 | 단계 | 주 모델 |
|
||||
|---|---|---|---|---|
|
||||
| 하랑 | harang | Planner — 요구사항 해석, 계획 작성 | `plan` | gpt-5.4 |
|
||||
| 나랑 | narang | Implementer — 코드/문서 생성 | `implement` | gpt-5.4 |
|
||||
| 다랑 | darang | Reviewer — QA, 체크리스트 검증 | `review` | gpt-codex-5.3 |
|
||||
| 이랑 | erang | Deployer — 배포 검증, 인프라 | `deploy` | glm-5-turbo |
|
||||
|
||||
각 자매는 내부적으로 **manager → principal → lead → junior** 4 단계 계층을 가진다. 사용자가 "X 를 만들어 줘" 라고 하면, 각 자매의 manager 가 태스크를 받고 복잡도에 따라 하위 junior / lead 에게 분배한다. 복잡한 태스크일수록 더 깊게 파고들어가 병렬 처리된다 (자세한 내용은 §7).
|
||||
|
||||
\newpage
|
||||
|
||||
# 4. 시스템 구성도
|
||||
|
||||
## 4.1 하이 레벨
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 사용자 (자기야) │
|
||||
│ Discord / Dashboard Web │
|
||||
└──────────────────────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ hanarang-dashboard │
|
||||
│ Next.js 16 (프론트) + NestJS (API) │
|
||||
│ │
|
||||
│ /rails, /rails/log, /rails/escalations, /office │
|
||||
└──────────────────────────────────┬───────────────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ hanarang-rails orchestrator │
|
||||
│ │
|
||||
│ XState FSM ─┬─ Sprint Contract Validator │
|
||||
│ ├─ SubTask Hierarchy Store │
|
||||
│ ├─ MariaDB (Prisma) │
|
||||
│ └─ HTTP API Server (citty + http) │
|
||||
└──────────────────────────────────┬───────────────────────────┘
|
||||
│ HTTP invoke
|
||||
▼
|
||||
┌────────────┬────────────┼────────────┬────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ harang │ │ narang │ │ darang │ │ erang │
|
||||
│ (LXC) │ │ (LXC) │ │ (LXC) │ │ (LXC) │
|
||||
│ │ │ │ │ │ │ │
|
||||
│sister- │ │sister- │ │sister- │ │sister- │
|
||||
│ agent │ │ agent │ │ agent │ │ agent │
|
||||
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
|
||||
│ │ │ │
|
||||
└───────────┴─────┬─────┴───────────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ openclaw CLI │ (LLM 호출: gpt-5.4 등)
|
||||
│ infer model │
|
||||
└──────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Gitea SSOT │ git.nabomhalang.co.kr
|
||||
│ (output) │ auto-push, public repos
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## 4.2 물리 토폴로지
|
||||
|
||||
| 역할 | 호스트 | IP | 내용 |
|
||||
|---|---|---|---|
|
||||
| 사용자 | 개인 PC | — | Discord 클라이언트, 대시보드 웹 브라우저 |
|
||||
| Proxmox hypervisor | `192.168.1.31` | — | VM / LXC 전체 호스트 |
|
||||
| Dev VM (SSOT) | VM 200 | `10.10.10.169` | `hanarang-rails` + `hanarang-dashboard` 실제 구동, PM2 |
|
||||
| 하랑이 LXC | LXC | 내부망 | sister-agent daemon + OpenClaw 런타임 |
|
||||
| 나랑이 LXC | LXC | 내부망 | 〃 |
|
||||
| 다랑이 LXC | LXC | 내부망 | 〃 |
|
||||
| 이랑이 LXC | LXC | 내부망 | 〃 |
|
||||
| Gitea | Docker | `git.nabomhalang.co.kr` | SSOT 저장소 (public + private), SSH 2222 |
|
||||
| MariaDB | Dev VM | `10.10.10.169:3306` | `hanarang_rails` DB |
|
||||
|
||||
하나의 파이프라인 요청은 최대 10 개 이상의 서브 프로세스로 확장될 수 있다 (4 자매 × manager/principal/lead/junior 계층). 병렬 실행은 `Promise.all` 기반이고, 동시 실행 한도는 자매별로 설정 가능하다 (기본 8, 나랑이는 6).
|
||||
|
||||
\newpage
|
||||
|
||||
# 5. 데이터 모델 — MariaDB 스키마
|
||||
|
||||
`prisma/schema.prisma` 에 정의되어 있다. 파이프라인 한 번의 실행이 각 테이블에 남기는 흔적을 따라가면 시스템 전체가 보인다.
|
||||
|
||||
## 5.1 테이블 요약
|
||||
|
||||
| 테이블 | 설명 | 키 |
|
||||
|---|---|---|
|
||||
| `pipelines` | 하나의 파이프라인 실행 (= 사용자 요청 1 회) | ULID |
|
||||
| `state_transitions` | FSM 상태 전이 로그 (감사 용) | auto |
|
||||
| `sub_tasks` | 자매/역할별 서브 태스크 트리 | ULID |
|
||||
| `sub_task_events` | 서브 태스크 수명 이벤트 (spawned/started/completed/failed) | auto |
|
||||
| `contracts` | Sprint Contract 스냅샷 (DoD + validator 정의) | ULID |
|
||||
| `escalations` | 사용자 개입이 필요해진 예외 상황 | ULID |
|
||||
| `actor_spawns` | 자매 프로세스 스폰 로그 (레거시) | auto |
|
||||
|
||||
## 5.2 Pipeline 레코드의 생애
|
||||
|
||||
```
|
||||
idle ─(START)─▶ running ─(ALL_STAGES_DONE)─▶ completed
|
||||
│
|
||||
├─(TIMEOUT 3회)──▶ escalated
|
||||
└─(FATAL_ERROR)──▶ failed
|
||||
```
|
||||
|
||||
`currentState` 는 XState 의 현재 노드, `contextJson` 은 FSM 의 context (전 단계 결과물 포함) 을 serialize 한 것이다. 매 전이마다 `StateTransition` row 가 한 줄씩 추가되므로, 나중에 `GET /api/transitions?pipelineId=…` 로 전체 이력을 재생할 수 있다.
|
||||
|
||||
## 5.3 SubTask 트리
|
||||
|
||||
각 파이프라인은 여러 개의 `sub_tasks` 를 만든다. 예를 들어 "todo 앱 만들어 줘" 라는 요청 하나가 다음 트리를 만들 수 있다.
|
||||
|
||||
```
|
||||
harang-manager (role=manager, stage=plan)
|
||||
└─ harang-principal (plan 의 세부 항목 3 개를 쪼갬)
|
||||
├─ harang-lead-1
|
||||
└─ harang-lead-2
|
||||
narang-manager (role=manager, stage=implement)
|
||||
├─ narang-lead-frontend
|
||||
│ ├─ narang-junior-html
|
||||
│ ├─ narang-junior-css
|
||||
│ └─ narang-junior-js
|
||||
└─ narang-lead-backend
|
||||
└─ narang-junior-api
|
||||
darang-manager (role=manager, stage=review)
|
||||
erang-manager (role=manager, stage=deploy)
|
||||
```
|
||||
|
||||
`parentId` 체인으로 트리를 재구성할 수 있고, 대시보드의 "서브태스크 상세 드로어" 가 이 트리를 직접 렌더링한다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 6. Orchestrator — XState FSM 엔진
|
||||
|
||||
`src/orchestrator/` 는 파이프라인의 심장이다.
|
||||
|
||||
## 6.1 파일 구조
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `machine.ts` | XState `setup({types}).createMachine(…)` 로 FSM 정의 |
|
||||
| `runner.ts` | 파이프라인 실행 루프 (actor 생성, 이벤트 dispatch, 단계 간 체이닝) |
|
||||
| `persist.ts` | `getPersistedSnapshot()` 으로 FSM 상태를 DB 에 왕복 저장 |
|
||||
| `context.ts` | FSM context 타입 (pipelineId, stage 결과, priorStages…) |
|
||||
| `events.ts` | `START`, `STAGE_DONE`, `TIMEOUT`, `FATAL_ERROR` 등 이벤트 스키마 |
|
||||
|
||||
## 6.2 상태 흐름
|
||||
|
||||
```
|
||||
[ idle ]
|
||||
│ START
|
||||
▼
|
||||
[ running ]
|
||||
│
|
||||
├─ stage="plan" ──▶ spawn harang ──▶ priorStages.push
|
||||
│ │
|
||||
├─ stage="implement" ──▶ spawn narang ──┤
|
||||
│ │
|
||||
├─ stage="review" ──▶ spawn darang ─────┤
|
||||
│ │
|
||||
└─ stage="deploy" ──▶ spawn erang ──────┤
|
||||
│
|
||||
▼
|
||||
[ completed ]
|
||||
```
|
||||
|
||||
각 stage 는 순차적으로 실행되지만, **stage 내부** 에서는 계층 구조 (manager → principal → lead → junior) 가 `Promise.all` 로 병렬 실행된다. 그래서 한 stage 안에 10 개 이상의 junior 가 동시에 코드를 쓰는 일이 자주 생긴다.
|
||||
|
||||
## 6.3 priorStages 체이닝
|
||||
|
||||
가장 중요한 구조적 결정. `plan` 의 결과물 텍스트가 `implement` 의 프롬프트에 통째로 들어간다. `implement` 가 만든 파일 목록이 `review` 의 입력이 되고, `review` 의 verdict 가 `deploy` 의 컨텍스트가 된다. 자매는 다음 자매의 결과물을 모른 채 일하지 않는다.
|
||||
|
||||
구현:
|
||||
|
||||
```ts
|
||||
// src/orchestrator/runner.ts
|
||||
const priorStages: PriorStageOutput[] = [];
|
||||
for (const stage of ["plan", "implement", "review", "deploy"]) {
|
||||
const result = await invokeSister(stage, { priorStages });
|
||||
priorStages.push({ stage, text: extractStageText(result) });
|
||||
}
|
||||
```
|
||||
|
||||
`extractStageText` 는 결과물 JSON 에서 `summary`, `repoUrl`, `rawUrlBase`, `producedFiles`, `filesCount` 를 뽑아 자연어 요약으로 합친다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 7. 역할 계층 — Manager/Principal/Lead/Junior
|
||||
|
||||
## 7.1 왜 계층이 있는가
|
||||
|
||||
LLM 한 개에게 "todo 앱 풀스택으로 만들어 줘" 라고 던지면 컨텍스트 한계에 부딪힌다. 사람 팀과 똑같이, 부장은 방향을 결정하고 신입은 코드를 친다. 이걸 구조적으로 강제하면 LLM 의 약점 (컨텍스트 파편화, 집중력 분산) 을 회피할 수 있다.
|
||||
|
||||
## 7.2 역할 정의 (`src/hierarchy/roles.ts`)
|
||||
|
||||
| Role | 한국어 | 주 모델 | 하위 스폰 가능 | 최대 스폰 |
|
||||
|---|---|---|---|---|
|
||||
| manager | 부장 | gpt-5.4 | principal, lead, junior | 4 |
|
||||
| principal | 수석 | gpt-5.4 | lead, junior | 3 |
|
||||
| lead | 선임 | gpt-codex-5.3 | junior | 4 |
|
||||
| junior | 신입 | glm-5-turbo | (없음) | 0 |
|
||||
|
||||
manager 는 직접 코드를 짜지 않는다. 대신 하위 직원에게 쪼개서 던진다. junior 는 리프 노드이며 실제 파일 생성을 책임진다.
|
||||
|
||||
## 7.3 복잡도 스코어 (`src/hierarchy/complexity.ts`)
|
||||
|
||||
태스크가 들어오면 먼저 complexity 점수를 계산한다.
|
||||
|
||||
```
|
||||
score = (길이_점수 × 0.3)
|
||||
+ (키워드_점수 × 0.5)
|
||||
+ (범위_점수 × 0.2)
|
||||
```
|
||||
|
||||
키워드 "풀스택", "데이터베이스", "인증", "배포", "아키텍처" 등은 가산점. 최종 score (0–100) 는 tier 로 매핑된다.
|
||||
|
||||
| Tier | 점수 | 권장 분해 |
|
||||
|---|---|---|
|
||||
| trivial | 0–20 | junior 한 명 |
|
||||
| simple | 21–40 | lead 한 명 또는 junior 2 |
|
||||
| moderate | 41–60 | principal 1, lead 1, junior 2–3 |
|
||||
| complex | 61–80 | principal 1, lead 2, junior 4 |
|
||||
| massive | 81–100 | principal 2, lead 3, junior 6+ |
|
||||
|
||||
이 분해는 `planner.ts` 의 `DecompositionPlan` 으로 표현되고, `spawn.ts` 의 재귀 트리 워커가 그걸 받아 실제 LLM 호출 그래프를 만든다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 8. Sister Agent — 레일 위를 달리는 열차
|
||||
|
||||
`sister-agent/` 는 각 LXC 에 독립적으로 배포되는 daemon 이다. 네 자매 모두 동일한 코드 베이스를 쓰지만, 환경변수 `AGENT_NAME` (harang / narang / darang / erang) 로 정체성을 구분한다.
|
||||
|
||||
## 8.1 엔드포인트
|
||||
|
||||
```
|
||||
POST /invoke
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"pipelineId": "01HXXXX...",
|
||||
"stage": "implement",
|
||||
"task": { "title": "...", "description": "...", "workdir": "" },
|
||||
"priorStages": [ { "stage": "plan", "text": "..." } ],
|
||||
"timeoutMs": 600000,
|
||||
"railsApiUrl": "http://10.10.10.169:18800"
|
||||
}
|
||||
```
|
||||
|
||||
리턴은 `HandoffMessage` 디스크리미네이티드 유니온이다.
|
||||
|
||||
```
|
||||
{ "stage": "implement", "verdict": "IMPL_DONE",
|
||||
"payload": { "branch":"main", "commits":[...], "workdir":"...",
|
||||
"selfTestReport": { "producedFiles":[...], "repoUrl":"..." }}}
|
||||
```
|
||||
|
||||
## 8.2 실행 파이프라인 (`src/spawn.ts`)
|
||||
|
||||
```
|
||||
runSpawnNode(ctx, node)
|
||||
│
|
||||
├─ prompts.build(role, stage, task, priorStages) // 한국어 역할 프롬프트
|
||||
│
|
||||
├─ llm.infer(prompt, model) // openclaw CLI 호출
|
||||
│
|
||||
├─ maybeExtractFiles(llmText, role, stage) // 코드 블록 파싱
|
||||
│ ├─ ```lang:path 패턴 감지
|
||||
│ ├─ 파일 경로 sanitize
|
||||
│ └─ ctx.producedFiles.push(`${stage}/files/${path}`)
|
||||
│
|
||||
├─ for child of node.children: // 하위 직원 재귀
|
||||
│ await runSpawnNode(ctx, child) // Promise.all
|
||||
│
|
||||
└─ buildSuccessResult(node, producedFiles)
|
||||
```
|
||||
|
||||
## 8.3 LLM 호출 — `openclaw infer model run`
|
||||
|
||||
각 자매는 로컬에서 `openclaw infer model run --model gpt-5.4 --json` 서브프로세스를 실행한다. stdout 은 Zod 로 검증된 후 쓴다. LLM 응답의 결정론성은 아래 세 가지로 관리한다.
|
||||
|
||||
1. **엄격한 프롬프트 템플릿** — 역할/단계별 한국어 템플릿이 `prompts.ts` 에 고정
|
||||
2. **구조화 응답 요구** — "이 형식 밖으로 나가면 재시도" 지시를 프롬프트 끝에 삽입
|
||||
3. **코드 블록 규약** — ` ```lang:path/to/file.ext` 형태로 내놓으라고 명시, 파서가 이걸 기대
|
||||
|
||||
## 8.4 코드 블록 추출 (`src/code-extractor.ts`)
|
||||
|
||||
LLM 응답에서 파일을 꺼내는 로직이다. 기대 포맷:
|
||||
|
||||
````
|
||||
```html:frontend/index.html
|
||||
<!doctype html>
|
||||
...
|
||||
```
|
||||
|
||||
```css:frontend/style.css
|
||||
body { ... }
|
||||
```
|
||||
````
|
||||
|
||||
파서는:
|
||||
|
||||
1. 정규식으로 ` ``` ` 블록 탐지
|
||||
2. 언어 뒤의 `:path` 힌트 추출
|
||||
3. path sanitize: `..`, 절대 경로, 백슬래시 금지
|
||||
4. path 가 없으면 언어별 기본 파일명 (`snippet.html` 등)
|
||||
5. `{ path, lang, content }` 리스트 반환
|
||||
|
||||
이 결과는 `maybeExtractFiles` 가 받아서 실제 파일로 쓰고 `ctx.producedFiles` 에 상대 경로를 기록한다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 9. Git 자동 푸시 — Gitea 연동 (`git-ops.ts`)
|
||||
|
||||
파이프라인이 만든 파일은 즉시 Gitea 에 올라가 실행 가능한 URL 로 바뀐다.
|
||||
|
||||
## 9.1 흐름
|
||||
|
||||
1. implement stage 가 끝나면 `commitAndPush(pipelineId, workdir)` 호출
|
||||
2. 리포 이름은 `rails-${pipelineId.slice(-10).toLowerCase()}` (예: `rails-abcd012345`)
|
||||
3. Gitea API 로 `hanarang` org 에 public repo 자동 생성
|
||||
`POST /api/v1/orgs/hanarang/repos`
|
||||
4. 로컬 `git init` → 커밋 → `git push https://user:TOKEN@git.nabomhalang.co.kr/…`
|
||||
5. 리턴:
|
||||
```
|
||||
{ ok:true, repoUrl:"https://git.nabomhalang.co.kr/hanarang/rails-abcd012345",
|
||||
rawUrlBase:"https://git.nabomhalang.co.kr/hanarang/rails-abcd012345/raw/branch/main",
|
||||
commit:"a1b2c3d", filesCount:7 }
|
||||
```
|
||||
|
||||
## 9.2 프리뷰 URL 추론
|
||||
|
||||
`derivePreviewUrl` 이 `producedFiles` 에서 `.html` 파일을 찾아 `${rawUrlBase}/${html파일경로}` 로 즉시 열 수 있는 공개 URL 을 계산한다. 결과는 `selfTestReport.deployUrl` 에 들어가고, 대시보드의 "url" 배지가 달린 FileRow 로 사용자에게 보여진다. 클릭하면 브라우저에서 바로 열린다.
|
||||
|
||||
## 9.3 왜 Gitea 인가
|
||||
|
||||
- `git.nabomhalang.co.kr` 은 우리 내부 SSOT 서버다 (Docker 로 Dev VM 에서 돌고 있음)
|
||||
- `gh` CLI 는 GitHub 전용이라 쓸 수 없고, 대신 `tea` CLI 또는 REST API 로 접근한다
|
||||
- 토큰: `.env` 의 `GITEA_TOKEN` 에 저장, 코드에서는 URL 에 `user:TOKEN@` 형태로만 사용
|
||||
|
||||
\newpage
|
||||
|
||||
# 10. 대시보드 — hanarang-dashboard
|
||||
|
||||
`hanarang-dashboard` 는 별도 repo 이며, rails 가 돌고 있는 모든 것을 시각화한다. Next.js 16 (Turbopack) + NestJS API + Socket.IO 실시간 이벤트로 만들어졌다.
|
||||
|
||||
## 10.1 페이지
|
||||
|
||||
| 경로 | 설명 |
|
||||
|---|---|
|
||||
| `/rails` | 활성 파이프라인 리스트 + SubTask 트리 시각화 |
|
||||
| `/rails/log` | 상태 전이 감사 로그 (SIEM 스타일) |
|
||||
| `/rails/escalations` | 에스컬레이션 큐 |
|
||||
| `/office` | 4 자매 대화 스트림 (사용자가 구경하는 용) |
|
||||
| `/sisters/[name]` | 자매 개별 프로필 + 통계 |
|
||||
|
||||
## 10.2 SubTask 상세 드로어
|
||||
|
||||
`/rails` 에서 노드를 클릭하면 우측 드로어가 열린다. 이 드로어에 들어가는 정보:
|
||||
|
||||
- **헤더**: 자매 아바타, role 배지, title, breadcrumb (부모 체인)
|
||||
- **상태/모델 그리드**: state, agent, model, duration, complexity, ID
|
||||
- **설명**: 태스크 description
|
||||
- **산출물 (Artifacts)**:
|
||||
- `.md` 로그 파일 → 클릭 시 모달로 내용 표시 (`FileViewerModal`)
|
||||
- 추출된 코드 파일 → 클릭 시 Gitea 프록시로 페치해서 표시
|
||||
- Deploy URL → 브라우저 외부 링크
|
||||
- **LLM 응답**: `react-markdown` 으로 렌더링 (front matter 는 분리)
|
||||
- **하위 노드 리스트**: children 요약
|
||||
- **이벤트 로그**: SubTaskEvent 전체
|
||||
|
||||
## 10.3 FileViewerModal
|
||||
|
||||
가장 최근에 추가된 기능. 대시보드에서 파일 내용을 보고 싶을 때 쓴다.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ FILE implement/files/index.html │
|
||||
│ [복사] [닫기] │
|
||||
├────────────────────────────────────┤
|
||||
│ <!doctype html> │
|
||||
│ <html> │
|
||||
│ ... │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
두 가지 소스 타입을 받는다:
|
||||
|
||||
1. `{ type: 'llm', text }` — 이미 메모리에 있는 LLM 결과물 (로그 .md 용)
|
||||
2. `{ type: 'url', url }` — Gitea raw URL, 백엔드 `/api/rails/file-content` 프록시로 페치
|
||||
|
||||
프록시는 Gitea 호스트만 allowlist 한다 (`git.nabomhalang.co.kr`). 외부 URL 은 거부.
|
||||
|
||||
`.md` 파일은 `react-markdown` 으로, 아닌 것은 `<pre>` 로 표시. Front matter (`--- ... ---`) 는 상단 메타 박스로 분리한다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 11. End-to-End 시나리오 — "todo 앱 만들어 줘"
|
||||
|
||||
처음 보는 사람이 가장 궁금해할 "한 번의 실행" 을 코드 흐름으로 따라가자.
|
||||
|
||||
## 11.1 Step 0 — 트리거
|
||||
|
||||
사용자가 Discord 에 다음과 같이 쓴다.
|
||||
|
||||
```
|
||||
/rails start project:todo-app requirements:"간단한 todo 웹앱 하나 만들어 줘"
|
||||
```
|
||||
|
||||
Discord 봇은 이걸 HTTP 요청으로 바꿔 Dev 서버 대시보드 백엔드로 보낸다.
|
||||
|
||||
```
|
||||
POST http://dev-vm/api/rails/pipelines/start
|
||||
{
|
||||
"project": "todo-app",
|
||||
"requirements": "간단한 todo 웹앱 하나 만들어 줘"
|
||||
}
|
||||
```
|
||||
|
||||
## 11.2 Step 1 — 오케스트레이터 진입
|
||||
|
||||
대시보드 백엔드 (`RailsService`) 가 rails orchestrator 에 포워딩.
|
||||
|
||||
```
|
||||
POST http://127.0.0.1:18800/pipelines/start
|
||||
```
|
||||
|
||||
rails 는:
|
||||
|
||||
1. ULID 를 발급해 `pipelines` 테이블에 새 row 를 만든다 (`currentState='idle'`)
|
||||
2. XState actor 를 생성해 `START` 이벤트 dispatch → `running` 상태로 전이
|
||||
3. `state_transitions` 에 `idle → running` 한 줄 기록
|
||||
4. 4 단계 루프를 시작한다
|
||||
|
||||
## 11.3 Step 2 — Plan (하랑이)
|
||||
|
||||
rails 는 harang LXC 의 `/invoke` 로 POST:
|
||||
|
||||
```
|
||||
{ stage:"plan", task:{ title:"todo-app", description:"..."}, priorStages:[] }
|
||||
```
|
||||
|
||||
harang sister-agent 는:
|
||||
|
||||
1. `harang-manager` SubTask row 생성, `sub_task_events` 에 `spawned`, `started` 이벤트
|
||||
2. complexity 계산 → 점수 35 → `simple` tier → principal 1 + junior 1 로 분해
|
||||
3. 각 하위 노드를 `Promise.all` 로 LLM 호출
|
||||
4. junior 가 반환한 계획을 manager 가 취합, ` ```md:plan.md` 코드 블록으로 감싼 응답을 만듦
|
||||
5. `maybeExtractFiles` 로 `plan/files/plan.md` 로 저장, producedFiles 에 기록
|
||||
6. `{stage:"plan", verdict:"PLAN_READY", payload:{ planDir, sprintId, selfTestReport:{producedFiles} }}` 리턴
|
||||
|
||||
rails 는 결과를 `priorStages[0]` 에 푸시한다.
|
||||
|
||||
## 11.4 Step 3 — Implement (나랑이)
|
||||
|
||||
```
|
||||
POST /invoke
|
||||
{ stage:"implement",
|
||||
task:{...},
|
||||
priorStages:[ { stage:"plan", text:"<plan.md 요약>" } ] }
|
||||
```
|
||||
|
||||
narang sister-agent 는:
|
||||
|
||||
1. complexity 60 → `moderate` → principal 1 + lead 2 (frontend/backend) + junior 4
|
||||
2. 병렬로 LLM 호출, junior 들이 각각 HTML / CSS / JS / server.js 를 생성
|
||||
3. 모든 산출물을 `implement/files/...` 로 저장
|
||||
4. `git-ops.commitAndPush(pipelineId, workdir)` 호출
|
||||
- Gitea 에 `rails-abcd012345` repo 생성
|
||||
- `git push` 성공
|
||||
- `repoUrl`, `rawUrlBase`, `commit` 리턴
|
||||
5. `derivePreviewUrl(producedFiles, rawUrlBase)` 로 `https://…/implement/files/frontend/index.html` 계산
|
||||
6. `{stage:"implement", verdict:"IMPL_DONE", payload:{ ..., selfTestReport:{ producedFiles, repoUrl, rawUrlBase, deployUrl } }}` 리턴
|
||||
|
||||
## 11.5 Step 4 — Review (다랑이)
|
||||
|
||||
```
|
||||
POST /invoke
|
||||
{ stage:"review",
|
||||
priorStages:[
|
||||
{ stage:"plan", text:"..." },
|
||||
{ stage:"implement", text:"repoUrl=...\nfilesCount=7\n..." }
|
||||
]}
|
||||
```
|
||||
|
||||
darang 은 rawUrlBase 로 Gitea 파일을 직접 페치해서 읽고, QA 체크리스트를 돌린다. 결과는 `verdict:"APPROVE" | "REQUEST_CHANGES" | "ABORT"`.
|
||||
|
||||
REQUEST_CHANGES 가 나오면 rails 는 implement 로 되돌려 재시도 (최대 3 회). 3 회 실패 시 `escalated` 상태로 전이하고 사용자에게 알림.
|
||||
|
||||
## 11.6 Step 5 — Deploy (이랑이)
|
||||
|
||||
erang 은 deploy URL 이 실제로 열리는지 verify, 필요하면 추가 설정 파일을 쓴다. 최종적으로 `{stage:"deploy", verdict:"DEPLOY_DONE", payload:{ deployArtifactPath, verificationResults }}`.
|
||||
|
||||
## 11.7 Step 6 — 완료
|
||||
|
||||
rails 는 `running → completed` 로 전이, 대시보드 Socket.IO 로 실시간 브로드캐스트. 사용자 Discord 에는 최종 deploy URL 이 포스트된다.
|
||||
|
||||
```
|
||||
✅ todo-app 완료
|
||||
repo: https://git.nabomhalang.co.kr/hanarang/rails-abcd012345
|
||||
deploy: https://git.nabomhalang.co.kr/hanarang/rails-abcd012345/raw/branch/main/implement/files/frontend/index.html
|
||||
duration: 4m 12s
|
||||
sub-tasks: 11 (완료 11, 실패 0)
|
||||
```
|
||||
|
||||
\newpage
|
||||
|
||||
# 12. HTTP API 명세
|
||||
|
||||
rails orchestrator 가 노출하는 엔드포인트. 대시보드 백엔드와 sister-agent 가 소비한다.
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/health` | 헬스체크 |
|
||||
| GET | `/pipelines?limit=N` | 파이프라인 리스트 |
|
||||
| GET | `/pipelines/:id` | 파이프라인 상세 (state, context, transitions) |
|
||||
| POST | `/pipelines/start` | 새 파이프라인 실행 |
|
||||
| POST | `/pipelines/:id/abort` | 파이프라인 강제 종료 |
|
||||
| GET | `/api/pipelines/:id/sub-tasks` | SubTask 트리 |
|
||||
| GET | `/api/sub-tasks/:id` | 서브태스크 상세 (parents/children/events) |
|
||||
| GET | `/api/transitions?pipelineId=...&limit=100` | 상태 전이 이력 |
|
||||
| GET | `/api/escalations?pipelineId=...&resolved=false` | 에스컬레이션 큐 |
|
||||
|
||||
대시보드 쪽 (`backend/src/rails/`) 은 이것들을 래핑해서 `/api/rails/*` 로 재노출하고, 인증/인가를 한 겹 더 얹는다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 13. Sprint Contract — DoD 의 기계 검증
|
||||
|
||||
## 13.1 왜 필요한가
|
||||
|
||||
F2 실패 모드 ("build 통과 = 완료") 를 막기 위해서. 스프린트가 시작되기 전에 "이 스프린트는 무엇으로 끝난 것으로 보는가" 를 기계가 읽을 수 있는 형태로 고정한다.
|
||||
|
||||
## 13.2 구조
|
||||
|
||||
`.claude/state/contracts/<task-id>.sprint-contract.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "11.2",
|
||||
"sprintId": "SPRINT-003",
|
||||
"version": "v1",
|
||||
"checks": [
|
||||
{ "id": "files-exist", "type": "file-exists", "paths": ["src/contract/generator.ts"] },
|
||||
{ "id": "tests-pass", "type": "command-success", "cmd": "pnpm test src/contract" },
|
||||
{ "id": "schema-valid", "type": "artifact-schema", "path": "out/contract.json", "schema": "ContractSchema" }
|
||||
],
|
||||
"nonGoals": ["UI 변경"],
|
||||
"reviewerProfile": "static",
|
||||
"riskFlags": ["security-sensitive"]
|
||||
}
|
||||
```
|
||||
|
||||
## 13.3 체크 타입 (`src/contract/checks/`)
|
||||
|
||||
| 타입 | 의미 |
|
||||
|---|---|
|
||||
| `file-exists` | 경로 존재 여부 |
|
||||
| `command-success` | 쉘 명령 exit code 0 |
|
||||
| `http-status` | URL 응답 2xx |
|
||||
| `regex-in-file` | 파일 내용이 정규식 매칭 |
|
||||
| `artifact-schema` | JSON 산출물이 Zod 스키마 통과 |
|
||||
| `db-query` | DB 쿼리가 기대 행 수 리턴 |
|
||||
| `process-listening` | 포트 LISTEN 확인 |
|
||||
| `manual` | 수동 체크박스 (escape hatch, 최소화 권장) |
|
||||
|
||||
하나라도 FAIL 이 나오면 스프린트는 `cc:완료` 가 될 수 없다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 14. 보안 모델
|
||||
|
||||
## 14.1 신뢰 경계
|
||||
|
||||
| 경계 | 정책 |
|
||||
|---|---|
|
||||
| 사용자 → 대시보드 | 세션 쿠키 인증 (NestJS) |
|
||||
| 대시보드 → rails | 내부망 전용 HTTP, 토큰 없음 (향후 추가 예정) |
|
||||
| rails → sister-agent | 내부망 HTTP, `AGENT_NAME` 환경변수로 정체성 고정 |
|
||||
| sister-agent → LLM | OpenClaw 런타임이 API 키 관리 |
|
||||
| rails → Gitea | `.env` 의 `GITEA_TOKEN`, URL 에만 주입 |
|
||||
|
||||
## 14.2 Gitea 프록시 allowlist
|
||||
|
||||
대시보드 백엔드 `/api/rails/file-content` 는 `URL.host === 'git.nabomhalang.co.kr'` 만 허용. 외부 URL 은 404 를 돌려준다. 이유: 악의적 링크로 백엔드에서 임의 HTTP 요청을 트리거하는 SSRF 공격 방지.
|
||||
|
||||
## 14.3 Zod 검증 경계
|
||||
|
||||
모든 외부 입력 (HTTP body, subprocess stdout, 파일 로드) 은 Zod 스키마를 통과한 뒤에만 내부 타입으로 들어온다. 경계 밖에서는 `any` 금지.
|
||||
|
||||
\newpage
|
||||
|
||||
# 15. 실패/복원력 (`src/resilience/`)
|
||||
|
||||
## 15.1 재시도 정책
|
||||
|
||||
Exponential backoff — `1s, 2s, 4s, 8s, 최대 30s`. 기본 3 회. 매 재시도는 `sub_task_events` 에 `retry` 이벤트로 기록된다.
|
||||
|
||||
## 15.2 타임아웃
|
||||
|
||||
자매 `/invoke` 응답 기본 600 초 (LLM 이 오래 걸릴 수 있어서). 초기에는 30 초로 잡았다가 `request-timed-out` 재현 → 600 초로 변경.
|
||||
|
||||
## 15.3 에스컬레이션
|
||||
|
||||
N 회 실패 시 `escalations` 테이블에 row 추가, Discord 에 사용자 멘션. 상태는 `escalated` 로 전이하고 파이프라인은 정지한다. 사용자가 `rails resume <id>` 를 호출하면 `escalated → running` 으로 복구.
|
||||
|
||||
\newpage
|
||||
|
||||
# 16. 설치/실행 가이드
|
||||
|
||||
## 16.1 사전 요구
|
||||
|
||||
- Node 22 + pnpm
|
||||
- MariaDB 10.11+
|
||||
- Gitea 인스턴스 (또는 환경변수 `GITEA_TOKEN` + `GITEA_API_URL` 재설정)
|
||||
- OpenClaw 런타임 (각 자매 LXC)
|
||||
|
||||
## 16.2 rails 서버 구동
|
||||
|
||||
```bash
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
|
||||
cd hanarang-rails
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
# DATABASE_URL, GITEA_TOKEN 등 채우기
|
||||
pnpm prisma migrate deploy
|
||||
pnpm build
|
||||
pnpm rails serve # 18800 포트
|
||||
```
|
||||
|
||||
## 16.3 sister-agent 구동 (각 LXC)
|
||||
|
||||
```bash
|
||||
cd sister-agent
|
||||
pnpm install
|
||||
pnpm build
|
||||
AGENT_NAME=harang RAILS_API_URL=http://dev-vm:18800 \
|
||||
node dist/server.js
|
||||
```
|
||||
|
||||
## 16.4 대시보드 구동
|
||||
|
||||
별도 repo `hanarang-dashboard` 참조. `pnpm build && pm2 start ecosystem.config.js`.
|
||||
|
||||
## 16.5 스모크 테스트
|
||||
|
||||
```bash
|
||||
pnpm rails run hello-world --mock -r "Try a pipeline"
|
||||
pnpm rails status
|
||||
```
|
||||
|
||||
`--mock` 모드는 실제 LLM 호출 없이 결정론 파이프라인만 확인한다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 17. 디렉토리 구조
|
||||
|
||||
```
|
||||
hanarang-rails/
|
||||
├── src/
|
||||
│ ├── orchestrator/ FSM 엔진
|
||||
│ ├── hierarchy/ 계층/복잡도/플래너
|
||||
│ ├── handoff/ 자매 간 메시지 스키마 + 트랜스포트
|
||||
│ ├── contract/ Sprint Contract + validator
|
||||
│ ├── qa/ QA 체크리스트 runtime
|
||||
│ ├── enforcement/ Skill 강제 진입 / bypass 감지
|
||||
│ ├── resilience/ 재시도/타임아웃/에스컬레이션
|
||||
│ ├── server/http.ts HTTP API 서버
|
||||
│ ├── cli/ citty 기반 rails CLI
|
||||
│ ├── config/ env + config loader
|
||||
│ └── bridge/ Discord 브릿지 (v0.2 예정)
|
||||
│
|
||||
├── sister-agent/
|
||||
│ └── src/
|
||||
│ ├── server.ts /invoke HTTP 서버
|
||||
│ ├── spawn.ts 재귀 트리 실행기
|
||||
│ ├── hierarchy.ts 역할 트리 builder
|
||||
│ ├── complexity.ts 스코어 계산
|
||||
│ ├── planner.ts 복잡도 → 분해 계획
|
||||
│ ├── roles.ts 역할 정의
|
||||
│ ├── prompts.ts 한국어 프롬프트 템플릿
|
||||
│ ├── llm.ts openclaw CLI wrapper
|
||||
│ ├── code-extractor.ts 코드 블록 파서
|
||||
│ ├── git-ops.ts Gitea API + git push
|
||||
│ └── rails-client.ts rails 에 이벤트 report
|
||||
│
|
||||
├── prisma/schema.prisma DB 스키마
|
||||
├── .plans/
|
||||
│ ├── OVERVIEW.md
|
||||
│ ├── failure-audit.md
|
||||
│ ├── design/ 설계 문서 9 종
|
||||
│ ├── sprints/ 스프린트 000–007 명세
|
||||
│ └── migration/
|
||||
├── docs/
|
||||
│ ├── GUIDE.md ★ 이 문서
|
||||
│ ├── migration-guide.md
|
||||
│ ├── operations.md
|
||||
│ └── discord-setup.md
|
||||
├── hooks/ OpenClaw pre/post-tool hooks
|
||||
├── qa-templates/ QA 체크리스트 6 종
|
||||
├── install.sh 설치 자동화
|
||||
└── rails.config.example.yaml
|
||||
```
|
||||
|
||||
\newpage
|
||||
|
||||
# 18. 로드맵
|
||||
|
||||
| 버전 | 상태 | 내용 |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 완료 | Sprint 000–007, FSM/contract/QA/migration 코어 |
|
||||
| v0.1.1 | 완료 | 실 LLM 통합, 계층 실행, 파일 추출, Gitea auto-push |
|
||||
| v0.1.2 | 완료 | 대시보드 아티팩트 뷰, MD 파일 뷰어 모달 |
|
||||
| v0.2 | 진행 | Discord 브릿지 정식화, GatewayHttpTransport 분리 |
|
||||
| v0.3 | 계획 | Skill 강제 진입 실측, OpenClaw hook 프로덕션 적용 |
|
||||
| v0.4 | 계획 | 멀티 테넌시 (여러 사용자 동시 실행) |
|
||||
| v1.0 | 계획 | 외부 공개 + 문서화 완성 |
|
||||
|
||||
\newpage
|
||||
|
||||
# 19. 용어집
|
||||
|
||||
| 용어 | 정의 |
|
||||
|---|---|
|
||||
| **자매 (Sister)** | 4 개의 LLM 에이전트 중 하나 (harang/narang/darang/erang) |
|
||||
| **자기야** | 사용자 (나봄하랑) 에 대한 4 자매의 호칭 |
|
||||
| **OpenClaw** | 하나랑 생태계에서 사용하는 AI 런타임. Claude Code 기반이지만 별개 브랜드 |
|
||||
| **Rails** | 이 프로젝트. 결정론적 파이프라인 오케스트레이터 |
|
||||
| **Harness** | rails 의 전임자 `hanarang-harness`. 권고 기반이라 실패가 잦았음 |
|
||||
| **FSM** | Finite State Machine. XState 로 구현 |
|
||||
| **Sprint Contract** | 스프린트 시작 전에 쓰는 DoD 기계 검증 스펙 |
|
||||
| **DoD** | Definition of Done. 완료 조건 |
|
||||
| **SubTask** | 자매/역할별로 쪼개진 서브 태스크 |
|
||||
| **Stage** | 파이프라인의 주 단계 (plan/implement/review/deploy) |
|
||||
| **Role** | 자매 내부의 직급 (manager/principal/lead/junior) |
|
||||
| **Escalation** | 자동 복구 실패 시 사용자에게 넘기는 예외 상황 |
|
||||
| **priorStages** | 이전 단계 결과물 텍스트의 누적 배열 |
|
||||
| **producedFiles** | 자매가 이번 실행에서 만든 파일의 상대 경로 리스트 |
|
||||
| **SSOT** | Single Source of Truth. 여기서는 Dev VM 위의 Gitea + MariaDB |
|
||||
| **LXC** | 리눅스 컨테이너. Proxmox 에서 각 자매를 격리 실행 |
|
||||
|
||||
\newpage
|
||||
|
||||
# 20. 참고 자료
|
||||
|
||||
- 원본 실패 감사: `.plans/failure-audit.md`
|
||||
- 설계 문서: `.plans/design/state-machine.md`, `sprint-contract.md`, `hierarchy.md`, `deployment.md`, `handoff.md`, `retry-policy.md`, `qa-template.md`, `transports.md`, `triggers.md`
|
||||
- 스프린트 명세: `.plans/sprints/SPRINT-000` ~ `SPRINT-007`
|
||||
- 마이그레이션 가이드: `docs/migration-guide.md`
|
||||
- 운영 가이드: `docs/operations.md`
|
||||
- Discord 셋업: `docs/discord-setup.md`
|
||||
- 전임자 repo: `hanarang/openclaw-harness` (private archive)
|
||||
- 대시보드 repo: `hanarang/hanarang-dashboard`
|
||||
|
||||
\newpage
|
||||
|
||||
# 부록 A. 용례 비교 — 구 하네스 vs rails
|
||||
|
||||
## A.1 핸드오프
|
||||
|
||||
**구 하네스**
|
||||
|
||||
```
|
||||
harang → "이제 나랑이가 구현해 주세요" (Discord 멘션)
|
||||
narang → 잠시 뒤 멘션을 본다 (혹은 못 봄)
|
||||
→ 본인 판단으로 스폰, 직접 처리
|
||||
→ skill 을 안 탐 (F1)
|
||||
```
|
||||
|
||||
**rails**
|
||||
|
||||
```
|
||||
stage="plan" → FSM context.priorStages.push({ stage:"plan", text:... })
|
||||
XState transition(STAGE_DONE) → guard 검사 → next state="implement"
|
||||
runner 가 자동으로 POST /invoke (stage=implement) → narang 실행
|
||||
narang 은 선택권이 없다. 호출된 대로만 실행
|
||||
```
|
||||
|
||||
## A.2 DoD
|
||||
|
||||
**구 하네스**: `npm run build` 가 exit 0 → 완료 처리.
|
||||
|
||||
**rails**: Sprint Contract 의 `checks[]` 가 전부 pass 해야 `cc:완료`. `artifact-schema` 체크는 산출물 JSON 을 Zod 로 한 번 더 검증한다.
|
||||
|
||||
## A.3 QA
|
||||
|
||||
**구 하네스**: 사용자가 "다랑아 이거 QA 해 줘" 라고 멘션. 다랑이가 답장 없음 → 사용자가 중재.
|
||||
|
||||
**rails**: FSM 이 자동으로 `review` stage 로 전이. 다랑이는 반드시 호출되고, QA 템플릿의 체크리스트를 전부 채워야 `APPROVE` 를 낼 수 있다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 부록 B. 자주 묻는 질문
|
||||
|
||||
**Q. 왜 Claude Code 가 아니라 OpenClaw 인가?**
|
||||
A. OpenClaw 는 하나랑이 내부에서 쓰는 커스텀 런타임이다. Claude Code 를 포크한 것이 아니라 별개의 구현이다. 4 자매는 OpenClaw 위에 올라가 있고, 이 rails 레포 자체는 Claude Code 세션에서 개발한다.
|
||||
|
||||
**Q. 왜 SQLite 가 아니라 MariaDB 를 쓰나?**
|
||||
A. 초기 설계에서는 SQLite 를 썼지만, Dev VM 에 MariaDB 가 이미 있고 대시보드가 같은 DB 를 공유하는 게 간단해서 MariaDB 로 옮겼다. Prisma 로 추상화되어 있어 다시 바꾸는 것도 어렵지 않다.
|
||||
|
||||
**Q. 병렬 실행은 어디까지 가능한가?**
|
||||
A. stage 는 순차 (plan → implement → …), stage 내부의 junior 스폰은 병렬. 기본 `default:8` 동시 실행, 나랑이는 빌드 자원 때문에 6 으로 제한. `concurrencyLimits.overrides` 로 자매별 조정 가능.
|
||||
|
||||
**Q. LLM 이 헛소리를 하면?**
|
||||
A. 세 겹의 방어가 있다. (1) 프롬프트 템플릿이 구조화 응답을 강제. (2) Zod 가 응답을 검증, 실패 시 재시도. (3) Sprint Contract 가 최종 산출물을 정적 검증.
|
||||
|
||||
**Q. 사용자 개입 없이 며칠 단위 장기 태스크가 가능한가?**
|
||||
A. 현재 v0.1.x 는 한 번의 파이프라인 = 한 번의 기획 → 배포 사이클이다. 더 긴 수명의 프로젝트는 여러 파이프라인을 엮는 방식으로 다룬다. v0.4 멀티 테넌시에서 검토 예정.
|
||||
|
||||
**Q. 테스트는 어떻게?**
|
||||
A. Vitest 105 테스트가 현재 통과. FSM, contract, QA, migration 핵심 경로를 커버한다. E2E 는 `--mock` 모드로 돌릴 수 있다.
|
||||
|
||||
\newpage
|
||||
|
||||
# 부록 C. 라이선스 및 기여
|
||||
|
||||
- 라이선스: MIT (`LICENSE`)
|
||||
- 저작권: 나봄하랑 / hanarang
|
||||
- 기여: PR 환영. `.plans/` 문서 규약을 따를 것.
|
||||
- 문의: Discord 또는 Gitea issue
|
||||
|
||||
> 하나랑의 4 자매가 사용자 중재 없이 달릴 수 있는 레일을 깐다 —
|
||||
> 그것이 이 프로젝트의 처음이자 끝의 목표다.
|
||||
BIN
docs/GUIDE.pdf
Normal file
BIN
docs/GUIDE.pdf
Normal file
Binary file not shown.
218
docs/LOCAL-SETUP.md
Normal file
218
docs/LOCAL-SETUP.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Local Setup — 30 분 퀵스타트
|
||||
|
||||
이 문서는 **본인 환경에서 hanarang-rails 를 처음부터 돌려 보는** 가장 짧은 경로다. 외부 인프라 (Gitea, OpenClaw, 4 개 LXC, MariaDB 전용 서버) 전혀 없어도 로컬에서 E2E 파이프라인을 한 번 돌리는 게 목표.
|
||||
|
||||
대상 독자: 이 리포를 처음 클론한 사람. Node 와 docker 를 쓸 줄 아는 사람.
|
||||
|
||||
---
|
||||
|
||||
## 0. 사전 요구
|
||||
|
||||
하나만 선택:
|
||||
|
||||
- **Option A — Docker 경로** (권장): `docker` + `docker compose` 만 있으면 끝. MariaDB 까지 컨테이너로 뜬다.
|
||||
- **Option B — 네이티브 경로**: Node 22, pnpm 9, MariaDB 10.11+ 로컬 설치.
|
||||
|
||||
추가로 **LLM 제공자 하나**를 정해 둬야 한다.
|
||||
|
||||
| 제공자 | 필요한 것 | 비용 |
|
||||
|---|---|---|
|
||||
| `mock` | (없음) | 무료, 진짜 LLM 호출 없음 — FSM 만 확인 |
|
||||
| `openai` | OpenAI API 키 | 사용량 기반 |
|
||||
| `anthropic` | Anthropic API 키 | 사용량 기반 |
|
||||
| `ollama` | 로컬 Ollama + 모델 pull | 무료, 로컬 GPU/CPU |
|
||||
| `openclaw` | hanarang 내부 런타임 | 외부인 접근 불가 |
|
||||
|
||||
**처음이면 `mock` 으로 시작**하는 걸 권장한다. 실제 LLM 없이 파이프라인 전 구간이 동작하는지 먼저 확인하고, 그 다음 원하는 제공자로 바꿔도 늦지 않다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Docker 경로 (권장)
|
||||
|
||||
### 1-1. 클론 + 환경 설정
|
||||
|
||||
```bash
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
|
||||
cd hanarang-rails
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env` 에서 최소 이 두 줄만 만져 주면 된다:
|
||||
|
||||
```bash
|
||||
# 모크 모드로 시작 (진짜 LLM 호출 안 함)
|
||||
LLM_PROVIDER=mock
|
||||
|
||||
# Docker compose 가 쓸 DB URL
|
||||
DATABASE_URL="mysql://rails:rails@mariadb:3306/hanarang_rails"
|
||||
```
|
||||
|
||||
### 1-2. 기동
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
처음 빌드는 몇 분 걸린다. 완료되면 rails 컨테이너가 마이그레이션을 돌리고 HTTP 서버가 18800 포트에서 리스닝한다.
|
||||
|
||||
```bash
|
||||
curl http://localhost:18800/health
|
||||
# → {"ok":true,"service":"hanarang-rails"}
|
||||
```
|
||||
|
||||
### 1-3. 파이프라인 첫 실행
|
||||
|
||||
다른 터미널에서:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:18800/pipelines/start \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"project":"todo-app","requirements":"간단한 todo 웹앱"}'
|
||||
```
|
||||
|
||||
응답으로 `pipelineId`, `finalState: done`, `transitions` 숫자가 돌아오면 성공. 파이프라인 상태는:
|
||||
|
||||
```bash
|
||||
curl http://localhost:18800/pipelines/<ID>
|
||||
```
|
||||
|
||||
### 1-4. 실제 LLM 로 갈아타기
|
||||
|
||||
`.env` 에서:
|
||||
|
||||
```bash
|
||||
LLM_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
LLM_MODEL_MANAGER=gpt-4o
|
||||
LLM_MODEL_PRINCIPAL=gpt-4o
|
||||
LLM_MODEL_LEAD=gpt-4o-mini
|
||||
LLM_MODEL_JUNIOR=gpt-4o-mini
|
||||
```
|
||||
|
||||
`docker compose up -d --build` 로 재시작. 같은 `curl` 명령을 또 날리면 이번에는 실제 LLM 이 호출되고, 각 junior 가 만든 코드 블록이 `rails-workspace` 볼륨 안으로 저장된다.
|
||||
|
||||
> **Anthropic / Ollama / OpenAI 호환 서버** 도 같은 패턴이다. `LLM_PROVIDER` 만 바꾸고 해당 API 키/URL 를 `.env` 에 채워 주면 된다. `.env.example` 파일 주석에 각 제공자별 키 이름이 정리돼 있다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 네이티브 경로
|
||||
|
||||
Docker 없이 로컬 프로세스로 돌리는 경로.
|
||||
|
||||
### 2-1. MariaDB 준비
|
||||
|
||||
```bash
|
||||
# brew / apt / 도커 중 편한 방법으로 MariaDB 10.11+ 기동
|
||||
# 그 다음 DB/사용자 생성:
|
||||
mysql -u root -p <<SQL
|
||||
CREATE DATABASE hanarang_rails;
|
||||
CREATE USER 'rails'@'localhost' IDENTIFIED BY 'rails';
|
||||
GRANT ALL ON hanarang_rails.* TO 'rails'@'localhost';
|
||||
SQL
|
||||
```
|
||||
|
||||
### 2-2. 클론 + 빌드
|
||||
|
||||
```bash
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
|
||||
cd hanarang-rails
|
||||
pnpm install
|
||||
|
||||
# sister-agent 도 별도 install
|
||||
cd sister-agent && pnpm install && cd ..
|
||||
```
|
||||
|
||||
### 2-3. 환경 설정
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 편집:
|
||||
# DATABASE_URL="mysql://rails:rails@localhost:3306/hanarang_rails"
|
||||
# LLM_PROVIDER=mock
|
||||
# RAILS_TRANSPORT=in-process
|
||||
|
||||
cp rails.config.local.yaml rails.config.yaml
|
||||
```
|
||||
|
||||
### 2-4. DB 마이그레이션 + 빌드
|
||||
|
||||
```bash
|
||||
pnpm prisma migrate deploy
|
||||
pnpm prisma generate
|
||||
pnpm build
|
||||
cd sister-agent && pnpm build && cd ..
|
||||
```
|
||||
|
||||
### 2-5. 기동 + 테스트
|
||||
|
||||
```bash
|
||||
pnpm rails serve -c rails.config.yaml
|
||||
```
|
||||
|
||||
다른 터미널:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:18800/pipelines/start \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"project":"hello","requirements":"Say hi"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 파일은 어디로 가나?
|
||||
|
||||
- Docker 경로: rails 컨테이너의 `/app/rails-projects/<pipelineId>/<stage>/files/` 에 저장되고, `rails-workspace` named volume 에 영속화된다. 컨테이너 밖에서 보려면 `docker compose run --rm rails ls /app/rails-projects/<pipelineId>` 또는 볼륨 mount 변경.
|
||||
- 네이티브 경로: `$HOME/rails-projects/<pipelineId>/<stage>/files/`.
|
||||
|
||||
Gitea auto-push 는 기본적으로 꺼져 있다. 켜고 싶으면 `.env` 에 `GITEA_TOKEN`, `GITEA_BASE_URL`, `GITEA_ORG` 를 채우면 자동으로 켜진다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 대시보드도 띄우려면
|
||||
|
||||
대시보드 (`hanarang-dashboard`) 는 별도 리포다. rails 가 돌아가고 있는 상태에서 같은 MariaDB 를 바라보도록 설정하면 `/rails` 페이지에서 파이프라인 트리가 시각화된다.
|
||||
|
||||
```bash
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard.git
|
||||
cd hanarang-dashboard/backend
|
||||
cp .env.example .env
|
||||
# DATABASE_URL 을 rails 와 같게
|
||||
# RAILS_API_URL=http://localhost:18800
|
||||
# GIT_RAW_ALLOWED_HOSTS=git.example.com (optional, for MD viewer)
|
||||
pnpm install && pnpm build && pnpm start:prod
|
||||
```
|
||||
|
||||
프론트엔드는 별도 프로세스:
|
||||
|
||||
```bash
|
||||
cd ../frontend
|
||||
pnpm install && pnpm dev
|
||||
# → http://localhost:3000/rails
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 자주 막히는 부분
|
||||
|
||||
**Q. `pnpm rails run` 이 "DATABASE_URL not set" 에러.**
|
||||
`.env` 가 rails 의 작업 디렉토리에 있어야 한다. `loadEnv()` 는 `process.cwd()` 기준으로 찾는다.
|
||||
|
||||
**Q. Mock 모드인데 LLM 응답이 텅 비어 있다.**
|
||||
정상이다. Mock 은 결정론 스켈레톤만 확인하려고 있는 거라 파일도 안 만들고 내용도 거의 없다. 실제 LLM 로 바꿔야 의미 있는 산출물이 나온다.
|
||||
|
||||
**Q. In-process 모드인데 `sister-agent core module not found`.**
|
||||
`sister-agent/dist/core.js` 가 빌드되지 않은 상태다. `cd sister-agent && pnpm build`. 또는 `SISTER_AGENT_CORE_PATH` 로 절대 경로 명시.
|
||||
|
||||
**Q. OpenAI 대신 OpenRouter / Azure OpenAI / 로컬 llama.cpp 서버를 쓸 수 있나?**
|
||||
된다. `LLM_PROVIDER=openai` 로 두고 `OPENAI_BASE_URL` 을 바꿔 주면 OpenAI Chat Completions 프로토콜을 말하는 모든 서버에 붙는다.
|
||||
|
||||
**Q. 4 개 자매를 진짜 분리된 컨테이너로 돌리고 싶다.**
|
||||
`docker-compose.full.yml` 을 써라. rails 1 개 + 각 자매 1 개씩 총 6 개 서비스가 뜨고, rails 가 HTTP 로 각 자매에게 invoke 를 보낸다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 다음 단계
|
||||
|
||||
- **구조 전체를 이해하고 싶다면**: [`docs/GUIDE.md`](GUIDE.md) 또는 PDF 버전
|
||||
- **실제로 코드를 건드리고 싶다면**: [`.plans/design/`](../.plans/design/) 의 설계 문서
|
||||
- **프롬프트/역할을 본인 도메인에 맞추고 싶다면**: `sister-agent/src/prompts.ts`, `sister-agent/src/roles.ts`, `rails.config.local.yaml` 순으로 읽기
|
||||
58
rails.config.distributed.yaml
Normal file
58
rails.config.distributed.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# rails.config.distributed.yaml
|
||||
#
|
||||
# Production-style topology. Each sister-agent runs as its own daemon
|
||||
# (typically on its own host/container/LXC) and rails calls them over
|
||||
# HTTP. This is what the hanarang internal deployment uses.
|
||||
#
|
||||
# Usage:
|
||||
# cp rails.config.distributed.yaml rails.config.yaml
|
||||
# # Start 4 sister-agent daemons (see docs/LOCAL-SETUP.md)
|
||||
# pnpm rails serve -c rails.config.yaml
|
||||
#
|
||||
# Endpoints can also be overridden via env:
|
||||
# SISTER_ENDPOINT_PLAN=http://host:18801 etc.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
pipeline:
|
||||
stages:
|
||||
- plan
|
||||
- implement
|
||||
- review
|
||||
- deploy
|
||||
|
||||
agents:
|
||||
plan:
|
||||
role: plan
|
||||
displayName: Planner
|
||||
agentName: harang
|
||||
transport: http
|
||||
endpoint: http://harang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
implement:
|
||||
role: implement
|
||||
displayName: Generator
|
||||
agentName: narang
|
||||
transport: http
|
||||
endpoint: http://narang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
review:
|
||||
role: review
|
||||
displayName: Evaluator
|
||||
agentName: darang
|
||||
transport: http
|
||||
endpoint: http://darang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
deploy:
|
||||
role: deploy
|
||||
displayName: Deploy
|
||||
agentName: erang
|
||||
transport: http
|
||||
endpoint: http://erang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
discord:
|
||||
enabled: false
|
||||
56
rails.config.local.yaml
Normal file
56
rails.config.local.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# rails.config.local.yaml
|
||||
#
|
||||
# Single-host / single-process configuration. Every stage runs the
|
||||
# sister-agent core directly inside the rails Node process — no separate
|
||||
# daemons, no networking between agents, just one binary.
|
||||
#
|
||||
# This is the fastest way to try rails on your laptop.
|
||||
#
|
||||
# Usage:
|
||||
# cp rails.config.local.yaml rails.config.yaml
|
||||
# cp .env.example .env # then set LLM_PROVIDER + any API keys
|
||||
# pnpm rails serve -c rails.config.yaml
|
||||
#
|
||||
# Env vars (RAILS_TRANSPORT, SISTER_ENDPOINT_*, etc.) always override
|
||||
# whatever is in this file.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
pipeline:
|
||||
stages:
|
||||
- plan
|
||||
- implement
|
||||
- review
|
||||
- deploy
|
||||
|
||||
agents:
|
||||
plan:
|
||||
role: plan
|
||||
displayName: Planner
|
||||
agentName: harang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
implement:
|
||||
role: implement
|
||||
displayName: Generator
|
||||
agentName: narang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
review:
|
||||
role: review
|
||||
displayName: Evaluator
|
||||
agentName: darang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
deploy:
|
||||
role: deploy
|
||||
displayName: Deploy
|
||||
agentName: erang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
discord:
|
||||
enabled: false
|
||||
@@ -1 +1 @@
|
||||
1775808589
|
||||
1775820498
|
||||
|
||||
0
sister-agent/.claude/state/session-events.lock
Normal file
0
sister-agent/.claude/state/session-events.lock
Normal file
0
sister-agent/.claude/state/session.events.jsonl
Normal file
0
sister-agent/.claude/state/session.events.jsonl
Normal file
0
sister-agent/.claude/state/session.json
Normal file
0
sister-agent/.claude/state/session.json
Normal file
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-04-10T08:10:32Z",
|
||||
"changed_file": "/home/erang/hanarang-rails/src/server/http.ts",
|
||||
"timestamp": "2026-04-10T11:32:37Z",
|
||||
"changed_file": "src/spawn.ts",
|
||||
"test_command": "npm test",
|
||||
"related_test": "",
|
||||
"recommendation": "テストの実行を推奨します"
|
||||
|
||||
1
sister-agent/.claude/state/tool-failure-counter.txt
Normal file
1
sister-agent/.claude/state/tool-failure-counter.txt
Normal file
@@ -0,0 +1 @@
|
||||
1 1775808904
|
||||
177
sister-agent/src/code-extractor.ts
Normal file
177
sister-agent/src/code-extractor.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, normalize, sep } from "node:path";
|
||||
|
||||
export interface ExtractedFile {
|
||||
path: string; // normalized relative path (e.g. "src/index.html")
|
||||
lang: string; // language tag from the fence
|
||||
content: string; // file contents
|
||||
absPath?: string; // populated after write
|
||||
}
|
||||
|
||||
const LANG_TO_EXT: Record<string, string> = {
|
||||
html: "html",
|
||||
htm: "html",
|
||||
xml: "xml",
|
||||
svg: "svg",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "sass",
|
||||
javascript: "js",
|
||||
js: "js",
|
||||
jsx: "jsx",
|
||||
typescript: "ts",
|
||||
ts: "ts",
|
||||
tsx: "tsx",
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
python: "py",
|
||||
py: "py",
|
||||
ruby: "rb",
|
||||
rb: "rb",
|
||||
rust: "rs",
|
||||
rs: "rs",
|
||||
go: "go",
|
||||
java: "java",
|
||||
kotlin: "kt",
|
||||
kt: "kt",
|
||||
swift: "swift",
|
||||
c: "c",
|
||||
"c++": "cpp",
|
||||
cpp: "cpp",
|
||||
cxx: "cpp",
|
||||
cs: "cs",
|
||||
csharp: "cs",
|
||||
php: "php",
|
||||
sh: "sh",
|
||||
bash: "sh",
|
||||
shell: "sh",
|
||||
zsh: "sh",
|
||||
fish: "fish",
|
||||
sql: "sql",
|
||||
markdown: "md",
|
||||
md: "md",
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "mk",
|
||||
prisma: "prisma",
|
||||
graphql: "graphql",
|
||||
env: "env",
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse markdown code fences out of an LLM response.
|
||||
*
|
||||
* Supported fence header forms:
|
||||
* ```html
|
||||
* ```html:index.html
|
||||
* ```html path=src/index.html
|
||||
* ```src/index.html (no lang, filename only)
|
||||
* ```ts title=src/main.ts
|
||||
*/
|
||||
export function extractCodeBlocks(text: string): ExtractedFile[] {
|
||||
const files: ExtractedFile[] = [];
|
||||
const re = /```([^\n`]*)\n([\s\S]*?)\n```/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let anonCounter = 0;
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const header = (match[1] ?? "").trim();
|
||||
const content = match[2] ?? "";
|
||||
|
||||
const parsed = parseHeader(header);
|
||||
if (!parsed) continue;
|
||||
|
||||
let path = parsed.path;
|
||||
if (!path) {
|
||||
anonCounter += 1;
|
||||
const ext = LANG_TO_EXT[parsed.lang] ?? "txt";
|
||||
path = `block-${String(anonCounter).padStart(2, "0")}.${ext}`;
|
||||
}
|
||||
|
||||
// Normalize and sanitize path — strip leading /, resolve ., block ..
|
||||
const cleanPath = sanitizePath(path);
|
||||
if (!cleanPath) continue;
|
||||
|
||||
files.push({ path: cleanPath, lang: parsed.lang, content });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseHeader(header: string): { lang: string; path: string } | null {
|
||||
if (header.length === 0) return null;
|
||||
|
||||
// form: "html:src/index.html"
|
||||
const colonIdx = header.indexOf(":");
|
||||
if (colonIdx > 0 && !header.slice(0, colonIdx).includes(" ")) {
|
||||
const lang = header.slice(0, colonIdx).toLowerCase();
|
||||
const rest = header.slice(colonIdx + 1).trim();
|
||||
if (looksLikePath(rest)) {
|
||||
return { lang, path: rest };
|
||||
}
|
||||
}
|
||||
|
||||
// form: "html path=src/index.html" or "ts title=src/main.ts"
|
||||
const kvMatch = header.match(/^(\w+)\s+(?:path|title|file)=(\S+)/i);
|
||||
if (kvMatch) {
|
||||
return { lang: kvMatch[1]!.toLowerCase(), path: kvMatch[2]! };
|
||||
}
|
||||
|
||||
// form: "src/index.html" (path only, no lang)
|
||||
if (looksLikePath(header) && !/^\w+$/.test(header)) {
|
||||
const ext = header.split(".").pop()?.toLowerCase() ?? "";
|
||||
return { lang: ext, path: header };
|
||||
}
|
||||
|
||||
// form: "html" (bare lang, no path)
|
||||
const lang = header.split(/\s+/)[0]?.toLowerCase() ?? "";
|
||||
if (lang.length === 0) return null;
|
||||
return { lang, path: "" };
|
||||
}
|
||||
|
||||
function looksLikePath(s: string): boolean {
|
||||
if (s.length === 0) return false;
|
||||
if (s.includes(" ")) return false;
|
||||
// Has an extension OR a slash
|
||||
return /\.[a-z0-9]{1,6}$/i.test(s) || s.includes("/");
|
||||
}
|
||||
|
||||
function sanitizePath(p: string): string | null {
|
||||
const normalized = normalize(p).replace(/^(?:\.\.(?:\/|\\))+/, "");
|
||||
if (
|
||||
normalized.startsWith(sep) ||
|
||||
normalized.startsWith("/") ||
|
||||
normalized.includes("..")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save extracted files to the given directory under a `files/` subdir.
|
||||
* Returns the same list with absPath populated.
|
||||
*/
|
||||
export async function saveExtractedFiles(
|
||||
baseDir: string,
|
||||
files: ExtractedFile[],
|
||||
): Promise<ExtractedFile[]> {
|
||||
if (files.length === 0) return files;
|
||||
const targetRoot = join(baseDir, "files");
|
||||
await mkdir(targetRoot, { recursive: true });
|
||||
|
||||
const saved: ExtractedFile[] = [];
|
||||
for (const f of files) {
|
||||
const absPath = join(targetRoot, f.path);
|
||||
try {
|
||||
await mkdir(dirname(absPath), { recursive: true });
|
||||
await writeFile(absPath, f.content, "utf8");
|
||||
saved.push({ ...f, absPath });
|
||||
} catch {
|
||||
// skip — best effort
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
26
sister-agent/src/core.ts
Normal file
26
sister-agent/src/core.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Sister-agent library entry — exposes the core execution functions so
|
||||
* another process (e.g. rails running in single-process mode) can
|
||||
* invoke them directly without going through HTTP.
|
||||
*
|
||||
* This lives in addition to ./server.ts (which wraps the same logic as
|
||||
* an HTTP daemon). Both code paths share ./spawn.ts under the hood.
|
||||
*/
|
||||
|
||||
export { executeInvocation } from "./spawn.js";
|
||||
export { RailsClient } from "./rails-client.js";
|
||||
export { createLlmAdapter, getLlmAdapter } from "./llm/index.js";
|
||||
export type {
|
||||
LlmAdapter,
|
||||
LlmRequest,
|
||||
LlmResult,
|
||||
ProviderName,
|
||||
} from "./llm/index.js";
|
||||
export { ROLES, ROLE_KOREAN } from "./roles.js";
|
||||
export type { RoleConfig } from "./roles.js";
|
||||
export {
|
||||
InvokeRequest,
|
||||
HandoffMessage,
|
||||
Role,
|
||||
type SubTaskRecord,
|
||||
} from "./types.js";
|
||||
333
sister-agent/src/discord-notify.ts
Normal file
333
sister-agent/src/discord-notify.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Send a Discord message via the local OpenClaw CLI.
|
||||
*
|
||||
* Each sister LXC has its own openclaw gateway logged in as a different
|
||||
* Discord bot identity (하랑이 / 나랑이 / 다랑이 / 이랑이). When this is
|
||||
* called from inside the sister-agent daemon running on that LXC, the
|
||||
* message goes out as that sister's bot.
|
||||
*
|
||||
* Best-effort: any error is swallowed and logged to console.warn so that
|
||||
* a Discord outage never blocks the actual rails pipeline.
|
||||
*/
|
||||
export async function notifyDiscord(opts: {
|
||||
channelId: string;
|
||||
message: string;
|
||||
/** Path to openclaw CLI binary. Defaults to ~/.npm-global/bin/openclaw. */
|
||||
bin?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!opts.channelId) return { ok: false, error: "no channelId" };
|
||||
if (!opts.message) return { ok: false, error: "empty message" };
|
||||
|
||||
const bin =
|
||||
opts.bin ??
|
||||
process.env["OPENCLAW_BIN"] ??
|
||||
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
|
||||
|
||||
const args = [
|
||||
"message",
|
||||
"send",
|
||||
"--channel",
|
||||
"discord",
|
||||
"--target",
|
||||
`channel:${opts.channelId}`,
|
||||
"--message",
|
||||
opts.message,
|
||||
];
|
||||
|
||||
return new Promise((resolveFn) => {
|
||||
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
// OpenClaw CLI cold-start (gateway connect + auth) can take 7-10s
|
||||
// even on a healthy LXC. Use a generous timeout — this call is
|
||||
// fire-and-forget from spawn.ts so a longer timer doesn't block the
|
||||
// pipeline; it only matters if the openclaw process is genuinely
|
||||
// stuck.
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
resolveFn({ ok: false, error: `openclaw timeout` });
|
||||
}, opts.timeoutMs ?? 25000);
|
||||
|
||||
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolveFn({ ok: false, error: `spawn error: ${err.message}` });
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
error: `openclaw exit ${code}: ${stderr.slice(0, 300)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolveFn({ ok: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-sister persona message pools.
|
||||
*
|
||||
* Each sister has a personality (from .openclaw/workspace/SOUL.md):
|
||||
* harang — planner / 차분하고 어른스러운 언니톤. 짧고 단정.
|
||||
* narang — developer / 활달하고 손이 빠른 동생. 능률적, 약간 캐주얼.
|
||||
* darang — qa / 꼼꼼하고 약간 까칠한, 정확함을 좋아하는.
|
||||
* erang — infra / 차분하고 믿음직, 기술적이지만 부드러움.
|
||||
*
|
||||
* Pools have multiple variants so the channel doesn't feel robotic. We
|
||||
* pick by hashing the pipeline title — same task always gets the same
|
||||
* line, but different tasks rotate.
|
||||
*/
|
||||
export interface StageMessageContext {
|
||||
agentName: string;
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
taskTitle: string;
|
||||
/**
|
||||
* Verdict produced by the stage. Only used for stage-end messages —
|
||||
* lets darang say "결함 발견" instead of "통과" when REQUEST_CHANGES,
|
||||
* lets erang say "배포 실패" instead of "검증 완료" when DEPLOY_FAILED,
|
||||
* etc. Stage-start ignores this field (verdict isn't known yet).
|
||||
*/
|
||||
verdict?: string;
|
||||
childCount?: number;
|
||||
filesProduced?: number;
|
||||
/**
|
||||
* Optional custom line provided by the LLM itself (extracted from a
|
||||
* `discord-line` code block in the junior output). When set, this line
|
||||
* is used verbatim instead of picking from the hardcoded pool. Falls
|
||||
* back to the pool if empty / undefined.
|
||||
*/
|
||||
customLine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every ```discord-line\n<line>\n``` block out of an LLM text blob.
|
||||
* Returns the extracted lines AND the original text with all such blocks
|
||||
* removed (so it can be safely passed to downstream stages without chat
|
||||
* noise polluting their priorStages context).
|
||||
*
|
||||
* The block is intentionally a fenced code block so it doesn't conflict
|
||||
* with regular markdown formatting and is easy for the LLM to emit
|
||||
* verbatim.
|
||||
*/
|
||||
export function extractDiscordLines(text: string): {
|
||||
lines: string[];
|
||||
cleaned: string;
|
||||
} {
|
||||
if (!text) return { lines: [], cleaned: text };
|
||||
// Match: ```discord-line<newline><single-line content><newline>```
|
||||
const pattern = /```discord-line\s*\n([^\n`]*)\n```/g;
|
||||
const lines: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(text)) !== null) {
|
||||
const line = (m[1] ?? "").trim();
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
const cleaned = text
|
||||
.replace(/```discord-line\s*\n[^\n`]*\n```/g, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
return { lines, cleaned };
|
||||
}
|
||||
|
||||
const START_POOLS: Record<string, string[]> = {
|
||||
harang: [
|
||||
`📋 자, 기획 들어갈게. *{title}* 일단 범위부터 잡아둘게.`,
|
||||
`📋 *{title}* — 어떤 게 MVP 안에 들어가야 할지 정리할게.`,
|
||||
`📋 *{title}*, 통과 기준 먼저 정해놓고 갈게.`,
|
||||
`📋 기획 시작 — *{title}*. 비범위도 명확히 박아둘게.`,
|
||||
],
|
||||
narang: [
|
||||
`🔨 *{title}* 받았어! 바로 짜볼게.`,
|
||||
`🔨 코드 작성 시작 — *{title}*. 후딱 만들어볼게.`,
|
||||
`🔨 *{title}* 구현 들어간다. 파일 세팅부터.`,
|
||||
`🔨 받았어 *{title}*. 손이 근질근질해.`,
|
||||
],
|
||||
darang: [
|
||||
`🔍 *{title}* — 어디 어디 봐야 하나 체크리스트 뽑을게.`,
|
||||
`🔍 리뷰 시작. *{title}* 한 줄씩 꼼꼼히 볼게.`,
|
||||
`🔍 *{title}*, 통과 기준 항목별로 검사 들어갈게.`,
|
||||
`🔍 *{title}* — 빠진 거 있나 보자.`,
|
||||
],
|
||||
erang: [
|
||||
`🚀 *{title}* 배포 검증 시작. 환경부터 확인할게.`,
|
||||
`🚀 *{title}*, 무리 없이 띄울 수 있는지 보고 올게.`,
|
||||
`🚀 배포 단계 진입 — *{title}*. 안전하게 올려볼게.`,
|
||||
`🚀 *{title}* 인프라 점검 들어갈게.`,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* End-message pools are split by verdict where it matters.
|
||||
*
|
||||
* - harang: PLAN_READY (success) vs ABORT (give up)
|
||||
* - narang: IMPL_DONE (success) vs ERROR (failure)
|
||||
* - darang: APPROVE / REQUEST_CHANGES / ABORT
|
||||
* - erang : DEPLOY_DONE / DEPLOY_FAILED
|
||||
*
|
||||
* The pool key is `${agent}/${verdict}`. If a verdict isn't recognised
|
||||
* we fall back to the success pool (`${agent}/ok`).
|
||||
*/
|
||||
const END_POOLS: Record<string, string[]> = {
|
||||
// ── 하랑이 ──
|
||||
"harang/ok": [
|
||||
`📋 기획 끝. 통과 기준 박아놨으니 나랑이 받아.`,
|
||||
`📋 범위 잡혔어. 나랑아 부탁해.`,
|
||||
`📋 정리 끝났어. 다음은 구현이야.`,
|
||||
`📋 plan 완료. 나랑이가 받아갈 차례.`,
|
||||
],
|
||||
"harang/abort": [
|
||||
`⚠️ 기획 중단할게 — 요구사항이 너무 모호해서 진행 못 해.`,
|
||||
`⚠️ plan 단계에서 중단. 자기야 요구사항 다시 알려줘.`,
|
||||
],
|
||||
// ── 나랑이 ──
|
||||
"narang/ok": [
|
||||
`🔨 구현 끝났어{tail}. 다랑이 리뷰 부탁해.`,
|
||||
`🔨 일단 다 박았어{tail}. 다랑아 봐줘.`,
|
||||
`🔨 코드 정리 끝{tail}. 검수 넘긴다.`,
|
||||
`🔨 implement 마무리{tail}. 다음은 review.`,
|
||||
],
|
||||
"narang/error": [
|
||||
`❌ 구현 중 막혔어{tail}. 자기야 봐줄래?`,
|
||||
`❌ implement 실패{tail}. 다음 단계 못 가.`,
|
||||
],
|
||||
// ── 다랑이 ──
|
||||
"darang/approve": [
|
||||
`🔍 리뷰 통과! 이랑이 받아.`,
|
||||
`🔍 체크리스트 다 ✓. 배포로 넘길게.`,
|
||||
`🔍 큰 문제 없어. 이랑아 배포 검증 부탁해.`,
|
||||
`🔍 review 통과 — 다음은 이랑이.`,
|
||||
],
|
||||
"darang/request_changes": [
|
||||
`⚠️ 결함 발견 — 나랑아 다시 봐줄래?`,
|
||||
`⚠️ 통과 못 시켰어. 코드 다시 짜야 해.`,
|
||||
`⚠️ 체크리스트 미달. 나랑아 수정 부탁해.`,
|
||||
`⚠️ REQUEST_CHANGES — 한 번 더 돌려야겠어.`,
|
||||
],
|
||||
"darang/abort": [
|
||||
`🛑 이건 접근 자체가 잘못된 것 같아. 중단.`,
|
||||
`🛑 review 단계에서 abort — plan 부터 다시 봐야 해.`,
|
||||
],
|
||||
// ── 이랑이 ──
|
||||
"erang/ok": [
|
||||
`🚀 배포 검증 완료{tail}. 안전해.`,
|
||||
`🚀 환경 점검 OK{tail}. 띄울 수 있어.`,
|
||||
`🚀 deploy 끝{tail}. 자기야 확인해줘.`,
|
||||
`🚀 검증 완료{tail}. 무리 없이 동작해.`,
|
||||
],
|
||||
"erang/failed": [
|
||||
`❌ 배포 실패{tail} — 자기야 봐줘.`,
|
||||
`❌ 환경 점검에서 막혔어{tail}. deploy 못 해.`,
|
||||
],
|
||||
};
|
||||
|
||||
/** Map (agentName, stage, verdict) → pool key. */
|
||||
function endPoolKey(
|
||||
agentName: string,
|
||||
_stage: string,
|
||||
verdict?: string,
|
||||
): string {
|
||||
const v = (verdict ?? "").toUpperCase();
|
||||
switch (agentName) {
|
||||
case "harang":
|
||||
return v === "ABORT" ? "harang/abort" : "harang/ok";
|
||||
case "narang":
|
||||
return v === "ERROR" ? "narang/error" : "narang/ok";
|
||||
case "darang":
|
||||
if (v === "REQUEST_CHANGES") return "darang/request_changes";
|
||||
if (v === "ABORT") return "darang/abort";
|
||||
return "darang/approve";
|
||||
case "erang":
|
||||
return v === "DEPLOY_FAILED" ? "erang/failed" : "erang/ok";
|
||||
default:
|
||||
return `${agentName}/ok`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable picker — same input gets same line. */
|
||||
function pickFromPool(pool: string[], seed: string): string {
|
||||
if (pool.length === 0) return "";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
const idx = Math.abs(hash) % pool.length;
|
||||
return pool[idx]!;
|
||||
}
|
||||
|
||||
export function renderStageStart(ctx: StageMessageContext): string {
|
||||
const title = ctx.taskTitle.slice(0, 80);
|
||||
const pool = START_POOLS[ctx.agentName];
|
||||
if (!pool) return `▶️ ${ctx.stage} 시작 — *${title}*`;
|
||||
return pickFromPool(pool, ctx.agentName + ":start:" + title).replace(
|
||||
"{title}",
|
||||
title,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderStageEnd(ctx: StageMessageContext): string {
|
||||
const tail =
|
||||
ctx.filesProduced && ctx.filesProduced > 0
|
||||
? ` (산출물 ${ctx.filesProduced}개)`
|
||||
: "";
|
||||
|
||||
// LLM-supplied custom line wins. The junior who actually did the work
|
||||
// already knows what to say — use it verbatim. (We still substitute
|
||||
// {tail} in case the LLM left the placeholder in.)
|
||||
if (ctx.customLine && ctx.customLine.trim().length > 0) {
|
||||
return ctx.customLine.trim().replace("{tail}", tail);
|
||||
}
|
||||
|
||||
// Otherwise fall back to the hardcoded persona pool.
|
||||
const key = endPoolKey(ctx.agentName, ctx.stage, ctx.verdict);
|
||||
const pool = END_POOLS[key];
|
||||
if (!pool) return `✅ ${ctx.stage} 완료${tail}`;
|
||||
return pickFromPool(
|
||||
pool,
|
||||
ctx.agentName + ":end:" + key + ":" + ctx.taskTitle,
|
||||
).replace("{tail}", tail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an escalation alert. Used by the orchestrator when a pipeline
|
||||
* exhausts retry / replan budget and needs the user to step in.
|
||||
*/
|
||||
export interface EscalationContext {
|
||||
pipelineId: string;
|
||||
projectName: string;
|
||||
reason: string;
|
||||
stage: string;
|
||||
attempts: number;
|
||||
/** Discord user ID to mention. If empty, no mention. */
|
||||
mentionUserId?: string;
|
||||
}
|
||||
|
||||
export function renderEscalation(ctx: EscalationContext): string {
|
||||
const mention = ctx.mentionUserId ? `<@${ctx.mentionUserId}> ` : "";
|
||||
const short = ctx.pipelineId.slice(0, 8);
|
||||
return [
|
||||
`${mention}🚨 **자기야, 막혔어** — 사람이 봐야 할 것 같아`,
|
||||
``,
|
||||
`**프로젝트:** ${ctx.projectName}`,
|
||||
`**단계:** ${ctx.stage}`,
|
||||
`**시도:** ${ctx.attempts}회`,
|
||||
`**사유:** ${ctx.reason.slice(0, 600)}`,
|
||||
``,
|
||||
`Pipeline ID: \`${ctx.pipelineId}\``,
|
||||
`대시보드: https://hanarang.nabomhalang.co.kr/rails`,
|
||||
``,
|
||||
`복구하려면:`,
|
||||
`\`bash ~/.openclaw/skills/hanarang-rails/scripts/rails-status.sh ${short}\``,
|
||||
].join("\n");
|
||||
}
|
||||
228
sister-agent/src/git-ops.ts
Normal file
228
sister-agent/src/git-ops.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
|
||||
const GITEA_BASE_URL =
|
||||
process.env["GITEA_BASE_URL"] ?? "https://git.nabomhalang.co.kr";
|
||||
const GITEA_ORG = process.env["GITEA_ORG"] ?? "hanarang";
|
||||
const GITEA_TOKEN = process.env["GITEA_TOKEN"] ?? "";
|
||||
const GIT_USER_NAME = process.env["GIT_USER_NAME"] ?? "rails-agent";
|
||||
const GIT_USER_EMAIL = process.env["GIT_USER_EMAIL"] ?? "rails@hanarang.local";
|
||||
|
||||
export interface GitPushResult {
|
||||
ok: boolean;
|
||||
repoUrl: string;
|
||||
rawUrlBase: string;
|
||||
commit: string;
|
||||
filesCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function runCmd(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: Record<string, string> = {},
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolveFn) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
|
||||
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||
child.on("exit", (code) => resolveFn({ code: code ?? -1, stdout, stderr }));
|
||||
child.on("error", () => resolveFn({ code: -1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGiteaRepo(name: string, description: string): Promise<boolean> {
|
||||
if (!GITEA_TOKEN) return false;
|
||||
|
||||
// Check if org-level repo exists
|
||||
const checkUrl = `${GITEA_BASE_URL}/api/v1/repos/${GITEA_ORG}/${name}`;
|
||||
try {
|
||||
const res = await fetch(checkUrl, {
|
||||
headers: { Authorization: `token ${GITEA_TOKEN}` },
|
||||
});
|
||||
if (res.ok) return true;
|
||||
if (res.status !== 404) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create the repo under the org
|
||||
const createUrl = `${GITEA_BASE_URL}/api/v1/orgs/${GITEA_ORG}/repos`;
|
||||
try {
|
||||
const res = await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${GITEA_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description.slice(0, 255),
|
||||
private: false,
|
||||
auto_init: false,
|
||||
default_branch: "main",
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize git in the workspace dir and push everything to a pipeline-specific
|
||||
* repo on Gitea. Returns the repo URL and a commit hash on success.
|
||||
*
|
||||
* The repo name is derived from the pipeline id: `rails-<short>`.
|
||||
* If the repo doesn't exist, it's created via Gitea API.
|
||||
*/
|
||||
export async function commitAndPush(opts: {
|
||||
workspaceDir: string;
|
||||
pipelineId: string;
|
||||
projectName: string;
|
||||
stage: string;
|
||||
agentName: string;
|
||||
}): Promise<GitPushResult> {
|
||||
if (!GITEA_TOKEN) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: "",
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: "GITEA_TOKEN not configured",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await access(opts.workspaceDir);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: "",
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `workspace does not exist: ${opts.workspaceDir}`,
|
||||
};
|
||||
}
|
||||
|
||||
const repoName = `rails-${opts.pipelineId.slice(-10).toLowerCase()}`;
|
||||
const description = `Rails pipeline ${opts.pipelineId} — ${opts.projectName}`;
|
||||
const ok = await ensureGiteaRepo(repoName, description);
|
||||
if (!ok) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: "",
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `failed to ensure gitea repo ${repoName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const repoUrlHttps = `${GITEA_BASE_URL}/${GITEA_ORG}/${repoName}`;
|
||||
const pushUrl = `${GITEA_BASE_URL.replace(
|
||||
/^https:\/\//,
|
||||
`https://${GIT_USER_NAME}:${GITEA_TOKEN}@`,
|
||||
)}/${GITEA_ORG}/${repoName}.git`;
|
||||
|
||||
const env: Record<string, string> = {
|
||||
GIT_AUTHOR_NAME: GIT_USER_NAME,
|
||||
GIT_AUTHOR_EMAIL: GIT_USER_EMAIL,
|
||||
GIT_COMMITTER_NAME: GIT_USER_NAME,
|
||||
GIT_COMMITTER_EMAIL: GIT_USER_EMAIL,
|
||||
};
|
||||
|
||||
// git init (idempotent)
|
||||
await runCmd("git", ["init", "-b", "main"], opts.workspaceDir, env);
|
||||
await runCmd("git", ["config", "user.name", GIT_USER_NAME], opts.workspaceDir, env);
|
||||
await runCmd("git", ["config", "user.email", GIT_USER_EMAIL], opts.workspaceDir, env);
|
||||
|
||||
// Track all files
|
||||
const addResult = await runCmd("git", ["add", "-A"], opts.workspaceDir, env);
|
||||
if (addResult.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `git add failed: ${addResult.stderr.slice(0, 300)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = `${opts.agentName}/${opts.stage}: pipeline ${opts.pipelineId.slice(-10)}`;
|
||||
const commitResult = await runCmd(
|
||||
"git",
|
||||
["commit", "-m", msg, "--allow-empty"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
if (commitResult.code !== 0 && !commitResult.stdout.includes("nothing to commit")) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `git commit failed: ${commitResult.stderr.slice(0, 300)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Set remote + push
|
||||
await runCmd("git", ["remote", "remove", "origin"], opts.workspaceDir, env);
|
||||
await runCmd("git", ["remote", "add", "origin", pushUrl], opts.workspaceDir, env);
|
||||
|
||||
const pushResult = await runCmd(
|
||||
"git",
|
||||
["push", "-u", "origin", "main", "--force"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
if (pushResult.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase: "",
|
||||
commit: "",
|
||||
filesCount: 0,
|
||||
error: `git push failed: ${pushResult.stderr.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch latest commit hash for reporting
|
||||
const hashResult = await runCmd(
|
||||
"git",
|
||||
["rev-parse", "--short", "HEAD"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
const commit = hashResult.stdout.trim();
|
||||
|
||||
// Count files tracked in the commit
|
||||
const fileList = await runCmd(
|
||||
"git",
|
||||
["ls-files"],
|
||||
opts.workspaceDir,
|
||||
env,
|
||||
);
|
||||
const filesCount = fileList.stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const rawUrlBase = `${repoUrlHttps}/raw/branch/main`;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
repoUrl: repoUrlHttps,
|
||||
rawUrlBase,
|
||||
commit,
|
||||
filesCount,
|
||||
};
|
||||
}
|
||||
11
sister-agent/src/llm.ts
Normal file
11
sister-agent/src/llm.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Shim for backwards compatibility — the adapter implementations now live
|
||||
// in ./llm/. Import from ./llm/index.js for new code.
|
||||
export {
|
||||
callLlm,
|
||||
createLlmAdapter,
|
||||
getLlmAdapter,
|
||||
type LlmAdapter,
|
||||
type LlmRequest,
|
||||
type LlmResult,
|
||||
type ProviderName,
|
||||
} from "./llm/index.js";
|
||||
18
sister-agent/src/llm/adapter.ts
Normal file
18
sister-agent/src/llm/adapter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface LlmRequest {
|
||||
prompt: string;
|
||||
model: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface LlmResult {
|
||||
ok: boolean;
|
||||
text: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface LlmAdapter {
|
||||
readonly name: string;
|
||||
infer(req: LlmRequest): Promise<LlmResult>;
|
||||
}
|
||||
97
sister-agent/src/llm/anthropic.ts
Normal file
97
sister-agent/src/llm/anthropic.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Anthropic Messages API adapter.
|
||||
*
|
||||
* Required env:
|
||||
* ANTHROPIC_API_KEY — your API key
|
||||
* Optional env:
|
||||
* ANTHROPIC_BASE_URL — override endpoint (defaults to api.anthropic.com)
|
||||
* ANTHROPIC_VERSION — API version header (defaults to 2023-06-01)
|
||||
*/
|
||||
export class AnthropicAdapter implements LlmAdapter {
|
||||
readonly name = "anthropic";
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly version: string;
|
||||
|
||||
constructor(opts: { apiKey?: string; baseUrl?: string; version?: string } = {}) {
|
||||
this.apiKey = opts.apiKey ?? process.env["ANTHROPIC_API_KEY"] ?? "";
|
||||
this.baseUrl =
|
||||
opts.baseUrl ??
|
||||
process.env["ANTHROPIC_BASE_URL"] ??
|
||||
"https://api.anthropic.com";
|
||||
this.version =
|
||||
opts.version ?? process.env["ANTHROPIC_VERSION"] ?? "2023-06-01";
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
if (!this.apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: "ANTHROPIC_API_KEY not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": this.apiKey,
|
||||
"anthropic-version": this.version,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: req.model,
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: "user", content: req.prompt }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: `anthropic HTTP ${res.status}: ${body.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
model?: string;
|
||||
};
|
||||
const text =
|
||||
data.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("") ?? "";
|
||||
return {
|
||||
ok: true,
|
||||
text,
|
||||
provider: this.name,
|
||||
model: data.model ?? req.model,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
75
sister-agent/src/llm/index.ts
Normal file
75
sister-agent/src/llm/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
import { OpenClawAdapter } from "./openclaw.js";
|
||||
import { OpenAiAdapter } from "./openai.js";
|
||||
import { AnthropicAdapter } from "./anthropic.js";
|
||||
import { OllamaAdapter } from "./ollama.js";
|
||||
import { MockAdapter } from "./mock.js";
|
||||
|
||||
export type { LlmAdapter, LlmRequest, LlmResult };
|
||||
|
||||
export type ProviderName =
|
||||
| "openclaw"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "ollama"
|
||||
| "mock";
|
||||
|
||||
/**
|
||||
* Build an adapter from env / explicit override.
|
||||
*
|
||||
* Provider selection order:
|
||||
* 1. explicit `opts.provider`
|
||||
* 2. env LLM_PROVIDER
|
||||
* 3. default: "mock" (safe fallback — won't accidentally spend money)
|
||||
*/
|
||||
export function createLlmAdapter(
|
||||
opts: { provider?: ProviderName } = {},
|
||||
): LlmAdapter {
|
||||
const raw =
|
||||
opts.provider ??
|
||||
(process.env["LLM_PROVIDER"] as ProviderName | undefined) ??
|
||||
"mock";
|
||||
|
||||
switch (raw) {
|
||||
case "openclaw":
|
||||
return new OpenClawAdapter();
|
||||
case "openai":
|
||||
return new OpenAiAdapter();
|
||||
case "anthropic":
|
||||
return new AnthropicAdapter();
|
||||
case "ollama":
|
||||
return new OllamaAdapter();
|
||||
case "mock":
|
||||
return new MockAdapter();
|
||||
default: {
|
||||
const exhaustive: never = raw;
|
||||
throw new Error(
|
||||
`Unknown LLM_PROVIDER: ${exhaustive as string}. ` +
|
||||
`Supported: openclaw, openai, anthropic, ollama, mock`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton so we don't rebuild the adapter on every LLM call.
|
||||
let cached: LlmAdapter | null = null;
|
||||
|
||||
export function getLlmAdapter(): LlmAdapter {
|
||||
if (!cached) cached = createLlmAdapter();
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Convenience wrapper — kept signature-compatible with the old callLlm(). */
|
||||
export async function callLlm(opts: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
modelOverride?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<LlmResult> {
|
||||
const adapter = getLlmAdapter();
|
||||
return adapter.infer({
|
||||
prompt: opts.prompt,
|
||||
model: opts.modelOverride ?? opts.model ?? "",
|
||||
timeoutMs: opts.timeoutMs ?? 120_000,
|
||||
});
|
||||
}
|
||||
42
sister-agent/src/llm/mock.ts
Normal file
42
sister-agent/src/llm/mock.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Mock adapter — returns a deterministic fake response based on the role
|
||||
* hint in the prompt. Useful for smoke tests and offline demos where no
|
||||
* real LLM credentials are available.
|
||||
*/
|
||||
export class MockAdapter implements LlmAdapter {
|
||||
readonly name = "mock";
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
// tiny delay so upstream concurrency code behaves as if it's async
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
const isImpl = /implement|구현/.test(req.prompt);
|
||||
const isJunior = /junior|신입/.test(req.prompt);
|
||||
|
||||
let text: string;
|
||||
if (isImpl && isJunior) {
|
||||
text = [
|
||||
"간단한 샘플 산출물입니다.",
|
||||
"",
|
||||
"```html:frontend/index.html",
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head><meta charset=\"utf-8\"><title>mock</title></head>",
|
||||
"<body><h1>Hello from mock adapter</h1></body>",
|
||||
"</html>",
|
||||
"```",
|
||||
].join("\n");
|
||||
} else {
|
||||
text = `# Mock ${req.model}\n\n이 응답은 MockAdapter 가 생성한 결정론적 더미입니다. 실제 LLM 응답이 아닙니다.`;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
text,
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
72
sister-agent/src/llm/ollama.ts
Normal file
72
sister-agent/src/llm/ollama.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Ollama adapter — for local LLM inference via https://ollama.com
|
||||
*
|
||||
* Optional env:
|
||||
* OLLAMA_BASE_URL — defaults to http://localhost:11434
|
||||
*
|
||||
* Example model names: llama3.1, qwen2.5-coder, mistral, deepseek-coder
|
||||
*/
|
||||
export class OllamaAdapter implements LlmAdapter {
|
||||
readonly name = "ollama";
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(opts: { baseUrl?: string } = {}) {
|
||||
this.baseUrl =
|
||||
opts.baseUrl ??
|
||||
process.env["OLLAMA_BASE_URL"] ??
|
||||
"http://localhost:11434";
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: req.model,
|
||||
prompt: req.prompt,
|
||||
stream: false,
|
||||
options: { temperature: 0.3 },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: `ollama HTTP ${res.status}: ${body.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
response?: string;
|
||||
model?: string;
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
text: data.response ?? "",
|
||||
provider: this.name,
|
||||
model: data.model ?? req.model,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
91
sister-agent/src/llm/openai.ts
Normal file
91
sister-agent/src/llm/openai.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* OpenAI Chat Completions adapter.
|
||||
*
|
||||
* Required env:
|
||||
* OPENAI_API_KEY — your API key
|
||||
* Optional env:
|
||||
* OPENAI_BASE_URL — override endpoint (defaults to api.openai.com/v1)
|
||||
* Use this for Azure OpenAI, OpenRouter, local
|
||||
* llama.cpp servers that speak the OpenAI protocol,
|
||||
* etc.
|
||||
*/
|
||||
export class OpenAiAdapter implements LlmAdapter {
|
||||
readonly name = "openai";
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(opts: { apiKey?: string; baseUrl?: string } = {}) {
|
||||
this.apiKey = opts.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
|
||||
this.baseUrl =
|
||||
opts.baseUrl ??
|
||||
process.env["OPENAI_BASE_URL"] ??
|
||||
"https://api.openai.com/v1";
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
if (!this.apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: "OPENAI_API_KEY not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: req.model,
|
||||
messages: [{ role: "user", content: req.prompt }],
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: `openai HTTP ${res.status}: ${body.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
model?: string;
|
||||
};
|
||||
const text = data.choices?.[0]?.message?.content ?? "";
|
||||
return {
|
||||
ok: true,
|
||||
text,
|
||||
provider: this.name,
|
||||
model: data.model ?? req.model,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
108
sister-agent/src/llm/openclaw.ts
Normal file
108
sister-agent/src/llm/openclaw.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* OpenClaw adapter — invokes the nabomhalang internal OpenClaw runtime via its
|
||||
* `openclaw infer model run --json` subprocess. This is the original adapter
|
||||
* used by the hanarang 4-sister deployment.
|
||||
*
|
||||
* External users will most likely NOT have OpenClaw installed. They should
|
||||
* use the `openai`, `anthropic`, `ollama`, or `mock` adapters instead.
|
||||
*/
|
||||
export class OpenClawAdapter implements LlmAdapter {
|
||||
readonly name = "openclaw";
|
||||
private readonly bin: string;
|
||||
|
||||
constructor(opts: { bin?: string } = {}) {
|
||||
this.bin =
|
||||
opts.bin ??
|
||||
process.env["OPENCLAW_BIN"] ??
|
||||
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
// Note: we intentionally do NOT pass `--model` to openclaw. OpenClaw has
|
||||
// its own per-agent model allowlist and routing logic, and overriding it
|
||||
// with rails-side role names like `gpt-5.4` / `glm-5-turbo` causes
|
||||
// "Model override not allowed for agent main" errors. Other adapters
|
||||
// (openai/anthropic/ollama) still honor req.model — only this adapter
|
||||
// delegates model selection back to the runtime.
|
||||
const args = ["infer", "model", "run", "--prompt", req.prompt, "--json"];
|
||||
|
||||
return new Promise((resolveFn) => {
|
||||
const child = spawn(this.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: this.name,
|
||||
model: req.model || "default",
|
||||
errorMessage: `openclaw timeout after ${req.timeoutMs}ms`,
|
||||
});
|
||||
}, req.timeoutMs);
|
||||
|
||||
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: this.name,
|
||||
model: req.model || "default",
|
||||
errorMessage: `openclaw spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model || "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 }>;
|
||||
};
|
||||
resolveFn({
|
||||
ok: parsed.ok,
|
||||
text: parsed.outputs?.[0]?.text ?? "",
|
||||
provider: parsed.provider || this.name,
|
||||
model: parsed.model || req.model,
|
||||
});
|
||||
} catch (err) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model || "default",
|
||||
errorMessage: `openclaw JSON parse failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,25 @@ export function planDecomposition(complexity: ComplexityScore): DecompositionPla
|
||||
|
||||
switch (tier) {
|
||||
case "trivial":
|
||||
// Even trivial tasks need a junior to actually produce code. Managers
|
||||
// are planner-only by role definition and maybeExtractFiles() in
|
||||
// spawn.ts only saves files from juniors. Without a junior the
|
||||
// pipeline completes "successfully" with zero output — the classic
|
||||
// ghost-pipeline bug. Spawn 1 junior to guarantee something lands.
|
||||
return {
|
||||
tier,
|
||||
score,
|
||||
strategy: "direct",
|
||||
spawn: [],
|
||||
strategy: "single-junior",
|
||||
spawn: [
|
||||
{
|
||||
role: "junior",
|
||||
count: 1,
|
||||
rationale:
|
||||
"Trivial task still needs one junior to produce actual output. Manager can't write code per role definition.",
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
"Manager handles directly — no team needed for trivial tasks.",
|
||||
"Even trivial tasks spawn one junior so the pipeline actually produces files.",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
324
sister-agent/src/prompts.ts
Normal file
324
sister-agent/src/prompts.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
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;
|
||||
priorStages?: Array<{ stage: string; text: 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.priorStages && ctx.priorStages.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(`# 앞 단계(들)의 결과물 — 반드시 처음부터 끝까지 모두 읽고 일관되게 이어가`);
|
||||
lines.push(
|
||||
`(아래 각 단계 본문은 잘리지 않은 원본이다. 코드가 중간에 끝난 것처럼 보이면 그것은 잘림이 아니라 진짜 끝이다.)`,
|
||||
);
|
||||
for (const ps of ctx.priorStages) {
|
||||
lines.push("");
|
||||
lines.push(`## ${STAGE_KOREAN[ps.stage] ?? ps.stage} 단계 결과`);
|
||||
// Cap matches the upstream spawn.ts aggregation (64KB). LLM context
|
||||
// windows are 200k+ tokens so this fits comfortably even after
|
||||
// multiple stages accumulate.
|
||||
lines.push(ps.text.slice(0, 64_000));
|
||||
}
|
||||
}
|
||||
if (ctx.prevStageOutput) {
|
||||
lines.push("");
|
||||
lines.push(`# 직전 상위 노드(같은 stage) 의 지시`);
|
||||
lines.push(ctx.prevStageOutput.slice(0, 32_000));
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`# 출력 형식`);
|
||||
lines.push(roleOutputHint(ctx.role, ctx.stage));
|
||||
lines.push(`반드시 한국어로 답해. 핵심만 간결하게.`);
|
||||
|
||||
// 모든 작업 결과 끝에 디스코드용 한 줄 멘트를 LLM 이 직접 emit 하게 한다.
|
||||
// sister-agent 가 이 블록을 추출해 stage-end Discord notify 메시지로 사용
|
||||
// 한다 (없으면 hardcoded 풀로 fallback). junior 가 가장 작업 내용을 잘
|
||||
// 알기 때문에 junior 에만 요청한다 — manager 는 작업 시작 전에 결정만 함.
|
||||
if (ctx.role === "junior") {
|
||||
lines.push("");
|
||||
lines.push(discordLineFooter(ctx));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer instructing the junior LLM to append a `discord-line` block at
|
||||
* the end of its response. The block is parsed by the sister-agent and
|
||||
* used as the stage-end Discord notification message.
|
||||
*
|
||||
* Persona context (자매 정체성) is included so the LLM matches tone:
|
||||
* harang — 차분/단정
|
||||
* narang — 활달/실용
|
||||
* darang — 꼼꼼/엄격
|
||||
* erang — 차분/믿음직
|
||||
*/
|
||||
function discordLineFooter(ctx: PromptContext): string {
|
||||
const persona: Record<string, string> = {
|
||||
harang: "차분하고 단정한 plan 단계 부장",
|
||||
narang: "활달하고 실용적인 implement 단계 부장",
|
||||
darang: "꼼꼼하고 엄격한 review 단계 부장",
|
||||
erang: "차분하고 믿음직한 deploy 단계 부장",
|
||||
};
|
||||
const exampleByStage: Record<string, string> = {
|
||||
plan: '"📋 MVP 범위 잡았어. 나랑이 받아."',
|
||||
implement: '"🔨 todo HTML 5개 함수 박았어. 다랑아 봐줘."',
|
||||
review:
|
||||
'"🔍 체크리스트 다 ✓. 이랑이 받아." (APPROVE) 또는 ' +
|
||||
'"⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘." (REQUEST_CHANGES)',
|
||||
deploy: '"🚀 todo-mvp.html 검증 완료. 안전해."',
|
||||
};
|
||||
return [
|
||||
`# 디스코드 알림 한 줄`,
|
||||
`자기야가 디스코드 채널에서 보게 될 너의 한 줄 보고를 마지막에 추가해.`,
|
||||
`너는 ${persona[ctx.agentName] ?? ctx.agentName} 의 페르소나를 살려.`,
|
||||
`방금 너가 한 작업의 핵심을 한 줄로 요약 (50 자 이내, 이모지 1-2개).`,
|
||||
`결과가 실패/REQUEST_CHANGES/ABORT 면 그 사실을 명확히 (✗/⚠️/🛑 중 하나) 표시.`,
|
||||
``,
|
||||
`**정확히 다음 형식으로** 응답 맨 끝에 추가:`,
|
||||
"```discord-line",
|
||||
"<여기에 한 줄>",
|
||||
"```",
|
||||
``,
|
||||
`예시 (${STAGE_KOREAN[ctx.stage]}):`,
|
||||
exampleByStage[ctx.stage] ?? '"✅ 작업 완료"',
|
||||
``,
|
||||
`이 블록은 따로 파싱되니까 위 형식 정확히 지켜. 본문 어디 다른 곳에는 같은 형식 쓰지 마.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
|
||||
// Stage-specific instructions take precedence. The original "decompose
|
||||
// into team" wording only makes sense for plan / implement — for review
|
||||
// and deploy it's actively wrong, because the manager would then output
|
||||
// a fake team plan ("수석 1명은…, 선임 1명은…") instead of an actual
|
||||
// verdict.
|
||||
if (stage === "review") {
|
||||
return reviewHintForRole(role);
|
||||
}
|
||||
if (stage === "deploy") {
|
||||
return deployHintForRole(role);
|
||||
}
|
||||
|
||||
// ── plan / implement ─────────────────────────────────────────
|
||||
// IMPORTANT: 팀 분배 (수석/선임/신입 N명...) narration 은 금지다.
|
||||
// 하위 노드의 spawn 트리는 sister-agent 의 planner.ts 가 complexity score 로
|
||||
// 결정론적으로 결정한다. LLM manager 는 spawn 결정에 영향을 주지 않으며,
|
||||
// "수석 1명을 붙일게" 같은 prose 는 빈 약속 + 토큰 낭비 + 사용자 혼란이다.
|
||||
// 대신 manager 는 이 stage 의 진짜 결정 (범위/스택/파일 경계) 만 짧게.
|
||||
if (role === "manager") {
|
||||
if (stage === "plan") {
|
||||
return [
|
||||
`너는 이 단계의 최종 결정권자다. 팀(수석/선임/신입)이 자동으로 붙으니 분배 narration 은 절대 하지 마.`,
|
||||
`대신 다음 3 가지만 짧게 결정해서 답해:`,
|
||||
`1) MVP 범위: 무엇을 포함하나 한 줄`,
|
||||
`2) 명시적 비범위: 의도적으로 제외할 것 한 줄`,
|
||||
`3) 통과 기준: 무엇이 동작해야 끝났다고 보는지 한 줄`,
|
||||
`각 줄은 명령형/단정형으로. "수석/선임/신입" 단어 사용 금지.`,
|
||||
].join("\n");
|
||||
}
|
||||
if (stage === "implement") {
|
||||
return [
|
||||
`너는 이 단계의 최종 결정권자다. 팀(수석/선임/신입)이 자동으로 붙으니 분배 narration 은 절대 하지 마.`,
|
||||
`대신 다음 3 가지만 짧게 결정해서 답해:`,
|
||||
`1) 기술 스택 / 런타임 한 줄 (예: "vanilla HTML+JS, localStorage")`,
|
||||
`2) 파일 구조 1~2 줄 (어떤 파일이 만들어지는지)`,
|
||||
`3) 핵심 구현 결정 한 줄 (상태 관리 방식, 데이터 형태 등)`,
|
||||
`각 줄은 명령형/단정형으로. "수석/선임/신입" 단어 사용 금지.`,
|
||||
].join("\n");
|
||||
}
|
||||
// Other stages handled above by reviewHintForRole / deployHintForRole
|
||||
return `이 단계의 핵심 결정 한 문단으로.`;
|
||||
}
|
||||
if (role === "principal") {
|
||||
return `${STAGE_KOREAN[stage]} 단계의 기술적 리스크와 핵심 결정 사항을 bullet 으로 1~3 개. "수석/선임/신입" 같은 팀 narration 금지 — 시스템이 자동으로 분배한다.`;
|
||||
}
|
||||
if (role === "lead") {
|
||||
return `${STAGE_KOREAN[stage]} 단계에서 검증해야 할 핵심 포인트를 bullet 1~3 개. 팀 분배 narration 금지.`;
|
||||
}
|
||||
// junior
|
||||
if (stage === "plan") {
|
||||
return `이 프로젝트의 핵심 plan 을 마크다운 bullet 형식으로 작성해. "수석/선임/신입" 단어 사용 금지.`;
|
||||
}
|
||||
if (stage === "implement") {
|
||||
return [
|
||||
`요구된 코드/파일을 실제로 작성해.`,
|
||||
`각 파일을 코드 블록으로 감싸고, **반드시 다음 형식으로 파일 경로를 명시**해:`,
|
||||
"```html:src/index.html",
|
||||
"<!DOCTYPE html>...",
|
||||
"```",
|
||||
`경로는 프로젝트 루트 기준 상대 경로. 언어 태그 콜론 뒤에 경로.`,
|
||||
`여러 파일이 필요하면 각각 별도 블록으로. 설명은 최소화.`,
|
||||
`"수석/선임/신입" 같은 팀 narration 은 코드 출력 안에 포함하지 마.`,
|
||||
].join("\n");
|
||||
}
|
||||
return `결과를 명확히 제출해.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Review-stage hints: every role outputs an actual verdict, never a team
|
||||
* plan. The manager is the FINAL authority and must commit to APPROVE or
|
||||
* REQUEST_CHANGES — no decomposition, no delegation, no "수석 1명은…" lists.
|
||||
*
|
||||
* Manager output format is locked into a DoD checklist. The reviewer must
|
||||
* extract concrete acceptance criteria from priorStages.plan ("통과 기준",
|
||||
* "MVP 범위") and check each one against the implement result. This forces
|
||||
* the LLM to think in terms of testable items instead of generic prose.
|
||||
*/
|
||||
function reviewHintForRole(role: Role): string {
|
||||
switch (role) {
|
||||
case "manager":
|
||||
return [
|
||||
`너는 review 단계의 최종 결정권자다. 절대 작업을 분해하거나 팀(수석/선임/신입)을 배치하지 마. 본인이 직접 결정한다.`,
|
||||
``,
|
||||
`## 입력`,
|
||||
`위 priorStages 에는 다음이 들어 있다:`,
|
||||
`- plan 단계 결과: 하랑이가 정한 MVP 범위 / 비범위 / 통과 기준`,
|
||||
`- implement 단계 결과: 나랑이가 만든 실제 코드 본문 (잘리지 않은 원본)`,
|
||||
``,
|
||||
`## 작업 절차 (정확히 이 순서)`,
|
||||
`1. plan 단계의 "MVP 범위" 와 "통과 기준" 에서 **검증 가능한 항목** 을 3~6개 추출한다. 추상적인 항목 말고 구체적으로 코드에서 확인 가능한 것 (예: "추가 버튼이 있고 동작함", "삭제 후 새로고침 시 유지됨").`,
|
||||
`2. 각 항목을 implement 코드에서 직접 찾아 통과/미달 판정한다.`,
|
||||
`3. 모든 항목이 통과면 APPROVE, 하나라도 미달이면 REQUEST_CHANGES, 본질적으로 잘못된 접근이면 ABORT.`,
|
||||
``,
|
||||
`## 출력 형식 (정확히 이대로)`,
|
||||
``,
|
||||
`\`\`\``,
|
||||
`## DoD 체크리스트`,
|
||||
`- [✓|✗] <항목 1 한 줄 설명> — <근거: 어떤 파일의 어떤 부분에서 확인됨>`,
|
||||
`- [✓|✗] <항목 2 한 줄 설명> — <근거>`,
|
||||
`- [✓|✗] <항목 3 한 줄 설명> — <근거>`,
|
||||
`(필요하면 더)`,
|
||||
``,
|
||||
`## 최종 결정`,
|
||||
`APPROVE | REQUEST_CHANGES | ABORT`,
|
||||
``,
|
||||
`## 결정 근거`,
|
||||
`<한 문단 — 어떤 항목이 결정적으로 통과/미달인지 한국어로>`,
|
||||
`\`\`\``,
|
||||
``,
|
||||
`## 엄격한 금지`,
|
||||
`- 작업 분배, 가상 팀 구성, "수석/선임/신입" 단어 사용`,
|
||||
`- "내가 마지막에 본다" 같은 미래 약속`,
|
||||
`- 코드를 다시 작성하거나 새 코드 제안 (그건 implement 단계의 일)`,
|
||||
`- DoD 체크리스트 없이 prose 만 출력하는 것 (반드시 위 형식)`,
|
||||
`- "✓" 가 아닌 "통과", "OK" 같은 단어 사용 (파서가 못 잡음)`,
|
||||
``,
|
||||
`## 보너스 규칙`,
|
||||
`- 사용자가 요구사항에 의도적으로 모순/제한 (예: "함수를 비워줘") 을 넣었으면 그건 새 DoD 다. 그 의도를 충족하면 APPROVE.`,
|
||||
`- minor 한 스타일 / 주석 누락은 REQUEST_CHANGES 가 아니다. critical/major 만 카운트.`,
|
||||
].join("\n");
|
||||
case "principal":
|
||||
return [
|
||||
`너는 기술 리뷰 담당이다. plan 의 통과 기준과 implement 코드를 보고 critical/major 결함만 1~3개 골라 bullet 로 정리해.`,
|
||||
``,
|
||||
`형식 (정확히 이대로):`,
|
||||
`- [critical|major] <어느 파일/라인/함수> — <무엇이 문제> — <왜 문제> — <어떻게 고쳐야>`,
|
||||
``,
|
||||
`minor / recommendation 은 적지 마. 작업을 분배하거나 팀을 구성하지 마.`,
|
||||
].join("\n");
|
||||
case "lead":
|
||||
return [
|
||||
`너는 기능 동작 검증 담당이다. plan 의 "통과 기준" 에서 핵심 기능을 추출하고, 각 기능별로 implement 코드에서 동작 여부를 한 줄씩 적어.`,
|
||||
``,
|
||||
`형식 (정확히 이대로):`,
|
||||
`✓ <기능명>: 동작 OK — <근거: 어느 함수가 어떻게 처리>`,
|
||||
`✗ <기능명>: 실패 — <원인: 어떤 코드가 빠지거나 잘못됨>`,
|
||||
``,
|
||||
`작업을 분배하거나 신입에게 위임하지 마. 새 코드 제안 금지.`,
|
||||
].join("\n");
|
||||
case "junior":
|
||||
return [
|
||||
`위 priorStages 의 implement 결과물 코드를 직접 읽고 plan 의 통과 기준과 비교해 평가해.`,
|
||||
`첫 줄에 \`APPROVE\` 또는 \`REQUEST_CHANGES\` 또는 \`ABORT\` 로만 시작.`,
|
||||
`그 다음 줄부터 한 문단 이내로 핵심 이유. 코드를 다시 작성하지 마.`,
|
||||
].join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy-stage hints: every role focuses on deployability / verification,
|
||||
* never on decomposition.
|
||||
*/
|
||||
function deployHintForRole(role: Role): string {
|
||||
switch (role) {
|
||||
case "manager":
|
||||
return [
|
||||
`너는 deploy 단계의 최종 결정권자다. 작업을 분해하거나 팀을 배치하지 마.`,
|
||||
`위 priorStages 의 review 결과 + implement 결과물을 보고 배포 검증 결과를 한 문단으로 종합한 뒤,`,
|
||||
`**마지막 줄** 에 \`DEPLOY_DONE\` 또는 \`DEPLOY_FAILED\` 중 하나만 적어.`,
|
||||
].join("\n");
|
||||
case "principal":
|
||||
return [
|
||||
`배포 환경에서 발생할 수 있는 리스크 (브라우저 호환성, CSP, CDN, 의존성 누락 등) 를 1~3개 bullet 로.`,
|
||||
`해당 없으면 "리스크 없음" 한 줄.`,
|
||||
].join("\n");
|
||||
case "lead":
|
||||
return [
|
||||
`배포 후 즉시 확인할 검증 체크리스트를 bullet 로. 각 항목은 "□ <확인 절차>" 형식.`,
|
||||
].join("\n");
|
||||
case "junior":
|
||||
return [
|
||||
`이 결과물을 어떻게 배포 검증할지 짧게 설명하고 마지막 줄에 "DEPLOY_DONE" 또는 "DEPLOY_FAILED" 표기.`,
|
||||
].join("\n");
|
||||
}
|
||||
}
|
||||
@@ -7,28 +7,71 @@ export interface RoleConfig {
|
||||
maxSpawnPerCall: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-role model defaults. For external users this is almost certainly the
|
||||
* first thing you'll want to change — the `gpt-5.4` / `glm-*` / `gpt-codex-*`
|
||||
* names are OpenClaw-internal labels that won't resolve against OpenAI,
|
||||
* Anthropic, or Ollama directly.
|
||||
*
|
||||
* Override priority (highest first):
|
||||
* 1. env vars:
|
||||
* LLM_MODEL_MANAGER, LLM_MODEL_PRINCIPAL, LLM_MODEL_LEAD, LLM_MODEL_JUNIOR
|
||||
* LLM_MODEL_FALLBACK (used for every role's fallback unless you set
|
||||
* LLM_MODEL_FALLBACK_<ROLE>)
|
||||
* 2. these hardcoded defaults (OpenClaw-flavored)
|
||||
*
|
||||
* Good starting points for a real deployment:
|
||||
* OpenAI: gpt-4o / gpt-4o-mini
|
||||
* Anthropic: claude-opus-4-6 / claude-haiku-4-5
|
||||
* Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b
|
||||
*/
|
||||
|
||||
const OPENCLAW_DEFAULTS: Record<Role, { primary: string; fallback: string }> = {
|
||||
manager: { primary: "gpt-5.4", fallback: "glm-5.1" },
|
||||
principal: { primary: "gpt-5.4", fallback: "glm-5.1" },
|
||||
lead: { primary: "gpt-codex-5.3", fallback: "glm-5" },
|
||||
junior: { primary: "glm-5-turbo", fallback: "gpt-5" },
|
||||
};
|
||||
|
||||
function envModel(role: Role, kind: "primary" | "fallback"): string | undefined {
|
||||
const up = role.toUpperCase();
|
||||
if (kind === "primary") {
|
||||
return process.env[`LLM_MODEL_${up}`];
|
||||
}
|
||||
return (
|
||||
process.env[`LLM_MODEL_FALLBACK_${up}`] ??
|
||||
process.env["LLM_MODEL_FALLBACK"]
|
||||
);
|
||||
}
|
||||
|
||||
function modelFor(role: Role, kind: "primary" | "fallback"): string {
|
||||
const override = envModel(role, kind);
|
||||
if (override && override.length > 0) return override;
|
||||
return OPENCLAW_DEFAULTS[role][kind];
|
||||
}
|
||||
|
||||
export const ROLES: Record<Role, RoleConfig> = {
|
||||
manager: {
|
||||
primaryModel: "gpt-5.4",
|
||||
fallbackModel: "glm-5.1",
|
||||
primaryModel: modelFor("manager", "primary"),
|
||||
fallbackModel: modelFor("manager", "fallback"),
|
||||
canSpawn: ["principal", "lead", "junior"],
|
||||
maxSpawnPerCall: 4,
|
||||
},
|
||||
principal: {
|
||||
primaryModel: "gpt-5.4",
|
||||
fallbackModel: "glm-5.1",
|
||||
primaryModel: modelFor("principal", "primary"),
|
||||
fallbackModel: modelFor("principal", "fallback"),
|
||||
canSpawn: ["lead", "junior"],
|
||||
maxSpawnPerCall: 3,
|
||||
},
|
||||
lead: {
|
||||
primaryModel: "gpt-codex-5.3",
|
||||
fallbackModel: "glm-5",
|
||||
primaryModel: modelFor("lead", "primary"),
|
||||
fallbackModel: modelFor("lead", "fallback"),
|
||||
canSpawn: ["junior"],
|
||||
maxSpawnPerCall: 4,
|
||||
},
|
||||
junior: {
|
||||
primaryModel: "glm-5-turbo",
|
||||
fallbackModel: "gpt-5",
|
||||
primaryModel: modelFor("junior", "primary"),
|
||||
fallbackModel: modelFor("junior", "fallback"),
|
||||
canSpawn: [],
|
||||
maxSpawnPerCall: 0,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { z } from "zod";
|
||||
import { InvokeRequest } from "./types.js";
|
||||
import { executeInvocation } from "./spawn.js";
|
||||
import { RailsClient } from "./rails-client.js";
|
||||
import { notifyDiscord } from "./discord-notify.js";
|
||||
|
||||
const NotifyRequest = z.object({
|
||||
channelId: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env["SISTER_AGENT_PORT"] ?? "18801", 10);
|
||||
const AGENT_NAME = process.env["SISTER_AGENT_NAME"] ?? "unknown";
|
||||
@@ -58,6 +65,30 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// ── /notify — fire a Discord message via the local OpenClaw CLI ──
|
||||
// Used by the rails orchestrator (or any other internal caller) to
|
||||
// post messages from this sister's bot identity. Best-effort.
|
||||
if (method === "POST" && path === "/notify") {
|
||||
const body = await readJson(req);
|
||||
const parsed = NotifyRequest.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return sendJson(res, 400, {
|
||||
error: "invalid_notify",
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
const result = await notifyDiscord({
|
||||
channelId: parsed.data.channelId,
|
||||
message: parsed.data.message,
|
||||
});
|
||||
log(result.ok ? "info" : "warn", "notify", {
|
||||
channel: parsed.data.channelId,
|
||||
ok: result.ok,
|
||||
error: result.error,
|
||||
});
|
||||
return sendJson(res, result.ok ? 200 : 502, result);
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/invoke") {
|
||||
const body = await readJson(req);
|
||||
const parsed = InvokeRequest.safeParse(body);
|
||||
@@ -68,12 +99,15 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const req2 = { ...parsed.data, agentName: parsed.data.agentName || AGENT_NAME };
|
||||
// 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,
|
||||
notifyChannelId: req2.notifyChannelId || "(none)",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,35 +1,90 @@
|
||||
import { ulid } from "ulid";
|
||||
import type { Role, SubTaskRecord, InvokeRequest, HandoffMessage } from "./types.js";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
Role,
|
||||
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 { scoreComplexity } from "./complexity.js";
|
||||
import {
|
||||
planDecomposition,
|
||||
type DecompositionPlan,
|
||||
type SpawnPlan,
|
||||
} from "./planner.js";
|
||||
import type { RailsClient } from "./rails-client.js";
|
||||
import { callLlm } from "./llm.js";
|
||||
import { buildPrompt } from "./prompts.js";
|
||||
import { extractCodeBlocks, saveExtractedFiles } from "./code-extractor.js";
|
||||
import { commitAndPush } from "./git-ops.js";
|
||||
import {
|
||||
notifyDiscord,
|
||||
renderStageStart,
|
||||
renderStageEnd,
|
||||
extractDiscordLines,
|
||||
} from "./discord-notify.js";
|
||||
|
||||
// Real LLM call is the default. Set USE_REAL_LLM=false (or the legacy
|
||||
// RAILS_USE_REAL_LLM=false) to short-circuit every LLM call — useful when
|
||||
// the operator wants determinism-only smoke tests.
|
||||
const USE_REAL_LLM =
|
||||
process.env["USE_REAL_LLM"] !== "false" &&
|
||||
process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||
|
||||
// Git push auto-activates when a Gitea token is present. Operators can
|
||||
// force it off (useful for local dry-runs) by setting GIT_PUSH_ENABLED=false.
|
||||
// The legacy RAILS_ENABLE_GIT_PUSH env var is still honored.
|
||||
const ENABLE_GIT_PUSH = (() => {
|
||||
if (process.env["GIT_PUSH_ENABLED"] === "false") return false;
|
||||
if (process.env["RAILS_ENABLE_GIT_PUSH"] === "false") return false;
|
||||
if (process.env["GIT_PUSH_ENABLED"] === "true") return true;
|
||||
if (process.env["RAILS_ENABLE_GIT_PUSH"] === "true") return true;
|
||||
// Auto: enabled iff we actually have a token to push with
|
||||
return Boolean(process.env["GITEA_TOKEN"]);
|
||||
})();
|
||||
const WORKSPACE_ROOT =
|
||||
process.env["SISTER_WORKSPACE_DIR"] ??
|
||||
join(homedir(), "rails-projects");
|
||||
|
||||
interface RunContext {
|
||||
req: InvokeRequest;
|
||||
rails: RailsClient;
|
||||
agentName: string;
|
||||
workspaceDir: string;
|
||||
/** Aggregated code file paths (relative to pipeline repo root) across all juniors */
|
||||
producedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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().
|
||||
* Entry point — score, plan, and execute the hierarchical team.
|
||||
* Everything is parallelized at each level using Promise.all.
|
||||
* Each LLM output is also persisted to a file under the pipeline workspace.
|
||||
*/
|
||||
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 workspaceDir = join(WORKSPACE_ROOT, req.pipelineId, req.stage);
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const ctx: RunContext = {
|
||||
req,
|
||||
rails,
|
||||
agentName,
|
||||
workspaceDir,
|
||||
producedFiles: [],
|
||||
};
|
||||
|
||||
// 1) Manager itself runs first (it is the single root). Its output is the
|
||||
// strategic decision that feeds into children.
|
||||
const managerId = ulid();
|
||||
const managerRecord: SubTaskRecord = {
|
||||
await rails.createSubTask({
|
||||
id: managerId,
|
||||
pipelineId: req.pipelineId,
|
||||
parentId: null,
|
||||
@@ -40,35 +95,187 @@ export async function executeInvocation(
|
||||
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),
|
||||
});
|
||||
await rails.recordEvent(managerId, "started", {});
|
||||
|
||||
// Discord stage-start ping (best-effort, fire-and-forget)
|
||||
if (req.notifyChannelId) {
|
||||
notifyDiscord({
|
||||
channelId: req.notifyChannelId,
|
||||
message: renderStageStart({
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: req.task.title,
|
||||
}),
|
||||
})
|
||||
.then((r) => {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
level: r.ok ? "info" : "warn",
|
||||
agent: agentName,
|
||||
msg: "notify.start",
|
||||
channel: req.notifyChannelId,
|
||||
ok: r.ok,
|
||||
error: r.error,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
level: "error",
|
||||
agent: agentName,
|
||||
msg: "notify.start.threw",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
level: "info",
|
||||
agent: agentName,
|
||||
msg: "notify.skipped",
|
||||
reason: "no notifyChannelId in invoke request",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: execute the plan recursively
|
||||
try {
|
||||
const result = await executeRole(
|
||||
"manager",
|
||||
managerId,
|
||||
req,
|
||||
plan,
|
||||
complexity,
|
||||
rails,
|
||||
const managerWork = await doWork({
|
||||
role: "manager",
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: req.task.title,
|
||||
taskDescription: req.task.description,
|
||||
priorStages: req.priorStages,
|
||||
});
|
||||
await persistResult(rails, managerId, managerWork);
|
||||
const managerPath = await writeOutputFile(
|
||||
ctx,
|
||||
managerId,
|
||||
"manager",
|
||||
0,
|
||||
managerWork.text,
|
||||
);
|
||||
|
||||
// 2) Spawn children from plan in parallel (principals / leads / juniors)
|
||||
const childTexts = await runPlanChildren(
|
||||
plan,
|
||||
managerId,
|
||||
managerWork.text,
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Git push on implement stage — publishes the workspace to Gitea
|
||||
let gitResult: Awaited<ReturnType<typeof commitAndPush>> | null = null;
|
||||
if (ENABLE_GIT_PUSH && req.stage === "implement") {
|
||||
gitResult = await commitAndPush({
|
||||
workspaceDir: join(WORKSPACE_ROOT, req.pipelineId),
|
||||
pipelineId: req.pipelineId,
|
||||
projectName: req.task.title,
|
||||
stage: req.stage,
|
||||
agentName,
|
||||
});
|
||||
}
|
||||
|
||||
// Deploy stage — derive a preview URL from the implement stage output
|
||||
let deployUrl = "";
|
||||
if (req.stage === "deploy") {
|
||||
deployUrl = derivePreviewUrl(req.priorStages ?? []);
|
||||
}
|
||||
|
||||
await rails.recordEvent(managerId, "completed", {
|
||||
verdict: result.verdict,
|
||||
verdict: "ok",
|
||||
childCount: childTexts.length,
|
||||
file: managerPath,
|
||||
...(gitResult?.ok && {
|
||||
repoUrl: gitResult.repoUrl,
|
||||
rawUrlBase: gitResult.rawUrlBase,
|
||||
commit: gitResult.commit,
|
||||
filesCount: gitResult.filesCount,
|
||||
}),
|
||||
...(gitResult && !gitResult.ok && { gitError: gitResult.error }),
|
||||
...(deployUrl && { deployUrl }),
|
||||
});
|
||||
|
||||
// Aggregate manager + all child outputs into a single text blob that
|
||||
// gets passed to the next stage as priorStages. The downstream agent
|
||||
// (especially the reviewer) needs to see ACTUAL CODE — not a snippet
|
||||
// — to make a meaningful judgement, so the cap is generous. Cap is
|
||||
// sized for full HTML/JS/CSS files; LLM context windows are 200k+ so
|
||||
// 64KB stays well inside budget even after 4 stages of accumulation.
|
||||
const aggregatedRaw = [managerWork.text, ...childTexts]
|
||||
.filter(Boolean)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// Pull every ```discord-line``` block out before slicing/persisting.
|
||||
// The first extracted line becomes the stage-end Discord message;
|
||||
// the cleaned text (with the blocks stripped) is what flows to the
|
||||
// next stage as priorStages so chat noise doesn't bleed through.
|
||||
const { lines: discordLines, cleaned: aggregatedClean } =
|
||||
extractDiscordLines(aggregatedRaw);
|
||||
const aggregated = aggregatedClean.slice(0, 64_000);
|
||||
|
||||
const result = buildSuccessResult(
|
||||
req.stage,
|
||||
req.task,
|
||||
aggregated,
|
||||
gitResult,
|
||||
ctx.producedFiles,
|
||||
);
|
||||
|
||||
// Discord stage-end ping (best-effort) — fired AFTER buildSuccessResult
|
||||
// so the message reflects the ACTUAL verdict ("리뷰 통과" vs "결함 발견"
|
||||
// vs "배포 실패"). Previously this was emitted before the verdict was
|
||||
// known, so darang would always say "통과" even when REQUEST_CHANGES.
|
||||
//
|
||||
// If a junior LLM emitted a `discord-line` block, use that verbatim
|
||||
// (it's the LLM speaking in character about its own work). Otherwise
|
||||
// fall back to the hardcoded persona pool.
|
||||
if (req.notifyChannelId) {
|
||||
const verdict = "verdict" in result ? result.verdict : "";
|
||||
const customLine = discordLines[0] ?? "";
|
||||
notifyDiscord({
|
||||
channelId: req.notifyChannelId,
|
||||
message: renderStageEnd({
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: req.task.title,
|
||||
verdict,
|
||||
childCount: childTexts.length,
|
||||
filesProduced: ctx.producedFiles.length,
|
||||
...(customLine && { customLine }),
|
||||
}),
|
||||
})
|
||||
.then((r) => {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
level: r.ok ? "info" : "warn",
|
||||
agent: agentName,
|
||||
msg: "notify.end",
|
||||
channel: req.notifyChannelId,
|
||||
verdict,
|
||||
ok: r.ok,
|
||||
error: r.error,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow */
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorReason = err instanceof Error ? err.message : String(err);
|
||||
@@ -78,137 +285,368 @@ export async function executeInvocation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single role node (recursive).
|
||||
* Spawns children if the plan calls for it, aggregates their results.
|
||||
* Run all children defined by `plan.spawn` in parallel.
|
||||
* Each child may itself spawn grandchildren (also in parallel).
|
||||
*/
|
||||
async function executeRole(
|
||||
role: Role,
|
||||
selfId: string,
|
||||
req: InvokeRequest,
|
||||
async function runPlanChildren(
|
||||
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);
|
||||
parentId: string,
|
||||
parentOutput: string,
|
||||
ctx: RunContext,
|
||||
): Promise<string[]> {
|
||||
if (plan.spawn.length === 0 || plan.strategy === "direct") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Spawn children per plan
|
||||
const tasks: Array<Promise<string>> = [];
|
||||
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 });
|
||||
tasks.push(runSpawnNode(spawnPlan, i, parentId, parentOutput, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
void complexity; // reserved for future LLM-based planning
|
||||
return buildSuccessResult(req.stage, req.task);
|
||||
return Promise.all(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder "work" — tiny delay per role so timeline looks realistic.
|
||||
* Replace with real openclaw agent CLI or LLM SDK call.
|
||||
* Execute a single node (principal / lead / junior) and recursively spawn
|
||||
* its own children (if any) in parallel.
|
||||
*/
|
||||
async function simulateWork(role: Role): Promise<void> {
|
||||
const delayByRole: Record<Role, number> = {
|
||||
manager: 40,
|
||||
principal: 60,
|
||||
lead: 80,
|
||||
junior: 100,
|
||||
async function runSpawnNode(
|
||||
spawnPlan: SpawnPlan,
|
||||
index: number,
|
||||
parentId: string,
|
||||
parentOutput: string,
|
||||
ctx: RunContext,
|
||||
): Promise<string> {
|
||||
const id = ulid();
|
||||
const title = `${spawnPlan.role}-${index + 1}: ${ctx.req.task.title.slice(0, 100)}`;
|
||||
|
||||
await ctx.rails.createSubTask({
|
||||
id,
|
||||
pipelineId: ctx.req.pipelineId,
|
||||
parentId,
|
||||
role: spawnPlan.role,
|
||||
agentName: ctx.agentName,
|
||||
title,
|
||||
description: spawnPlan.rationale,
|
||||
complexityScore: null,
|
||||
complexityTier: null,
|
||||
model: ROLES[spawnPlan.role].primaryModel,
|
||||
});
|
||||
await ctx.rails.recordEvent(id, "spawned", {
|
||||
parent: parentId,
|
||||
role: spawnPlan.role,
|
||||
});
|
||||
await ctx.rails.recordEvent(id, "started", {});
|
||||
|
||||
// Do this node's own work first — its output feeds its children
|
||||
const work = await doWork({
|
||||
role: spawnPlan.role,
|
||||
agentName: ctx.agentName,
|
||||
stage: ctx.req.stage,
|
||||
taskTitle: title,
|
||||
taskDescription: spawnPlan.rationale,
|
||||
parentTitle: ctx.req.task.title,
|
||||
prevStageOutput: parentOutput,
|
||||
priorStages: ctx.req.priorStages,
|
||||
});
|
||||
await persistResult(ctx.rails, id, work);
|
||||
const filePath = await writeOutputFile(
|
||||
ctx,
|
||||
id,
|
||||
spawnPlan.role,
|
||||
index,
|
||||
work.text,
|
||||
);
|
||||
|
||||
// Extract code blocks and save as real files (implement stage junior)
|
||||
const extractedFiles = await maybeExtractFiles(ctx, spawnPlan.role, work.text);
|
||||
|
||||
// Spawn grandchildren (if any) in parallel
|
||||
let childTexts: string[] = [];
|
||||
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
|
||||
const grandTasks: Array<Promise<string>> = [];
|
||||
for (const grandPlan of spawnPlan.subBreakdown) {
|
||||
for (let j = 0; j < grandPlan.count; j++) {
|
||||
grandTasks.push(
|
||||
runSpawnNode(grandPlan, j, id, work.text, ctx),
|
||||
);
|
||||
}
|
||||
}
|
||||
childTexts = await Promise.all(grandTasks);
|
||||
}
|
||||
|
||||
await ctx.rails.recordEvent(id, "completed", {
|
||||
ok: work.ok,
|
||||
file: filePath,
|
||||
childCount: childTexts.length,
|
||||
extractedFiles: extractedFiles.map((f) => ({ path: f.path, lang: f.lang })),
|
||||
});
|
||||
|
||||
return [work.text, ...childTexts].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ```lang:path code blocks from text and save them to the pipeline
|
||||
* workspace. Only runs for implement-stage junior nodes to keep things scoped.
|
||||
*/
|
||||
async function maybeExtractFiles(
|
||||
ctx: RunContext,
|
||||
role: Role,
|
||||
text: string,
|
||||
): Promise<Array<{ path: string; lang: string; absPath?: string }>> {
|
||||
if (!text) return [];
|
||||
// Only juniors in implement stage actually produce code artifacts.
|
||||
if (role !== "junior") return [];
|
||||
if (ctx.req.stage !== "implement") return [];
|
||||
|
||||
const blocks = extractCodeBlocks(text);
|
||||
if (blocks.length === 0) return [];
|
||||
|
||||
const saved = await saveExtractedFiles(ctx.workspaceDir, blocks);
|
||||
// Track the repo-relative path (e.g., implement/files/frontend/index.html)
|
||||
// so the deploy stage can build an accurate preview URL.
|
||||
for (const f of saved) {
|
||||
const repoRelPath = `${ctx.req.stage}/files/${f.path}`;
|
||||
ctx.producedFiles.push(repoRelPath);
|
||||
}
|
||||
return saved.map((f) => {
|
||||
const base: { path: string; lang: string; absPath?: string } = {
|
||||
path: f.path,
|
||||
lang: f.lang,
|
||||
};
|
||||
if (f.absPath !== undefined) base.absPath = f.absPath;
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call LLM (or stub) to produce the node's work output.
|
||||
*/
|
||||
async function doWork(args: {
|
||||
role: Role;
|
||||
agentName: string;
|
||||
stage: InvokeRequest["stage"];
|
||||
taskTitle: string;
|
||||
taskDescription: string;
|
||||
prevStageOutput?: string;
|
||||
parentTitle?: string;
|
||||
priorStages?: Array<{ stage: string; text: 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 }),
|
||||
...(args.priorStages !== undefined && { priorStages: args.priorStages }),
|
||||
});
|
||||
|
||||
const result = await callLlm({
|
||||
prompt,
|
||||
model: ROLES[args.role].primaryModel,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
error: result.errorMessage ?? "unknown LLM error",
|
||||
};
|
||||
}
|
||||
return { ok: true, text: result.text };
|
||||
}
|
||||
|
||||
async function persistResult(
|
||||
rails: RailsClient,
|
||||
subTaskId: string,
|
||||
work: { ok: boolean; text: string; error?: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const patch: Record<string, unknown> = {
|
||||
resultJson: JSON.stringify({ text: work.text, ok: work.ok }),
|
||||
};
|
||||
if (work.error) {
|
||||
patch["errorReason"] = work.error;
|
||||
}
|
||||
await rails.patchSubTask(subTaskId, patch);
|
||||
} catch {
|
||||
// best-effort — file write still succeeds and LLM output isn't lost
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the LLM output as a file in the pipeline workspace.
|
||||
* Returns the absolute file path so we can reference it in events/results.
|
||||
*/
|
||||
async function writeOutputFile(
|
||||
ctx: RunContext,
|
||||
subTaskId: string,
|
||||
role: Role,
|
||||
index: number,
|
||||
text: string,
|
||||
): Promise<string> {
|
||||
if (!text) return "";
|
||||
const shortId = subTaskId.slice(-6);
|
||||
const fileName = `${role}-${String(index + 1).padStart(2, "0")}-${shortId}.md`;
|
||||
const fullPath = join(ctx.workspaceDir, fileName);
|
||||
|
||||
const header = [
|
||||
`---`,
|
||||
`pipeline: ${ctx.req.pipelineId}`,
|
||||
`stage: ${ctx.req.stage}`,
|
||||
`agent: ${ctx.agentName}`,
|
||||
`role: ${role}`,
|
||||
`subTaskId: ${subTaskId}`,
|
||||
`createdAt: ${new Date().toISOString()}`,
|
||||
`---`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
await writeFile(fullPath, header + text, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the prior-stage outputs looking for an implement-stage rawUrlBase,
|
||||
* then produce a preview URL pointing to the first HTML file (or just the
|
||||
* repo URL if we can't find one).
|
||||
*/
|
||||
function derivePreviewUrl(
|
||||
priorStages: Array<{ stage: string; text: string }>,
|
||||
): string {
|
||||
const impl = priorStages.find((s) => s.stage === "implement");
|
||||
if (!impl) return "";
|
||||
const rawBaseMatch = impl.text.match(/rawUrlBase=(\S+)/);
|
||||
const rawBase = rawBaseMatch?.[1];
|
||||
if (!rawBase) return "";
|
||||
|
||||
// Prefer the exact producedFiles list emitted by implement stage.
|
||||
const producedMatch = impl.text.match(/producedFiles=([^\n]+)/);
|
||||
if (producedMatch?.[1]) {
|
||||
const files = producedMatch[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const html = files.find((f) => f.toLowerCase().endsWith(".html"));
|
||||
if (html) return `${rawBase}/${html}`;
|
||||
if (files[0]) return `${rawBase}/${files[0]}`;
|
||||
}
|
||||
|
||||
// Fallback: browse view
|
||||
return rawBase.replace("/raw/branch/main", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the review junior's text output for verdict.
|
||||
*
|
||||
* Prompt asks the LLM to start the response with one of:
|
||||
* APPROVE / REQUEST_CHANGES / ABORT
|
||||
*
|
||||
* We scan the entire text (not just the prefix) because the LLM sometimes
|
||||
* adds a preamble before the verdict keyword. First match wins.
|
||||
*
|
||||
* Default = APPROVE only when the text is empty (LLM failure). Otherwise
|
||||
* if no marker is found we conservatively treat it as REQUEST_CHANGES so
|
||||
* the pipeline doesn't silently approve unparsable output.
|
||||
*/
|
||||
function parseReviewVerdict(text: string): {
|
||||
verdict: "APPROVE" | "REQUEST_CHANGES" | "ABORT";
|
||||
reason: string;
|
||||
} {
|
||||
if (!text || text.trim().length === 0) {
|
||||
return { verdict: "APPROVE", reason: "review junior produced no output" };
|
||||
}
|
||||
const upper = text.toUpperCase();
|
||||
|
||||
// Order matters — REQUEST_CHANGES contains the substring "CHANGES",
|
||||
// ABORT is the strongest signal, so check ABORT first.
|
||||
const abortIdx = upper.search(/\bABORT\b/);
|
||||
const rcIdx = upper.search(/\bREQUEST[_\s-]?CHANGES?\b/);
|
||||
const approveIdx = upper.search(/\bAPPROVE\b/);
|
||||
|
||||
// If both APPROVE and REQUEST_CHANGES appear, the LLM is uncertain —
|
||||
// bias toward REQUEST_CHANGES so problems aren't silently ignored.
|
||||
if (abortIdx >= 0 && (rcIdx < 0 || abortIdx < rcIdx)) {
|
||||
return { verdict: "ABORT", reason: text.slice(0, 16_000) };
|
||||
}
|
||||
if (rcIdx >= 0) {
|
||||
return { verdict: "REQUEST_CHANGES", reason: text.slice(0, 16_000) };
|
||||
}
|
||||
if (approveIdx >= 0) {
|
||||
return { verdict: "APPROVE", reason: "" };
|
||||
}
|
||||
// No marker found — conservatively request changes rather than auto-approve
|
||||
return {
|
||||
verdict: "REQUEST_CHANGES",
|
||||
reason: "Reviewer did not emit an APPROVE / REQUEST_CHANGES marker. Raw text:\n" + text.slice(0, 16_000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the deploy junior's text output for verdict.
|
||||
* Prompt asks the LLM to end with "DEPLOY_DONE" or "DEPLOY_FAILED".
|
||||
*/
|
||||
function parseDeployVerdict(text: string): {
|
||||
verdict: "DEPLOY_DONE" | "DEPLOY_FAILED";
|
||||
reason: string;
|
||||
} {
|
||||
if (!text || text.trim().length === 0) {
|
||||
return {
|
||||
verdict: "DEPLOY_FAILED",
|
||||
reason: "deploy junior produced no output",
|
||||
};
|
||||
}
|
||||
const upper = text.toUpperCase();
|
||||
const failedIdx = upper.lastIndexOf("DEPLOY_FAILED");
|
||||
const doneIdx = upper.lastIndexOf("DEPLOY_DONE");
|
||||
// Take the LAST marker (the prompt asks for it on the final line)
|
||||
if (failedIdx > doneIdx) {
|
||||
return { verdict: "DEPLOY_FAILED", reason: text.slice(0, 16_000) };
|
||||
}
|
||||
if (doneIdx >= 0) {
|
||||
return { verdict: "DEPLOY_DONE", reason: "" };
|
||||
}
|
||||
// No marker — bias toward FAILED so silent passes don't happen
|
||||
return {
|
||||
verdict: "DEPLOY_FAILED",
|
||||
reason:
|
||||
"Deployer did not emit a DEPLOY_DONE / DEPLOY_FAILED marker. Raw text:\n" +
|
||||
text.slice(0, 16_000),
|
||||
};
|
||||
await new Promise((r) => setTimeout(r, delayByRole[role]));
|
||||
}
|
||||
|
||||
function buildSuccessResult(
|
||||
stage: InvokeRequest["stage"],
|
||||
task: InvokeRequest["task"],
|
||||
outputText?: string,
|
||||
gitResult?: {
|
||||
ok: boolean;
|
||||
repoUrl: string;
|
||||
rawUrlBase: string;
|
||||
commit: string;
|
||||
filesCount: number;
|
||||
} | null,
|
||||
producedFiles: string[] = [],
|
||||
): HandoffMessage {
|
||||
// The summary is the payload the next stage will see as priorStages
|
||||
// text. Reviewer needs to see actual code, not a snippet, so the cap
|
||||
// matches the upstream aggregation (64KB).
|
||||
const summary = outputText?.slice(0, 64_000) ?? "";
|
||||
switch (stage) {
|
||||
case "plan":
|
||||
return {
|
||||
@@ -219,42 +657,136 @@ function buildSuccessResult(
|
||||
sprintId: "SPRINT-AUTO",
|
||||
contractId: "",
|
||||
},
|
||||
abortReason: "",
|
||||
abortReason: summary ? "" : "",
|
||||
};
|
||||
case "implement":
|
||||
return {
|
||||
stage: "implement",
|
||||
verdict: "IMPL_DONE",
|
||||
payload: {
|
||||
branch: "feature/sister-agent",
|
||||
commits: ["simulated"],
|
||||
branch: gitResult?.ok ? "main" : "feature/sister-agent",
|
||||
commits: gitResult?.ok && gitResult.commit ? [gitResult.commit] : ["llm"],
|
||||
workdir: task.workdir || "",
|
||||
selfTestReport: { simulated: true },
|
||||
selfTestReport: {
|
||||
summary,
|
||||
producedFiles,
|
||||
...(gitResult?.ok && {
|
||||
repoUrl: gitResult.repoUrl,
|
||||
rawUrlBase: gitResult.rawUrlBase,
|
||||
filesCount: gitResult.filesCount,
|
||||
}),
|
||||
},
|
||||
},
|
||||
errorReason: "",
|
||||
};
|
||||
case "review":
|
||||
case "review": {
|
||||
// Test-only override: force a verdict without consulting the LLM.
|
||||
// Used to verify the FSM review-loop / re-plan paths without
|
||||
// depending on LLM judgement. Set RAILS_FORCE_REVIEW_VERDICT to
|
||||
// APPROVE / REQUEST_CHANGES / ABORT on the darang sister-agent
|
||||
// host. Empty / unset → normal LLM-parsed behavior.
|
||||
const forced = process.env["RAILS_FORCE_REVIEW_VERDICT"];
|
||||
if (forced === "REQUEST_CHANGES") {
|
||||
return {
|
||||
stage: "review",
|
||||
verdict: "REQUEST_CHANGES",
|
||||
payload: {
|
||||
artifactPath: "",
|
||||
checklistResults: [],
|
||||
issues: [
|
||||
{
|
||||
severity: "major",
|
||||
message:
|
||||
"[forced via RAILS_FORCE_REVIEW_VERDICT] retry-loop test injection",
|
||||
},
|
||||
],
|
||||
},
|
||||
abortReason: "",
|
||||
};
|
||||
}
|
||||
if (forced === "APPROVE") {
|
||||
return {
|
||||
stage: "review",
|
||||
verdict: "APPROVE",
|
||||
payload: { artifactPath: "", checklistResults: [], issues: [] },
|
||||
abortReason: "",
|
||||
};
|
||||
}
|
||||
if (forced === "ABORT") {
|
||||
return {
|
||||
stage: "review",
|
||||
verdict: "ABORT",
|
||||
payload: { artifactPath: "", checklistResults: [], issues: [] },
|
||||
abortReason: "[forced] test ABORT",
|
||||
};
|
||||
}
|
||||
const parsed = parseReviewVerdict(summary);
|
||||
if (parsed.verdict === "APPROVE") {
|
||||
return {
|
||||
stage: "review",
|
||||
verdict: "APPROVE",
|
||||
payload: {
|
||||
artifactPath: "",
|
||||
checklistResults: [],
|
||||
issues: [],
|
||||
},
|
||||
abortReason: "",
|
||||
};
|
||||
}
|
||||
if (parsed.verdict === "REQUEST_CHANGES") {
|
||||
return {
|
||||
stage: "review",
|
||||
verdict: "REQUEST_CHANGES",
|
||||
payload: {
|
||||
artifactPath: "",
|
||||
checklistResults: [],
|
||||
issues: [
|
||||
{
|
||||
severity: "major",
|
||||
message: parsed.reason,
|
||||
},
|
||||
],
|
||||
},
|
||||
abortReason: "",
|
||||
};
|
||||
}
|
||||
// ABORT
|
||||
return {
|
||||
stage: "review",
|
||||
verdict: "APPROVE",
|
||||
verdict: "ABORT",
|
||||
payload: {
|
||||
artifactPath: "",
|
||||
checklistResults: [],
|
||||
issues: [],
|
||||
},
|
||||
abortReason: "",
|
||||
abortReason: parsed.reason,
|
||||
};
|
||||
case "deploy":
|
||||
}
|
||||
case "deploy": {
|
||||
const parsed = parseDeployVerdict(summary);
|
||||
if (parsed.verdict === "DEPLOY_DONE") {
|
||||
return {
|
||||
stage: "deploy",
|
||||
verdict: "DEPLOY_DONE",
|
||||
payload: {
|
||||
deployArtifactPath: "",
|
||||
projectType: "llm",
|
||||
verificationResults: { summary },
|
||||
},
|
||||
errorReason: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
stage: "deploy",
|
||||
verdict: "DEPLOY_DONE",
|
||||
verdict: "DEPLOY_FAILED",
|
||||
payload: {
|
||||
deployArtifactPath: "",
|
||||
projectType: "simulated",
|
||||
verificationResults: {},
|
||||
projectType: "llm",
|
||||
verificationResults: { summary, reason: parsed.reason },
|
||||
},
|
||||
errorReason: "",
|
||||
errorReason: parsed.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,15 +805,3 @@ function buildErrorResult(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ import { z } from "zod";
|
||||
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
|
||||
export type Role = z.infer<typeof Role>;
|
||||
|
||||
// ── Prior stage outputs (for chaining) ──
|
||||
export const PriorStageOutput = z.object({
|
||||
stage: z.enum(["plan", "implement", "review", "deploy"]),
|
||||
text: z.string(),
|
||||
});
|
||||
export type PriorStageOutput = z.infer<typeof PriorStageOutput>;
|
||||
|
||||
// ── Incoming invoke from rails ──
|
||||
export const InvokeRequest = z.object({
|
||||
pipelineId: z.string(),
|
||||
@@ -14,9 +21,16 @@ export const InvokeRequest = z.object({
|
||||
description: z.string().default(""),
|
||||
workdir: z.string().default(""),
|
||||
}),
|
||||
priorStages: z.array(PriorStageOutput).default([]),
|
||||
timeoutMs: z.number().int().positive().default(600_000),
|
||||
railsApiUrl: z.string().url(),
|
||||
agentName: z.string().default(""),
|
||||
/**
|
||||
* Optional Discord channel ID — propagated from rails so each sister
|
||||
* can post a stage update to the originating channel via her own
|
||||
* OpenClaw bot identity.
|
||||
*/
|
||||
notifyChannelId: z.string().default(""),
|
||||
});
|
||||
export type InvokeRequest = z.infer<typeof InvokeRequest>;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadConfig } from "../config/loader.js";
|
||||
import { runPipeline } from "../orchestrator/runner.js";
|
||||
import { MockTransport } from "../handoff/mock-transport.js";
|
||||
import type { SisterTransport } from "../handoff/transport.js";
|
||||
import { buildTransports } from "../handoff/build.js";
|
||||
import { disconnectPrisma } from "../orchestrator/persist.js";
|
||||
|
||||
export default defineCommand({
|
||||
@@ -39,31 +40,16 @@ export default defineCommand({
|
||||
loadEnv();
|
||||
try {
|
||||
const config = await loadConfig(args.config || undefined);
|
||||
const transports = new Map<string, SisterTransport>();
|
||||
|
||||
let transports: Map<string, SisterTransport>;
|
||||
if (args.mock) {
|
||||
// Hard override: force mock across all stages
|
||||
const mock = new MockTransport();
|
||||
for (const stage of config.pipeline.stages) {
|
||||
transports.set(stage, mock);
|
||||
}
|
||||
transports = new Map();
|
||||
for (const stage of config.pipeline.stages) transports.set(stage, mock);
|
||||
} else {
|
||||
// For now, default to mock when no real transport wiring is provided.
|
||||
// Sprint 004 ships DiscordTransport as a class; wiring a live discord
|
||||
// client is an operator task (see docs/discord-setup.md).
|
||||
const mock = new MockTransport();
|
||||
for (const stage of config.pipeline.stages) {
|
||||
const t = config.agents[stage]?.transport;
|
||||
if (t === "mock" || !t) {
|
||||
transports.set(stage, mock);
|
||||
} else if (t === "discord") {
|
||||
console.warn(
|
||||
`[rails] Discord transport for stage '${stage}' requires a bot wiring — falling back to mock.`,
|
||||
);
|
||||
transports.set(stage, mock);
|
||||
} else {
|
||||
transports.set(stage, mock);
|
||||
}
|
||||
}
|
||||
// Config + env-based transport wiring
|
||||
transports = buildTransports(config);
|
||||
}
|
||||
|
||||
const result = await runPipeline({
|
||||
|
||||
@@ -35,10 +35,23 @@ export default defineCommand({
|
||||
|
||||
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
|
||||
|
||||
// Optional Discord escalation alert config — set on Dev VM via env so
|
||||
// pipelines that carry a notifyChannelId auto-generate a notifier
|
||||
// pointed at one of the sister-agent /notify endpoints.
|
||||
const escalationSisterUrl = process.env["RAILS_NOTIFY_SISTER_URL"] ?? "";
|
||||
const escalationUserId = process.env["RAILS_NOTIFY_USER_ID"] ?? "";
|
||||
const escalationConfig = escalationSisterUrl
|
||||
? {
|
||||
sisterUrl: escalationSisterUrl,
|
||||
...(escalationUserId && { userId: escalationUserId }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { url, close } = await startHttpServer({
|
||||
port,
|
||||
host: args.host ?? "0.0.0.0",
|
||||
...(args.config && { configPath: args.config }),
|
||||
...(escalationConfig && { escalationConfig }),
|
||||
});
|
||||
|
||||
log.info(
|
||||
@@ -46,7 +59,6 @@ export default defineCommand({
|
||||
"hanarang-rails server ready",
|
||||
);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
log.info({ signal }, "Shutdown requested");
|
||||
try {
|
||||
@@ -64,7 +76,6 @@ export default defineCommand({
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Keep alive
|
||||
await new Promise<never>(() => {
|
||||
/* block until signal */
|
||||
});
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TransportMode = z.enum(["discord", "mock", "local"]);
|
||||
export const TransportMode = z.enum([
|
||||
"discord",
|
||||
"mock",
|
||||
"local",
|
||||
"http",
|
||||
"in-process",
|
||||
]);
|
||||
export type TransportMode = z.infer<typeof TransportMode>;
|
||||
|
||||
export const AgentConfig = z.object({
|
||||
role: z.string().min(1),
|
||||
displayName: z.string().default(""),
|
||||
/** Sister identity — harang / narang / darang / erang. Required for http / in-process. */
|
||||
agentName: z.string().default(""),
|
||||
transport: TransportMode.default("mock"),
|
||||
channelId: z.string().default(""),
|
||||
timeoutMs: z.number().int().positive().default(30_000),
|
||||
/** http transport: the sister-agent daemon endpoint, e.g. http://10.10.10.112:18801 */
|
||||
endpoint: z.string().default(""),
|
||||
/** in-process: optional override for the sister-agent core module path */
|
||||
coreModulePath: z.string().default(""),
|
||||
timeoutMs: z.number().int().positive().default(600_000),
|
||||
});
|
||||
export type AgentConfig = z.infer<typeof AgentConfig>;
|
||||
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
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 { InProcessTransport } from "./in-process-transport.js";
|
||||
import type { RailsConfig, TransportMode } from "../config/schema.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "transport-builder" });
|
||||
|
||||
const DEFAULT_SISTER_NAMES: Record<string, string> = {
|
||||
plan: "harang",
|
||||
implement: "narang",
|
||||
review: "darang",
|
||||
deploy: "erang",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Environment overrides (convenient for docker-compose / smoke tests):
|
||||
*
|
||||
* RAILS_TRANSPORT=mock|http|in-process (applies to every stage)
|
||||
* RAILS_TRANSPORT_{STAGE}=… (per-stage override)
|
||||
* RAILS_API_URL=http://127.0.0.1:18800 (callback URL for sub-tasks)
|
||||
*
|
||||
* For http transport:
|
||||
* SISTER_ENDPOINT_{STAGE}=http://host:18801
|
||||
* or legacy RAILS_AGENT_{STAGE}_HOST / _PORT
|
||||
*
|
||||
* For in-process transport:
|
||||
* SISTER_AGENT_CORE_PATH=/abs/path/to/sister-agent/dist/core.js
|
||||
*
|
||||
* Sister identity:
|
||||
* SISTER_NAME_{STAGE}=harang|narang|darang|erang|custom
|
||||
*/
|
||||
export function buildTransports(config: RailsConfig): Map<string, SisterTransport> {
|
||||
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";
|
||||
|
||||
@@ -25,59 +44,106 @@ export function buildTransports(config: RailsConfig): Map<string, SisterTranspor
|
||||
|
||||
for (const stage of config.pipeline.stages) {
|
||||
const agentConfig = config.agents[stage];
|
||||
const configured = agentConfig?.transport ?? "mock";
|
||||
const mode = resolveMode(stage, configured);
|
||||
const agentName = sisterName(stage, agentConfig?.agentName ?? "");
|
||||
const timeoutMs = agentConfig?.timeoutMs ?? 600_000;
|
||||
|
||||
// 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",
|
||||
);
|
||||
switch (mode) {
|
||||
case "mock":
|
||||
case "local":
|
||||
transports.set(stage, sharedMock);
|
||||
continue;
|
||||
log.info({ stage, transport: "mock" }, "transport wired");
|
||||
break;
|
||||
|
||||
case "in-process": {
|
||||
const coreOverride =
|
||||
agentConfig?.coreModulePath || undefined;
|
||||
transports.set(
|
||||
stage,
|
||||
new InProcessTransport({
|
||||
agentName,
|
||||
railsApiUrl,
|
||||
...(coreOverride ? { coreModulePath: coreOverride } : {}),
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
log.info(
|
||||
{ stage, transport: "in-process", agentName },
|
||||
"transport wired",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
case "http": {
|
||||
const endpoint = sisterEndpoint(stage, agentConfig?.endpoint ?? "");
|
||||
if (!endpoint) {
|
||||
log.warn(
|
||||
{ stage },
|
||||
"http transport requested but endpoint missing — falling back to mock",
|
||||
);
|
||||
transports.set(stage, sharedMock);
|
||||
break;
|
||||
}
|
||||
transports.set(
|
||||
stage,
|
||||
new HttpTransport({
|
||||
agentName,
|
||||
endpoint,
|
||||
railsApiUrl,
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
log.info(
|
||||
{ stage, transport: "http", endpoint, agentName },
|
||||
"transport wired",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Unknown — fall back to mock
|
||||
transports.set(stage, sharedMock);
|
||||
log.warn({ stage, desired }, "unknown transport, using mock");
|
||||
case "discord":
|
||||
log.warn(
|
||||
{ stage },
|
||||
"discord transport not wired — falling back to mock",
|
||||
);
|
||||
transports.set(stage, sharedMock);
|
||||
break;
|
||||
|
||||
default: {
|
||||
const exhaustive: never = mode;
|
||||
void exhaustive;
|
||||
transports.set(stage, sharedMock);
|
||||
log.warn({ stage, mode }, "unknown transport — using mock");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transports;
|
||||
}
|
||||
|
||||
function resolveMode(stage: string, configured: TransportMode): TransportMode {
|
||||
const perStage = process.env[`RAILS_TRANSPORT_${stage.toUpperCase()}`];
|
||||
const global = process.env["RAILS_TRANSPORT"];
|
||||
// Legacy env (kept for backwards compatibility with existing deployments)
|
||||
const legacy = process.env["RAILS_TRANSPORT_MODE"];
|
||||
const raw = perStage ?? global ?? (legacy && legacy !== "auto" ? legacy : undefined) ?? configured;
|
||||
return raw as TransportMode;
|
||||
}
|
||||
|
||||
function sisterEndpoint(stage: string, configured: string): string {
|
||||
const perStage = process.env[`SISTER_ENDPOINT_${stage.toUpperCase()}`];
|
||||
if (perStage) return perStage;
|
||||
if (configured) return configured;
|
||||
// Legacy host/port style
|
||||
const host = process.env[`RAILS_AGENT_${stage.toUpperCase()}_HOST`];
|
||||
const port = process.env[`RAILS_AGENT_${stage.toUpperCase()}_PORT`] ?? "18801";
|
||||
if (host) return `http://${host}:${port}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function sisterName(stage: string, configured: string): string {
|
||||
if (configured) return configured;
|
||||
const perStage = process.env[`SISTER_NAME_${stage.toUpperCase()}`];
|
||||
if (perStage) return perStage;
|
||||
return DEFAULT_SISTER_NAMES[stage] ?? stage;
|
||||
}
|
||||
|
||||
45
src/handoff/direct-rails-client.ts
Normal file
45
src/handoff/direct-rails-client.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
CreateSubTaskInput,
|
||||
SubTaskEventInput,
|
||||
UpdateSubTaskInput,
|
||||
createSubTask,
|
||||
updateSubTask,
|
||||
recordSubTaskEvent,
|
||||
} from "../hierarchy/store.js";
|
||||
|
||||
/**
|
||||
* In-process replacement for sister-agent's HTTP-based RailsClient.
|
||||
*
|
||||
* When rails runs in single-process mode there's no point going through
|
||||
* an HTTP loopback to write sub-task events — we can call the store
|
||||
* directly. This class is duck-type compatible with the sister-agent
|
||||
* RailsClient (same 3 methods) so InProcessTransport can pass it in place
|
||||
* of the real client.
|
||||
*/
|
||||
export class DirectRailsClient {
|
||||
async createSubTask(record: unknown): Promise<void> {
|
||||
const parsed = CreateSubTaskInput.parse(record);
|
||||
await createSubTask(parsed);
|
||||
}
|
||||
|
||||
async recordEvent(
|
||||
subTaskId: string,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const parsed = SubTaskEventInput.parse({
|
||||
subTaskId,
|
||||
eventType,
|
||||
payload,
|
||||
});
|
||||
await recordSubTaskEvent(parsed);
|
||||
}
|
||||
|
||||
async patchSubTask(
|
||||
id: string,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const parsed = UpdateSubTaskInput.parse(patch);
|
||||
await updateSubTask(id, parsed);
|
||||
}
|
||||
}
|
||||
88
src/handoff/escalation-notifier.ts
Normal file
88
src/handoff/escalation-notifier.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { EscalationNotifier } from "../resilience/escalate.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "escalation-notifier" });
|
||||
|
||||
export interface DiscordEscalationOptions {
|
||||
/**
|
||||
* Sister-agent endpoint that owns the Discord bot identity used for the
|
||||
* alert. Typically harang's sister-agent (port 18801).
|
||||
*/
|
||||
sisterUrl: string;
|
||||
/** Discord channel ID where the alert should land. */
|
||||
channelId: string;
|
||||
/** Optional Discord user ID to @-mention in the alert. */
|
||||
userId?: string;
|
||||
/** Project name for the message header. */
|
||||
projectName?: string;
|
||||
/** Pipeline id (used in formatted message). */
|
||||
pipelineId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EscalationNotifier that posts a Discord alert via a sister-agent's
|
||||
* /notify endpoint. The sister-agent then uses its local OpenClaw CLI to
|
||||
* send the message under that sister's bot identity (so the channel sees
|
||||
* "하랑이 [bot]" mentioning 자기야 instead of a generic webhook).
|
||||
*/
|
||||
export class DiscordEscalationNotifier implements EscalationNotifier {
|
||||
constructor(private readonly opts: DiscordEscalationOptions) {}
|
||||
|
||||
async notify(message: {
|
||||
title: string;
|
||||
body: string;
|
||||
mentionUser?: boolean;
|
||||
}): Promise<void> {
|
||||
const mention =
|
||||
message.mentionUser && this.opts.userId
|
||||
? `<@${this.opts.userId}> `
|
||||
: "";
|
||||
|
||||
const formatted = [
|
||||
`${mention}🚨 **자기야, 막혔어** — 사람이 봐야 할 것 같아`,
|
||||
``,
|
||||
this.opts.projectName ? `**프로젝트:** ${this.opts.projectName}` : "",
|
||||
`${message.title}`,
|
||||
``,
|
||||
message.body.slice(0, 1500),
|
||||
``,
|
||||
`Pipeline ID: \`${this.opts.pipelineId}\``,
|
||||
`대시보드: https://hanarang.nabomhalang.co.kr/rails`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const url = `${this.opts.sisterUrl}/notify`;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30_000);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
channelId: this.opts.channelId,
|
||||
message: formatted,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
log.warn(
|
||||
{ status: res.status, body: txt.slice(0, 200) },
|
||||
"escalation notify HTTP error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info(
|
||||
{ pipelineId: this.opts.pipelineId, channel: this.opts.channelId },
|
||||
"escalation notify sent",
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"escalation notify threw — non-fatal",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/handoff/in-process-transport.ts
Normal file
122
src/handoff/in-process-transport.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import type { SisterTransport, HealthStatus } from "./transport.js";
|
||||
import { HandoffMessage, type InvokeRequest } from "./message.js";
|
||||
import { DirectRailsClient } from "./direct-rails-client.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "in-process-transport" });
|
||||
|
||||
export interface InProcessTransportOptions {
|
||||
/** Agent identity — harang / narang / darang / erang (or any custom name). */
|
||||
agentName: string;
|
||||
/**
|
||||
* Rails API URL (loopback). The embedded sister-agent core uses this to
|
||||
* report sub-task events back via HTTP. Typically "http://127.0.0.1:<port>".
|
||||
*/
|
||||
railsApiUrl: string;
|
||||
/**
|
||||
* Absolute path to sister-agent's compiled core.js. Defaults to
|
||||
* env SISTER_AGENT_CORE_PATH
|
||||
* or <cwd>/sister-agent/dist/core.js
|
||||
*/
|
||||
coreModulePath?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface SisterCore {
|
||||
executeInvocation: (req: unknown, rails: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
let cachedCore: Promise<SisterCore> | null = null;
|
||||
|
||||
function loadCore(modulePath: string): Promise<SisterCore> {
|
||||
if (!cachedCore) {
|
||||
const url = pathToFileURL(resolve(modulePath)).href;
|
||||
cachedCore = import(url).then((mod: unknown) => {
|
||||
const m = mod as Partial<SisterCore>;
|
||||
if (typeof m.executeInvocation !== "function") {
|
||||
throw new Error(
|
||||
`sister-agent core module at ${modulePath} is missing executeInvocation export`,
|
||||
);
|
||||
}
|
||||
return m as SisterCore;
|
||||
});
|
||||
}
|
||||
return cachedCore;
|
||||
}
|
||||
|
||||
/**
|
||||
* InProcessTransport — runs sister-agent logic inside the same Node process
|
||||
* as rails. Used for single-host, zero-config deployments where spinning up
|
||||
* 4 separate LXCs is overkill.
|
||||
*
|
||||
* Under the hood it dynamically imports sister-agent/dist/core.js and calls
|
||||
* executeInvocation() directly. Sub-task events still flow through the rails
|
||||
* HTTP API (loopback) so the observability surface is identical to the
|
||||
* distributed HTTP transport.
|
||||
*/
|
||||
export class InProcessTransport implements SisterTransport {
|
||||
readonly name: string;
|
||||
private readonly opts: Required<InProcessTransportOptions>;
|
||||
|
||||
constructor(opts: InProcessTransportOptions) {
|
||||
this.name = `in-process:${opts.agentName}`;
|
||||
this.opts = {
|
||||
agentName: opts.agentName,
|
||||
railsApiUrl: opts.railsApiUrl,
|
||||
coreModulePath:
|
||||
opts.coreModulePath ??
|
||||
process.env["SISTER_AGENT_CORE_PATH"] ??
|
||||
resolve(process.cwd(), "sister-agent/dist/core.js"),
|
||||
timeoutMs: opts.timeoutMs ?? 600_000,
|
||||
};
|
||||
}
|
||||
|
||||
async invoke(
|
||||
req: InvokeRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HandoffMessage> {
|
||||
const core = await loadCore(this.opts.coreModulePath);
|
||||
// Use a direct-DB client — skipping HTTP loopback entirely.
|
||||
const rails = new DirectRailsClient();
|
||||
|
||||
const payload = {
|
||||
...req,
|
||||
agentName: this.opts.agentName,
|
||||
railsApiUrl: this.opts.railsApiUrl,
|
||||
timeoutMs: req.timeoutMs || this.opts.timeoutMs,
|
||||
};
|
||||
|
||||
log.info(
|
||||
{ agent: this.opts.agentName, stage: req.stage, pipelineId: req.pipelineId },
|
||||
"in-process invoke start",
|
||||
);
|
||||
|
||||
// The caller's AbortSignal is honored indirectly — executeInvocation
|
||||
// itself does not take a signal today, but if it hangs the outer pipeline
|
||||
// timeout will bubble up through the FSM.
|
||||
void signal;
|
||||
|
||||
const raw = await core.executeInvocation(payload, rails);
|
||||
return HandoffMessage.parse(raw);
|
||||
}
|
||||
|
||||
async health(): Promise<HealthStatus> {
|
||||
try {
|
||||
await loadCore(this.opts.coreModulePath);
|
||||
return { alive: true, latencyMs: 0, message: "ok" };
|
||||
} catch (err) {
|
||||
return {
|
||||
alive: false,
|
||||
latencyMs: 0,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Nothing to clean up — the module import is cached for the lifetime
|
||||
// of the process.
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,12 @@ export const HandoffMessage = z.discriminatedUnion("stage", [
|
||||
|
||||
export type HandoffMessage = z.infer<typeof HandoffMessage>;
|
||||
|
||||
export const PriorStageOutput = z.object({
|
||||
stage: z.enum(["plan", "implement", "review", "deploy"]),
|
||||
text: z.string(),
|
||||
});
|
||||
export type PriorStageOutput = z.infer<typeof PriorStageOutput>;
|
||||
|
||||
export const InvokeRequest = z.object({
|
||||
pipelineId: z.string(),
|
||||
contractId: z.string().default(""),
|
||||
@@ -88,8 +94,16 @@ export const InvokeRequest = z.object({
|
||||
description: z.string().default(""),
|
||||
workdir: z.string().default(""),
|
||||
}),
|
||||
priorStages: z.array(PriorStageOutput).default([]),
|
||||
timeoutMs: z.number().int().positive().default(30_000),
|
||||
structuredOutput: z.literal(true).default(true),
|
||||
/**
|
||||
* Optional Discord channel ID. When set, the sister-agent posts stage
|
||||
* start / end messages to that channel using its local OpenClaw bot
|
||||
* identity (so each sister speaks in her own voice in the originating
|
||||
* channel). Empty string = no Discord notification.
|
||||
*/
|
||||
notifyChannelId: z.string().default(""),
|
||||
});
|
||||
|
||||
export type InvokeRequest = z.infer<typeof InvokeRequest>;
|
||||
|
||||
@@ -127,6 +127,74 @@ export async function recordSubTaskEvent(
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
@@ -5,10 +5,23 @@ export const PipelineContext = z.object({
|
||||
projectName: z.string(),
|
||||
requirements: z.string().default(""),
|
||||
currentSprintId: z.string().nullable().default(null),
|
||||
/** Inner loop: how many times the current plan has been re-implemented */
|
||||
reviewRound: z.number().int().min(0).default(0),
|
||||
/** Outer loop: how many times the whole plan→impl→review cycle restarted */
|
||||
replanCount: z.number().int().min(0).default(0),
|
||||
retryCount: z.number().int().min(0).default(0),
|
||||
maxRetries: z.number().int().positive().default(3),
|
||||
maxReviewRounds: z.number().int().positive().default(3),
|
||||
/**
|
||||
* Inner-loop budget. Each round = a real LLM call (30-60s) so we keep
|
||||
* this small. Total review attempts per plan = 1 + maxReviewRounds.
|
||||
*/
|
||||
maxReviewRounds: z.number().int().positive().default(2),
|
||||
/**
|
||||
* Outer-loop budget. Total review attempts across the whole pipeline =
|
||||
* (1+maxReplans)*(1+maxReviewRounds). With defaults (1, 2) = 6 attempts,
|
||||
* keeping total wall-clock under ~6 min before escalation.
|
||||
*/
|
||||
maxReplans: z.number().int().min(0).default(1),
|
||||
lastError: z.string().nullable().default(null),
|
||||
contractPath: z.string().nullable().default(null),
|
||||
createdAt: z.string().datetime(),
|
||||
@@ -27,9 +40,11 @@ export function createInitialContext(
|
||||
requirements,
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
replanCount: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
maxReviewRounds: 2,
|
||||
maxReplans: 1,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
@@ -19,6 +19,8 @@ export const pipelineMachine = setup({
|
||||
context.retryCount < context.maxRetries,
|
||||
canReviewAgain: ({ context }: { context: PipelineContext }) =>
|
||||
context.reviewRound < context.maxReviewRounds,
|
||||
canReplan: ({ context }: { context: PipelineContext }) =>
|
||||
context.replanCount < context.maxReplans,
|
||||
isRetryable: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" && event.retryable === true,
|
||||
},
|
||||
@@ -33,6 +35,10 @@ export const pipelineMachine = setup({
|
||||
context.reviewRound + 1,
|
||||
}),
|
||||
resetReviewRound: assign({ reviewRound: 0 }),
|
||||
incrementReplanCount: assign({
|
||||
replanCount: ({ context }: { context: PipelineContext }) =>
|
||||
context.replanCount + 1,
|
||||
}),
|
||||
setError: assign({
|
||||
lastError: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" ? event.reason : null,
|
||||
@@ -56,9 +62,11 @@ export const pipelineMachine = setup({
|
||||
requirements: "",
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
replanCount: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
maxReviewRounds: 2,
|
||||
maxReplans: 1,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -144,15 +152,35 @@ export const pipelineMachine = setup({
|
||||
},
|
||||
REQUEST_CHANGES: [
|
||||
{
|
||||
// Inner loop: still have review rounds left → re-implement
|
||||
// with the same plan
|
||||
guard: "canReviewAgain",
|
||||
target: "implementing",
|
||||
actions: ["incrementReviewRound"],
|
||||
},
|
||||
{
|
||||
// Inner loop exhausted but outer loop still has budget →
|
||||
// go back to planning. The next plan stage sees the failed
|
||||
// review issues via priorStages and can produce a new
|
||||
// approach. reviewRound is reset so the new plan gets a
|
||||
// fresh review budget.
|
||||
guard: "canReplan",
|
||||
target: "planning",
|
||||
actions: [
|
||||
"incrementReplanCount",
|
||||
"resetReviewRound",
|
||||
assign({
|
||||
lastError:
|
||||
"Re-planning after exhausted review rounds — see prior stage feedback",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Both inner and outer loops exhausted → ask the user
|
||||
target: "escalated",
|
||||
actions: [
|
||||
assign({
|
||||
lastError: "Max review rounds exceeded",
|
||||
lastError: "Max replans exceeded — needs human intervention",
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -172,6 +172,71 @@ function mergeContextIntoSnapshot(
|
||||
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?: {
|
||||
state?: PipelineState;
|
||||
limit?: number;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ulid } from "ulid";
|
||||
import { sendEvent, createPipeline } from "./persist.js";
|
||||
import {
|
||||
sendEvent,
|
||||
createPipeline,
|
||||
getPipelineState,
|
||||
} from "./persist.js";
|
||||
import type { PipelineEvent, PipelineState } from "./events.js";
|
||||
import type { HandoffMessage, InvokeRequest } from "../handoff/message.js";
|
||||
import type { SisterTransport } from "../handoff/transport.js";
|
||||
@@ -10,6 +14,46 @@ import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "runner" });
|
||||
|
||||
export type PipelineLifecycleEvent =
|
||||
| {
|
||||
type: "started";
|
||||
pipelineId: string;
|
||||
projectName: string;
|
||||
requirements: string;
|
||||
}
|
||||
| {
|
||||
type: "stage-done";
|
||||
pipelineId: string;
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "stage-failed";
|
||||
pipelineId: string;
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "completed";
|
||||
pipelineId: string;
|
||||
finalState: PipelineState;
|
||||
transitions: number;
|
||||
}
|
||||
| {
|
||||
type: "failed";
|
||||
pipelineId: string;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "escalated";
|
||||
pipelineId: string;
|
||||
stage: string;
|
||||
reason: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type PipelineEventListener = (evt: PipelineLifecycleEvent) => void;
|
||||
|
||||
export interface RunOptions {
|
||||
projectName: string;
|
||||
requirements: string;
|
||||
@@ -18,6 +62,20 @@ export interface RunOptions {
|
||||
signal?: AbortSignal;
|
||||
maxRetries?: number;
|
||||
notifier?: EscalationNotifier;
|
||||
/** Optional lifecycle listener — used by the Discord bridge to post updates. */
|
||||
onEvent?: PipelineEventListener;
|
||||
/**
|
||||
* If provided, resume an already-created pipeline row instead of making
|
||||
* a new one. Used by async HTTP starts where the caller needs the id
|
||||
* before runPipeline finishes.
|
||||
*/
|
||||
pipelineId?: string;
|
||||
/**
|
||||
* Optional Discord channel ID — propagated through every InvokeRequest
|
||||
* so sister-agents can post stage start/end messages in the originating
|
||||
* channel using their own OpenClaw bot identity.
|
||||
*/
|
||||
notifyChannelId?: string;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
@@ -32,13 +90,44 @@ export interface RunResult {
|
||||
* into the FSM until done or escalated.
|
||||
*/
|
||||
export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
const { pipelineId, state: initialState } = await createPipeline(
|
||||
opts.projectName,
|
||||
opts.requirements,
|
||||
);
|
||||
let pipelineId: string;
|
||||
let initialState: string;
|
||||
if (opts.pipelineId) {
|
||||
pipelineId = opts.pipelineId;
|
||||
const existing = await getPipelineState(pipelineId);
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
`runPipeline: pipelineId ${pipelineId} does not exist in DB`,
|
||||
);
|
||||
}
|
||||
initialState = existing.state;
|
||||
} else {
|
||||
const created = await createPipeline(opts.projectName, opts.requirements);
|
||||
pipelineId = created.pipelineId;
|
||||
initialState = created.state;
|
||||
}
|
||||
|
||||
log.info({ pipelineId, project: opts.projectName }, "Pipeline run started");
|
||||
|
||||
const emit = (evt: PipelineLifecycleEvent): void => {
|
||||
if (!opts.onEvent) return;
|
||||
try {
|
||||
opts.onEvent(evt);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"pipeline event listener threw",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
emit({
|
||||
type: "started",
|
||||
pipelineId,
|
||||
projectName: opts.projectName,
|
||||
requirements: opts.requirements,
|
||||
});
|
||||
|
||||
// REQUEST event — enters planning
|
||||
let result = await sendEvent(pipelineId, {
|
||||
type: "REQUEST",
|
||||
@@ -47,8 +136,16 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
});
|
||||
|
||||
let transitions = 1;
|
||||
let escalationRecorded = false;
|
||||
let lastActiveStage: "plan" | "implement" | "review" | "deploy" = "plan";
|
||||
const TERMINAL: PipelineState[] = ["done", "escalated", "aborted"];
|
||||
|
||||
// Accumulate stage outputs so each stage can see what the previous ones produced.
|
||||
const priorStages: Array<{
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
text: string;
|
||||
}> = [];
|
||||
|
||||
while (!TERMINAL.includes(result.state)) {
|
||||
if (opts.signal?.aborted) {
|
||||
result = await sendEvent(pipelineId, {
|
||||
@@ -64,6 +161,7 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
log.warn({ state: result.state }, "Non-active state encountered, stopping");
|
||||
break;
|
||||
}
|
||||
lastActiveStage = stage;
|
||||
|
||||
const transport = opts.transports.get(stage);
|
||||
if (!transport) {
|
||||
@@ -89,19 +187,32 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
description: opts.requirements,
|
||||
workdir: process.cwd(),
|
||||
},
|
||||
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 30_000,
|
||||
priorStages,
|
||||
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
|
||||
structuredOutput: true,
|
||||
notifyChannelId: opts.notifyChannelId ?? "",
|
||||
};
|
||||
|
||||
const retryResult = await withRetry(
|
||||
async () => transport.invoke(invokeReq, opts.signal),
|
||||
{
|
||||
maxRetries: opts.maxRetries ?? 3,
|
||||
maxRetries: opts.maxRetries ?? 1,
|
||||
...(opts.signal && { signal: opts.signal }),
|
||||
},
|
||||
);
|
||||
|
||||
if (retryResult.ok && retryResult.value) {
|
||||
// Extract the text output for the next stage
|
||||
const stageText = extractStageText(retryResult.value);
|
||||
if (stageText) {
|
||||
priorStages.push({ stage, text: stageText });
|
||||
}
|
||||
emit({
|
||||
type: "stage-done",
|
||||
pipelineId,
|
||||
stage,
|
||||
text: stageText,
|
||||
});
|
||||
const event = handoffToEvent(retryResult.value);
|
||||
result = await sendEvent(pipelineId, event);
|
||||
transitions += 1;
|
||||
@@ -120,6 +231,13 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
"Transport invoke failed after retries",
|
||||
);
|
||||
|
||||
emit({
|
||||
type: "stage-failed",
|
||||
pipelineId,
|
||||
stage,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (classification && !classification.retryable) {
|
||||
await recordEscalation(
|
||||
{
|
||||
@@ -132,6 +250,14 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
},
|
||||
opts.notifier,
|
||||
);
|
||||
escalationRecorded = true;
|
||||
emit({
|
||||
type: "escalated",
|
||||
pipelineId,
|
||||
stage,
|
||||
reason,
|
||||
attempts: retryResult.attempts,
|
||||
});
|
||||
}
|
||||
|
||||
result = await sendEvent(pipelineId, {
|
||||
@@ -149,6 +275,62 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
"Pipeline run finished",
|
||||
);
|
||||
|
||||
if (result.state === "done") {
|
||||
emit({
|
||||
type: "completed",
|
||||
pipelineId,
|
||||
finalState: result.state,
|
||||
transitions,
|
||||
});
|
||||
} else if (result.state === "escalated") {
|
||||
// FSM can reach `escalated` two ways:
|
||||
// 1. ERROR (non-retryable) — recordEscalation was called inline
|
||||
// and escalationRecorded was set true.
|
||||
// 2. REQUEST_CHANGES exhaustion (review-loop / replan budget) —
|
||||
// that's a normal handoff event, not an ERROR, so the inline
|
||||
// branch above never runs. Catch it here.
|
||||
if (!escalationRecorded) {
|
||||
const reason = String(
|
||||
result.context.lastError ?? "Pipeline escalated",
|
||||
);
|
||||
const replanCount = (result.context as { replanCount?: number })
|
||||
.replanCount ?? 0;
|
||||
try {
|
||||
await recordEscalation(
|
||||
{
|
||||
pipelineId,
|
||||
stage: lastActiveStage,
|
||||
reason,
|
||||
attempts: replanCount,
|
||||
contextSnapshot: result.context as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
},
|
||||
opts.notifier,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"post-loop recordEscalation failed",
|
||||
);
|
||||
}
|
||||
emit({
|
||||
type: "escalated",
|
||||
pipelineId,
|
||||
stage: lastActiveStage,
|
||||
reason,
|
||||
attempts: replanCount,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
emit({
|
||||
type: "failed",
|
||||
pipelineId,
|
||||
reason: `Pipeline ended in ${result.state}`,
|
||||
});
|
||||
}
|
||||
|
||||
void initialState; // referenced only for typecheck
|
||||
return {
|
||||
pipelineId,
|
||||
@@ -174,6 +356,57 @@ function mapStateToStage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a text summary from a HandoffMessage for stage chaining.
|
||||
* sister-agent buildSuccessResult packs summary into selfTestReport/verificationResults.
|
||||
*/
|
||||
function extractStageText(h: HandoffMessage): string {
|
||||
switch (h.stage) {
|
||||
case "plan":
|
||||
if (h.payload) {
|
||||
return `plan dir: ${h.payload.planDir}, sprint: ${h.payload.sprintId}`;
|
||||
}
|
||||
return "";
|
||||
case "implement": {
|
||||
const report = h.payload?.selfTestReport as
|
||||
| {
|
||||
summary?: string;
|
||||
repoUrl?: string;
|
||||
rawUrlBase?: string;
|
||||
filesCount?: number;
|
||||
producedFiles?: string[];
|
||||
}
|
||||
| undefined;
|
||||
const parts: string[] = [];
|
||||
if (report?.summary) parts.push(report.summary);
|
||||
if (report?.repoUrl) parts.push(`[git] repoUrl=${report.repoUrl}`);
|
||||
if (report?.rawUrlBase) parts.push(`[git] rawUrlBase=${report.rawUrlBase}`);
|
||||
if (typeof report?.filesCount === "number") {
|
||||
parts.push(`[git] filesCount=${report.filesCount}`);
|
||||
}
|
||||
if (report?.producedFiles && report.producedFiles.length > 0) {
|
||||
parts.push(`[git] producedFiles=${report.producedFiles.join(",")}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
case "review": {
|
||||
if (h.payload?.issues && h.payload.issues.length > 0) {
|
||||
// Generous cap so the next implement loop sees the full reviewer
|
||||
// critique (not just the first 2KB). Reviewer reason text can be
|
||||
// multiple paragraphs and the implement junior needs all of it
|
||||
// to fix the right things.
|
||||
return `Review ${h.verdict}: ${JSON.stringify(h.payload.issues).slice(0, 32_000)}`;
|
||||
}
|
||||
return `Review verdict: ${h.verdict}`;
|
||||
}
|
||||
case "deploy": {
|
||||
const summary =
|
||||
(h.payload?.verificationResults as { summary?: string } | undefined)?.summary;
|
||||
return summary ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handoffToEvent(h: HandoffMessage): PipelineEvent {
|
||||
switch (h.stage) {
|
||||
case "plan":
|
||||
|
||||
@@ -4,12 +4,19 @@ import {
|
||||
createPipeline,
|
||||
getPipelineState,
|
||||
listPipelines,
|
||||
listTransitions,
|
||||
listEscalations,
|
||||
sendEvent,
|
||||
} from "../orchestrator/persist.js";
|
||||
import { runPipeline } from "../orchestrator/runner.js";
|
||||
import {
|
||||
runPipeline,
|
||||
type PipelineEventListener,
|
||||
} from "../orchestrator/runner.js";
|
||||
import { loadConfig } from "../config/loader.js";
|
||||
import type { SisterTransport } from "../handoff/transport.js";
|
||||
import { buildTransports } from "../handoff/build.js";
|
||||
import type { EscalationNotifier } from "../resilience/escalate.js";
|
||||
import { DiscordEscalationNotifier } from "../handoff/escalation-notifier.js";
|
||||
import {
|
||||
CreateSubTaskInput,
|
||||
SubTaskEventInput,
|
||||
@@ -18,6 +25,7 @@ import {
|
||||
recordSubTaskEvent,
|
||||
updateSubTask,
|
||||
getSubTaskTree,
|
||||
getSubTaskDetail,
|
||||
} from "../hierarchy/store.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
@@ -27,6 +35,13 @@ const StartRequest = z.object({
|
||||
project: z.string().min(1),
|
||||
requirements: z.string().default(""),
|
||||
mock: z.boolean().default(true),
|
||||
/**
|
||||
* Optional Discord channel ID — propagated to each sister-agent so they
|
||||
* can post stage updates in the originating channel using their own
|
||||
* OpenClaw bot identity. Set by the harang skill wrapper which extracts
|
||||
* it from the local sessions.json.
|
||||
*/
|
||||
notifyChannelId: z.string().default(""),
|
||||
});
|
||||
|
||||
const AbortRequest = z.object({
|
||||
@@ -37,6 +52,19 @@ interface ServerOpts {
|
||||
port: number;
|
||||
host?: string;
|
||||
configPath?: string;
|
||||
/** Optional lifecycle listener injected into every runPipeline call. */
|
||||
onPipelineEvent?: PipelineEventListener;
|
||||
/** Optional escalation notifier injected into every runPipeline call. */
|
||||
notifier?: EscalationNotifier;
|
||||
/**
|
||||
* Optional Discord escalation alert config. When set, every pipeline that
|
||||
* carries a notifyChannelId gets an auto-generated DiscordEscalationNotifier
|
||||
* pointed at that channel.
|
||||
*/
|
||||
escalationConfig?: {
|
||||
sisterUrl: string;
|
||||
userId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
@@ -69,7 +97,7 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
return sendJson(res, 200, { pipelines: list });
|
||||
}
|
||||
|
||||
// ── Start new pipeline ──
|
||||
// ── Start new pipeline (synchronous — blocks until done) ──
|
||||
if (method === "POST" && path === "/pipelines/start") {
|
||||
const body = await readJson(req);
|
||||
const parsed = StartRequest.safeParse(body);
|
||||
@@ -79,22 +107,32 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
const { project, requirements, mock: useMock } = parsed.data;
|
||||
const { project, requirements, notifyChannelId } = parsed.data;
|
||||
|
||||
// Only mock mode is wired right now — real transports come in a follow-up.
|
||||
if (!useMock) {
|
||||
return sendJson(res, 501, {
|
||||
error: "not_implemented",
|
||||
message: "Non-mock transport wiring deferred to next iteration.",
|
||||
// Build a per-pipeline escalation notifier if both escalationConfig
|
||||
// and a notifyChannelId are present. This wraps opts.notifier so the
|
||||
// existing manual override still works for callers that pass one.
|
||||
let activeNotifier: EscalationNotifier | undefined = opts.notifier;
|
||||
if (opts.escalationConfig && notifyChannelId) {
|
||||
activeNotifier = new DiscordEscalationNotifier({
|
||||
sisterUrl: opts.escalationConfig.sisterUrl,
|
||||
...(opts.escalationConfig.userId && {
|
||||
userId: opts.escalationConfig.userId,
|
||||
}),
|
||||
channelId: notifyChannelId,
|
||||
projectName: project,
|
||||
pipelineId: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
// Run pipeline (async, but we await for this simple demo)
|
||||
const result = await runPipeline({
|
||||
projectName: project,
|
||||
requirements,
|
||||
config,
|
||||
transports,
|
||||
...(notifyChannelId && { notifyChannelId }),
|
||||
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
|
||||
...(activeNotifier && { notifier: activeNotifier }),
|
||||
});
|
||||
|
||||
return sendJson(res, 201, {
|
||||
@@ -104,6 +142,68 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
// ── Start new pipeline (async — returns pipelineId immediately) ──
|
||||
//
|
||||
// Used by the Discord slash command so the bot can ACK within 3 s and
|
||||
// then post progress updates to a thread as the pipeline advances.
|
||||
if (method === "POST" && path === "/pipelines/start-async") {
|
||||
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, notifyChannelId } = parsed.data;
|
||||
|
||||
// Create the pipeline row synchronously so we can return its id
|
||||
// immediately, then run the rest in the background under that id.
|
||||
const { pipelineId } = await createPipeline(project, requirements);
|
||||
|
||||
// Build per-pipeline escalation notifier with the actual pipelineId
|
||||
let activeNotifier: EscalationNotifier | undefined = opts.notifier;
|
||||
if (opts.escalationConfig && notifyChannelId) {
|
||||
activeNotifier = new DiscordEscalationNotifier({
|
||||
sisterUrl: opts.escalationConfig.sisterUrl,
|
||||
...(opts.escalationConfig.userId && {
|
||||
userId: opts.escalationConfig.userId,
|
||||
}),
|
||||
channelId: notifyChannelId,
|
||||
projectName: project,
|
||||
pipelineId,
|
||||
});
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await runPipeline({
|
||||
projectName: project,
|
||||
requirements,
|
||||
pipelineId,
|
||||
config,
|
||||
transports,
|
||||
...(notifyChannelId && { notifyChannelId }),
|
||||
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
|
||||
...(activeNotifier && { notifier: activeNotifier }),
|
||||
});
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pipelineId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"background pipeline run failed",
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return sendJson(res, 202, {
|
||||
pipelineId,
|
||||
status: "accepted",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Get pipeline status ──
|
||||
const statusMatch = path.match(/^\/pipelines\/([^/]+)$/);
|
||||
if (method === "GET" && statusMatch) {
|
||||
@@ -198,6 +298,41 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
|
||||
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(
|
||||
|
||||
@@ -44,7 +44,9 @@ describe("pipelineMachine", () => {
|
||||
expect(snapshot.context.reviewRound).toBe(1);
|
||||
});
|
||||
|
||||
it("escalates after max review rounds exceeded", () => {
|
||||
it("after max review rounds, falls back to planning (re-plan loop)", () => {
|
||||
// Defaults: maxReviewRounds=2, maxReplans=1.
|
||||
// Burn through (1 + maxReviewRounds) = 3 review attempts to trigger replan.
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
||||
@@ -54,15 +56,60 @@ describe("pipelineMachine", () => {
|
||||
// Round 2 (reviewRound: 1 → 2)
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c2"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
// Round 3 (reviewRound: 2 → 3)
|
||||
// Round 3 — reviewRound=2, canReviewAgain (2<2)=false, canReplan (0<1)=true → planning
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c3"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
// Round 4 — reviewRound=3, guard 3 < 3 = false → escalated
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c4"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
]);
|
||||
expect(snapshot.value).toBe("planning");
|
||||
expect(snapshot.context.replanCount).toBe(1);
|
||||
expect(snapshot.context.reviewRound).toBe(0); // reset on re-plan
|
||||
expect(snapshot.context.lastError).toContain("Re-planning");
|
||||
});
|
||||
|
||||
it("escalates only after maxReplans + maxReviewRounds both exhausted", () => {
|
||||
// Defaults: maxReplans=1, maxReviewRounds=2 →
|
||||
// (1+maxReplans) = 2 plan attempts × (1+maxReviewRounds) = 3 review attempts each
|
||||
// = 6 total REQUEST_CHANGES events before escalation.
|
||||
const events: Array<Record<string, unknown>> = [
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
];
|
||||
for (let plan = 0; plan < 2; plan++) {
|
||||
events.push({ type: "PLAN_READY", planDir: "/tmp", sprintId: `S${plan}` });
|
||||
for (let round = 0; round < 3; round++) {
|
||||
events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${plan}-${round}`] });
|
||||
events.push({
|
||||
type: "REQUEST_CHANGES",
|
||||
issues: [{ severity: "major", message: "fix" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
const snapshot = runMachine(events);
|
||||
expect(snapshot.value).toBe("escalated");
|
||||
expect(snapshot.context.lastError).toContain("review rounds");
|
||||
expect(snapshot.context.replanCount).toBe(1); // maxReplans = 1
|
||||
expect(snapshot.context.lastError).toContain("Max replans exceeded");
|
||||
});
|
||||
|
||||
it("re-plan: APPROVE within new plan still leads to deploying", () => {
|
||||
const events: Array<Record<string, unknown>> = [
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
||||
];
|
||||
// Burn through 3 review attempts to trigger first replan
|
||||
for (let round = 0; round < 3; round++) {
|
||||
events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${round}`] });
|
||||
events.push({
|
||||
type: "REQUEST_CHANGES",
|
||||
issues: [{ severity: "major", message: "fix" }],
|
||||
});
|
||||
}
|
||||
// Now in planning (replan #1). New plan, then APPROVE on first review.
|
||||
events.push({ type: "PLAN_READY", planDir: "/tmp/v2", sprintId: "S2" });
|
||||
events.push({ type: "IMPL_DONE", branch: "b", commits: ["c-new"] });
|
||||
events.push({ type: "APPROVE", reviewArtifact: "/tmp/r.json" });
|
||||
events.push({ type: "DEPLOY_DONE", deployArtifact: "/tmp/d.json" });
|
||||
const snapshot = runMachine(events);
|
||||
expect(snapshot.value).toBe("done");
|
||||
expect(snapshot.context.replanCount).toBe(1);
|
||||
});
|
||||
|
||||
it("retryable error goes to retrying, then back (if under limit)", () => {
|
||||
|
||||
128
tests/transport-build.test.ts
Normal file
128
tests/transport-build.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { buildTransports } from "../src/handoff/build.js";
|
||||
import { RailsConfig } from "../src/config/schema.js";
|
||||
|
||||
const baseConfig = (overrides: Partial<{
|
||||
transport: string;
|
||||
endpoint: string;
|
||||
agentName: string;
|
||||
}>) =>
|
||||
RailsConfig.parse({
|
||||
pipeline: { stages: ["plan", "implement", "review", "deploy"] },
|
||||
agents: {
|
||||
plan: {
|
||||
role: "plan",
|
||||
agentName: overrides.agentName ?? "harang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
implement: {
|
||||
role: "implement",
|
||||
agentName: "narang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
review: {
|
||||
role: "review",
|
||||
agentName: "darang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
deploy: {
|
||||
role: "deploy",
|
||||
agentName: "erang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const envKeys = [
|
||||
"RAILS_TRANSPORT",
|
||||
"RAILS_TRANSPORT_MODE",
|
||||
"RAILS_TRANSPORT_PLAN",
|
||||
"RAILS_TRANSPORT_IMPLEMENT",
|
||||
"RAILS_TRANSPORT_REVIEW",
|
||||
"RAILS_TRANSPORT_DEPLOY",
|
||||
"SISTER_ENDPOINT_PLAN",
|
||||
"SISTER_ENDPOINT_IMPLEMENT",
|
||||
"SISTER_ENDPOINT_REVIEW",
|
||||
"SISTER_ENDPOINT_DEPLOY",
|
||||
"RAILS_AGENT_PLAN_HOST",
|
||||
"RAILS_AGENT_IMPLEMENT_HOST",
|
||||
];
|
||||
|
||||
describe("transport builder", () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of envKeys) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to mock when nothing is set", () => {
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.size).toBe(4);
|
||||
for (const [, t] of map) expect(t.name).toBe("mock");
|
||||
});
|
||||
|
||||
it("RAILS_TRANSPORT=in-process wires in-process transport everywhere", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "in-process";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("in-process:harang");
|
||||
expect(map.get("implement")?.name).toBe("in-process:narang");
|
||||
expect(map.get("review")?.name).toBe("in-process:darang");
|
||||
expect(map.get("deploy")?.name).toBe("in-process:erang");
|
||||
});
|
||||
|
||||
it("per-stage RAILS_TRANSPORT_REVIEW overrides global", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "in-process";
|
||||
process.env["RAILS_TRANSPORT_REVIEW"] = "mock";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("in-process:harang");
|
||||
expect(map.get("review")?.name).toBe("mock");
|
||||
});
|
||||
|
||||
it("http transport falls back to mock when no endpoint is configured", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "http";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
// Without endpoints, everything falls back to mock
|
||||
for (const [, t] of map) expect(t.name).toBe("mock");
|
||||
});
|
||||
|
||||
it("http transport honors SISTER_ENDPOINT_* env", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "http";
|
||||
process.env["SISTER_ENDPOINT_PLAN"] = "http://plan.local:18801";
|
||||
process.env["SISTER_ENDPOINT_IMPLEMENT"] = "http://impl.local:18801";
|
||||
process.env["SISTER_ENDPOINT_REVIEW"] = "http://rev.local:18801";
|
||||
process.env["SISTER_ENDPOINT_DEPLOY"] = "http://dep.local:18801";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("http:harang");
|
||||
expect(map.get("deploy")?.name).toBe("http:erang");
|
||||
});
|
||||
|
||||
it("legacy RAILS_TRANSPORT_MODE=http + RAILS_AGENT_*_HOST still works", () => {
|
||||
process.env["RAILS_TRANSPORT_MODE"] = "http";
|
||||
process.env["RAILS_AGENT_PLAN_HOST"] = "10.0.0.1";
|
||||
process.env["RAILS_AGENT_IMPLEMENT_HOST"] = "10.0.0.2";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("http:harang");
|
||||
expect(map.get("implement")?.name).toBe("http:narang");
|
||||
// review/deploy have no host → fall back to mock
|
||||
expect(map.get("review")?.name).toBe("mock");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user