@covibes/zeroshot 1.0.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/CHANGELOG.md +167 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/cli/index.js +3990 -0
- package/cluster-templates/base-templates/debug-workflow.json +181 -0
- package/cluster-templates/base-templates/full-workflow.json +455 -0
- package/cluster-templates/base-templates/single-worker.json +48 -0
- package/cluster-templates/base-templates/worker-validator.json +131 -0
- package/cluster-templates/conductor-bootstrap.json +122 -0
- package/cluster-templates/conductor-junior-bootstrap.json +69 -0
- package/docker/zeroshot-cluster/Dockerfile +132 -0
- package/lib/completion.js +174 -0
- package/lib/id-detector.js +53 -0
- package/lib/settings.js +97 -0
- package/lib/stream-json-parser.js +236 -0
- package/package.json +121 -0
- package/src/agent/agent-config.js +121 -0
- package/src/agent/agent-context-builder.js +241 -0
- package/src/agent/agent-hook-executor.js +329 -0
- package/src/agent/agent-lifecycle.js +555 -0
- package/src/agent/agent-stuck-detector.js +256 -0
- package/src/agent/agent-task-executor.js +1034 -0
- package/src/agent/agent-trigger-evaluator.js +67 -0
- package/src/agent-wrapper.js +459 -0
- package/src/agents/git-pusher-agent.json +20 -0
- package/src/attach/attach-client.js +438 -0
- package/src/attach/attach-server.js +543 -0
- package/src/attach/index.js +35 -0
- package/src/attach/protocol.js +220 -0
- package/src/attach/ring-buffer.js +121 -0
- package/src/attach/socket-discovery.js +242 -0
- package/src/claude-task-runner.js +468 -0
- package/src/config-router.js +80 -0
- package/src/config-validator.js +598 -0
- package/src/github.js +103 -0
- package/src/isolation-manager.js +1042 -0
- package/src/ledger.js +429 -0
- package/src/logic-engine.js +223 -0
- package/src/message-bus-bridge.js +139 -0
- package/src/message-bus.js +202 -0
- package/src/name-generator.js +232 -0
- package/src/orchestrator.js +1938 -0
- package/src/schemas/sub-cluster.js +156 -0
- package/src/sub-cluster-wrapper.js +545 -0
- package/src/task-runner.js +28 -0
- package/src/template-resolver.js +347 -0
- package/src/tui/CHANGES.txt +133 -0
- package/src/tui/LAYOUT.md +261 -0
- package/src/tui/README.txt +192 -0
- package/src/tui/TWO-LEVEL-NAVIGATION.md +186 -0
- package/src/tui/data-poller.js +325 -0
- package/src/tui/demo.js +208 -0
- package/src/tui/formatters.js +123 -0
- package/src/tui/index.js +193 -0
- package/src/tui/keybindings.js +383 -0
- package/src/tui/layout.js +317 -0
- package/src/tui/renderer.js +194 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-Cluster Schema
|
|
3
|
+
*
|
|
4
|
+
* Defines the structure for nested clusters where an agent can be replaced
|
|
5
|
+
* with an entire cluster configuration, enabling recursive composition.
|
|
6
|
+
*
|
|
7
|
+
* Example:
|
|
8
|
+
* {
|
|
9
|
+
* "id": "implementation-cluster",
|
|
10
|
+
* "type": "subcluster",
|
|
11
|
+
* "config": {
|
|
12
|
+
* "agents": [
|
|
13
|
+
* { "id": "worker", "role": "implementation", ... },
|
|
14
|
+
* { "id": "validator", "role": "validator", ... }
|
|
15
|
+
* ]
|
|
16
|
+
* },
|
|
17
|
+
* "triggers": [{ "topic": "PLAN_READY" }],
|
|
18
|
+
* "hooks": {
|
|
19
|
+
* "onComplete": {
|
|
20
|
+
* "action": "publish_message",
|
|
21
|
+
* "config": { "topic": "IMPLEMENTATION_COMPLETE" }
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate sub-cluster agent configuration
|
|
29
|
+
* @param {Object} agentConfig - Agent config with type: 'subcluster'
|
|
30
|
+
* @param {Number} depth - Current nesting depth (for recursion limit)
|
|
31
|
+
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
|
32
|
+
*/
|
|
33
|
+
function validateSubCluster(agentConfig, depth = 0) {
|
|
34
|
+
const errors = [];
|
|
35
|
+
const warnings = [];
|
|
36
|
+
|
|
37
|
+
// Max nesting depth to prevent infinite recursion
|
|
38
|
+
const MAX_DEPTH = 5;
|
|
39
|
+
|
|
40
|
+
if (depth > MAX_DEPTH) {
|
|
41
|
+
errors.push(`Sub-cluster '${agentConfig.id}' exceeds max nesting depth (${MAX_DEPTH})`);
|
|
42
|
+
return { valid: false, errors, warnings };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate required fields
|
|
46
|
+
if (agentConfig.type !== 'subcluster') {
|
|
47
|
+
errors.push(`Agent '${agentConfig.id}' must have type: 'subcluster'`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!agentConfig.config) {
|
|
51
|
+
errors.push(`Sub-cluster '${agentConfig.id}' missing config field`);
|
|
52
|
+
return { valid: false, errors, warnings };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!agentConfig.config.agents || !Array.isArray(agentConfig.config.agents)) {
|
|
56
|
+
errors.push(`Sub-cluster '${agentConfig.id}' config.agents must be an array`);
|
|
57
|
+
return { valid: false, errors, warnings };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (agentConfig.config.agents.length === 0) {
|
|
61
|
+
errors.push(`Sub-cluster '${agentConfig.id}' config.agents cannot be empty`);
|
|
62
|
+
return { valid: false, errors, warnings };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Recursively validate nested cluster config
|
|
66
|
+
const configValidator = require('../config-validator');
|
|
67
|
+
const childValidation = configValidator.validateConfig(agentConfig.config, depth + 1);
|
|
68
|
+
|
|
69
|
+
if (!childValidation.valid) {
|
|
70
|
+
errors.push(...childValidation.errors.map((e) => `Sub-cluster '${agentConfig.id}': ${e}`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
warnings.push(...childValidation.warnings.map((w) => `Sub-cluster '${agentConfig.id}': ${w}`));
|
|
74
|
+
|
|
75
|
+
// Validate triggers (sub-cluster must have triggers to activate)
|
|
76
|
+
if (!agentConfig.triggers || agentConfig.triggers.length === 0) {
|
|
77
|
+
errors.push(`Sub-cluster '${agentConfig.id}' must have triggers to activate`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate hooks structure
|
|
81
|
+
if (agentConfig.hooks) {
|
|
82
|
+
if (agentConfig.hooks.onComplete) {
|
|
83
|
+
const hook = agentConfig.hooks.onComplete;
|
|
84
|
+
if (!hook.action) {
|
|
85
|
+
errors.push(`Sub-cluster '${agentConfig.id}' onComplete hook missing action`);
|
|
86
|
+
}
|
|
87
|
+
if (hook.action === 'publish_message' && !hook.config?.topic) {
|
|
88
|
+
errors.push(`Sub-cluster '${agentConfig.id}' onComplete hook missing config.topic`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check for context bridging configuration
|
|
94
|
+
if (agentConfig.contextStrategy?.parentTopics) {
|
|
95
|
+
if (!Array.isArray(agentConfig.contextStrategy.parentTopics)) {
|
|
96
|
+
errors.push(`Sub-cluster '${agentConfig.id}' contextStrategy.parentTopics must be an array`);
|
|
97
|
+
} else {
|
|
98
|
+
// Validate each parent topic is a string
|
|
99
|
+
for (const topic of agentConfig.contextStrategy.parentTopics) {
|
|
100
|
+
if (typeof topic !== 'string') {
|
|
101
|
+
errors.push(
|
|
102
|
+
`Sub-cluster '${agentConfig.id}' parentTopics must contain strings, got ${typeof topic}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
valid: errors.length === 0,
|
|
111
|
+
errors,
|
|
112
|
+
warnings,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get default sub-cluster template
|
|
118
|
+
* @returns {Object} Default sub-cluster configuration
|
|
119
|
+
*/
|
|
120
|
+
function getDefaultSubCluster() {
|
|
121
|
+
return {
|
|
122
|
+
id: 'example-subcluster',
|
|
123
|
+
type: 'subcluster',
|
|
124
|
+
role: 'orchestrator',
|
|
125
|
+
config: {
|
|
126
|
+
agents: [
|
|
127
|
+
{
|
|
128
|
+
id: 'worker',
|
|
129
|
+
role: 'implementation',
|
|
130
|
+
triggers: [{ topic: 'PARENT_TRIGGER' }],
|
|
131
|
+
hooks: {
|
|
132
|
+
onComplete: {
|
|
133
|
+
action: 'publish_message',
|
|
134
|
+
config: { topic: 'WORK_COMPLETE' },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
triggers: [{ topic: 'START_WORK' }],
|
|
141
|
+
hooks: {
|
|
142
|
+
onComplete: {
|
|
143
|
+
action: 'publish_message',
|
|
144
|
+
config: { topic: 'SUBCLUSTER_COMPLETE' },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
contextStrategy: {
|
|
148
|
+
parentTopics: ['ISSUE_OPENED', 'PLAN_READY'],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
validateSubCluster,
|
|
155
|
+
getDefaultSubCluster,
|
|
156
|
+
};
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubClusterWrapper - Manages nested cluster lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Implements same interface as AgentWrapper but spawns a child Orchestrator
|
|
5
|
+
* instead of a Claude task. Enables recursive cluster composition.
|
|
6
|
+
*
|
|
7
|
+
* Lifecycle:
|
|
8
|
+
* - On trigger match: spawns nested Orchestrator with child cluster config
|
|
9
|
+
* - Passes parent context to child via ISSUE_OPENED equivalent
|
|
10
|
+
* - Listens for child CLUSTER_COMPLETE → executes onComplete hook
|
|
11
|
+
* - Listens for child CLUSTER_FAILED → executes onError hook
|
|
12
|
+
* - Supports maxIterations at subcluster level (restart child on failure)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const LogicEngine = require('./logic-engine');
|
|
16
|
+
const MessageBusBridge = require('./message-bus-bridge');
|
|
17
|
+
const { DEFAULT_MAX_ITERATIONS } = require('./agent/agent-config');
|
|
18
|
+
|
|
19
|
+
class SubClusterWrapper {
|
|
20
|
+
constructor(config, messageBus, parentCluster, options = {}) {
|
|
21
|
+
this.id = config.id;
|
|
22
|
+
this.role = config.role || 'orchestrator';
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.messageBus = messageBus; // Parent message bus
|
|
25
|
+
this.parentCluster = parentCluster;
|
|
26
|
+
this.logicEngine = new LogicEngine(messageBus, parentCluster);
|
|
27
|
+
|
|
28
|
+
this.state = 'idle';
|
|
29
|
+
this.iteration = 0;
|
|
30
|
+
this.maxIterations = config.maxIterations || DEFAULT_MAX_ITERATIONS;
|
|
31
|
+
this.running = false;
|
|
32
|
+
this.unsubscribe = null;
|
|
33
|
+
|
|
34
|
+
// Child cluster state
|
|
35
|
+
this.childCluster = null; // { id, orchestrator, messageBus, bridge }
|
|
36
|
+
this.childClusterId = null;
|
|
37
|
+
|
|
38
|
+
this.quiet = options.quiet || false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Log message (respects quiet mode)
|
|
43
|
+
* @private
|
|
44
|
+
*/
|
|
45
|
+
_log(...args) {
|
|
46
|
+
if (!this.quiet) {
|
|
47
|
+
console.log(...args);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Publish lifecycle event to parent message bus
|
|
53
|
+
* @private
|
|
54
|
+
*/
|
|
55
|
+
_publishLifecycle(event, details = {}) {
|
|
56
|
+
this.messageBus.publish({
|
|
57
|
+
cluster_id: this.parentCluster.id,
|
|
58
|
+
topic: 'AGENT_LIFECYCLE',
|
|
59
|
+
sender: this.id,
|
|
60
|
+
receiver: 'system',
|
|
61
|
+
content: {
|
|
62
|
+
text: `${this.id}: ${event}`,
|
|
63
|
+
data: {
|
|
64
|
+
event,
|
|
65
|
+
agent: this.id,
|
|
66
|
+
role: this.role,
|
|
67
|
+
state: this.state,
|
|
68
|
+
type: 'subcluster',
|
|
69
|
+
...details,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Start the sub-cluster wrapper (begin listening for triggers)
|
|
77
|
+
*/
|
|
78
|
+
start() {
|
|
79
|
+
if (this.running) {
|
|
80
|
+
throw new Error(`SubCluster ${this.id} is already running`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.running = true;
|
|
84
|
+
this.state = 'idle';
|
|
85
|
+
|
|
86
|
+
// Subscribe to parent cluster messages
|
|
87
|
+
this.unsubscribe = this.messageBus.subscribe((message) => {
|
|
88
|
+
if (message.cluster_id === this.parentCluster.id) {
|
|
89
|
+
this._handleMessage(message).catch((error) => {
|
|
90
|
+
console.error(`\n${'='.repeat(80)}`);
|
|
91
|
+
console.error(`🔴 FATAL: SubCluster ${this.id} message handler crashed`);
|
|
92
|
+
console.error(`${'='.repeat(80)}`);
|
|
93
|
+
console.error(`Topic: ${message.topic}`);
|
|
94
|
+
console.error(`Error: ${error.message}`);
|
|
95
|
+
console.error(`Stack: ${error.stack}`);
|
|
96
|
+
console.error(`${'='.repeat(80)}\n`);
|
|
97
|
+
throw error;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this._log(`SubCluster ${this.id} started (role: ${this.role})`);
|
|
103
|
+
this._publishLifecycle('STARTED', {
|
|
104
|
+
triggers: this.config.triggers?.map((t) => t.topic) || [],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Stop the sub-cluster wrapper
|
|
110
|
+
*/
|
|
111
|
+
async stop() {
|
|
112
|
+
if (!this.running) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.running = false;
|
|
117
|
+
this.state = 'stopped';
|
|
118
|
+
|
|
119
|
+
if (this.unsubscribe) {
|
|
120
|
+
this.unsubscribe();
|
|
121
|
+
this.unsubscribe = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Stop child cluster if running
|
|
125
|
+
if (this.childCluster) {
|
|
126
|
+
await this._stopChildCluster();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this._log(`SubCluster ${this.id} stopped`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle incoming message from parent cluster
|
|
134
|
+
* @private
|
|
135
|
+
*/
|
|
136
|
+
async _handleMessage(message) {
|
|
137
|
+
// Check if any trigger matches
|
|
138
|
+
const matchingTrigger = this._findMatchingTrigger(message);
|
|
139
|
+
if (!matchingTrigger) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check state
|
|
144
|
+
if (!this.running) {
|
|
145
|
+
console.warn(`[${this.id}] ⚠️ DROPPING message (not running): ${message.topic}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (this.state !== 'idle') {
|
|
149
|
+
console.warn(
|
|
150
|
+
`[${this.id}] ⚠️ DROPPING message (busy, state=${this.state}): ${message.topic}`
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Evaluate trigger logic
|
|
156
|
+
this.state = 'evaluating_logic';
|
|
157
|
+
const shouldExecute = this._evaluateTrigger(matchingTrigger, message);
|
|
158
|
+
|
|
159
|
+
if (!shouldExecute) {
|
|
160
|
+
this.state = 'idle';
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Execute trigger action (spawn child cluster)
|
|
165
|
+
await this._handleTrigger(message);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Find trigger matching the message topic
|
|
170
|
+
* @private
|
|
171
|
+
*/
|
|
172
|
+
_findMatchingTrigger(message) {
|
|
173
|
+
if (!this.config.triggers) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return this.config.triggers.find((trigger) => {
|
|
178
|
+
if (trigger.topic === '*' || trigger.topic === message.topic) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (trigger.topic.endsWith('*')) {
|
|
182
|
+
const prefix = trigger.topic.slice(0, -1);
|
|
183
|
+
return message.topic.startsWith(prefix);
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Evaluate trigger logic
|
|
191
|
+
* @private
|
|
192
|
+
*/
|
|
193
|
+
_evaluateTrigger(trigger, message) {
|
|
194
|
+
if (!trigger.logic || !trigger.logic.script) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const agent = {
|
|
199
|
+
id: this.id,
|
|
200
|
+
role: this.role,
|
|
201
|
+
iteration: this.iteration,
|
|
202
|
+
cluster_id: this.parentCluster.id,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return this.logicEngine.evaluate(trigger.logic.script, agent, message);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Handle trigger: spawn child cluster
|
|
210
|
+
* @private
|
|
211
|
+
*/
|
|
212
|
+
async _handleTrigger(triggeringMessage) {
|
|
213
|
+
// Check max iterations
|
|
214
|
+
if (this.iteration >= this.maxIterations) {
|
|
215
|
+
this._log(`[SubCluster ${this.id}] Hit max iterations (${this.maxIterations}), failing`);
|
|
216
|
+
this._publishLifecycle('MAX_ITERATIONS_REACHED', {
|
|
217
|
+
iteration: this.iteration,
|
|
218
|
+
maxIterations: this.maxIterations,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
this.messageBus.publish({
|
|
222
|
+
cluster_id: this.parentCluster.id,
|
|
223
|
+
topic: 'CLUSTER_FAILED',
|
|
224
|
+
sender: this.id,
|
|
225
|
+
receiver: 'system',
|
|
226
|
+
content: {
|
|
227
|
+
text: `SubCluster ${this.id} hit max iterations limit (${this.maxIterations})`,
|
|
228
|
+
data: {
|
|
229
|
+
reason: 'max_iterations',
|
|
230
|
+
iteration: this.iteration,
|
|
231
|
+
maxIterations: this.maxIterations,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
this.state = 'failed';
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.iteration++;
|
|
241
|
+
this.state = 'spawning_child';
|
|
242
|
+
|
|
243
|
+
this._publishLifecycle('SPAWNING_CHILD', {
|
|
244
|
+
iteration: this.iteration,
|
|
245
|
+
triggeredBy: triggeringMessage.topic,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// Build child cluster context from parent messages
|
|
250
|
+
const context = this._buildChildContext(triggeringMessage);
|
|
251
|
+
|
|
252
|
+
// Spawn child cluster
|
|
253
|
+
await this._spawnChildCluster(context);
|
|
254
|
+
|
|
255
|
+
this._publishLifecycle('CHILD_SPAWNED', {
|
|
256
|
+
childClusterId: this.childClusterId,
|
|
257
|
+
iteration: this.iteration,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
this.state = 'monitoring_child';
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.error(`\n${'='.repeat(80)}`);
|
|
263
|
+
console.error(`🔴 CHILD CLUSTER SPAWN FAILED - ${this.id}`);
|
|
264
|
+
console.error(`${'='.repeat(80)}`);
|
|
265
|
+
console.error(`Error: ${error.message}`);
|
|
266
|
+
console.error(`Stack: ${error.stack}`);
|
|
267
|
+
console.error(`${'='.repeat(80)}\n`);
|
|
268
|
+
|
|
269
|
+
this.state = 'error';
|
|
270
|
+
|
|
271
|
+
// Execute onError hook
|
|
272
|
+
await this._executeHook('onError', { error, triggeringMessage });
|
|
273
|
+
|
|
274
|
+
// Return to idle if we haven't hit max iterations
|
|
275
|
+
if (this.iteration < this.maxIterations) {
|
|
276
|
+
this.state = 'idle';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build context for child cluster from parent messages
|
|
283
|
+
* @private
|
|
284
|
+
*/
|
|
285
|
+
_buildChildContext(triggeringMessage) {
|
|
286
|
+
const parentTopics = this.config.contextStrategy?.parentTopics || [];
|
|
287
|
+
|
|
288
|
+
let context = `# Child Cluster Context\n\n`;
|
|
289
|
+
context += `Parent Cluster: ${this.parentCluster.id}\n`;
|
|
290
|
+
context += `SubCluster ID: ${this.id}\n`;
|
|
291
|
+
context += `Iteration: ${this.iteration}\n\n`;
|
|
292
|
+
|
|
293
|
+
// Add messages from specified parent topics
|
|
294
|
+
if (parentTopics.length > 0) {
|
|
295
|
+
context += `## Parent Cluster Messages\n\n`;
|
|
296
|
+
|
|
297
|
+
for (const topic of parentTopics) {
|
|
298
|
+
const messages = this.messageBus.query({
|
|
299
|
+
cluster_id: this.parentCluster.id,
|
|
300
|
+
topic,
|
|
301
|
+
limit: 10,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (messages.length > 0) {
|
|
305
|
+
context += `### Topic: ${topic}\n\n`;
|
|
306
|
+
for (const msg of messages) {
|
|
307
|
+
context += `[${new Date(msg.timestamp).toISOString()}] ${msg.sender}:\n`;
|
|
308
|
+
if (msg.content?.text) {
|
|
309
|
+
context += `${msg.content.text}\n`;
|
|
310
|
+
}
|
|
311
|
+
if (msg.content?.data) {
|
|
312
|
+
context += `Data: ${JSON.stringify(msg.content.data, null, 2)}\n`;
|
|
313
|
+
}
|
|
314
|
+
context += '\n';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Add triggering message
|
|
321
|
+
context += `\n## Triggering Message\n\n`;
|
|
322
|
+
context += `Topic: ${triggeringMessage.topic}\n`;
|
|
323
|
+
context += `Sender: ${triggeringMessage.sender}\n`;
|
|
324
|
+
if (triggeringMessage.content?.text) {
|
|
325
|
+
context += `\n${triggeringMessage.content.text}\n`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return context;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Spawn child cluster with nested Orchestrator
|
|
333
|
+
* @private
|
|
334
|
+
*/
|
|
335
|
+
async _spawnChildCluster(context) {
|
|
336
|
+
const Orchestrator = require('./orchestrator');
|
|
337
|
+
const path = require('path');
|
|
338
|
+
|
|
339
|
+
// Generate child cluster ID (namespaced under parent)
|
|
340
|
+
const childId = `${this.parentCluster.id}.${this.id}`;
|
|
341
|
+
this.childClusterId = childId;
|
|
342
|
+
|
|
343
|
+
// Create child orchestrator with separate database
|
|
344
|
+
const childOrchestrator = new Orchestrator({
|
|
345
|
+
quiet: this.quiet,
|
|
346
|
+
skipLoad: true,
|
|
347
|
+
storageDir: path.join(this.parentCluster.ledger.dbPath, '..', 'subclusters', childId),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Start child cluster with text input (context from parent)
|
|
351
|
+
const childCluster = await childOrchestrator.start(
|
|
352
|
+
this.config.config, // Child cluster config
|
|
353
|
+
{ text: context },
|
|
354
|
+
{ testMode: false }
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Create message bridge
|
|
358
|
+
const bridge = new MessageBusBridge(this.messageBus, childCluster.messageBus, {
|
|
359
|
+
parentClusterId: this.parentCluster.id,
|
|
360
|
+
childClusterId: childId,
|
|
361
|
+
parentTopics: this.config.contextStrategy?.parentTopics || [],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Store child cluster state
|
|
365
|
+
this.childCluster = {
|
|
366
|
+
id: childId,
|
|
367
|
+
orchestrator: childOrchestrator,
|
|
368
|
+
messageBus: childCluster.messageBus,
|
|
369
|
+
bridge,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Listen for child cluster completion
|
|
373
|
+
childCluster.messageBus.subscribe((message) => {
|
|
374
|
+
if (message.topic === 'CLUSTER_COMPLETE' && message.cluster_id === childId) {
|
|
375
|
+
this._onChildComplete(message).catch((err) => {
|
|
376
|
+
console.error(`Failed to handle child completion: ${err.message}`);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Listen for child cluster failure
|
|
382
|
+
childCluster.messageBus.subscribe((message) => {
|
|
383
|
+
if (message.topic === 'CLUSTER_FAILED' && message.cluster_id === childId) {
|
|
384
|
+
this._onChildFailed(message).catch((err) => {
|
|
385
|
+
console.error(`Failed to handle child failure: ${err.message}`);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Handle child cluster completion
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
async _onChildComplete(message) {
|
|
396
|
+
this._log(`[SubCluster ${this.id}] Child cluster completed`);
|
|
397
|
+
|
|
398
|
+
this._publishLifecycle('CHILD_COMPLETE', {
|
|
399
|
+
childClusterId: this.childClusterId,
|
|
400
|
+
iteration: this.iteration,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Execute onComplete hook
|
|
404
|
+
await this._executeHook('onComplete', {
|
|
405
|
+
result: message,
|
|
406
|
+
triggeringMessage: null,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Clean up child cluster
|
|
410
|
+
await this._stopChildCluster();
|
|
411
|
+
|
|
412
|
+
this.state = 'idle';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Handle child cluster failure
|
|
417
|
+
* @private
|
|
418
|
+
*/
|
|
419
|
+
async _onChildFailed(message) {
|
|
420
|
+
this._log(`[SubCluster ${this.id}] Child cluster failed`);
|
|
421
|
+
|
|
422
|
+
this._publishLifecycle('CHILD_FAILED', {
|
|
423
|
+
childClusterId: this.childClusterId,
|
|
424
|
+
iteration: this.iteration,
|
|
425
|
+
error: message.content?.data?.reason,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Execute onError hook
|
|
429
|
+
const error = new Error(message.content?.data?.reason || 'Child cluster failed');
|
|
430
|
+
await this._executeHook('onError', { error, triggeringMessage: null });
|
|
431
|
+
|
|
432
|
+
// Clean up child cluster
|
|
433
|
+
await this._stopChildCluster();
|
|
434
|
+
|
|
435
|
+
// Retry if within max iterations
|
|
436
|
+
if (this.iteration < this.maxIterations) {
|
|
437
|
+
this.state = 'idle';
|
|
438
|
+
} else {
|
|
439
|
+
this.state = 'failed';
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Stop child cluster
|
|
445
|
+
* @private
|
|
446
|
+
*/
|
|
447
|
+
async _stopChildCluster() {
|
|
448
|
+
if (!this.childCluster) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Close message bridge
|
|
453
|
+
if (this.childCluster.bridge) {
|
|
454
|
+
this.childCluster.bridge.close();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Stop child orchestrator
|
|
458
|
+
try {
|
|
459
|
+
await this.childCluster.orchestrator.stop(this.childCluster.id);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
console.warn(`Warning: Failed to stop child cluster ${this.childCluster.id}: ${err.message}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
this.childCluster = null;
|
|
465
|
+
this.childClusterId = null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Execute a hook
|
|
470
|
+
* @private
|
|
471
|
+
*/
|
|
472
|
+
_executeHook(hookName, context) {
|
|
473
|
+
const hook = this.config.hooks?.[hookName];
|
|
474
|
+
if (!hook) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (hook.action === 'publish_message') {
|
|
479
|
+
const message = this._substituteTemplate(hook.config, context);
|
|
480
|
+
this.messageBus.publish({
|
|
481
|
+
cluster_id: this.parentCluster.id,
|
|
482
|
+
sender: this.id,
|
|
483
|
+
...message,
|
|
484
|
+
});
|
|
485
|
+
} else {
|
|
486
|
+
throw new Error(`Unknown hook action: ${hook.action}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Substitute template variables in hook config
|
|
492
|
+
* @private
|
|
493
|
+
*/
|
|
494
|
+
_substituteTemplate(config, context) {
|
|
495
|
+
if (!config) {
|
|
496
|
+
throw new Error('_substituteTemplate: config is required');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const json = JSON.stringify(config);
|
|
500
|
+
|
|
501
|
+
let substituted = json
|
|
502
|
+
.replace(/\{\{cluster\.id\}\}/g, this.parentCluster.id)
|
|
503
|
+
.replace(/\{\{subcluster\.id\}\}/g, this.id)
|
|
504
|
+
.replace(/\{\{child\.id\}\}/g, this.childClusterId || '')
|
|
505
|
+
.replace(/\{\{iteration\}\}/g, String(this.iteration))
|
|
506
|
+
.replace(/\{\{error\.message\}\}/g, context.error?.message || '');
|
|
507
|
+
|
|
508
|
+
// Parse and validate
|
|
509
|
+
let result;
|
|
510
|
+
try {
|
|
511
|
+
result = JSON.parse(substituted);
|
|
512
|
+
} catch (e) {
|
|
513
|
+
console.error('JSON parse failed. Substituted string:');
|
|
514
|
+
console.error(substituted);
|
|
515
|
+
throw new Error(`Template substitution produced invalid JSON: ${e.message}`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Resume sub-cluster task (not implemented for subclusters)
|
|
523
|
+
*/
|
|
524
|
+
resume(_resumeContext) {
|
|
525
|
+
throw new Error('Resume not implemented for subclusters');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Get current sub-cluster state
|
|
530
|
+
*/
|
|
531
|
+
getState() {
|
|
532
|
+
return {
|
|
533
|
+
id: this.id,
|
|
534
|
+
role: this.role,
|
|
535
|
+
state: this.state,
|
|
536
|
+
iteration: this.iteration,
|
|
537
|
+
maxIterations: this.maxIterations,
|
|
538
|
+
type: 'subcluster',
|
|
539
|
+
childClusterId: this.childClusterId,
|
|
540
|
+
childRunning: this.childCluster !== null,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
module.exports = SubClusterWrapper;
|