feat(FN-2251): merge fusion/fn-2251
This commit is contained in:
@@ -482,10 +482,12 @@ Per-agent token budget tracking controls costs and prevents runaway AI spending.
|
||||
|
||||
### Enforcement Behavior
|
||||
|
||||
Budget enforcement happens at multiple points:
|
||||
Budget enforcement is centralized in `HeartbeatMonitor.executeHeartbeat()`:
|
||||
|
||||
- `HeartbeatMonitor.executeHeartbeat()` checks budget before creating sessions; skips when `isOverBudget: true` or `isOverThreshold: true` (for timer triggers)
|
||||
- `HeartbeatTriggerScheduler.onTimerTick()` skips timer ticks when budget is exceeded
|
||||
- **Timer triggers**: Budget is enforced in `executeHeartbeat()` which creates explicit run records with `budget_exhausted` or `budget_threshold_exceeded` reasons. This makes timer budget skips observable rather than silent drops — users see explicit "skipped" run records in the dashboard instead of timer ticks that appear to "not run".
|
||||
- **Assignment and on-demand triggers**: Budget is enforced in `executeHeartbeat()` with the same outcome recording. These triggers are allowed when over threshold (but not over budget) to maintain responsiveness.
|
||||
|
||||
The `HeartbeatTriggerScheduler` always dispatches timer callbacks regardless of budget status, delegating budget enforcement to the execution layer. This ensures every timer tick produces a heartbeat run record that is visible in the agent's run history.
|
||||
|
||||
Agents can be paused by budget exhaustion. Timer-triggered heartbeats skip when over threshold to avoid runaway costs, but assignment-triggered and on-demand runs may still execute for responsiveness.
|
||||
|
||||
|
||||
@@ -4367,7 +4367,10 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips timer tick when agent is over budget", async () => {
|
||||
it("dispatches timer callback even when agent is over budget (budget enforcement in executeHeartbeat)", async () => {
|
||||
// Budget checks have been moved from the scheduler to executeHeartbeat().
|
||||
// The scheduler dispatches the callback so that executeHeartbeat() can create
|
||||
// explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
|
||||
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
|
||||
);
|
||||
@@ -4375,10 +4378,19 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
// Callback IS called so executeHeartbeat() can create a run record
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips timer tick when agent is over threshold", async () => {
|
||||
it("dispatches timer callback even when agent is over threshold (budget enforcement in executeHeartbeat)", async () => {
|
||||
// Budget checks have been moved from the scheduler to executeHeartbeat().
|
||||
// The scheduler dispatches the callback so that executeHeartbeat() can create
|
||||
// explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
|
||||
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
createBudgetStatus({
|
||||
budgetLimit: 1000,
|
||||
@@ -4392,7 +4404,13 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
// Callback IS called so executeHeartbeat() can create a run record
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("fires timer tick normally when below threshold", async () => {
|
||||
|
||||
@@ -2003,20 +2003,10 @@ export class HeartbeatTriggerScheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Budget governance: skip timer triggers for over-budget agents
|
||||
try {
|
||||
const budgetStatus = await this.store.getBudgetStatus(agentId);
|
||||
if (budgetStatus.isOverBudget) {
|
||||
heartbeatLog.log(`Agent ${agentId} budget exhausted — timer tick skipped`);
|
||||
return;
|
||||
}
|
||||
if (budgetStatus.isOverThreshold) {
|
||||
heartbeatLog.log(`Agent ${agentId} over budget threshold (${budgetStatus.usagePercent}%) — timer tick skipped`);
|
||||
return;
|
||||
}
|
||||
} catch (budgetErr) {
|
||||
heartbeatLog.warn(`Timer tick budget check failed for ${agentId}: ${budgetErr instanceof Error ? budgetErr.message : String(budgetErr)} — proceeding without budget check`);
|
||||
}
|
||||
// Budget enforcement is handled in HeartbeatMonitor.executeHeartbeat() for timer sources.
|
||||
// The scheduler dispatches the callback regardless of budget status so that executeHeartbeat()
|
||||
// can create explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
|
||||
// This makes timer budget skips observable rather than silent drops.
|
||||
|
||||
await this.callback(agentId, "timer", {
|
||||
wakeReason: "timer",
|
||||
|
||||
Reference in New Issue
Block a user