@grantx/fleet-core 0.1.0
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/package.json +22 -0
- package/src/agent-runner.js +291 -0
- package/src/conductor-loop.js +336 -0
- package/src/conductor-runner.js +182 -0
- package/src/config.js +103 -0
- package/src/dispatch-prompts.js +141 -0
- package/src/fleet-utils.js +189 -0
- package/src/index.js +12 -0
- package/src/logger.js +49 -0
- package/src/platform.js +102 -0
- package/src/smart-dispatcher.js +153 -0
- package/src/supabase-client.js +87 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// conductor-runner.js — Dedicated conductor channel.
|
|
2
|
+
// Separate from AgentPool to avoid contention with worker agents.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { log } from './logger.js';
|
|
9
|
+
import { lockDir, ensureLockDir, buildAgentEnv, agentHome as resolveAgentHome } from './platform.js';
|
|
10
|
+
import { getConductor } from './config.js';
|
|
11
|
+
|
|
12
|
+
const LOCK_FILENAME = 'fleet-conductor.lock';
|
|
13
|
+
|
|
14
|
+
export class ConductorRunner {
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.projectRoot = config._projectRoot || config.projectRoot || process.cwd();
|
|
18
|
+
this.teamId = config.teamId;
|
|
19
|
+
this.running = false;
|
|
20
|
+
this._process = null;
|
|
21
|
+
|
|
22
|
+
const conductor = getConductor(config);
|
|
23
|
+
this.conductorName = conductor?.name || 'conductor';
|
|
24
|
+
|
|
25
|
+
// Load or create session
|
|
26
|
+
const sessFile = path.join(this.projectRoot, '.fleet', 'sessions.json');
|
|
27
|
+
try {
|
|
28
|
+
const sessions = JSON.parse(readFileSync(sessFile, 'utf8'));
|
|
29
|
+
const entry = sessions[this.conductorName];
|
|
30
|
+
if (typeof entry === 'string') {
|
|
31
|
+
this.sessionId = entry;
|
|
32
|
+
this.initialized = false;
|
|
33
|
+
} else if (entry) {
|
|
34
|
+
this.sessionId = entry.id;
|
|
35
|
+
this.initialized = entry.initialized || false;
|
|
36
|
+
} else {
|
|
37
|
+
this.sessionId = randomUUID();
|
|
38
|
+
this.initialized = false;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
this.sessionId = randomUUID();
|
|
42
|
+
this.initialized = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
ensureLockDir();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async invoke(prompt) {
|
|
49
|
+
if (this.running) {
|
|
50
|
+
return { ok: false, message: 'Conductor is already running' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lockFile = path.join(lockDir, LOCK_FILENAME);
|
|
54
|
+
const homeDir = resolveAgentHome(this.projectRoot, this.conductorName);
|
|
55
|
+
const workDir = this.projectRoot;
|
|
56
|
+
|
|
57
|
+
return this._spawn(prompt, lockFile, homeDir, workDir);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async _spawn(prompt, lockFile, homeDir, workDir, retryCount = 0) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
this.running = true;
|
|
63
|
+
|
|
64
|
+
try { writeFileSync(lockFile, String(process.pid)); } catch { /* ignore */ }
|
|
65
|
+
|
|
66
|
+
const env = buildAgentEnv(this.conductorName, homeDir, this.teamId);
|
|
67
|
+
let stdout = '';
|
|
68
|
+
let stderr = '';
|
|
69
|
+
|
|
70
|
+
const sessionFlag = this.initialized ? '--resume' : '--session-id';
|
|
71
|
+
const child = spawn('claude', [
|
|
72
|
+
'-p', prompt,
|
|
73
|
+
'--dangerously-skip-permissions',
|
|
74
|
+
sessionFlag, this.sessionId,
|
|
75
|
+
], {
|
|
76
|
+
cwd: workDir,
|
|
77
|
+
env,
|
|
78
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this._process = child;
|
|
82
|
+
|
|
83
|
+
// 10 minute timeout for conductor
|
|
84
|
+
const timeoutTimer = setTimeout(() => {
|
|
85
|
+
if (this.running) {
|
|
86
|
+
log.warn('conductor-runner', `Conductor timed out after 600s`);
|
|
87
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
88
|
+
}
|
|
89
|
+
}, 600000);
|
|
90
|
+
|
|
91
|
+
log.info('conductor-runner', `Spawned conductor (PID ${child.pid}, ${sessionFlag} ${this.sessionId.slice(0, 8)}...)`);
|
|
92
|
+
|
|
93
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
94
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
95
|
+
|
|
96
|
+
child.on('close', (code) => {
|
|
97
|
+
clearTimeout(timeoutTimer);
|
|
98
|
+
this.running = false;
|
|
99
|
+
this._process = null;
|
|
100
|
+
try { unlinkSync(lockFile); } catch { /* ignore */ }
|
|
101
|
+
|
|
102
|
+
const output = stdout.trim();
|
|
103
|
+
const errText = stderr.trim();
|
|
104
|
+
|
|
105
|
+
// Self-healing
|
|
106
|
+
if (code === 1 && retryCount < 2) {
|
|
107
|
+
if (errText.includes('already in use') && !this.initialized) {
|
|
108
|
+
log.info('conductor-runner', 'Session exists, retrying with --resume');
|
|
109
|
+
this.initialized = true;
|
|
110
|
+
this._saveSession();
|
|
111
|
+
this._spawn(prompt, lockFile, homeDir, workDir, retryCount + 1).then(resolve);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (errText.includes('No conversation found') && this.initialized) {
|
|
115
|
+
this.sessionId = randomUUID();
|
|
116
|
+
this.initialized = false;
|
|
117
|
+
this._saveSession();
|
|
118
|
+
log.info('conductor-runner', `Fresh session ${this.sessionId.slice(0, 8)}`);
|
|
119
|
+
this._spawn(prompt, lockFile, homeDir, workDir, retryCount + 1).then(resolve);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (code === 0 || (code === null && output.length > 0)) {
|
|
125
|
+
if (!this.initialized) {
|
|
126
|
+
this.initialized = true;
|
|
127
|
+
this._saveSession();
|
|
128
|
+
}
|
|
129
|
+
log.info('conductor-runner', `Conductor completed (${output.length} chars)`);
|
|
130
|
+
resolve({ ok: true, output });
|
|
131
|
+
} else {
|
|
132
|
+
const errMsg = errText.slice(0, 500) || 'No output';
|
|
133
|
+
log.warn('conductor-runner', `Conductor exited with code ${code}: ${errMsg.slice(0, 200)}`);
|
|
134
|
+
resolve({ ok: false, message: `Conductor exited with code ${code}: ${errMsg}` });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
child.on('error', (err) => {
|
|
139
|
+
clearTimeout(timeoutTimer);
|
|
140
|
+
this.running = false;
|
|
141
|
+
this._process = null;
|
|
142
|
+
try { unlinkSync(lockFile); } catch { /* ignore */ }
|
|
143
|
+
resolve({ ok: false, message: `Failed to spawn conductor: ${err.message}` });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_saveSession() {
|
|
149
|
+
const sessFile = path.join(this.projectRoot, '.fleet', 'sessions.json');
|
|
150
|
+
try {
|
|
151
|
+
let sessions = {};
|
|
152
|
+
try { sessions = JSON.parse(readFileSync(sessFile, 'utf8')); } catch { /* new file */ }
|
|
153
|
+
sessions[this.conductorName] = { id: this.sessionId, initialized: this.initialized };
|
|
154
|
+
mkdirSync(path.dirname(sessFile), { recursive: true });
|
|
155
|
+
writeFileSync(sessFile, JSON.stringify(sessions, null, 2));
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log.error('conductor-runner', `Failed to save session: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
kill() {
|
|
162
|
+
if (this._process) {
|
|
163
|
+
try { this._process.kill('SIGTERM'); } catch { /* ignore */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse the ---DISPATCH--- block from conductor output.
|
|
170
|
+
* Returns { dispatches, surface, reviews, kills } or null if no block found.
|
|
171
|
+
*/
|
|
172
|
+
export function parseDispatchBlock(output) {
|
|
173
|
+
const match = output.match(/---DISPATCH---([\s\S]*?)---END---/);
|
|
174
|
+
if (!match) return null;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(match[1].trim());
|
|
178
|
+
} catch {
|
|
179
|
+
log.warn('conductor-runner', 'Failed to parse DISPATCH block as JSON');
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// config.js — Load and validate fleet.config.json
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load fleet.config.json from the given path, or search upward from cwd.
|
|
8
|
+
* Returns a frozen config object.
|
|
9
|
+
*/
|
|
10
|
+
export function loadConfig(configPath) {
|
|
11
|
+
const resolved = configPath
|
|
12
|
+
? path.resolve(configPath)
|
|
13
|
+
: findConfigFile(process.cwd());
|
|
14
|
+
|
|
15
|
+
if (!resolved) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'fleet.config.json not found. Run `fleet init` to create one.'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw new Error(`Failed to parse ${resolved}: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
validate(raw, resolved);
|
|
29
|
+
|
|
30
|
+
// Attach resolved paths
|
|
31
|
+
raw._configPath = resolved;
|
|
32
|
+
raw._projectRoot = raw.projectRoot || path.dirname(resolved);
|
|
33
|
+
|
|
34
|
+
return Object.freeze(raw);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Search upward from dir for fleet.config.json.
|
|
39
|
+
*/
|
|
40
|
+
function findConfigFile(dir) {
|
|
41
|
+
let current = path.resolve(dir);
|
|
42
|
+
const root = path.parse(current).root;
|
|
43
|
+
|
|
44
|
+
while (current !== root) {
|
|
45
|
+
const candidate = path.join(current, 'fleet.config.json');
|
|
46
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
47
|
+
current = path.dirname(current);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate config schema. Throws on invalid.
|
|
54
|
+
*/
|
|
55
|
+
function validate(config, filePath) {
|
|
56
|
+
if (config.version !== 1) {
|
|
57
|
+
throw new Error(`${filePath}: unsupported version ${config.version} (expected 1)`);
|
|
58
|
+
}
|
|
59
|
+
if (!config.teamId || typeof config.teamId !== 'string') {
|
|
60
|
+
throw new Error(`${filePath}: teamId is required (string)`);
|
|
61
|
+
}
|
|
62
|
+
if (!config.supabase?.url || !config.supabase?.key) {
|
|
63
|
+
throw new Error(`${filePath}: supabase.url and supabase.key are required`);
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(config.agents) || config.agents.length === 0) {
|
|
66
|
+
throw new Error(`${filePath}: agents array is required and must be non-empty`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const conductors = config.agents.filter(a => a.isConductor);
|
|
70
|
+
if (conductors.length !== 1) {
|
|
71
|
+
throw new Error(`${filePath}: exactly one agent must have isConductor: true (found ${conductors.length})`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const agent of config.agents) {
|
|
75
|
+
if (!agent.name || typeof agent.name !== 'string') {
|
|
76
|
+
throw new Error(`${filePath}: each agent must have a name (string)`);
|
|
77
|
+
}
|
|
78
|
+
if (!agent.role || typeof agent.role !== 'string') {
|
|
79
|
+
throw new Error(`${filePath}: agent "${agent.name}" must have a role (string)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get the full agent roster from config.
|
|
86
|
+
*/
|
|
87
|
+
export function getAgentRoster(config) {
|
|
88
|
+
return config.agents;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the conductor agent entry.
|
|
93
|
+
*/
|
|
94
|
+
export function getConductor(config) {
|
|
95
|
+
return config.agents.find(a => a.isConductor);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get worker (non-conductor) agents.
|
|
100
|
+
*/
|
|
101
|
+
export function getWorkerAgents(config) {
|
|
102
|
+
return config.agents.filter(a => !a.isConductor);
|
|
103
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// dispatch-prompts.js — Templatized prompt builders.
|
|
2
|
+
// No grantx-kb references. Agent names from config.
|
|
3
|
+
|
|
4
|
+
import { getWorkerAgents, getConductor } from './config.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build the task dispatch prompt sent to a worker agent.
|
|
8
|
+
*/
|
|
9
|
+
export function buildTaskDispatchPrompt(agent, task, config) {
|
|
10
|
+
const agentConfig = config.agents.find(a => a.name === agent);
|
|
11
|
+
const role = agentConfig?.role || 'general purpose agent';
|
|
12
|
+
|
|
13
|
+
return `You are "${agent}", a fleet agent specializing in: ${role}.
|
|
14
|
+
|
|
15
|
+
## Your Task
|
|
16
|
+
|
|
17
|
+
**Task ID**: ${task.id}
|
|
18
|
+
**Title**: ${task.title}
|
|
19
|
+
**Priority**: ${task.priority || 3} (1=highest, 5=lowest)
|
|
20
|
+
${task.description ? `**Description**: ${task.description}` : ''}
|
|
21
|
+
${task.sprint_id ? `**Sprint**: ${task.sprint_id}` : ''}
|
|
22
|
+
|
|
23
|
+
## Instructions
|
|
24
|
+
|
|
25
|
+
1. **Understand** the task fully before starting work.
|
|
26
|
+
2. **Search** the codebase for relevant files and context.
|
|
27
|
+
3. **Implement** the required changes, following existing patterns and conventions.
|
|
28
|
+
4. **Test** your changes if a test suite exists.
|
|
29
|
+
5. **Document** what you did and any decisions you made.
|
|
30
|
+
|
|
31
|
+
## Output Format
|
|
32
|
+
|
|
33
|
+
When you're done, output a clear summary of:
|
|
34
|
+
- What you did (specific files changed, functions added/modified)
|
|
35
|
+
- Why you made the choices you did
|
|
36
|
+
- Any issues or blockers encountered
|
|
37
|
+
- Suggestions for follow-up work (if any)
|
|
38
|
+
|
|
39
|
+
Keep your summary concise but thorough — it will be stored as the task result.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the conductor orchestration prompt for event-driven decisions.
|
|
44
|
+
*/
|
|
45
|
+
export function buildConductorEventPrompt(events, state, config) {
|
|
46
|
+
const workers = getWorkerAgents(config);
|
|
47
|
+
const workerList = workers.map(a => `- ${a.name}: ${a.role}`).join('\n');
|
|
48
|
+
|
|
49
|
+
let prompt = `You are the fleet conductor. You orchestrate a team of AI agents.
|
|
50
|
+
|
|
51
|
+
## Available Agents
|
|
52
|
+
${workerList}
|
|
53
|
+
|
|
54
|
+
## Current Fleet State
|
|
55
|
+
|
|
56
|
+
**Pending tasks** (${(state.pendingTasks || []).length}):
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
for (const t of (state.pendingTasks || []).slice(0, 10)) {
|
|
60
|
+
const deps = t.depends_on?.length ? ` [deps: ${t._deps_satisfied || 0}/${t.depends_on.length} done]` : '';
|
|
61
|
+
const assignee = t.to_agent ? ` -> ${t.to_agent}` : '';
|
|
62
|
+
prompt += ` P${t.priority || 3}${assignee}: "${t.title}"${deps} (${t.id.slice(0, 8)})\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
prompt += `\n**Active tasks** (${(state.activeTasks || []).length}):\n`;
|
|
66
|
+
for (const t of (state.activeTasks || []).slice(0, 10)) {
|
|
67
|
+
prompt += ` ${t.to_agent}: "${t.title}" (${t.status})\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (state.blockedTasks?.length > 0) {
|
|
71
|
+
prompt += `\n**Blocked tasks** (${state.blockedTasks.length}):\n`;
|
|
72
|
+
for (const t of state.blockedTasks.slice(0, 5)) {
|
|
73
|
+
prompt += ` ${t.to_agent}: "${t.title}" — ${t.blocked_reason || 'no reason'}\n`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (state.sprintProgress) {
|
|
78
|
+
prompt += `\n**Sprint**: ${state.sprint?.title || 'active'} — ${state.sprintProgress}\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
prompt += `\n## Events Since Last Cycle\n`;
|
|
82
|
+
for (const e of events) {
|
|
83
|
+
if (e.type === 'task_completed') {
|
|
84
|
+
prompt += `- COMPLETED: ${e.agent} finished "${e.title}"\n`;
|
|
85
|
+
} else if (e.type === 'task_failed') {
|
|
86
|
+
prompt += `- FAILED: ${e.agent} failed on "${e.title}" (attempt ${e.failure_count}): ${e.failure_reason || 'unknown'}\n`;
|
|
87
|
+
} else if (e.type === 'task_flagged') {
|
|
88
|
+
prompt += `- FLAGGED: ${e.agent} flagged "${e.title}" as ${e.flag_type}: ${e.flag_reason || ''}\n`;
|
|
89
|
+
} else if (e.type === 'task_unblocked') {
|
|
90
|
+
prompt += `- UNBLOCKED: "${e.title}" — all dependencies completed\n`;
|
|
91
|
+
} else if (e.type === 'dispatch_tick') {
|
|
92
|
+
prompt += `- DISPATCH_TICK: check for pending work to assign\n`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
prompt += `
|
|
97
|
+
## Your Decision
|
|
98
|
+
|
|
99
|
+
Analyze the events and fleet state. Output your decisions in this exact format:
|
|
100
|
+
|
|
101
|
+
\`\`\`
|
|
102
|
+
---DISPATCH---
|
|
103
|
+
{
|
|
104
|
+
"dispatches": [
|
|
105
|
+
{ "agent": "<name>", "task_id": "<uuid>", "reason": "<why this agent>" }
|
|
106
|
+
],
|
|
107
|
+
"surface": [
|
|
108
|
+
{ "channel": "fleet-decisions", "message": "<status update>" }
|
|
109
|
+
],
|
|
110
|
+
"reviews": [],
|
|
111
|
+
"kills": []
|
|
112
|
+
}
|
|
113
|
+
---END---
|
|
114
|
+
\`\`\`
|
|
115
|
+
|
|
116
|
+
Rules:
|
|
117
|
+
- Only dispatch idle agents (not currently running).
|
|
118
|
+
- Respect task dependencies — do not dispatch tasks with unsatisfied deps.
|
|
119
|
+
- If no action needed, output an empty dispatches array.
|
|
120
|
+
- Kill agents only if they appear stuck (running >30 min on a simple task).
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
return prompt;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a review prompt for code review tasks.
|
|
128
|
+
*/
|
|
129
|
+
export function buildReviewPrompt(reviewer, subject, taskId) {
|
|
130
|
+
return `You are "${reviewer}", assigned to review: ${subject}.
|
|
131
|
+
|
|
132
|
+
${taskId ? `Task ID: ${taskId}` : ''}
|
|
133
|
+
|
|
134
|
+
Review the recent changes and provide:
|
|
135
|
+
1. Code quality assessment
|
|
136
|
+
2. Potential issues or bugs
|
|
137
|
+
3. Suggestions for improvement
|
|
138
|
+
4. Approval or request for changes
|
|
139
|
+
|
|
140
|
+
Be concise and actionable.`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// fleet-utils.js — Shared utilities for dispatch execution and fleet state.
|
|
2
|
+
// Team-scoped version: all queries include team_id.
|
|
3
|
+
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
import { buildTaskDispatchPrompt, buildReviewPrompt } from './dispatch-prompts.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetch current fleet state from Supabase (team-scoped).
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchFleetState(supabase) {
|
|
11
|
+
try {
|
|
12
|
+
const [pendingTasks, activeTasks, blockedTasks, heartbeats, sprint, recentLessons, pendingPitches] = await Promise.all([
|
|
13
|
+
supabase.get('task_queue?status=eq.pending&order=priority.asc,created_at.desc&limit=30'),
|
|
14
|
+
supabase.get('task_queue?status=in.(claimed,in_progress)&order=created_at.desc&limit=20'),
|
|
15
|
+
supabase.get('task_queue?status=eq.blocked&order=flagged_at.desc&limit=10'),
|
|
16
|
+
supabase.get('heartbeats?order=created_at.desc&limit=50'),
|
|
17
|
+
supabase.get('sprints?status=eq.active&limit=1'),
|
|
18
|
+
supabase.get('knowledge?category=in.(lesson,gotcha)&superseded_by=is.null&order=hits.desc,created_at.desc&limit=5&select=title,category,confidence'),
|
|
19
|
+
supabase.get('pitches?status=eq.pending&select=id,agent_id,title&order=created_at.desc&limit=10'),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// Deduplicate heartbeats: latest per agent
|
|
23
|
+
const hbMap = new Map();
|
|
24
|
+
for (const hb of heartbeats) {
|
|
25
|
+
if (!hbMap.has(hb.agent_id)) hbMap.set(hb.agent_id, hb);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Sprint progress
|
|
29
|
+
let sprintProgress = null;
|
|
30
|
+
const activeSprint = sprint[0] || null;
|
|
31
|
+
if (activeSprint) {
|
|
32
|
+
const sprintTasks = await supabase.get(
|
|
33
|
+
`task_queue?sprint_id=eq.${activeSprint.id}&select=status`
|
|
34
|
+
);
|
|
35
|
+
const counts = { total: sprintTasks.length, completed: 0, blocked: 0, pending: 0, active: 0 };
|
|
36
|
+
for (const t of sprintTasks) {
|
|
37
|
+
if (t.status === 'completed') counts.completed++;
|
|
38
|
+
else if (t.status === 'blocked') counts.blocked++;
|
|
39
|
+
else if (t.status === 'pending') counts.pending++;
|
|
40
|
+
else counts.active++;
|
|
41
|
+
}
|
|
42
|
+
sprintProgress = `${counts.completed}/${counts.total} complete, ${counts.active} active, ${counts.blocked} blocked, ${counts.pending} pending`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
pendingTasks,
|
|
47
|
+
activeTasks,
|
|
48
|
+
blockedTasks,
|
|
49
|
+
recentHeartbeats: Array.from(hbMap.values()),
|
|
50
|
+
sprint: activeSprint,
|
|
51
|
+
sprintProgress,
|
|
52
|
+
recentLessons: recentLessons || [],
|
|
53
|
+
pendingPitches: pendingPitches || [],
|
|
54
|
+
};
|
|
55
|
+
} catch (err) {
|
|
56
|
+
log.error('fleet-utils', `Failed to fetch fleet state: ${err.message}`);
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Execute structured decisions from conductor or smart-dispatcher.
|
|
63
|
+
*/
|
|
64
|
+
export async function executeDecisions(decisions, agentPool, supabase, config) {
|
|
65
|
+
// 1. Kills first (free capacity)
|
|
66
|
+
if (decisions.kills?.length > 0) {
|
|
67
|
+
for (const kill of decisions.kills) {
|
|
68
|
+
if (kill.agent) {
|
|
69
|
+
log.info('fleet-utils', `Kill ordered: ${kill.agent} — ${kill.reason || 'no reason'}`);
|
|
70
|
+
agentPool.killAgent(kill.agent);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Dispatches (fire all — agent pool handles concurrency)
|
|
76
|
+
if (decisions.dispatches?.length > 0) {
|
|
77
|
+
log.info('fleet-utils', `Dispatching ${decisions.dispatches.length} agents`);
|
|
78
|
+
for (const dispatch of decisions.dispatches) {
|
|
79
|
+
dispatchAgent(dispatch, agentPool, supabase, config);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. Reviews
|
|
84
|
+
for (const review of (decisions.reviews || [])) {
|
|
85
|
+
await dispatchReview(review, agentPool);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Dispatch a single agent to a task.
|
|
91
|
+
*/
|
|
92
|
+
export async function dispatchAgent(dispatch, agentPool, supabase, config) {
|
|
93
|
+
const { agent, task_id, reason } = dispatch;
|
|
94
|
+
if (!agent || !task_id) {
|
|
95
|
+
log.warn('fleet-utils', 'Invalid dispatch: missing agent or task_id');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const tasks = await supabase.get(`task_queue?id=eq.${task_id}`);
|
|
101
|
+
const task = tasks[0];
|
|
102
|
+
if (!task) {
|
|
103
|
+
log.warn('fleet-utils', `Dispatch: task ${task_id} not found`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (['completed', 'in_progress', 'claimed', 'failed'].includes(task.status)) {
|
|
108
|
+
log.info('fleet-utils', `Dispatch: task ${task_id} already ${task.status}, skipping`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Circuit breaker
|
|
113
|
+
if ((task.failure_count || 0) >= 3) {
|
|
114
|
+
log.warn('fleet-utils', `Dispatch: task ${task_id} has ${task.failure_count} failures, halted`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
log.info('fleet-utils', `Dispatching ${agent} to "${task.title}" (${task_id}): ${reason}`);
|
|
119
|
+
const prompt = buildTaskDispatchPrompt(agent, task, config);
|
|
120
|
+
const result = await agentPool.invoke(agent, prompt, { dispatch: true });
|
|
121
|
+
|
|
122
|
+
if (!result.ok) {
|
|
123
|
+
log.warn('fleet-utils', `Dispatch failed for ${agent}: ${result.message}`);
|
|
124
|
+
if (!result.busy) {
|
|
125
|
+
await recordTaskFailure(supabase, task_id, agent, result.message);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
log.info('fleet-utils', `${agent} completed dispatch (${result.output?.length || 0} chars)`);
|
|
131
|
+
|
|
132
|
+
// Store result
|
|
133
|
+
try {
|
|
134
|
+
await supabase.patch(`task_queue?id=eq.${task_id}`, {
|
|
135
|
+
status: 'completed',
|
|
136
|
+
result: result.output?.slice(0, 50000) || '(no output)',
|
|
137
|
+
completed_at: new Date().toISOString(),
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log.warn('fleet-utils', `Failed to store dispatch result: ${err.message}`);
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log.error('fleet-utils', `Dispatch error for ${agent}: ${err.message}`);
|
|
144
|
+
await recordTaskFailure(supabase, task_id, agent, err.message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Record a task failure. Marks as 'failed' after 3 retries (circuit breaker).
|
|
150
|
+
*/
|
|
151
|
+
export async function recordTaskFailure(supabase, taskId, agent, reason) {
|
|
152
|
+
try {
|
|
153
|
+
const tasks = await supabase.get(`task_queue?id=eq.${taskId}&select=failure_count`);
|
|
154
|
+
const current = tasks[0]?.failure_count || 0;
|
|
155
|
+
const newCount = current + 1;
|
|
156
|
+
const maxRetries = 3;
|
|
157
|
+
const newStatus = newCount >= maxRetries ? 'failed' : 'pending';
|
|
158
|
+
|
|
159
|
+
await supabase.patch(`task_queue?id=eq.${taskId}`, {
|
|
160
|
+
status: newStatus,
|
|
161
|
+
failure_count: newCount,
|
|
162
|
+
last_failure_at: new Date().toISOString(),
|
|
163
|
+
last_failure_reason: reason?.slice(0, 500) || 'Unknown failure',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
log.info('fleet-utils', `Recorded failure for task ${taskId} (attempt ${newCount}/${maxRetries}, status -> ${newStatus})`);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
log.error('fleet-utils', `Failed to record task failure: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Dispatch a review to an agent.
|
|
174
|
+
*/
|
|
175
|
+
async function dispatchReview(review, agentPool) {
|
|
176
|
+
const { reviewer, subject, task_id } = review;
|
|
177
|
+
if (!reviewer || !subject) return;
|
|
178
|
+
|
|
179
|
+
log.info('fleet-utils', `Routing review to ${reviewer}: ${subject}`);
|
|
180
|
+
try {
|
|
181
|
+
const prompt = buildReviewPrompt(reviewer, subject, task_id);
|
|
182
|
+
const result = await agentPool.invoke(reviewer, prompt, { dispatch: true });
|
|
183
|
+
if (!result.ok) {
|
|
184
|
+
log.warn('fleet-utils', `Review dispatch failed for ${reviewer}: ${result.message}`);
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
log.error('fleet-utils', `Review dispatch error: ${err.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @grantx/fleet-core — shared engine for the fleet plugin
|
|
2
|
+
// Cross-platform, config-driven, no hardcoded Grantx references.
|
|
3
|
+
|
|
4
|
+
export { loadConfig, getAgentRoster, getConductor, getWorkerAgents } from './config.js';
|
|
5
|
+
export { AgentPool } from './agent-runner.js';
|
|
6
|
+
export { ConductorRunner, parseDispatchBlock } from './conductor-runner.js';
|
|
7
|
+
export { SmartDispatcher } from './smart-dispatcher.js';
|
|
8
|
+
export { ConductorLoop } from './conductor-loop.js';
|
|
9
|
+
export { SupabaseClient } from './supabase-client.js';
|
|
10
|
+
export { fetchFleetState, executeDecisions, dispatchAgent, recordTaskFailure } from './fleet-utils.js';
|
|
11
|
+
export { log } from './logger.js';
|
|
12
|
+
export * from './platform.js';
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// logger.js — Structured logging for fleet plugin.
|
|
2
|
+
// Writes to .fleet/logs/fleet.log (configurable) + stdout fallback.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
let logStream = null;
|
|
8
|
+
let logDir = null;
|
|
9
|
+
|
|
10
|
+
function initLogStream() {
|
|
11
|
+
if (logStream) return;
|
|
12
|
+
|
|
13
|
+
logDir = process.env.FLEET_LOG_DIR || null;
|
|
14
|
+
if (!logDir) {
|
|
15
|
+
// Try .fleet/logs/ relative to cwd
|
|
16
|
+
const candidate = path.join(process.cwd(), '.fleet', 'logs');
|
|
17
|
+
if (fs.existsSync(path.join(process.cwd(), '.fleet'))) {
|
|
18
|
+
logDir = candidate;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (logDir) {
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
25
|
+
logStream = fs.createWriteStream(path.join(logDir, 'fleet.log'), { flags: 'a' });
|
|
26
|
+
} catch {
|
|
27
|
+
logStream = null; // Fall back to stdout
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function write(level, component, message) {
|
|
33
|
+
const ts = new Date().toISOString();
|
|
34
|
+
const line = `[${ts}] [${level}] [${component}] ${message}\n`;
|
|
35
|
+
|
|
36
|
+
initLogStream();
|
|
37
|
+
if (logStream) {
|
|
38
|
+
logStream.write(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Also write to stderr for real-time visibility (MCP servers use stdout for protocol)
|
|
42
|
+
process.stderr.write(line);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const log = {
|
|
46
|
+
info: (component, message) => write('INFO', component, message),
|
|
47
|
+
warn: (component, message) => write('WARN', component, message),
|
|
48
|
+
error: (component, message) => write('ERROR', component, message),
|
|
49
|
+
};
|