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:
120
sister-agent/src/discord-notify.ts
Normal file
120
sister-agent/src/discord-notify.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user