@hyperdrive.bot/bmad-workflow 1.0.26 → 1.0.27
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/dist/commands/epics/create.d.ts +1 -0
- package/dist/commands/lock/acquire.d.ts +54 -0
- package/dist/commands/lock/acquire.js +193 -0
- package/dist/commands/lock/cleanup.d.ts +38 -0
- package/dist/commands/lock/cleanup.js +148 -0
- package/dist/commands/lock/list.d.ts +31 -0
- package/dist/commands/lock/list.js +123 -0
- package/dist/commands/lock/release.d.ts +42 -0
- package/dist/commands/lock/release.js +134 -0
- package/dist/commands/lock/status.d.ts +34 -0
- package/dist/commands/lock/status.js +109 -0
- package/dist/commands/stories/create.d.ts +1 -0
- package/dist/commands/stories/develop.d.ts +4 -0
- package/dist/commands/stories/develop.js +55 -5
- package/dist/commands/stories/qa.d.ts +1 -0
- package/dist/commands/stories/qa.js +31 -0
- package/dist/commands/stories/review.d.ts +1 -0
- package/dist/commands/workflow.d.ts +11 -0
- package/dist/commands/workflow.js +120 -4
- package/dist/models/agent-options.d.ts +33 -0
- package/dist/models/agent-result.d.ts +10 -1
- package/dist/models/dispatch.d.ts +16 -0
- package/dist/models/dispatch.js +8 -0
- package/dist/models/index.d.ts +3 -0
- package/dist/models/index.js +2 -0
- package/dist/models/lock.d.ts +80 -0
- package/dist/models/lock.js +69 -0
- package/dist/models/phase-result.d.ts +8 -0
- package/dist/models/provider.js +1 -1
- package/dist/models/workflow-callbacks.d.ts +37 -0
- package/dist/models/workflow-config.d.ts +50 -0
- package/dist/services/agents/agent-runner-factory.d.ts +24 -15
- package/dist/services/agents/agent-runner-factory.js +95 -15
- package/dist/services/agents/channel-agent-runner.d.ts +76 -0
- package/dist/services/agents/channel-agent-runner.js +256 -0
- package/dist/services/agents/channel-session-manager.d.ts +126 -0
- package/dist/services/agents/channel-session-manager.js +260 -0
- package/dist/services/agents/claude-agent-runner.d.ts +9 -50
- package/dist/services/agents/claude-agent-runner.js +221 -199
- package/dist/services/agents/gemini-agent-runner.js +3 -0
- package/dist/services/agents/index.d.ts +1 -0
- package/dist/services/agents/index.js +1 -0
- package/dist/services/agents/opencode-agent-runner.js +3 -0
- package/dist/services/file-system/file-manager.d.ts +11 -0
- package/dist/services/file-system/file-manager.js +26 -0
- package/dist/services/git/git-ops.d.ts +58 -0
- package/dist/services/git/git-ops.js +73 -0
- package/dist/services/git/index.d.ts +3 -0
- package/dist/services/git/index.js +2 -0
- package/dist/services/git/push-conflict-handler.d.ts +32 -0
- package/dist/services/git/push-conflict-handler.js +84 -0
- package/dist/services/lock/git-backed-lock-service.d.ts +76 -0
- package/dist/services/lock/git-backed-lock-service.js +173 -0
- package/dist/services/lock/lock-cleanup.d.ts +49 -0
- package/dist/services/lock/lock-cleanup.js +85 -0
- package/dist/services/lock/lock-service.d.ts +143 -0
- package/dist/services/lock/lock-service.js +290 -0
- package/dist/services/orchestration/locked-story-dispatcher.d.ts +40 -0
- package/dist/services/orchestration/locked-story-dispatcher.js +84 -0
- package/dist/services/orchestration/workflow-orchestrator.d.ts +31 -0
- package/dist/services/orchestration/workflow-orchestrator.js +181 -31
- package/dist/services/review/ai-review-scanner.js +1 -0
- package/dist/services/review/review-phase-executor.js +3 -0
- package/dist/services/review/self-heal-loop.js +1 -0
- package/dist/services/review/types.d.ts +2 -0
- package/dist/utils/errors.d.ts +17 -1
- package/dist/utils/errors.js +18 -0
- package/dist/utils/session-naming.d.ts +23 -0
- package/dist/utils/session-naming.js +30 -0
- package/dist/utils/shared-flags.d.ts +1 -0
- package/dist/utils/shared-flags.js +5 -0
- package/package.json +3 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelSessionManager — Agent Lifecycle Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of Channel-connected Claude Code agent sessions:
|
|
5
|
+
* start (with discovery polling), health check (ping/pong), stop, and cleanup.
|
|
6
|
+
*
|
|
7
|
+
* Used by the orchestrator to pre-provision agent sessions before workflow runs
|
|
8
|
+
* and guarantee clean shutdown on errors or SIGINT.
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
let _discoveryModule = null;
|
|
14
|
+
function loadDiscovery() {
|
|
15
|
+
if (!_discoveryModule) {
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
17
|
+
_discoveryModule = require('@hyperdrive.bot/claude-channels/dist/services/discovery.js');
|
|
18
|
+
}
|
|
19
|
+
return _discoveryModule;
|
|
20
|
+
}
|
|
21
|
+
const defaultListAgents = () => loadDiscovery().listAgents();
|
|
22
|
+
const defaultDeregister = (sessionId) => loadDiscovery().deregisterAgent(sessionId);
|
|
23
|
+
/**
|
|
24
|
+
* Error thrown when agent session start times out waiting for discovery registration
|
|
25
|
+
*/
|
|
26
|
+
export class SessionStartTimeoutError extends Error {
|
|
27
|
+
agentType;
|
|
28
|
+
timeoutMs;
|
|
29
|
+
constructor(agentType, timeoutMs) {
|
|
30
|
+
super(`Agent session '${agentType}' failed to register within ${timeoutMs}ms`);
|
|
31
|
+
this.name = 'SessionStartTimeoutError';
|
|
32
|
+
this.agentType = agentType;
|
|
33
|
+
this.timeoutMs = timeoutMs;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_START_TIMEOUT = 30_000;
|
|
37
|
+
const DEFAULT_HEALTH_TIMEOUT = 2_000;
|
|
38
|
+
const POLL_INTERVAL = 500;
|
|
39
|
+
/**
|
|
40
|
+
* Manages Channel-connected Claude Code agent session lifecycles.
|
|
41
|
+
*
|
|
42
|
+
* Handles spawning, discovery polling, health checks, graceful shutdown,
|
|
43
|
+
* and SIGINT cleanup for multiple concurrent agent sessions.
|
|
44
|
+
*/
|
|
45
|
+
export class ChannelSessionManager {
|
|
46
|
+
static sigintRegistered = false;
|
|
47
|
+
sessions = new Map();
|
|
48
|
+
logger;
|
|
49
|
+
listAgentsFn;
|
|
50
|
+
deregisterAgentFn;
|
|
51
|
+
spawnFn;
|
|
52
|
+
constructor(logger, deps = {}) {
|
|
53
|
+
this.logger = logger;
|
|
54
|
+
this.listAgentsFn = deps.listAgents ?? defaultListAgents;
|
|
55
|
+
this.deregisterAgentFn = deps.deregisterAgent ?? defaultDeregister;
|
|
56
|
+
this.spawnFn = deps.spawn ?? nodeSpawn;
|
|
57
|
+
this.registerSigintHandler();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Start a new agent session and wait for it to register via discovery.
|
|
61
|
+
*
|
|
62
|
+
* Spawns a Claude Code subprocess with --channel flag, then polls listAgents()
|
|
63
|
+
* at 500ms intervals until a new AgentRegistration appears. Returns a SessionHandle
|
|
64
|
+
* on success, throws SessionStartTimeoutError if discovery times out.
|
|
65
|
+
*
|
|
66
|
+
* @param agentType - Agent type identifier (used to match discovery registration)
|
|
67
|
+
* @param config - Session configuration
|
|
68
|
+
* @returns SessionHandle with session details
|
|
69
|
+
* @throws SessionStartTimeoutError if agent doesn't register within startTimeout
|
|
70
|
+
*/
|
|
71
|
+
async startAgentSession(agentType, config = {}) {
|
|
72
|
+
const startTimeout = config.startTimeout ?? DEFAULT_START_TIMEOUT;
|
|
73
|
+
this.logger.info({ agentType, startTimeout }, 'Starting agent session');
|
|
74
|
+
// Snapshot existing agent session IDs before spawning
|
|
75
|
+
const existingIds = new Set(this.listAgentsFn().map((a) => a.sessionId));
|
|
76
|
+
// Spawn Claude Code with --channel flag
|
|
77
|
+
const command = config.command ?? 'claude';
|
|
78
|
+
const args = ['--channel'];
|
|
79
|
+
if (config.model) {
|
|
80
|
+
args.push('--model', config.model);
|
|
81
|
+
}
|
|
82
|
+
const env = { ...process.env };
|
|
83
|
+
delete env.CLAUDECODE;
|
|
84
|
+
const child = this.spawnFn(command, args, {
|
|
85
|
+
cwd: config.cwd,
|
|
86
|
+
env,
|
|
87
|
+
stdio: 'pipe',
|
|
88
|
+
});
|
|
89
|
+
const pid = child.pid;
|
|
90
|
+
this.logger.info({ agentType, pid }, 'Spawned Claude Code process');
|
|
91
|
+
// Register exit listener for unexpected process death
|
|
92
|
+
child.on('exit', (code, signal) => {
|
|
93
|
+
for (const [sessionId, handle] of this.sessions.entries()) {
|
|
94
|
+
if (handle.pid === pid) {
|
|
95
|
+
this.logger.warn({ agentType, code, pid, sessionId, signal }, 'Agent process exited unexpectedly');
|
|
96
|
+
this.sessions.delete(sessionId);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// Poll for new discovery registration
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const poll = () => {
|
|
105
|
+
if (Date.now() - startTime > startTimeout) {
|
|
106
|
+
try {
|
|
107
|
+
child.kill('SIGTERM');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Process may already be dead
|
|
111
|
+
}
|
|
112
|
+
reject(new SessionStartTimeoutError(agentType, startTimeout));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const agents = this.listAgentsFn();
|
|
117
|
+
const newAgent = agents.find((a) => !existingIds.has(a.sessionId));
|
|
118
|
+
if (newAgent) {
|
|
119
|
+
const handle = {
|
|
120
|
+
agentType,
|
|
121
|
+
pid,
|
|
122
|
+
port: newAgent.port ?? 0,
|
|
123
|
+
process: child,
|
|
124
|
+
sessionId: newAgent.sessionId,
|
|
125
|
+
};
|
|
126
|
+
this.sessions.set(newAgent.sessionId, handle);
|
|
127
|
+
this.logger.info({ agentType, pid, port: newAgent.port, sessionId: newAgent.sessionId }, 'Agent session registered via discovery');
|
|
128
|
+
resolve(handle);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
this.logger.debug({ error: error.message }, 'Discovery poll error (retrying)');
|
|
134
|
+
}
|
|
135
|
+
setTimeout(poll, POLL_INTERVAL);
|
|
136
|
+
};
|
|
137
|
+
poll();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Send a ping envelope to the agent and check for pong response.
|
|
142
|
+
*
|
|
143
|
+
* @param handle - Session handle to health-check
|
|
144
|
+
* @param config - Optional config for health timeout override
|
|
145
|
+
* @returns true if agent responds with pong, false otherwise
|
|
146
|
+
*/
|
|
147
|
+
async checkHealth(handle, config = {}) {
|
|
148
|
+
const healthTimeout = config.healthTimeout ?? DEFAULT_HEALTH_TIMEOUT;
|
|
149
|
+
const envelope = {
|
|
150
|
+
from: 'bmad-session-manager',
|
|
151
|
+
id: randomUUID(),
|
|
152
|
+
payload: {},
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
to: handle.sessionId,
|
|
155
|
+
type: 'ping',
|
|
156
|
+
};
|
|
157
|
+
try {
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const timeoutId = setTimeout(() => controller.abort(), healthTimeout);
|
|
160
|
+
const response = await fetch(`http://localhost:${handle.port}/webhook`, {
|
|
161
|
+
body: JSON.stringify(envelope),
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
method: 'POST',
|
|
164
|
+
signal: controller.signal,
|
|
165
|
+
});
|
|
166
|
+
clearTimeout(timeoutId);
|
|
167
|
+
if (response.status !== 200) {
|
|
168
|
+
this.logger.debug({ sessionId: handle.sessionId, status: response.status }, 'Health check: non-200 status');
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const body = (await response.json());
|
|
172
|
+
return body.type === 'pong';
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
this.logger.debug({ sessionId: handle.sessionId }, 'Health check failed (timeout or connection error)');
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Stop a single agent session: SIGTERM + deregister + remove from map.
|
|
181
|
+
*
|
|
182
|
+
* @param handle - Session handle to stop
|
|
183
|
+
*/
|
|
184
|
+
async stopSession(handle) {
|
|
185
|
+
try {
|
|
186
|
+
this.logger.info({ pid: handle.pid, sessionId: handle.sessionId }, 'Stopping agent session');
|
|
187
|
+
try {
|
|
188
|
+
process.kill(handle.pid, 'SIGTERM');
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
this.logger.warn({ error: error.message, pid: handle.pid }, 'Failed to send SIGTERM (process may already be dead)');
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
this.deregisterAgentFn(handle.sessionId);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this.logger.warn({ error: error.message, sessionId: handle.sessionId }, 'Failed to deregister agent (file may already be removed)');
|
|
198
|
+
}
|
|
199
|
+
this.sessions.delete(handle.sessionId);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
this.logger.error({ error: error.message, sessionId: handle.sessionId }, 'Error stopping session');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Stop all managed sessions. Best-effort: errors per session are logged, never thrown.
|
|
207
|
+
*/
|
|
208
|
+
async stopAll() {
|
|
209
|
+
const handles = Array.from(this.sessions.values());
|
|
210
|
+
this.logger.info({ sessionCount: handles.length }, 'Stopping all agent sessions');
|
|
211
|
+
const results = await Promise.allSettled(handles.map((h) => this.stopSession(h)));
|
|
212
|
+
for (const result of results) {
|
|
213
|
+
if (result.status === 'rejected') {
|
|
214
|
+
this.logger.error({ error: result.reason.message }, 'Error during stopAll session cleanup');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this.sessions.clear();
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Shutdown method for orchestrator integration.
|
|
221
|
+
* Call this in the orchestrator's finally block.
|
|
222
|
+
*/
|
|
223
|
+
async shutdown() {
|
|
224
|
+
await this.stopAll();
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Get the number of currently managed sessions.
|
|
228
|
+
*/
|
|
229
|
+
get sessionCount() {
|
|
230
|
+
return this.sessions.size;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Get a session handle by sessionId.
|
|
234
|
+
*/
|
|
235
|
+
getSession(sessionId) {
|
|
236
|
+
return this.sessions.get(sessionId);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Register SIGINT handler for graceful cleanup.
|
|
240
|
+
* Uses a static flag to prevent duplicate registration.
|
|
241
|
+
*/
|
|
242
|
+
registerSigintHandler() {
|
|
243
|
+
if (ChannelSessionManager.sigintRegistered)
|
|
244
|
+
return;
|
|
245
|
+
ChannelSessionManager.sigintRegistered = true;
|
|
246
|
+
process.on('SIGINT', async () => {
|
|
247
|
+
this.logger.info('Received SIGINT — cleaning up channel sessions');
|
|
248
|
+
await this.stopAll();
|
|
249
|
+
process.exit(130);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Reset the static SIGINT registration flag.
|
|
254
|
+
* Only for testing — do not use in production.
|
|
255
|
+
* @internal
|
|
256
|
+
*/
|
|
257
|
+
static resetSigintFlag() {
|
|
258
|
+
ChannelSessionManager.sigintRegistered = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Encapsulates Claude CLI process spawning with comprehensive error handling,
|
|
5
5
|
* logging, and timeout management for reliable AI agent execution.
|
|
6
|
+
*
|
|
7
|
+
* Uses spawn() with stream-json output format for real-time progress reporting.
|
|
6
8
|
*/
|
|
7
9
|
import type pino from 'pino';
|
|
8
10
|
import type { AgentOptions, AgentResult } from '../../models/index.js';
|
|
@@ -13,69 +15,26 @@ import type { AIProviderRunner } from './agent-runner.js';
|
|
|
13
15
|
*
|
|
14
16
|
* Spawns Claude CLI processes to execute AI agents with specified prompts.
|
|
15
17
|
* Handles timeout, error collection, result formatting, and process cleanup.
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* ```typescript
|
|
19
|
-
* const logger = createLogger({ namespace: 'agent-runner' })
|
|
20
|
-
* const runner = new ClaudeAgentRunner(logger)
|
|
21
|
-
* const result = await runner.runAgent({
|
|
22
|
-
* prompt: '@.bmad-core/agents/architect.md Create epic for user auth',
|
|
23
|
-
* agentType: 'architect',
|
|
24
|
-
* timeout: 300000
|
|
25
|
-
* })
|
|
26
|
-
* ```
|
|
18
|
+
* Uses stream-json output format for real-time progress reporting.
|
|
27
19
|
*/
|
|
28
20
|
export declare class ClaudeAgentRunner implements AIProviderRunner {
|
|
29
21
|
readonly provider: AIProvider;
|
|
30
22
|
private readonly config;
|
|
31
23
|
private readonly logger;
|
|
32
|
-
/**
|
|
33
|
-
* Create a new ClaudeAgentRunner instance
|
|
34
|
-
*
|
|
35
|
-
* @param logger - Logger instance for structured logging
|
|
36
|
-
*/
|
|
37
24
|
constructor(logger: pino.Logger);
|
|
38
|
-
/**
|
|
39
|
-
* Get the count of active processes
|
|
40
|
-
* (Useful for testing and monitoring purposes)
|
|
41
|
-
*
|
|
42
|
-
* @returns Number of currently active child processes
|
|
43
|
-
*/
|
|
44
25
|
getActiveProcessCount(): number;
|
|
45
|
-
/**
|
|
46
|
-
* Execute a Claude AI agent with the specified prompt and options
|
|
47
|
-
*
|
|
48
|
-
* This method spawns a Claude CLI process, captures output, handles errors,
|
|
49
|
-
* and enforces timeouts. It never throws exceptions - all errors are returned
|
|
50
|
-
* as typed AgentResult objects.
|
|
51
|
-
*
|
|
52
|
-
* @param prompt - The prompt to execute with the agent
|
|
53
|
-
* @param options - Agent execution options (without prompt)
|
|
54
|
-
* @returns AgentResult with success status, output, errors, and metadata
|
|
55
|
-
*
|
|
56
|
-
* @example
|
|
57
|
-
* ```typescript
|
|
58
|
-
* const result = await runner.runAgent('@.bmad-core/agents/architect.md Create epic for user auth', {
|
|
59
|
-
* agentType: 'architect',
|
|
60
|
-
* timeout: 300000
|
|
61
|
-
* })
|
|
62
|
-
*
|
|
63
|
-
* if (result.success) {
|
|
64
|
-
* console.log('Output:', result.output)
|
|
65
|
-
* } else {
|
|
66
|
-
* console.error('Error:', result.errors)
|
|
67
|
-
* }
|
|
68
|
-
* ```
|
|
69
|
-
*/
|
|
70
26
|
runAgent(prompt: string, options: Omit<AgentOptions, 'prompt'>): Promise<AgentResult>;
|
|
71
27
|
/**
|
|
72
|
-
*
|
|
73
|
-
* @private
|
|
28
|
+
* Spawn Claude process and stream output in real-time
|
|
74
29
|
*/
|
|
75
|
-
private
|
|
30
|
+
private spawnAndStream;
|
|
76
31
|
/**
|
|
77
32
|
* Invoke onResponse callback and return result
|
|
78
33
|
* @private
|
|
79
34
|
*/
|
|
80
35
|
private returnWithCallback;
|
|
36
|
+
/**
|
|
37
|
+
* Shell-escape a single argument
|
|
38
|
+
*/
|
|
39
|
+
private shellEscape;
|
|
81
40
|
}
|