@hyperdrive.bot/bmad-workflow 1.0.25 → 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
|
@@ -2,30 +2,39 @@
|
|
|
2
2
|
* Agent Runner Factory
|
|
3
3
|
*
|
|
4
4
|
* Creates the appropriate AIProviderRunner based on provider configuration.
|
|
5
|
+
* Includes both the legacy `createAgentRunner()` function and the new
|
|
6
|
+
* `AgentRunnerFactory` class with Channel transport discovery.
|
|
5
7
|
*/
|
|
6
8
|
import type pino from 'pino';
|
|
9
|
+
import type { WorkflowCallbacks } from '../../models/workflow-callbacks.js';
|
|
10
|
+
import type { WorkflowConfig } from '../../models/workflow-config.js';
|
|
7
11
|
import type { AIProvider } from '../../models/provider.js';
|
|
8
12
|
import type { AIProviderRunner } from './agent-runner.js';
|
|
9
13
|
/**
|
|
10
14
|
* Create an AI provider runner for the specified provider
|
|
11
|
-
*
|
|
12
|
-
* @param provider - The AI provider to create a runner for
|
|
13
|
-
* @param logger - Logger instance for structured logging
|
|
14
|
-
* @returns An AIProviderRunner instance for the specified provider
|
|
15
|
-
* @throws Error if the provider is not supported
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* ```typescript
|
|
19
|
-
* const logger = createLogger({ namespace: 'agent-runner' })
|
|
20
|
-
* const runner = createAgentRunner('claude', logger)
|
|
21
|
-
* const result = await runner.runAgent('Hello', { agentType: 'dev' })
|
|
22
|
-
* ```
|
|
23
15
|
*/
|
|
24
16
|
export declare function createAgentRunner(provider: AIProvider, logger: pino.Logger): AIProviderRunner;
|
|
25
17
|
/**
|
|
26
18
|
* Check if a provider is supported
|
|
27
|
-
*
|
|
28
|
-
* @param provider - The provider to check
|
|
29
|
-
* @returns true if the provider is supported
|
|
30
19
|
*/
|
|
31
20
|
export declare function isProviderSupported(provider: string): provider is AIProvider;
|
|
21
|
+
/**
|
|
22
|
+
* AgentRunnerFactory — discovery-based runner selection
|
|
23
|
+
*
|
|
24
|
+
* When `config.useChannels` is true, discovers Channel-capable agents via
|
|
25
|
+
* `listAgents()` and returns a `ChannelAgentRunner` when a match is found.
|
|
26
|
+
* Falls back to the subprocess runner otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export declare class AgentRunnerFactory {
|
|
29
|
+
private readonly callbacks?;
|
|
30
|
+
private readonly logger;
|
|
31
|
+
constructor(logger: pino.Logger, callbacks?: WorkflowCallbacks);
|
|
32
|
+
/**
|
|
33
|
+
* Create an AIProviderRunner for the requested agent type.
|
|
34
|
+
*/
|
|
35
|
+
create(agentType: string, config: WorkflowConfig, phaseName?: string): AIProviderRunner;
|
|
36
|
+
/**
|
|
37
|
+
* Fire onChannelFallback callback with defensive error handling.
|
|
38
|
+
*/
|
|
39
|
+
private fireChannelFallback;
|
|
40
|
+
}
|
|
@@ -2,24 +2,35 @@
|
|
|
2
2
|
* Agent Runner Factory
|
|
3
3
|
*
|
|
4
4
|
* Creates the appropriate AIProviderRunner based on provider configuration.
|
|
5
|
+
* Includes both the legacy `createAgentRunner()` function and the new
|
|
6
|
+
* `AgentRunnerFactory` class with Channel transport discovery.
|
|
5
7
|
*/
|
|
6
8
|
import { ClaudeAgentRunner } from './claude-agent-runner.js';
|
|
7
9
|
import { GeminiAgentRunner } from './gemini-agent-runner.js';
|
|
8
10
|
import { OpenCodeAgentRunner } from './opencode-agent-runner.js';
|
|
11
|
+
// claude-channels is an optional file: dependency — not available in CI.
|
|
12
|
+
// Lazy-loaded at runtime only when --use-channels is set.
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
let _channelsModule = null;
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
let _channelRunnerModule = null;
|
|
17
|
+
function loadChannelsSync() {
|
|
18
|
+
if (!_channelsModule) {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
|
+
_channelsModule = require('@hyperdrive.bot/claude-channels/dist/services/discovery.js');
|
|
21
|
+
}
|
|
22
|
+
return _channelsModule;
|
|
23
|
+
}
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
function loadChannelRunner() {
|
|
26
|
+
if (!_channelRunnerModule) {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
|
+
_channelRunnerModule = require('./channel-agent-runner.js');
|
|
29
|
+
}
|
|
30
|
+
return _channelRunnerModule;
|
|
31
|
+
}
|
|
9
32
|
/**
|
|
10
33
|
* Create an AI provider runner for the specified provider
|
|
11
|
-
*
|
|
12
|
-
* @param provider - The AI provider to create a runner for
|
|
13
|
-
* @param logger - Logger instance for structured logging
|
|
14
|
-
* @returns An AIProviderRunner instance for the specified provider
|
|
15
|
-
* @throws Error if the provider is not supported
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* ```typescript
|
|
19
|
-
* const logger = createLogger({ namespace: 'agent-runner' })
|
|
20
|
-
* const runner = createAgentRunner('claude', logger)
|
|
21
|
-
* const result = await runner.runAgent('Hello', { agentType: 'dev' })
|
|
22
|
-
* ```
|
|
23
34
|
*/
|
|
24
35
|
export function createAgentRunner(provider, logger) {
|
|
25
36
|
switch (provider) {
|
|
@@ -39,10 +50,79 @@ export function createAgentRunner(provider, logger) {
|
|
|
39
50
|
}
|
|
40
51
|
/**
|
|
41
52
|
* Check if a provider is supported
|
|
42
|
-
*
|
|
43
|
-
* @param provider - The provider to check
|
|
44
|
-
* @returns true if the provider is supported
|
|
45
53
|
*/
|
|
46
54
|
export function isProviderSupported(provider) {
|
|
47
55
|
return provider === 'claude' || provider === 'gemini' || provider === 'opencode';
|
|
48
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* AgentRunnerFactory — discovery-based runner selection
|
|
59
|
+
*
|
|
60
|
+
* When `config.useChannels` is true, discovers Channel-capable agents via
|
|
61
|
+
* `listAgents()` and returns a `ChannelAgentRunner` when a match is found.
|
|
62
|
+
* Falls back to the subprocess runner otherwise.
|
|
63
|
+
*/
|
|
64
|
+
export class AgentRunnerFactory {
|
|
65
|
+
callbacks;
|
|
66
|
+
logger;
|
|
67
|
+
constructor(logger, callbacks) {
|
|
68
|
+
this.logger = logger;
|
|
69
|
+
this.callbacks = callbacks;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create an AIProviderRunner for the requested agent type.
|
|
73
|
+
*/
|
|
74
|
+
create(agentType, config, phaseName) {
|
|
75
|
+
if (!config.useChannels) {
|
|
76
|
+
return createAgentRunner(config.provider ?? 'claude', this.logger);
|
|
77
|
+
}
|
|
78
|
+
let agents;
|
|
79
|
+
try {
|
|
80
|
+
const channels = loadChannelsSync();
|
|
81
|
+
agents = channels.listAgents();
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
this.logger.warn(`[channel] Discovery failed: ${error.message} — falling back to subprocess`);
|
|
85
|
+
this.fireChannelFallback(agentType, 'not_discovered', phaseName);
|
|
86
|
+
return createAgentRunner(config.provider ?? 'claude', this.logger);
|
|
87
|
+
}
|
|
88
|
+
const matches = agents.filter((reg) => reg.name.toLowerCase() === agentType.toLowerCase());
|
|
89
|
+
if (matches.length === 0) {
|
|
90
|
+
this.logger.info(`[subprocess] Falling back to claude -p for ${agentType} — no Channel agent discovered`);
|
|
91
|
+
this.fireChannelFallback(agentType, 'not_discovered', phaseName);
|
|
92
|
+
return createAgentRunner(config.provider ?? 'claude', this.logger);
|
|
93
|
+
}
|
|
94
|
+
// Select most recently started agent
|
|
95
|
+
const matched = matches.length === 1
|
|
96
|
+
? matches[0]
|
|
97
|
+
: matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
|
|
98
|
+
this.logger.info({ agentType, sessionId: matched.sessionId, webhookUrl: matched.webhookUrl }, `[channel] Discovered Channel agent for ${agentType}`);
|
|
99
|
+
try {
|
|
100
|
+
const { ChannelAgentRunner } = loadChannelRunner();
|
|
101
|
+
return new ChannelAgentRunner(this.logger, matched.webhookUrl, matched.sessionId, this.callbacks);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.logger.warn(`[channel] Failed to load ChannelAgentRunner: ${error.message} — falling back`);
|
|
105
|
+
this.fireChannelFallback(agentType, 'not_discovered', phaseName);
|
|
106
|
+
return createAgentRunner(config.provider ?? 'claude', this.logger);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fire onChannelFallback callback with defensive error handling.
|
|
111
|
+
*/
|
|
112
|
+
fireChannelFallback(agentType, reason, phaseName) {
|
|
113
|
+
const callback = this.callbacks?.onChannelFallback;
|
|
114
|
+
if (!callback)
|
|
115
|
+
return;
|
|
116
|
+
const context = {
|
|
117
|
+
agentType,
|
|
118
|
+
phaseName: phaseName ?? 'unknown',
|
|
119
|
+
reason,
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
callback(context);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.logger.warn({ callbackName: 'onChannelFallback', error: error.message }, 'Callback error (ignored to prevent workflow interruption)');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelAgentRunner Service
|
|
3
|
+
*
|
|
4
|
+
* Sends task envelopes to discovered Channel agent endpoints via HTTP POST
|
|
5
|
+
* and returns results as AgentResult. Used by the factory to delegate work
|
|
6
|
+
* to persistent, context-preserving Claude Code sessions.
|
|
7
|
+
*/
|
|
8
|
+
import type pino from 'pino';
|
|
9
|
+
import type { AgentOptions, AgentResult, WorkflowCallbacks } from '../../models/index.js';
|
|
10
|
+
import type { AIProvider } from '../../models/provider.js';
|
|
11
|
+
import type { AIProviderRunner } from './agent-runner.js';
|
|
12
|
+
/**
|
|
13
|
+
* Error thrown when the channel agent endpoint is unreachable (ECONNREFUSED).
|
|
14
|
+
* The factory uses this signal to trigger subprocess fallback.
|
|
15
|
+
*/
|
|
16
|
+
export declare class ChannelUnavailableError extends Error {
|
|
17
|
+
readonly name = "ChannelUnavailableError";
|
|
18
|
+
readonly webhookUrl: string;
|
|
19
|
+
constructor(webhookUrl: string, cause?: Error);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* ChannelAgentRunner — sends task envelopes to channel agent endpoints via HTTP POST.
|
|
23
|
+
*
|
|
24
|
+
* Never throws on agent errors (returns AgentResult with success: false).
|
|
25
|
+
* The ONLY exception is ECONNREFUSED → ChannelUnavailableError, because
|
|
26
|
+
* the factory needs this signal to trigger subprocess fallback.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ChannelAgentRunner implements AIProviderRunner {
|
|
29
|
+
readonly provider: AIProvider;
|
|
30
|
+
private activeRequests;
|
|
31
|
+
private connected;
|
|
32
|
+
private readonly callbacks?;
|
|
33
|
+
private readonly logger;
|
|
34
|
+
private readonly port;
|
|
35
|
+
private readonly sessionId;
|
|
36
|
+
private readonly webhookUrl;
|
|
37
|
+
constructor(logger: pino.Logger, webhookUrl: string, sessionId: string, callbacks?: WorkflowCallbacks);
|
|
38
|
+
getActiveProcessCount(): number;
|
|
39
|
+
private fireChannelConnect;
|
|
40
|
+
/**
|
|
41
|
+
* Send a task to the Channel agent and return the result.
|
|
42
|
+
*
|
|
43
|
+
* **Task envelope structure:**
|
|
44
|
+
* The prompt is wrapped in a Channel envelope via `EnvelopeFactory.createTask()`:
|
|
45
|
+
* - `from`: `"bmad-orchestrator"` — identifies the caller
|
|
46
|
+
* - `to`: the target session ID discovered during factory creation
|
|
47
|
+
* - `type`: `"task"` — tells the Channel agent this is a work request
|
|
48
|
+
* - `payload`: `{ prompt, context? }` — the actual prompt plus optional metadata
|
|
49
|
+
* (systemPrompt, model) passed via the context field
|
|
50
|
+
* - `id` / `timestamp`: auto-generated for tracing and deduplication
|
|
51
|
+
*
|
|
52
|
+
* **Response parsing:**
|
|
53
|
+
* - `type: "result"` with `payload.output` (string) and `payload.exitCode` (number)
|
|
54
|
+
* → success path. `exitCode === 0` means the agent completed successfully.
|
|
55
|
+
* - `type: "error"` → the Channel agent encountered an internal error. Returned as
|
|
56
|
+
* `AgentResult` with `success: false` (never throws).
|
|
57
|
+
* - Any other `type` → treated as unexpected and returned as failure.
|
|
58
|
+
*
|
|
59
|
+
* **Timeout handling:**
|
|
60
|
+
* An `AbortController` is created per request with the configured timeout (default
|
|
61
|
+
* 300s). If the Channel agent doesn't respond within the timeout window, the fetch
|
|
62
|
+
* is aborted and an `AgentResult` with `success: false` is returned. The timer is
|
|
63
|
+
* always cleared in `finally` to prevent leaks.
|
|
64
|
+
*
|
|
65
|
+
* **Connection errors:**
|
|
66
|
+
* ECONNREFUSED is the only error that throws (`ChannelUnavailableError`) instead of
|
|
67
|
+
* returning a failed `AgentResult`. This allows the factory to catch it and trigger
|
|
68
|
+
* subprocess fallback.
|
|
69
|
+
*
|
|
70
|
+
* @param prompt - The task prompt to send to the Channel agent
|
|
71
|
+
* @param options - Agent options (agentType, timeout, model, systemPrompt, phaseName)
|
|
72
|
+
* @returns AgentResult with output, exitCode, duration, and transport: "channel"
|
|
73
|
+
* @throws ChannelUnavailableError when the endpoint is unreachable (ECONNREFUSED)
|
|
74
|
+
*/
|
|
75
|
+
runAgent(prompt: string, options: Omit<AgentOptions, 'prompt'>): Promise<AgentResult>;
|
|
76
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelAgentRunner Service
|
|
3
|
+
*
|
|
4
|
+
* Sends task envelopes to discovered Channel agent endpoints via HTTP POST
|
|
5
|
+
* and returns results as AgentResult. Used by the factory to delegate work
|
|
6
|
+
* to persistent, context-preserving Claude Code sessions.
|
|
7
|
+
*/
|
|
8
|
+
// Lazy-loaded: @hyperdrive.bot/claude-channels is optional (file: dep, not in CI)
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
let _envelopeFactory = null;
|
|
11
|
+
function getEnvelopeFactory() {
|
|
12
|
+
if (!_envelopeFactory) {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
_envelopeFactory = require('@hyperdrive.bot/claude-channels/dist/envelope/factory.js').EnvelopeFactory;
|
|
15
|
+
}
|
|
16
|
+
return _envelopeFactory;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Error thrown when the channel agent endpoint is unreachable (ECONNREFUSED).
|
|
20
|
+
* The factory uses this signal to trigger subprocess fallback.
|
|
21
|
+
*/
|
|
22
|
+
export class ChannelUnavailableError extends Error {
|
|
23
|
+
name = 'ChannelUnavailableError';
|
|
24
|
+
webhookUrl;
|
|
25
|
+
constructor(webhookUrl, cause) {
|
|
26
|
+
super(`Channel unavailable at ${webhookUrl}`);
|
|
27
|
+
this.webhookUrl = webhookUrl;
|
|
28
|
+
if (cause) {
|
|
29
|
+
this.cause = cause;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* ChannelAgentRunner — sends task envelopes to channel agent endpoints via HTTP POST.
|
|
35
|
+
*
|
|
36
|
+
* Never throws on agent errors (returns AgentResult with success: false).
|
|
37
|
+
* The ONLY exception is ECONNREFUSED → ChannelUnavailableError, because
|
|
38
|
+
* the factory needs this signal to trigger subprocess fallback.
|
|
39
|
+
*/
|
|
40
|
+
export class ChannelAgentRunner {
|
|
41
|
+
provider = 'claude';
|
|
42
|
+
activeRequests = 0;
|
|
43
|
+
connected = false;
|
|
44
|
+
callbacks;
|
|
45
|
+
logger;
|
|
46
|
+
port;
|
|
47
|
+
sessionId;
|
|
48
|
+
webhookUrl;
|
|
49
|
+
constructor(logger, webhookUrl, sessionId, callbacks) {
|
|
50
|
+
this.logger = logger;
|
|
51
|
+
this.webhookUrl = webhookUrl;
|
|
52
|
+
this.sessionId = sessionId;
|
|
53
|
+
this.callbacks = callbacks;
|
|
54
|
+
// Extract port from webhook URL
|
|
55
|
+
try {
|
|
56
|
+
this.port = new URL(webhookUrl).port ? Number.parseInt(new URL(webhookUrl).port, 10) : 0;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
this.port = 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
getActiveProcessCount() {
|
|
63
|
+
return this.activeRequests;
|
|
64
|
+
}
|
|
65
|
+
fireChannelConnect(agentType, phaseName) {
|
|
66
|
+
if (this.connected)
|
|
67
|
+
return;
|
|
68
|
+
this.connected = true;
|
|
69
|
+
const callback = this.callbacks?.onChannelConnect;
|
|
70
|
+
if (!callback)
|
|
71
|
+
return;
|
|
72
|
+
const context = {
|
|
73
|
+
agentType,
|
|
74
|
+
phaseName,
|
|
75
|
+
port: this.port,
|
|
76
|
+
sessionId: this.sessionId,
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
callback(context);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
this.logger.warn({ callbackName: 'onChannelConnect', error: error.message }, 'Callback error (ignored to prevent workflow interruption)');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Send a task to the Channel agent and return the result.
|
|
87
|
+
*
|
|
88
|
+
* **Task envelope structure:**
|
|
89
|
+
* The prompt is wrapped in a Channel envelope via `EnvelopeFactory.createTask()`:
|
|
90
|
+
* - `from`: `"bmad-orchestrator"` — identifies the caller
|
|
91
|
+
* - `to`: the target session ID discovered during factory creation
|
|
92
|
+
* - `type`: `"task"` — tells the Channel agent this is a work request
|
|
93
|
+
* - `payload`: `{ prompt, context? }` — the actual prompt plus optional metadata
|
|
94
|
+
* (systemPrompt, model) passed via the context field
|
|
95
|
+
* - `id` / `timestamp`: auto-generated for tracing and deduplication
|
|
96
|
+
*
|
|
97
|
+
* **Response parsing:**
|
|
98
|
+
* - `type: "result"` with `payload.output` (string) and `payload.exitCode` (number)
|
|
99
|
+
* → success path. `exitCode === 0` means the agent completed successfully.
|
|
100
|
+
* - `type: "error"` → the Channel agent encountered an internal error. Returned as
|
|
101
|
+
* `AgentResult` with `success: false` (never throws).
|
|
102
|
+
* - Any other `type` → treated as unexpected and returned as failure.
|
|
103
|
+
*
|
|
104
|
+
* **Timeout handling:**
|
|
105
|
+
* An `AbortController` is created per request with the configured timeout (default
|
|
106
|
+
* 300s). If the Channel agent doesn't respond within the timeout window, the fetch
|
|
107
|
+
* is aborted and an `AgentResult` with `success: false` is returned. The timer is
|
|
108
|
+
* always cleared in `finally` to prevent leaks.
|
|
109
|
+
*
|
|
110
|
+
* **Connection errors:**
|
|
111
|
+
* ECONNREFUSED is the only error that throws (`ChannelUnavailableError`) instead of
|
|
112
|
+
* returning a failed `AgentResult`. This allows the factory to catch it and trigger
|
|
113
|
+
* subprocess fallback.
|
|
114
|
+
*
|
|
115
|
+
* @param prompt - The task prompt to send to the Channel agent
|
|
116
|
+
* @param options - Agent options (agentType, timeout, model, systemPrompt, phaseName)
|
|
117
|
+
* @returns AgentResult with output, exitCode, duration, and transport: "channel"
|
|
118
|
+
* @throws ChannelUnavailableError when the endpoint is unreachable (ECONNREFUSED)
|
|
119
|
+
*/
|
|
120
|
+
async runAgent(prompt, options) {
|
|
121
|
+
const startTime = Date.now();
|
|
122
|
+
const timeout = options.timeout ?? 300_000;
|
|
123
|
+
// Build task envelope
|
|
124
|
+
const context = {};
|
|
125
|
+
if (options.systemPrompt)
|
|
126
|
+
context.systemPrompt = options.systemPrompt;
|
|
127
|
+
if (options.model)
|
|
128
|
+
context.model = options.model;
|
|
129
|
+
const EnvelopeFactory = getEnvelopeFactory();
|
|
130
|
+
const envelope = EnvelopeFactory.createTask('bmad-orchestrator', this.sessionId, prompt, Object.keys(context).length > 0 ? context : undefined);
|
|
131
|
+
this.logger.debug({ agentType: options.agentType, envelopeId: envelope.id, sessionId: this.sessionId }, 'Sending task envelope to channel agent');
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
134
|
+
this.activeRequests++;
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(this.webhookUrl, {
|
|
137
|
+
body: JSON.stringify(envelope),
|
|
138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
139
|
+
method: 'POST',
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
});
|
|
142
|
+
// Parse response JSON
|
|
143
|
+
let body;
|
|
144
|
+
try {
|
|
145
|
+
body = await response.json();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
this.logger.warn({ envelopeId: envelope.id, status: response.status }, 'Malformed channel response: invalid JSON');
|
|
149
|
+
return {
|
|
150
|
+
agentType: options.agentType,
|
|
151
|
+
duration: Date.now() - startTime,
|
|
152
|
+
errors: 'Malformed channel response: invalid JSON',
|
|
153
|
+
exitCode: 1,
|
|
154
|
+
output: '',
|
|
155
|
+
success: false,
|
|
156
|
+
transport: 'channel',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Validate envelope structure
|
|
160
|
+
const parsed = body;
|
|
161
|
+
if (!parsed.type || !parsed.payload) {
|
|
162
|
+
this.logger.warn({ envelopeId: envelope.id, responseType: parsed.type }, 'Malformed channel response: missing type or payload');
|
|
163
|
+
return {
|
|
164
|
+
agentType: options.agentType,
|
|
165
|
+
duration: Date.now() - startTime,
|
|
166
|
+
errors: `Malformed channel response: missing ${!parsed.type ? 'type' : 'payload'} field`,
|
|
167
|
+
exitCode: 1,
|
|
168
|
+
output: '',
|
|
169
|
+
success: false,
|
|
170
|
+
transport: 'channel',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// Handle result envelope
|
|
174
|
+
if (parsed.type === 'result') {
|
|
175
|
+
const payload = parsed.payload;
|
|
176
|
+
if (typeof payload.output === 'string' && typeof payload.exitCode === 'number') {
|
|
177
|
+
const duration = Date.now() - startTime;
|
|
178
|
+
const exitCode = payload.exitCode;
|
|
179
|
+
// Fire onChannelConnect on first successful exchange
|
|
180
|
+
this.fireChannelConnect(options.agentType, options.phaseName ?? 'unknown');
|
|
181
|
+
this.logger.debug({ agentType: options.agentType, duration, envelopeId: envelope.id, exitCode }, 'Channel agent completed');
|
|
182
|
+
return {
|
|
183
|
+
agentType: options.agentType,
|
|
184
|
+
duration,
|
|
185
|
+
errors: '',
|
|
186
|
+
exitCode,
|
|
187
|
+
output: payload.output,
|
|
188
|
+
success: exitCode === 0,
|
|
189
|
+
transport: 'channel',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// Result type but malformed payload
|
|
193
|
+
this.logger.warn({ envelopeId: envelope.id }, 'Malformed channel response: result payload missing output or exitCode');
|
|
194
|
+
return {
|
|
195
|
+
agentType: options.agentType,
|
|
196
|
+
duration: Date.now() - startTime,
|
|
197
|
+
errors: 'Malformed channel response: result payload missing output or exitCode',
|
|
198
|
+
exitCode: 1,
|
|
199
|
+
output: '',
|
|
200
|
+
success: false,
|
|
201
|
+
transport: 'channel',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// Non-result envelope type (error, status, etc.)
|
|
205
|
+
const errorDescription = parsed.type === 'error'
|
|
206
|
+
? `Channel returned error: ${JSON.stringify(parsed.payload)}`
|
|
207
|
+
: `Unexpected envelope type: ${parsed.type}`;
|
|
208
|
+
this.logger.warn({ envelopeId: envelope.id, responseType: parsed.type }, errorDescription);
|
|
209
|
+
return {
|
|
210
|
+
agentType: options.agentType,
|
|
211
|
+
duration: Date.now() - startTime,
|
|
212
|
+
errors: errorDescription,
|
|
213
|
+
exitCode: 1,
|
|
214
|
+
output: '',
|
|
215
|
+
success: false,
|
|
216
|
+
transport: 'channel',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
const err = error;
|
|
221
|
+
// Connection refused — throw ChannelUnavailableError for factory fallback
|
|
222
|
+
if (err.cause?.code === 'ECONNREFUSED' || (err instanceof TypeError && err.cause?.code === 'ECONNREFUSED')) {
|
|
223
|
+
this.logger.error({ webhookUrl: this.webhookUrl }, 'Channel agent unreachable (ECONNREFUSED)');
|
|
224
|
+
throw new ChannelUnavailableError(this.webhookUrl, err);
|
|
225
|
+
}
|
|
226
|
+
// Timeout via AbortController
|
|
227
|
+
if (err.name === 'AbortError') {
|
|
228
|
+
this.logger.warn({ envelopeId: envelope.id, timeout }, `Channel timeout after ${timeout}ms`);
|
|
229
|
+
return {
|
|
230
|
+
agentType: options.agentType,
|
|
231
|
+
duration: Date.now() - startTime,
|
|
232
|
+
errors: `Channel timeout after ${timeout}ms`,
|
|
233
|
+
exitCode: 1,
|
|
234
|
+
output: '',
|
|
235
|
+
success: false,
|
|
236
|
+
transport: 'channel',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// Unexpected error
|
|
240
|
+
this.logger.error({ envelopeId: envelope.id, error: err.message }, 'Unexpected channel error');
|
|
241
|
+
return {
|
|
242
|
+
agentType: options.agentType,
|
|
243
|
+
duration: Date.now() - startTime,
|
|
244
|
+
errors: `Unexpected channel error: ${err.message}`,
|
|
245
|
+
exitCode: 1,
|
|
246
|
+
output: '',
|
|
247
|
+
success: false,
|
|
248
|
+
transport: 'channel',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
this.activeRequests--;
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { type ChildProcess, spawn as nodeSpawn } from 'node:child_process';
|
|
11
|
+
import type pino from 'pino';
|
|
12
|
+
type AgentRegistration = {
|
|
13
|
+
name: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
webhookUrl: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Handle to a managed agent session
|
|
21
|
+
*/
|
|
22
|
+
export interface SessionHandle {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
port: number;
|
|
25
|
+
agentType: string;
|
|
26
|
+
pid: number;
|
|
27
|
+
process: ChildProcess;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Configuration for starting an agent session
|
|
31
|
+
*/
|
|
32
|
+
export interface SessionConfig {
|
|
33
|
+
command?: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
systemPrompt?: string;
|
|
36
|
+
cwd?: string;
|
|
37
|
+
startTimeout?: number;
|
|
38
|
+
healthTimeout?: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Injectable dependencies for testing
|
|
42
|
+
*/
|
|
43
|
+
export interface SessionManagerDeps {
|
|
44
|
+
listAgents?: () => AgentRegistration[];
|
|
45
|
+
deregisterAgent?: (sessionId: string) => void;
|
|
46
|
+
spawn?: typeof nodeSpawn;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Error thrown when agent session start times out waiting for discovery registration
|
|
50
|
+
*/
|
|
51
|
+
export declare class SessionStartTimeoutError extends Error {
|
|
52
|
+
readonly agentType: string;
|
|
53
|
+
readonly timeoutMs: number;
|
|
54
|
+
constructor(agentType: string, timeoutMs: number);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Manages Channel-connected Claude Code agent session lifecycles.
|
|
58
|
+
*
|
|
59
|
+
* Handles spawning, discovery polling, health checks, graceful shutdown,
|
|
60
|
+
* and SIGINT cleanup for multiple concurrent agent sessions.
|
|
61
|
+
*/
|
|
62
|
+
export declare class ChannelSessionManager {
|
|
63
|
+
private static sigintRegistered;
|
|
64
|
+
private readonly sessions;
|
|
65
|
+
private readonly logger;
|
|
66
|
+
private readonly listAgentsFn;
|
|
67
|
+
private readonly deregisterAgentFn;
|
|
68
|
+
private readonly spawnFn;
|
|
69
|
+
constructor(logger: pino.Logger, deps?: SessionManagerDeps);
|
|
70
|
+
/**
|
|
71
|
+
* Start a new agent session and wait for it to register via discovery.
|
|
72
|
+
*
|
|
73
|
+
* Spawns a Claude Code subprocess with --channel flag, then polls listAgents()
|
|
74
|
+
* at 500ms intervals until a new AgentRegistration appears. Returns a SessionHandle
|
|
75
|
+
* on success, throws SessionStartTimeoutError if discovery times out.
|
|
76
|
+
*
|
|
77
|
+
* @param agentType - Agent type identifier (used to match discovery registration)
|
|
78
|
+
* @param config - Session configuration
|
|
79
|
+
* @returns SessionHandle with session details
|
|
80
|
+
* @throws SessionStartTimeoutError if agent doesn't register within startTimeout
|
|
81
|
+
*/
|
|
82
|
+
startAgentSession(agentType: string, config?: SessionConfig): Promise<SessionHandle>;
|
|
83
|
+
/**
|
|
84
|
+
* Send a ping envelope to the agent and check for pong response.
|
|
85
|
+
*
|
|
86
|
+
* @param handle - Session handle to health-check
|
|
87
|
+
* @param config - Optional config for health timeout override
|
|
88
|
+
* @returns true if agent responds with pong, false otherwise
|
|
89
|
+
*/
|
|
90
|
+
checkHealth(handle: SessionHandle, config?: SessionConfig): Promise<boolean>;
|
|
91
|
+
/**
|
|
92
|
+
* Stop a single agent session: SIGTERM + deregister + remove from map.
|
|
93
|
+
*
|
|
94
|
+
* @param handle - Session handle to stop
|
|
95
|
+
*/
|
|
96
|
+
stopSession(handle: SessionHandle): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Stop all managed sessions. Best-effort: errors per session are logged, never thrown.
|
|
99
|
+
*/
|
|
100
|
+
stopAll(): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Shutdown method for orchestrator integration.
|
|
103
|
+
* Call this in the orchestrator's finally block.
|
|
104
|
+
*/
|
|
105
|
+
shutdown(): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Get the number of currently managed sessions.
|
|
108
|
+
*/
|
|
109
|
+
get sessionCount(): number;
|
|
110
|
+
/**
|
|
111
|
+
* Get a session handle by sessionId.
|
|
112
|
+
*/
|
|
113
|
+
getSession(sessionId: string): SessionHandle | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* Register SIGINT handler for graceful cleanup.
|
|
116
|
+
* Uses a static flag to prevent duplicate registration.
|
|
117
|
+
*/
|
|
118
|
+
private registerSigintHandler;
|
|
119
|
+
/**
|
|
120
|
+
* Reset the static SIGINT registration flag.
|
|
121
|
+
* Only for testing — do not use in production.
|
|
122
|
+
* @internal
|
|
123
|
+
*/
|
|
124
|
+
static resetSigintFlag(): void;
|
|
125
|
+
}
|
|
126
|
+
export {};
|