@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 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
+ }