Extract Persona initiative scheduling organ

This commit is contained in:
Meta Vens 2026-07-15 22:23:42 +01:00
commit 1f25371c0e
7 changed files with 172 additions and 130 deletions

View file

@ -45,6 +45,13 @@ import {
type SourceMessage,
type SourceMessageAttachment,
} from "@voidbot/shared";
import {
advanceInitiativeClockFromWallClock,
applyPendingMentionPriority,
countPendingMentionsByIdentity,
rescheduleStaleOverdueParticipants,
selectReadyParticipants,
} from "../apps/persona-scheduler/src/initiative-engine";
const HEARTBEAT_SCHEMA_VERSION = REPO_FACE_HEARTBEAT_SCHEMA_VERSION;
const HEARTBEAT_COMMAND = "repo-face-rumination";
@ -1435,70 +1442,6 @@ function applyActiveTurnFreeze(
};
}
function selectReadyParticipants(
state: FaceHeartbeatState,
maxJobs: number,
completedThisTick: Set<string>,
restStates: Map<string, RepoFaceRestSnapshot>,
idleCooling: IdleCoolingSnapshot,
): FaceHeartbeatParticipant[] {
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
const eligible = state.participants
.filter((participant) => {
const pendingMentionCount = pendingMentionCounts.get(participant.identityId) ?? 0;
const restState = restStates.get(participant.identityId);
return (
participant.status === "active" &&
participant.currentLoad < 1 &&
!completedThisTick.has(participant.identityId) &&
!(
participant.participantKind === "repo_face" &&
pendingMentionCount === 0 &&
restState?.isNapping === true
)
);
});
if (eligible.length === 0) {
return [];
}
const ready = eligible
.filter((participant) => participant.nextTurnAt <= state.initiativeClock)
.sort((left, right) => {
const pendingDelta =
(pendingMentionCounts.get(right.identityId) ?? 0) -
(pendingMentionCounts.get(left.identityId) ?? 0);
if (pendingDelta !== 0) {
return pendingDelta;
}
const readyDelta = left.nextTurnAt - right.nextTurnAt;
if (readyDelta !== 0) {
return readyDelta;
}
if (right.reactionBias !== left.reactionBias) {
return right.reactionBias - left.reactionBias;
}
if (right.effectiveSpeed !== left.effectiveSpeed) {
return right.effectiveSpeed - left.effectiveSpeed;
}
return left.identityId.localeCompare(right.identityId);
});
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);
}
async function applySemanticResponsePressure(input: {
state: FaceHeartbeatState;
specs: ParticipantSpec[];
@ -1635,58 +1578,6 @@ function renderInitiativeAffinityCard(spec: ParticipantSpec): string {
].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n");
}
function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void {
const lastTickMs = Date.parse(state.lastTickAt ?? "");
if (!Number.isFinite(lastTickMs)) {
return;
}
const elapsedMinutes = (now.getTime() - lastTickMs) / 60_000;
if (elapsedMinutes <= 0) {
return;
}
const boundedElapsedMinutes = Math.min(elapsedMinutes, 60);
state.initiativeClock = round3(state.initiativeClock + boundedElapsedMinutes);
}
function rescheduleStaleOverdueParticipants(state: FaceHeartbeatState): void {
const activeParticipants = state.participants.filter((participant) => participant.status === "active");
const count = Math.max(activeParticipants.length, 1);
let rescheduledCount = 0;
activeParticipants.forEach((participant, index) => {
const staleThreshold = Math.max(participant.baseRecoveryMinutes, 15);
if (participant.nextTurnAt >= state.initiativeClock - staleThreshold) {
return;
}
participant.nextTurnAt = round3(state.initiativeClock + (participant.baseRecoveryMinutes / count) * index);
rescheduledCount += 1;
});
if (rescheduledCount > 0) {
state.history.push({
type: "wall_clock_resync",
rescheduledCount,
initiativeClock: state.initiativeClock,
});
}
}
function applyPendingMentionPriority(state: FaceHeartbeatState): void {
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
for (const participant of state.participants) {
if (
participant.status === "active" &&
participant.currentLoad < 1 &&
(pendingMentionCounts.get(participant.identityId) ?? 0) > 0
) {
participant.nextTurnAt = Math.min(participant.nextTurnAt, state.initiativeClock);
}
}
}
function pendingMentionsForParticipant(
state: FaceHeartbeatState,
identityId: string,
@ -1696,16 +1587,6 @@ function pendingMentionsForParticipant(
.sort((left, right) => Date.parse(left.queuedAt) - Date.parse(right.queuedAt));
}
function countPendingMentionsByIdentity(
pendingMentions: RepoFacePendingMention[],
): Map<string, number> {
const counts = new Map<string, number>();
for (const mention of pendingMentions) {
counts.set(mention.identityId, (counts.get(mention.identityId) ?? 0) + 1);
}
return counts;
}
async function readIdleCoolingSnapshot(input: {
config: ReturnType<typeof loadConfig>;
identities: RepoDiscordIdentity[];

View file

@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import {
advanceInitiativeClockFromWallClock,
applyPendingMentionPriority,
selectReadyParticipants,
type InitiativeParticipant,
type InitiativeState,
} from "../apps/persona-scheduler/src/initiative-engine";
function participant(identityId: string, nextTurnAt: number): InitiativeParticipant {
return {
identityId,
participantKind: "repo_face",
status: "active",
currentLoad: 0,
nextTurnAt,
baseRecoveryMinutes: 30,
reactionBias: 1,
effectiveSpeed: 1,
};
}
const state: InitiativeState = {
initiativeClock: 10,
lastTickAt: "2026-07-15T20:00:00.000Z",
participants: [participant("quiet", 9), participant("mentioned", 30), participant("napping", 9)],
pendingMentions: [{ identityId: "mentioned" }],
history: [],
};
advanceInitiativeClockFromWallClock(state, new Date("2026-07-15T20:02:30.000Z"));
assert.equal(state.initiativeClock, 12.5, "wall time advances the scheduler clock once");
applyPendingMentionPriority(state);
assert.equal(state.participants[1].nextTurnAt, 12.5, "a mention makes its participant ready without queuing it");
const selected = selectReadyParticipants(
state,
2,
new Set(),
new Map([["napping", { isNapping: true }]]),
{ enabled: false, active: false },
Date.parse("2026-07-15T20:02:30.000Z"),
);
assert.deepEqual(selected.map((entry) => entry.identityId), ["mentioned", "quiet"], "mentions lead while unmentioned naps remain asleep");
const cooled = selectReadyParticipants(
state,
2,
new Set(),
new Map(),
{ enabled: true, active: true, nextUnpromptedTurnAllowedAt: "2026-07-15T21:00:00.000Z" },
Date.parse("2026-07-15T20:02:30.000Z"),
);
assert.deepEqual(cooled.map((entry) => entry.identityId), ["mentioned"], "idle cooling suppresses unprompted turns but never pending mentions");
process.stdout.write("Persona initiative engine smoke passed.\n");