Slow Face idle cooling into nap mode

This commit is contained in:
Meta Vens 2026-05-24 22:26:01 +01:00
commit 40cbdd075a
4 changed files with 37 additions and 5 deletions

View file

@ -73,6 +73,10 @@ REPO_FACE_HEARTBEAT_TASK_NAME=VoidBot Repo Face Heartbeats
REPO_FACE_HEARTBEAT_INTERVAL_MINUTES=15
REPO_FACE_HEARTBEAT_MAX_JOBS_PER_TICK=2
REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID=
REPO_FACE_HEARTBEAT_IDLE_COOLING_ENABLED=true
REPO_FACE_HEARTBEAT_IDLE_AFTER_MINUTES=180
REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES=120
REPO_FACE_HEARTBEAT_IDLE_NAP_AFTER_MINUTES=360
REPO_FACE_HEARTBEAT_CODEX_MODEL=gpt-5.3-codex-spark
REPO_FACE_HEARTBEAT_CODEX_MODELS=gpt-5.3-codex-spark,gpt-5.4-mini
REPO_FACE_TURN_CODEX_MODEL=gpt-5.4

View file

@ -116,8 +116,9 @@ const envSchema = z.object({
REPO_FACE_HEARTBEAT_BASE_RECOVERY_MINUTES: z.coerce.number().positive().default(10),
REPO_FACE_HEARTBEAT_GLOBAL_HEAT: z.coerce.number().positive().default(1),
REPO_FACE_HEARTBEAT_IDLE_COOLING_ENABLED: booleanFromEnv.default(true),
REPO_FACE_HEARTBEAT_IDLE_AFTER_MINUTES: z.coerce.number().positive().default(30),
REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES: z.coerce.number().positive().default(30),
REPO_FACE_HEARTBEAT_IDLE_AFTER_MINUTES: z.coerce.number().positive().default(180),
REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES: z.coerce.number().positive().default(120),
REPO_FACE_HEARTBEAT_IDLE_NAP_AFTER_MINUTES: z.coerce.number().positive().default(360),
REPO_FACE_HEARTBEAT_SPEED_OVERRIDES: z.string().default(""),
REPO_FACE_HEARTBEAT_HEAT_OVERRIDES: z.string().default(""),
REPO_FACE_HEARTBEAT_CODEX_MODEL: z.string().min(1).default("gpt-5.3-codex-spark"),
@ -181,6 +182,7 @@ export interface AppConfig {
enabled: boolean;
idleAfterMinutes: number;
recoveryMinutes: number;
napAfterMinutes: number;
};
speedOverrides: Record<string, number>;
heatOverrides: Record<string, number>;
@ -412,6 +414,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
enabled: parsed.REPO_FACE_HEARTBEAT_IDLE_COOLING_ENABLED,
idleAfterMinutes: parsed.REPO_FACE_HEARTBEAT_IDLE_AFTER_MINUTES,
recoveryMinutes: parsed.REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES,
napAfterMinutes: parsed.REPO_FACE_HEARTBEAT_IDLE_NAP_AFTER_MINUTES,
},
speedOverrides: parseNumericMap(parsed.REPO_FACE_HEARTBEAT_SPEED_OVERRIDES),
heatOverrides: parseNumericMap(parsed.REPO_FACE_HEARTBEAT_HEAT_OVERRIDES),

View file

@ -93,6 +93,8 @@ interface IdleCoolingSnapshot {
idleForMinutes?: number;
idleAfterMinutes: number;
recoveryMinutes: number;
napAfterMinutes: number;
napModeActive?: boolean;
lastUnpromptedTurnQueuedAt?: string;
nextUnpromptedTurnAllowedAt?: string;
}
@ -824,6 +826,9 @@ async function maybeStartRepoFaceMemoryMaintenance(input: {
if (isRecentRunningStatus(currentStatus, 25)) {
return { started: false, reason: "maintenance_already_running", statusPath: paths.statusPath };
}
if (input.projectedRest.isNapping === true && isStatusCompletedRecently(currentStatus, 60)) {
return { started: false, reason: "nap_maintenance_recently_completed", statusPath: paths.statusPath };
}
if (isStatusCompletedAfter(currentStatus, newestTimestampMs(shortTerm))) {
return { started: false, reason: "maintenance_already_completed_for_sources", statusPath: paths.statusPath };
}
@ -913,6 +918,14 @@ function isStatusCompletedAfter(status: Record<string, unknown> | undefined, sou
return Number.isFinite(finishedAtMs) && finishedAtMs >= sourceTimestampMs;
}
function isStatusCompletedRecently(status: Record<string, unknown> | undefined, maxAgeMinutes: number): boolean {
if (status?.status !== "ok") {
return false;
}
const finishedAtMs = typeof status.finishedAt === "string" ? Date.parse(status.finishedAt) : NaN;
return Number.isFinite(finishedAtMs) && Date.now() - finishedAtMs < maxAgeMinutes * 60_000;
}
function newestTimestampMs(entries: Array<{ updatedAt: string }>): number {
return Math.max(...entries.map((entry) => Date.parse(entry.updatedAt)).filter(Number.isFinite));
}
@ -1560,7 +1573,8 @@ function selectReadyParticipants(
!(
participant.participantKind === "repo_face" &&
pendingMentionCount === 0 &&
restState?.isNapping === true
restState?.isNapping === true &&
idleCooling.napModeActive !== true
)
);
});
@ -1597,10 +1611,16 @@ 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 napRuns = idleCooling.napModeActive === true
? unprompted.filter((participant) => restStates.get(participant.identityId)?.isNapping === true)
: [];
const awakeRuns = unprompted.filter((participant) => restStates.get(participant.identityId)?.isNapping !== true);
const coolingAllowsUnpromptedTurn =
!idleCooling.nextUnpromptedTurnAllowedAt ||
Date.parse(idleCooling.nextUnpromptedTurnAllowedAt) <= Date.now();
const cooled = coolingAllowsUnpromptedTurn && mentioned.length < maxJobs ? unprompted.slice(0, 1) : [];
const cooled = coolingAllowsUnpromptedTurn && mentioned.length < maxJobs
? (napRuns[0] ? napRuns : awakeRuns).slice(0, 1)
: [];
return [...mentioned, ...cooled].slice(0, maxJobs);
}
@ -1690,6 +1710,7 @@ async function readIdleCoolingSnapshot(input: {
checkedChannelIds,
idleAfterMinutes: policy.idleAfterMinutes,
recoveryMinutes: policy.recoveryMinutes,
napAfterMinutes: policy.napAfterMinutes,
lastUnpromptedTurnQueuedAt: newestUnpromptedTurnQueuedAt(input.state),
};
@ -1737,15 +1758,18 @@ async function readIdleCoolingSnapshot(input: {
return {
...base,
active: true,
napModeActive: true,
reason: fetchErrors.length > 0 ? "activity_fetch_failed_or_no_human_messages" : "no_recent_human_messages",
nextUnpromptedTurnAllowedAt,
};
}
const idleForMinutes = Math.max(0, (input.now.getTime() - Date.parse(newestHumanActivityAt)) / 60_000);
const active = idleForMinutes >= policy.idleAfterMinutes;
return {
...base,
active: idleForMinutes >= policy.idleAfterMinutes,
active,
napModeActive: active && idleForMinutes >= policy.napAfterMinutes,
reason: fetchErrors.length > 0 ? "partial_activity_fetch_failure" : undefined,
lastHumanActivityAt: newestHumanActivityAt,
idleForMinutes: round3(idleForMinutes),

View file

@ -322,3 +322,4 @@
{"ts":"2026-05-24T12:09:30Z","type":"implementation","status":"ok","note":"CultCacheTS state writes now retry transient Windows EPERM/EBUSY/EACCES replace failures before falling back to copy-over-write. This protects repo Face .cc operations when the live bot, scanner, or another reader briefly pins the target state file during temp-file replacement."}
{"ts":"2026-05-24T18:36:00Z","type":"implementation","status":"ok","note":"Repo Face CTB urgency now derives from typed speaking pressure; Kiko's StreamPixels state carries a June 10 2026 delivery pressure for Metacrat's sister's project."}
{"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."}