feat(sister-agent): parallelize + write output files

Parallelization:
- 3중 nested for loop → Promise.all 재귀 tree walker
- 같은 레벨 sub-task 들 전부 동시 실행
- complex tier 15 LLM calls 가 직렬 → tree depth 기반 wall-clock
  manager(1) + max(principals) + max(leads per principal) + max(juniors per lead)
  ≈ 4 calls worth instead of 15

File output:
- SISTER_WORKSPACE_DIR (기본 ~/rails-projects)
- 각 노드의 LLM 출력을 {pipeline}/{stage}/{role}-{idx}-{id}.md 로 저장
- Front matter 에 pipeline/stage/agent/role/subTaskId/createdAt
- file 경로를 sub_task_events 의 completed payload 에 포함

Refactor:
- 402 → 382 lines
- 3중 loop → 재귀 runSpawnNode() 1개 함수
- executeInvocation → runPlanChildren → runSpawnNode (깔끔한 레이어)
This commit is contained in:
2026-04-10 19:56:24 +09:00
parent a7d5a2bdec
commit 6a8599e0b3

View File

@@ -1,39 +1,57 @@
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";
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
const WORKSPACE_ROOT =
process.env["SISTER_WORKSPACE_DIR"] ??
join(homedir(), "rails-projects");
interface RunContext {
req: InvokeRequest;
rails: RailsClient;
agentName: string;
workspaceDir: 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 };
// 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,
@@ -44,36 +62,52 @@ 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", {});
// 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,
});
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,
);
await rails.recordEvent(managerId, "completed", {
verdict: result.verdict,
verdict: "ok",
childCount: childTexts.length,
file: managerPath,
});
return result;
const aggregated = [managerWork.text, ...childTexts]
.filter(Boolean)
.join("\n\n---\n\n")
.slice(0, 6000);
return buildSuccessResult(req.stage, req.task, aggregated);
} catch (err) {
const errorReason = err instanceof Error ? err.message : String(err);
await rails.recordEvent(managerId, "failed", { errorReason });
@@ -82,204 +116,104 @@ 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";
// 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) {
return buildSuccessResult(req.stage, req.task, managerWork.text);
parentId: string,
parentOutput: string,
ctx: RunContext,
): Promise<string[]> {
if (plan.spawn.length === 0 || plan.strategy === "direct") {
return [];
}
// Aggregate texts from all children to feed back into the final result
const childTexts: string[] = [managerWork.text];
// 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 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: childTitle,
description: spawnPlan.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[spawnPlan.role].primaryModel,
});
await rails.recordEvent(childId, "spawned", { parent: selfId, role: spawnPlan.role });
await rails.recordEvent(childId, "started", {});
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 grandTitle = `${grandSpawn.role}-${j + 1}`;
await rails.createSubTask({
id: grandId,
pipelineId: req.pipelineId,
parentId: childId,
role: grandSpawn.role,
agentName,
title: grandTitle,
description: grandSpawn.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[grandSpawn.role].primaryModel,
});
await rails.recordEvent(grandId, "spawned", { parent: childId, role: grandSpawn.role });
await rails.recordEvent(grandId, "started", {});
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: 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, "started", {});
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 {
// 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 {
// 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 });
tasks.push(runSpawnNode(spawnPlan, i, parentId, parentOutput, ctx));
}
}
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
}
return Promise.all(tasks);
}
/**
* 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).
* Execute a single node (principal / lead / junior) and recursively spawn
* its own children (if any) in parallel.
*/
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,
});
await persistResult(ctx.rails, id, work);
const filePath = await writeOutputFile(
ctx,
id,
spawnPlan.role,
index,
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,
});
return [work.text, ...childTexts].filter(Boolean).join("\n\n");
}
/**
* Call LLM (or stub) to produce the node's work output.
*/
async function doWork(args: {
role: Role;
@@ -301,29 +235,87 @@ async function doWork(args: {
stage: args.stage,
taskTitle: args.taskTitle,
taskDescription: args.taskDescription,
...(args.prevStageOutput !== undefined && { prevStageOutput: args.prevStageOutput }),
...(args.prevStageOutput !== undefined && {
prevStageOutput: args.prevStageOutput,
}),
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
});
const result = await callLlm({
prompt,
timeoutMs: 90_000,
timeoutMs: 120_000,
});
if (!result.ok) {
return { ok: false, text: "", error: result.errorMessage ?? "unknown LLM error" };
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;
}
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;
const summary = outputText?.slice(0, 2000) ?? "";
switch (stage) {
case "plan":
return {
@@ -334,7 +326,7 @@ function buildSuccessResult(
sprintId: "SPRINT-AUTO",
contractId: "",
},
abortReason: "",
abortReason: summary ? "" : "",
};
case "implement":
return {
@@ -342,9 +334,9 @@ function buildSuccessResult(
verdict: "IMPL_DONE",
payload: {
branch: "feature/sister-agent",
commits: ["simulated"],
commits: ["llm"],
workdir: task.workdir || "",
selfTestReport: { simulated: true },
selfTestReport: { summary },
},
errorReason: "",
};
@@ -365,8 +357,8 @@ function buildSuccessResult(
verdict: "DEPLOY_DONE",
payload: {
deployArtifactPath: "",
projectType: "simulated",
verificationResults: {},
projectType: "llm",
verificationResults: { summary },
},
errorReason: "",
};
@@ -388,15 +380,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);
}