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
|
### 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)
|
- **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".
|
||||||
- `HeartbeatTriggerScheduler.onTimerTick()` skips timer ticks when budget is exceeded
|
- **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.
|
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();
|
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(
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
|
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
|
||||||
);
|
);
|
||||||
@@ -4375,10 +4378,19 @@ describe("HeartbeatTriggerScheduler", () => {
|
|||||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||||
await vi.advanceTimersByTimeAsync(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(
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
createBudgetStatus({
|
createBudgetStatus({
|
||||||
budgetLimit: 1000,
|
budgetLimit: 1000,
|
||||||
@@ -4392,7 +4404,13 @@ describe("HeartbeatTriggerScheduler", () => {
|
|||||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||||
await vi.advanceTimersByTimeAsync(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 () => {
|
it("fires timer tick normally when below threshold", async () => {
|
||||||
|
|||||||
@@ -2003,20 +2003,10 @@ export class HeartbeatTriggerScheduler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budget governance: skip timer triggers for over-budget agents
|
// Budget enforcement is handled in HeartbeatMonitor.executeHeartbeat() for timer sources.
|
||||||
try {
|
// The scheduler dispatches the callback regardless of budget status so that executeHeartbeat()
|
||||||
const budgetStatus = await this.store.getBudgetStatus(agentId);
|
// can create explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
|
||||||
if (budgetStatus.isOverBudget) {
|
// This makes timer budget skips observable rather than silent drops.
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.callback(agentId, "timer", {
|
await this.callback(agentId, "timer", {
|
||||||
wakeReason: "timer",
|
wakeReason: "timer",
|
||||||
|
|||||||
Reference in New Issue
Block a user