@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
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grantx/fleet-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": ["src/"],
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./agent-runner": "./src/agent-runner.js",
|
|
9
|
+
"./conductor-runner": "./src/conductor-runner.js",
|
|
10
|
+
"./conductor-loop": "./src/conductor-loop.js",
|
|
11
|
+
"./smart-dispatcher": "./src/smart-dispatcher.js",
|
|
12
|
+
"./dispatch-prompts": "./src/dispatch-prompts.js",
|
|
13
|
+
"./supabase-client": "./src/supabase-client.js",
|
|
14
|
+
"./fleet-utils": "./src/fleet-utils.js",
|
|
15
|
+
"./config": "./src/config.js",
|
|
16
|
+
"./platform": "./src/platform.js",
|
|
17
|
+
"./logger": "./src/logger.js"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// agent-runner.js — Spawn claude -p processes with pooling and lockfiles.
|
|
2
|
+
// Cross-platform port of the Grantx fleet agent-runner.
|
|
3
|
+
// Persistent sessions via --resume. Max-turns and timeouts restore bounded execution.
|
|
4
|
+
//
|
|
5
|
+
// Session lifecycle:
|
|
6
|
+
// First invocation -> --session-id <uuid> (creates the session)
|
|
7
|
+
// All subsequent -> --resume <uuid> (resumes the session)
|
|
8
|
+
// Tracked via "initialized" flag in sessions.json. Self-heals on errors.
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { log } from './logger.js';
|
|
15
|
+
import { lockDir, ensureLockDir, buildAgentEnv, agentHome as resolveAgentHome } from './platform.js';
|
|
16
|
+
import { getWorkerAgents } from './config.js';
|
|
17
|
+
|
|
18
|
+
// ── Session Management ────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function sessionsPath(projectRoot) {
|
|
21
|
+
return path.join(projectRoot, '.fleet', 'sessions.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadSessions(projectRoot, agentNames) {
|
|
25
|
+
const sessFile = sessionsPath(projectRoot);
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(readFileSync(sessFile, 'utf8'));
|
|
28
|
+
const sessions = {};
|
|
29
|
+
for (const [agent, val] of Object.entries(raw)) {
|
|
30
|
+
if (typeof val === 'string') {
|
|
31
|
+
sessions[agent] = { id: val, initialized: false };
|
|
32
|
+
} else {
|
|
33
|
+
sessions[agent] = val;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return sessions;
|
|
37
|
+
} catch {
|
|
38
|
+
// First run — generate sessions for all configured agents
|
|
39
|
+
const sessions = {};
|
|
40
|
+
for (const name of agentNames) {
|
|
41
|
+
sessions[name] = { id: randomUUID(), initialized: false };
|
|
42
|
+
}
|
|
43
|
+
saveSessions(projectRoot, sessions);
|
|
44
|
+
return sessions;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveSessions(projectRoot, sessions) {
|
|
49
|
+
const sessFile = sessionsPath(projectRoot);
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(path.dirname(sessFile), { recursive: true });
|
|
52
|
+
writeFileSync(sessFile, JSON.stringify(sessions, null, 2));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
log.error('sessions', `Failed to write sessions file: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── AgentPool ─────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export class AgentPool {
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} config - Loaded fleet config
|
|
63
|
+
*/
|
|
64
|
+
constructor(config) {
|
|
65
|
+
this.maxConcurrent = config.execution?.maxConcurrent || 5;
|
|
66
|
+
this.projectRoot = config._projectRoot || config.projectRoot || process.cwd();
|
|
67
|
+
this.teamId = config.teamId;
|
|
68
|
+
this.config = config;
|
|
69
|
+
|
|
70
|
+
const allAgents = config.agents.map(a => a.name);
|
|
71
|
+
this.sessions = loadSessions(this.projectRoot, allAgents);
|
|
72
|
+
this.running = new Map(); // agent -> { process, startTime, mode }
|
|
73
|
+
this.queue = [];
|
|
74
|
+
this.children = new Set();
|
|
75
|
+
|
|
76
|
+
ensureLockDir();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async invoke(agent, prompt, options = {}) {
|
|
80
|
+
const isBroadcast = options.broadcast || false;
|
|
81
|
+
const isDispatch = options.dispatch || false;
|
|
82
|
+
|
|
83
|
+
// Check if agent is already running
|
|
84
|
+
if (this.running.has(agent)) {
|
|
85
|
+
const entry = this.running.get(agent);
|
|
86
|
+
const elapsed = Math.round((Date.now() - entry.startTime) / 1000);
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
busy: true,
|
|
90
|
+
message: `*${agent}* is already processing a request (${elapsed}s elapsed, mode: ${entry.mode}). Wait for it to finish.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check pool capacity
|
|
95
|
+
if (this.running.size >= this.maxConcurrent) {
|
|
96
|
+
if (this.queue.length >= 10) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
busy: true,
|
|
100
|
+
message: `Fleet is at capacity (${this.running.size}/${this.maxConcurrent} active, ${this.queue.length} queued). Try again later.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const mode = isDispatch ? 'dispatch' : isBroadcast ? 'broadcast' : 'on-demand';
|
|
105
|
+
this.queue.push({ agent, prompt, mode, resolve });
|
|
106
|
+
log.info('agent-pool', `Queued ${agent} (queue depth: ${this.queue.length})`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const mode = isDispatch ? 'dispatch' : isBroadcast ? 'broadcast' : 'on-demand';
|
|
111
|
+
return this._spawn(agent, prompt, mode);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async _spawn(agent, prompt, mode) {
|
|
115
|
+
const lockFile = path.join(lockDir, `fleet-${agent}.lock`);
|
|
116
|
+
const agentHomeDir = resolveAgentHome(this.projectRoot, agent);
|
|
117
|
+
const workDir = this.projectRoot;
|
|
118
|
+
const sess = this.sessions[agent] || { id: randomUUID(), initialized: false };
|
|
119
|
+
const sessionId = sess.id;
|
|
120
|
+
|
|
121
|
+
return this._spawnWithSession(agent, prompt, mode, lockFile, agentHomeDir, workDir, sessionId, sess.initialized);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _spawnWithSession(agent, prompt, mode, lockFile, agentHomeDir, workDir, sessionId, isInitialized, retryCount = 0) {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
// Write lockfile
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(lockFile, String(process.pid));
|
|
129
|
+
} catch (err) {
|
|
130
|
+
log.warn('agent-runner', `Could not write lockfile: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const env = buildAgentEnv(agent, agentHomeDir, this.teamId);
|
|
134
|
+
let stdout = '';
|
|
135
|
+
let stderr = '';
|
|
136
|
+
|
|
137
|
+
const sessionFlag = isInitialized ? '--resume' : '--session-id';
|
|
138
|
+
const child = spawn('claude', [
|
|
139
|
+
'-p', prompt,
|
|
140
|
+
'--dangerously-skip-permissions',
|
|
141
|
+
sessionFlag, sessionId,
|
|
142
|
+
], {
|
|
143
|
+
cwd: workDir,
|
|
144
|
+
env,
|
|
145
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
this.children.add(child);
|
|
149
|
+
this.running.set(agent, { process: child, startTime: Date.now(), mode });
|
|
150
|
+
|
|
151
|
+
const timeouts = this.config.execution?.timeouts || {};
|
|
152
|
+
const timeoutMs = mode === 'broadcast' ? 120000
|
|
153
|
+
: mode === 'on-demand' ? (timeouts.review || 300000)
|
|
154
|
+
: (timeouts.task || 1800000);
|
|
155
|
+
|
|
156
|
+
const timeoutTimer = setTimeout(() => {
|
|
157
|
+
if (this.running.has(agent)) {
|
|
158
|
+
log.warn('agent-runner', `${agent} timed out after ${timeoutMs / 1000}s (mode: ${mode})`);
|
|
159
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
160
|
+
}
|
|
161
|
+
}, timeoutMs);
|
|
162
|
+
|
|
163
|
+
log.info('agent-runner', `Spawned ${agent} (PID ${child.pid}, mode: ${mode}, timeout: ${timeoutMs / 1000}s, ${sessionFlag} ${sessionId.slice(0, 8)}...)`);
|
|
164
|
+
|
|
165
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
166
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
167
|
+
|
|
168
|
+
child.on('close', (code) => {
|
|
169
|
+
clearTimeout(timeoutTimer);
|
|
170
|
+
this.running.delete(agent);
|
|
171
|
+
this.children.delete(child);
|
|
172
|
+
try { unlinkSync(lockFile); } catch { /* ignore */ }
|
|
173
|
+
|
|
174
|
+
let output = stdout.trim();
|
|
175
|
+
const errText = stderr.trim();
|
|
176
|
+
|
|
177
|
+
// Self-healing: wrong session flag -> retry once. On second failure, fresh session.
|
|
178
|
+
if (code === 1 && retryCount < 2) {
|
|
179
|
+
if (errText.includes('already in use') && !isInitialized) {
|
|
180
|
+
log.info('agent-runner', `${agent}: session exists, retrying with --resume`);
|
|
181
|
+
this._markInitialized(agent);
|
|
182
|
+
this._spawnWithSession(agent, prompt, mode, lockFile, agentHomeDir, workDir, sessionId, true, retryCount + 1).then(resolve);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (errText.includes('No conversation found') && isInitialized) {
|
|
186
|
+
const newId = randomUUID();
|
|
187
|
+
log.info('agent-runner', `${agent}: session not found, creating fresh session ${newId.slice(0, 8)}`);
|
|
188
|
+
this.sessions[agent] = { id: newId, initialized: false };
|
|
189
|
+
saveSessions(this.projectRoot, this.sessions);
|
|
190
|
+
this._spawnWithSession(agent, prompt, mode, lockFile, agentHomeDir, workDir, newId, false, retryCount + 1).then(resolve);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (code === 0 || (code === null && output.length > 0)) {
|
|
196
|
+
if (!isInitialized) this._markInitialized(agent);
|
|
197
|
+
log.info('agent-runner', `${agent} completed (${output.length} chars, mode: ${mode})`);
|
|
198
|
+
resolve({ ok: true, output });
|
|
199
|
+
} else {
|
|
200
|
+
const errMsg = errText.slice(0, 500) || 'No output';
|
|
201
|
+
log.warn('agent-runner', `${agent} exited with code ${code} (mode: ${mode}): ${errMsg.slice(0, 200)}`);
|
|
202
|
+
resolve({
|
|
203
|
+
ok: false,
|
|
204
|
+
message: `*${agent}* exited with code ${code}: ${errMsg}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this._drainQueue();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
child.on('error', (err) => {
|
|
212
|
+
clearTimeout(timeoutTimer);
|
|
213
|
+
this.running.delete(agent);
|
|
214
|
+
this.children.delete(child);
|
|
215
|
+
try { unlinkSync(lockFile); } catch { /* ignore */ }
|
|
216
|
+
resolve({
|
|
217
|
+
ok: false,
|
|
218
|
+
message: `Failed to spawn *${agent}*: ${err.message}`,
|
|
219
|
+
});
|
|
220
|
+
this._drainQueue();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_markInitialized(agent) {
|
|
226
|
+
if (this.sessions[agent]) {
|
|
227
|
+
this.sessions[agent].initialized = true;
|
|
228
|
+
saveSessions(this.projectRoot, this.sessions);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_drainQueue() {
|
|
233
|
+
while (this.queue.length > 0 && this.running.size < this.maxConcurrent) {
|
|
234
|
+
const next = this.queue.shift();
|
|
235
|
+
log.info('agent-pool', `Dequeuing ${next.agent} (remaining: ${this.queue.length})`);
|
|
236
|
+
this._spawn(next.agent, next.prompt, next.mode).then(next.resolve);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get status of all running agents.
|
|
242
|
+
*/
|
|
243
|
+
getRunningStatus() {
|
|
244
|
+
const status = [];
|
|
245
|
+
for (const [agent, entry] of this.running) {
|
|
246
|
+
status.push({
|
|
247
|
+
agent,
|
|
248
|
+
mode: entry.mode,
|
|
249
|
+
elapsed: Math.round((Date.now() - entry.startTime) / 1000),
|
|
250
|
+
pid: entry.process.pid,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return status;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Kill a specific agent process.
|
|
258
|
+
*/
|
|
259
|
+
killAgent(agent) {
|
|
260
|
+
const entry = this.running.get(agent);
|
|
261
|
+
if (!entry) {
|
|
262
|
+
log.warn('agent-pool', `killAgent: ${agent} not in running map`);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
log.info('agent-pool', `Killing ${agent} (PID ${entry.process.pid})`);
|
|
266
|
+
try { entry.process.kill('SIGTERM'); } catch { /* ignore */ }
|
|
267
|
+
setTimeout(() => {
|
|
268
|
+
if (this.running.has(agent)) {
|
|
269
|
+
log.warn('agent-pool', `Force-killing ${agent} (SIGKILL)`);
|
|
270
|
+
try { entry.process.kill('SIGKILL'); } catch { /* ignore */ }
|
|
271
|
+
}
|
|
272
|
+
}, 5000);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Kill all running child processes. Called on SIGTERM for graceful shutdown.
|
|
278
|
+
*/
|
|
279
|
+
killAll() {
|
|
280
|
+
log.info('agent-pool', `Killing ${this.children.size} child processes`);
|
|
281
|
+
for (const child of this.children) {
|
|
282
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
283
|
+
}
|
|
284
|
+
for (const agent of this.running.keys()) {
|
|
285
|
+
try { unlinkSync(path.join(lockDir, `fleet-${agent}.lock`)); } catch { /* ignore */ }
|
|
286
|
+
}
|
|
287
|
+
this.running.clear();
|
|
288
|
+
this.children.clear();
|
|
289
|
+
this.queue = [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// conductor-loop.js — Event-driven orchestration loop.
|
|
2
|
+
// Polls Supabase for state changes, routes to SmartDispatcher for fast-path,
|
|
3
|
+
// falls through to conductor AI for complex decisions.
|
|
4
|
+
// All queries team-scoped. No Grantx-specific events.
|
|
5
|
+
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
import { SmartDispatcher } from './smart-dispatcher.js';
|
|
8
|
+
import { ConductorRunner, parseDispatchBlock } from './conductor-runner.js';
|
|
9
|
+
import { fetchFleetState, executeDecisions } from './fleet-utils.js';
|
|
10
|
+
import { buildConductorEventPrompt } from './dispatch-prompts.js';
|
|
11
|
+
|
|
12
|
+
export class ConductorLoop {
|
|
13
|
+
/**
|
|
14
|
+
* @param {AgentPool} agentPool
|
|
15
|
+
* @param {SupabaseClient} supabase
|
|
16
|
+
* @param {object} config - Loaded fleet config
|
|
17
|
+
*/
|
|
18
|
+
constructor(agentPool, supabase, config) {
|
|
19
|
+
this.agentPool = agentPool;
|
|
20
|
+
this.supabase = supabase;
|
|
21
|
+
this.config = config;
|
|
22
|
+
|
|
23
|
+
this.dispatcher = new SmartDispatcher(agentPool, supabase, config);
|
|
24
|
+
this.conductor = new ConductorRunner(config);
|
|
25
|
+
|
|
26
|
+
this.loopIntervalMs = config.daemon?.loopIntervalMs || 5000;
|
|
27
|
+
this.cronConfig = config.daemon?.cron || {};
|
|
28
|
+
|
|
29
|
+
this._events = [];
|
|
30
|
+
this._running = false;
|
|
31
|
+
this._tickTimer = null;
|
|
32
|
+
this._tickInProgress = false;
|
|
33
|
+
this._lastState = {};
|
|
34
|
+
|
|
35
|
+
// Cron trackers
|
|
36
|
+
this._lastHealthCheck = Date.now();
|
|
37
|
+
this._lastSynthesis = Date.now();
|
|
38
|
+
|
|
39
|
+
// Stats
|
|
40
|
+
this.stats = {
|
|
41
|
+
ticks: 0,
|
|
42
|
+
conductorCalls: 0,
|
|
43
|
+
dispatches: 0,
|
|
44
|
+
errors: 0,
|
|
45
|
+
startTime: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start the orchestration loop.
|
|
51
|
+
*/
|
|
52
|
+
start() {
|
|
53
|
+
if (this._running) {
|
|
54
|
+
log.warn('conductor-loop', 'Already running');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this._running = true;
|
|
58
|
+
this.stats.startTime = Date.now();
|
|
59
|
+
log.info('conductor-loop', `Starting loop (interval: ${this.loopIntervalMs}ms)`);
|
|
60
|
+
|
|
61
|
+
this._tickTimer = setInterval(() => this._tick(), this.loopIntervalMs);
|
|
62
|
+
|
|
63
|
+
// Push initial dispatch tick to get things moving
|
|
64
|
+
this.pushEvent({ type: 'dispatch_tick', timestamp: new Date().toISOString() });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stop the orchestration loop gracefully.
|
|
69
|
+
*/
|
|
70
|
+
stop() {
|
|
71
|
+
if (!this._running) return;
|
|
72
|
+
this._running = false;
|
|
73
|
+
if (this._tickTimer) {
|
|
74
|
+
clearInterval(this._tickTimer);
|
|
75
|
+
this._tickTimer = null;
|
|
76
|
+
}
|
|
77
|
+
log.info('conductor-loop', `Stopped (${this.stats.ticks} ticks, ${this.stats.conductorCalls} conductor calls)`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Push an event into the queue (processed on next tick).
|
|
82
|
+
*/
|
|
83
|
+
pushEvent(event) {
|
|
84
|
+
this._events.push(event);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get loop stats.
|
|
89
|
+
*/
|
|
90
|
+
getStats() {
|
|
91
|
+
return {
|
|
92
|
+
...this.stats,
|
|
93
|
+
running: this._running,
|
|
94
|
+
eventQueueDepth: this._events.length,
|
|
95
|
+
uptime: this.stats.startTime ? Math.round((Date.now() - this.stats.startTime) / 1000) : 0,
|
|
96
|
+
dispatcher: this.dispatcher.getStats(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Internal ──────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async _tick() {
|
|
103
|
+
// Guard: no overlapping ticks
|
|
104
|
+
if (this._tickInProgress) return;
|
|
105
|
+
this._tickInProgress = true;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
this.stats.ticks++;
|
|
109
|
+
|
|
110
|
+
// Inject cron events
|
|
111
|
+
this._checkCrons();
|
|
112
|
+
|
|
113
|
+
// Detect state changes from Supabase
|
|
114
|
+
await this._detectStateChanges();
|
|
115
|
+
|
|
116
|
+
// Collect events for this cycle
|
|
117
|
+
const events = this._events.splice(0);
|
|
118
|
+
if (events.length === 0) {
|
|
119
|
+
// Nothing happened — push a dispatch tick to check for pending work
|
|
120
|
+
events.push({ type: 'dispatch_tick', timestamp: new Date().toISOString() });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fetch fleet state
|
|
124
|
+
const state = await fetchFleetState(this.supabase);
|
|
125
|
+
this._lastState = state;
|
|
126
|
+
|
|
127
|
+
// Try smart-dispatcher first (fast path)
|
|
128
|
+
const handled = await this.dispatcher.tryFastPath(events, state);
|
|
129
|
+
if (handled) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fall through to conductor AI for complex decisions
|
|
134
|
+
await this._invokeConductor(events, state);
|
|
135
|
+
|
|
136
|
+
} catch (err) {
|
|
137
|
+
this.stats.errors++;
|
|
138
|
+
log.error('conductor-loop', `Tick error: ${err.message}`);
|
|
139
|
+
} finally {
|
|
140
|
+
this._tickInProgress = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Detect state changes by comparing Supabase snapshots.
|
|
146
|
+
* Generates events for: completions, failures, flags.
|
|
147
|
+
*/
|
|
148
|
+
async _detectStateChanges() {
|
|
149
|
+
try {
|
|
150
|
+
// Check for recently completed tasks
|
|
151
|
+
const completed = await this.supabase.get(
|
|
152
|
+
'task_queue?status=eq.completed&order=completed_at.desc&limit=5'
|
|
153
|
+
);
|
|
154
|
+
for (const t of completed) {
|
|
155
|
+
const key = `completed:${t.id}`;
|
|
156
|
+
if (!this._seenEvent(key)) {
|
|
157
|
+
this._markSeen(key);
|
|
158
|
+
this.pushEvent({
|
|
159
|
+
type: 'task_completed',
|
|
160
|
+
task_id: t.id,
|
|
161
|
+
agent: t.to_agent,
|
|
162
|
+
title: t.title,
|
|
163
|
+
timestamp: t.completed_at,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Try chain dispatch for the completing agent
|
|
167
|
+
const state = this._lastState;
|
|
168
|
+
if (t.to_agent && state.pendingTasks) {
|
|
169
|
+
await this.dispatcher.tryChainDispatch(t.to_agent, state);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check for recently failed tasks
|
|
175
|
+
const failed = await this.supabase.get(
|
|
176
|
+
'task_queue?status=eq.failed&order=last_failure_at.desc&limit=5'
|
|
177
|
+
);
|
|
178
|
+
for (const t of failed) {
|
|
179
|
+
const key = `failed:${t.id}:${t.failure_count}`;
|
|
180
|
+
if (!this._seenEvent(key)) {
|
|
181
|
+
this._markSeen(key);
|
|
182
|
+
this.pushEvent({
|
|
183
|
+
type: 'task_failed',
|
|
184
|
+
task_id: t.id,
|
|
185
|
+
agent: t.to_agent,
|
|
186
|
+
title: t.title,
|
|
187
|
+
failure_count: t.failure_count,
|
|
188
|
+
failure_reason: t.last_failure_reason,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check for blocked tasks
|
|
194
|
+
const blocked = await this.supabase.get(
|
|
195
|
+
'task_queue?status=eq.blocked&order=flagged_at.desc&limit=5'
|
|
196
|
+
);
|
|
197
|
+
for (const t of blocked) {
|
|
198
|
+
const key = `blocked:${t.id}`;
|
|
199
|
+
if (!this._seenEvent(key)) {
|
|
200
|
+
this._markSeen(key);
|
|
201
|
+
this.pushEvent({
|
|
202
|
+
type: 'task_flagged',
|
|
203
|
+
task_id: t.id,
|
|
204
|
+
agent: t.to_agent,
|
|
205
|
+
title: t.title,
|
|
206
|
+
flag_type: 'blocked',
|
|
207
|
+
flag_reason: t.blocked_reason,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check for unblocked tasks (pending tasks whose deps are now all completed)
|
|
213
|
+
await this._checkUnblocked();
|
|
214
|
+
|
|
215
|
+
} catch (err) {
|
|
216
|
+
log.warn('conductor-loop', `State change detection error: ${err.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Check for tasks that just became unblocked (all deps completed).
|
|
222
|
+
*/
|
|
223
|
+
async _checkUnblocked() {
|
|
224
|
+
try {
|
|
225
|
+
const pending = await this.supabase.get(
|
|
226
|
+
'task_queue?status=eq.pending&depends_on=not.is.null&select=id,title,depends_on'
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
for (const task of pending) {
|
|
230
|
+
if (!task.depends_on?.length) continue;
|
|
231
|
+
|
|
232
|
+
const depTasks = await this.supabase.getUnscoped(
|
|
233
|
+
`task_queue?id=in.(${task.depends_on.join(',')})&select=id,status`
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const allDone = depTasks.every(d => d.status === 'completed');
|
|
237
|
+
if (allDone) {
|
|
238
|
+
const key = `unblocked:${task.id}`;
|
|
239
|
+
if (!this._seenEvent(key)) {
|
|
240
|
+
this._markSeen(key);
|
|
241
|
+
this.pushEvent({
|
|
242
|
+
type: 'task_unblocked',
|
|
243
|
+
task_id: task.id,
|
|
244
|
+
title: task.title,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
log.warn('conductor-loop', `Unblocked check error: ${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Invoke conductor AI for complex decisions.
|
|
256
|
+
*/
|
|
257
|
+
async _invokeConductor(events, state) {
|
|
258
|
+
if (this.conductor.running) {
|
|
259
|
+
log.info('conductor-loop', 'Conductor already running, skipping');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.stats.conductorCalls++;
|
|
264
|
+
const prompt = buildConductorEventPrompt(events, state, this.config);
|
|
265
|
+
|
|
266
|
+
log.info('conductor-loop', `Invoking conductor (${events.length} events)`);
|
|
267
|
+
const result = await this.conductor.invoke(prompt);
|
|
268
|
+
|
|
269
|
+
if (!result.ok) {
|
|
270
|
+
log.warn('conductor-loop', `Conductor failed: ${result.message?.slice(0, 200)}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Parse dispatch block
|
|
275
|
+
const block = parseDispatchBlock(result.output);
|
|
276
|
+
if (!block) {
|
|
277
|
+
log.info('conductor-loop', 'Conductor returned no dispatch block');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Execute decisions
|
|
282
|
+
const dispatchCount = block.dispatches?.length || 0;
|
|
283
|
+
this.stats.dispatches += dispatchCount;
|
|
284
|
+
if (dispatchCount > 0 || block.kills?.length > 0 || block.reviews?.length > 0) {
|
|
285
|
+
log.info('conductor-loop', `Executing: ${dispatchCount} dispatches, ${block.kills?.length || 0} kills, ${block.reviews?.length || 0} reviews`);
|
|
286
|
+
await executeDecisions(block, this.agentPool, this.supabase, this.config);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Cron events ───────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
_checkCrons() {
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
|
|
295
|
+
// Health check
|
|
296
|
+
const healthIntervalMs = (this.cronConfig.healthCheckMinutes || 10) * 60 * 1000;
|
|
297
|
+
if (now - this._lastHealthCheck >= healthIntervalMs) {
|
|
298
|
+
this._lastHealthCheck = now;
|
|
299
|
+
this.pushEvent({ type: 'health_check', timestamp: new Date().toISOString() });
|
|
300
|
+
log.info('conductor-loop', 'Cron: health check');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Synthesis
|
|
304
|
+
const synthesisIntervalMs = (this.cronConfig.synthesisHours || 2) * 60 * 60 * 1000;
|
|
305
|
+
if (now - this._lastSynthesis >= synthesisIntervalMs) {
|
|
306
|
+
this._lastSynthesis = now;
|
|
307
|
+
this.pushEvent({ type: 'synthesis', timestamp: new Date().toISOString() });
|
|
308
|
+
log.info('conductor-loop', 'Cron: synthesis');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Dedup helpers ─────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
_seenEvents = new Set();
|
|
315
|
+
_seenTimestamps = new Map();
|
|
316
|
+
|
|
317
|
+
_seenEvent(key) {
|
|
318
|
+
return this._seenEvents.has(key);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_markSeen(key) {
|
|
322
|
+
this._seenEvents.add(key);
|
|
323
|
+
this._seenTimestamps.set(key, Date.now());
|
|
324
|
+
|
|
325
|
+
// Cleanup old entries every 100 additions to prevent memory growth
|
|
326
|
+
if (this._seenEvents.size % 100 === 0) {
|
|
327
|
+
const cutoff = Date.now() - 30 * 60 * 1000; // 30 min
|
|
328
|
+
for (const [k, ts] of this._seenTimestamps) {
|
|
329
|
+
if (ts < cutoff) {
|
|
330
|
+
this._seenEvents.delete(k);
|
|
331
|
+
this._seenTimestamps.delete(k);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|