kernelbot 1.0.28 → 1.0.32
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/.env.example +4 -0
- package/bin/kernel.js +68 -7
- package/config.example.yaml +45 -1
- package/package.json +1 -1
- package/src/agent.js +613 -28
- package/src/bot.js +643 -7
- package/src/claude-auth.js +93 -0
- package/src/coder.js +48 -6
- package/src/life/codebase.js +388 -0
- package/src/life/engine.js +1317 -0
- package/src/life/evolution.js +244 -0
- package/src/life/improvements.js +81 -0
- package/src/life/journal.js +109 -0
- package/src/life/memory.js +283 -0
- package/src/life/share-queue.js +136 -0
- package/src/prompts/orchestrator.js +71 -5
- package/src/prompts/workers.js +65 -5
- package/src/providers/models.js +8 -1
- package/src/self.js +122 -0
- package/src/services/stt.js +139 -0
- package/src/services/tts.js +124 -0
- package/src/swarm/job-manager.js +54 -7
- package/src/swarm/job.js +19 -1
- package/src/swarm/worker-registry.js +5 -0
- package/src/tools/coding.js +6 -1
- package/src/tools/orchestrator-tools.js +93 -21
- package/src/tools/os.js +14 -1
- package/src/utils/config.js +105 -2
- package/src/worker.js +98 -5
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { getLogger } from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
const CACHE_DIR = join(homedir(), '.kernelbot', 'tts-cache');
|
|
9
|
+
const DEFAULT_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'; // ElevenLabs "George" voice
|
|
10
|
+
const MAX_TEXT_LENGTH = 5000; // ElevenLabs limit
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Text-to-Speech service using ElevenLabs API.
|
|
14
|
+
* Converts text to OGG/opus audio compatible with Telegram voice messages.
|
|
15
|
+
*/
|
|
16
|
+
export class TTSService {
|
|
17
|
+
constructor(config = {}) {
|
|
18
|
+
this.apiKey = config.elevenlabs?.api_key || process.env.ELEVENLABS_API_KEY || null;
|
|
19
|
+
this.voiceId = config.elevenlabs?.voice_id || process.env.ELEVENLABS_VOICE_ID || DEFAULT_VOICE_ID;
|
|
20
|
+
this.enabled = config.voice?.tts_enabled !== false && !!this.apiKey;
|
|
21
|
+
this.logger = getLogger();
|
|
22
|
+
|
|
23
|
+
// Ensure cache directory exists
|
|
24
|
+
if (this.enabled) {
|
|
25
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Check if TTS is available. */
|
|
30
|
+
isAvailable() {
|
|
31
|
+
return this.enabled && !!this.apiKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert text to an OGG/opus audio buffer.
|
|
36
|
+
* Returns the file path to the generated audio, or null on failure.
|
|
37
|
+
*/
|
|
38
|
+
async synthesize(text) {
|
|
39
|
+
if (!this.isAvailable()) return null;
|
|
40
|
+
if (!text || text.trim().length === 0) return null;
|
|
41
|
+
|
|
42
|
+
// Truncate if too long
|
|
43
|
+
const cleanText = text.slice(0, MAX_TEXT_LENGTH).trim();
|
|
44
|
+
|
|
45
|
+
// Check cache
|
|
46
|
+
const cacheKey = this._cacheKey(cleanText, this.voiceId);
|
|
47
|
+
const cachedPath = join(CACHE_DIR, `${cacheKey}.ogg`);
|
|
48
|
+
if (existsSync(cachedPath)) {
|
|
49
|
+
this.logger.debug(`[TTS] Cache hit: ${cacheKey}`);
|
|
50
|
+
return cachedPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
this.logger.info(`[TTS] Synthesizing ${cleanText.length} chars with voice ${this.voiceId}`);
|
|
55
|
+
|
|
56
|
+
const response = await axios.post(
|
|
57
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${this.voiceId}`,
|
|
58
|
+
{
|
|
59
|
+
text: cleanText,
|
|
60
|
+
model_id: 'eleven_multilingual_v2',
|
|
61
|
+
voice_settings: {
|
|
62
|
+
stability: 0.5,
|
|
63
|
+
similarity_boost: 0.75,
|
|
64
|
+
style: 0.0,
|
|
65
|
+
use_speaker_boost: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
headers: {
|
|
70
|
+
'Accept': 'audio/mpeg',
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
'xi-api-key': this.apiKey,
|
|
73
|
+
},
|
|
74
|
+
responseType: 'arraybuffer',
|
|
75
|
+
timeout: 30_000,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// ElevenLabs returns MP3 by default when Accept: audio/mpeg
|
|
80
|
+
// We write it as-is; Telegram accepts MP3 for voice messages via sendVoice
|
|
81
|
+
// when sent with the right content type
|
|
82
|
+
const audioBuffer = Buffer.from(response.data);
|
|
83
|
+
|
|
84
|
+
if (audioBuffer.length < 100) {
|
|
85
|
+
this.logger.warn('[TTS] Response too small, likely an error');
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Cache the result
|
|
90
|
+
writeFileSync(cachedPath, audioBuffer);
|
|
91
|
+
this.logger.info(`[TTS] Synthesized and cached: ${cachedPath} (${audioBuffer.length} bytes)`);
|
|
92
|
+
|
|
93
|
+
return cachedPath;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.response) {
|
|
96
|
+
const errBody = err.response.data instanceof Buffer
|
|
97
|
+
? err.response.data.toString('utf-8').slice(0, 200)
|
|
98
|
+
: JSON.stringify(err.response.data).slice(0, 200);
|
|
99
|
+
this.logger.error(`[TTS] API error ${err.response.status}: ${errBody}`);
|
|
100
|
+
} else {
|
|
101
|
+
this.logger.error(`[TTS] Request failed: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Generate a deterministic cache key from text + voice. */
|
|
108
|
+
_cacheKey(text, voiceId) {
|
|
109
|
+
return createHash('sha256').update(`${voiceId}:${text}`).digest('hex').slice(0, 16);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Clear the TTS cache. */
|
|
113
|
+
clearCache() {
|
|
114
|
+
try {
|
|
115
|
+
const files = readdirSync(CACHE_DIR);
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
unlinkSync(join(CACHE_DIR, file));
|
|
118
|
+
}
|
|
119
|
+
this.logger.info(`[TTS] Cache cleared (${files.length} files)`);
|
|
120
|
+
} catch {
|
|
121
|
+
// Cache dir may not exist yet
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/swarm/job-manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
2
|
import { Job } from './job.js';
|
|
3
|
+
import { WORKER_TYPES } from './worker-registry.js';
|
|
3
4
|
import { getLogger } from '../utils/logger.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -10,6 +11,7 @@ import { getLogger } from '../utils/logger.js';
|
|
|
10
11
|
* job:completed (job)
|
|
11
12
|
* job:failed (job)
|
|
12
13
|
* job:cancelled (job)
|
|
14
|
+
* job:ready (job) — emitted when a queued job's dependencies are all met
|
|
13
15
|
*/
|
|
14
16
|
export class JobManager extends EventEmitter {
|
|
15
17
|
constructor({ jobTimeoutSeconds = 300, cleanupIntervalMinutes = 30 } = {}) {
|
|
@@ -22,9 +24,12 @@ export class JobManager extends EventEmitter {
|
|
|
22
24
|
/** Create a new job (status: queued). */
|
|
23
25
|
createJob(chatId, workerType, task) {
|
|
24
26
|
const job = new Job({ chatId, workerType, task });
|
|
27
|
+
// Set per-job timeout from worker type config, fall back to global
|
|
28
|
+
const workerConfig = WORKER_TYPES[workerType];
|
|
29
|
+
job.timeoutMs = workerConfig?.timeout ? workerConfig.timeout * 1000 : this.jobTimeoutMs;
|
|
25
30
|
this.jobs.set(job.id, job);
|
|
26
31
|
const logger = getLogger();
|
|
27
|
-
logger.info(`[JobManager] Job created: ${job.id} [${workerType}] for chat ${chatId} — "${task.slice(0, 100)}"`);
|
|
32
|
+
logger.info(`[JobManager] Job created: ${job.id} [${workerType}] for chat ${chatId} — timeout: ${job.timeoutMs / 1000}s — "${task.slice(0, 100)}"`);
|
|
28
33
|
logger.debug(`[JobManager] Total jobs tracked: ${this.jobs.size}`);
|
|
29
34
|
return job;
|
|
30
35
|
}
|
|
@@ -36,13 +41,17 @@ export class JobManager extends EventEmitter {
|
|
|
36
41
|
getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → running`);
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
/** Move a job to completed with a result. */
|
|
40
|
-
completeJob(jobId, result) {
|
|
44
|
+
/** Move a job to completed with a result and optional structured data. */
|
|
45
|
+
completeJob(jobId, result, structuredResult = null) {
|
|
41
46
|
const job = this._get(jobId);
|
|
42
47
|
job.transition('completed');
|
|
43
48
|
job.result = result;
|
|
44
|
-
|
|
49
|
+
if (structuredResult) {
|
|
50
|
+
job.structuredResult = structuredResult;
|
|
51
|
+
}
|
|
52
|
+
getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → completed (${job.duration}s) — result: ${(result || '').length} chars, structured: ${!!structuredResult}`);
|
|
45
53
|
this.emit('job:completed', job);
|
|
54
|
+
this._checkDependents(job);
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
/** Move a job to failed with an error message. */
|
|
@@ -52,6 +61,7 @@ export class JobManager extends EventEmitter {
|
|
|
52
61
|
job.error = typeof error === 'string' ? error : error?.message || String(error);
|
|
53
62
|
getLogger().error(`[JobManager] Job ${job.id} [${job.workerType}] → failed (${job.duration}s) — ${job.error}`);
|
|
54
63
|
this.emit('job:failed', job);
|
|
64
|
+
this._failDependents(job);
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
/** Cancel a specific job. Returns the job or null if not found / already terminal. */
|
|
@@ -145,13 +155,14 @@ export class JobManager extends EventEmitter {
|
|
|
145
155
|
if (job.status === 'running' && job.startedAt) {
|
|
146
156
|
checkedCount++;
|
|
147
157
|
const elapsed = now - job.startedAt;
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
const timeoutMs = job.timeoutMs || this.jobTimeoutMs;
|
|
159
|
+
if (elapsed > timeoutMs) {
|
|
160
|
+
getLogger().warn(`[JobManager] Job ${job.id} [${job.workerType}] timed out — elapsed: ${Math.round(elapsed / 1000)}s, limit: ${timeoutMs / 1000}s`);
|
|
150
161
|
// Cancel the worker first so it stops executing & frees resources
|
|
151
162
|
if (job.worker && typeof job.worker.cancel === 'function') {
|
|
152
163
|
job.worker.cancel();
|
|
153
164
|
}
|
|
154
|
-
this.failJob(job.id, `Timed out after ${
|
|
165
|
+
this.failJob(job.id, `Timed out after ${timeoutMs / 1000}s`);
|
|
155
166
|
}
|
|
156
167
|
}
|
|
157
168
|
}
|
|
@@ -160,6 +171,42 @@ export class JobManager extends EventEmitter {
|
|
|
160
171
|
}
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
/** Check if any queued jobs have all dependencies met after a job completes. */
|
|
175
|
+
_checkDependents(completedJob) {
|
|
176
|
+
const logger = getLogger();
|
|
177
|
+
for (const job of this.jobs.values()) {
|
|
178
|
+
if (job.status !== 'queued' || job.dependsOn.length === 0) continue;
|
|
179
|
+
if (!job.dependsOn.includes(completedJob.id)) continue;
|
|
180
|
+
|
|
181
|
+
// Check if ALL dependencies are completed
|
|
182
|
+
const allMet = job.dependsOn.every(depId => {
|
|
183
|
+
const dep = this.jobs.get(depId);
|
|
184
|
+
return dep && dep.status === 'completed';
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (allMet) {
|
|
188
|
+
logger.info(`[JobManager] Job ${job.id} [${job.workerType}] — all dependencies met, emitting job:ready`);
|
|
189
|
+
this.emit('job:ready', job);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Cascade failure to dependent jobs when a job fails. */
|
|
195
|
+
_failDependents(failedJob) {
|
|
196
|
+
const logger = getLogger();
|
|
197
|
+
for (const job of this.jobs.values()) {
|
|
198
|
+
if (job.status !== 'queued' || job.dependsOn.length === 0) continue;
|
|
199
|
+
if (!job.dependsOn.includes(failedJob.id)) continue;
|
|
200
|
+
|
|
201
|
+
logger.warn(`[JobManager] Job ${job.id} [${job.workerType}] — dependency ${failedJob.id} failed, cascading failure`);
|
|
202
|
+
job.transition('failed');
|
|
203
|
+
job.error = `Dependency job ${failedJob.id} failed: ${failedJob.error || 'unknown error'}`;
|
|
204
|
+
this.emit('job:failed', job);
|
|
205
|
+
// Recursively cascade to jobs depending on this one
|
|
206
|
+
this._failDependents(job);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
163
210
|
/** Internal: get job or throw. */
|
|
164
211
|
_get(jobId) {
|
|
165
212
|
const job = this.jobs.get(jobId);
|
package/src/swarm/job.js
CHANGED
|
@@ -20,12 +20,19 @@ export class Job {
|
|
|
20
20
|
this.task = task;
|
|
21
21
|
this.status = 'queued';
|
|
22
22
|
this.result = null;
|
|
23
|
+
this.structuredResult = null; // WorkerResult: { summary, status, details, artifacts, followUp, toolsUsed, errors }
|
|
24
|
+
this.context = null; // Orchestrator-provided context string
|
|
25
|
+
this.dependsOn = []; // Job IDs this job depends on
|
|
26
|
+
this.userId = null; // Telegram user ID for persona loading
|
|
23
27
|
this.error = null;
|
|
24
28
|
this.worker = null; // WorkerAgent ref
|
|
25
29
|
this.statusMessageId = null; // Telegram message for progress edits
|
|
26
30
|
this.createdAt = Date.now();
|
|
27
31
|
this.startedAt = null;
|
|
28
32
|
this.completedAt = null;
|
|
33
|
+
this.timeoutMs = null; // Per-job timeout (set from worker type config)
|
|
34
|
+
this.progress = []; // Recent activity entries
|
|
35
|
+
this.lastActivity = null; // Timestamp of last activity
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/** Transition to a new status. Throws if the transition is invalid. */
|
|
@@ -46,6 +53,13 @@ export class Job {
|
|
|
46
53
|
return Math.round((end - this.startedAt) / 1000);
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/** Record a progress heartbeat from the worker. Caps at 20 entries. */
|
|
57
|
+
addProgress(text) {
|
|
58
|
+
this.progress.push(text);
|
|
59
|
+
if (this.progress.length > 20) this.progress.shift();
|
|
60
|
+
this.lastActivity = Date.now();
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
/** Whether this job is in a terminal state. */
|
|
50
64
|
get isTerminal() {
|
|
51
65
|
return ['completed', 'failed', 'cancelled'].includes(this.status);
|
|
@@ -62,6 +76,10 @@ export class Job {
|
|
|
62
76
|
};
|
|
63
77
|
const emoji = statusEmoji[this.status] || '❓';
|
|
64
78
|
const dur = this.duration != null ? ` (${this.duration}s)` : '';
|
|
65
|
-
|
|
79
|
+
const summaryText = this.structuredResult?.summary || null;
|
|
80
|
+
const lastAct = !summaryText && this.progress.length > 0 ? ` | ${this.progress[this.progress.length - 1]}` : '';
|
|
81
|
+
const resultSnippet = summaryText ? ` | ${summaryText.slice(0, 80)}` : '';
|
|
82
|
+
const deps = this.dependsOn.length > 0 ? ` [deps: ${this.dependsOn.join(',')}]` : '';
|
|
83
|
+
return `${emoji} \`${this.id}\` [${this.workerType}] ${this.task.slice(0, 60)}${this.task.length > 60 ? '...' : ''}${dur}${resultSnippet}${lastAct}${deps}`;
|
|
66
84
|
}
|
|
67
85
|
}
|
|
@@ -10,30 +10,35 @@ export const WORKER_TYPES = {
|
|
|
10
10
|
emoji: '💻',
|
|
11
11
|
categories: ['core', 'coding', 'git', 'github'],
|
|
12
12
|
description: 'Write code, fix bugs, create PRs',
|
|
13
|
+
timeout: 86400, // 24 hours — Claude Code can legitimately run for hours
|
|
13
14
|
},
|
|
14
15
|
browser: {
|
|
15
16
|
label: 'Browser Worker',
|
|
16
17
|
emoji: '🌐',
|
|
17
18
|
categories: ['browser'],
|
|
18
19
|
description: 'Web search, scraping, screenshots',
|
|
20
|
+
timeout: 300, // 5 minutes
|
|
19
21
|
},
|
|
20
22
|
system: {
|
|
21
23
|
label: 'System Worker',
|
|
22
24
|
emoji: '🖥️',
|
|
23
25
|
categories: ['core', 'process', 'monitor', 'network'],
|
|
24
26
|
description: 'OS operations, monitoring, network',
|
|
27
|
+
timeout: 600, // 10 minutes
|
|
25
28
|
},
|
|
26
29
|
devops: {
|
|
27
30
|
label: 'DevOps Worker',
|
|
28
31
|
emoji: '🚀',
|
|
29
32
|
categories: ['core', 'docker', 'process', 'monitor', 'network', 'git'],
|
|
30
33
|
description: 'Docker, deploy, infrastructure',
|
|
34
|
+
timeout: 3600, // 1 hour
|
|
31
35
|
},
|
|
32
36
|
research: {
|
|
33
37
|
label: 'Research Worker',
|
|
34
38
|
emoji: '🔍',
|
|
35
39
|
categories: ['browser', 'core'],
|
|
36
40
|
description: 'Deep web research and analysis',
|
|
41
|
+
timeout: 600, // 10 minutes
|
|
37
42
|
},
|
|
38
43
|
};
|
|
39
44
|
|
package/src/tools/coding.js
CHANGED
|
@@ -5,11 +5,15 @@ import { getLogger } from '../utils/logger.js';
|
|
|
5
5
|
|
|
6
6
|
let spawner = null;
|
|
7
7
|
|
|
8
|
-
function getSpawner(config) {
|
|
8
|
+
export function getSpawner(config) {
|
|
9
9
|
if (!spawner) spawner = new ClaudeCodeSpawner(config);
|
|
10
10
|
return spawner;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export function resetClaudeCodeSpawner() {
|
|
14
|
+
spawner = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
export const definitions = [
|
|
14
18
|
{
|
|
15
19
|
name: 'spawn_claude_code',
|
|
@@ -57,6 +61,7 @@ export const handlers = {
|
|
|
57
61
|
prompt: params.prompt,
|
|
58
62
|
maxTurns: params.max_turns,
|
|
59
63
|
onOutput: onUpdate,
|
|
64
|
+
signal: context.signal || null,
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
// Show stderr if any
|
|
@@ -23,6 +23,15 @@ export const orchestratorToolDefinitions = [
|
|
|
23
23
|
type: 'string',
|
|
24
24
|
description: 'A clear, detailed description of what the worker should do.',
|
|
25
25
|
},
|
|
26
|
+
context: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Optional background context for the worker — relevant conversation details, goals, constraints. The worker cannot see the chat history, so include anything important here.',
|
|
29
|
+
},
|
|
30
|
+
depends_on: {
|
|
31
|
+
type: 'array',
|
|
32
|
+
items: { type: 'string' },
|
|
33
|
+
description: 'Optional array of job IDs that must complete before this worker starts. The worker will receive dependency results as context.',
|
|
34
|
+
},
|
|
26
35
|
},
|
|
27
36
|
required: ['worker_type', 'task'],
|
|
28
37
|
},
|
|
@@ -158,12 +167,12 @@ export const orchestratorToolDefinitions = [
|
|
|
158
167
|
*/
|
|
159
168
|
export async function executeOrchestratorTool(name, input, context) {
|
|
160
169
|
const logger = getLogger();
|
|
161
|
-
const { chatId, jobManager, config, spawnWorker, automationManager } = context;
|
|
170
|
+
const { chatId, jobManager, config, spawnWorker, automationManager, user } = context;
|
|
162
171
|
|
|
163
172
|
switch (name) {
|
|
164
173
|
case 'dispatch_task': {
|
|
165
|
-
const { worker_type, task } = input;
|
|
166
|
-
logger.info(`[dispatch_task] Request: type=${worker_type}, task="${task.slice(0, 120)}"`);
|
|
174
|
+
const { worker_type, task, context: taskContext, depends_on } = input;
|
|
175
|
+
logger.info(`[dispatch_task] Request: type=${worker_type}, task="${task.slice(0, 120)}", deps=${depends_on?.length || 0}`);
|
|
167
176
|
|
|
168
177
|
// Validate worker type
|
|
169
178
|
if (!WORKER_TYPES[worker_type]) {
|
|
@@ -171,31 +180,82 @@ export async function executeOrchestratorTool(name, input, context) {
|
|
|
171
180
|
return { error: `Unknown worker type: ${worker_type}. Valid: ${workerTypeEnum.join(', ')}` };
|
|
172
181
|
}
|
|
173
182
|
|
|
174
|
-
//
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
183
|
+
// Validate dependency IDs
|
|
184
|
+
const depIds = Array.isArray(depends_on) ? depends_on : [];
|
|
185
|
+
for (const depId of depIds) {
|
|
186
|
+
const depJob = jobManager.getJob(depId);
|
|
187
|
+
if (!depJob) {
|
|
188
|
+
logger.warn(`[dispatch_task] Unknown dependency job: ${depId}`);
|
|
189
|
+
return { error: `Dependency job not found: ${depId}` };
|
|
190
|
+
}
|
|
191
|
+
if (depJob.status === 'failed' || depJob.status === 'cancelled') {
|
|
192
|
+
logger.warn(`[dispatch_task] Dependency job ${depId} already ${depJob.status}`);
|
|
193
|
+
return { error: `Dependency job ${depId} already ${depJob.status}. Cannot dispatch.` };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check concurrent job limit (only if we'll spawn immediately)
|
|
198
|
+
const hasUnmetDeps = depIds.some(id => {
|
|
199
|
+
const dep = jobManager.getJob(id);
|
|
200
|
+
return dep && dep.status !== 'completed';
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!hasUnmetDeps) {
|
|
204
|
+
const running = jobManager.getRunningJobsForChat(chatId);
|
|
205
|
+
const maxConcurrent = config.swarm?.max_concurrent_jobs || 3;
|
|
206
|
+
logger.debug(`[dispatch_task] Running jobs: ${running.length}/${maxConcurrent} for chat ${chatId}`);
|
|
207
|
+
if (running.length >= maxConcurrent) {
|
|
208
|
+
logger.warn(`[dispatch_task] Rejected — concurrent limit reached: ${running.length}/${maxConcurrent} jobs for chat ${chatId}`);
|
|
209
|
+
return { error: `Maximum concurrent jobs (${maxConcurrent}) reached. Wait for a job to finish or cancel one.` };
|
|
210
|
+
}
|
|
181
211
|
}
|
|
182
212
|
|
|
183
213
|
// Pre-check credentials for the worker's tools
|
|
184
214
|
const toolNames = getToolNamesForWorkerType(worker_type);
|
|
215
|
+
const missingCreds = [];
|
|
185
216
|
for (const toolName of toolNames) {
|
|
186
217
|
const missing = getMissingCredential(toolName, config);
|
|
187
218
|
if (missing) {
|
|
188
|
-
|
|
219
|
+
missingCreds.push(missing);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let credentialWarning = null;
|
|
224
|
+
if (missingCreds.length > 0) {
|
|
225
|
+
if (worker_type === 'coding') {
|
|
226
|
+
// Soft warning for coding worker — spawn_claude_code handles git/GitHub internally
|
|
227
|
+
const warnings = missingCreds.map(c => `${c.label} (${c.envKey})`).join(', ');
|
|
228
|
+
logger.info(`[dispatch_task] Coding worker — soft credential warning (non-blocking): ${warnings}`);
|
|
229
|
+
credentialWarning = `Note: The following credentials are not configured as env vars: ${warnings}. If git/GitHub tools are unavailable, use spawn_claude_code for ALL operations — it handles git, GitHub, and PR creation internally.`;
|
|
230
|
+
} else {
|
|
231
|
+
// Hard block for all other worker types
|
|
232
|
+
const first = missingCreds[0];
|
|
233
|
+
logger.warn(`[dispatch_task] Missing credential for ${worker_type}: ${first.envKey}`);
|
|
189
234
|
return {
|
|
190
|
-
error: `Missing credential for ${worker_type} worker: ${
|
|
235
|
+
error: `Missing credential for ${worker_type} worker: ${first.label} (${first.envKey}). Ask the user to provide it.`,
|
|
191
236
|
};
|
|
192
237
|
}
|
|
193
238
|
}
|
|
194
239
|
|
|
195
|
-
// Create
|
|
240
|
+
// Create the job with context and dependencies
|
|
196
241
|
const job = jobManager.createJob(chatId, worker_type, task);
|
|
242
|
+
job.context = [taskContext, credentialWarning].filter(Boolean).join('\n\n') || null;
|
|
243
|
+
job.dependsOn = depIds;
|
|
244
|
+
job.userId = user?.id || null;
|
|
197
245
|
const workerConfig = WORKER_TYPES[worker_type];
|
|
198
246
|
|
|
247
|
+
// If dependencies are not all met, leave job queued (job:ready will spawn it later)
|
|
248
|
+
if (hasUnmetDeps) {
|
|
249
|
+
logger.info(`[dispatch_task] Job ${job.id} queued — waiting for dependencies: ${depIds.join(', ')}`);
|
|
250
|
+
return {
|
|
251
|
+
job_id: job.id,
|
|
252
|
+
worker_type,
|
|
253
|
+
status: 'queued_waiting',
|
|
254
|
+
depends_on: depIds,
|
|
255
|
+
message: `${workerConfig.emoji} ${workerConfig.label} queued — waiting for ${depIds.length} dependency job(s) to complete.`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
199
259
|
logger.info(`[dispatch_task] Dispatching job ${job.id} — ${workerConfig.emoji} ${workerConfig.label}: "${task.slice(0, 100)}"`);
|
|
200
260
|
|
|
201
261
|
// Fire and forget — spawnWorker handles lifecycle
|
|
@@ -224,14 +284,26 @@ export async function executeOrchestratorTool(name, input, context) {
|
|
|
224
284
|
return { message: 'No jobs for this chat.' };
|
|
225
285
|
}
|
|
226
286
|
return {
|
|
227
|
-
jobs: jobs.slice(0, 20).map((j) =>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
287
|
+
jobs: jobs.slice(0, 20).map((j) => {
|
|
288
|
+
const entry = {
|
|
289
|
+
id: j.id,
|
|
290
|
+
worker_type: j.workerType,
|
|
291
|
+
status: j.status,
|
|
292
|
+
task: j.task.slice(0, 100),
|
|
293
|
+
duration: j.duration,
|
|
294
|
+
recent_activity: j.progress.slice(-5),
|
|
295
|
+
last_activity_seconds_ago: j.lastActivity ? Math.round((Date.now() - j.lastActivity) / 1000) : null,
|
|
296
|
+
summary: j.toSummary(),
|
|
297
|
+
};
|
|
298
|
+
if (j.dependsOn.length > 0) entry.depends_on = j.dependsOn;
|
|
299
|
+
if (j.structuredResult) {
|
|
300
|
+
entry.result_summary = j.structuredResult.summary;
|
|
301
|
+
entry.result_status = j.structuredResult.status;
|
|
302
|
+
if (j.structuredResult.artifacts?.length > 0) entry.artifacts = j.structuredResult.artifacts;
|
|
303
|
+
if (j.structuredResult.followUp) entry.follow_up = j.structuredResult.followUp;
|
|
304
|
+
}
|
|
305
|
+
return entry;
|
|
306
|
+
}),
|
|
235
307
|
};
|
|
236
308
|
}
|
|
237
309
|
|
package/src/tools/os.js
CHANGED
|
@@ -122,12 +122,15 @@ export const handlers = {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
return new Promise((res) => {
|
|
125
|
+
let abortHandler = null;
|
|
126
|
+
|
|
125
127
|
const child = exec(
|
|
126
128
|
command,
|
|
127
129
|
{ timeout: timeout_seconds * 1000, maxBuffer: 10 * 1024 * 1024 },
|
|
128
130
|
(error, stdout, stderr) => {
|
|
131
|
+
if (abortHandler && context.signal) context.signal.removeEventListener('abort', abortHandler);
|
|
129
132
|
if (error && error.killed) {
|
|
130
|
-
return res({ error: `Command timed out after ${timeout_seconds}s` });
|
|
133
|
+
return res({ error: `Command timed out or was cancelled after ${timeout_seconds}s` });
|
|
131
134
|
}
|
|
132
135
|
const result = {
|
|
133
136
|
stdout: stdout || '',
|
|
@@ -147,6 +150,16 @@ export const handlers = {
|
|
|
147
150
|
res(result);
|
|
148
151
|
},
|
|
149
152
|
);
|
|
153
|
+
|
|
154
|
+
// Wire abort signal to kill the child process
|
|
155
|
+
if (context.signal) {
|
|
156
|
+
if (context.signal.aborted) {
|
|
157
|
+
child.kill('SIGTERM');
|
|
158
|
+
} else {
|
|
159
|
+
abortHandler = () => child.kill('SIGTERM');
|
|
160
|
+
context.signal.addEventListener('abort', abortHandler, { once: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
150
163
|
});
|
|
151
164
|
},
|
|
152
165
|
|