Centralize Persona turn transitions
This commit is contained in:
parent
f85710f777
commit
eef9ba495c
6 changed files with 198 additions and 105 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue