Compare commits
4 commits
669ecb2b5d
...
a1a0c80acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1a0c80acc | ||
|
|
661cf12549 | ||
| fac6bbd371 | |||
|
|
8fed430afc |
8 changed files with 686 additions and 77 deletions
|
|
@ -6,6 +6,7 @@ import { loadDisclawConfig } from "../config/loader";
|
||||||
import { resolveClaude } from "../runtime/resolve-claude";
|
import { resolveClaude } from "../runtime/resolve-claude";
|
||||||
import { sanitizedEnv } from "../runtime/env";
|
import { sanitizedEnv } from "../runtime/env";
|
||||||
import { Semaphore } from "../runtime/concurrency";
|
import { Semaphore } from "../runtime/concurrency";
|
||||||
|
import type { RunResult } from "./types";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Module-level semaphore — lazy-initialised on first runAgent() call so the
|
// Module-level semaphore — lazy-initialised on first runAgent() call so the
|
||||||
|
|
@ -167,7 +168,7 @@ function parseClaudeJsonOutput(raw: string): string {
|
||||||
* Timeout defaults to 120 seconds. The process is killed if it exceeds
|
* Timeout defaults to 120 seconds. The process is killed if it exceeds
|
||||||
* the timeout.
|
* the timeout.
|
||||||
*/
|
*/
|
||||||
export async function runAgent(options: RunAgentOptions): Promise<string> {
|
export async function runAgent(options: RunAgentOptions): Promise<RunResult> {
|
||||||
const {
|
const {
|
||||||
workspacePath,
|
workspacePath,
|
||||||
channelName,
|
channelName,
|
||||||
|
|
@ -198,7 +199,7 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
||||||
await semaphore.acquire();
|
await semaphore.acquire();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await new Promise<string>((resolve, reject) => {
|
return await new Promise<RunResult>((resolve) => {
|
||||||
// NOTE: --bare is intentionally NOT used here. --bare disables OAuth/keychain
|
// NOTE: --bare is intentionally NOT used here. --bare disables OAuth/keychain
|
||||||
// auth and requires ANTHROPIC_API_KEY, which breaks Claude Pro/Max subscriptions.
|
// auth and requires ANTHROPIC_API_KEY, which breaks Claude Pro/Max subscriptions.
|
||||||
// CLAUDE.md isolation is handled structurally: workspaces live under
|
// CLAUDE.md isolation is handled structurally: workspaces live under
|
||||||
|
|
@ -242,7 +243,8 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout handling
|
// Timeout handling — kill the process; the "close" handler will resolve
|
||||||
|
// with { status: "timeout" } once the process exits (killed flag is set).
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
killed = true;
|
killed = true;
|
||||||
child.kill("SIGTERM");
|
child.kill("SIGTERM");
|
||||||
|
|
@ -267,15 +269,20 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
||||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
reject(
|
resolve({
|
||||||
new Error(
|
status: "cli-error",
|
||||||
|
message:
|
||||||
`Claude Code CLI not found at "${claudeCommand}". ` +
|
`Claude Code CLI not found at "${claudeCommand}". ` +
|
||||||
`Install it with: npm install -g @anthropic-ai/claude-code\n` +
|
`Install it with: npm install -g @anthropic-ai/claude-code\n` +
|
||||||
`Or set CLAUDE_PATH in your .env file.`
|
`Or set CLAUDE_PATH in your .env file.`,
|
||||||
)
|
stderr: undefined,
|
||||||
);
|
});
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Failed to start Claude Code CLI: ${err.message}`));
|
resolve({
|
||||||
|
status: "cli-error",
|
||||||
|
message: `Failed to start Claude Code CLI: ${err.message}`,
|
||||||
|
stderr: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -283,18 +290,22 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
|
||||||
if (killed) {
|
if (killed) {
|
||||||
reject(new Error(`Claude Code CLI timed out after ${timeoutMs / 1000} seconds.`));
|
resolve({ status: "timeout", timeoutMs });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
|
const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
|
||||||
reject(new Error(`Claude Code CLI error: ${errorDetail}`));
|
resolve({
|
||||||
|
status: "cli-error",
|
||||||
|
message: `Claude Code CLI error: ${errorDetail}`,
|
||||||
|
stderr: stderr.trim() || undefined,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = parseClaudeJsonOutput(stdout);
|
const text = parseClaudeJsonOutput(stdout);
|
||||||
resolve(response);
|
resolve({ status: "success", text });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close stdin immediately since we pass everything via args
|
// Close stdin immediately since we pass everything via args
|
||||||
|
|
|
||||||
5
src/agent/types.ts
Normal file
5
src/agent/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type RunResult =
|
||||||
|
| { status: "success"; text: string; sessionId?: string }
|
||||||
|
| { status: "timeout"; timeoutMs: number }
|
||||||
|
| { status: "cli-error"; message: string; stderr?: string }
|
||||||
|
| { status: "parse-error"; message: string; raw: string };
|
||||||
|
|
@ -4,7 +4,7 @@ export const DisclawConfigSchema = z.object({
|
||||||
workspaces_root: z.string().min(1),
|
workspaces_root: z.string().min(1),
|
||||||
management_channel: z.string().min(1).default("disclaw"),
|
management_channel: z.string().min(1).default("disclaw"),
|
||||||
claude_command: z.string().min(1).default("claude"),
|
claude_command: z.string().min(1).default("claude"),
|
||||||
claude_timeout_seconds: z.number().int().positive().default(120),
|
claude_timeout_seconds: z.number().positive().default(120),
|
||||||
max_concurrent_agents: z.number().int().positive().default(4),
|
max_concurrent_agents: z.number().int().positive().default(4),
|
||||||
env_blocklist: z.array(z.string()).default([]),
|
env_blocklist: z.array(z.string()).default([]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
130
src/router.ts
130
src/router.ts
|
|
@ -5,6 +5,10 @@ import { DisclawConfig } from "./config/loader";
|
||||||
import { runAgent } from "./agent/runner";
|
import { runAgent } from "./agent/runner";
|
||||||
import { splitForDiscord } from "./discord/split";
|
import { splitForDiscord } from "./discord/split";
|
||||||
import { sanitizeForDiscord } from "./runtime/sanitize";
|
import { sanitizeForDiscord } from "./runtime/sanitize";
|
||||||
|
import { ChannelQueue } from "./runtime/channel-queue";
|
||||||
|
import type { RunResult } from "./agent/types";
|
||||||
|
|
||||||
|
const channelQueue = new ChannelQueue();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes a Discord message to the correct agent based on channel_id.
|
* Routes a Discord message to the correct agent based on channel_id.
|
||||||
|
|
@ -19,88 +23,88 @@ export async function routeMessage(
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const channelId = message.channelId;
|
const channelId = message.channelId;
|
||||||
|
|
||||||
// Ignore messages in the management channel (commands only)
|
|
||||||
if (channelId === managementChannelId) {
|
if (channelId === managementChannelId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up workspace for this channel
|
|
||||||
const workspace = db.getWorkspaceByChannelId(channelId);
|
const workspace = db.getWorkspaceByChannelId(channelId);
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = message.channel as TextChannel;
|
const channel = message.channel as TextChannel;
|
||||||
|
const sanitizeOpts = {
|
||||||
|
repoRoot: workspace.workspace_path,
|
||||||
|
home: os.homedir(),
|
||||||
|
};
|
||||||
|
|
||||||
// Show typing indicator while the agent works
|
await channelQueue.enqueue(channelId, async () => {
|
||||||
const typingInterval = setInterval(() => {
|
const typingInterval = setInterval(() => {
|
||||||
channel.sendTyping().catch(() => {});
|
channel.sendTyping().catch(() => {});
|
||||||
}, 5000);
|
}, 5000);
|
||||||
// Send immediately as well
|
await channel.sendTyping().catch(() => {});
|
||||||
await channel.sendTyping().catch(() => {});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load recent conversation history for context
|
const history = db.getConversationHistory(workspace.id, 30);
|
||||||
const history = db.getConversationHistory(workspace.id, 30);
|
|
||||||
|
|
||||||
// Run the agent
|
const result: RunResult = await runAgent({
|
||||||
const response = await runAgent({
|
workspacePath: workspace.workspace_path,
|
||||||
workspacePath: workspace.workspace_path,
|
channelName: channel.name,
|
||||||
channelName: channel.name,
|
userMessage: message.content,
|
||||||
userMessage: message.content,
|
conversationHistory: history,
|
||||||
conversationHistory: history,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Store user message
|
if (result.status === "success") {
|
||||||
db.addConversation(
|
db.addConversation(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
message.id,
|
message.id,
|
||||||
"user",
|
"user",
|
||||||
message.content,
|
message.content,
|
||||||
message.author.username
|
message.author.username
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sanitize the full response once, then use the sanitized version
|
const sanitizedResponse = sanitizeForDiscord(result.text, sanitizeOpts);
|
||||||
// for both Discord output and conversation history storage.
|
const chunks = splitForDiscord(sanitizedResponse);
|
||||||
// This prevents a feedback loop where raw paths in stored history
|
let firstMsgId: string | null = null;
|
||||||
// get fed back to the agent as context, causing it to reproduce them.
|
|
||||||
const sanitizeOpts = {
|
|
||||||
repoRoot: workspace.workspace_path,
|
|
||||||
home: os.homedir(),
|
|
||||||
};
|
|
||||||
const sanitizedResponse = sanitizeForDiscord(response, sanitizeOpts);
|
|
||||||
|
|
||||||
// Split and send response
|
for (const chunk of chunks) {
|
||||||
const chunks = splitForDiscord(sanitizedResponse);
|
const sent = await channel.send(chunk);
|
||||||
let firstMsgId: string | null = null;
|
if (!firstMsgId) {
|
||||||
|
firstMsgId = sent.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
if (firstMsgId) {
|
||||||
const sent = await channel.send(chunk);
|
db.addConversation(
|
||||||
if (!firstMsgId) {
|
workspace.id,
|
||||||
firstMsgId = sent.id;
|
firstMsgId,
|
||||||
|
"assistant",
|
||||||
|
sanitizedResponse,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (result.status === "timeout") {
|
||||||
|
await channel
|
||||||
|
.send(`⏱️ Agent-Timeout nach ${result.timeoutMs / 1000}s`)
|
||||||
|
.catch(() => {});
|
||||||
|
} else if (result.status === "cli-error") {
|
||||||
|
await channel
|
||||||
|
.send(sanitizeForDiscord(`❌ CLI-Fehler: ${result.message}`, sanitizeOpts))
|
||||||
|
.catch(() => {});
|
||||||
|
} else if (result.status === "parse-error") {
|
||||||
|
await channel
|
||||||
|
.send(`⚠️ Antwort konnte nicht gelesen werden`)
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
await channel
|
||||||
|
.send(sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, sanitizeOpts))
|
||||||
|
.catch(() => {});
|
||||||
|
} finally {
|
||||||
|
clearInterval(typingInterval);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Store sanitized assistant response (use first message id for dedup)
|
|
||||||
if (firstMsgId) {
|
|
||||||
db.addConversation(
|
|
||||||
workspace.id,
|
|
||||||
firstMsgId,
|
|
||||||
"assistant",
|
|
||||||
sanitizedResponse,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
|
|
||||||
const safeMsg = sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, {
|
|
||||||
repoRoot: workspace.workspace_path,
|
|
||||||
home: os.homedir(),
|
|
||||||
});
|
|
||||||
await channel.send(safeMsg).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
clearInterval(typingInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/runtime/channel-queue.ts
Normal file
37
src/runtime/channel-queue.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
type Task = () => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-channel FIFO task serializer.
|
||||||
|
*
|
||||||
|
* Messages arriving in the same Discord channel are enqueued and executed one
|
||||||
|
* at a time. A failing task logs the error and is swallowed so the next task
|
||||||
|
* is not blocked. Different channels run their queues in parallel.
|
||||||
|
*/
|
||||||
|
export class ChannelQueue {
|
||||||
|
private queues = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
enqueue(channelId: string, task: Task): Promise<void> {
|
||||||
|
// Use the existing tail promise as the predecessor, or a pre-resolved one
|
||||||
|
// for a fresh channel.
|
||||||
|
const prev = this.queues.get(channelId) ?? Promise.resolve();
|
||||||
|
|
||||||
|
// Chain the new task behind the current tail. Errors are caught and
|
||||||
|
// swallowed so subsequent tasks always get a chance to run.
|
||||||
|
const next = prev.then(() => task()).catch((err) => {
|
||||||
|
console.error(`[ChannelQueue] Task error in channel ${channelId}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the new tail. Use a finalizer that removes the entry only when
|
||||||
|
// THIS chain link is still the stored tail — if another task was enqueued
|
||||||
|
// in the meantime the entry must stay.
|
||||||
|
this.queues.set(channelId, next);
|
||||||
|
|
||||||
|
next.finally(() => {
|
||||||
|
if (this.queues.get(channelId) === next) {
|
||||||
|
this.queues.delete(channelId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
tests/integration/channel-queue-router.test.ts
Normal file
115
tests/integration/channel-queue-router.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: verifies that routeMessage serializes same-channel messages
|
||||||
|
* through ChannelQueue so they are processed in FIFO order.
|
||||||
|
*
|
||||||
|
* runAgent is mocked to record call order without spawning a real process.
|
||||||
|
* discord.js and the database are also mocked so no real I/O occurs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- Mock runAgent BEFORE importing router ----
|
||||||
|
const runOrder: string[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../src/agent/runner", () => ({
|
||||||
|
runAgent: vi.fn(async ({ userMessage }: { userMessage: string }) => {
|
||||||
|
// Simulate brief async work so the promise chain is exercised
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||||
|
runOrder.push(userMessage);
|
||||||
|
return `echo: ${userMessage}`;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- Import after mock registration ----
|
||||||
|
import { routeMessage } from "../../src/router";
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
/** Builds a minimal fake discord.js Message object. */
|
||||||
|
function makeMessage(channelId: string, content: string) {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const channel = {
|
||||||
|
name: "test-channel",
|
||||||
|
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||||
|
send: vi.fn(async (text: string) => {
|
||||||
|
sent.push(text);
|
||||||
|
return { id: `msg-${Math.random()}` };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
channelId,
|
||||||
|
content,
|
||||||
|
id: `mid-${content}`,
|
||||||
|
author: { username: "tester" },
|
||||||
|
channel,
|
||||||
|
_sent: sent,
|
||||||
|
} as unknown as import("discord.js").Message & { _sent: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal fake DisclawDatabase. */
|
||||||
|
function makeDb(channelId: string) {
|
||||||
|
return {
|
||||||
|
getWorkspaceByChannelId: vi.fn((_id: string) => ({
|
||||||
|
id: 1,
|
||||||
|
workspace_path: "/fake/workspace",
|
||||||
|
})),
|
||||||
|
getConversationHistory: vi.fn(() => []),
|
||||||
|
addConversation: vi.fn(),
|
||||||
|
} as unknown as import("../../src/db/database").DisclawDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal fake DisclawConfig. */
|
||||||
|
const fakeConfig = {} as import("../../src/config/loader").DisclawConfig;
|
||||||
|
|
||||||
|
// ---- Tests ----
|
||||||
|
|
||||||
|
describe("channel-queue / router integration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runOrder.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes 3 messages in the same channel in FIFO order", async () => {
|
||||||
|
const channelId = "ch-999";
|
||||||
|
const db = makeDb(channelId);
|
||||||
|
|
||||||
|
// Fire all three without awaiting — they should serialise internally
|
||||||
|
const p1 = routeMessage(makeMessage(channelId, "first"), db, fakeConfig, null);
|
||||||
|
const p2 = routeMessage(makeMessage(channelId, "second"), db, fakeConfig, null);
|
||||||
|
const p3 = routeMessage(makeMessage(channelId, "third"), db, fakeConfig, null);
|
||||||
|
|
||||||
|
await Promise.all([p1, p2, p3]);
|
||||||
|
|
||||||
|
expect(runOrder).toEqual(["first", "second", "third"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for every handled message", async () => {
|
||||||
|
const channelId = "ch-handled";
|
||||||
|
const db = makeDb(channelId);
|
||||||
|
|
||||||
|
const results = await Promise.all([
|
||||||
|
routeMessage(makeMessage(channelId, "a"), db, fakeConfig, null),
|
||||||
|
routeMessage(makeMessage(channelId, "b"), db, fakeConfig, null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toEqual([true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when channel is the management channel", async () => {
|
||||||
|
const channelId = "ch-mgmt";
|
||||||
|
const db = makeDb(channelId);
|
||||||
|
|
||||||
|
const result = await routeMessage(
|
||||||
|
makeMessage(channelId, "hi"),
|
||||||
|
db,
|
||||||
|
fakeConfig,
|
||||||
|
channelId // same id => management channel
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
tests/unit/channel-queue.test.ts
Normal file
100
tests/unit/channel-queue.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { ChannelQueue } from "../../src/runtime/channel-queue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a task that resolves after `ms` milliseconds and pushes its label
|
||||||
|
* into the shared `order` array.
|
||||||
|
*/
|
||||||
|
function makeTask(order: string[], label: string, ms = 0): () => Promise<void> {
|
||||||
|
return () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
order.push(label);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ChannelQueue", () => {
|
||||||
|
it("(a) executes tasks in FIFO order within the same channel", async () => {
|
||||||
|
const queue = new ChannelQueue();
|
||||||
|
const order: string[] = [];
|
||||||
|
|
||||||
|
const p1 = queue.enqueue("ch1", makeTask(order, "first"));
|
||||||
|
const p2 = queue.enqueue("ch1", makeTask(order, "second"));
|
||||||
|
const p3 = queue.enqueue("ch1", makeTask(order, "third"));
|
||||||
|
|
||||||
|
await Promise.all([p1, p2, p3]);
|
||||||
|
|
||||||
|
expect(order).toEqual(["first", "second", "third"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(b) a failing task does not block the next task", async () => {
|
||||||
|
const queue = new ChannelQueue();
|
||||||
|
const order: string[] = [];
|
||||||
|
|
||||||
|
const failing: () => Promise<void> = () =>
|
||||||
|
Promise.reject(new Error("intentional failure"));
|
||||||
|
|
||||||
|
// Suppress the console.error output from ChannelQueue internals
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const p1 = queue.enqueue("ch1", failing);
|
||||||
|
const p2 = queue.enqueue("ch1", makeTask(order, "after-error"));
|
||||||
|
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(order).toEqual(["after-error"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(c) map entry is removed after all tasks complete", async () => {
|
||||||
|
const queue = new ChannelQueue();
|
||||||
|
|
||||||
|
const p1 = queue.enqueue("ch1", () => Promise.resolve());
|
||||||
|
const p2 = queue.enqueue("ch1", () => Promise.resolve());
|
||||||
|
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
// Allow the finally-cleanup microtask to run
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect((queue as unknown as { queues: Map<string, unknown> }).queues.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(d) different channels run in parallel, not serialized", async () => {
|
||||||
|
const queue = new ChannelQueue();
|
||||||
|
const completionOrder: string[] = [];
|
||||||
|
|
||||||
|
// ch-slow gets a 50 ms task; ch-fast gets a 10 ms task.
|
||||||
|
// If channels were serialized (ch-slow first), ch-fast would finish after
|
||||||
|
// ch-slow. With true parallelism ch-fast finishes first.
|
||||||
|
const pSlow = queue.enqueue(
|
||||||
|
"ch-slow",
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
completionOrder.push("slow");
|
||||||
|
resolve();
|
||||||
|
}, 50);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const pFast = queue.enqueue(
|
||||||
|
"ch-fast",
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
completionOrder.push("fast");
|
||||||
|
resolve();
|
||||||
|
}, 10);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([pSlow, pFast]);
|
||||||
|
|
||||||
|
// fast must finish before slow
|
||||||
|
expect(completionOrder).toEqual(["fast", "slow"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
337
tests/unit/runner-result.test.ts
Normal file
337
tests/unit/runner-result.test.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeAll,
|
||||||
|
afterAll,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
} from "vitest";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the RunResult discriminated union returned by runAgent().
|
||||||
|
*
|
||||||
|
* cross-spawn is mocked so no real process is launched. Each test controls
|
||||||
|
* what the fake child process emits (stdout, exit code, error event) to cover
|
||||||
|
* all four RunResult status variants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configurable mock state — mutated per test via setMockBehavior()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface MockBehavior {
|
||||||
|
/** stdout data to emit (default: valid JSON success payload) */
|
||||||
|
stdoutData?: string;
|
||||||
|
/** exit code the child closes with (default: 0) */
|
||||||
|
exitCode?: number;
|
||||||
|
/** if set, child emits an "error" event with this error object */
|
||||||
|
spawnError?: NodeJS.ErrnoException;
|
||||||
|
/** if true, close event is never emitted (simulates a hanging process) */
|
||||||
|
neverClose?: boolean;
|
||||||
|
/** delay in ms before emitting close (default: 0) */
|
||||||
|
closeDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockBehavior: MockBehavior = {};
|
||||||
|
|
||||||
|
function setMockBehavior(b: MockBehavior) {
|
||||||
|
mockBehavior = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("cross-spawn", () => {
|
||||||
|
function makeMockChild() {
|
||||||
|
const listeners: Record<string, Array<(...a: unknown[]) => void>> = {};
|
||||||
|
|
||||||
|
const emit = (event: string, ...args: unknown[]) => {
|
||||||
|
for (const cb of listeners[event] ?? []) {
|
||||||
|
cb(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stdoutListeners: Record<string, Array<(...a: unknown[]) => void>> =
|
||||||
|
{};
|
||||||
|
|
||||||
|
const stdoutStream = {
|
||||||
|
setEncoding: () => {},
|
||||||
|
on: (event: string, cb: (...a: unknown[]) => void) => {
|
||||||
|
if (!stdoutListeners[event]) stdoutListeners[event] = [];
|
||||||
|
stdoutListeners[event].push(cb);
|
||||||
|
|
||||||
|
if (event === "data" && !mockBehavior.spawnError) {
|
||||||
|
const data =
|
||||||
|
mockBehavior.stdoutData ??
|
||||||
|
JSON.stringify({ result: "hello from agent" });
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
for (const handler of stdoutListeners["data"] ?? []) {
|
||||||
|
handler(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const child = {
|
||||||
|
stdout: stdoutStream,
|
||||||
|
stderr: {
|
||||||
|
setEncoding: () => {},
|
||||||
|
on: () => {},
|
||||||
|
},
|
||||||
|
stdin: { end: () => {} },
|
||||||
|
on: (event: string, cb: (...a: unknown[]) => void) => {
|
||||||
|
if (!listeners[event]) listeners[event] = [];
|
||||||
|
listeners[event].push(cb);
|
||||||
|
|
||||||
|
if (event === "error" && mockBehavior.spawnError) {
|
||||||
|
Promise.resolve().then(() => emit("error", mockBehavior.spawnError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === "close" && !mockBehavior.neverClose) {
|
||||||
|
// Emit close after stdout data (two microtask ticks away)
|
||||||
|
const delay = mockBehavior.closeDelayMs ?? 0;
|
||||||
|
const schedule = delay > 0
|
||||||
|
? (fn: () => void) => setTimeout(fn, delay)
|
||||||
|
: (fn: () => void) =>
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => Promise.resolve())
|
||||||
|
.then(fn);
|
||||||
|
|
||||||
|
schedule(() => emit("close", mockBehavior.exitCode ?? 0));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
kill: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: (_cmd: string, _args: string[]) => makeMockChild(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import after mock registration
|
||||||
|
import { runAgent, _resetSemaphore } from "../../src/agent/runner";
|
||||||
|
import { _resetResolveClaudeCache } from "../../src/runtime/resolve-claude";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared workspace fixture
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RunResult discriminated union", () => {
|
||||||
|
let workspaceDir: string;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
workspaceDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "disclaw-runresult-")
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(workspaceDir, "agent.yaml"),
|
||||||
|
`name: result-agent\ndisplay_name: Result Agent\nrole: Testing\nchannel_id: "999"\n`,
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(workspaceDir, "CLAUDE.md"),
|
||||||
|
"# Result Agent\nYou are a test agent.\n",
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBehavior = {};
|
||||||
|
_resetResolveClaudeCache();
|
||||||
|
_resetSemaphore(4);
|
||||||
|
process.env.CLAUDE_PATH = "/fake/claude";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
_resetResolveClaudeCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try {
|
||||||
|
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// (a) success
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('(a) success: valid JSON stdout → status "success" with correct text', async () => {
|
||||||
|
setMockBehavior({
|
||||||
|
stdoutData: JSON.stringify({ result: "Agent says hi!" }),
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAgent({
|
||||||
|
workspacePath: workspaceDir,
|
||||||
|
channelName: "test",
|
||||||
|
userMessage: "hello",
|
||||||
|
conversationHistory: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("success");
|
||||||
|
if (result.status === "success") {
|
||||||
|
expect(result.text).toBe("Agent says hi!");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// (b) timeout
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('(b) timeout: process never closes → status "timeout" with timeoutMs', async () => {
|
||||||
|
// Never emit close so the timeout fires. Use a very short timeout by
|
||||||
|
// writing a disclaw.yaml that sets claude_timeout_seconds to something tiny.
|
||||||
|
// Instead we rely on the fact that the timeout logic in runner.ts will kill
|
||||||
|
// the child (kill() is a no-op in the mock) and then the close event must
|
||||||
|
// still fire. We simulate this by making close fire *after* a delay longer
|
||||||
|
// than the timeout but still having `killed = true` set by the timer.
|
||||||
|
//
|
||||||
|
// The cleanest approach: let the mock emit close immediately but set
|
||||||
|
// killed=true before it fires by abusing the AbortController signal to
|
||||||
|
// trigger the kill path, and then emit close with code 0.
|
||||||
|
//
|
||||||
|
// Actually the simplest: set closeDelayMs to something > timeout, but
|
||||||
|
// the default timeout is 120s which is way too long for a test. Instead
|
||||||
|
// we use a custom disclaw.yaml in the workspace.
|
||||||
|
|
||||||
|
const configPath = path.join(workspaceDir, "disclaw.yaml");
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
`workspaces_root: /tmp\nmanagement_channel: disclaw\nclaude_command: claude\nclaude_timeout_seconds: 0.05\nmax_concurrent_agents: 4\nenv_blocklist: []\n`,
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mock's kill() is a no-op, so the close event will never arrive via
|
||||||
|
// the normal path. We need close to fire after the timeout kills the child.
|
||||||
|
// Set a small delay so close fires *after* the 50ms timeout.
|
||||||
|
setMockBehavior({
|
||||||
|
neverClose: false,
|
||||||
|
closeDelayMs: 200, // fires after the 50ms timeout
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override __dirname resolution: runner.ts resolves rootDir as 2 levels
|
||||||
|
// up from __dirname (src/agent -> root). We need to point it to our temp
|
||||||
|
// workspace. Use DISCLAW_ROOT env var is not available, so instead we
|
||||||
|
// write disclaw.yaml to the project root's expected location. This is
|
||||||
|
// tricky in a unit test — easier to just accept the default 120s timeout
|
||||||
|
// won't fire and instead mock the behavior differently.
|
||||||
|
//
|
||||||
|
// Simplest working approach: use AbortController to abort → child.kill()
|
||||||
|
// is called → mock emits close immediately with code 0 but killed=true.
|
||||||
|
// BUT abort doesn't set `killed` in the timeout branch — it sets it in
|
||||||
|
// the abort branch, and close code 0 after abort currently resolves success.
|
||||||
|
//
|
||||||
|
// The cleanest unit-testable path: just verify the timeout branch by
|
||||||
|
// patching the timeout to a very small value via the config file in the
|
||||||
|
// project root (since runner.ts uses loadDisclawConfig from 2 levels up).
|
||||||
|
|
||||||
|
// Clean up config to avoid affecting other tests
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(configPath);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative approach: write config to project root temporarily
|
||||||
|
const projectRoot = path.resolve(__dirname, "../..");
|
||||||
|
const projectConfig = path.join(projectRoot, "disclaw.yaml");
|
||||||
|
const originalConfig = fs.existsSync(projectConfig)
|
||||||
|
? fs.readFileSync(projectConfig, "utf-8")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
projectConfig,
|
||||||
|
`workspaces_root: /tmp\nmanagement_channel: disclaw\nclaude_command: claude\nclaude_timeout_seconds: 0.05\nmax_concurrent_agents: 4\nenv_blocklist: []\n`,
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
setMockBehavior({
|
||||||
|
neverClose: false,
|
||||||
|
closeDelayMs: 200,
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAgent({
|
||||||
|
workspacePath: workspaceDir,
|
||||||
|
channelName: "test",
|
||||||
|
userMessage: "hello",
|
||||||
|
conversationHistory: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("timeout");
|
||||||
|
if (result.status === "timeout") {
|
||||||
|
expect(result.timeoutMs).toBe(50);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalConfig !== null) {
|
||||||
|
fs.writeFileSync(projectConfig, originalConfig, "utf-8");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(projectConfig);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// (c) cli-error: non-zero exit code
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('(c) cli-error: exit code != 0 → status "cli-error"', async () => {
|
||||||
|
setMockBehavior({
|
||||||
|
stdoutData: "",
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAgent({
|
||||||
|
workspacePath: workspaceDir,
|
||||||
|
channelName: "test",
|
||||||
|
userMessage: "hello",
|
||||||
|
conversationHistory: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("cli-error");
|
||||||
|
if (result.status === "cli-error") {
|
||||||
|
expect(result.message).toMatch(/Claude Code CLI error/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// (d) cli-error ENOENT
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('(d) cli-error ENOENT: spawn error → status "cli-error" with "not found" message', async () => {
|
||||||
|
const enoentError = Object.assign(new Error("spawn ENOENT"), {
|
||||||
|
code: "ENOENT",
|
||||||
|
}) as NodeJS.ErrnoException;
|
||||||
|
|
||||||
|
setMockBehavior({
|
||||||
|
spawnError: enoentError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAgent({
|
||||||
|
workspacePath: workspaceDir,
|
||||||
|
channelName: "test",
|
||||||
|
userMessage: "hello",
|
||||||
|
conversationHistory: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("cli-error");
|
||||||
|
if (result.status === "cli-error") {
|
||||||
|
expect(result.message).toMatch(/not found/i);
|
||||||
|
expect(result.stderr).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue