diff --git a/docs/voidbot-swarm-web-interface.md b/docs/voidbot-swarm-web-interface.md index 78c8ebd..29c5392 100644 --- a/docs/voidbot-swarm-web-interface.md +++ b/docs/voidbot-swarm-web-interface.md @@ -16,7 +16,14 @@ It writes: - `.voidbot/status/swarm-dashboard.html` - `.voidbot/status/cultmesh/voidbot-swarm-state.cc` -The `.cc` file is a CultCache snapshot document with schema id `voidbot.swarm_state_snapshot.v1`. CultMesh should distribute that document when a live mesh bridge is installed. The web page is only a reader. +The `.cc` file is a CultCache snapshot document with schema id `voidbot.swarm_state_snapshot.v1`. CultMesh should distribute that document when a live mesh bridge is installed. + +The app controls do not edit the turn queue directly. They write a small `controls` object into the heartbeat state: + +- `cadenceMultiplier`: multiplies scheduler heat on the next heartbeat pulse. +- `manualTurnRequests`: asks the scheduler to pull a Face forward on the next pulse. + +The heartbeat runner owns the actual mutation. Direct mentions, manual pull-forward requests, and the resulting upcoming CTB order are mirrored back into `swarm-state.json` and the CultCache snapshot so the app is seeing state, not guessing from DOM tricks. ## Local iPad Use @@ -26,7 +33,9 @@ From `E:\Projects\VoidBot`: npm run swarm:dashboard ``` -Open the LAN URL printed by the command on the iPad. The server refreshes the snapshot every 10 seconds and serves the static page from `.voidbot/status`. +Open the LAN URL printed by the command on the iPad. The server refreshes the snapshot every 10 seconds and serves the page from `.voidbot/status`. + +The top CTB strip is intentionally compact: avatar icons show upcoming Face turns in scheduler order. A direct mention should appear as a shuffle in the mirrored snapshot once the heartbeat state records the pending obligation or queued turn. For a one-shot render without serving: diff --git a/scripts/render-voidbot-swarm-dashboard.mjs b/scripts/render-voidbot-swarm-dashboard.mjs index bb2d5f5..5dfee95 100644 --- a/scripts/render-voidbot-swarm-dashboard.mjs +++ b/scripts/render-voidbot-swarm-dashboard.mjs @@ -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; } } @@ -598,11 +848,12 @@ function renderHtml(snapshot) { }); const organs = snapshot.orchestrator?.organs || []; const events = snapshot.recentEvents || []; + const controls = snapshot.controls || {}; app.innerHTML = [ "
", "
" + badge(summary.state) + "Generated " + esc(relative(snapshot.generatedAt)) + "" + esc(snapshot.cultMesh?.schemaId) + "
", "

VoidBot Swarm State

", - "

Read-only CTB scheduler state, rendered from the local status files and mirrored as a CultCache snapshot for CultMesh.

", + "

CTB scheduler state, rendered from the local status files and mirrored as a CultCache snapshot for CultMesh. Controls write only explicit scheduler commands.

", "
", "
", 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"), "
", + "

Controls

", + "
Heartbeat cadence multiplier
x" + esc(number(controls.cadenceMultiplier || 1)) + "
", + "
Manual next turn
Latest request: " + esc((controls.manualTurnRequests || [])[0]?.identityId || "none") + " " + esc((controls.manualTurnRequests || [])[0]?.status || "") + "
", + "
", + "

CTB Timeline

", + renderTimeline(snapshot.upcomingTurns || []), + "
", "
", "

Agents

", "
", @@ -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), "
", - "
This page polls swarm-state.json every 10 seconds when served over HTTP. It does not mutate VoidBot, Discord, CultCache, or the scheduler.
", + "
This page polls swarm-state.json every 10 seconds when served over HTTP. Controls write scheduler commands; heartbeat pulses own the actual turn mutation.
", ].join(""); + attachControls(); + } + + function renderTimeline(turns) { + const cards = turns.map((turn) => { + const klass = turn.activeJobId ? "turn-card active-turn" : "turn-card"; + return "
" + avatarHtml(turn) + "" + esc(turn.displayName) + "" + esc(minutes(turn.nextTurnInMinutes)) + "" + (turn.shuffleReason ? "" + esc(turn.shuffleReason) + "" : "") + "
"; + }).join(""); + return "
" + cards + "
"; + } + + function avatarHtml(turn) { + const initials = text(turn.displayName).split(/\\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase(); + return turn.avatarUrl + ? "
\\""
" + : "
" + esc(initials || "?") + "
"; } function agentCard(agent) { @@ -665,6 +941,41 @@ function renderHtml(snapshot) { return "" + esc(label) + "" + esc(value) + "" + badge(state || "unknown") + ""; } + 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": diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index abc30ba..cd4b342 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -77,6 +77,21 @@ interface FaceHeartbeatState { participants: FaceHeartbeatParticipant[]; history: Array>; 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 { 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 { } 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(); state.participants = reconcileParticipants( state.participants, @@ -254,7 +272,7 @@ async function main(): Promise { 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 { ); 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 { completedThisTick, runtimeSnapshots.restStates, idleCooling, + manualOverrideIdentities, ); const queuedIdentityIds: string[] = []; @@ -291,6 +311,7 @@ async function main(): Promise { 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 { 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, restStates: Map, idleCooling: IdleCoolingSnapshot, + manualOverrideIdentities: Set, ): 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(); + 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 { + const manualOverrideIdentities = new Set(); + 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 { 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 { 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; + 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; + 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; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index dcb6472..7f17b64 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -324,3 +324,4 @@ {"ts":"2026-05-24T19:38:23Z","type":"implementation","status":"ok","note":"Repo Face affect now has socialBiases for neuroticism, threat sensitivity, hostile attribution, reassurance need, grievance retention, status vigilance, and trust baseline. Active Faces plus Void were seeded with archetypal bias baselines; Nibu's projection smoke shows the biases reaching child prompt context."} {"ts":"2026-05-24T21:25:43Z","type":"implementation","status":"ok","note":"Repo Face idle cooling now decays over hours by default: cooling after 180 minutes, nap mode after 360 minutes, cooled unprompted turns every 120 minutes; nap mode can select napping Faces for sparse dream turns and nap memory maintenance has a 60-minute completion cooldown."} {"ts":"2026-05-24T21:27:15Z","type":"implementation","status":"ok","note":"Added read-only VoidBot swarm web exporter: renders .voidbot/status/swarm-dashboard.html and swarm-state.json from heartbeat/orchestrator status, writes a CultCache snapshot for CultMesh at .voidbot/status/cultmesh/voidbot-swarm-state.cc, and supports --public redaction for future voidbot.gamecult.org hosting."} +{"ts":"2026-05-24T21:42:11Z","type":"implementation","status":"ok","note":"Swarm dashboard now acts as local CTB control surface: heartbeat state carries scheduler-owned controls for cadenceMultiplier and manualTurnRequests, the heartbeat runner consumes those controls on pulse, and the CultMesh snapshot mirrors compact avatar-based upcoming turn order plus direct-mention shuffle evidence."}