@inceptionstack/roundhouse 0.5.17 → 0.5.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/agents/pi/pi-adapter.ts +15 -10
- package/src/cli/cli.ts +12 -10
- package/src/cli/subagent-command.ts +171 -0
- package/src/gateway/commands.ts +11 -1
- package/src/gateway/gateway.ts +91 -0
- package/src/gateway/tools.md +39 -0
- package/src/subagents/brief.ts +28 -0
- package/src/subagents/index.ts +8 -0
- package/src/subagents/orchestrator.ts +185 -0
- package/src/subagents/pid.ts +52 -0
- package/src/subagents/process-launcher.ts +102 -0
- package/src/subagents/run-finalizer.ts +64 -0
- package/src/subagents/run-store.ts +80 -0
- package/src/subagents/termination-handler.ts +87 -0
- package/src/subagents/types.ts +57 -0
- package/src/subagents/watcher.ts +63 -0
- package/src/transports/telegram/telegram-adapter.ts +2 -2
- package/src/transports/types.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@inceptionstack/roundhouse` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.5.19] — 2026-05-10
|
|
6
|
+
- Sub-agent orchestrator: spawn background Pi agents for review/research/scout/implementation
|
|
7
|
+
- CLI: `roundhouse subagent spawn/status/list/abort`
|
|
8
|
+
- Telegram notifications on sub-agent completion (✅/⏰/❌)
|
|
9
|
+
- Security: UUID-only run IDs, path traversal guard, SIGKILL escalation
|
|
10
|
+
- Boot turn: agent greets in-character on startup
|
|
11
|
+
- /status shows configured model after /model switch
|
|
12
|
+
- TUI: fresh session support on new deploys
|
|
13
|
+
|
|
5
14
|
## [0.5.14] — 2026-05-10
|
|
6
15
|
|
|
7
16
|
### Added
|
package/package.json
CHANGED
|
@@ -521,6 +521,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
521
521
|
getInfo(threadId?: string): Record<string, unknown> {
|
|
522
522
|
// Get model from the requested thread's session, or most recently used
|
|
523
523
|
let modelInfo: string | undefined;
|
|
524
|
+
let hasActiveSession = false;
|
|
524
525
|
let contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
|
|
525
526
|
const threadEntry = threadId ? sessions.get(threadId) : undefined;
|
|
526
527
|
|
|
@@ -528,6 +529,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
528
529
|
const model = threadEntry.session.model;
|
|
529
530
|
if (model) modelInfo = `${model.provider}/${model.id}`;
|
|
530
531
|
contextUsage = threadEntry.session.getContextUsage() ?? undefined;
|
|
532
|
+
hasActiveSession = true;
|
|
531
533
|
}
|
|
532
534
|
|
|
533
535
|
if (!modelInfo) {
|
|
@@ -542,17 +544,18 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
542
544
|
}
|
|
543
545
|
}
|
|
544
546
|
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
} catch (err) {
|
|
554
|
-
console.warn(`[pi-agent] could not read settings.json for model info:`, (err as Error).message);
|
|
547
|
+
// Read configured model from settings.json (used for fallback + configuredModel field)
|
|
548
|
+
let configuredModel = "";
|
|
549
|
+
try {
|
|
550
|
+
const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
|
|
551
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
552
|
+
if (settings.defaultProvider && settings.defaultModel) {
|
|
553
|
+
configuredModel = `${settings.defaultProvider}/${settings.defaultModel}`;
|
|
555
554
|
}
|
|
555
|
+
} catch {}
|
|
556
|
+
|
|
557
|
+
if (!modelInfo && configuredModel) {
|
|
558
|
+
modelInfo = configuredModel;
|
|
556
559
|
}
|
|
557
560
|
|
|
558
561
|
// Read agent version
|
|
@@ -565,6 +568,8 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
565
568
|
return {
|
|
566
569
|
version,
|
|
567
570
|
model: modelInfo ?? "unknown",
|
|
571
|
+
hasActiveSession,
|
|
572
|
+
configuredModel: configuredModel || modelInfo || "unknown",
|
|
568
573
|
activeSessions: sessions.size,
|
|
569
574
|
cwd,
|
|
570
575
|
contextTokens: contextUsage?.tokens ?? null,
|
package/src/cli/cli.ts
CHANGED
|
@@ -322,20 +322,20 @@ async function cmdTui() {
|
|
|
322
322
|
})
|
|
323
323
|
.sort((a, b) => b.mtime - a.mtime);
|
|
324
324
|
} catch {
|
|
325
|
-
|
|
326
|
-
process.exit(1);
|
|
325
|
+
// No session directory — will start fresh below
|
|
327
326
|
}
|
|
328
327
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
let piArgs: string[];
|
|
329
|
+
if (candidates.length > 0) {
|
|
330
|
+
const selected = candidates[0];
|
|
331
|
+
console.log(`\nResuming: ${selected.sessionFile}\n`);
|
|
332
|
+
piArgs = ["--resume", selected.sessionFile];
|
|
333
|
+
} else {
|
|
334
|
+
console.log(`\nNo existing sessions for thread "${threadId}". Starting fresh.\n`);
|
|
335
|
+
piArgs = ["--session-dir", threadPath];
|
|
332
336
|
}
|
|
333
337
|
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
console.log(`\nOpening: ${selected.sessionFile}\n`);
|
|
337
|
-
|
|
338
|
-
const child = spawn("pi", ["--resume", selected.sessionFile], { stdio: "inherit" });
|
|
338
|
+
const child = spawn("pi", piArgs, { stdio: "inherit" });
|
|
339
339
|
child.on("error", (err) => {
|
|
340
340
|
console.error((err as any).code === "ENOENT" ? "'pi' not found in PATH." : `Failed: ${err.message}`);
|
|
341
341
|
process.exit(1);
|
|
@@ -390,6 +390,7 @@ import { cmdAgent } from "./agent-command";
|
|
|
390
390
|
import { cmdCron } from "./cron";
|
|
391
391
|
import { cmdSetup, cmdPair } from "./setup";
|
|
392
392
|
import { cmdMessage } from "./message";
|
|
393
|
+
import { handleSubagentCommand } from "./subagent-command";
|
|
393
394
|
|
|
394
395
|
const command = process.argv[2];
|
|
395
396
|
|
|
@@ -410,6 +411,7 @@ const commands: Record<string, () => void | Promise<void>> = {
|
|
|
410
411
|
doctor: () => cmdDoctor(process.argv.slice(3)),
|
|
411
412
|
cron: () => cmdCron(process.argv.slice(3)),
|
|
412
413
|
message: () => cmdMessage(process.argv.slice(3)),
|
|
414
|
+
subagent: () => handleSubagentCommand(process.argv.slice(3)),
|
|
413
415
|
agent: cmdAgent,
|
|
414
416
|
};
|
|
415
417
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/subagent-command.ts — CLI interface for sub-agent delegation
|
|
3
|
+
*
|
|
4
|
+
* Thin disk-state client that reads/writes ~/.roundhouse/subagents/ directly.
|
|
5
|
+
* The gateway's watcher handles lifecycle (timeout, completion notification).
|
|
6
|
+
* spawn() creates the process and persists state; the gateway adopts it on next poll.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { SubAgentOrchestratorImpl } from "../subagents/orchestrator";
|
|
13
|
+
import { validateRunId } from "../subagents/run-store";
|
|
14
|
+
import type { SpawnSpec, SubAgentRole, RoutingInfo } from "../subagents/types";
|
|
15
|
+
|
|
16
|
+
const ROUNDHOUSE_DIR = join(homedir(), ".roundhouse");
|
|
17
|
+
|
|
18
|
+
function loadGatewayConfig(): { notifyChatIds: number[] } {
|
|
19
|
+
try {
|
|
20
|
+
const raw = readFileSync(join(ROUNDHOUSE_DIR, "gateway.config.json"), "utf8");
|
|
21
|
+
const cfg = JSON.parse(raw);
|
|
22
|
+
return { notifyChatIds: cfg?.chat?.notifyChatIds ?? [] };
|
|
23
|
+
} catch {
|
|
24
|
+
return { notifyChatIds: [] };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildRouting(): RoutingInfo {
|
|
29
|
+
const cfg = loadGatewayConfig();
|
|
30
|
+
const chatId = String(cfg.notifyChatIds[0] ?? "");
|
|
31
|
+
if (!chatId) {
|
|
32
|
+
console.error("Error: no Telegram chat configured. Run 'roundhouse setup' first.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
transport: "telegram",
|
|
37
|
+
chatId,
|
|
38
|
+
parentThreadId: `telegram:${chatId}:main`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function handleSubagentCommand(args: string[]): Promise<void> {
|
|
43
|
+
const subcommand = args[0];
|
|
44
|
+
|
|
45
|
+
const orchestrator = new SubAgentOrchestratorImpl();
|
|
46
|
+
|
|
47
|
+
switch (subcommand) {
|
|
48
|
+
case "spawn": {
|
|
49
|
+
const role = getFlag(args, "--role") as SubAgentRole | undefined;
|
|
50
|
+
const task = getFlag(args, "--task");
|
|
51
|
+
const cwd = getFlag(args, "--cwd") || process.cwd();
|
|
52
|
+
const model = getFlag(args, "--model");
|
|
53
|
+
const timeoutStr = getFlag(args, "--timeout");
|
|
54
|
+
|
|
55
|
+
if (!role || !task) {
|
|
56
|
+
console.error("Usage: roundhouse subagent spawn --role <role> --task \"...\" [--cwd <dir>] [--model <id>] [--timeout <ms>]");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const validRoles: SubAgentRole[] = ["review", "research", "scout", "implementation"];
|
|
61
|
+
if (!validRoles.includes(role)) {
|
|
62
|
+
console.error(`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let timeoutMs: number | undefined;
|
|
67
|
+
if (timeoutStr) {
|
|
68
|
+
const n = Number(timeoutStr);
|
|
69
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
70
|
+
console.error(`Invalid timeout: ${timeoutStr}. Must be a positive number (milliseconds).`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
timeoutMs = n;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const spec: SpawnSpec = {
|
|
77
|
+
role,
|
|
78
|
+
task,
|
|
79
|
+
cwd,
|
|
80
|
+
routing: buildRouting(),
|
|
81
|
+
model: model || undefined,
|
|
82
|
+
timeoutMs,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const runId = await orchestrator.spawn(spec);
|
|
87
|
+
console.log(JSON.stringify({ runId, status: "spawned", role, cwd }));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`Spawn failed: ${(err as Error).message}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case "status": {
|
|
96
|
+
const runId = args[1];
|
|
97
|
+
if (!runId) {
|
|
98
|
+
console.error("Usage: roundhouse subagent status <runId>");
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
validateCliRunId(runId);
|
|
102
|
+
const status = await orchestrator.status(runId);
|
|
103
|
+
if (!status) {
|
|
104
|
+
console.error(`Run not found: ${runId}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
console.log(JSON.stringify(status, null, 2));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case "list": {
|
|
112
|
+
const statuses = await orchestrator.list();
|
|
113
|
+
if (statuses.length === 0) {
|
|
114
|
+
console.log("No sub-agent runs.");
|
|
115
|
+
} else {
|
|
116
|
+
for (const s of statuses) {
|
|
117
|
+
const duration = s.completedAt
|
|
118
|
+
? `${Math.round((Date.parse(s.completedAt) - Date.parse(s.startedAt)) / 1000)}s`
|
|
119
|
+
: "running";
|
|
120
|
+
console.log(`${s.status.padEnd(8)} ${s.role.padEnd(14)} ${duration.padEnd(8)} ${s.runId.slice(0, 8)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case "abort": {
|
|
127
|
+
const runId = args[1];
|
|
128
|
+
if (!runId) {
|
|
129
|
+
console.error("Usage: roundhouse subagent abort <runId>");
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
validateCliRunId(runId);
|
|
133
|
+
const current = await orchestrator.status(runId);
|
|
134
|
+
if (!current) {
|
|
135
|
+
console.error(`Run not found: ${runId}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
if (current.status !== "running") {
|
|
139
|
+
console.log(`Run already ${current.status}: ${runId.slice(0, 8)}`);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
await orchestrator.abort(runId);
|
|
143
|
+
const status = await orchestrator.status(runId);
|
|
144
|
+
if (status?.status === "running") {
|
|
145
|
+
console.log(`Abort requested: ${runId.slice(0, 8)}`);
|
|
146
|
+
} else {
|
|
147
|
+
console.log(`Aborted: ${runId.slice(0, 8)}`);
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
default:
|
|
153
|
+
console.error("Usage: roundhouse subagent <spawn|status|list|abort>");
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
159
|
+
const idx = args.indexOf(flag);
|
|
160
|
+
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
|
161
|
+
return args[idx + 1];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateCliRunId(runId: string): void {
|
|
165
|
+
try {
|
|
166
|
+
validateRunId(runId);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error((err as Error).message);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/gateway/commands.ts
CHANGED
|
@@ -211,7 +211,17 @@ export async function handleStatus(ctx: CommandContext): Promise<void> {
|
|
|
211
211
|
`🤖 Agent: ${agentLabel}`,
|
|
212
212
|
];
|
|
213
213
|
|
|
214
|
-
if (info.model
|
|
214
|
+
if (info.model && info.model !== "unknown") {
|
|
215
|
+
const configuredModel = info.configuredModel as string | undefined;
|
|
216
|
+
if (configuredModel && configuredModel !== info.model && info.hasActiveSession) {
|
|
217
|
+
lines.push(`🧠 Model: \`${configuredModel}\` (configured)`);
|
|
218
|
+
lines.push(` ↳ session using: \`${info.model}\` (until /new)`);
|
|
219
|
+
} else {
|
|
220
|
+
lines.push(`🧠 Model: \`${configuredModel || info.model}\``);
|
|
221
|
+
}
|
|
222
|
+
} else if (info.configuredModel) {
|
|
223
|
+
lines.push(`🧠 Model: \`${info.configuredModel}\``);
|
|
224
|
+
}
|
|
215
225
|
if (info.activeSessions !== undefined) lines.push(`💬 Active sessions: ${info.activeSessions}`);
|
|
216
226
|
|
|
217
227
|
lines.push(
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { handleLater } from "./later-command";
|
|
|
28
28
|
import { handleTopic, applyTopicOverride } from "./topic-command";
|
|
29
29
|
import { TelegramAdapter } from "../transports";
|
|
30
30
|
import type { TransportAdapter } from "../transports";
|
|
31
|
+
import { SubAgentOrchestratorImpl, SubAgentWatcher } from "../subagents";
|
|
32
|
+
import type { RunStatus, RoutingInfo } from "../subagents";
|
|
31
33
|
import { hostname } from "node:os";
|
|
32
34
|
import { join } from "node:path";
|
|
33
35
|
import { injectToolsSection } from "./tools-inject";
|
|
@@ -89,6 +91,8 @@ export class Gateway {
|
|
|
89
91
|
private sttService: SttService | null = null;
|
|
90
92
|
private cronScheduler: CronSchedulerService | null = null;
|
|
91
93
|
private ipcServer: IpcServer | null = null;
|
|
94
|
+
private subagentOrchestrator: SubAgentOrchestratorImpl | null = null;
|
|
95
|
+
private subagentWatcher: SubAgentWatcher | null = null;
|
|
92
96
|
|
|
93
97
|
constructor(router: AgentRouter, config: GatewayConfig) {
|
|
94
98
|
this.router = router;
|
|
@@ -362,8 +366,22 @@ export class Gateway {
|
|
|
362
366
|
console.error("[roundhouse] IPC server start failed:", (err as Error).message);
|
|
363
367
|
}
|
|
364
368
|
|
|
369
|
+
// Start sub-agent orchestrator + watcher
|
|
370
|
+
this.subagentOrchestrator = new SubAgentOrchestratorImpl();
|
|
371
|
+
this.subagentWatcher = new SubAgentWatcher(
|
|
372
|
+
this.subagentOrchestrator,
|
|
373
|
+
async (status, routing) => {
|
|
374
|
+
await this.handleSubagentCompletion(status, routing);
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
this.subagentWatcher.start();
|
|
378
|
+
console.log("[roundhouse] sub-agent watcher started");
|
|
379
|
+
|
|
365
380
|
// Send startup notification (after cron init so we can include job counts)
|
|
366
381
|
await this.notifyStartup(platforms);
|
|
382
|
+
|
|
383
|
+
// Fire boot turn — agent says hello (seeds session so it's never empty)
|
|
384
|
+
await this.fireBootTurn(verboseThreads, threadLocks, abortControllers);
|
|
367
385
|
}
|
|
368
386
|
|
|
369
387
|
/**
|
|
@@ -782,7 +800,62 @@ export class Gateway {
|
|
|
782
800
|
}
|
|
783
801
|
}
|
|
784
802
|
|
|
803
|
+
/**
|
|
804
|
+
* Fire a boot turn — send a prompt to the agent so it greets in-character.
|
|
805
|
+
* Seeds the session on startup so context is never empty.
|
|
806
|
+
*/
|
|
807
|
+
private async fireBootTurn(
|
|
808
|
+
verboseThreads: Set<string>,
|
|
809
|
+
threadLocks: Map<string, Promise<void>>,
|
|
810
|
+
abortControllers: Map<string, AbortController>,
|
|
811
|
+
) {
|
|
812
|
+
const chatIds = this.config.chat.notifyChatIds;
|
|
813
|
+
if (!chatIds?.length) return;
|
|
814
|
+
|
|
815
|
+
// Only fire for the primary (first) chat
|
|
816
|
+
const primaryChatId = chatIds[0];
|
|
817
|
+
const threadId = `telegram:${primaryChatId}`;
|
|
818
|
+
const agentThreadId = "main";
|
|
819
|
+
|
|
820
|
+
// Create a synthetic Telegram-compatible thread so streaming, HTML conversion,
|
|
821
|
+
// message splitting, and progressive edits all work identically to real chat threads.
|
|
822
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
823
|
+
const syntheticThread = {
|
|
824
|
+
id: threadId,
|
|
825
|
+
adapter: {
|
|
826
|
+
telegramFetch: async (method: string, payload: Record<string, unknown>) => {
|
|
827
|
+
if (!token) return null;
|
|
828
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
829
|
+
method: "POST",
|
|
830
|
+
headers: { "Content-Type": "application/json" },
|
|
831
|
+
body: JSON.stringify({ chat_id: primaryChatId, ...payload }),
|
|
832
|
+
signal: AbortSignal.timeout(30_000),
|
|
833
|
+
});
|
|
834
|
+
if (!res.ok) return null;
|
|
835
|
+
const json = await res.json() as { result?: unknown };
|
|
836
|
+
return json.result ?? null;
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
post: async (content: string | { markdown: string }) => {
|
|
840
|
+
const text = typeof content === "string" ? content : content.markdown;
|
|
841
|
+
await this.transport.notify([primaryChatId], text);
|
|
842
|
+
},
|
|
843
|
+
startTyping: async () => {},
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const bootPrompt = "You just came online after a restart. Say a brief hello in-character (1–2 sentences max). Check your workspace for any pending tasks.";
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
await this.handleAgentTurn(syntheticThread, agentThreadId, bootPrompt, [], verboseThreads, threadLocks, abortControllers);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
console.error("[roundhouse] boot turn failed:", (err as Error).message);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
785
855
|
async stop() {
|
|
856
|
+
if (this.subagentWatcher) {
|
|
857
|
+
this.subagentWatcher.stop();
|
|
858
|
+
}
|
|
786
859
|
if (this.ipcServer) {
|
|
787
860
|
this.ipcServer.stop();
|
|
788
861
|
}
|
|
@@ -793,4 +866,22 @@ export class Gateway {
|
|
|
793
866
|
await this.router.dispose();
|
|
794
867
|
console.log("[roundhouse] stopped");
|
|
795
868
|
}
|
|
869
|
+
|
|
870
|
+
/** Handle sub-agent completion — post result to originating thread */
|
|
871
|
+
private async handleSubagentCompletion(status: RunStatus, routing: RoutingInfo): Promise<void> {
|
|
872
|
+
const emoji = status.status === "complete" ? "✅" : status.status === "timeout" ? "⏰" : "❌";
|
|
873
|
+
const duration = status.completedAt && status.startedAt
|
|
874
|
+
? Math.round((Date.parse(status.completedAt) - Date.parse(status.startedAt)) / 1000)
|
|
875
|
+
: 0;
|
|
876
|
+
const summary = `${emoji} <b>Sub-agent ${status.status}</b> (${status.role})\n⏱ ${duration}s | run: <code>${status.runId.slice(0, 8)}</code>`;
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
const chatId = Number(routing.chatId);
|
|
880
|
+
if (chatId) {
|
|
881
|
+
await this.transport.notify([chatId], summary, { parseMode: "HTML" });
|
|
882
|
+
}
|
|
883
|
+
} catch (err) {
|
|
884
|
+
console.error("[roundhouse] sub-agent completion notification failed:", err);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
796
887
|
}
|
package/src/gateway/tools.md
CHANGED
|
@@ -176,3 +176,42 @@ aws s3 ls
|
|
|
176
176
|
aws logs tail /aws/lambda/<name> --since 1h
|
|
177
177
|
aws cloudformation describe-stacks --stack-name <name>
|
|
178
178
|
```
|
|
179
|
+
|
|
180
|
+
## Sub-Agent Delegation
|
|
181
|
+
|
|
182
|
+
Spawn background sub-agents for tasks that take time. The main conversation stays responsive.
|
|
183
|
+
|
|
184
|
+
**Usage:**
|
|
185
|
+
```bash
|
|
186
|
+
# Spawn a background agent
|
|
187
|
+
roundhouse subagent spawn --role <role> --task "..." --cwd <dir>
|
|
188
|
+
|
|
189
|
+
# Check status
|
|
190
|
+
roundhouse subagent status <runId>
|
|
191
|
+
roundhouse subagent list
|
|
192
|
+
|
|
193
|
+
# Cancel a running agent
|
|
194
|
+
roundhouse subagent abort <runId>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Roles:**
|
|
198
|
+
- `review` — Code review, architecture review, PR review
|
|
199
|
+
- `research` — Read docs, explore APIs, gather information
|
|
200
|
+
- `scout` — Search codebase, find patterns, map structure
|
|
201
|
+
- `implementation` — Write code, fix bugs, refactor
|
|
202
|
+
|
|
203
|
+
**Behavior:**
|
|
204
|
+
- Sub-agents run as background processes (15 min timeout default)
|
|
205
|
+
- You get notified when they complete
|
|
206
|
+
- Multiple agents can run concurrently
|
|
207
|
+
- They share the filesystem — no isolation between them
|
|
208
|
+
|
|
209
|
+
**When to delegate:**
|
|
210
|
+
- Task will take several minutes
|
|
211
|
+
- User wants to keep chatting about something else
|
|
212
|
+
- Independent work that doesn't need live interaction
|
|
213
|
+
|
|
214
|
+
**When NOT to delegate:**
|
|
215
|
+
- Quick tasks (< 30 seconds)
|
|
216
|
+
- Tasks needing user input mid-way
|
|
217
|
+
- You can just do it directly in the current turn
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SpawnSpec } from "./types";
|
|
2
|
+
|
|
3
|
+
export function buildBrief(spec: SpawnSpec): string {
|
|
4
|
+
const sections: string[] = [
|
|
5
|
+
"# Role",
|
|
6
|
+
spec.role,
|
|
7
|
+
"",
|
|
8
|
+
"# Task",
|
|
9
|
+
spec.task,
|
|
10
|
+
"",
|
|
11
|
+
"# Working Directory",
|
|
12
|
+
spec.cwd,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
if (spec.context?.briefing) {
|
|
16
|
+
sections.push("", "# Context", spec.context.briefing);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (spec.context?.targetFiles?.length) {
|
|
20
|
+
sections.push("", "# Target Files", ...spec.context.targetFiles.map((file) => `- ${file}`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (spec.context?.completionContract) {
|
|
24
|
+
sections.push("", "# Done When", spec.context.completionContract);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return sections.join("\n") + "\n";
|
|
28
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./brief";
|
|
2
|
+
export * from "./orchestrator";
|
|
3
|
+
export * from "./pid";
|
|
4
|
+
export * from "./process-launcher";
|
|
5
|
+
export * from "./run-store";
|
|
6
|
+
export * from "./types";
|
|
7
|
+
export * from "./watcher";
|
|
8
|
+
// Internal modules (not re-exported): run-finalizer.ts, termination-handler.ts
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
3
|
+
import type { ChildProcess } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { buildBrief } from "./brief";
|
|
7
|
+
import { isProcessAlive as defaultIsProcessAlive } from "./pid";
|
|
8
|
+
import { ProcessLauncher, type ProcessLauncherOptions } from "./process-launcher";
|
|
9
|
+
import { RunFinalizer } from "./run-finalizer";
|
|
10
|
+
import { RunStore } from "./run-store";
|
|
11
|
+
import { TerminationHandler } from "./termination-handler";
|
|
12
|
+
import type { RunStatus, SpawnSpec, SubAgentLifecycle, SubAgentOrchestrator } from "./types";
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
export interface OrchestratorOptions extends ProcessLauncherOptions {
|
|
16
|
+
dataRoot?: string;
|
|
17
|
+
isProcessAlive?: (pid: number, expectedTicks: string) => Promise<boolean>;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SubAgentOrchestratorImpl implements SubAgentOrchestrator, SubAgentLifecycle {
|
|
22
|
+
private readonly store: RunStore;
|
|
23
|
+
private readonly launcher: ProcessLauncher;
|
|
24
|
+
private readonly isProcessAlive: (pid: number, expectedTicks: string) => Promise<boolean>;
|
|
25
|
+
private readonly now: () => Date;
|
|
26
|
+
private readonly finalizer: RunFinalizer;
|
|
27
|
+
private readonly terminationHandler: TerminationHandler;
|
|
28
|
+
private readonly children = new Map<string, { pid: number }>();
|
|
29
|
+
private readonly statusReady = new Map<string, Promise<void>>();
|
|
30
|
+
|
|
31
|
+
constructor(options: OrchestratorOptions = {}) {
|
|
32
|
+
const dataRoot = options.dataRoot ?? join(homedir(), ".roundhouse");
|
|
33
|
+
this.store = new RunStore(dataRoot);
|
|
34
|
+
this.launcher = new ProcessLauncher(options);
|
|
35
|
+
this.isProcessAlive = options.isProcessAlive ?? defaultIsProcessAlive;
|
|
36
|
+
this.now = options.now ?? (() => new Date());
|
|
37
|
+
this.finalizer = new RunFinalizer({ store: this.store, now: this.now });
|
|
38
|
+
this.terminationHandler = new TerminationHandler({
|
|
39
|
+
store: this.store,
|
|
40
|
+
isProcessAlive: this.isProcessAlive,
|
|
41
|
+
signalProcess: this.launcher.signalProcess,
|
|
42
|
+
finalizeRun: this.finalizer.finalizeRun.bind(this.finalizer),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
onCompletion(listener: (status: RunStatus) => Promise<void> | void): () => void { return this.finalizer.onCompletion(listener); }
|
|
46
|
+
isRunManagedInProcess(runId: string): boolean { return this.children.has(runId); }
|
|
47
|
+
|
|
48
|
+
async spawn(spec: SpawnSpec): Promise<string> {
|
|
49
|
+
await assertDirectoryExists(spec.cwd);
|
|
50
|
+
this.launcher.assertAvailable();
|
|
51
|
+
|
|
52
|
+
const runId = randomUUID();
|
|
53
|
+
const runDir = this.store.getRunDir(runId);
|
|
54
|
+
const brief = buildBrief(spec);
|
|
55
|
+
const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
56
|
+
const startedAt = this.now();
|
|
57
|
+
|
|
58
|
+
await mkdir(runDir, { recursive: true });
|
|
59
|
+
await this.store.writeFile(runId, "brief.md", brief);
|
|
60
|
+
if (spec.model) {
|
|
61
|
+
await this.store.writeJson(runId, "settings.json", { defaultModel: spec.model });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let resolveReady: () => void;
|
|
65
|
+
const readyPromise = new Promise<void>((r) => { resolveReady = r; });
|
|
66
|
+
this.statusReady.set(runId, readyPromise);
|
|
67
|
+
let launchedChild: ChildProcess | undefined;
|
|
68
|
+
let launchedPid: number | undefined;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const { pid, spawnClockTicks } = await this.launcher.launch(runDir, spec.cwd, (child) => {
|
|
72
|
+
launchedChild = child;
|
|
73
|
+
child.on("exit", (exitCode) => {
|
|
74
|
+
void this.handleChildExit(runId, exitCode);
|
|
75
|
+
});
|
|
76
|
+
if (child.exitCode !== null) {
|
|
77
|
+
void this.handleChildExit(runId, child.exitCode);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
launchedPid = pid;
|
|
81
|
+
|
|
82
|
+
const initialStatus: RunStatus = {
|
|
83
|
+
runId,
|
|
84
|
+
role: spec.role,
|
|
85
|
+
cwd: spec.cwd,
|
|
86
|
+
routing: spec.routing,
|
|
87
|
+
status: "running",
|
|
88
|
+
pid,
|
|
89
|
+
startedAt: startedAt.toISOString(),
|
|
90
|
+
deadlineAt: new Date(startedAt.getTime() + timeoutMs).toISOString(),
|
|
91
|
+
spawnClockTicks,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this.children.set(runId, { pid });
|
|
95
|
+
await this.store.write(initialStatus);
|
|
96
|
+
resolveReady!();
|
|
97
|
+
return runId;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.children.delete(runId);
|
|
100
|
+
if (typeof launchedPid === "number") {
|
|
101
|
+
// Defense in depth: the launcher handles /proc/bootstrap failures, while the orchestrator
|
|
102
|
+
// still owns cleanup for later failures such as status.json persistence after launch.
|
|
103
|
+
try {
|
|
104
|
+
this.launcher.signalProcess(launchedPid, "SIGTERM");
|
|
105
|
+
} catch {}
|
|
106
|
+
} else if (typeof launchedChild?.pid === "number") {
|
|
107
|
+
try {
|
|
108
|
+
this.launcher.signalProcess(launchedChild.pid, "SIGTERM");
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
resolveReady!();
|
|
112
|
+
this.statusReady.delete(runId);
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async status(runId: string): Promise<RunStatus | null> {
|
|
118
|
+
const current = await this.store.read(runId);
|
|
119
|
+
if (!current) return null;
|
|
120
|
+
if (current.status !== "running") return current;
|
|
121
|
+
return this.refreshRunningStatus(current);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async list(): Promise<RunStatus[]> { return this.listRuns(true); }
|
|
125
|
+
async listRaw(): Promise<RunStatus[]> { return this.listRuns(false); }
|
|
126
|
+
|
|
127
|
+
private async listRuns(refresh: boolean): Promise<RunStatus[]> {
|
|
128
|
+
const dirs = await this.store.listDirs();
|
|
129
|
+
const statuses = await Promise.all(
|
|
130
|
+
dirs.map((id) => refresh ? this.status(id) : this.store.read(id)),
|
|
131
|
+
);
|
|
132
|
+
return statuses.filter((s): s is RunStatus => s !== null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async abort(runId: string): Promise<void> { await this.terminationHandler.terminateRun(runId, "aborted"); }
|
|
136
|
+
|
|
137
|
+
async enforceTimeout(runId: string): Promise<RunStatus | null> {
|
|
138
|
+
const current = await this.store.read(runId);
|
|
139
|
+
if (!current || current.status !== "running") return current;
|
|
140
|
+
return this.terminationHandler.terminateRun(runId, "timeout");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async recoverRun(runId: string): Promise<RunStatus | null> {
|
|
144
|
+
const current = await this.store.read(runId);
|
|
145
|
+
if (!current || current.status !== "running") return current;
|
|
146
|
+
return this.refreshRunningStatus(current, true);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async refreshRunningStatus(current: RunStatus, notify = false): Promise<RunStatus> {
|
|
150
|
+
const alive = await this.isProcessAlive(current.pid, current.spawnClockTicks);
|
|
151
|
+
if (alive) return current;
|
|
152
|
+
|
|
153
|
+
const outcome = this.terminationHandler.terminalStatusFor(current);
|
|
154
|
+
return this.finalizer.finalizeRun(current.runId, outcome, { notify });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async handleChildExit(runId: string, exitCode: number | null): Promise<void> {
|
|
158
|
+
const ready = this.statusReady.get(runId);
|
|
159
|
+
if (ready) {
|
|
160
|
+
await ready;
|
|
161
|
+
this.statusReady.delete(runId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.children.delete(runId);
|
|
165
|
+
|
|
166
|
+
const current = await this.store.read(runId);
|
|
167
|
+
if (!current || current.status !== "running") return;
|
|
168
|
+
|
|
169
|
+
const outcome = current.requestedOutcome
|
|
170
|
+
? this.terminationHandler.terminalStatusFor(current)
|
|
171
|
+
: (exitCode === 0 ? "complete" : "failed");
|
|
172
|
+
|
|
173
|
+
await this.finalizer.finalizeRun(runId, outcome, { exitCode: exitCode ?? undefined });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function assertDirectoryExists(path: string): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
const info = await stat(path);
|
|
180
|
+
if (!info.isDirectory()) throw new Error(`Sub-agent cwd is not a directory: ${path}`);
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
if (err?.code === "ENOENT") throw new Error(`Sub-agent cwd does not exist: ${path}`);
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export interface ParsedStatFile {
|
|
4
|
+
state: string;
|
|
5
|
+
starttime: string;
|
|
6
|
+
isZombie: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseStatFile(content: string): ParsedStatFile {
|
|
10
|
+
const trimmed = content.trim();
|
|
11
|
+
// Field 2 (comm) is parenthesized and can contain spaces/parens.
|
|
12
|
+
// Strip everything through the LAST ") " to safely reach field 3+.
|
|
13
|
+
const boundary = trimmed.lastIndexOf(") ");
|
|
14
|
+
if (boundary === -1) {
|
|
15
|
+
throw new Error("Invalid /proc stat format");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// After stripping pid + comm, remainder starts at field 3.
|
|
19
|
+
// fields[0] = state (field 3), fields[19] = starttime (field 22).
|
|
20
|
+
const remainder = trimmed.slice(boundary + 2).trim();
|
|
21
|
+
const fields = remainder.split(/\s+/);
|
|
22
|
+
if (fields.length < 20) {
|
|
23
|
+
throw new Error("Incomplete /proc stat format");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const state = fields[0];
|
|
27
|
+
const starttime = fields[19]; // Original /proc field 22 (starttime)
|
|
28
|
+
if (!state || !starttime) {
|
|
29
|
+
throw new Error("Missing required /proc stat fields");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
state,
|
|
34
|
+
starttime,
|
|
35
|
+
isZombie: state === "Z",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readSpawnClockTicks(pid: number): Promise<string> {
|
|
40
|
+
const content = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
41
|
+
return parseStatFile(content).starttime;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function isProcessAlive(pid: number, expectedTicks: string): Promise<boolean> {
|
|
45
|
+
try {
|
|
46
|
+
const content = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
47
|
+
const parsed = parseStatFile(content);
|
|
48
|
+
return !parsed.isZombie && parsed.starttime === expectedTicks;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readSpawnClockTicks as defaultReadSpawnClockTicks } from "./pid";
|
|
5
|
+
|
|
6
|
+
export interface LaunchResult {
|
|
7
|
+
child: ChildProcess;
|
|
8
|
+
pid: number;
|
|
9
|
+
spawnClockTicks: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProcessLauncherOptions {
|
|
13
|
+
spawnProcess?: typeof spawn;
|
|
14
|
+
checkPiAvailable?: () => void;
|
|
15
|
+
readSpawnClockTicks?: (pid: number) => Promise<string>;
|
|
16
|
+
signalProcess?: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ProcessLauncher {
|
|
20
|
+
private readonly spawnProcess: typeof spawn;
|
|
21
|
+
private readonly checkPiAvailable: () => void;
|
|
22
|
+
private readonly readSpawnClockTicksFn: (pid: number) => Promise<string>;
|
|
23
|
+
readonly signalProcess: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
|
|
24
|
+
|
|
25
|
+
constructor(options: ProcessLauncherOptions = {}) {
|
|
26
|
+
this.spawnProcess = options.spawnProcess ?? spawn;
|
|
27
|
+
this.checkPiAvailable = options.checkPiAvailable ?? defaultPiAvailabilityCheck;
|
|
28
|
+
this.readSpawnClockTicksFn = options.readSpawnClockTicks ?? defaultReadSpawnClockTicks;
|
|
29
|
+
this.signalProcess = options.signalProcess ?? defaultSignalProcess;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
assertAvailable(): void {
|
|
33
|
+
this.checkPiAvailable();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async launch(runDir: string, cwd: string, onChildCreated?: (child: ChildProcess) => void): Promise<LaunchResult> {
|
|
37
|
+
const stdoutHandle = await open(join(runDir, "stdout.log"), "a");
|
|
38
|
+
const stderrHandle = await open(join(runDir, "stderr.log"), "a");
|
|
39
|
+
let child: ChildProcess | undefined;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
child = this.spawnProcess("pi", ["--session-dir", runDir, "-p", `@${join(runDir, "brief.md")}`], {
|
|
43
|
+
cwd,
|
|
44
|
+
detached: true,
|
|
45
|
+
stdio: ["ignore", stdoutHandle.fd, stderrHandle.fd],
|
|
46
|
+
});
|
|
47
|
+
onChildCreated?.(child);
|
|
48
|
+
|
|
49
|
+
await waitForChildSpawn(child);
|
|
50
|
+
|
|
51
|
+
if (typeof child.pid !== "number") {
|
|
52
|
+
throw new Error("Sub-agent process did not expose a PID");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const spawnClockTicks = await this.readSpawnClockTicksFn(child.pid);
|
|
56
|
+
child.unref();
|
|
57
|
+
|
|
58
|
+
return { child, pid: child.pid, spawnClockTicks };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (typeof child?.pid === "number") {
|
|
61
|
+
try {
|
|
62
|
+
this.signalProcess(child.pid, "SIGTERM");
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
} finally {
|
|
67
|
+
await Promise.allSettled([stdoutHandle.close(), stderrHandle.close()]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function defaultPiAvailabilityCheck(): void {
|
|
73
|
+
if (piAvailableCache === true) return;
|
|
74
|
+
// No negative caching — retry on every spawn so installing pi mid-session works
|
|
75
|
+
try {
|
|
76
|
+
execFileSync("which", ["pi"], { stdio: "pipe" });
|
|
77
|
+
piAvailableCache = true;
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error("pi executable not found in PATH");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let piAvailableCache: boolean | undefined;
|
|
84
|
+
|
|
85
|
+
function defaultSignalProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
|
|
86
|
+
process.kill(pid, signal);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function waitForChildSpawn(child: ChildProcess): Promise<void> {
|
|
90
|
+
await new Promise<void>((resolve, reject) => {
|
|
91
|
+
const onSpawn = (): void => {
|
|
92
|
+
child.off("error", onError);
|
|
93
|
+
resolve();
|
|
94
|
+
};
|
|
95
|
+
const onError = (err: Error): void => {
|
|
96
|
+
child.off("spawn", onSpawn);
|
|
97
|
+
reject(err);
|
|
98
|
+
};
|
|
99
|
+
child.once("spawn", onSpawn);
|
|
100
|
+
child.once("error", onError);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { RunStore } from "./run-store";
|
|
2
|
+
import type { RunStatus, TerminalStatus } from "./types";
|
|
3
|
+
|
|
4
|
+
type CompletionListener = (status: RunStatus) => Promise<void> | void;
|
|
5
|
+
|
|
6
|
+
export interface RunFinalizerOptions {
|
|
7
|
+
store: RunStore;
|
|
8
|
+
now: () => Date;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class RunFinalizer {
|
|
12
|
+
private readonly store: RunStore;
|
|
13
|
+
private readonly now: () => Date;
|
|
14
|
+
private readonly completionListeners = new Set<CompletionListener>();
|
|
15
|
+
private readonly finalizingRuns = new Map<string, Promise<RunStatus>>();
|
|
16
|
+
|
|
17
|
+
constructor(options: RunFinalizerOptions) {
|
|
18
|
+
this.store = options.store;
|
|
19
|
+
this.now = options.now;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
onCompletion(listener: CompletionListener): () => void {
|
|
23
|
+
this.completionListeners.add(listener);
|
|
24
|
+
return () => { this.completionListeners.delete(listener); };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async finalizeRun(
|
|
28
|
+
runId: string,
|
|
29
|
+
status: TerminalStatus,
|
|
30
|
+
extra: { exitCode?: number; notify?: boolean },
|
|
31
|
+
): Promise<RunStatus> {
|
|
32
|
+
const inFlight = this.finalizingRuns.get(runId);
|
|
33
|
+
if (inFlight) return inFlight;
|
|
34
|
+
|
|
35
|
+
const finalization = (async (): Promise<RunStatus> => {
|
|
36
|
+
const latest = await this.store.read(runId);
|
|
37
|
+
if (!latest) throw new Error(`Unknown sub-agent run: ${runId}`);
|
|
38
|
+
if (latest.status !== "running") return latest;
|
|
39
|
+
|
|
40
|
+
const updated: RunStatus = {
|
|
41
|
+
...latest,
|
|
42
|
+
status,
|
|
43
|
+
completedAt: this.now().toISOString(),
|
|
44
|
+
exitCode: extra.exitCode ?? latest.exitCode,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
await this.store.write(updated);
|
|
48
|
+
if (extra.notify !== false) {
|
|
49
|
+
await Promise.allSettled(
|
|
50
|
+
[...this.completionListeners].map((listener) => Promise.resolve(listener(updated))),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return updated;
|
|
54
|
+
})();
|
|
55
|
+
|
|
56
|
+
this.finalizingRuns.set(runId, finalization);
|
|
57
|
+
try {
|
|
58
|
+
return await finalization;
|
|
59
|
+
} finally {
|
|
60
|
+
this.finalizingRuns.delete(runId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { RunStatus } from "./types";
|
|
5
|
+
|
|
6
|
+
const RUN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
7
|
+
|
|
8
|
+
export function validateRunId(runId: string): string {
|
|
9
|
+
if (!RUN_ID_RE.test(runId)) {
|
|
10
|
+
throw new Error(`Invalid sub-agent run ID: ${runId}`);
|
|
11
|
+
}
|
|
12
|
+
return runId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class RunStore {
|
|
16
|
+
private readonly subagentsRoot: string;
|
|
17
|
+
|
|
18
|
+
constructor(dataRoot: string) {
|
|
19
|
+
this.subagentsRoot = join(dataRoot, "subagents");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getRunDir(runId: string): string {
|
|
23
|
+
return join(this.subagentsRoot, validateRunId(runId));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async read(runId: string): Promise<RunStatus | null> {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(join(this.getRunDir(runId), "status.json"), "utf8");
|
|
29
|
+
return JSON.parse(raw) as RunStatus;
|
|
30
|
+
} catch (err: any) {
|
|
31
|
+
if (err?.code === "ENOENT") return null;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async write(status: RunStatus): Promise<void> {
|
|
37
|
+
const dir = this.getRunDir(status.runId);
|
|
38
|
+
await mkdir(dir, { recursive: true });
|
|
39
|
+
await atomicWriteJson(join(dir, "status.json"), status);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async listDirs(): Promise<string[]> {
|
|
43
|
+
try {
|
|
44
|
+
const entries = await readdir(this.subagentsRoot, { withFileTypes: true });
|
|
45
|
+
return entries
|
|
46
|
+
.filter((e) => e.isDirectory() && RUN_ID_RE.test(e.name))
|
|
47
|
+
.map((e) => e.name);
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
if (err?.code === "ENOENT") return [];
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async writeFile(runId: string, filename: string, content: string): Promise<void> {
|
|
55
|
+
const dir = this.getRunDir(runId);
|
|
56
|
+
await mkdir(dir, { recursive: true });
|
|
57
|
+
await atomicWriteText(join(dir, filename), content);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async writeJson(runId: string, filename: string, value: unknown): Promise<void> {
|
|
61
|
+
const dir = this.getRunDir(runId);
|
|
62
|
+
await mkdir(dir, { recursive: true });
|
|
63
|
+
await atomicWriteJson(join(dir, filename), value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
|
|
68
|
+
await atomicWriteText(path, JSON.stringify(value, null, 2) + "\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function atomicWriteText(path: string, content: string): Promise<void> {
|
|
72
|
+
const tmp = `${path}.tmp.${randomUUID()}`;
|
|
73
|
+
try {
|
|
74
|
+
await writeFile(tmp, content, { mode: 0o600 });
|
|
75
|
+
await rename(tmp, path);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
try { await unlink(tmp); } catch {}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { RunStatus, TerminalStatus } from "./types";
|
|
2
|
+
import { RunStore } from "./run-store";
|
|
3
|
+
|
|
4
|
+
const TERMINATE_GRACE_MS = 10_000;
|
|
5
|
+
|
|
6
|
+
type RequestedOutcome = NonNullable<RunStatus["requestedOutcome"]>;
|
|
7
|
+
|
|
8
|
+
export interface TerminationHandlerOptions {
|
|
9
|
+
store: RunStore;
|
|
10
|
+
isProcessAlive: (pid: number, expectedTicks: string) => Promise<boolean>;
|
|
11
|
+
signalProcess: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
|
|
12
|
+
finalizeRun: (
|
|
13
|
+
runId: string,
|
|
14
|
+
status: TerminalStatus,
|
|
15
|
+
extra: { exitCode?: number; notify?: boolean },
|
|
16
|
+
) => Promise<RunStatus>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TerminationHandler {
|
|
20
|
+
private readonly store: RunStore;
|
|
21
|
+
private readonly isProcessAlive: (pid: number, expectedTicks: string) => Promise<boolean>;
|
|
22
|
+
private readonly signalProcess: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
|
|
23
|
+
private readonly finalizeRun: (
|
|
24
|
+
runId: string,
|
|
25
|
+
status: TerminalStatus,
|
|
26
|
+
extra: { exitCode?: number; notify?: boolean },
|
|
27
|
+
) => Promise<RunStatus>;
|
|
28
|
+
|
|
29
|
+
constructor(options: TerminationHandlerOptions) {
|
|
30
|
+
this.store = options.store;
|
|
31
|
+
this.isProcessAlive = options.isProcessAlive;
|
|
32
|
+
this.signalProcess = options.signalProcess;
|
|
33
|
+
this.finalizeRun = options.finalizeRun;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async terminateRun(runId: string, outcome: RequestedOutcome): Promise<RunStatus | null> {
|
|
37
|
+
const current = await this.store.read(runId);
|
|
38
|
+
if (!current || current.status !== "running") return current;
|
|
39
|
+
const updated = await this.persistRequestedOutcome(current, outcome);
|
|
40
|
+
|
|
41
|
+
const alive = await this.isProcessAlive(updated.pid, updated.spawnClockTicks);
|
|
42
|
+
if (!alive) {
|
|
43
|
+
return this.finalizeRun(runId, this.terminalStatusFor(updated), {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
this.signalProcess(updated.pid, "SIGTERM");
|
|
48
|
+
} catch {
|
|
49
|
+
return this.finalizeRun(runId, this.terminalStatusFor(updated), {});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
void this.escalateTermination(runId, updated.pid, updated.spawnClockTicks);
|
|
54
|
+
}, TERMINATE_GRACE_MS);
|
|
55
|
+
|
|
56
|
+
return updated;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async escalateTermination(runId: string, pid: number, spawnClockTicks: string): Promise<void> {
|
|
60
|
+
const current = await this.store.read(runId);
|
|
61
|
+
if (!current || current.status !== "running") return;
|
|
62
|
+
if (current.pid !== pid || current.spawnClockTicks !== spawnClockTicks) return;
|
|
63
|
+
|
|
64
|
+
const alive = await this.isProcessAlive(pid, spawnClockTicks);
|
|
65
|
+
if (!alive) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
this.signalProcess(pid, "SIGKILL");
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
terminalStatusFor(status: RunStatus): TerminalStatus {
|
|
73
|
+
if (status.requestedOutcome === "timeout") return "timeout";
|
|
74
|
+
// "aborted" maps to "failed" because there's no "aborted" terminal status in the
|
|
75
|
+
// RunStatus union — abort is an intent, "failed" is the observable outcome.
|
|
76
|
+
if (status.requestedOutcome === "aborted") return "failed";
|
|
77
|
+
return "failed";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async persistRequestedOutcome(current: RunStatus, requestedOutcome: RequestedOutcome): Promise<RunStatus> {
|
|
81
|
+
if (current.requestedOutcome === requestedOutcome) return current;
|
|
82
|
+
const updated: RunStatus = { ...current, requestedOutcome };
|
|
83
|
+
await this.store.write(updated);
|
|
84
|
+
return updated;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type SubAgentRole = "review" | "research" | "scout" | "implementation";
|
|
2
|
+
|
|
3
|
+
/** Terminal states for a sub-agent run (excludes "running") */
|
|
4
|
+
export type TerminalStatus = Exclude<RunStatus["status"], "running">;
|
|
5
|
+
|
|
6
|
+
export interface RoutingInfo {
|
|
7
|
+
transport: "telegram";
|
|
8
|
+
chatId: string;
|
|
9
|
+
topicId?: string;
|
|
10
|
+
parentThreadId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SpawnSpec {
|
|
14
|
+
role: SubAgentRole;
|
|
15
|
+
task: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
routing: RoutingInfo;
|
|
18
|
+
context?: {
|
|
19
|
+
briefing?: string;
|
|
20
|
+
targetFiles?: string[];
|
|
21
|
+
completionContract?: string;
|
|
22
|
+
};
|
|
23
|
+
model?: string;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RunStatus {
|
|
28
|
+
runId: string;
|
|
29
|
+
role: SubAgentRole;
|
|
30
|
+
cwd: string;
|
|
31
|
+
routing: RoutingInfo;
|
|
32
|
+
status: "running" | "complete" | "failed" | "timeout";
|
|
33
|
+
requestedOutcome?: "aborted" | "timeout";
|
|
34
|
+
pid: number;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
deadlineAt?: string;
|
|
37
|
+
completedAt?: string;
|
|
38
|
+
exitCode?: number;
|
|
39
|
+
spawnClockTicks: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Public API for consumers (gateway, commands, agent tools) */
|
|
43
|
+
export interface SubAgentOrchestrator {
|
|
44
|
+
spawn(spec: SpawnSpec): Promise<string>;
|
|
45
|
+
status(runId: string): Promise<RunStatus | null>;
|
|
46
|
+
list(): Promise<RunStatus[]>;
|
|
47
|
+
abort(runId: string): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Internal API used by SubAgentWatcher for lifecycle management */
|
|
51
|
+
export interface SubAgentLifecycle {
|
|
52
|
+
listRaw(): Promise<RunStatus[]>;
|
|
53
|
+
recoverRun(runId: string): Promise<RunStatus | null>;
|
|
54
|
+
enforceTimeout(runId: string): Promise<RunStatus | null>;
|
|
55
|
+
isRunManagedInProcess(runId: string): boolean;
|
|
56
|
+
onCompletion(listener: (status: RunStatus) => Promise<void> | void): () => void;
|
|
57
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { RoutingInfo, RunStatus, SubAgentLifecycle } from "./types";
|
|
2
|
+
|
|
3
|
+
export class SubAgentWatcher {
|
|
4
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
5
|
+
private unsubscribe: (() => void) | null = null;
|
|
6
|
+
private polling = false;
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly orchestrator: SubAgentLifecycle,
|
|
10
|
+
private readonly notifyCompletion: (status: RunStatus, routing: RoutingInfo) => Promise<void> | void,
|
|
11
|
+
private readonly pollIntervalMs = 5000,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
start(): void {
|
|
15
|
+
if (this.timer) return;
|
|
16
|
+
|
|
17
|
+
this.unsubscribe = this.orchestrator.onCompletion((status) =>
|
|
18
|
+
this.notifyCompletion(status, status.routing),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
this.timer = setInterval(() => {
|
|
22
|
+
void this.poll().catch((err) => {
|
|
23
|
+
console.error("[roundhouse] subagent watcher poll error:", err);
|
|
24
|
+
});
|
|
25
|
+
}, this.pollIntervalMs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stop(): void {
|
|
29
|
+
if (this.timer) {
|
|
30
|
+
clearInterval(this.timer);
|
|
31
|
+
this.timer = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (this.unsubscribe) {
|
|
35
|
+
this.unsubscribe();
|
|
36
|
+
this.unsubscribe = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async poll(): Promise<void> {
|
|
41
|
+
if (this.polling) return;
|
|
42
|
+
this.polling = true;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const statuses = await this.orchestrator.listRaw();
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
|
|
48
|
+
for (const status of statuses) {
|
|
49
|
+
if (status.status !== "running") continue;
|
|
50
|
+
if (this.orchestrator.isRunManagedInProcess(status.runId)) continue;
|
|
51
|
+
|
|
52
|
+
if (status.deadlineAt && Date.parse(status.deadlineAt) <= now) {
|
|
53
|
+
await this.orchestrator.enforceTimeout(status.runId);
|
|
54
|
+
} else {
|
|
55
|
+
// Detect crashed out-of-process children and finalize with notification
|
|
56
|
+
await this.orchestrator.recoverRun(status.runId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
this.polling = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -50,12 +50,12 @@ export class TelegramAdapter implements TransportAdapter {
|
|
|
50
50
|
return isTelegramThread(thread as any);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async notify(chatIds: number[], text: string): Promise<void> {
|
|
53
|
+
async notify(chatIds: number[], text: string, options?: { parseMode?: string }): Promise<void> {
|
|
54
54
|
if (!process.env.TELEGRAM_BOT_TOKEN) {
|
|
55
55
|
console.warn("[roundhouse] TELEGRAM_BOT_TOKEN not set — skipping notification");
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
-
await sendTelegramToMany(chatIds, text);
|
|
58
|
+
await sendTelegramToMany(chatIds, text, options);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
async isPairingPending(): Promise<boolean> {
|
package/src/transports/types.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface TransportAdapter {
|
|
|
54
54
|
ownsThread(thread: ChatThread): boolean;
|
|
55
55
|
|
|
56
56
|
/** Send notifications to configured recipients */
|
|
57
|
-
notify(chatIds: number[], text: string): Promise<void>;
|
|
57
|
+
notify(chatIds: number[], text: string, options?: { parseMode?: string }): Promise<void>;
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Check if a pairing flow is pending.
|