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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user