feat(notify): 각 자매가 본인 봇 identity 로 디스코드 stage 업데이트 직접 포스트

자기야 요청: 지금은 하랑이가 모든 stage 를 대신 말해서 어색함. 각 자매가
자기 작업할 때 본인 목소리로 짧게 디스코드에 보고해야 함.

핵심 발견: OpenClaw 가 sessions.json (`agent:main:discord:channel:<id>`) 의
키에 활성 채널 ID 를 박아 놓음. updatedAt 으로 정렬하면 가장 최근에 자기야
가 말한 채널을 자동 추출 가능. bash tool 환경변수에는 채널 ID 가 안 들어
있어서 이 우회가 필요했음.

## rails 쪽 (notifyChannelId 전파)

- handoff/message.ts: InvokeRequest schema 에 notifyChannelId optional 추가
- src/orchestrator/runner.ts: RunOptions 에 notifyChannelId 받아 InvokeRequest
  에 그대로 propagate
- src/server/http.ts: StartRequest schema + /pipelines/start 와
  /pipelines/start-async 둘 다 notifyChannelId 받아 runPipeline 에 전달
- sister-agent/src/types.ts: InvokeRequest 에도 같은 필드 추가

## sister-agent 쪽 (각자 본인 봇으로 포스트)

- sister-agent/src/discord-notify.ts: 신설. local openclaw CLI 를 spawn 으로
  호출해 본인 봇 identity 로 메시지 발송 (best-effort, 실패해도 파이프라인
  안 막음). 자매별 페르소나 메시지 템플릿 (renderStageStart/End) 포함
- sister-agent/src/spawn.ts: executeInvocation 시작과 manager 완료 시점에
  notifyDiscord 호출. notifyChannelId 가 없으면 no-op

## skill wrapper

- ~/.openclaw/skills/hanarang-rails/scripts/rails-start-and-watch.sh:
  sessions.json 에서 가장 최근 discord 채널 ID 자동 추출 →
  /pipelines/start-async 본문에 notifyChannelId 포함. 중간 stage echo 제거 —
  이제 각 자매가 본인 봇으로 직접 포스트하므로 하랑이는 시작 banner 와 최종
  보고만 출력
- SKILL.md "스크립트 출력 처리" 섹션 새 흐름에 맞게 업데이트

## 검증

- 사전 검증: nara LXC 에서 `openclaw message send --channel discord --target
  channel:<id>` 호출이 정상 작동 (Message ID 받아옴), 자기야가 디스코드에서
  나랑이 봇 메시지 확인
- E2E 테스트: 다음 단계에서 실제 디스코드 호출로 최종 검증
This commit is contained in:
2026-04-11 00:02:29 +09:00
parent 4a77f43a32
commit 809d5b94c4
6 changed files with 186 additions and 2 deletions

View File

@@ -0,0 +1,120 @@
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;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
resolveFn({ ok: false, error: `openclaw timeout` });
}, opts.timeoutMs ?? 8000);
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 templates for stage start / end messages.
* Keep them short and sister-flavored. Each entry maps stage → {start, end}
* where end is a function that takes a one-line summary.
*/
export interface StageMessageContext {
agentName: string;
stage: "plan" | "implement" | "review" | "deploy";
taskTitle: string;
childCount?: number;
filesProduced?: number;
}
export function renderStageStart(ctx: StageMessageContext): string {
const title = ctx.taskTitle.slice(0, 80);
switch (ctx.agentName) {
case "harang":
return `📋 내가 기획 시작할게 — *${title}*`;
case "narang":
return `🔨 구현 시작할게 — *${title}*`;
case "darang":
return `🔍 리뷰 시작할게 — *${title}*`;
case "erang":
return `🚀 배포 검증 시작할게 — *${title}*`;
default:
return `▶️ ${ctx.stage} 시작 — *${title}*`;
}
}
export function renderStageEnd(ctx: StageMessageContext): string {
const tail =
ctx.filesProduced && ctx.filesProduced > 0
? ` (산출물 ${ctx.filesProduced}개)`
: "";
switch (ctx.agentName) {
case "harang":
return `📋 기획 끝났어. 나랑이한테 넘길게${tail}`;
case "narang":
return `🔨 구현 끝났어${tail}. 다랑이한테 리뷰 넘길게`;
case "darang":
return `🔍 리뷰 통과! 이랑이한테 배포 넘길게${tail}`;
case "erang":
return `🚀 배포 검증 완료${tail}`;
default:
return `${ctx.stage} 완료${tail}`;
}
}

View File

@@ -19,6 +19,11 @@ 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,
} 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
@@ -98,6 +103,20 @@ export async function executeInvocation(
});
await rails.recordEvent(managerId, "started", {});
// Discord stage-start ping (best-effort, fire-and-forget)
if (req.notifyChannelId) {
void notifyDiscord({
channelId: req.notifyChannelId,
message: renderStageStart({
agentName,
stage: req.stage,
taskTitle: req.task.title,
}),
}).catch(() => {
/* swallow — discord notify must never break the pipeline */
});
}
try {
const managerWork = await doWork({
role: "manager",
@@ -156,6 +175,22 @@ export async function executeInvocation(
...(deployUrl && { deployUrl }),
});
// Discord stage-end ping (best-effort)
if (req.notifyChannelId) {
void notifyDiscord({
channelId: req.notifyChannelId,
message: renderStageEnd({
agentName,
stage: req.stage,
taskTitle: req.task.title,
childCount: childTexts.length,
filesProduced: ctx.producedFiles.length,
}),
}).catch(() => {
/* swallow */
});
}
const aggregated = [managerWork.text, ...childTexts]
.filter(Boolean)
.join("\n\n---\n\n")

View File

@@ -25,6 +25,12 @@ export const InvokeRequest = z.object({
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>;

View File

@@ -97,6 +97,13 @@ export const InvokeRequest = z.object({
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>;

View File

@@ -70,6 +70,12 @@ export interface RunOptions {
* 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 {
@@ -181,6 +187,7 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
priorStages,
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
structuredOutput: true,
notifyChannelId: opts.notifyChannelId ?? "",
};
const retryResult = await withRetry(

View File

@@ -34,6 +34,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({
@@ -90,13 +97,14 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
issues: parsed.error.issues,
});
}
const { project, requirements } = parsed.data;
const { project, requirements, notifyChannelId } = parsed.data;
const result = await runPipeline({
projectName: project,
requirements,
config,
transports,
...(notifyChannelId && { notifyChannelId }),
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
...(opts.notifier && { notifier: opts.notifier }),
});
@@ -121,7 +129,7 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
issues: parsed.error.issues,
});
}
const { project, requirements } = parsed.data;
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.
@@ -135,6 +143,7 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
pipelineId,
config,
transports,
...(notifyChannelId && { notifyChannelId }),
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
...(opts.notifier && { notifier: opts.notifier }),
});