From b2a55c5c6ca74d152d9995767dfe667d3000d3f4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 00:06:01 +0100 Subject: [PATCH] Sync Face sleep with operator absence --- scripts/run-repo-face-heartbeats.ts | 80 +++++++++++++++++++++++------ state/evidence.jsonl | 1 + 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index cd4b342..f4afc6c 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -23,6 +23,7 @@ import { resolveRepoFaceStatePath, type RepoFacePendingMention, type RepoDiscordIdentity, + type VoidScheduledRuntime, type VoidSelfStateTypedProjection, } from "@voidbot/core"; import { @@ -240,8 +241,19 @@ async function main(): Promise { 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(); + const idleCooling = await readIdleCoolingSnapshot({ + config, + identities: registry.identities, + state, + now, + }); + const runtimeSnapshots = await loadRepoFaceRuntimeSnapshots( + registry.identities, + config.storageRoot, + state, + { dryRun, idleCooling }, + ); advanceInitiativeClockFromWallClock(state, now); const activeTurnScan = dryRun ? { active: new Map(), staleRecovered: [] } @@ -285,13 +297,6 @@ async function main(): Promise { rescheduleStaleOverdueParticipants(state); applyPendingMentionPriority(state); const manualOverrideIdentities = applyManualTurnRequests(state, now); - const idleCooling = await readIdleCoolingSnapshot({ - config, - identities: registry.identities, - state, - now, - }); - const selected = selectReadyParticipants( state, config.repoFaceHeartbeats.maxJobsPerTick, @@ -636,7 +641,7 @@ async function loadRepoFaceRuntimeSnapshots( identities: RepoDiscordIdentity[], storageRoot: string, heartbeatState: FaceHeartbeatState, - options: { dryRun?: boolean } = {}, + options: { dryRun?: boolean; idleCooling?: IdleCoolingSnapshot } = {}, ): Promise<{ restStates: Map; pressureByIdentity: Map; @@ -661,7 +666,12 @@ async function loadRepoFaceRuntimeSnapshots( identity.id, now, ); - if (!options.dryRun && !sleepCyclesEqual(typedState.scheduledRuntime.sleepCycle, projected.sleepCycle)) { + const sleepCycle = projectSleepCycleForOperatorAbsence( + projected.sleepCycle, + options.idleCooling, + now, + ); + if (!options.dryRun && !sleepCyclesEqual(typedState.scheduledRuntime.sleepCycle, sleepCycle)) { await applyVoidSelfStateOperation( { canonicalPath: statePath, @@ -673,13 +683,13 @@ async function loadRepoFaceRuntimeSnapshots( }, { operation: "update_sleep_cycle", - sleepCycle: projected.sleepCycle, + sleepCycle, }, ); } const speakingPressure = buildRepoFaceSpeakingPressure( typedState, - projected.sleepCycle, + sleepCycle, now, ); pressureByIdentity.set(identity.id, { @@ -709,7 +719,11 @@ async function loadRepoFaceRuntimeSnapshots( identity, statePath, typedState, - projectedRest: projected, + projectedRest: { + isNapping: sleepCycle.isNapping === true, + napEndsAt: sleepCycle.currentNapEndsAt, + nextNapStartsAt: sleepCycle.nextNapStartsAt, + }, }); if (maintenance.started || maintenance.failureReason) { heartbeatState.history.push({ @@ -723,9 +737,9 @@ async function loadRepoFaceRuntimeSnapshots( }); } restStates.set(identity.id, { - isNapping: projected.isNapping, - napEndsAt: projected.napEndsAt, - nextNapStartsAt: projected.nextNapStartsAt, + isNapping: sleepCycle.isNapping === true, + napEndsAt: sleepCycle.currentNapEndsAt, + nextNapStartsAt: sleepCycle.nextNapStartsAt, }); } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -738,6 +752,40 @@ async function loadRepoFaceRuntimeSnapshots( return { restStates, pressureByIdentity }; } +function projectSleepCycleForOperatorAbsence( + sleepCycle: VoidScheduledRuntime["sleepCycle"], + idleCooling: IdleCoolingSnapshot | undefined, + now: Date, +): VoidScheduledRuntime["sleepCycle"] { + if (idleCooling?.napModeActive !== true) { + return sleepCycle; + } + + const nowMs = now.getTime(); + const minimumNapMinutes = Math.max(60, Math.min(180, idleCooling.recoveryMinutes || 120)); + const currentNapEndsMs = Date.parse(sleepCycle.currentNapEndsAt ?? ""); + const desiredNapEndsMs = nowMs + minimumNapMinutes * 60_000; + const napEndsMs = Number.isFinite(currentNapEndsMs) + ? Math.max(currentNapEndsMs, desiredNapEndsMs) + : desiredNapEndsMs; + const activeDreamThemes = Array.from(new Set([ + ...sleepCycle.activeDreamThemes, + "operator-absence-dreaming", + "memory-distillation", + ])); + + return { + ...sleepCycle, + isNapping: true, + currentNapStartedAt: sleepCycle.isNapping && sleepCycle.currentNapStartedAt + ? sleepCycle.currentNapStartedAt + : now.toISOString(), + currentNapEndsAt: new Date(napEndsMs).toISOString(), + nextNapStartsAt: new Date(napEndsMs + 180 * 60_000).toISOString(), + activeDreamThemes, + }; +} + function buildRepoFaceSpeakingPressure( typedState: Awaited>, sleepCycle: Awaited>["sleepCycle"], diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 53c8bae..ae5cd39 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -331,3 +331,4 @@ {"ts":"2026-05-24T22:35:07Z","type":"implementation","status":"ok","note":"The cockpit HUD now uses fixed opposite-edge positioning so portrait iPad layout cannot push it offscreen, and it reports old-panel-style watchdog/orchestrator organ health: WDOG, ORCH, FACE, MOOD, RUM, MESH, heartbeat age, and selected turn."} {"ts":"2026-05-24T22:47:00Z","type":"implementation","status":"ok","note":"Fixed the missing HUD by moving the status panel into the cockpit workspace grid instead of leaving it as a shell sibling; browser verification places the square watchdog HUD in the visible right column between Controls and Mesh Events."} {"ts":"2026-05-24T22:48:35Z","type":"implementation","status":"ok","note":"Repo Face heartbeat parallelism increased: live REPO_FACE_HEARTBEAT_MAX_JOBS_PER_TICK set to 3, and tracked default/example now allow three concurrent independent Face turns per CTB tick while active-turn freeze still prevents duplicate turns per Face."} +{"ts":"2026-05-24T23:05:48Z","type":"implementation","status":"ok","note":"Operator absence now drives Face sleep projection: when idle cooling reaches nap mode, repo Face sleep cycles are forced/extended into napping with operator-absence-dreaming and memory-distillation themes before speaking pressure, CTB eligibility, and nap memory maintenance are computed."}