Add spawn-runner agent system with GitHub Actions workflow

Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-11-24 23:17:24 +00:00
parent 211c843f5d
commit 887a1fe2b1
28 changed files with 154 additions and 338 deletions

23
.github/workflows/spawn-runner.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: 🧬 Lucidia Spawn Runner
on:
schedule:
- cron: "0 */6 * * *" # Every 6 hours
workflow_dispatch:
jobs:
spawn-runner:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Run Spawn Runner
run: node bot/spawn-runner.js

8
.gitignore vendored
View File

@@ -1,3 +1,11 @@
node_modules node_modules
coverage coverage
# Build artifacts
*.js
!bot/*.js
!scripts/*.js
# Generated agents directory
agents/

View File

@@ -1,3 +0,0 @@
export async function GET() {
return Response.json({ status: "ok" });
}

View File

@@ -1,5 +0,0 @@
import { getBuildInfo } from "../../../src/utils/buildInfo";
export async function GET() {
const info = getBuildInfo();
return Response.json({ version: info.version, commit: info.commit });
}

37
bot/spawn-runner.js Normal file
View File

@@ -0,0 +1,37 @@
const fs = require("fs");
const spawn = require("child_process").spawnSync;
const yaml = require("js-yaml");
const rules = yaml.load(fs.readFileSync("lucidia.spawn-rules.yml", "utf8"));
const signals = {
escalations_last_7_days: 12,
digest_count: 6,
average_blocked_pct: 25,
created_repos_last_72h: 8
};
function evaluateCondition(expr) {
const [key, operator] = Object.entries(expr)[0];
const value = parseFloat(operator.replace(/[^\d.]/g, ""));
const signal = signals[key];
if (!signal) return false;
if (operator.includes(">")) return signal > value;
if (operator.includes("<")) return signal < value;
return false;
}
function runSpawn(agentId, config) {
console.log(`🚀 Spawning new agent: ${agentId}`);
spawn("node", ["scripts/spawn-agent.js", agentId], { stdio: "inherit" });
if (config.ttl) {
console.log(`⏳ TTL set: ${config.ttl} (manual enforcement needed)`);
}
}
rules.spawn_rules.forEach((rule) => {
if (evaluateCondition(rule.if)) {
runSpawn(rule.then.spawn, rule.then.config);
}
});

View File

@@ -1,5 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { StatusPill } from "./StatusPill";
export function EnvCard({ env }) {
return (_jsxs("div", { className: "env-card", children: [_jsx("div", { className: "env-region", style: { textTransform: "uppercase", fontSize: "0.85rem" }, children: env.region }), _jsx("h2", { children: env.name }), _jsxs("div", { children: ["Env ID: ", env.id] }), _jsx(StatusPill, { status: env.status })] }));
}

View File

@@ -1,10 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
const statusConfig = {
healthy: { label: "Healthy", className: "status-pill status-pill--healthy" },
degraded: { label: "Degraded", className: "status-pill status-pill--degraded" },
down: { label: "Down", className: "status-pill status-pill--down" }
};
export function StatusPill({ status }) {
const config = statusConfig[status];
return _jsx("span", { className: config.className, children: config.label });
}

View File

@@ -1,18 +0,0 @@
const mockEnvironments = [
{ id: "env_1", name: "Development", region: "us-east-1", status: "healthy" },
{ id: "env_2", name: "Staging", region: "eu-west-1", status: "degraded" }
];
export async function getEnvironments() {
return mockEnvironments;
}
export async function getEnvById(id) {
return mockEnvironments.find((env) => env.id === id);
}
export async function getHealth() {
return { status: "ok", uptime: process.uptime() };
}
export async function getVersion() {
const version = process.env.APP_VERSION || "1.0.0";
const commit = process.env.APP_COMMIT || "unknown";
return { version, commit };
}

39
lucidia.spawn-rules.yml Normal file
View File

@@ -0,0 +1,39 @@
# 🧬 lucidia.spawn-rules.yml
# Spawn rules based on signals and emoji heatmaps
spawn_rules:
- name: "Escalation Surge Handler"
if:
escalations_last_7_days: ">10"
then:
spawn: "escalation-handler"
config:
priority: "high"
ttl: "72h"
- name: "Blocked Issue Resolver"
if:
average_blocked_pct: ">20"
then:
spawn: "blocked-resolver"
config:
priority: "medium"
ttl: "48h"
- name: "Repo Activity Monitor"
if:
created_repos_last_72h: ">5"
then:
spawn: "repo-watcher"
config:
priority: "low"
ttl: "24h"
- name: "Digest Overload Handler"
if:
digest_count: ">5"
then:
spawn: "digest-processor"
config:
priority: "medium"
ttl: "12h"

19
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"express": "^5.1.0", "express": "^5.1.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"js-yaml": "^4.1.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
@@ -2124,6 +2125,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -3328,6 +3335,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "27.2.0", "version": "27.2.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz",

View File

@@ -16,6 +16,7 @@
"express": "^5.1.0", "express": "^5.1.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"js-yaml": "^4.1.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"

27
scripts/spawn-agent.js Normal file
View File

@@ -0,0 +1,27 @@
const fs = require("fs");
const path = require("path");
const agentId = process.argv[2];
if (!agentId) {
console.error("❌ No agent ID provided.");
process.exit(1);
}
console.log(`🧬 Scaffolding agent: ${agentId}`);
const agentDir = path.join(__dirname, "..", "agents", agentId);
if (!fs.existsSync(agentDir)) {
fs.mkdirSync(agentDir, { recursive: true });
}
const indexFile = path.join(agentDir, "index.js");
if (!fs.existsSync(indexFile)) {
fs.writeFileSync(
indexFile,
`// Agent: ${agentId}\nconsole.log("🤖 Agent ${agentId} initialized");\n`
);
console.log(`✅ Agent scaffolded at: ${agentDir}`);
} else {
console.log(`⚠️ Agent ${agentId} already exists.`);
}

View File

@@ -1,8 +0,0 @@
import express from "express";
import { createMetaRouter } from "./routes/meta";
export function createApp() {
const app = express();
app.use(express.json());
app.use("/internal", createMetaRouter());
return app;
}

View File

@@ -1,23 +0,0 @@
import cron from "node-cron";
import { Queue } from "bullmq";
export function buildHeartbeatQueue(connection = { host: "localhost", port: 6379 }) {
return new Queue("heartbeat", { connection });
}
let defaultQueue = null;
function getDefaultQueue() {
if (!defaultQueue) {
defaultQueue = buildHeartbeatQueue();
}
return defaultQueue;
}
export async function enqueueHeartbeat(queue = getDefaultQueue()) {
const payload = { ts: Date.now() };
await queue.add("heartbeat", payload);
return payload;
}
export function startHeartbeatScheduler(queue = getDefaultQueue()) {
const task = cron.schedule("*/5 * * * *", () => {
enqueueHeartbeat(queue);
});
return task;
}

View File

@@ -1,23 +0,0 @@
import Fastify from "fastify";
import { getBuildInfo } from "./utils/buildInfo";
export async function createServer() {
const server = Fastify({ logger: true });
server.get("/health", async () => ({ status: "ok" }));
server.get("/version", async () => {
const info = getBuildInfo();
return { version: info.version, commit: info.commit };
});
return server;
}
if (require.main === module) {
const port = Number(process.env.PORT || 3000);
createServer()
.then((server) => server.listen({ port, host: "0.0.0.0" }))
.then((address) => {
console.log(`Server listening at ${address}`);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -1,11 +0,0 @@
import { Worker } from "bullmq";
export function registerSampleJobProcessor(connection = { host: "localhost", port: 6379 }) {
const worker = new Worker("sample", async (job) => {
console.log(`Processing job ${job.id}`);
return job.data;
}, { connection });
worker.on("failed", (job, err) => {
console.error(`Job ${job?.id} failed`, err);
});
return worker;
}

View File

@@ -1,13 +0,0 @@
import { Router } from "express";
import { getBuildInfo } from "../utils/buildInfo";
export function createMetaRouter() {
const router = Router();
router.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
router.get("/version", (_req, res) => {
const info = getBuildInfo();
res.json({ version: info.version, commit: info.commit });
});
return router;
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,15 +0,0 @@
import * as childProcess from "child_process";
export function readGitCommit() {
try {
return childProcess.execSync("git rev-parse HEAD", { stdio: "pipe" }).toString().trim();
}
catch {
return undefined;
}
}
export function getBuildInfo(gitReader = readGitCommit) {
const version = process.env.APP_VERSION || "1.0.0";
const commit = process.env.APP_COMMIT || gitReader() || "unknown";
const buildTime = new Date().toISOString();
return { version, commit, buildTime };
}

View File

@@ -1,36 +0,0 @@
import request from "supertest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createApp } from "../src/app";
import { createServer } from "../src/index";
vi.mock("../src/utils/buildInfo", () => ({
getBuildInfo: () => ({ version: "test-version", commit: "test-commit", buildTime: "now" })
}));
describe("Express internal routes", () => {
const app = createApp();
it("returns health", async () => {
const response = await request(app).get("/internal/health");
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: "ok" });
});
it("returns version", async () => {
const response = await request(app).get("/internal/version");
expect(response.status).toBe(200);
expect(response.body).toEqual({ version: "test-version", commit: "test-commit" });
});
});
describe("Fastify public routes", () => {
let server;
beforeEach(async () => {
server = await createServer();
});
it("returns health", async () => {
const response = await server.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ status: "ok" });
});
it("returns version", async () => {
const response = await server.inject({ method: "GET", url: "/version" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ version: "test-version", commit: "test-commit" });
});
});

View File

@@ -1,23 +0,0 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { getBuildInfo } from "../src/utils/buildInfo";
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
describe("getBuildInfo", () => {
it("uses env vars when provided", () => {
process.env.APP_VERSION = "3.0.0";
process.env.APP_COMMIT = "xyz";
const info = getBuildInfo();
expect(info.version).toBe("3.0.0");
expect(info.commit).toBe("xyz");
expect(new Date(info.buildTime).toString()).not.toBe("Invalid Date");
});
it("falls back to git when env missing", () => {
const gitReader = vi.fn().mockReturnValue("abcdef");
delete process.env.APP_COMMIT;
const info = getBuildInfo(gitReader);
expect(info.commit).toBe("abcdef");
});
});

View File

@@ -1,17 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { render, screen } from "@testing-library/react";
import { EnvCard } from "../components/EnvCard";
describe("EnvCard", () => {
const env = {
id: "env_123",
name: "Production",
region: "us-west-2",
status: "healthy"
};
it("renders name, region, and id", () => {
render(_jsx(EnvCard, { env: env }));
expect(screen.getByText(env.region)).toBeInTheDocument();
expect(screen.getByText(env.name)).toBeInTheDocument();
expect(screen.getByText(`Env ID: ${env.id}`)).toBeInTheDocument();
});
});

View File

@@ -1,29 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getEnvironments, getEnvById, getHealth, getVersion } from "../lib/fetcher";
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
describe("fetcher", () => {
it("returns mock environments", async () => {
const envs = await getEnvironments();
expect(envs).toHaveLength(2);
expect(envs[0]).toEqual(expect.objectContaining({ id: "env_1", name: "Development", region: "us-east-1" }));
});
it("returns environment by id", async () => {
const env = await getEnvById("env_2");
expect(env?.name).toBe("Staging");
expect(await getEnvById("missing"))?.toBeUndefined();
});
it("returns health with uptime", async () => {
vi.spyOn(process, "uptime").mockReturnValue(42);
const health = await getHealth();
expect(health).toEqual({ status: "ok", uptime: 42 });
});
it("returns version info", async () => {
process.env.APP_VERSION = "2.0.0";
process.env.APP_COMMIT = "abc123";
const info = await getVersion();
expect(info).toEqual({ version: "2.0.0", commit: "abc123" });
});
});

View File

@@ -1,23 +0,0 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
import cron from "node-cron";
import { startHeartbeatScheduler } from "../src/heartbeat";
vi.mock("node-cron", () => {
return {
default: {
schedule: vi.fn((expression, callback) => ({ fireOnTick: callback, expression }))
}
};
});
describe("startHeartbeatScheduler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("schedules heartbeat every five minutes and enqueues payload", async () => {
const add = vi.fn();
const task = startHeartbeatScheduler({ add });
expect(cron.schedule).toHaveBeenCalledWith("*/5 * * * *", expect.any(Function));
// fire the cron callback
task.fireOnTick();
expect(add).toHaveBeenCalledWith("heartbeat", expect.objectContaining({ ts: expect.any(Number) }));
});
});

View File

@@ -1,18 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { GET as health } from "../app/api/health/route";
import { GET as version } from "../app/api/version/route";
vi.mock("../src/utils/buildInfo", () => ({
getBuildInfo: () => ({ version: "api-version", commit: "api-commit", buildTime: "now" })
}));
describe("Next API routes", () => {
it("returns health response", async () => {
const res = await health();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: "ok" });
});
it("returns version response", async () => {
const res = await version();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ version: "api-version", commit: "api-commit" });
});
});

View File

@@ -1,31 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { registerSampleJobProcessor } from "../src/jobs/sample.job";
vi.mock("bullmq", () => {
class MockWorker {
constructor(_name, processor, _opts) {
this.handlers = {};
this.processor = processor;
}
on(event, handler) {
this.handlers[event] = this.handlers[event] || [];
this.handlers[event].push(handler);
}
}
return { Worker: MockWorker };
});
describe("registerSampleJobProcessor", () => {
it("registers worker and handlers", () => {
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => { });
const consoleError = vi.spyOn(console, "error").mockImplementation(() => { });
const worker = registerSampleJobProcessor({ host: "localhost", port: 6379 });
expect(worker.processor).toBeInstanceOf(Function);
expect(worker.handlers.failed).toHaveLength(1);
// simulate processing and failure
worker.processor({ id: 1, data: { hello: "world" } });
worker.handlers.failed[0]({ id: 1 }, new Error("boom"));
expect(consoleLog).toHaveBeenCalledWith("Processing job 1");
expect(consoleError).toHaveBeenCalled();
consoleLog.mockRestore();
consoleError.mockRestore();
});
});

View File

@@ -1,16 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { render, screen } from "@testing-library/react";
import { StatusPill } from "../components/StatusPill";
describe("StatusPill", () => {
const cases = [
["healthy", "Healthy", "status-pill--healthy"],
["degraded", "Degraded", "status-pill--degraded"],
["down", "Down", "status-pill--down"]
];
it.each(cases)("renders %s status", (status, label, className) => {
render(_jsx(StatusPill, { status: status }));
const pill = screen.getByText(label);
expect(pill).toBeInTheDocument();
expect(pill.className).toContain(className);
});
});

View File

@@ -1,10 +0,0 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./vitest.setup.ts",
globals: true
}
});