diff --git a/server/config.example.yaml b/server/config.example.yaml
index e298857b..25ca0561 100644
--- a/server/config.example.yaml
+++ b/server/config.example.yaml
@@ -16,7 +16,6 @@ llmCommentary:
overseerControl:
enabled: false
observeOnly: true
- alwaysRunModel: false
postToolsOnlyMessages: false
tiebreakerEnable: false
runWhileNoPeopleOnline: false
@@ -25,8 +24,6 @@ overseerControl:
ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000
- heartbeatMs: 30000
- postChatDelayMs: 20000
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http:////whep
diff --git a/server/src/services/overseerControlService/constants.js b/server/src/services/overseerControlService/constants.js
index e7af36fc..159bf5ec 100644
--- a/server/src/services/overseerControlService/constants.js
+++ b/server/src/services/overseerControlService/constants.js
@@ -3,8 +3,6 @@ const path = require('path');
const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_control_system.txt');
const DEFAULT_NAME = 'The Overseer';
const DEFAULT_GATE_INTERVAL_MS = 2000;
-const DEFAULT_HEARTBEAT_MS = 30000;
-const DEFAULT_POST_CHAT_DELAY_MS = 20000;
const MIN_INTERVAL_MS = 250;
const MAX_RUN_HISTORY = 100;
const MAX_CHAT_CONTEXT = 12;
@@ -19,8 +17,6 @@ module.exports = {
PROMPT_PATH,
DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS,
- DEFAULT_HEARTBEAT_MS,
- DEFAULT_POST_CHAT_DELAY_MS,
MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT,
diff --git a/server/src/services/overseerControlService/index.js b/server/src/services/overseerControlService/index.js
index a1ee6b1c..c5497b18 100644
--- a/server/src/services/overseerControlService/index.js
+++ b/server/src/services/overseerControlService/index.js
@@ -20,8 +20,6 @@ const {
PROMPT_PATH,
DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS,
- DEFAULT_HEARTBEAT_MS,
- DEFAULT_POST_CHAT_DELAY_MS,
MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT,
@@ -40,9 +38,6 @@ const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
const model = String(overseerConfig.model || '').trim();
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
-const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
-const postChatDelayMs = normalizeMs(Number(overseerConfig.postChatDelayMs), DEFAULT_POST_CHAT_DELAY_MS);
-const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
const tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
const runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
@@ -53,7 +48,6 @@ const runtime = {
timer: null,
inFlight: false,
tickCount: 0,
- lastModelAt: 0,
generationCount: 0,
generationTotalMs: 0,
runHistory: [],
@@ -70,9 +64,6 @@ let status = {
ollamaUrl,
promptPath: PROMPT_PATH,
gateIntervalMs,
- heartbeatMs,
- postChatDelayMs,
- alwaysRunModel,
postToolsOnlyMessages,
tiebreakerEnable,
runWhileNoPeopleOnline,
@@ -215,20 +206,6 @@ function buildRosterSummary() {
});
}
-function computeTriggerReason() {
- if (alwaysRunModel) return 'loop_tick';
- const recent = getRecentMessages(1, { includeSystem: false });
- const last = recent[recent.length - 1];
- if (last && Number(last.ts || 0) < runtime.contextResetAt) return null;
- if (last && Date.now() - Number(last.ts || 0) < 5000) {
- const txt = String(last.text || '').toLowerCase();
- if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
- return 'chat_activity';
- }
- if (!runtime.lastModelAt || Date.now() - runtime.lastModelAt >= heartbeatMs) return 'heartbeat';
- return null;
-}
-
function normalizeToolCalls(payload = null) {
const calls = Array.isArray(payload?.message?.tool_calls) ? payload.message.tool_calls : [];
return calls
@@ -385,7 +362,6 @@ async function runDecision(triggerReason) {
runtime.generationCount += 1;
runtime.generationTotalMs += generationMs;
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
- runtime.lastModelAt = Date.now();
const actionResults = [];
const requestedActions = toolCalls;
@@ -472,21 +448,45 @@ async function runDecision(triggerReason) {
}
async function tick() {
+ // The scheduler is deliberately loop-only. Older versions mixed chat-triggered
+ // and heartbeat-triggered paths into this same timer, which made it possible
+ // for one slow model generation to be followed by another run that was still
+ // reasoning over the same chat message. In the loop model, every completed
+ // timer means exactly one model request, and the next timer is not installed
+ // until the current request has completely finished.
+ const startedAt = Date.now();
+
+ // setTimeout handles are one-shot. Clearing the reference at the beginning of
+ // the callback makes scheduler ownership obvious: if any gate reevaluation
+ // happens while this tick is running, startScheduler can see that work is
+ // already in-flight and will not create a competing timer.
+ runtime.timer = null;
+
+ if (runtime.inFlight) {
+ // This is a defensive guard for unusual event-loop races or manual calls.
+ // Sending the same model input twice is worse than skipping one interval,
+ // so a re-entrant tick is ignored and the active tick remains responsible
+ // for scheduling the next pass.
+ updateStatus({
+ phase: 'idle',
+ lastOutcome: 'skipped',
+ lastReason: 'loop tick skipped while model request is in flight',
+ });
+ return;
+ }
+
runtime.tickCount += 1;
runtime.inFlight = true;
- updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
+ updateStatus({
+ inFlight: true,
+ tickCount: runtime.tickCount,
+ lastTickAt: startedAt,
+ phase: 'loop_tick',
+ lastTriggerReason: 'loop_tick',
+ });
- let nextDelayMs = gateIntervalMs;
try {
- const triggerReason = computeTriggerReason();
- if (!triggerReason) {
- updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
- } else {
- const runResult = await runDecision(triggerReason);
- if (runResult?.postedChat) {
- nextDelayMs = postChatDelayMs;
- }
- }
+ await runDecision('loop_tick');
} catch (err) {
const failure = buildFailureInfo(err);
updateStatus({
@@ -500,8 +500,13 @@ async function tick() {
} finally {
runtime.inFlight = false;
if (status.running) {
- updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
- runtime.timer = setTimeout(tick, nextDelayMs);
+ const nextRunAt = Date.now() + gateIntervalMs;
+ updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt });
+
+ // The next timer is installed after all async work completes. This keeps
+ // Ollama calls strictly serialized even when a generation takes longer
+ // than gateIntervalMs.
+ runtime.timer = setTimeout(tick, gateIntervalMs);
} else {
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
}
@@ -521,7 +526,6 @@ function clearHistory(reason = 'admin requested clear history', options = {}) {
runtime.liveToolCalls = [];
runtime.generationCount = 0;
runtime.generationTotalMs = 0;
- runtime.lastModelAt = 0;
if (resetPersistentMemory) {
runtime.memoryStore = saveMemory(createDefaultMemory());
}
@@ -567,14 +571,17 @@ function stopScheduler(reason = 'paused') {
}
function startScheduler(reason = null) {
- if (runtime.timer) return;
- updateStatus({ running: true, phase: 'idle', lastReason: reason });
+ // Gate changes can happen while a loop iteration is still waiting on Ollama.
+ // In that case we mark the service as running again, but let the active tick's
+ // finally block install the next timer. That preserves a single owner for
+ // scheduling and prevents overlapping model requests.
+ updateStatus({ running: true, phase: runtime.inFlight ? status.phase : 'idle', lastReason: reason });
+ if (runtime.timer || runtime.inFlight) return;
runtime.timer = setTimeout(tick, gateIntervalMs);
}
function evaluateSchedulerGate(reason = 'gate reevaluated') {
const voteStatus = buildVoteStatus();
- const runningAllowed = Boolean(enabled && voteStatus.gatePassed && getMode() !== MODES.LOCKDOWN);
updateStatus({ voteStatus });
if (!enabled) {
stopScheduler('overseerControl.enabled is false');
@@ -640,7 +647,7 @@ if (!enabled) {
logger.info('overseerControl paused on startup due to lockdown mode');
} else {
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
- logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
+ logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, observeOnly });
}
}
diff --git a/server/src/services/overseerControlService/runtimeHelpers.js b/server/src/services/overseerControlService/runtimeHelpers.js
index 75d1784a..a44ce2fd 100644
--- a/server/src/services/overseerControlService/runtimeHelpers.js
+++ b/server/src/services/overseerControlService/runtimeHelpers.js
@@ -21,8 +21,6 @@ function buildAdminState(status, runHistory) {
model: status.model,
ollamaUrl: status.ollamaUrl,
gateIntervalMs: status.gateIntervalMs,
- heartbeatMs: status.heartbeatMs,
- alwaysRunModel: status.alwaysRunModel,
observeOnly: status.observeOnly,
promptPath: status.promptPath,
},