Centralize Persona turn transitions

This commit is contained in:
Meta Vens 2026-07-15 22:38:23 +01:00
commit eef9ba495c
6 changed files with 198 additions and 105 deletions

View file

@ -47,9 +47,13 @@ import {
} from "@voidbot/shared";
import {
advanceInitiativeClockFromWallClock,
applyActiveTurnFreeze,
applyPendingMentionPriority,
countPendingMentionsByIdentity,
reconcileParticipants,
recordDryRunSelection,
recordTurnFailedToStart,
recordTurnStarted,
rescheduleStaleOverdueParticipants,
selectReadyParticipants,
type ParticipantSpec as SchedulerParticipantSpec,
@ -317,25 +321,8 @@ async function main(): Promise<void> {
const queuedAt = new Date().toISOString();
for (const participant of selected) {
const pendingMentions = pendingMentionsForParticipant(state, participant.identityId);
const recoveryMinutes = recoveryFor(participant);
participant.lastQueuedAt = queuedAt;
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.nextTurnAt = Math.max(state.initiativeClock, participant.nextTurnAt) + recoveryMinutes;
recordDryRunSelection(participant, state, queuedAt, pendingMentions.length);
queuedIdentityIds.push(participant.identityId);
state.history.push({
type: "dry_run_selected",
identityId: participant.identityId,
queuedAt,
initiativeClock: state.initiativeClock,
nextTurnAt: participant.nextTurnAt,
recoveryMinutes,
heat: participant.heat,
dynamicHeat: participant.dynamicHeat,
responsePressure: participant.responsePressure,
effectiveSpeed: participant.effectiveSpeed,
pendingMentionCount: pendingMentions.length,
});
}
} else if (selected.length > 0) {
const storage = await createStateStorage({
@ -362,12 +349,14 @@ async function main(): Promise<void> {
if (turn.created) {
queuedIdentityIds.push(participant.identityId);
participant.lastQueuedAt = queuedAt;
participant.activeTurnStartedAt = state.initiativeClock;
participant.activeJobId = turn.activeJobId;
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.currentLoad = 1;
recordTurnStarted({
participant,
state,
queuedAt,
activeJobId: turn.activeJobId,
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));
@ -391,38 +380,14 @@ async function main(): Promise<void> {
})),
});
}
state.history.push({
type: "queued",
identityId: participant.identityId,
participantKind: participant.participantKind,
turnKind: participant.turnKind,
activeJobId: turn.activeJobId,
requestMessageId: turn.requestMessageId,
queuedAt,
initiativeClock: state.initiativeClock,
frozen: true,
heat: participant.heat,
dynamicHeat: participant.dynamicHeat,
responsePressure: participant.responsePressure,
effectiveSpeed: participant.effectiveSpeed,
pendingMentionCount: pendingMentions.length,
});
} else if (turn.failureReason) {
participant.currentLoad = 0;
state.history.push({
type: "turn_failed_to_start",
identityId: participant.identityId,
participantKind: participant.participantKind,
turnKind: participant.turnKind,
recordTurnFailedToStart({
participant,
state,
queuedAt,
activeJobId: turn.activeJobId,
requestMessageId: turn.requestMessageId,
queuedAt,
initiativeClock: state.initiativeClock,
reason: turn.failureReason,
heat: participant.heat,
dynamicHeat: participant.dynamicHeat,
responsePressure: participant.responsePressure,
effectiveSpeed: participant.effectiveSpeed,
});
}
}
@ -1307,51 +1272,6 @@ async function readRecentLock(path: string, maxAgeMinutes: number): Promise<bool
}
}
function applyActiveTurnFreeze(
participant: FaceHeartbeatParticipant,
activeJobId: string | undefined,
state: FaceHeartbeatState,
completedThisTick: Set<string>,
): FaceHeartbeatParticipant {
if (activeJobId) {
return {
...participant,
currentLoad: 1,
activeJobId,
activeTurnStartedAt: participant.activeTurnStartedAt ?? participant.lastTurnAt ?? state.initiativeClock,
};
}
if (participant.currentLoad >= 1 || participant.activeTurnStartedAt !== undefined || participant.activeJobId) {
const completedTurnStartedAt = participant.activeTurnStartedAt ?? participant.lastTurnAt ?? state.initiativeClock;
const unfrozen = {
...participant,
currentLoad: 0,
activeTurnStartedAt: undefined,
activeJobId: undefined,
};
const recoveryMinutes = recoveryFor(unfrozen);
unfrozen.nextTurnAt = Math.max(state.initiativeClock, completedTurnStartedAt) + recoveryMinutes;
completedThisTick.add(participant.identityId);
state.history.push({
type: "turn_completed",
identityId: participant.identityId,
completedAtClock: state.initiativeClock,
startedAtClock: completedTurnStartedAt,
nextTurnAt: unfrozen.nextTurnAt,
recoveryMinutes,
heat: participant.heat,
effectiveSpeed: participant.effectiveSpeed,
});
return unfrozen;
}
return {
...participant,
currentLoad: 0,
};
}
async function applySemanticResponsePressure(input: {
state: FaceHeartbeatState;
specs: ParticipantSpec[];
@ -5324,11 +5244,6 @@ function projectCharacterDescription(description: string | undefined): string |
.join(" ");
}
function recoveryFor(participant: FaceHeartbeatParticipant): number {
const loadPenalty = 1 + participant.currentLoad * 0.75;
return (participant.baseRecoveryMinutes * loadPenalty) / Math.max(participant.effectiveSpeed, 0.1);
}
async function readSwarmControlState(): Promise<{ globalHeat: number } | null> {
const controlStorePath = resolve(
process.env.VOIDBOT_SWARM_CONTROL_STORE || ".voidbot/private/swarm-controls.cc",

View file

@ -2,8 +2,12 @@ import assert from "node:assert/strict";
import {
advanceInitiativeClockFromWallClock,
applyActiveTurnFreeze,
applyPendingMentionPriority,
reconcileParticipants,
recordDryRunSelection,
recordTurnFailedToStart,
recordTurnStarted,
selectReadyParticipants,
type InitiativeParticipant,
type InitiativeState,
@ -104,4 +108,46 @@ assert.equal(reconciled[0].initiativeSpeed, 3, "operator speed and channel multi
assert.equal(reconciled[0].heat, 0.6, "global and identity heat compose in the scheduler owner");
assert.equal(reconciled[1].status, "blocked", "a new Face without any mouth fails closed");
const freezeState: InitiativeState = {
initiativeClock: 50,
participants: [],
pendingMentions: [],
history: [],
};
const frozen = applyActiveTurnFreeze(participant("working", 40), "job-live", freezeState, new Set());
assert.equal(frozen.currentLoad, 1, "an active queue witness freezes initiative");
assert.equal(frozen.activeJobId, "job-live", "the scheduler records the external job witness without transferring ownership");
assert.equal(frozen.activeTurnStartedAt, 50, "freeze begins at the scheduler clock when no earlier start exists");
const completed = new Set<string>();
const recovered = applyActiveTurnFreeze(frozen, undefined, freezeState, completed);
assert.equal(recovered.currentLoad, 0, "loss of the active witness releases scheduler load");
assert.equal(recovered.activeJobId, undefined, "completed work cannot remain an initiative owner");
assert.equal(recovered.nextTurnAt, 80, "recovery begins after completion using scheduler-owned recovery math");
assert.deepEqual([...completed], ["working"], "a just-completed participant cannot be selected in the same tick");
assert.equal(freezeState.history.at(-1)?.type, "turn_completed", "the state machine records its own completion transition");
const queueState: InitiativeState = { initiativeClock: 70, participants: [], pendingMentions: [], history: [] };
const queued = participant("queued", 68);
recordTurnStarted({
participant: queued,
state: queueState,
queuedAt: "2026-07-15T21:00:00.000Z",
activeJobId: "job-queued",
requestMessageId: "agent-turn:queued:test",
pendingMentionCount: 2,
});
assert.equal(queued.currentLoad, 1, "queue success freezes the participant through one scheduler commit");
assert.equal(queued.queuedCount, 1, "queue success advances its counter once");
assert.equal(queueState.history.at(-1)?.type, "queued", "queue success and its history witness share one owner");
recordTurnFailedToStart({ participant: queued, state: queueState, queuedAt: "2026-07-15T21:01:00.000Z", reason: "actuator refused" });
assert.equal(queued.currentLoad, 0, "queue failure cannot leave false scheduler load");
assert.equal(queueState.history.at(-1)?.type, "turn_failed_to_start", "queue failure is recorded by the state owner");
const inspected = participant("inspected", 69);
recordDryRunSelection(inspected, queueState, "2026-07-15T21:02:00.000Z", 0);
assert.equal(inspected.nextTurnAt, 100, "dry-run selection uses the same recovery ownership without claiming active load");
assert.equal(inspected.currentLoad, 0, "inspection cannot impersonate a live job");
process.stdout.write("Persona initiative engine smoke passed.\n");