Centralize Persona scheduler state commits
This commit is contained in:
parent
7178cfaa85
commit
7a0b49b3ed
7 changed files with 210 additions and 71 deletions
|
|
@ -45,6 +45,13 @@ export interface ParticipantSpec {
|
|||
|
||||
export interface PendingMention {
|
||||
identityId: string;
|
||||
id?: string;
|
||||
channelId?: string;
|
||||
messageId?: string;
|
||||
authorId?: string;
|
||||
authorName?: string;
|
||||
queuedAt?: string;
|
||||
visiblePrompt?: string;
|
||||
}
|
||||
|
||||
export interface InitiativeState<TParticipant extends InitiativeParticipant = InitiativeParticipant> {
|
||||
|
|
@ -156,6 +163,34 @@ export function reconcileParticipants(input: ReconcileParticipantsInput): Initia
|
|||
});
|
||||
}
|
||||
|
||||
export function reconcileInitiativeParticipants<TSpec extends ParticipantSpec>(input: {
|
||||
state: InitiativeState;
|
||||
specs: TSpec[];
|
||||
defaultChannelId?: string;
|
||||
speedOverrides: Record<string, number>;
|
||||
heatOverrides: Record<string, number>;
|
||||
baseRecoveryMinutes: number;
|
||||
globalHeat: number;
|
||||
activeTurns: Map<string, string>;
|
||||
completedThisTick: Set<string>;
|
||||
}): void {
|
||||
input.state.participants = reconcileParticipants({
|
||||
existing: input.state.participants,
|
||||
specs: input.specs,
|
||||
defaultChannelId: input.defaultChannelId,
|
||||
speedOverrides: input.speedOverrides,
|
||||
heatOverrides: input.heatOverrides,
|
||||
initiativeClock: input.state.initiativeClock,
|
||||
baseRecoveryMinutes: input.baseRecoveryMinutes,
|
||||
globalHeat: input.globalHeat,
|
||||
}).map((participant) => applyActiveTurnFreeze(
|
||||
participant,
|
||||
input.activeTurns.get(participant.identityId),
|
||||
input.state,
|
||||
input.completedThisTick,
|
||||
));
|
||||
}
|
||||
|
||||
export function applyActiveTurnFreeze<TParticipant extends InitiativeParticipant>(
|
||||
participant: TParticipant,
|
||||
activeJobId: string | undefined,
|
||||
|
|
@ -315,6 +350,96 @@ export function applyPendingMentionPriority(state: InitiativeState): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function applySchedulerControls(input: {
|
||||
state: InitiativeState;
|
||||
baseRecoveryMinutes: number;
|
||||
globalHeat: number;
|
||||
}): void {
|
||||
Object.assign(input.state, {
|
||||
baseRecoveryMinutes: input.baseRecoveryMinutes,
|
||||
globalHeat: input.globalHeat,
|
||||
});
|
||||
}
|
||||
|
||||
export function recordSchedulerSkip(input: {
|
||||
state: InitiativeState;
|
||||
skippedAt: Date;
|
||||
reason: string;
|
||||
details?: Record<string, unknown>;
|
||||
}): void {
|
||||
input.state.lastTickAt = input.skippedAt.toISOString();
|
||||
input.state.history.push({
|
||||
type: "skipped",
|
||||
reason: input.reason,
|
||||
skippedAt: input.state.lastTickAt,
|
||||
...input.details,
|
||||
});
|
||||
trimSchedulerHistory(input.state);
|
||||
}
|
||||
|
||||
export function recordStaleActiveTurnRecoveries(input: {
|
||||
state: InitiativeState;
|
||||
recoveredAt: Date;
|
||||
recoveries: Array<{
|
||||
identityId: string;
|
||||
jobId: string;
|
||||
requestMessageId?: string;
|
||||
state: string;
|
||||
updatedAt: string;
|
||||
ageMinutes: number;
|
||||
}>;
|
||||
}): void {
|
||||
for (const stale of input.recoveries) {
|
||||
input.state.history.push({
|
||||
type: "stale_active_turn_recovered",
|
||||
identityId: stale.identityId,
|
||||
activeJobId: stale.jobId,
|
||||
requestMessageId: stale.requestMessageId,
|
||||
jobState: stale.state,
|
||||
jobUpdatedAt: stale.updatedAt,
|
||||
ageMinutes: stale.ageMinutes,
|
||||
recoveredAt: input.recoveredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function consumePendingMentions(input: {
|
||||
state: InitiativeState;
|
||||
participant: InitiativeParticipant;
|
||||
mentions: PendingMention[];
|
||||
consumedAt: string;
|
||||
activeJobId?: string;
|
||||
requestMessageId?: string;
|
||||
}): void {
|
||||
if (input.mentions.length === 0) return;
|
||||
const consumedIds = new Set(input.mentions.map((entry) => entry.id).filter((id): id is string => Boolean(id)));
|
||||
input.state.pendingMentions = input.state.pendingMentions.filter((entry) => !entry.id || !consumedIds.has(entry.id));
|
||||
input.state.history.push({
|
||||
type: "pending_mentions_consumed",
|
||||
identityId: input.participant.identityId,
|
||||
participantKind: input.participant.participantKind,
|
||||
turnKind: input.participant.turnKind,
|
||||
activeJobId: input.activeJobId,
|
||||
requestMessageId: input.requestMessageId,
|
||||
consumedAt: input.consumedAt,
|
||||
mentionCount: input.mentions.length,
|
||||
mentions: input.mentions.slice(-6).map((mention) => ({
|
||||
id: mention.id,
|
||||
channelId: mention.channelId,
|
||||
messageId: mention.messageId,
|
||||
authorId: mention.authorId,
|
||||
authorName: mention.authorName,
|
||||
queuedAt: mention.queuedAt,
|
||||
visiblePrompt: collapseWhitespace(mention.visiblePrompt ?? "", 240),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function finalizeSchedulerTick(state: InitiativeState, completedAt: Date): void {
|
||||
state.lastTickAt = completedAt.toISOString();
|
||||
trimSchedulerHistory(state);
|
||||
}
|
||||
|
||||
export function applySemanticPressureProjection(input: {
|
||||
state: InitiativeState;
|
||||
projections: SemanticPressureProjection[];
|
||||
|
|
@ -399,6 +524,14 @@ function round3(value: number): number {
|
|||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function trimSchedulerHistory(state: InitiativeState, limit = 80): void {
|
||||
state.history = state.history.slice(-limit);
|
||||
}
|
||||
|
||||
function collapseWhitespace(value: string, maxLength: number): string {
|
||||
return value.replace(/\s+/g, " ").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function initiativeSpeedFor(spec: ParticipantSpec, overrides: Record<string, number>): number {
|
||||
const override = overrides[spec.id.toLowerCase()];
|
||||
if (override !== undefined) return clamp(override, 0.35, 6);
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- 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 turns use one CTB/Final-Fantasy-X-style initiative queue. `apps/persona-scheduler` owns the resident one-minute pulse on Yggdrasil. `initiative-engine.ts` owns participant reconciliation plus clock, active-turn freeze/recovery, queue-result commits, eligibility, and ready ordering; `state-store.ts` alone owns load/migration/atomic persistence of scheduler state. Discord mention ingress is no longer a second whole-state writer: the bot creates immutable per-message `voidbot.persona_attention_command` CultCache inbox documents, and the scheduler ingests/commits/acknowledges them with replay-safe ordering. `active-turn-source.ts` alone reads Persona job liveness from Postgres/the remaining Void lock and may fail a stale queue claim; it returns facts to the engine and cannot edit initiative. `discord-activity-source.ts` owns read-only human-activity polling and idle-window derivation; it excludes bot traffic and cannot write state, consume mentions, or select work. The configured scheduler JSON path is isolated legacy storage behind the state-store port, not a second owner or the desired final state shape; replace it with CultCache rather than teaching orchestration about another format. `scripts/run-repo-face-heartbeats.ts` may build neutral specs from registered identities and invoke queue actuators, but it no longer derives or persists speed, groups, heat, reaction bias, interrupt threshold, status, load, counters, recovery, initiative position, active-job liveness, or idle-activity observations. It remains the temporary orchestration shell for semantic pressure, context acquisition, and prompt assembly. Registered repo Faces and native Personas queue normal `repo-face-rumination` jobs through Postgres, and active jobs freeze initiative until the worker completes or fails them. Built-in Void is omitted when its distinct moderation heartbeat organ is disabled, so the hosted scheduler never launches the old PowerShell rumination path. The Windows task installer and wrapper are deleted. Heat knobs remain `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; idle cooling and pending-mention priority remain scheduler inputs.
|
||||
- Bifrost governance acquisition is isolated in `bifrost-governance-source.ts`. That adapter alone owns the remaining `governance-threads.mjs digest` CLI contract and converts provider command/parse failures into typed error topics; prompt assembly receives governance facts and does not know the subprocess.
|
||||
- Scheduler state mutation is centralized. `initiative-engine.ts` owns reconciliation, controls, skip/stale witnesses, pressure, mention consumption, queue transitions, and tick finalization through named commits. The orchestration shell has no direct scheduler state/history writes; queue and context adapters only return facts.
|
||||
- Dynamic heat is live as derived semantic response pressure. `semantic-pressure-source.ts` batches canonical Face affinity cards and recent permitted-channel human-message embeddings through the existing RAG embedder, applies recency decay/noisy-OR aggregation, and returns bounded projections for at most three scheduler-eligible Faces. It does not write scheduler state. `initiative-engine.ts` alone commits inspectable evidence, `dynamicHeat`, one-time semantic interrupt receipts, and readiness changes. Direct mentions remain obligations, and model/Discord failures fail cool without bypassing rest, active jobs, or scheduler capacity.
|
||||
- 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. `turn-context-source.ts` owns raw Discord acquisition and its bounded attachment cache; it returns current-room and nearby-channel messages oldest-to-newest plus explicit nearby-channel failure witnesses, without reading Persona state or composing prompts. Pending mentions remain scheduler-owned input. 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.
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor
|
|||
- Repo character turns now have a state-projector plus turn-Interpreter shape. The state-in boundary is `prompts/repo-face-state-projector.prompt.md`: typed state and identity material are translated into lived character prose before the child sees it. The state projector must surface values, drives, wounds, fascinations, aesthetic taste, dignity, mood, needs, social reads, bonds, agency pressure, and the personality-specific meaning of sparse or dense social topology when the state contains those facts. The deterministic state packet feeding that projector is intentionally verbose and parent-facing: it exposes private notes, values, activation vectors, runtime rest/speaking pressure, affect needs, bonds, status reads, mood dimensions, durable memories, short-term residue, agency pressures, incubating thoughts, candidate speech pressure, recent speech residue, social graph facts, room weather, and semantic curiosity graph attractors so every included variable can appear directly or bend behavior. The curiosity graph is built by seeding history/source vector retrieval from current room context, nearby channels, typed Face state, and home-jurisdiction identity, then decoding retrieved chunks into weighted attractor clusters with prominence, saturation, novelty, density, jurisdiction fit, suggested motion, and evidence labels. The projector owns turning those neutral facts into lived curiosity, boredom, fatigue, territorial pull, underexplored intrigue, or a closing/pivot impulse; deterministic code must not decide the feeling. Deterministic code may also attach neutral graph facts such as mapped people and unmapped registered peers to the projector packet, but it must not decide whether that feels like anxiety, territorial scouting, or nothing. Deterministic code attaches recent read-only home-repo activity before chat, using the Face's own state path as the repo-activity cursor source, so current implementation motion is visible as body context. Current-room and nearby-channel logs no longer cross a model conversation projector; deterministic code attaches raw oldest-to-newest transcript evidence with explicit recency/correction rules so the Face can notice human reframes directly. Deterministic code also attaches a social-affordance surface that matches active typed person-bonds/status reads to recent raw messages from that person, so relationship state arrives as a live hook rather than private decorative lore. Deterministic code still owns full prompt assembly and inspection through `scripts/run-repo-face-heartbeats.ts --assemble-prompt <identity>`; that path composes stable doctrine, allowed rooms, research/archive capability guidance, Bifrost digest, projected memory/affect prose, home-repo activity, live social affordances, and raw conversation evidence into the exact child prompt. It must not invent personality, mood, affect, or social memory from typed state. Situational obligations such as direct-call priority or first-public-introduction duty are emitted only when the scheduler packet proves they apply, not as standing prompt law. The child character writes a natural in-character turn on full `gpt-5.4` and may describe desired speech, repo/article proposals, social reads, needs, mood shifts, or memory-worthy thoughts without emitting action DSL blocks. The cheaper Spark/Mini parent Interpreter decides route/retry/drop and translates warranted effects into structured `STATE NOTE` and `SAY` blocks for the worker while governance dispatch remains paused. `STATE NOTE` blocks become typed short-term memory, affect needs, social bonds, status reads, mood dimensions, or agency pressure. The worker still accepts legacy compact JSON sentinels and legacy `UPDATE REQUEST` blocks for compatibility, but the live prompt path keeps transport and state syntax out of the character's roleplay surface.
|
||||
- `apps/persona-scheduler` is the resident daemon organ that owns the serialized Persona wall-clock pulse on Yggdrasil. It invokes the remaining turn orchestrator without overlap and remains alive between pulses. `apps/persona-scheduler/src/initiative-engine.ts` owns the pure scheduling state machine: participant reconciliation, deterministic base speed/bias/threshold derivation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-success/failure/dry-run commits, stale rescheduling, pending-mention priority, rest/idle eligibility, and ready ordering. Explicit blocked/withdrawn status, active load, semantic receipts, and initiative position survive reconciliation; new Faces without a usable mouth fail closed. `apps/persona-scheduler/src/state-store.ts` owns scheduler persistence and legacy migration. It creates state only when the path is absent, rejects malformed/unsupported state rather than erasing it, and atomically promotes complete writes. `packages/core/src/repo-face-heartbeat-queue.ts` no longer rewrites scheduler state: the Discord bot publishes one immutable `voidbot.persona_attention_command` CultCache `.cc` inbox document per addressed message. The scheduler store ingests and deduplicates those commands, persists them as pending attention, then acknowledges inbox files only after the durable commit; a crash before acknowledgement replays safely. `apps/persona-scheduler/src/active-turn-source.ts` owns the Postgres/legacy-lock liveness adapter. It recognizes only Persona turn request IDs, applies the bounded stale threshold, marks dead queue claims failed, and returns active/stale witnesses; it never writes scheduler state. `apps/persona-scheduler/src/discord-activity-source.ts` owns neutral Discord activity acquisition: it deduplicates explicit watched channels, excludes bot traffic, reports raw human messages, and derives idle-cooling times from scheduler history without writing state or selecting work. `apps/persona-scheduler/src/semantic-pressure-source.ts` owns embedding-backed affinity observation and returns bounded projections for eligible Personas and permitted channels. It cannot mutate initiative; `initiative-engine.ts` alone applies pressure, dynamic heat, interrupt readiness, dedupe receipts, and transition history. `apps/persona-scheduler/src/turn-context-source.ts` owns current-room and nearby-channel Discord evidence plus the bounded image-attachment cache. It returns chronological source messages and explicit snapshot failure witnesses; it has no access to Persona state, prompt composition, or the job queue. The configured scheduler JSON path is isolated legacy storage behind the store port and should be replaced with a typed CultCache `.cc` backing without changing the engine, inbox, or orchestration shell. `scripts/run-repo-face-heartbeats.ts` projects the identity registry into neutral participant specs and affinity candidates, invokes external queue actuators, and still owns Bifrost/repo/Persona-state context interpretation plus prompt assembly pending further extraction. External adapters report facts; only the initiative engine mutates load, counters, recovery position, pressure, and queue-transition history. Repo Faces and native Personas queue ordinary `repo-face-rumination` jobs through the canonical job queue. Built-in Void participates only when its separate moderation heartbeat organ is enabled; the hosted scheduler does not launch Windows PowerShell.
|
||||
- `apps/persona-scheduler/src/bifrost-governance-source.ts` owns the remaining Bifrost governance digest subprocess contract. It supplies the CLI path, arguments, timeout, JSON parsing, and typed command/parse failure topics; Persona prompt composition receives a `BifrostGovernanceDigest` and cannot invoke Bifrost directly.
|
||||
- The scheduler state has one write authority. `initiative-engine.ts` commits participant reconciliation, operator controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, pending-attention consumption, queue outcomes, and tick finalization. `run-repo-face-heartbeats.ts` contains no direct participant/state assignment or history append; its sources and actuators return facts to engine commits.
|
||||
- The same scheduler derives semantic `responsePressure` for scheduler-eligible Personas by embedding canonical Face identity-card projections and recent human messages from channels each Face may enter. Pressure decays by message age, aggregates with noisy-OR, awakens at most three eligible Faces, and exposes its message-scoped cosine evidence. It produces bounded `dynamicHeat` for the next scheduled recovery calculation; an unseen message crossing the Persona's interrupt threshold may pull readiness to the current initiative clock exactly once. It cannot create a job, bypass rest or active-turn brakes, impersonate a direct mention, or write inferred desire into Persona state.
|
||||
- The Windows `GameCult Local Orchestrator` and per-organ scheduled-task wrapper no longer own Persona pulses. `voidbot.service` deploys `persona-scheduler` beside bot, worker, Qdrant, and the CultMesh publisher. Turn frequency is controlled by `REPO_FACE_HEARTBEAT_BASE_RECOVERY_MINUTES`, `REPO_FACE_HEARTBEAT_GLOBAL_HEAT`, `REPO_FACE_HEARTBEAT_SPEED_OVERRIDES`, and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`. Heat keys can target `all`, `kind:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or the bare identity/repo/display key.
|
||||
- The local ignored registry currently includes Nibu for `AetheriaLore`, with Face state in `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, Discord role id `1505918221453103247`, and an Aquarium channel allow-list. Nibu's Face state now treats AetheriaLore-first map-building, abrasive character voice, consensus-backed change agency, her own `Aetheria/Lore/Nibu.md` character-note stewardship, Nibu-authored lore essays, and tangential setting fascination as separate active doctrines. That tangential fascination points at embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. The repo Face heartbeat organ is now pulsed by `GameCult Local Orchestrator`; after restarting the stack onto the new worker code, forced heartbeat job `76020f1e-0c55-424b-9e35-89ba7f77fcc3` completed without base-bot auto-posting.
|
||||
|
|
|
|||
|
|
@ -36,12 +36,16 @@ import {
|
|||
} from "@voidbot/shared";
|
||||
import {
|
||||
advanceInitiativeClockFromWallClock,
|
||||
applyActiveTurnFreeze,
|
||||
applyPendingMentionPriority,
|
||||
applySchedulerControls,
|
||||
applySemanticPressureProjection,
|
||||
consumePendingMentions,
|
||||
finalizeSchedulerTick,
|
||||
countPendingMentionsByIdentity,
|
||||
reconcileParticipants,
|
||||
reconcileInitiativeParticipants,
|
||||
recordDryRunSelection,
|
||||
recordSchedulerSkip,
|
||||
recordStaleActiveTurnRecoveries,
|
||||
recordTurnFailedToStart,
|
||||
recordTurnStarted,
|
||||
rescheduleStaleOverdueParticipants,
|
||||
|
|
@ -107,15 +111,12 @@ async function main(): Promise<void> {
|
|||
const pause = await readAgentSwarmPause();
|
||||
if (pause.paused) {
|
||||
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
|
||||
state.lastTickAt = new Date().toISOString();
|
||||
state.history.push({
|
||||
type: "skipped",
|
||||
recordSchedulerSkip({
|
||||
state,
|
||||
skippedAt: new Date(),
|
||||
reason: "agent_swarm_paused",
|
||||
skippedAt: state.lastTickAt,
|
||||
pausePath: pause.path,
|
||||
pauseReason: pause.reason,
|
||||
details: { pausePath: pause.path, pauseReason: pause.reason },
|
||||
});
|
||||
state.history = state.history.slice(-80);
|
||||
if (!dryRun) {
|
||||
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
|
||||
publishSwarmDashboardSurface();
|
||||
|
|
@ -136,13 +137,11 @@ async function main(): Promise<void> {
|
|||
|
||||
if (!config.repoFaceHeartbeats.enabled && !process.argv.includes("--force")) {
|
||||
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
|
||||
state.lastTickAt = new Date().toISOString();
|
||||
state.history.push({
|
||||
type: "skipped",
|
||||
recordSchedulerSkip({
|
||||
state,
|
||||
skippedAt: new Date(),
|
||||
reason: "repo_face_heartbeats_disabled",
|
||||
skippedAt: state.lastTickAt,
|
||||
});
|
||||
state.history = state.history.slice(-80);
|
||||
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
|
||||
publishSwarmDashboardSurface();
|
||||
return;
|
||||
|
|
@ -157,42 +156,24 @@ async function main(): Promise<void> {
|
|||
const activeTurnScan = dryRun
|
||||
? { active: new Map<string, string>(), staleRecovered: [] }
|
||||
: await scanActivePersonaTurns(config);
|
||||
for (const stale of activeTurnScan.staleRecovered) {
|
||||
state.history.push({
|
||||
type: "stale_active_turn_recovered",
|
||||
identityId: stale.identityId,
|
||||
activeJobId: stale.jobId,
|
||||
requestMessageId: stale.requestMessageId,
|
||||
jobState: stale.state,
|
||||
jobUpdatedAt: stale.updatedAt,
|
||||
ageMinutes: stale.ageMinutes,
|
||||
recoveredAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
recordStaleActiveTurnRecoveries({ state, recoveries: activeTurnScan.staleRecovered, recoveredAt: now });
|
||||
|
||||
const swarmControl = await readSwarmControlState();
|
||||
const globalHeat = swarmControl?.globalHeat ?? config.repoFaceHeartbeats.globalHeat;
|
||||
state.baseRecoveryMinutes = config.repoFaceHeartbeats.baseRecoveryMinutes;
|
||||
state.globalHeat = globalHeat;
|
||||
applySchedulerControls({ state, baseRecoveryMinutes: config.repoFaceHeartbeats.baseRecoveryMinutes, globalHeat });
|
||||
const completedThisTick = new Set<string>();
|
||||
const participantSpecs = buildParticipantSpecs(registry.identities, config.voidModerationHeartbeatEnabled);
|
||||
state.participants = reconcileParticipants({
|
||||
existing: state.participants,
|
||||
reconcileInitiativeParticipants({
|
||||
state,
|
||||
specs: participantSpecs,
|
||||
defaultChannelId: config.repoFaceHeartbeats.defaultChannelId,
|
||||
speedOverrides: config.repoFaceHeartbeats.speedOverrides,
|
||||
heatOverrides: config.repoFaceHeartbeats.heatOverrides,
|
||||
initiativeClock: state.initiativeClock,
|
||||
baseRecoveryMinutes: config.repoFaceHeartbeats.baseRecoveryMinutes,
|
||||
globalHeat,
|
||||
}).map((participant) =>
|
||||
applyActiveTurnFreeze(
|
||||
participant,
|
||||
activeTurnScan.active.get(participant.identityId),
|
||||
state,
|
||||
completedThisTick,
|
||||
),
|
||||
);
|
||||
activeTurns: activeTurnScan.active,
|
||||
completedThisTick,
|
||||
});
|
||||
rescheduleStaleOverdueParticipants(state);
|
||||
applyPendingMentionPriority(state);
|
||||
const idleCooling = await readDiscordActivitySnapshot({
|
||||
|
|
@ -287,29 +268,14 @@ async function main(): Promise<void> {
|
|||
requestMessageId: turn.requestMessageId,
|
||||
pendingMentionCount: pendingMentions.length,
|
||||
});
|
||||
if (pendingMentions.length > 0) {
|
||||
const consumedIds = new Set(pendingMentions.map((entry) => entry.id));
|
||||
state.pendingMentions = state.pendingMentions.filter((entry) => !consumedIds.has(entry.id));
|
||||
state.history.push({
|
||||
type: "pending_mentions_consumed",
|
||||
identityId: participant.identityId,
|
||||
participantKind: participant.participantKind,
|
||||
turnKind: participant.turnKind,
|
||||
activeJobId: turn.activeJobId,
|
||||
requestMessageId: turn.requestMessageId,
|
||||
consumedAt: queuedAt,
|
||||
mentionCount: pendingMentions.length,
|
||||
mentions: pendingMentions.slice(-6).map((mention) => ({
|
||||
id: mention.id,
|
||||
channelId: mention.channelId,
|
||||
messageId: mention.messageId,
|
||||
authorId: mention.authorId,
|
||||
authorName: mention.authorName,
|
||||
queuedAt: mention.queuedAt,
|
||||
visiblePrompt: collapseWhitespace(mention.visiblePrompt, 240),
|
||||
})),
|
||||
});
|
||||
}
|
||||
consumePendingMentions({
|
||||
state,
|
||||
participant,
|
||||
mentions: pendingMentions,
|
||||
consumedAt: queuedAt,
|
||||
activeJobId: turn.activeJobId,
|
||||
requestMessageId: turn.requestMessageId,
|
||||
});
|
||||
} else if (turn.failureReason) {
|
||||
recordTurnFailedToStart({
|
||||
participant,
|
||||
|
|
@ -326,8 +292,7 @@ async function main(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
state.history = state.history.slice(-80);
|
||||
state.lastTickAt = now.toISOString();
|
||||
finalizeSchedulerTick(state, now);
|
||||
if (!dryRun) {
|
||||
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
|
||||
}
|
||||
|
|
@ -431,7 +396,7 @@ async function queueRepoFaceTurn(input: {
|
|||
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> {
|
||||
const identity = input.registryIdentities.find((entry) => entry.id === input.participant.identityId);
|
||||
if (!identity) {
|
||||
return { created: false };
|
||||
return { created: false, failureReason: `No registered Persona identity exists for ${input.participant.identityId}.` };
|
||||
}
|
||||
|
||||
const preferredChannelId = newestPendingMentionChannel(input.pendingMentions);
|
||||
|
|
@ -442,12 +407,7 @@ async function queueRepoFaceTurn(input: {
|
|||
);
|
||||
const channelId = channelPlan.primaryChannelId;
|
||||
if (!channelId) {
|
||||
input.participant.status = "blocked";
|
||||
input.participant.constraints = mergeStrings(
|
||||
input.participant.constraints,
|
||||
"No CTB turn channel is configured for this Face.",
|
||||
);
|
||||
return { created: false };
|
||||
return { created: false, failureReason: "No CTB turn channel is configured for this Persona." };
|
||||
}
|
||||
|
||||
const contextBuilder = new ContextBuilder();
|
||||
|
|
|
|||
|
|
@ -12,9 +12,14 @@ import {
|
|||
advanceInitiativeClockFromWallClock,
|
||||
applyActiveTurnFreeze,
|
||||
applyPendingMentionPriority,
|
||||
applySchedulerControls,
|
||||
applySemanticPressureProjection,
|
||||
consumePendingMentions,
|
||||
finalizeSchedulerTick,
|
||||
reconcileParticipants,
|
||||
reconcileInitiativeParticipants,
|
||||
recordDryRunSelection,
|
||||
recordSchedulerSkip,
|
||||
recordTurnFailedToStart,
|
||||
recordTurnStarted,
|
||||
selectReadyParticipants,
|
||||
|
|
@ -238,6 +243,43 @@ assert.deepEqual(pressureState.participants[0].semanticInterruptReceipts, ["pres
|
|||
assert.equal(pressureState.participants[1].responsePressure, 0, "missing projections explicitly cool stale pressure");
|
||||
assert.equal(pressureState.history.at(-1)?.type, "semantic_response_interrupt", "the state owner records the semantic transition");
|
||||
|
||||
const commitState: InitiativeState = {
|
||||
initiativeClock: 20,
|
||||
participants: [participant("commit-owner", 25)],
|
||||
pendingMentions: [{ identityId: "commit-owner", id: "mention-1", visiblePrompt: " answer this " }],
|
||||
history: [],
|
||||
};
|
||||
applySchedulerControls({ state: commitState, baseRecoveryMinutes: 45, globalHeat: 1.2 });
|
||||
assert.equal((commitState as InitiativeState & { baseRecoveryMinutes: number }).baseRecoveryMinutes, 45, "scheduler controls cross one engine commit primitive");
|
||||
consumePendingMentions({
|
||||
state: commitState,
|
||||
participant: commitState.participants[0],
|
||||
mentions: commitState.pendingMentions,
|
||||
consumedAt: "2026-07-15T21:00:00.000Z",
|
||||
});
|
||||
assert.equal(commitState.pendingMentions.length, 0, "only the engine consumes pending attention after queue success");
|
||||
assert.equal((commitState.history.at(-1)?.mentions as Array<{ visiblePrompt: string }>)[0].visiblePrompt, "answer this", "the durable consumption witness is bounded and normalized");
|
||||
finalizeSchedulerTick(commitState, new Date("2026-07-15T21:01:00.000Z"));
|
||||
assert.equal(commitState.lastTickAt, "2026-07-15T21:01:00.000Z", "tick finalization belongs to the state owner");
|
||||
|
||||
const skippedState: InitiativeState = { initiativeClock: 0, participants: [], pendingMentions: [], history: [] };
|
||||
recordSchedulerSkip({ state: skippedState, skippedAt: new Date("2026-07-15T21:02:00.000Z"), reason: "paused" });
|
||||
assert.equal(skippedState.history[0].type, "skipped", "pause observations become scheduler history through an engine commit");
|
||||
|
||||
const reconciledState: InitiativeState = { initiativeClock: 20, participants: [], pendingMentions: [], history: [] };
|
||||
reconcileInitiativeParticipants({
|
||||
state: reconciledState,
|
||||
specs: [{ id: "resident", participantKind: "repo_face", turnKind: "repo_face_rumination", repoName: "Resident", displayName: "Resident", allowedChannelIds: ["aquarium"], channelSpeedMultiplier: 1 }],
|
||||
defaultChannelId: "aquarium",
|
||||
speedOverrides: {},
|
||||
heatOverrides: {},
|
||||
baseRecoveryMinutes: 30,
|
||||
globalHeat: 1,
|
||||
activeTurns: new Map(),
|
||||
completedThisTick: new Set(),
|
||||
});
|
||||
assert.deepEqual(reconciledState.participants.map((entry) => entry.identityId), ["resident"], "the engine commits reconciled participants instead of returning a second-writer patch");
|
||||
|
||||
const contextMessages = await fetchRecentDiscordMessages({
|
||||
botToken: "test-token",
|
||||
channelId: "aquarium",
|
||||
|
|
|
|||
|
|
@ -397,3 +397,4 @@
|
|||
{"ts":"2026-07-15T22:05:02Z","type":"implementation","status":"ok","note":"Semantic response-pressure sensing and initiative mutation are now separate authorities: semantic-pressure-source.ts returns embedding-backed projections, while initiative-engine.ts alone commits heat, readiness, receipts, and history."}
|
||||
{"ts":"2026-07-15T22:13:47Z","type":"implementation","status":"ok","note":"Raw Persona turn Discord acquisition now lives in turn-context-source.ts with chronological source messages, bounded attachment caching, and explicit nearby-channel failure witnesses; the heartbeat shell no longer owns Discord API or attachment-cache implementation."}
|
||||
{"ts":"2026-07-15T22:17:01Z","type":"implementation","status":"ok","note":"Bifrost governance acquisition now lives in bifrost-governance-source.ts; it alone owns the digest CLI contract and maps command/parse failures into typed topics, leaving prompt assembly with provider facts rather than process mechanics."}
|
||||
{"ts":"2026-07-15T22:19:44Z","type":"implementation","status":"ok","note":"Scheduler state now has one write authority: initiative-engine.ts owns reconciliation, controls, skip/stale witnesses, semantic pressure, mention consumption, queue outcomes, and tick finalization; the heartbeat shell has no direct state assignment or history append."}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ active_subgoals:
|
|||
- Keep the rebuilt moderation/self-state foundation on canonical CultCache `.cc` typed operations, never an editable JSON working projection.
|
||||
- The resident `apps/persona-scheduler` process inside `voidbot.service` owns repo Face and native Persona turn pulses on Yggdrasil. Its `initiative-engine.ts` owns participant reconciliation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-result commits, stale rescheduling, mention priority, rest/idle eligibility, and ready-order selection as a pure state machine. `state-store.ts` is the sole persistence/migration port: missing state is explicit, legacy state migrates with a witness, malformed state fails closed, and same-directory temporary writes promote atomically. Discord attention crosses through immutable per-mention `voidbot.persona_attention_command` CultCache inbox documents; the bot creates commands, while the scheduler alone ingests, deduplicates, commits, and acknowledges them after durable state. The bot can no longer read-modify-write the whole heartbeat state. `active-turn-source.ts` is the narrow Postgres/legacy-lock adapter: it reports active job witnesses and marks truly stale queue claims failed, but it cannot mutate initiative. `discord-activity-source.ts` owns read-only Discord human-activity observation and idle-window derivation from explicit watched channels plus scheduler history; it cannot mutate initiative, consume attention, or select work. `semantic-pressure-source.ts` embeds eligible Persona affinity cards against recent permitted human messages and returns bounded pressure projections; only `initiative-engine.ts` may turn those facts into dynamic heat, readiness, interrupt receipts, or history. `turn-context-source.ts` owns raw current-room and nearby-channel Discord acquisition plus its bounded derived attachment cache; it returns chronological evidence and explicit snapshot failures without reading Persona state, composing prompts, or queuing work. The current scheduler JSON file is tolerated legacy persistence behind the state-store port, not the target typed state shape; move that port to CultCache without reopening orchestration. The heartbeat orchestration script may project registered identities into neutral participant specs and affinity candidates and report external actuator results, but it cannot decide, persist, migrate, inspect queue liveness, poll Discord, commit semantic pressure, or overwrite initiative fields. The Windows `GameCult Local Orchestrator` no longer owns Persona scheduling, and its repo-Face task installer/wrapper have been deleted. Void moderation, mood, watchdog, and Bifrost dispatch remain separate organs pending their own daemon migration; they must not be smuggled back into the Persona scheduler.
|
||||
- `bifrost-governance-source.ts` owns the remaining Bifrost digest CLI adapter and converts command or parse failures into typed error topics. Persona prompt composition receives governance facts without knowing Bifrost process mechanics; `scripts/run-repo-face-heartbeats.ts` may not invoke the Bifrost CLI directly.
|
||||
- `initiative-engine.ts` is the only writer of scheduler state fields and history. Reconciliation, controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, attention consumption, queue transitions, and tick finalization all cross named engine commit primitives. Queue/context actuators return results and cannot patch participants or history directly.
|
||||
- Keep legacy moderation mirrors, whole-state projection commits, and deleted memory-organ compensators from re-entering the active path before adding more moderation, mood, agency, or public-lane behavior.
|
||||
- Keep public-lane limits, moderation, and provider behavior constrained enough that one 4060 Ti box does not get flayed for sport.
|
||||
- Preserve boring observability and startup discipline around Postgres, Qdrant, Ollama, Docker, backups, scheduled tasks, watchdogs, and any private local fire-signal extensions.
|
||||
|
|
|
|||
Loading…
Reference in a new issue