fix(runner): default invoke timeout 30s→600s, retries 3→1 for LLM workloads

This commit is contained in:
2026-04-10 18:46:30 +09:00
parent 579137e4bf
commit 98540af98c
6 changed files with 371 additions and 45 deletions

View File

@@ -1 +1 @@
1775809049
1775814166

View File

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

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

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

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

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

View File

@@ -4,6 +4,10 @@ import { ROLES } from "./roles.js";
import { scoreComplexity, type ComplexityScore } from "./complexity.js";
import { planDecomposition, type DecompositionPlan } from "./planner.js";
import type { RailsClient } from "./rails-client.js";
import { callLlm } from "./llm.js";
import { buildPrompt } from "./prompts.js";
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
/**
* Execute an invocation using the hierarchical team strategy.
@@ -95,120 +99,231 @@ async function executeRole(
const hasChildren =
depth === 0 && plan.spawn.length > 0 && plan.strategy !== "direct";
// Manager executes its own decision/judgment first
const managerWork = await doWork({
role,
agentName,
stage: req.stage,
taskTitle: req.task.title,
taskDescription: req.task.description,
});
await persistResult(rails, selfId, managerWork);
if (!hasChildren) {
// Leaf execution — in this simulation we just produce a success result
await simulateWork(role);
return buildSuccessResult(req.stage, req.task);
return buildSuccessResult(req.stage, req.task, managerWork.text);
}
// Aggregate texts from all children to feed back into the final result
const childTexts: string[] = [managerWork.text];
// Spawn children per plan
for (const spawnPlan of plan.spawn) {
for (let i = 0; i < spawnPlan.count; i++) {
const childId = ulid();
const childRecord: SubTaskRecord = {
const childTitle = `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`;
await rails.createSubTask({
id: childId,
pipelineId: req.pipelineId,
parentId: selfId,
role: spawnPlan.role,
agentName,
title: `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`,
title: childTitle,
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, "spawned", { parent: selfId, role: spawnPlan.role });
await rails.recordEvent(childId, "started", {});
// Recursively spawn grandchildren if subBreakdown exists
let childWorkText = "";
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
// Principal-level: do its own assessment then spawn leads
const principalWork = await doWork({
role: spawnPlan.role,
agentName,
stage: req.stage,
taskTitle: childTitle,
taskDescription: spawnPlan.rationale,
parentTitle: req.task.title,
prevStageOutput: managerWork.text,
});
await persistResult(rails, childId, principalWork);
childWorkText = principalWork.text;
for (const grandSpawn of spawnPlan.subBreakdown) {
for (let j = 0; j < grandSpawn.count; j++) {
const grandId = ulid();
const grandRecord: SubTaskRecord = {
const grandTitle = `${grandSpawn.role}-${j + 1}`;
await rails.createSubTask({
id: grandId,
pipelineId: req.pipelineId,
parentId: childId,
role: grandSpawn.role,
agentName,
title: `${grandSpawn.role}-${j + 1}`,
title: grandTitle,
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, "spawned", { parent: childId, role: grandSpawn.role });
await rails.recordEvent(grandId, "started", {});
// Third-level (junior) grand-grandchildren
if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) {
// Lead does its own work then spawns juniors
const leadWork = await doWork({
role: grandSpawn.role,
agentName,
stage: req.stage,
taskTitle: grandTitle,
taskDescription: grandSpawn.rationale,
parentTitle: childTitle,
prevStageOutput: principalWork.text,
});
await persistResult(rails, grandId, leadWork);
for (const ggSpawn of grandSpawn.subBreakdown) {
for (let k = 0; k < ggSpawn.count; k++) {
const ggId = ulid();
const ggTitle = `${ggSpawn.role}-${k + 1}`;
await rails.createSubTask({
id: ggId,
pipelineId: req.pipelineId,
parentId: grandId,
role: ggSpawn.role,
agentName,
title: `${ggSpawn.role}-${k + 1}`,
title: ggTitle,
description: ggSpawn.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[ggSpawn.role].primaryModel,
});
await rails.recordEvent(ggId, "spawned", {
parent: grandId,
role: ggSpawn.role,
});
await rails.recordEvent(ggId, "spawned", { parent: grandId, role: ggSpawn.role });
await rails.recordEvent(ggId, "started", {});
await simulateWork(ggSpawn.role);
await rails.recordEvent(ggId, "completed", { ok: true });
const juniorWork = await doWork({
role: ggSpawn.role,
agentName,
stage: req.stage,
taskTitle: ggTitle,
taskDescription: ggSpawn.rationale,
parentTitle: grandTitle,
prevStageOutput: leadWork.text,
});
await persistResult(rails, ggId, juniorWork);
await rails.recordEvent(ggId, "completed", { ok: juniorWork.ok });
}
}
} else {
await simulateWork(grandSpawn.role);
// grandSpawn is a leaf (junior or lead acting alone)
const leafWork = await doWork({
role: grandSpawn.role,
agentName,
stage: req.stage,
taskTitle: grandTitle,
taskDescription: grandSpawn.rationale,
parentTitle: childTitle,
prevStageOutput: principalWork.text,
});
await persistResult(rails, grandId, leafWork);
}
await rails.recordEvent(grandId, "completed", { ok: true });
}
}
} else {
await simulateWork(spawnPlan.role);
// Direct child is leaf — execute work and store
const leafWork = await doWork({
role: spawnPlan.role,
agentName,
stage: req.stage,
taskTitle: childTitle,
taskDescription: spawnPlan.rationale,
parentTitle: req.task.title,
prevStageOutput: managerWork.text,
});
await persistResult(rails, childId, leafWork);
childWorkText = leafWork.text;
}
childTexts.push(childWorkText);
await rails.recordEvent(childId, "completed", { ok: true });
}
}
void complexity; // reserved for future LLM-based planning
return buildSuccessResult(req.stage, req.task);
void complexity;
return buildSuccessResult(
req.stage,
req.task,
childTexts.filter(Boolean).join("\n\n---\n\n").slice(0, 6000),
);
}
async function persistResult(
rails: RailsClient,
subTaskId: string,
work: { ok: boolean; text: string; error?: string },
): Promise<void> {
try {
await rails.patchSubTask(subTaskId, {
resultJson: JSON.stringify({ text: work.text, ok: work.ok }),
...(work.error && { errorReason: work.error }),
});
} catch {
// best-effort
}
}
/**
* Placeholder "work" — tiny delay per role so timeline looks realistic.
* Replace with real openclaw agent CLI or LLM SDK call.
* Run actual work for a node. Calls openclaw infer model run if RAILS_USE_REAL_LLM
* is enabled (default). Falls back to short sleep if disabled.
*
* Returns the LLM text output (or empty if simulation).
*/
async function simulateWork(role: Role): Promise<void> {
const delayByRole: Record<Role, number> = {
manager: 40,
principal: 60,
lead: 80,
junior: 100,
};
await new Promise((r) => setTimeout(r, delayByRole[role]));
async function doWork(args: {
role: Role;
agentName: string;
stage: InvokeRequest["stage"];
taskTitle: string;
taskDescription: string;
prevStageOutput?: string;
parentTitle?: string;
}): Promise<{ ok: boolean; text: string; error?: string }> {
if (!USE_REAL_LLM) {
await new Promise((r) => setTimeout(r, 80));
return { ok: true, text: "" };
}
const prompt = buildPrompt({
role: args.role,
agentName: args.agentName,
stage: args.stage,
taskTitle: args.taskTitle,
taskDescription: args.taskDescription,
...(args.prevStageOutput !== undefined && { prevStageOutput: args.prevStageOutput }),
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
});
const result = await callLlm({
prompt,
timeoutMs: 90_000,
});
if (!result.ok) {
return { ok: false, text: "", error: result.errorMessage ?? "unknown LLM error" };
}
return { ok: true, text: result.text };
}
function buildSuccessResult(
stage: InvokeRequest["stage"],
task: InvokeRequest["task"],
outputText?: string,
): HandoffMessage {
// Pack the LLM output into selfTestReport / verificationResults so the
// next stage can read it via the handoff payload.
void outputText;
switch (stage) {
case "plan":
return {

View File

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