edsger 0.27.11 → 0.28.1
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/api/chat.d.ts +53 -0
- package/dist/api/chat.js +177 -0
- package/dist/commands/agent-workflow/chat-worker.d.ts +16 -0
- package/dist/commands/agent-workflow/chat-worker.js +242 -0
- package/dist/commands/agent-workflow/processor.d.ts +5 -0
- package/dist/commands/agent-workflow/processor.js +91 -2
- package/dist/phases/chat-processor/context.d.ts +37 -0
- package/dist/phases/chat-processor/context.js +84 -0
- package/dist/phases/chat-processor/index.d.ts +34 -0
- package/dist/phases/chat-processor/index.js +291 -0
- package/dist/phases/chat-processor/prompts.d.ts +30 -0
- package/dist/phases/chat-processor/prompts.js +96 -0
- package/dist/phases/chat-processor/tools.d.ts +12 -0
- package/dist/phases/chat-processor/tools.js +278 -0
- package/dist/phases/pr-execution/index.js +9 -2
- package/dist/phases/pr-execution/outcome.d.ts +1 -0
- package/dist/types/index.d.ts +72 -0
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client wrappers for chat operations.
|
|
3
|
+
* Used by the chat-worker subprocess to interact with chat channels and messages.
|
|
4
|
+
*/
|
|
5
|
+
import type { ChatChannel, ChatChannelType, ChatMode, ChatMessage, ChatMessageType } from '../types/index.js';
|
|
6
|
+
export declare function getOrCreateChannel(channelType: ChatChannelType, channelRefId: string | null, chatMode?: ChatMode, name?: string, verbose?: boolean): Promise<{
|
|
7
|
+
channel: ChatChannel;
|
|
8
|
+
created: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function listChannels(channelType?: ChatChannelType, channelRefId?: string, verbose?: boolean): Promise<ChatChannel[]>;
|
|
11
|
+
export declare function addChannelMember(channelId: string, userId: string, role?: 'owner' | 'admin' | 'member', verbose?: boolean): Promise<void>;
|
|
12
|
+
export declare function removeChannelMember(channelId: string, userId: string, verbose?: boolean): Promise<void>;
|
|
13
|
+
export declare function listChatMessages(channelId: string, options?: {
|
|
14
|
+
since?: string;
|
|
15
|
+
limit?: number;
|
|
16
|
+
offset?: number;
|
|
17
|
+
}, verbose?: boolean): Promise<ChatMessage[]>;
|
|
18
|
+
export declare function sendAiMessage(channelId: string, content: string, metadata?: Record<string, unknown>, options?: {
|
|
19
|
+
messageType?: ChatMessageType;
|
|
20
|
+
parentMessageId?: string;
|
|
21
|
+
}, verbose?: boolean): Promise<ChatMessage>;
|
|
22
|
+
export declare function sendSystemMessage(channelId: string, content: string, metadata?: Record<string, unknown>, verbose?: boolean): Promise<ChatMessage>;
|
|
23
|
+
export declare function getPendingMessages(channelId: string, verbose?: boolean): Promise<ChatMessage[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Atomically claim pending messages for a channel.
|
|
26
|
+
* Uses FOR UPDATE SKIP LOCKED at the database level so multiple CLI workers
|
|
27
|
+
* never process the same message. Stale claims are automatically released.
|
|
28
|
+
*/
|
|
29
|
+
export declare function claimPendingMessages(channelId: string, workerId: string, options?: {
|
|
30
|
+
limit?: number;
|
|
31
|
+
staleTimeoutSeconds?: number;
|
|
32
|
+
}, verbose?: boolean): Promise<ChatMessage[]>;
|
|
33
|
+
export declare function markMessageProcessed(messageId: string, verbose?: boolean): Promise<void>;
|
|
34
|
+
export declare function markChannelRead(channelId: string, lastReadMessageId?: string, verbose?: boolean): Promise<void>;
|
|
35
|
+
export declare function getUnreadCount(channelId: string, verbose?: boolean): Promise<number>;
|
|
36
|
+
/**
|
|
37
|
+
* Get or create the group chat channel for a feature.
|
|
38
|
+
* This is the most common operation — every feature has one group channel.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getFeatureChannel(featureId: string, verbose?: boolean): Promise<ChatChannel>;
|
|
41
|
+
/**
|
|
42
|
+
* Send a system message to a feature's group channel.
|
|
43
|
+
* Creates the channel if it doesn't exist.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sendFeatureSystemMessage(featureId: string, content: string, metadata?: Record<string, unknown>, verbose?: boolean): Promise<ChatMessage>;
|
|
46
|
+
/**
|
|
47
|
+
* Send an AI message to a feature's group channel.
|
|
48
|
+
* Creates the channel if it doesn't exist.
|
|
49
|
+
*/
|
|
50
|
+
export declare function sendFeatureAiMessage(featureId: string, content: string, metadata?: Record<string, unknown>, options?: {
|
|
51
|
+
messageType?: ChatMessageType;
|
|
52
|
+
parentMessageId?: string;
|
|
53
|
+
}, verbose?: boolean): Promise<ChatMessage>;
|
package/dist/api/chat.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client wrappers for chat operations.
|
|
3
|
+
* Used by the chat-worker subprocess to interact with chat channels and messages.
|
|
4
|
+
*/
|
|
5
|
+
import { callMcpEndpoint } from './mcp-client.js';
|
|
6
|
+
import { logInfo, logError } from '../utils/logger.js';
|
|
7
|
+
// ============================================================
|
|
8
|
+
// Channels
|
|
9
|
+
// ============================================================
|
|
10
|
+
export async function getOrCreateChannel(channelType, channelRefId, chatMode = 'group', name, verbose) {
|
|
11
|
+
if (verbose) {
|
|
12
|
+
logInfo(`Getting/creating channel: ${channelType}/${channelRefId}/${chatMode}`);
|
|
13
|
+
}
|
|
14
|
+
const result = (await callMcpEndpoint('chat/channels/get_or_create', {
|
|
15
|
+
channel_type: channelType,
|
|
16
|
+
channel_ref_id: channelRefId,
|
|
17
|
+
chat_mode: chatMode,
|
|
18
|
+
name,
|
|
19
|
+
}));
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
export async function listChannels(channelType, channelRefId, verbose) {
|
|
23
|
+
if (verbose) {
|
|
24
|
+
logInfo(`Listing channels: type=${channelType || 'all'}`);
|
|
25
|
+
}
|
|
26
|
+
const result = (await callMcpEndpoint('chat/channels/list', {
|
|
27
|
+
channel_type: channelType,
|
|
28
|
+
channel_ref_id: channelRefId,
|
|
29
|
+
}));
|
|
30
|
+
return result.channels || [];
|
|
31
|
+
}
|
|
32
|
+
// ============================================================
|
|
33
|
+
// Members
|
|
34
|
+
// ============================================================
|
|
35
|
+
export async function addChannelMember(channelId, userId, role = 'member', verbose) {
|
|
36
|
+
if (verbose) {
|
|
37
|
+
logInfo(`Adding member ${userId} to channel ${channelId}`);
|
|
38
|
+
}
|
|
39
|
+
await callMcpEndpoint('chat/members/add', {
|
|
40
|
+
channel_id: channelId,
|
|
41
|
+
user_id: userId,
|
|
42
|
+
role,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export async function removeChannelMember(channelId, userId, verbose) {
|
|
46
|
+
if (verbose) {
|
|
47
|
+
logInfo(`Removing member ${userId} from channel ${channelId}`);
|
|
48
|
+
}
|
|
49
|
+
await callMcpEndpoint('chat/members/remove', {
|
|
50
|
+
channel_id: channelId,
|
|
51
|
+
user_id: userId,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// ============================================================
|
|
55
|
+
// Messages
|
|
56
|
+
// ============================================================
|
|
57
|
+
export async function listChatMessages(channelId, options = {}, verbose) {
|
|
58
|
+
if (verbose) {
|
|
59
|
+
logInfo(`Listing messages for channel ${channelId}`);
|
|
60
|
+
}
|
|
61
|
+
const result = (await callMcpEndpoint('chat/messages/list', {
|
|
62
|
+
channel_id: channelId,
|
|
63
|
+
...options,
|
|
64
|
+
}));
|
|
65
|
+
return result.messages || [];
|
|
66
|
+
}
|
|
67
|
+
export async function sendAiMessage(channelId, content, metadata = {}, options = {}, verbose) {
|
|
68
|
+
if (verbose) {
|
|
69
|
+
logInfo(`Sending AI message to channel ${channelId}`);
|
|
70
|
+
}
|
|
71
|
+
const result = (await callMcpEndpoint('chat/messages/send_ai', {
|
|
72
|
+
channel_id: channelId,
|
|
73
|
+
content,
|
|
74
|
+
message_type: options.messageType || 'text',
|
|
75
|
+
metadata,
|
|
76
|
+
parent_message_id: options.parentMessageId,
|
|
77
|
+
}));
|
|
78
|
+
return result.message;
|
|
79
|
+
}
|
|
80
|
+
export async function sendSystemMessage(channelId, content, metadata = {}, verbose) {
|
|
81
|
+
if (verbose) {
|
|
82
|
+
logInfo(`Sending system message to channel ${channelId}`);
|
|
83
|
+
}
|
|
84
|
+
const result = (await callMcpEndpoint('chat/messages/send_system', {
|
|
85
|
+
channel_id: channelId,
|
|
86
|
+
content,
|
|
87
|
+
metadata,
|
|
88
|
+
}));
|
|
89
|
+
return result.message;
|
|
90
|
+
}
|
|
91
|
+
export async function getPendingMessages(channelId, verbose) {
|
|
92
|
+
const result = (await callMcpEndpoint('chat/messages/pending', {
|
|
93
|
+
channel_id: channelId,
|
|
94
|
+
}));
|
|
95
|
+
return result.messages || [];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Atomically claim pending messages for a channel.
|
|
99
|
+
* Uses FOR UPDATE SKIP LOCKED at the database level so multiple CLI workers
|
|
100
|
+
* never process the same message. Stale claims are automatically released.
|
|
101
|
+
*/
|
|
102
|
+
export async function claimPendingMessages(channelId, workerId, options = {}, verbose) {
|
|
103
|
+
if (verbose) {
|
|
104
|
+
logInfo(`Claiming pending messages for channel ${channelId} (worker: ${workerId})`);
|
|
105
|
+
}
|
|
106
|
+
const result = (await callMcpEndpoint('chat/messages/claim', {
|
|
107
|
+
channel_id: channelId,
|
|
108
|
+
worker_id: workerId,
|
|
109
|
+
limit: options.limit || 10,
|
|
110
|
+
stale_timeout_seconds: options.staleTimeoutSeconds || 300,
|
|
111
|
+
}));
|
|
112
|
+
return result.messages || [];
|
|
113
|
+
}
|
|
114
|
+
export async function markMessageProcessed(messageId, verbose) {
|
|
115
|
+
if (verbose) {
|
|
116
|
+
logInfo(`Marking message ${messageId} as processed`);
|
|
117
|
+
}
|
|
118
|
+
await callMcpEndpoint('chat/messages/mark_processed', {
|
|
119
|
+
message_id: messageId,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// ============================================================
|
|
123
|
+
// Read Status
|
|
124
|
+
// ============================================================
|
|
125
|
+
export async function markChannelRead(channelId, lastReadMessageId, verbose) {
|
|
126
|
+
await callMcpEndpoint('chat/read_status/mark_read', {
|
|
127
|
+
channel_id: channelId,
|
|
128
|
+
last_read_message_id: lastReadMessageId,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
export async function getUnreadCount(channelId, verbose) {
|
|
132
|
+
const result = (await callMcpEndpoint('chat/read_status/unread_count', {
|
|
133
|
+
channel_id: channelId,
|
|
134
|
+
}));
|
|
135
|
+
return result.unread_count;
|
|
136
|
+
}
|
|
137
|
+
// ============================================================
|
|
138
|
+
// Convenience: Feature Channel
|
|
139
|
+
// ============================================================
|
|
140
|
+
/**
|
|
141
|
+
* Get or create the group chat channel for a feature.
|
|
142
|
+
* This is the most common operation — every feature has one group channel.
|
|
143
|
+
*/
|
|
144
|
+
export async function getFeatureChannel(featureId, verbose) {
|
|
145
|
+
const { channel } = await getOrCreateChannel('feature', featureId, 'group', undefined, verbose);
|
|
146
|
+
return channel;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Send a system message to a feature's group channel.
|
|
150
|
+
* Creates the channel if it doesn't exist.
|
|
151
|
+
*/
|
|
152
|
+
export async function sendFeatureSystemMessage(featureId, content, metadata = {}, verbose) {
|
|
153
|
+
try {
|
|
154
|
+
const channel = await getFeatureChannel(featureId, verbose);
|
|
155
|
+
return await sendSystemMessage(channel.id, content, metadata, verbose);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
159
|
+
logError(`Failed to send system message for feature ${featureId}: ${msg}`);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Send an AI message to a feature's group channel.
|
|
165
|
+
* Creates the channel if it doesn't exist.
|
|
166
|
+
*/
|
|
167
|
+
export async function sendFeatureAiMessage(featureId, content, metadata = {}, options = {}, verbose) {
|
|
168
|
+
try {
|
|
169
|
+
const channel = await getFeatureChannel(featureId, verbose);
|
|
170
|
+
return await sendAiMessage(channel.id, content, metadata, options, verbose);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
174
|
+
logError(`Failed to send AI message for feature ${featureId}: ${msg}`);
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Worker — Child process entry point for processing chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Spawned by AgentWorkflowProcessor as a parallel subprocess alongside feature workers.
|
|
5
|
+
* Runs continuously, polling for unprocessed human messages across all active feature channels.
|
|
6
|
+
*
|
|
7
|
+
* Communication with parent via IPC:
|
|
8
|
+
* - Parent sends: { type: 'init', config }
|
|
9
|
+
* - Parent sends: { type: 'event:phase_completed', featureId, phase, summary, phaseOutput }
|
|
10
|
+
* - Parent sends: { type: 'event:phase_failed', featureId, phase, error }
|
|
11
|
+
* - Parent sends: { type: 'event:feature_done', featureId }
|
|
12
|
+
* - Worker sends: { type: 'log', level, message }
|
|
13
|
+
* - Worker sends: { type: 'command:pause_feature', featureId }
|
|
14
|
+
* - Worker sends: { type: 'command:resume_feature', featureId }
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Worker — Child process entry point for processing chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Spawned by AgentWorkflowProcessor as a parallel subprocess alongside feature workers.
|
|
5
|
+
* Runs continuously, polling for unprocessed human messages across all active feature channels.
|
|
6
|
+
*
|
|
7
|
+
* Communication with parent via IPC:
|
|
8
|
+
* - Parent sends: { type: 'init', config }
|
|
9
|
+
* - Parent sends: { type: 'event:phase_completed', featureId, phase, summary, phaseOutput }
|
|
10
|
+
* - Parent sends: { type: 'event:phase_failed', featureId, phase, error }
|
|
11
|
+
* - Parent sends: { type: 'event:feature_done', featureId }
|
|
12
|
+
* - Worker sends: { type: 'log', level, message }
|
|
13
|
+
* - Worker sends: { type: 'command:pause_feature', featureId }
|
|
14
|
+
* - Worker sends: { type: 'command:resume_feature', featureId }
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { getFeatureChannel, claimPendingMessages, listChannels, sendSystemMessage, } from '../../api/chat.js';
|
|
18
|
+
import { processHumanMessages, processPhaseCompletion, } from '../../phases/chat-processor/index.js';
|
|
19
|
+
function sendMessage(msg) {
|
|
20
|
+
if (process.send) {
|
|
21
|
+
process.send(msg);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function log(level, message) {
|
|
25
|
+
sendMessage({ type: 'log', level, message });
|
|
26
|
+
}
|
|
27
|
+
// ============================================================
|
|
28
|
+
// State
|
|
29
|
+
// ============================================================
|
|
30
|
+
let config = null;
|
|
31
|
+
let isRunning = false;
|
|
32
|
+
let pollTimer = null;
|
|
33
|
+
// Unique worker ID for this process instance — used for atomic message claiming
|
|
34
|
+
const WORKER_ID = `chat-worker-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
35
|
+
// Track active feature channels (featureId -> channelId)
|
|
36
|
+
const activeChannels = new Map();
|
|
37
|
+
// Poll interval in ms
|
|
38
|
+
const POLL_INTERVAL = 5000;
|
|
39
|
+
// Refresh channel list every N polls (~30s at 5s intervals)
|
|
40
|
+
const CHANNEL_REFRESH_INTERVAL = 6;
|
|
41
|
+
let pollCount = 0;
|
|
42
|
+
// ============================================================
|
|
43
|
+
// Message Polling Loop
|
|
44
|
+
// ============================================================
|
|
45
|
+
async function pollForMessages() {
|
|
46
|
+
if (!config || !isRunning)
|
|
47
|
+
return;
|
|
48
|
+
// Periodically refresh the channel list to pick up newly created channels
|
|
49
|
+
pollCount++;
|
|
50
|
+
if (pollCount % CHANNEL_REFRESH_INTERVAL === 0) {
|
|
51
|
+
await refreshChannels();
|
|
52
|
+
}
|
|
53
|
+
for (const [featureId, channelId] of activeChannels) {
|
|
54
|
+
try {
|
|
55
|
+
// Atomically claim messages — other workers won't see these
|
|
56
|
+
const claimed = await claimPendingMessages(channelId, WORKER_ID);
|
|
57
|
+
if (claimed.length > 0) {
|
|
58
|
+
log('info', `Claimed ${claimed.length} message(s) for feature ${featureId} (worker: ${WORKER_ID})`);
|
|
59
|
+
// Batch all claimed messages into a single AI session
|
|
60
|
+
await processHumanMessages(claimed, featureId, config);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
65
|
+
log('error', `Error polling channel ${channelId}: ${msg}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function startPolling() {
|
|
70
|
+
if (pollTimer)
|
|
71
|
+
return;
|
|
72
|
+
isRunning = true;
|
|
73
|
+
log('info', 'Chat worker started polling');
|
|
74
|
+
// Initial poll
|
|
75
|
+
pollForMessages().catch((error) => {
|
|
76
|
+
log('error', `Initial poll error: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
});
|
|
78
|
+
// Set up interval
|
|
79
|
+
pollTimer = setInterval(() => {
|
|
80
|
+
pollForMessages().catch((error) => {
|
|
81
|
+
log('error', `Poll error: ${error instanceof Error ? error.message : String(error)}`);
|
|
82
|
+
});
|
|
83
|
+
}, POLL_INTERVAL);
|
|
84
|
+
}
|
|
85
|
+
function stopPolling() {
|
|
86
|
+
isRunning = false;
|
|
87
|
+
if (pollTimer) {
|
|
88
|
+
clearInterval(pollTimer);
|
|
89
|
+
pollTimer = null;
|
|
90
|
+
}
|
|
91
|
+
log('info', 'Chat worker stopped polling');
|
|
92
|
+
}
|
|
93
|
+
// ============================================================
|
|
94
|
+
// Event Handlers
|
|
95
|
+
// ============================================================
|
|
96
|
+
async function handleInit(msg) {
|
|
97
|
+
config = msg.config;
|
|
98
|
+
log('info', `Chat worker initialized (id: ${WORKER_ID})`);
|
|
99
|
+
// Load existing channels before starting the poll loop
|
|
100
|
+
await refreshChannels();
|
|
101
|
+
startPolling();
|
|
102
|
+
}
|
|
103
|
+
async function handlePhaseCompleted(msg) {
|
|
104
|
+
if (!config)
|
|
105
|
+
return;
|
|
106
|
+
const { featureId, phase, summary, phaseOutput } = msg;
|
|
107
|
+
log('info', `Phase completed: ${phase} for feature ${featureId}`);
|
|
108
|
+
try {
|
|
109
|
+
// Ensure we have the channel registered
|
|
110
|
+
await ensureFeatureChannel(featureId);
|
|
111
|
+
// Process with AI for next-step suggestions
|
|
112
|
+
await processPhaseCompletion(featureId, phase, summary, phaseOutput, config);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
116
|
+
log('error', `Failed to process phase completion: ${errorMsg}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function handlePhaseFailed(msg) {
|
|
120
|
+
const { featureId, phase, error: errorStr } = msg;
|
|
121
|
+
log('warning', `Phase failed: ${phase} for feature ${featureId}`);
|
|
122
|
+
try {
|
|
123
|
+
await ensureFeatureChannel(featureId);
|
|
124
|
+
const channelId = activeChannels.get(featureId);
|
|
125
|
+
if (channelId) {
|
|
126
|
+
await sendSystemMessage(channelId, `Phase "${phase}" failed: ${errorStr}`, { phase, status: 'failed', error: errorStr });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
131
|
+
log('error', `Failed to send phase failure message: ${errorMsg}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function handleFeatureDone(msg) {
|
|
135
|
+
const { featureId } = msg;
|
|
136
|
+
log('info', `Feature done: ${featureId}`);
|
|
137
|
+
try {
|
|
138
|
+
const channelId = activeChannels.get(featureId);
|
|
139
|
+
if (channelId) {
|
|
140
|
+
await sendSystemMessage(channelId, 'All workflow phases completed.', { status: 'feature_done' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
// Non-critical
|
|
145
|
+
}
|
|
146
|
+
// Keep the channel in activeChannels so we can still process messages
|
|
147
|
+
}
|
|
148
|
+
// ============================================================
|
|
149
|
+
// Channel Management
|
|
150
|
+
// ============================================================
|
|
151
|
+
/**
|
|
152
|
+
* Refresh the active channels list from the server.
|
|
153
|
+
* Called on init and periodically during polling to discover
|
|
154
|
+
* newly created channels (e.g., when a user opens a feature chat on the web).
|
|
155
|
+
*/
|
|
156
|
+
async function refreshChannels() {
|
|
157
|
+
try {
|
|
158
|
+
const channels = await listChannels('feature');
|
|
159
|
+
let added = 0;
|
|
160
|
+
for (const channel of channels) {
|
|
161
|
+
if (channel.channel_ref_id && !activeChannels.has(channel.channel_ref_id)) {
|
|
162
|
+
activeChannels.set(channel.channel_ref_id, channel.id);
|
|
163
|
+
added++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (added > 0) {
|
|
167
|
+
log('info', `Discovered ${added} new channel(s) (total: ${activeChannels.size})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
172
|
+
log('error', `Failed to refresh channels: ${msg}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function ensureFeatureChannel(featureId) {
|
|
176
|
+
if (activeChannels.has(featureId)) {
|
|
177
|
+
return activeChannels.get(featureId);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const channel = await getFeatureChannel(featureId);
|
|
181
|
+
activeChannels.set(featureId, channel.id);
|
|
182
|
+
log('info', `Registered channel ${channel.id} for feature ${featureId}`);
|
|
183
|
+
return channel.id;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
187
|
+
log('error', `Failed to get channel for feature ${featureId}: ${msg}`);
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ============================================================
|
|
192
|
+
// Main IPC Listener
|
|
193
|
+
// ============================================================
|
|
194
|
+
process.on('message', (msg) => {
|
|
195
|
+
switch (msg.type) {
|
|
196
|
+
case 'init':
|
|
197
|
+
handleInit(msg).catch((error) => {
|
|
198
|
+
log('error', `Init error: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
|
+
});
|
|
200
|
+
break;
|
|
201
|
+
case 'event:phase_completed':
|
|
202
|
+
handlePhaseCompleted(msg).catch((error) => {
|
|
203
|
+
log('error', `Phase event error: ${error instanceof Error ? error.message : String(error)}`);
|
|
204
|
+
});
|
|
205
|
+
break;
|
|
206
|
+
case 'event:phase_failed':
|
|
207
|
+
handlePhaseFailed(msg).catch((error) => {
|
|
208
|
+
log('error', `Phase fail event error: ${error instanceof Error ? error.message : String(error)}`);
|
|
209
|
+
});
|
|
210
|
+
break;
|
|
211
|
+
case 'event:feature_done':
|
|
212
|
+
handleFeatureDone(msg).catch((error) => {
|
|
213
|
+
log('error', `Feature done event error: ${error instanceof Error ? error.message : String(error)}`);
|
|
214
|
+
});
|
|
215
|
+
break;
|
|
216
|
+
// Register a feature channel for polling when a worker starts
|
|
217
|
+
case 'event:feature_started': {
|
|
218
|
+
const startMsg = msg;
|
|
219
|
+
ensureFeatureChannel(startMsg.featureId).catch((error) => {
|
|
220
|
+
log('error', `Channel registration error: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
+
});
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
log('warning', `Unknown message type: ${msg.type}`);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
// Handle graceful shutdown
|
|
229
|
+
process.on('SIGTERM', () => {
|
|
230
|
+
stopPolling();
|
|
231
|
+
process.exit(0);
|
|
232
|
+
});
|
|
233
|
+
process.on('SIGINT', () => {
|
|
234
|
+
stopPolling();
|
|
235
|
+
process.exit(0);
|
|
236
|
+
});
|
|
237
|
+
// Handle uncaught errors
|
|
238
|
+
process.on('uncaughtException', (error) => {
|
|
239
|
+
log('error', `Uncaught exception: ${error.message}`);
|
|
240
|
+
stopPolling();
|
|
241
|
+
process.exit(1);
|
|
242
|
+
});
|
|
@@ -33,8 +33,13 @@ export declare class AgentWorkflowProcessor {
|
|
|
33
33
|
private pollTimer?;
|
|
34
34
|
/** Currently active worker processes, keyed by featureId */
|
|
35
35
|
private activeWorkers;
|
|
36
|
+
/** Chat worker subprocess — runs in parallel, handles chat messages and phase events */
|
|
37
|
+
private chatWorker?;
|
|
36
38
|
constructor(options: AgentWorkflowOptions, config: EdsgerConfig);
|
|
37
39
|
start(): Promise<void>;
|
|
40
|
+
private startChatWorker;
|
|
41
|
+
/** Send a message to the chat worker via IPC */
|
|
42
|
+
private notifyChatWorker;
|
|
38
43
|
stop(): void;
|
|
39
44
|
private processNextFeatures;
|
|
40
45
|
/**
|
|
@@ -25,10 +25,11 @@ import { createInitialState, updateFeatureState, createProcessingState, createCo
|
|
|
25
25
|
* - Memory: each fork() creates ~50-80MB Node.js process
|
|
26
26
|
*/
|
|
27
27
|
export const MAX_CONCURRENCY = 10;
|
|
28
|
-
/** Path to the compiled
|
|
28
|
+
/** Path to the compiled worker entry points */
|
|
29
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
30
30
|
const __dirname = dirname(__filename);
|
|
31
31
|
const WORKER_SCRIPT = join(__dirname, 'feature-worker.js');
|
|
32
|
+
const CHAT_WORKER_SCRIPT = join(__dirname, 'chat-worker.js');
|
|
32
33
|
export class AgentWorkflowProcessor {
|
|
33
34
|
options;
|
|
34
35
|
config;
|
|
@@ -37,6 +38,8 @@ export class AgentWorkflowProcessor {
|
|
|
37
38
|
pollTimer;
|
|
38
39
|
/** Currently active worker processes, keyed by featureId */
|
|
39
40
|
activeWorkers = new Map();
|
|
41
|
+
/** Chat worker subprocess — runs in parallel, handles chat messages and phase events */
|
|
42
|
+
chatWorker;
|
|
40
43
|
constructor(options, config) {
|
|
41
44
|
this.options = {
|
|
42
45
|
pollInterval: 30_000,
|
|
@@ -54,6 +57,8 @@ export class AgentWorkflowProcessor {
|
|
|
54
57
|
}
|
|
55
58
|
this.isRunning = true;
|
|
56
59
|
logInfo(`Concurrent processing: up to ${this.options.maxConcurrent} feature(s)`);
|
|
60
|
+
// Start chat worker subprocess
|
|
61
|
+
this.startChatWorker();
|
|
57
62
|
// Initial feature check
|
|
58
63
|
await this.processNextFeatures();
|
|
59
64
|
// Set up polling
|
|
@@ -63,6 +68,69 @@ export class AgentWorkflowProcessor {
|
|
|
63
68
|
});
|
|
64
69
|
}, this.options.pollInterval);
|
|
65
70
|
}
|
|
71
|
+
startChatWorker() {
|
|
72
|
+
try {
|
|
73
|
+
this.chatWorker = fork(CHAT_WORKER_SCRIPT, [], {
|
|
74
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
75
|
+
env: { ...process.env },
|
|
76
|
+
});
|
|
77
|
+
// Forward chat worker logs to parent
|
|
78
|
+
this.chatWorker.stdout?.on('data', (data) => {
|
|
79
|
+
const lines = data.toString().trim().split('\n');
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
if (line)
|
|
82
|
+
logInfo(` [chat] ${line}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
this.chatWorker.stderr?.on('data', (data) => {
|
|
86
|
+
const lines = data.toString().trim().split('\n');
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
if (line)
|
|
89
|
+
logError(` [chat] ${line}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
this.chatWorker.on('message', (msg) => {
|
|
93
|
+
if (msg.type === 'log') {
|
|
94
|
+
const prefix = '[chat]';
|
|
95
|
+
switch (msg.level) {
|
|
96
|
+
case 'info':
|
|
97
|
+
logInfo(` ${prefix} ${msg.message}`);
|
|
98
|
+
break;
|
|
99
|
+
case 'warning':
|
|
100
|
+
logWarning(` ${prefix} ${msg.message}`);
|
|
101
|
+
break;
|
|
102
|
+
case 'error':
|
|
103
|
+
logError(` ${prefix} ${msg.message}`);
|
|
104
|
+
break;
|
|
105
|
+
case 'success':
|
|
106
|
+
logSuccess(` ${prefix} ${msg.message}`);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
this.chatWorker.on('exit', (code) => {
|
|
112
|
+
logWarning(`Chat worker exited with code ${code}`);
|
|
113
|
+
this.chatWorker = undefined;
|
|
114
|
+
});
|
|
115
|
+
// Initialize the chat worker with config
|
|
116
|
+
this.chatWorker.send({ type: 'init', config: this.config });
|
|
117
|
+
logInfo('Chat worker started');
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
logError(`Failed to start chat worker: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Send a message to the chat worker via IPC */
|
|
124
|
+
notifyChatWorker(msg) {
|
|
125
|
+
if (this.chatWorker && this.chatWorker.connected) {
|
|
126
|
+
try {
|
|
127
|
+
this.chatWorker.send(msg);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Chat worker may have died — non-critical
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
66
134
|
stop() {
|
|
67
135
|
if (!this.isRunning)
|
|
68
136
|
return;
|
|
@@ -71,7 +139,17 @@ export class AgentWorkflowProcessor {
|
|
|
71
139
|
clearInterval(this.pollTimer);
|
|
72
140
|
this.pollTimer = undefined;
|
|
73
141
|
}
|
|
74
|
-
// Kill
|
|
142
|
+
// Kill chat worker
|
|
143
|
+
if (this.chatWorker) {
|
|
144
|
+
try {
|
|
145
|
+
this.chatWorker.kill('SIGTERM');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Ignore kill errors
|
|
149
|
+
}
|
|
150
|
+
this.chatWorker = undefined;
|
|
151
|
+
}
|
|
152
|
+
// Kill all active feature workers gracefully
|
|
75
153
|
for (const [featureId, worker] of this.activeWorkers) {
|
|
76
154
|
logInfo(`Stopping worker for feature: ${featureId}`);
|
|
77
155
|
try {
|
|
@@ -232,6 +310,8 @@ export class AgentWorkflowProcessor {
|
|
|
232
310
|
verbose: this.options.verbose,
|
|
233
311
|
config: this.config,
|
|
234
312
|
});
|
|
313
|
+
// Notify chat worker that a feature has started processing
|
|
314
|
+
this.notifyChatWorker({ type: 'event:feature_started', featureId });
|
|
235
315
|
}
|
|
236
316
|
catch (error) {
|
|
237
317
|
this.activeWorkers.delete(featureId);
|
|
@@ -243,10 +323,19 @@ export class AgentWorkflowProcessor {
|
|
|
243
323
|
if (success) {
|
|
244
324
|
this.processedFeatures = updateFeatureState(this.processedFeatures, featureId, (currentState) => createCompletedState(featureId, currentState));
|
|
245
325
|
logSuccess(`Feature completed: ${featureName}`);
|
|
326
|
+
// Notify chat worker that feature workflow is done
|
|
327
|
+
this.notifyChatWorker({ type: 'event:feature_done', featureId });
|
|
246
328
|
}
|
|
247
329
|
else {
|
|
248
330
|
this.processedFeatures = updateFeatureState(this.processedFeatures, featureId, (currentState) => createFailedState(featureId, currentState));
|
|
249
331
|
logError(`Feature failed: ${featureName}${error ? ` - ${error}` : ''}`);
|
|
332
|
+
// Notify chat worker about the failure
|
|
333
|
+
this.notifyChatWorker({
|
|
334
|
+
type: 'event:phase_failed',
|
|
335
|
+
featureId,
|
|
336
|
+
phase: 'workflow',
|
|
337
|
+
error: error || 'Unknown error',
|
|
338
|
+
});
|
|
250
339
|
}
|
|
251
340
|
// Clear heartbeat feature info
|
|
252
341
|
sendHeartbeat().catch(() => { });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build context for the chat AI processor.
|
|
3
|
+
* Fetches feature state, user stories, test cases, and recent chat history.
|
|
4
|
+
*/
|
|
5
|
+
import type { ChatMessage } from '../../types/index.js';
|
|
6
|
+
export interface ChatProcessorContext {
|
|
7
|
+
featureId: string;
|
|
8
|
+
featureDescription: string;
|
|
9
|
+
featureStatus: string;
|
|
10
|
+
executionMode: string;
|
|
11
|
+
workflow: Array<{
|
|
12
|
+
phase: string;
|
|
13
|
+
status: string;
|
|
14
|
+
}>;
|
|
15
|
+
userStories: Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
status: string;
|
|
20
|
+
}>;
|
|
21
|
+
testCases: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
is_critical: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
recentChatMessages: ChatMessage[];
|
|
28
|
+
channelId: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the full context for the chat AI to process a message.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildChatContext(featureId: string, channelId: string, verbose?: boolean): Promise<ChatProcessorContext>;
|
|
34
|
+
/**
|
|
35
|
+
* Format context into a string for the AI system prompt.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatContextForAI(context: ChatProcessorContext): string;
|