Skip to content

Commit 284589f

Browse files
bugerclaude
andcommitted
feat: add check execution throttling/debounce support
Add debounce-manager for throttling check executions and integrate it into level-dispatch. Supports configurable throttle settings per check via config types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a80c44b commit 284589f

4 files changed

Lines changed: 236 additions & 6 deletions

File tree

src/generated/config-schema.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,16 @@ export const configSchema = {
926926
description:
927927
'Tags for categorizing and filtering checks (e.g., ["local", "fast", "security"])',
928928
},
929+
debounce: {
930+
type: 'number',
931+
description:
932+
"Debounce window in milliseconds. When set, multiple invocations of this step (across concurrent engine instances) within the window are coalesced — only the last invocation actually executes after the window expires. Useful for steps that should run once after a burst of triggers settles.\n\nThe debounce key defaults to the step's fully-qualified check ID. Use `debounce_key` to group different steps under the same debounce.",
933+
},
934+
debounce_key: {
935+
type: 'string',
936+
description:
937+
'Custom debounce key. Steps sharing the same key share the same debounce window.',
938+
},
929939
criticality: {
930940
type: 'string',
931941
enum: ['external', 'internal', 'policy', 'info'],
@@ -1147,7 +1157,7 @@ export const configSchema = {
11471157
description: 'Arguments/inputs for the workflow',
11481158
},
11491159
overrides: {
1150-
$ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-62495%3E%3E',
1160+
$ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E%3E',
11511161
description: 'Override specific step configurations in the workflow',
11521162
},
11531163
output_mapping: {
@@ -1164,7 +1174,7 @@ export const configSchema = {
11641174
'Config file path - alternative to workflow ID (loads a Visor config file as workflow)',
11651175
},
11661176
workflow_overrides: {
1167-
$ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-62495%3E%3E',
1177+
$ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E%3E',
11681178
description: 'Alias for overrides - workflow step overrides (backward compatibility)',
11691179
},
11701180
ref: {
@@ -1908,7 +1918,7 @@ export const configSchema = {
19081918
description: 'Custom output name (defaults to workflow name)',
19091919
},
19101920
overrides: {
1911-
$ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-62495%3E%3E',
1921+
$ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E%3E',
19121922
description: 'Step overrides',
19131923
},
19141924
output_mapping: {
@@ -1923,14 +1933,14 @@ export const configSchema = {
19231933
'^x-': {},
19241934
},
19251935
},
1926-
'Record<string,Partial<interface-src_types_config.ts-15521-30601-src_types_config.ts-0-62495>>':
1936+
'Record<string,Partial<interface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102>>':
19271937
{
19281938
type: 'object',
19291939
additionalProperties: {
1930-
$ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-15521-30601-src_types_config.ts-0-62495%3E',
1940+
$ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E',
19311941
},
19321942
},
1933-
'Partial<interface-src_types_config.ts-15521-30601-src_types_config.ts-0-62495>': {
1943+
'Partial<interface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102>': {
19341944
type: 'object',
19351945
additionalProperties: false,
19361946
},
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { logger } from '../../logger';
2+
3+
/** Resolve effective debounce time, respecting VISOR_DEBOUNCE_OVERRIDE env var for testing */
4+
function effectiveDebounceMs(requested: number): number {
5+
const override = process.env.VISOR_DEBOUNCE_OVERRIDE;
6+
if (override !== undefined) {
7+
const ms = parseInt(override, 10);
8+
if (!isNaN(ms) && ms >= 0) return ms;
9+
}
10+
return requested;
11+
}
12+
13+
interface PendingEntry {
14+
timer: ReturnType<typeof setTimeout>;
15+
invocations: number;
16+
firstInvocationTime: number;
17+
resolve: (value: 'executed' | 'debounced') => void;
18+
reject: (err: unknown) => void;
19+
fn: () => Promise<unknown>;
20+
}
21+
22+
/**
23+
* Global debounce manager for step execution.
24+
*
25+
* When a step has `debounce: <ms>`, multiple invocations within the debounce
26+
* window are coalesced — only the LAST invocation actually executes, after
27+
* the window expires with no new invocations.
28+
*
29+
* Key: typically `workflow:stepId` — callers decide the grouping.
30+
*
31+
* Earlier invocations resolve with 'debounced' (skipped).
32+
* The final invocation resolves with 'executed' after the real execution.
33+
*/
34+
export class DebounceManager {
35+
private pending = new Map<string, PendingEntry>();
36+
/** Waiters that were superseded and should resolve as 'debounced' */
37+
private superseded = new Map<string, Array<(v: 'debounced') => void>>();
38+
39+
/**
40+
* Enqueue an execution. Returns 'executed' if this invocation won the
41+
* debounce race, or 'debounced' if it was superseded by a later one.
42+
*/
43+
enqueue(
44+
key: string,
45+
debounceMs: number,
46+
fn: () => Promise<unknown>
47+
): Promise<'executed' | 'debounced'> {
48+
const actualMs = effectiveDebounceMs(debounceMs);
49+
return new Promise<'executed' | 'debounced'>((resolve, reject) => {
50+
const existing = this.pending.get(key);
51+
52+
if (existing) {
53+
// Supersede previous invocation — it resolves as 'debounced'
54+
clearTimeout(existing.timer);
55+
// Move the previous resolve to superseded list
56+
if (!this.superseded.has(key)) {
57+
this.superseded.set(key, []);
58+
}
59+
this.superseded.get(key)!.push(existing.resolve as (v: 'debounced') => void);
60+
existing.invocations++;
61+
62+
logger.info(
63+
`[DebounceManager] ${key}: invocation #${existing.invocations}, resetting ${actualMs}ms timer`
64+
);
65+
} else {
66+
logger.info(`[DebounceManager] ${key}: first invocation, starting ${actualMs}ms debounce`);
67+
}
68+
69+
const invocations = existing ? existing.invocations : 1;
70+
const firstTime = existing ? existing.firstInvocationTime : Date.now();
71+
72+
const timer = setTimeout(async () => {
73+
this.pending.delete(key);
74+
75+
// Resolve all superseded waiters as 'debounced'
76+
const waiters = this.superseded.get(key) || [];
77+
this.superseded.delete(key);
78+
for (const w of waiters) {
79+
w('debounced');
80+
}
81+
82+
const elapsed = Date.now() - firstTime;
83+
logger.info(
84+
`[DebounceManager] ${key}: executing after ${invocations} invocation(s) over ${elapsed}ms`
85+
);
86+
87+
try {
88+
await fn();
89+
resolve('executed');
90+
} catch (err) {
91+
reject(err);
92+
}
93+
}, actualMs);
94+
95+
this.pending.set(key, {
96+
timer,
97+
invocations,
98+
firstInvocationTime: firstTime,
99+
resolve,
100+
reject,
101+
fn,
102+
});
103+
});
104+
}
105+
106+
/** Cancel a pending debounce. All waiters resolve as 'debounced'. */
107+
cancel(key: string): boolean {
108+
const entry = this.pending.get(key);
109+
if (!entry) return false;
110+
clearTimeout(entry.timer);
111+
this.pending.delete(key);
112+
entry.resolve('debounced');
113+
const waiters = this.superseded.get(key) || [];
114+
this.superseded.delete(key);
115+
for (const w of waiters) {
116+
w('debounced');
117+
}
118+
return true;
119+
}
120+
121+
/** Cancel all pending debounces. */
122+
clear(): void {
123+
for (const [key] of this.pending) {
124+
this.cancel(key);
125+
}
126+
}
127+
128+
/** Number of pending debounce keys. */
129+
get size(): number {
130+
return this.pending.size;
131+
}
132+
}
133+
134+
let __instance: DebounceManager | undefined;
135+
136+
export function getDebounceManager(): DebounceManager {
137+
if (!__instance) __instance = new DebounceManager();
138+
return __instance;
139+
}
140+
141+
export function resetDebounceManager(): void {
142+
if (__instance) {
143+
__instance.clear();
144+
__instance = undefined;
145+
}
146+
}

src/state-machine/states/level-dispatch.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { FailureConditionEvaluator } from '../../failure-condition-evaluator';
3939
import { resolveWorkflowInputs } from '../context/workflow-inputs';
4040
import { executeWithSandboxRouting } from '../dispatch/sandbox-routing';
4141
import { applyPolicyGate } from '../dispatch/policy-gate';
42+
import { getDebounceManager } from '../dispatch/debounce-manager';
4243
import { createExtendedLiquid } from '../../liquid-extensions';
4344

4445
function isEngineerCheck(checkId: string): boolean {
@@ -1106,6 +1107,38 @@ async function executeCheckWithForEachItems(
11061107
wave: state.wave,
11071108
},
11081109
async span => {
1110+
// Debounce support: coalesce rapid invocations of the same step
1111+
if (checkConfig.debounce && checkConfig.debounce > 0) {
1112+
const debounceKey = checkConfig.debounce_key || checkId;
1113+
const outcome = await getDebounceManager().enqueue(
1114+
debounceKey,
1115+
checkConfig.debounce,
1116+
async () => {
1117+
const r = await executeWithSandboxRouting(
1118+
checkId,
1119+
checkConfig,
1120+
context,
1121+
prInfo,
1122+
dependencyResults,
1123+
checkConfig.timeout || checkConfig.ai?.timeout || 1800000,
1124+
() =>
1125+
provider.execute(prInfo, providerConfig, dependencyResults, executionContext)
1126+
);
1127+
try {
1128+
captureCheckOutput(span, (r as any).output);
1129+
} catch {}
1130+
return r;
1131+
}
1132+
);
1133+
if (outcome === 'debounced') {
1134+
logger.info(
1135+
`[LevelDispatch] ${checkId}: debounced (superseded by later invocation)`
1136+
);
1137+
return { issues: [], output: { debounced: true } };
1138+
}
1139+
// outcome === 'executed' — the fn above already ran
1140+
return { issues: [], output: { debounced: false } };
1141+
}
11091142
const res = await executeWithSandboxRouting(
11101143
checkId,
11111144
checkConfig,
@@ -2568,6 +2601,35 @@ async function executeSingleCheck(
25682601
wave: state.wave,
25692602
},
25702603
async span => {
2604+
// Debounce support: coalesce rapid invocations of the same step
2605+
if (checkConfig.debounce && checkConfig.debounce > 0) {
2606+
const debounceKey = checkConfig.debounce_key || checkId;
2607+
const outcome = await getDebounceManager().enqueue(
2608+
debounceKey,
2609+
checkConfig.debounce,
2610+
async () => {
2611+
const r = await executeWithSandboxRouting(
2612+
checkId,
2613+
checkConfig,
2614+
context,
2615+
prInfo,
2616+
dependencyResults,
2617+
checkConfig.timeout || checkConfig.ai?.timeout || 1800000,
2618+
() =>
2619+
provider.execute(prInfo, providerConfig, dependencyResults, executionContext)
2620+
);
2621+
try {
2622+
captureCheckOutput(span, (r as any).output);
2623+
} catch {}
2624+
return r;
2625+
}
2626+
);
2627+
if (outcome === 'debounced') {
2628+
logger.info(`[LevelDispatch] ${checkId}: debounced (superseded by later invocation)`);
2629+
return { issues: [], output: { debounced: true } };
2630+
}
2631+
return { issues: [], output: { debounced: false } };
2632+
}
25712633
const res = await executeWithSandboxRouting(
25722634
checkId,
25732635
checkConfig,

src/types/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,18 @@ export interface CheckConfig {
653653
failure_conditions?: FailureConditions;
654654
/** Tags for categorizing and filtering checks (e.g., ["local", "fast", "security"]) */
655655
tags?: string[];
656+
/**
657+
* Debounce window in milliseconds. When set, multiple invocations of this
658+
* step (across concurrent engine instances) within the window are coalesced
659+
* — only the last invocation actually executes after the window expires.
660+
* Useful for steps that should run once after a burst of triggers settles.
661+
*
662+
* The debounce key defaults to the step's fully-qualified check ID.
663+
* Use `debounce_key` to group different steps under the same debounce.
664+
*/
665+
debounce?: number;
666+
/** Custom debounce key. Steps sharing the same key share the same debounce window. */
667+
debounce_key?: string;
656668
/**
657669
* Operational criticality of this step. Drives default safety policies
658670
* (contracts, retries, loop budgets) at load time. Behavior can still be

0 commit comments

Comments
 (0)