Add swarm CTB controls

This commit is contained in:
Meta Vens 2026-05-24 22:45:17 +01:00
commit a5c3401e9a
4 changed files with 595 additions and 12 deletions

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { networkInterfaces } from "node:os";
@ -88,6 +89,7 @@ async function buildSnapshot() {
const heartbeat = await readJsonFile(heartbeatStatePath);
const orchestrator = await readJsonFile(orchestratorPath);
const pause = await readJsonFile(pausePath);
const avatarByIdentity = await readIdentityAvatars(env.REPO_DISCORD_IDENTITIES_PATH);
const heartbeatMtime = await getMtime(heartbeatStatePath);
const orchestratorMtime = await getMtime(orchestratorPath);
const paused = pause.ok ? pause.value?.paused !== false : false;
@ -96,7 +98,7 @@ async function buildSnapshot() {
const pendingMentions = Array.isArray(heartbeat.value?.pendingMentions) ? heartbeat.value.pendingMentions : [];
const initiativeClock = numberOrNull(heartbeat.value?.initiativeClock);
const participantSnapshots = participants.map((participant) =>
projectParticipant(participant, pendingMentions, initiativeClock),
projectParticipant(participant, pendingMentions, initiativeClock, avatarByIdentity),
);
const activeTurns = participantSnapshots.filter((participant) => participant.activeJobId);
const readyNow = participantSnapshots.filter((participant) =>
@ -112,6 +114,9 @@ async function buildSnapshot() {
const recentEvents = Array.isArray(heartbeat.value?.history)
? heartbeat.value.history.slice(-24).reverse().map(projectHistoryEvent)
: [];
const controls = projectControls(heartbeat.value?.controls);
const upcomingTurns = buildUpcomingTurns(participantSnapshots);
const lastShuffle = findLastShuffleEvent(recentEvents);
return {
schemaVersion: snapshotSchemaId,
@ -152,8 +157,12 @@ async function buildSnapshot() {
initiativeClock,
baseRecoveryMinutes: numberOrNull(heartbeat.value?.baseRecoveryMinutes),
globalHeat: numberOrNull(heartbeat.value?.globalHeat),
cadenceMultiplier: controls.cadenceMultiplier,
lastTickAt: stringOrNull(heartbeat.value?.lastTickAt),
lastShuffle,
},
controls,
upcomingTurns,
participants: participantSnapshots,
activeTurns,
pendingMentions: pendingMentions.map(projectPendingMention),
@ -165,7 +174,76 @@ async function buildSnapshot() {
};
}
function projectParticipant(participant, pendingMentions, initiativeClock) {
function projectControls(value) {
const cadenceMultiplier = numberOrNull(value?.cadenceMultiplier) ?? 1;
const manualTurnRequests = Array.isArray(value?.manualTurnRequests)
? value.manualTurnRequests.slice(-12).reverse().map((request) => ({
id: stringOrNull(request?.id),
identityId: stringOrNull(request?.identityId),
requestedAt: stringOrNull(request?.requestedAt),
status: stringOrNull(request?.status) ?? "pending",
note: stringOrNull(request?.note),
}))
: [];
return {
cadenceMultiplier,
updatedAt: stringOrNull(value?.updatedAt),
manualTurnRequests,
};
}
function buildUpcomingTurns(participants) {
const ordered = [...participants]
.filter((participant) => participant.status === "active")
.sort((left, right) => {
const activeDelta = Number(Boolean(right.activeJobId)) - Number(Boolean(left.activeJobId));
if (activeDelta) return activeDelta;
return (left.nextTurnInMinutes ?? 999999) - (right.nextTurnInMinutes ?? 999999);
})
.slice(0, 16);
const maxMinutes = Math.max(1, ...ordered.map((participant) => Math.max(0, participant.nextTurnInMinutes ?? 0)));
return ordered.map((participant, index) => ({
identityId: participant.identityId,
displayName: participant.displayName,
repoName: participant.repoName,
avatarUrl: participant.avatarUrl,
participantKind: participant.participantKind,
activeJobId: participant.activeJobId,
pendingMentionCount: participant.pendingMentionCount,
shuffleReason: participant.pendingMentionCount > 0 ? "mention" : participant.activeJobId ? "active" : null,
nextTurnInMinutes: participant.nextTurnInMinutes,
effectiveSpeed: participant.effectiveSpeed,
heat: participant.heat,
lane: index % 3,
timelinePosition: participant.activeJobId ? 0 : Math.min(100, Math.max(0, ((Math.max(0, participant.nextTurnInMinutes ?? 0)) / maxMinutes) * 100)),
}));
}
function findLastShuffleEvent(events) {
const directMention = events.find((event) =>
event.type === "queued" &&
typeof event.pendingMentionCount === "number" &&
event.pendingMentionCount > 0
);
if (directMention) {
return {
kind: "direct_mention",
identityId: directMention.identityId,
observedAt: directMention.observedAt,
};
}
const manual = events.find((event) => event.type === "manual_turn_override_applied");
if (manual) {
return {
kind: "manual_override",
identityId: manual.identityId,
observedAt: manual.observedAt,
};
}
return null;
}
function projectParticipant(participant, pendingMentions, initiativeClock, avatarByIdentity) {
const identityId = String(participant.identityId ?? "unknown");
const nextTurnAt = numberOrNull(participant.nextTurnAt);
const lastTurnAt = numberOrNull(participant.lastTurnAt);
@ -183,6 +261,7 @@ function projectParticipant(participant, pendingMentions, initiativeClock) {
identityId,
displayName: String(participant.displayName ?? identityId),
repoName: String(participant.repoName ?? "unknown"),
avatarUrl: avatarByIdentity.get(identityId.toLowerCase()) ?? null,
participantKind: String(participant.participantKind ?? "repo_face"),
turnKind: String(participant.turnKind ?? "repo_face_rumination"),
status: String(participant.status ?? "unknown"),
@ -222,15 +301,35 @@ function projectHistoryEvent(event) {
return {
type: String(event?.type ?? "event"),
identityId: stringOrNull(event?.identityId),
observedAt: stringOrNull(event?.observedAt ?? event?.queuedAt),
observedAt: stringOrNull(event?.observedAt ?? event?.queuedAt ?? event?.appliedAt),
activeJobId: stringOrNull(event?.activeJobId),
statusPath: stringOrNull(event?.statusPath),
reason: stringOrNull(event?.reason),
pendingMentionCount: numberOrNull(event?.pendingMentionCount),
nextTurnAt: numberOrNull(event?.nextTurnAt),
recoveryMinutes: numberOrNull(event?.recoveryMinutes),
};
}
async function readIdentityAvatars(registryPathValue) {
const avatars = new Map();
const registryPath = resolveConfigPath(registryPathValue, resolve(repoRoot, ".voidbot", "private", "repo-discord-identities.json"));
const registry = await readJsonFile(registryPath);
const identities = Array.isArray(registry.value?.identities)
? registry.value.identities
: Array.isArray(registry.value)
? registry.value
: [];
for (const identity of identities) {
const id = typeof identity?.id === "string" ? identity.id.toLowerCase() : "";
const avatarUrl = typeof identity?.avatarUrl === "string" ? identity.avatarUrl : "";
if (id && avatarUrl) {
avatars.set(id, avatarUrl);
}
}
return avatars;
}
function projectPendingMention(mention) {
return {
identityId: stringOrNull(mention?.identityId),
@ -495,6 +594,156 @@ function renderHtml(snapshot) {
gap: 10px;
}
.controls {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr);
gap: 12px;
align-items: stretch;
}
.control-block {
display: grid;
gap: 10px;
min-width: 0;
}
.control-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
input[type="range"] {
width: min(360px, 100%);
accent-color: var(--cyan);
}
select, button {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-2);
color: var(--text);
font: 700 0.94rem/1 var(--font);
}
select {
min-width: 180px;
padding: 0 10px;
}
button {
cursor: pointer;
padding: 0 14px;
}
button:hover {
border-color: var(--cyan);
}
.timeline {
position: relative;
min-height: 132px;
padding: 8px 4px 2px;
overflow-x: auto;
}
.timeline-track {
position: relative;
min-width: 720px;
height: 104px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-top: 2px solid var(--line);
background:
linear-gradient(90deg, rgba(85, 184, 198, 0.24), transparent 18%),
repeating-linear-gradient(90deg, transparent 0 11.5%, rgba(255,255,255,0.04) 11.5% 12%);
border-radius: 8px;
}
.timeline-now {
position: absolute;
top: -11px;
left: 0;
width: 2px;
height: 112px;
background: var(--cyan);
box-shadow: 0 0 16px rgba(85, 184, 198, 0.45);
}
.turn-card {
position: relative;
flex: 0 0 auto;
width: 62px;
height: 86px;
padding: 5px;
border-radius: 8px;
border: 1px solid var(--line);
background: #24282d;
box-shadow: 0 12px 26px rgba(0,0,0,0.2);
display: grid;
justify-items: center;
align-content: start;
gap: 3px;
}
.turn-card.active-turn {
border-color: var(--cyan);
background: #26343a;
}
.turn-card strong {
max-width: 100%;
font-size: 0.68rem;
line-height: 1.05;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.turn-card span {
max-width: 100%;
color: var(--muted);
font-family: var(--mono);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar {
width: 38px;
height: 38px;
border-radius: 999px;
border: 2px solid rgba(255,255,255,0.14);
background: #111;
display: grid;
place-items: center;
overflow: hidden;
font-weight: 900;
color: var(--text);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.turn-card .turn-time {
font-size: 0.62rem;
}
.turn-card .turn-reason {
color: var(--amber);
font-size: 0.58rem;
text-transform: uppercase;
}
table {
width: 100%;
border-collapse: collapse;
@ -555,6 +804,7 @@ function renderHtml(snapshot) {
.summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.participants { grid-template-columns: 1fr; }
.tables { grid-template-columns: 1fr; }
.controls { grid-template-columns: 1fr; }
.facts { grid-template-columns: 1fr 1fr; }
}
</style>
@ -598,11 +848,12 @@ function renderHtml(snapshot) {
});
const organs = snapshot.orchestrator?.organs || [];
const events = snapshot.recentEvents || [];
const controls = snapshot.controls || {};
app.innerHTML = [
"<header>",
"<div class=\\"topline\\">" + badge(summary.state) + "<span>Generated " + esc(relative(snapshot.generatedAt)) + "</span><span class=\\"mono\\">" + esc(snapshot.cultMesh?.schemaId) + "</span></div>",
"<h1>VoidBot Swarm State</h1>",
"<p class=\\"muted\\">Read-only CTB scheduler state, rendered from the local status files and mirrored as a CultCache snapshot for CultMesh.</p>",
"<p class=\\"muted\\">CTB scheduler state, rendered from the local status files and mirrored as a CultCache snapshot for CultMesh. Controls write only explicit scheduler commands.</p>",
"</header>",
"<section class=\\"summary\\">",
metric("Participants", number(summary.participantCount)),
@ -612,8 +863,17 @@ function renderHtml(snapshot) {
metric("Next", summary.nextDisplayName ? summary.nextDisplayName : "missing"),
metric("Next turn", minutes(summary.nextTurnInMinutes)),
metric("Global heat", number(summary.globalHeat)),
metric("Cadence", "x" + number(summary.cadenceMultiplier)),
metric("Shuffle", summary.lastShuffle ? summary.lastShuffle.kind.replace(/_/g, " ") : "none"),
metric("CultMesh", snapshot.cultMesh?.writeStatus || "missing"),
"</section>",
"<section><h2>Controls</h2><div class=\\"controls\\">",
"<div class=\\"control-block\\"><div class=\\"muted\\">Heartbeat cadence multiplier</div><div class=\\"control-row\\"><input id=\\"cadence-input\\" type=\\"range\\" min=\\"0.1\\" max=\\"12\\" step=\\"0.1\\" value=\\"" + esc(controls.cadenceMultiplier || 1) + "\\"><strong id=\\"cadence-value\\" class=\\"mono\\">x" + esc(number(controls.cadenceMultiplier || 1)) + "</strong><button id=\\"cadence-apply\\">Apply</button></div></div>",
"<div class=\\"control-block\\"><div class=\\"muted\\">Manual next turn</div><div class=\\"control-row\\"><select id=\\"force-identity\\">" + participants.map((agent) => "<option value=\\"" + esc(agent.identityId) + "\\">" + esc(agent.displayName) + " / " + esc(agent.repoName) + "</option>").join("") + "</select><button id=\\"force-turn\\">Pull Forward</button></div><div class=\\"repo\\">Latest request: " + esc((controls.manualTurnRequests || [])[0]?.identityId || "none") + " " + esc((controls.manualTurnRequests || [])[0]?.status || "") + "</div></div>",
"</div></section>",
"<section><h2>CTB Timeline</h2>",
renderTimeline(snapshot.upcomingTurns || []),
"</section>",
"<section>",
"<h2>Agents</h2>",
"<div class=\\"participants\\">",
@ -634,8 +894,24 @@ function renderHtml(snapshot) {
row("Pause flag", snapshot.sources?.pausePath, snapshot.sources?.pauseReadable ? (summary.paused ? "paused" : "ok") : "missing"),
row("CultMesh store", snapshot.cultMesh?.storePath, snapshot.cultMesh?.writeStatus),
"</tbody></table></section>",
"<div class=\\"footer\\">This page polls <code>swarm-state.json</code> every 10 seconds when served over HTTP. It does not mutate VoidBot, Discord, CultCache, or the scheduler.</div>",
"<div class=\\"footer\\">This page polls <code>swarm-state.json</code> every 10 seconds when served over HTTP. Controls write scheduler commands; heartbeat pulses own the actual turn mutation.</div>",
].join("");
attachControls();
}
function renderTimeline(turns) {
const cards = turns.map((turn) => {
const klass = turn.activeJobId ? "turn-card active-turn" : "turn-card";
return "<article class=\\"" + klass + "\\" title=\\"" + esc(turn.displayName + " / " + turn.repoName + " / " + minutes(turn.nextTurnInMinutes)) + "\\">" + avatarHtml(turn) + "<strong>" + esc(turn.displayName) + "</strong><span class=\\"turn-time\\">" + esc(minutes(turn.nextTurnInMinutes)) + "</span>" + (turn.shuffleReason ? "<span class=\\"turn-reason\\">" + esc(turn.shuffleReason) + "</span>" : "") + "</article>";
}).join("");
return "<div class=\\"timeline\\"><div class=\\"timeline-track\\"><div class=\\"timeline-now\\"></div>" + cards + "</div></div>";
}
function avatarHtml(turn) {
const initials = text(turn.displayName).split(/\\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase();
return turn.avatarUrl
? "<div class=\\"avatar\\"><img src=\\"" + esc(turn.avatarUrl) + "\\" alt=\\"" + esc(turn.displayName) + "\\"></div>"
: "<div class=\\"avatar\\">" + esc(initials || "?") + "</div>";
}
function agentCard(agent) {
@ -665,6 +941,41 @@ function renderHtml(snapshot) {
return "<tr><th>" + esc(label) + "</th><td class=\\"mono\\">" + esc(value) + "</td><td>" + badge(state || "unknown") + "</td></tr>";
}
function attachControls() {
const cadenceInput = document.getElementById("cadence-input");
const cadenceValue = document.getElementById("cadence-value");
const cadenceApply = document.getElementById("cadence-apply");
const forceSelect = document.getElementById("force-identity");
const forceButton = document.getElementById("force-turn");
if (cadenceInput && cadenceValue) {
cadenceInput.addEventListener("input", () => cadenceValue.textContent = "x" + Number(cadenceInput.value).toLocaleString(undefined, { maximumFractionDigits: 1 }));
}
if (cadenceApply && cadenceInput) {
cadenceApply.addEventListener("click", async () => {
await postJson("/api/controls", { cadenceMultiplier: Number(cadenceInput.value) });
await refresh();
});
}
if (forceButton && forceSelect) {
forceButton.addEventListener("click", async () => {
await postJson("/api/turns/force", { identityId: forceSelect.value });
await refresh();
});
}
}
async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || "Control request failed.");
}
}
async function refresh() {
try {
const response = await fetch("swarm-state.json?ts=" + Date.now(), { cache: "no-store" });
@ -743,6 +1054,14 @@ async function serveDashboard({ host, port, rootDir, refreshSeconds }) {
const server = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
if (request.method === "POST" && requestUrl.pathname === "/api/controls") {
await handleControlUpdate(request, response);
return;
}
if (request.method === "POST" && requestUrl.pathname === "/api/turns/force") {
await handleForceTurn(request, response);
return;
}
const pathname = requestUrl.pathname === "/" ? "/swarm-dashboard.html" : decodeURIComponent(requestUrl.pathname);
const target = resolve(rootDir, `.${pathname}`);
if (!target.startsWith(rootDir)) {
@ -774,6 +1093,95 @@ async function serveDashboard({ host, port, rootDir, refreshSeconds }) {
}
}
async function handleControlUpdate(request, response) {
if (args.public) {
writeJsonResponse(response, 403, { ok: false, error: "Controls are disabled in public mode." });
return;
}
const body = await readRequestJson(request);
const multiplier = Number(body.cadenceMultiplier);
if (!Number.isFinite(multiplier) || multiplier < 0.1 || multiplier > 12) {
writeJsonResponse(response, 400, { ok: false, error: "cadenceMultiplier must be between 0.1 and 12." });
return;
}
const state = await readMutableHeartbeatState();
state.controls = normalizeDashboardControls(state.controls);
state.controls.cadenceMultiplier = Math.round(multiplier * 100) / 100;
state.controls.updatedAt = new Date().toISOString();
await writeMutableHeartbeatState(state);
await render();
writeJsonResponse(response, 200, { ok: true, controls: state.controls });
}
async function handleForceTurn(request, response) {
if (args.public) {
writeJsonResponse(response, 403, { ok: false, error: "Controls are disabled in public mode." });
return;
}
const body = await readRequestJson(request);
const identityId = typeof body.identityId === "string" ? body.identityId.trim() : "";
if (!identityId) {
writeJsonResponse(response, 400, { ok: false, error: "identityId is required." });
return;
}
const state = await readMutableHeartbeatState();
state.controls = normalizeDashboardControls(state.controls);
state.controls.manualTurnRequests.push({
id: randomUUID(),
identityId,
requestedAt: new Date().toISOString(),
status: "pending",
note: "Requested from swarm dashboard.",
});
state.controls.manualTurnRequests = state.controls.manualTurnRequests.slice(-20);
state.controls.updatedAt = new Date().toISOString();
await writeMutableHeartbeatState(state);
await render();
writeJsonResponse(response, 200, { ok: true, controls: state.controls });
}
async function readMutableHeartbeatState() {
const parsed = await readJsonFile(heartbeatStatePath);
if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") {
throw new Error(`Cannot read heartbeat state at ${heartbeatStatePath}`);
}
return parsed.value;
}
async function writeMutableHeartbeatState(state) {
await mkdir(dirname(heartbeatStatePath), { recursive: true });
await writeFile(heartbeatStatePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}
function normalizeDashboardControls(value) {
const controls = value && typeof value === "object" ? value : {};
return {
cadenceMultiplier: Math.min(12, Math.max(0.1, Number(controls.cadenceMultiplier) || 1)),
manualTurnRequests: Array.isArray(controls.manualTurnRequests) ? controls.manualTurnRequests : [],
updatedAt: typeof controls.updatedAt === "string" ? controls.updatedAt : undefined,
};
}
async function readRequestJson(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
if (Buffer.concat(chunks).length > 64 * 1024) {
throw new Error("Request body is too large.");
}
}
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? JSON.parse(raw) : {};
}
function writeJsonResponse(response, statusCode, payload) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
response.end(`${JSON.stringify(payload)}\n`);
}
function contentType(path) {
switch (extname(path).toLowerCase()) {
case ".html":

View file

@ -77,6 +77,21 @@ interface FaceHeartbeatState {
participants: FaceHeartbeatParticipant[];
history: Array<Record<string, unknown>>;
pendingMentions: RepoFacePendingMention[];
controls: FaceHeartbeatControls;
}
interface FaceHeartbeatControls {
cadenceMultiplier: number;
manualTurnRequests: ManualTurnRequest[];
updatedAt?: string;
}
interface ManualTurnRequest {
id: string;
identityId: string;
requestedAt: string;
status: "pending" | "applied" | "consumed" | "rejected";
note?: string;
}
interface ActiveTurnScan {
@ -224,6 +239,7 @@ async function main(): Promise<void> {
const faceRegistry = await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath);
const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry);
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
state.controls = normalizeHeartbeatControls(state.controls);
const runtimeSnapshots = await loadRepoFaceRuntimeSnapshots(registry.identities, config.storageRoot, state, { dryRun });
const now = new Date();
advanceInitiativeClockFromWallClock(state, now);
@ -244,7 +260,9 @@ async function main(): Promise<void> {
}
state.baseRecoveryMinutes = config.repoFaceHeartbeats.baseRecoveryMinutes;
state.globalHeat = config.repoFaceHeartbeats.globalHeat;
const cadenceMultiplier = clamp(state.controls.cadenceMultiplier, 0.1, 12);
const effectiveGlobalHeat = config.repoFaceHeartbeats.globalHeat * cadenceMultiplier;
state.globalHeat = effectiveGlobalHeat;
const completedThisTick = new Set<string>();
state.participants = reconcileParticipants(
state.participants,
@ -254,7 +272,7 @@ async function main(): Promise<void> {
config.repoFaceHeartbeats.heatOverrides,
state.initiativeClock,
config.repoFaceHeartbeats.baseRecoveryMinutes,
config.repoFaceHeartbeats.globalHeat,
effectiveGlobalHeat,
runtimeSnapshots.pressureByIdentity,
).map((participant) =>
applyActiveTurnFreeze(
@ -266,6 +284,7 @@ async function main(): Promise<void> {
);
rescheduleStaleOverdueParticipants(state);
applyPendingMentionPriority(state);
const manualOverrideIdentities = applyManualTurnRequests(state, now);
const idleCooling = await readIdleCoolingSnapshot({
config,
identities: registry.identities,
@ -279,6 +298,7 @@ async function main(): Promise<void> {
completedThisTick,
runtimeSnapshots.restStates,
idleCooling,
manualOverrideIdentities,
);
const queuedIdentityIds: string[] = [];
@ -291,6 +311,7 @@ async function main(): Promise<void> {
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.nextTurnAt = Math.max(state.initiativeClock, participant.nextTurnAt) + recoveryMinutes;
consumeManualTurnRequests(state, participant.identityId, "consumed", "dry run selected");
queuedIdentityIds.push(participant.identityId);
state.history.push({
type: "dry_run_selected",
@ -335,6 +356,7 @@ async function main(): Promise<void> {
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.currentLoad = 1;
consumeManualTurnRequests(state, participant.identityId, "consumed", `queued ${turn.activeJobId}`);
if (pendingMentions.length > 0) {
const consumedIds = new Set(pendingMentions.map((entry) => entry.id));
state.pendingMentions = state.pendingMentions.filter((entry) => !consumedIds.has(entry.id));
@ -1560,11 +1582,13 @@ function selectReadyParticipants(
completedThisTick: Set<string>,
restStates: Map<string, RepoFaceRestSnapshot>,
idleCooling: IdleCoolingSnapshot,
manualOverrideIdentities: Set<string>,
): FaceHeartbeatParticipant[] {
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
const eligible = state.participants
.filter((participant) => {
const pendingMentionCount = pendingMentionCounts.get(participant.identityId) ?? 0;
const manualOverrideCount = manualOverrideIdentities.has(participant.identityId) ? 1 : 0;
const restState = restStates.get(participant.identityId);
return (
participant.status === "active" &&
@ -1573,6 +1597,7 @@ function selectReadyParticipants(
!(
participant.participantKind === "repo_face" &&
pendingMentionCount === 0 &&
manualOverrideCount === 0 &&
restState?.isNapping === true &&
idleCooling.napModeActive !== true
)
@ -1586,6 +1611,12 @@ function selectReadyParticipants(
const ready = eligible
.filter((participant) => participant.nextTurnAt <= state.initiativeClock)
.sort((left, right) => {
const manualDelta =
Number(manualOverrideIdentities.has(right.identityId)) -
Number(manualOverrideIdentities.has(left.identityId));
if (manualDelta !== 0) {
return manualDelta;
}
const pendingDelta =
(pendingMentionCounts.get(right.identityId) ?? 0) -
(pendingMentionCounts.get(left.identityId) ?? 0);
@ -1610,7 +1641,12 @@ function selectReadyParticipants(
}
const mentioned = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) > 0);
const unprompted = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) === 0);
const manual = ready.filter((participant) => manualOverrideIdentities.has(participant.identityId));
const prompted = uniqueParticipants([...manual, ...mentioned]);
const unprompted = ready.filter((participant) =>
(pendingMentionCounts.get(participant.identityId) ?? 0) === 0 &&
!manualOverrideIdentities.has(participant.identityId)
);
const napRuns = idleCooling.napModeActive === true
? unprompted.filter((participant) => restStates.get(participant.identityId)?.isNapping === true)
: [];
@ -1618,11 +1654,24 @@ function selectReadyParticipants(
const coolingAllowsUnpromptedTurn =
!idleCooling.nextUnpromptedTurnAllowedAt ||
Date.parse(idleCooling.nextUnpromptedTurnAllowedAt) <= Date.now();
const cooled = coolingAllowsUnpromptedTurn && mentioned.length < maxJobs
const cooled = coolingAllowsUnpromptedTurn && prompted.length < maxJobs
? (napRuns[0] ? napRuns : awakeRuns).slice(0, 1)
: [];
return [...mentioned, ...cooled].slice(0, maxJobs);
return [...prompted, ...cooled].slice(0, maxJobs);
}
function uniqueParticipants(participants: FaceHeartbeatParticipant[]): FaceHeartbeatParticipant[] {
const seen = new Set<string>();
const unique: FaceHeartbeatParticipant[] = [];
for (const participant of participants) {
if (seen.has(participant.identityId)) {
continue;
}
seen.add(participant.identityId);
unique.push(participant);
}
return unique;
}
function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void {
@ -1677,6 +1726,75 @@ function applyPendingMentionPriority(state: FaceHeartbeatState): void {
}
}
function applyManualTurnRequests(state: FaceHeartbeatState, now: Date): Set<string> {
const manualOverrideIdentities = new Set<string>();
const participantsByIdentity = new Map(state.participants.map((participant) => [participant.identityId, participant]));
let changed = false;
for (const request of state.controls.manualTurnRequests) {
if (request.status !== "pending" && request.status !== "applied") {
continue;
}
const participant = participantsByIdentity.get(request.identityId);
if (!participant || participant.status !== "active") {
request.status = "rejected";
request.note = "No active participant with that identity.";
changed = true;
continue;
}
if (participant.currentLoad >= 1 || participant.activeJobId) {
request.status = "applied";
request.note = "Participant is already running; request will clear after the turn closes.";
manualOverrideIdentities.add(request.identityId);
changed = true;
continue;
}
participant.nextTurnAt = Math.min(participant.nextTurnAt, state.initiativeClock);
manualOverrideIdentities.add(request.identityId);
if (request.status !== "applied") {
request.status = "applied";
request.note = "Pulled to the front of the CTB queue.";
state.history.push({
type: "manual_turn_override_applied",
identityId: request.identityId,
requestId: request.id,
appliedAt: now.toISOString(),
initiativeClock: state.initiativeClock,
});
changed = true;
}
}
if (changed) {
state.controls.updatedAt = now.toISOString();
}
state.controls.manualTurnRequests = state.controls.manualTurnRequests.slice(-20);
return manualOverrideIdentities;
}
function consumeManualTurnRequests(
state: FaceHeartbeatState,
identityId: string,
status: ManualTurnRequest["status"],
note: string,
): void {
const now = new Date().toISOString();
let changed = false;
for (const request of state.controls.manualTurnRequests) {
if ((request.status === "pending" || request.status === "applied") && request.identityId === identityId) {
request.status = status;
request.note = note;
changed = true;
}
}
if (changed) {
state.controls.updatedAt = now;
}
}
function pendingMentionsForParticipant(
state: FaceHeartbeatState,
identityId: string,
@ -4750,6 +4868,7 @@ async function readHeartbeatState(path: string): Promise<FaceHeartbeatState> {
pendingMentions: Array.isArray(parsed.pendingMentions)
? parsed.pendingMentions.filter(isRepoFacePendingMention)
: [],
controls: normalizeHeartbeatControls((parsed as { controls?: unknown }).controls),
};
}
if (Array.isArray(parsed.participants)) {
@ -4767,6 +4886,7 @@ async function readHeartbeatState(path: string): Promise<FaceHeartbeatState> {
participants: [],
history: [],
pendingMentions: [],
controls: defaultHeartbeatControls(),
};
}
@ -4826,6 +4946,7 @@ function migrateLegacyHeartbeatState(
pendingMentions: Array.isArray(parsed.pendingMentions)
? parsed.pendingMentions.filter(isRepoFacePendingMention)
: [],
controls: defaultHeartbeatControls(),
history: [
...(Array.isArray(parsed.history) ? parsed.history : []),
{
@ -4838,6 +4959,50 @@ function migrateLegacyHeartbeatState(
};
}
function defaultHeartbeatControls(): FaceHeartbeatControls {
return {
cadenceMultiplier: 1,
manualTurnRequests: [],
};
}
function normalizeHeartbeatControls(value: unknown): FaceHeartbeatControls {
if (!value || typeof value !== "object") {
return defaultHeartbeatControls();
}
const record = value as Record<string, unknown>;
const cadenceMultiplier = Number(record.cadenceMultiplier);
const manualTurnRequests = Array.isArray(record.manualTurnRequests)
? record.manualTurnRequests.flatMap((entry): ManualTurnRequest[] => {
if (!entry || typeof entry !== "object") {
return [];
}
const request = entry as Record<string, unknown>;
const identityId = typeof request.identityId === "string" ? request.identityId.trim() : "";
if (!identityId) {
return [];
}
const status = request.status === "applied" || request.status === "consumed" || request.status === "rejected"
? request.status
: "pending";
return [{
id: typeof request.id === "string" && request.id.trim() ? request.id : `manual-${Date.now()}-${identityId}`,
identityId,
requestedAt: typeof request.requestedAt === "string" ? request.requestedAt : new Date().toISOString(),
status,
note: typeof request.note === "string" ? request.note : undefined,
}];
})
: [];
return {
cadenceMultiplier: Number.isFinite(cadenceMultiplier) ? clamp(cadenceMultiplier, 0.1, 12) : 1,
manualTurnRequests: manualTurnRequests.slice(-20),
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}
function isRepoFacePendingMention(value: unknown): value is RepoFacePendingMention {
if (!value || typeof value !== "object") {
return false;