Add CTB idle cooling for swarm heartbeats

This commit is contained in:
Meta Vens 2026-05-22 23:23:25 +01:00
commit 11b37f7ace
5 changed files with 158 additions and 5 deletions

View file

@ -129,7 +129,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Repo Face state projection is no longer allowed to be deterministic schema slurry. `scripts/run-repo-face-heartbeats.ts` builds a compact state packet, then calls the model prompt at `prompts/repo-face-state-projector.prompt.md` to turn it into character-facing prose before the Face sees it. `REPO_FACE_STATE_PROJECTOR_ENABLED=false` exists only as an emergency isolation switch; both the model output and fallback packet reject schema/prompt-construction leaks such as `grants:`, `jurisdictions:`, `Face of`, repo paths, and "do not prompt" phrasing.
- First role-addressed chat also initializes the target repo's local `.voidbot` home. `ensureRepoFaceInitialized` resolves `repoPath` from the registry, `SOURCE_REPO_ROOT`, or `E:/Projects/<repoName>`, creates `voice/`, `state/`, `birth/`, and `logs/`, writes `voice/identity.json`, then starts `scripts/run-repo-face-birth.mjs` detached. That worker runs EpiphanyAgent's `epiphany-repo-personality scout` and `epiphany-repo-birth-runner` so newborn personality/memory/trajectory packets come from repo contents and git history. Status and logs live in the target repo under `.voidbot/birth/voidbot-face-birth.json` and `.voidbot/logs/voidbot-face-birth.log`.
- Manual newborn/strengthening passes should use `prompts/repo-face-personality-seed.prompt.md`. It makes the intended rite explicit: inspect repo docs/source/state/history, migrate useful legacy `memory.json` material into typed `.cc` memories, seed values/needs/agency/social state only when they will change behavior, and keep `.voidbot/voice` plus `.voidbot/state` as the repo-local home.
- Agent heartbeats now use one CTB/Final-Fantasy-X-style initiative queue. `scripts/run-repo-face-heartbeats.ts` still has its old filename, but it now reconciles built-in system agents plus registered repo Faces, stores the roster at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and dispatches selected turns. Repo Faces queue normal `repo-face-rumination` jobs; built-in `void` starts the existing moderation rumination runner as its turn. Active jobs/locks freeze initiative: recovery starts only after the owner-Codex job or Void moderation lock clears, and completion cannot grant another turn in the same pulse. The active-job scan now marks stale approved/running Face jobs failed through the job queue and records `stale_active_turn_recovered`, so a dead worker claim cannot freeze one Face forever. `GameCult Local Orchestrator` is the live hidden local scheduled task that ticks the scheduler; `npm run state:install-repo-face-heartbeat-task` remains an isolation/backcompat installer, not the normal pulse owner. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `kind:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or bare identity/repo/display keys. Repo Face prompts now include a jurisdiction-dive cadence so unattended heartbeats periodically inspect repo/history terrain with source tools and convert real findings into state, proposals, essay seeds, agency pressure, or speech.
- Agent heartbeats now use one CTB/Final-Fantasy-X-style initiative queue. `scripts/run-repo-face-heartbeats.ts` still has its old filename, but it now reconciles built-in system agents plus registered repo Faces, stores the roster at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and dispatches selected turns. Repo Faces queue normal `repo-face-rumination` jobs; built-in `void` starts the existing moderation rumination runner as its turn. Active jobs/locks freeze initiative: recovery starts only after the owner-Codex job or Void moderation lock clears, and completion cannot grant another turn in the same pulse. The active-job scan now marks stale approved/running Face jobs failed through the job queue and records `stale_active_turn_recovered`, so a dead worker claim cannot freeze one Face forever. `GameCult Local Orchestrator` is the live hidden local scheduled task that ticks the scheduler; `npm run state:install-repo-face-heartbeat-task` remains an isolation/backcompat installer, not the normal pulse owner. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `kind:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or bare identity/repo/display keys. Idle cooling is part of the same scheduler: if watched Discord channels have no recent human message, unprompted swarm turns are globally clamped to one dispatch per idle recovery window, defaulting to one every 30 minutes, while pending mentions still bypass the brake. Repo Face prompts now include a jurisdiction-dive cadence so unattended heartbeats periodically inspect repo/history terrain with source tools and convert real findings into state, proposals, essay seeds, agency pressure, or speech.
- Repo Face heartbeat prompt assembly now has one model projection boundary before the child Face sees context: `repo-face-state-projector.prompt.md` turns typed Face state into character-facing memory/affect/social prose. Recent room messages, nearby channel snapshots, and pending mentions are now deterministic raw transcript sections, oldest-to-newest, with explicit recency/correction rules. The deleted conversation projector was turning evidence into mushy consensus; do not revive it. The Face model must read human corrections directly. A separate deterministic social-affordance surface connects active typed person-bonds/status reads to recent raw messages by that person, so relationship state becomes a live hook instead of private flavor text. Model ownership is split: Face turns default to full `gpt-5.4`; state projection and parent Interpreter passes use the cheaper Spark/Mini support chain.
- Repo Face prompts now include read-only recent home-repo activity before Discord transcript. `scripts/run-repo-face-heartbeats.ts` calls `scripts/export-recent-repo-activity.mjs --repos <repo> --state-path <face-state> --read-only --hours 96 --max-commits 5`, so Faces see current body/jurisdiction motion before reacting to room pressure. This is the fix for Mimir missing live camera-runtime work, not a downstream compensator telling it to apologize harder.
- Worker delivery must treat Interpreter channel names as Face-local labels, not raw Discord ids. `SAY channel: aquarium` or `channel: narrative` resolves through the registered identity's `channelPermissions` before allow-list checks and webhook delivery. Earlier live Aqua/Nibu turns parsed `say=1` but did not post because the worker literally checked whether the identity was allowed in channel id `aquarium`; the post path now resolves labels and has a live Epiphany webhook receipt after restart.

View file

@ -115,6 +115,9 @@ const envSchema = z.object({
REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID: optionalNonEmptyString,
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_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"),
@ -174,6 +177,11 @@ export interface AppConfig {
defaultChannelId?: string;
baseRecoveryMinutes: number;
globalHeat: number;
idleCooling: {
enabled: boolean;
idleAfterMinutes: number;
recoveryMinutes: number;
};
speedOverrides: Record<string, number>;
heatOverrides: Record<string, number>;
codexModel?: string;
@ -400,6 +408,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
defaultChannelId: parsed.REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID,
baseRecoveryMinutes: parsed.REPO_FACE_HEARTBEAT_BASE_RECOVERY_MINUTES,
globalHeat: parsed.REPO_FACE_HEARTBEAT_GLOBAL_HEAT,
idleCooling: {
enabled: parsed.REPO_FACE_HEARTBEAT_IDLE_COOLING_ENABLED,
idleAfterMinutes: parsed.REPO_FACE_HEARTBEAT_IDLE_AFTER_MINUTES,
recoveryMinutes: parsed.REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES,
},
speedOverrides: parseNumericMap(parsed.REPO_FACE_HEARTBEAT_SPEED_OVERRIDES),
heatOverrides: parseNumericMap(parsed.REPO_FACE_HEARTBEAT_HEAT_OVERRIDES),
codexModel: parsed.REPO_FACE_HEARTBEAT_CODEX_MODEL,

View file

@ -70,6 +70,19 @@ interface ActiveTurnScan {
staleRecovered: StaleActiveTurn[];
}
interface IdleCoolingSnapshot {
enabled: boolean;
active: boolean;
reason?: string;
checkedChannelIds: string[];
lastHumanActivityAt?: string;
idleForMinutes?: number;
idleAfterMinutes: number;
recoveryMinutes: number;
lastUnpromptedTurnQueuedAt?: string;
nextUnpromptedTurnAllowedAt?: string;
}
interface StaleActiveTurn {
identityId: string;
jobId: string;
@ -230,12 +243,19 @@ async function main(): Promise<void> {
);
rescheduleStaleOverdueParticipants(state);
applyPendingMentionPriority(state);
const idleCooling = await readIdleCoolingSnapshot({
config,
identities: registry.identities,
state,
now,
});
const selected = selectReadyParticipants(
state,
config.repoFaceHeartbeats.maxJobsPerTick,
completedThisTick,
restStates,
idleCooling,
);
const queuedIdentityIds: string[] = [];
@ -347,6 +367,7 @@ async function main(): Promise<void> {
selected: selected.map((entry) => entry.identityId),
queued: queuedIdentityIds,
statePath: config.repoFaceHeartbeats.statePath,
idleCooling,
})}\n`,
);
}
@ -1311,6 +1332,7 @@ function selectReadyParticipants(
maxJobs: number,
completedThisTick: Set<string>,
restStates: Map<string, RepoFaceRestSnapshot>,
idleCooling: IdleCoolingSnapshot,
): FaceHeartbeatParticipant[] {
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
const eligible = state.participants
@ -1333,7 +1355,7 @@ function selectReadyParticipants(
return [];
}
return eligible
const ready = eligible
.filter((participant) => participant.nextTurnAt <= state.initiativeClock)
.sort((left, right) => {
const pendingDelta =
@ -1353,8 +1375,20 @@ function selectReadyParticipants(
return right.effectiveSpeed - left.effectiveSpeed;
}
return left.identityId.localeCompare(right.identityId);
})
.slice(0, maxJobs);
});
if (!idleCooling.enabled || !idleCooling.active) {
return ready.slice(0, maxJobs);
}
const mentioned = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) > 0);
const unprompted = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) === 0);
const coolingAllowsUnpromptedTurn =
!idleCooling.nextUnpromptedTurnAllowedAt ||
Date.parse(idleCooling.nextUnpromptedTurnAllowedAt) <= Date.now();
const cooled = coolingAllowsUnpromptedTurn && mentioned.length < maxJobs ? unprompted.slice(0, 1) : [];
return [...mentioned, ...cooled].slice(0, maxJobs);
}
function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void {
@ -1428,6 +1462,111 @@ function countPendingMentionsByIdentity(
return counts;
}
async function readIdleCoolingSnapshot(input: {
config: ReturnType<typeof loadConfig>;
identities: RepoDiscordIdentity[];
state: FaceHeartbeatState;
now: Date;
}): Promise<IdleCoolingSnapshot> {
const policy = input.config.repoFaceHeartbeats.idleCooling;
const checkedChannelIds = collectIdleCoolingChannelIds(input.config, input.identities);
const base = {
enabled: policy.enabled,
active: false,
checkedChannelIds,
idleAfterMinutes: policy.idleAfterMinutes,
recoveryMinutes: policy.recoveryMinutes,
lastUnpromptedTurnQueuedAt: newestUnpromptedTurnQueuedAt(input.state),
};
if (!policy.enabled) {
return { ...base, reason: "disabled" };
}
if (!input.config.botToken) {
return { ...base, reason: "missing_discord_bot_token" };
}
if (checkedChannelIds.length === 0) {
return { ...base, reason: "no_watched_discord_channels" };
}
let newestHumanActivityAt: string | undefined;
const fetchErrors: string[] = [];
for (const channelId of checkedChannelIds) {
try {
const messages = await fetchRecentDiscordMessages({
botToken: input.config.botToken,
channelId,
limit: 10,
});
for (const message of messages) {
if (message.isBot || !message.content.trim()) {
continue;
}
const timestampMs = Date.parse(message.timestamp);
const newestMs = Date.parse(newestHumanActivityAt ?? "");
if (Number.isFinite(timestampMs) && (!Number.isFinite(newestMs) || timestampMs > newestMs)) {
newestHumanActivityAt = message.timestamp;
}
}
} catch (error) {
fetchErrors.push(`${channelId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
const lastUnpromptedTurnQueuedAt = base.lastUnpromptedTurnQueuedAt;
const lastUnpromptedTurnQueuedMs = Date.parse(lastUnpromptedTurnQueuedAt ?? "");
const nextUnpromptedTurnAllowedAt = Number.isFinite(lastUnpromptedTurnQueuedMs)
? new Date(lastUnpromptedTurnQueuedMs + policy.recoveryMinutes * 60_000).toISOString()
: undefined;
if (!newestHumanActivityAt) {
return {
...base,
active: 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);
return {
...base,
active: idleForMinutes >= policy.idleAfterMinutes,
reason: fetchErrors.length > 0 ? "partial_activity_fetch_failure" : undefined,
lastHumanActivityAt: newestHumanActivityAt,
idleForMinutes: round3(idleForMinutes),
nextUnpromptedTurnAllowedAt,
};
}
function collectIdleCoolingChannelIds(
config: ReturnType<typeof loadConfig>,
identities: RepoDiscordIdentity[],
): string[] {
return Array.from(new Set([
config.repoFaceHeartbeats.defaultChannelId,
config.bifrostDiscordChannelId,
...config.indexedChannelIds,
...identities.flatMap((identity) => getRepoDiscordIdentityAllowedChannelIds(identity)),
].filter((value): value is string => typeof value === "string" && value.trim().length > 0)));
}
function newestUnpromptedTurnQueuedAt(state: FaceHeartbeatState): string | undefined {
for (const entry of [...state.history].reverse()) {
if (entry.type !== "queued" && entry.type !== "dry_run_selected") {
continue;
}
if (typeof entry.queuedAt !== "string") {
continue;
}
if (typeof entry.pendingMentionCount === "number" && entry.pendingMentionCount > 0) {
continue;
}
return entry.queuedAt;
}
return undefined;
}
function buildHeartbeatPrompt(input: {
identity: RepoDiscordIdentity;
channelId: string;

View file

@ -292,3 +292,4 @@
{"ts":"2026-05-22T22:02:15Z","type":"fix","status":"ok","note":"Repaired repo Face speech channel ownership after Heimdall posted an Aquarium-shaped reply in development: parent Interpreter can no longer invent a SAY venue when the Face did not explicitly choose one; default and current_room speech now resolve to the job conversation channel before registered-channel allow-list checks. Typecheck/build passed and the local stack was restarted on the new dist."}
{"ts":"2026-05-22T22:07:51Z","type":"fix","status":"ok","note":"Added a parent Interpreter echo gate after Huginn/Libby mirrored Heimdall's bridge reply: public SAY now retries when it only restates a recent agent claim in another Face's vocabulary without new question, repo fact, disagreement, handoff, or social move. Retired Huginn's echo-formed Libby bond and bridge/status read through typed Face-state operations."}
{"ts":"2026-05-22T22:09:00Z","type":"maintenance","status":"ok","note":"Retired Huginn's deleted/contextless #development delivery receipt in CultCacheTS after the echo-gate fix so the active recent-speech surface no longer treats the removed post as live context."}
{"ts":"2026-05-22T22:22:23Z","type":"fix","status":"ok","note":"Repo Face CTB idle cooling is scheduler-owned: watched Discord human inactivity now clamps unprompted swarm dispatches to one per idle recovery window while pending mentions bypass the brake. Dry-run on 2026-05-22 reported recent human activity, idleCooling.active=false, and next unprompted dispatch gate in output."}

View file

@ -138,7 +138,7 @@ accepted_design:
- First chat under a repo identity must initialize the target repo's own `.voidbot` home: `voice/` for identity and mouth files, `state/` for Face/swarm state, `birth/` for Epiphany birth artifacts, and `logs/` for durable worker output. The birth hook uses EpiphanyAgent's repo-personality/birth-runner path over repo contents and git history instead of inventing a VoidBot-only personality generator.
- Repo Face personality seeding has a named prompt surface now: `prompts/repo-face-personality-seed.prompt.md`. Use it for newborn or strengthening passes that trawl repo docs, source layout, commit history, repo-local `.voidbot` material, and legacy `memory.json`, then convert useful meaning into typed `.cc` state instead of leaving it in inert memory files.
- Recent chat consensus can now be packaged for any target Codex workspace through `scripts/feed-codex-chat-consensus.mjs`. The helper reads archived Discord messages, defaults to Aquarium, writes a markdown consensus packet plus a Codex prompt under `.voidbot/artifacts/chat-consensus/`, and only feeds `codex exec` when `--execute` is explicit.
- Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. Wall-clock elapsed time advances initiative; the scheduled task only supplies frequent pulses. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Heat can be turned up globally, by participant kind, turn kind, identity, repo, display name, channel, or `all` group without creating new schedulers, but heat only changes recovery speed; it must not fast-forward time. Active-turn freeze and rest gating remain hard brakes unless a direct mention creates room debt. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. The Windows pulse task supports a one-minute floor; stream mode should raise heat or temporarily loosen fatigue, not compress wall-clock time.
- Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. Wall-clock elapsed time advances initiative; the scheduled task only supplies frequent pulses. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Heat can be turned up globally, by participant kind, turn kind, identity, repo, display name, channel, or `all` group without creating new schedulers, but heat only changes recovery speed; it must not fast-forward time. Active-turn freeze and rest gating remain hard brakes unless a direct mention creates room debt. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. The Windows pulse task supports a one-minute floor; stream mode should raise heat or temporarily loosen fatigue, not compress wall-clock time. Idle cooling is also scheduler-owned: when watched Discord channels have no recent human activity, non-mentioned swarm turns are globally throttled to one unprompted dispatch per `REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES` window, while pending mentions still pull their target forward.
- Runtime liveness is not enough. The operations watchdog must also verify bot/worker freshness by comparing each live process start time against its built `dist/index.js` entrypoint timestamp. A process that predates its entrypoint is stale and must fail `runtime.<component>.freshness`; otherwise source/build repairs can silently sit beside an old in-memory worker that keeps eating new Face action DSL output as private summaries.
- Active-turn freeze is bounded by actual job liveness, not blind faith in an old `running` row. Repo Face heartbeat jobs that remain approved/running past the stale-job window are marked failed through the job queue, recorded as `stale_active_turn_recovered`, and removed from the active-turn map so one dead worker claim cannot silence a Face indefinitely.
- Repo Face rest projection is parent-owned state, not a read-only scheduler illusion. If the heartbeat runner projects a Face sleep cycle forward, it must persist the normalized `update_sleep_cycle` operation to that Face's `.cc` state before ready selection. Missed nap windows roll forward as completed rest instead of starting a brand-new nap at inspection time; otherwise overdue agents can be skipped forever as "currently napping" while raw state keeps the same stale boundary.