feat(sprint-004): 4-agent handoff engine — Transport 추상화 + runner + Discord marker

Sprint 004 핵심 구현 — F3 (QA 자동 라우팅 누락) + F4 (핸드오프 불안정) 해결:

Config (범용):
- src/config/schema.ts — Zod RailsConfig (pipeline/agents/discord)
- src/config/loader.ts — YAML + 환경변수 interpolation (${VAR})
- rails.config.example.yaml — 샘플 설정

Handoff:
- src/handoff/message.ts — HandoffMessage discriminated union (plan/implement/review/deploy)
- src/handoff/transport.ts — SisterTransport 인터페이스
- src/handoff/mock-transport.ts — 시나리오 override 가능한 mock
- src/handoff/discord-transport.ts — encodeInvokeMarker / decodeResultMarker
  (HTML 주석 + json 블록 — 자매는 LLM 우회 파서로 처리)
  DiscordPoster 인터페이스 주입으로 discord.js 와 독립 테스트 가능

Orchestrator:
- src/orchestrator/runner.ts — runPipeline E2E
  state → stage 매핑 → transport.invoke → HandoffMessage → FSM 이벤트
  타임아웃/에러는 ERROR 이벤트로 변환해 FSM 에 위임

CLI:
- rails run <project> [-r requirements] [-c config.yaml] [--mock]

Tests (16 신규, 57 total pass):
- HandoffMessage discriminated union 검증
- MockTransport 기본/오버라이드 시나리오
- Discord marker encode/decode round-trip
- DiscordTransport with fake poster
- Config loader YAML + 환경변수 interpolation

검증: tsc --noEmit ✓ | vitest 57/57 ✓ | build ✓ | rails run --help ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:34:28 +09:00
parent eb63428174
commit fcd2e56129
13 changed files with 1051 additions and 1 deletions

View File

@@ -19,7 +19,7 @@
| 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:완료 [PR#1] |
| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:완료 [PR#2] |
| 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:완료 [PR#3] |
| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:TODO |
| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:WIP |
| 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:TODO |
| 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:TODO |
| 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:TODO |

44
rails.config.example.yaml Normal file
View File

@@ -0,0 +1,44 @@
# hanarang-rails sample configuration.
# Copy to rails.config.yaml and tune for your environment.
pipeline:
stages:
- plan
- implement
- review
- deploy
agents:
plan:
role: plan
displayName: Planner
transport: mock # or discord, local
channelId: "" # discord channel id for this agent
timeoutMs: 30000
implement:
role: implement
displayName: Generator
transport: mock
channelId: ""
timeoutMs: 60000
review:
role: review
displayName: Evaluator
transport: mock
channelId: ""
timeoutMs: 30000
deploy:
role: deploy
displayName: Deploy
transport: mock
channelId: ""
timeoutMs: 30000
discord:
enabled: false
railsToken: ${RAILS_DISCORD_TOKEN}
guildId: ${DISCORD_GUILD_ID}
pipelineChannelId: ${DISCORD_PIPELINE_CHANNEL_ID}

View File

@@ -16,6 +16,7 @@ const main = defineCommand({
"skill-trace": () =>
import("./skill-trace.js").then((m) => m.default),
contract: () => import("./contract.js").then((m) => m.default),
run: () => import("./run.js").then((m) => m.default),
},
});

85
src/cli/run.ts Normal file
View File

@@ -0,0 +1,85 @@
import { defineCommand } from "citty";
import { loadEnv } from "../env.js";
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 { disconnectPrisma } from "../orchestrator/persist.js";
export default defineCommand({
meta: {
name: "run",
description: "Run a pipeline end-to-end through configured transports",
},
args: {
project: {
type: "positional",
description: "Project name",
required: true,
},
requirements: {
type: "string",
alias: "r",
description: "Task description",
default: "",
},
config: {
type: "string",
alias: "c",
description: "Path to rails.config.yaml",
default: "",
},
mock: {
type: "boolean",
description: "Force mock transport for all stages",
default: false,
},
},
async run({ args }) {
loadEnv();
try {
const config = await loadConfig(args.config || undefined);
const transports = new Map<string, SisterTransport>();
if (args.mock) {
const mock = new MockTransport();
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);
}
}
}
const result = await runPipeline({
projectName: args.project,
requirements: args.requirements ?? "",
config,
transports,
});
console.log(`Pipeline: ${result.pipelineId}`);
console.log(`Final state: ${result.finalState}`);
console.log(`Transitions: ${result.transitions}`);
process.exitCode = result.finalState === "done" ? 0 : 1;
} finally {
await disconnectPrisma();
}
},
});

57
src/config/loader.ts Normal file
View File

@@ -0,0 +1,57 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parse as parseYaml } from "yaml";
import { RailsConfig, DEFAULT_CONFIG } from "./schema.js";
import type { RailsConfig as Config } from "./schema.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "config-loader" });
/**
* Resolve ${VAR_NAME} patterns in string values against process.env.
* Returns the original string if no variable reference.
*/
function interpolate(value: unknown, env: Record<string, string>): unknown {
if (typeof value === "string") {
return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, name: string) => {
return env[name] ?? "";
});
}
if (Array.isArray(value)) {
return value.map((v) => interpolate(v, env));
}
if (value && typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
result[k] = interpolate(v, env);
}
return result;
}
return value;
}
export async function loadConfig(
configPath?: string,
env: Record<string, string> = process.env as Record<string, string>,
): Promise<Config> {
if (!configPath) {
log.info("No config file specified, using defaults");
return DEFAULT_CONFIG;
}
const fullPath = resolve(configPath);
try {
const raw = await readFile(fullPath, "utf8");
const parsed = parseYaml(raw) as unknown;
const interpolated = interpolate(parsed, env);
const config = RailsConfig.parse(interpolated);
log.info({ path: fullPath }, "Config loaded");
return config;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
log.warn({ path: fullPath }, "Config file not found, using defaults");
return DEFAULT_CONFIG;
}
throw err;
}
}

46
src/config/schema.ts Normal file
View File

@@ -0,0 +1,46 @@
import { z } from "zod";
export const TransportMode = z.enum(["discord", "mock", "local"]);
export type TransportMode = z.infer<typeof TransportMode>;
export const AgentConfig = z.object({
role: z.string().min(1),
displayName: z.string().default(""),
transport: TransportMode.default("mock"),
channelId: z.string().default(""),
timeoutMs: z.number().int().positive().default(30_000),
});
export type AgentConfig = z.infer<typeof AgentConfig>;
export const DiscordConfig = z.object({
enabled: z.boolean().default(false),
railsToken: z.string().default(""),
guildId: z.string().default(""),
pipelineChannelId: z.string().default(""),
});
export type DiscordConfig = z.infer<typeof DiscordConfig>;
export const PipelineConfig = z.object({
stages: z
.array(z.enum(["plan", "implement", "review", "deploy"]))
.default(["plan", "implement", "review", "deploy"]),
});
export type PipelineConfig = z.infer<typeof PipelineConfig>;
export const RailsConfig = z.object({
pipeline: PipelineConfig.default({}),
agents: z.record(z.string(), AgentConfig).default({}),
discord: DiscordConfig.default({}),
});
export type RailsConfig = z.infer<typeof RailsConfig>;
export const DEFAULT_CONFIG: RailsConfig = RailsConfig.parse({
pipeline: { stages: ["plan", "implement", "review", "deploy"] },
agents: {
plan: { role: "plan", displayName: "Planner", transport: "mock" },
implement: { role: "implement", displayName: "Generator", transport: "mock" },
review: { role: "review", displayName: "Evaluator", transport: "mock" },
deploy: { role: "deploy", displayName: "Deploy", transport: "mock" },
},
discord: { enabled: false },
});

View File

@@ -0,0 +1,120 @@
import type {
SisterTransport,
HealthStatus,
} from "./transport.js";
import {
HandoffMessage,
type InvokeRequest,
} from "./message.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "discord-transport" });
export interface DiscordTransportOptions {
token: string;
guildId: string;
channelId: string;
timeoutMs?: number;
/**
* Dependency-injected message poster. Real usage passes a discord.js client;
* tests pass a fake. Rails invokes this to put the request marker into
* the agent channel and waits for a reply marker.
*/
poster: DiscordPoster;
}
export interface DiscordPoster {
postMessage(channelId: string, content: string): Promise<string>;
waitForResult(opts: {
channelId: string;
pipelineId: string;
stage: string;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<string>;
close(): Promise<void>;
}
/**
* Encode an invoke request as a marker block that the agent bot can parse
* without LLM interpretation.
*/
export function encodeInvokeMarker(req: InvokeRequest): string {
const json = JSON.stringify(req);
return (
"<!-- rails:invoke v1 -->\n" +
"```json\n" +
json +
"\n```\n" +
"<!-- /rails:invoke -->"
);
}
/**
* Extract a result marker from a message body.
* Returns the parsed HandoffMessage or throws on invalid payload.
*/
export function decodeResultMarker(body: string): HandoffMessage {
const match = body.match(
/<!--\s*rails:result\s+v1\s*-->\s*```json\s*([\s\S]*?)```\s*<!--\s*\/rails:result\s*-->/,
);
if (!match?.[1]) {
throw new Error("No rails:result marker found in message");
}
const json = match[1].trim();
const data = JSON.parse(json) as unknown;
return HandoffMessage.parse(data);
}
export class DiscordTransport implements SisterTransport {
readonly name = "discord";
private readonly opts: Required<Omit<DiscordTransportOptions, "poster">> & {
poster: DiscordPoster;
};
constructor(opts: DiscordTransportOptions) {
this.opts = {
token: opts.token,
guildId: opts.guildId,
channelId: opts.channelId,
timeoutMs: opts.timeoutMs ?? 30_000,
poster: opts.poster,
};
}
async invoke(
req: InvokeRequest,
signal?: AbortSignal,
): Promise<HandoffMessage> {
const marker = encodeInvokeMarker(req);
const content = marker + "\n\n" + this.humanPreamble(req);
log.info(
{ stage: req.stage, pipelineId: req.pipelineId },
"Dispatching invoke via discord",
);
await this.opts.poster.postMessage(this.opts.channelId, content);
const replyBody = await this.opts.poster.waitForResult({
channelId: this.opts.channelId,
pipelineId: req.pipelineId,
stage: req.stage,
timeoutMs: req.timeoutMs || this.opts.timeoutMs,
...(signal && { signal }),
});
return decodeResultMarker(replyBody);
}
async health(): Promise<HealthStatus> {
return { alive: true, latencyMs: 0 };
}
async close(): Promise<void> {
await this.opts.poster.close();
}
private humanPreamble(req: InvokeRequest): string {
return `📋 Task dispatched — stage: **${req.stage}** | pipeline: \`${req.pipelineId.slice(0, 8)}\` | timeout: ${req.timeoutMs}ms\n${req.task.title}`;
}
}

95
src/handoff/message.ts Normal file
View File

@@ -0,0 +1,95 @@
import { z } from "zod";
/**
* HandoffMessage — the structured result returned by an agent invocation.
* Each stage has its own payload shape.
*
* Rails will always parse agent responses through this discriminated union;
* any response that fails validation is treated as an ERROR event.
*/
export const PlanHandoffPayload = z.object({
planDir: z.string(),
sprintId: z.string(),
contractId: z.string().default(""),
});
export const ImplementHandoffPayload = z.object({
branch: z.string(),
commits: z.array(z.string()),
workdir: z.string().default(""),
selfTestReport: z.record(z.unknown()).default({}),
});
export const ReviewIssueLite = z.object({
severity: z.enum(["critical", "major", "minor", "recommendation"]),
message: z.string(),
file: z.string().optional(),
line: z.number().optional(),
});
export const ReviewHandoffPayload = z.object({
artifactPath: z.string().default(""),
checklistResults: z
.array(
z.object({
id: z.string(),
passed: z.boolean(),
note: z.string().default(""),
}),
)
.default([]),
issues: z.array(ReviewIssueLite).default([]),
});
export const DeployHandoffPayload = z.object({
deployArtifactPath: z.string().default(""),
projectType: z.string().default(""),
verificationResults: z.record(z.unknown()).default({}),
});
export const HandoffMessage = z.discriminatedUnion("stage", [
z.object({
stage: z.literal("plan"),
verdict: z.enum(["PLAN_READY", "ABORT"]),
payload: PlanHandoffPayload.optional(),
abortReason: z.string().default(""),
}),
z.object({
stage: z.literal("implement"),
verdict: z.enum(["IMPL_DONE", "ERROR"]),
payload: ImplementHandoffPayload.optional(),
errorReason: z.string().default(""),
}),
z.object({
stage: z.literal("review"),
verdict: z.enum(["APPROVE", "REQUEST_CHANGES", "ABORT"]),
payload: ReviewHandoffPayload.optional(),
abortReason: z.string().default(""),
}),
z.object({
stage: z.literal("deploy"),
verdict: z.enum(["DEPLOY_DONE", "DEPLOY_FAILED"]),
payload: DeployHandoffPayload.optional(),
errorReason: z.string().default(""),
}),
]);
export type HandoffMessage = z.infer<typeof HandoffMessage>;
export const InvokeRequest = z.object({
pipelineId: z.string(),
contractId: z.string().default(""),
stage: z.enum(["plan", "implement", "review", "deploy"]),
role: z.string(),
sprintId: z.string().default(""),
task: z.object({
title: z.string(),
description: z.string().default(""),
workdir: z.string().default(""),
}),
timeoutMs: z.number().int().positive().default(30_000),
structuredOutput: z.literal(true).default(true),
});
export type InvokeRequest = z.infer<typeof InvokeRequest>;

View File

@@ -0,0 +1,100 @@
import type {
SisterTransport,
HealthStatus,
} from "./transport.js";
import type { HandoffMessage, InvokeRequest } from "./message.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "mock-transport" });
/**
* MockTransport — deterministic fake transport for testing and local dev.
*
* By default it returns successful HandoffMessages for each stage:
* plan → PLAN_READY
* implement → IMPL_DONE
* review → APPROVE
* deploy → DEPLOY_DONE
*
* Scenarios can be overridden per pipelineId or per stage via constructor.
*/
export class MockTransport implements SisterTransport {
readonly name = "mock";
private scenarios: Map<string, HandoffMessage>;
constructor(overrides: Record<string, HandoffMessage> = {}) {
this.scenarios = new Map(Object.entries(overrides));
}
setScenario(key: string, message: HandoffMessage): void {
this.scenarios.set(key, message);
}
async invoke(req: InvokeRequest): Promise<HandoffMessage> {
const key = `${req.pipelineId}:${req.stage}`;
const override = this.scenarios.get(key) ?? this.scenarios.get(req.stage);
if (override) {
log.debug({ stage: req.stage, key }, "Mock scenario override");
return override;
}
return defaultSuccess(req);
}
async health(): Promise<HealthStatus> {
return { alive: true, latencyMs: 1 };
}
async close(): Promise<void> {
/* noop */
}
}
function defaultSuccess(req: InvokeRequest): HandoffMessage {
switch (req.stage) {
case "plan":
return {
stage: "plan",
verdict: "PLAN_READY",
payload: {
planDir: "/tmp/mock-plans",
sprintId: req.sprintId || "SPRINT-MOCK",
contractId: req.contractId || "",
},
abortReason: "",
};
case "implement":
return {
stage: "implement",
verdict: "IMPL_DONE",
payload: {
branch: "feature/mock",
commits: ["mockc01"],
workdir: req.task.workdir || "/tmp",
selfTestReport: { typecheck: "pass", tests: "pass" },
},
errorReason: "",
};
case "review":
return {
stage: "review",
verdict: "APPROVE",
payload: {
artifactPath: "/tmp/mock-review.json",
checklistResults: [],
issues: [],
},
abortReason: "",
};
case "deploy":
return {
stage: "deploy",
verdict: "DEPLOY_DONE",
payload: {
deployArtifactPath: "/tmp/mock-deploy.json",
projectType: "mock",
verificationResults: {},
},
errorReason: "",
};
}
}

14
src/handoff/transport.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { HandoffMessage, InvokeRequest } from "./message.js";
export interface HealthStatus {
alive: boolean;
latencyMs: number;
message?: string;
}
export interface SisterTransport {
readonly name: string;
invoke(req: InvokeRequest, signal?: AbortSignal): Promise<HandoffMessage>;
health(role: string): Promise<HealthStatus>;
close(): Promise<void>;
}

200
src/orchestrator/runner.ts Normal file
View File

@@ -0,0 +1,200 @@
import { ulid } from "ulid";
import { sendEvent, createPipeline } 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";
import type { RailsConfig } from "../config/schema.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "runner" });
export interface RunOptions {
projectName: string;
requirements: string;
config: RailsConfig;
transports: Map<string, SisterTransport>;
signal?: AbortSignal;
}
export interface RunResult {
pipelineId: string;
finalState: PipelineState;
transitions: number;
}
/**
* Run an end-to-end pipeline using the configured transports.
* Each stage invokes the corresponding agent and feeds the result back
* 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,
);
log.info({ pipelineId, project: opts.projectName }, "Pipeline run started");
// REQUEST event — enters planning
let result = await sendEvent(pipelineId, {
type: "REQUEST",
projectName: opts.projectName,
requirements: opts.requirements,
});
let transitions = 1;
const TERMINAL: PipelineState[] = ["done", "escalated", "aborted"];
while (!TERMINAL.includes(result.state)) {
if (opts.signal?.aborted) {
result = await sendEvent(pipelineId, {
type: "ABORT",
reason: "Aborted by caller",
});
transitions += 1;
break;
}
const stage = mapStateToStage(result.state);
if (!stage) {
log.warn({ state: result.state }, "Non-active state encountered, stopping");
break;
}
const transport = opts.transports.get(stage);
if (!transport) {
log.error({ stage }, "No transport configured for stage");
result = await sendEvent(pipelineId, {
type: "ERROR",
actor: stage,
reason: `No transport configured for stage: ${stage}`,
retryable: false,
});
transitions += 1;
continue;
}
const invokeReq: InvokeRequest = {
pipelineId,
contractId: result.context.contractPath ?? "",
stage,
role: opts.config.agents[stage]?.role ?? stage,
sprintId: result.context.currentSprintId ?? "",
task: {
title: opts.requirements || opts.projectName,
description: opts.requirements,
workdir: process.cwd(),
},
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 30_000,
structuredOutput: true,
};
try {
const handoff = await transport.invoke(invokeReq, opts.signal);
const event = handoffToEvent(handoff);
result = await sendEvent(pipelineId, event);
transitions += 1;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
log.error({ stage, reason }, "Transport invoke failed");
result = await sendEvent(pipelineId, {
type: "ERROR",
actor: stage,
reason,
retryable: true,
});
transitions += 1;
}
}
log.info(
{ pipelineId, finalState: result.state, transitions },
"Pipeline run finished",
);
void initialState; // referenced only for typecheck
return {
pipelineId,
finalState: result.state,
transitions,
};
}
function mapStateToStage(
state: PipelineState,
): "plan" | "implement" | "review" | "deploy" | null {
switch (state) {
case "planning":
return "plan";
case "implementing":
return "implement";
case "reviewing":
return "review";
case "deploying":
return "deploy";
default:
return null;
}
}
function handoffToEvent(h: HandoffMessage): PipelineEvent {
switch (h.stage) {
case "plan":
if (h.verdict === "PLAN_READY" && h.payload) {
return {
type: "PLAN_READY",
planDir: h.payload.planDir,
sprintId: h.payload.sprintId,
};
}
return {
type: "ABORT",
reason: h.abortReason || "Planner aborted",
};
case "implement":
if (h.verdict === "IMPL_DONE" && h.payload) {
return {
type: "IMPL_DONE",
branch: h.payload.branch,
commits: h.payload.commits,
};
}
return {
type: "ERROR",
actor: "implement",
reason: h.errorReason || "Implementation failed",
retryable: true,
};
case "review":
if (h.verdict === "APPROVE" && h.payload) {
return { type: "APPROVE", reviewArtifact: h.payload.artifactPath };
}
if (h.verdict === "REQUEST_CHANGES" && h.payload) {
return {
type: "REQUEST_CHANGES",
issues: h.payload.issues.map((i) => ({
severity: i.severity,
message: i.message,
...(i.file !== undefined && { file: i.file }),
...(i.line !== undefined && { line: i.line }),
})),
};
}
return { type: "ABORT", reason: h.abortReason || "Review aborted" };
case "deploy":
if (h.verdict === "DEPLOY_DONE" && h.payload) {
return {
type: "DEPLOY_DONE",
deployArtifact: h.payload.deployArtifactPath,
};
}
return {
type: "ERROR",
actor: "deploy",
reason: h.errorReason || "Deploy failed",
retryable: false,
};
}
}
void ulid; // satisfy unused import check if any

79
tests/config.test.ts Normal file
View File

@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadConfig } from "../src/config/loader.js";
import { DEFAULT_CONFIG } from "../src/config/schema.js";
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "rails-config-test-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("loadConfig", () => {
it("returns defaults when no path provided", async () => {
const cfg = await loadConfig();
expect(cfg).toEqual(DEFAULT_CONFIG);
});
it("returns defaults when file does not exist", async () => {
const cfg = await loadConfig(join(testDir, "missing.yaml"));
expect(cfg).toEqual(DEFAULT_CONFIG);
});
it("parses valid yaml config", async () => {
const yamlPath = join(testDir, "rails.config.yaml");
await writeFile(
yamlPath,
`
pipeline:
stages: [plan, implement, review]
agents:
plan:
role: plan
displayName: TestPlanner
transport: mock
timeoutMs: 15000
`,
);
const cfg = await loadConfig(yamlPath);
expect(cfg.pipeline.stages).toEqual(["plan", "implement", "review"]);
expect(cfg.agents["plan"]?.displayName).toBe("TestPlanner");
expect(cfg.agents["plan"]?.timeoutMs).toBe(15_000);
});
it("interpolates environment variables", async () => {
const yamlPath = join(testDir, "rails.config.yaml");
await writeFile(
yamlPath,
`
discord:
enabled: true
railsToken: \${MY_TEST_TOKEN}
guildId: fixed-guild
`,
);
const cfg = await loadConfig(yamlPath, { MY_TEST_TOKEN: "secret-abc" });
expect(cfg.discord.railsToken).toBe("secret-abc");
expect(cfg.discord.guildId).toBe("fixed-guild");
});
it("defaults missing env vars to empty string", async () => {
const yamlPath = join(testDir, "rails.config.yaml");
await writeFile(
yamlPath,
`
discord:
enabled: false
railsToken: \${MISSING_VAR}
`,
);
const cfg = await loadConfig(yamlPath, {});
expect(cfg.discord.railsToken).toBe("");
});
});

209
tests/handoff.test.ts Normal file
View File

@@ -0,0 +1,209 @@
import { describe, it, expect } from "vitest";
import { HandoffMessage, InvokeRequest } from "../src/handoff/message.js";
import { MockTransport } from "../src/handoff/mock-transport.js";
import {
encodeInvokeMarker,
decodeResultMarker,
DiscordTransport,
type DiscordPoster,
} from "../src/handoff/discord-transport.js";
describe("HandoffMessage schema", () => {
it("parses a valid plan result", () => {
const parsed = HandoffMessage.parse({
stage: "plan",
verdict: "PLAN_READY",
payload: { planDir: "/tmp", sprintId: "S1", contractId: "c1" },
abortReason: "",
});
expect(parsed.stage).toBe("plan");
if (parsed.stage === "plan") {
expect(parsed.verdict).toBe("PLAN_READY");
}
});
it("parses a review REQUEST_CHANGES with issues", () => {
const parsed = HandoffMessage.parse({
stage: "review",
verdict: "REQUEST_CHANGES",
payload: {
artifactPath: "/tmp/r.json",
checklistResults: [],
issues: [
{ severity: "major", message: "fix this", file: "src/a.ts", line: 10 },
],
},
abortReason: "",
});
expect(parsed.stage).toBe("review");
if (parsed.stage === "review" && parsed.payload) {
expect(parsed.payload.issues[0]!.severity).toBe("major");
}
});
it("rejects invalid stage", () => {
expect(() =>
HandoffMessage.parse({ stage: "bogus", verdict: "PLAN_READY" }),
).toThrow();
});
it("rejects invalid verdict for stage", () => {
expect(() =>
HandoffMessage.parse({ stage: "plan", verdict: "DEPLOY_DONE" }),
).toThrow();
});
});
describe("MockTransport", () => {
it("returns PLAN_READY for plan stage by default", async () => {
const t = new MockTransport();
const req = InvokeRequest.parse({
pipelineId: "01MOCK",
stage: "plan",
role: "plan",
sprintId: "S1",
task: { title: "test" },
});
const result = await t.invoke(req);
expect(result.stage).toBe("plan");
if (result.stage === "plan") {
expect(result.verdict).toBe("PLAN_READY");
}
});
it("applies per-pipeline scenario overrides", async () => {
const t = new MockTransport({
"01TEST:review": {
stage: "review",
verdict: "REQUEST_CHANGES",
payload: { artifactPath: "", checklistResults: [], issues: [] },
abortReason: "",
},
});
const req = InvokeRequest.parse({
pipelineId: "01TEST",
stage: "review",
role: "review",
task: { title: "test" },
});
const result = await t.invoke(req);
if (result.stage === "review") {
expect(result.verdict).toBe("REQUEST_CHANGES");
}
});
it("applies stage-level overrides", async () => {
const t = new MockTransport();
t.setScenario("implement", {
stage: "implement",
verdict: "ERROR",
errorReason: "mock fail",
});
const req = InvokeRequest.parse({
pipelineId: "01ANY",
stage: "implement",
role: "implement",
task: { title: "test" },
});
const result = await t.invoke(req);
if (result.stage === "implement") {
expect(result.verdict).toBe("ERROR");
expect(result.errorReason).toBe("mock fail");
}
});
});
describe("Discord marker encoding", () => {
it("encode → decode round-trip (result marker)", () => {
const original: HandoffMessage = {
stage: "implement",
verdict: "IMPL_DONE",
payload: {
branch: "feature/x",
commits: ["abc1234"],
workdir: "/tmp",
selfTestReport: { tests: "pass" },
},
errorReason: "",
};
const body =
"some natural language before\n\n" +
"<!-- rails:result v1 -->\n" +
"```json\n" +
JSON.stringify(original) +
"\n```\n" +
"<!-- /rails:result -->\n\n" +
"constructor note";
const parsed = decodeResultMarker(body);
expect(parsed.stage).toBe("implement");
if (parsed.stage === "implement" && parsed.payload) {
expect(parsed.payload.branch).toBe("feature/x");
expect(parsed.payload.commits).toEqual(["abc1234"]);
}
});
it("encodeInvokeMarker produces a parseable block", () => {
const req = InvokeRequest.parse({
pipelineId: "01INV",
stage: "plan",
role: "plan",
task: { title: "go" },
});
const marker = encodeInvokeMarker(req);
expect(marker).toContain("rails:invoke");
expect(marker).toContain("01INV");
expect(marker).toContain('"stage":"plan"');
});
it("decodeResultMarker throws when no marker present", () => {
expect(() => decodeResultMarker("no marker here")).toThrow(/No rails:result/);
});
});
describe("DiscordTransport (fake poster)", () => {
it("posts invoke and parses result", async () => {
const fakePoster: DiscordPoster = {
async postMessage(_channelId, _content) {
return "msg-123";
},
async waitForResult() {
const result: HandoffMessage = {
stage: "plan",
verdict: "PLAN_READY",
payload: {
planDir: "/tmp/plans",
sprintId: "S1",
contractId: "c1",
},
abortReason: "",
};
return (
"<!-- rails:result v1 -->\n" +
"```json\n" +
JSON.stringify(result) +
"\n```\n" +
"<!-- /rails:result -->"
);
},
async close() {},
};
const t = new DiscordTransport({
token: "fake",
guildId: "g1",
channelId: "c1",
poster: fakePoster,
});
const req = InvokeRequest.parse({
pipelineId: "01DC",
stage: "plan",
role: "plan",
task: { title: "test" },
});
const result = await t.invoke(req);
if (result.stage === "plan") {
expect(result.verdict).toBe("PLAN_READY");
}
});
});