fix(v2): CRITICAL -- deterministic tie-break in Top10 selection (real duplicate-send root cause)

Found running the real double-send idempotency proof the mission
explicitly required: two executions seconds apart, essentially unchanged
GLPI data, produced TWO DIFFERENT Top10 compositions -- and since the
idempotency guard is keyed on the exact message hash, both were treated
as legitimately different digests and BOTH sent real Slack messages.

Root cause: logic/diversity.js's selectTop() sorted candidates by score
descending using Array.sort, which is stable but has no secondary key.
Many real candidates tie on score (e.g. several ACTIONABLE_NOW tickets
at 260 or 247.6 points), so ties were broken purely by whatever order
$input.all() happened to deliver items in -- and n8n does NOT guarantee
relative item order across parallel branches that re-merge (documented
elsewhere in this project's history: cache/ia/fallback classification
branches converge at Merge - Consolidar Classificacoes before reaching
here). Different arrival order -> different tie-break -> different
Top10 -> different message -> both "different enough" to bypass
idempotency, even though nothing meaningful changed in GLPI.

Fixed with a deterministic secondary sort key (ticket.id, ascending)
that only applies when scores are exactly equal -- doesn't change WHO
wins (still purely score-driven), only makes ties reproducible run to
run. This is a determinism fix, not a ranking/weight change (mission
section 21 constraint respected).

Added a regression test that reproduces the exact real-world scenario:
the same 12 candidates (all tied at score 200), fed in two different
orders, must produce the identical Top10.

171/171 tests passing.

Two real Slack messages were sent during this investigation (with the
user's standing test authorization) before the fix landed -- both
correctly formatted, no attribution footer, real diverse next_action
values, no literal backslash-n. Reverted SEND_ENABLED to false and
deactivated the workflow immediately after discovering the root cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-24 21:59:29 -03:00
co-authored by Claude Sonnet 5
parent 124516e64a
commit b11fcb8a7f
3 changed files with 36 additions and 2 deletions
+12 -1
View File
@@ -2,7 +2,18 @@ function selectTop(scored, topN, config) {
const eligible = scored.filter((item) => !item.excludedFromTop10);
const excluded = scored.filter((item) => item.excludedFromTop10);
const sorted = [...eligible].sort((a, b) => b.score - a.score);
// BUG REAL comprovado em execucao ao vivo (missao "correcao final", secao 14): Array.sort e
// estavel, e muitos chamados empatam em score -- sem um criterio de desempate deterministico,
// a ordem de chegada de $input.all() (que o n8n NAO garante entre branches paralelos --
// cache/ia/fallback se juntam de novo antes daqui) decidia o desempate. Duas execucoes
// seguidas com os MESMOS dados produziram Top10 DIFERENTES so por causa disso, gerando duas
// mensagens reais distintas pro Slack no mesmo dia. ticket.id como criterio secundario
// garante o mesmo resultado sempre que os scores forem os mesmos -- nao muda QUEM vence
// (isso continua sendo so o score), so desempata de forma reproduzivel.
const sorted = [...eligible].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return String(a.ticket.id).localeCompare(String(b.ticket.id));
});
const groupCap = Math.max(1, Math.ceil(topN * 0.5));
const groupCounts = {};
+23
View File
@@ -25,6 +25,29 @@ test('10 criticos legitimos no mesmo grupo entram todos (diversidade nao bloquei
assert.deepEqual(selected.map((s) => s.ticket.id).sort(), scored.map((s) => s.ticket.id).sort());
});
test('BUG REAL comprovado em execucao real: mesmo conjunto de itens empatados em score, em ordens de chegada diferentes, produz o MESMO Top10 -- missao "correcao final" secao 14/15', () => {
// Simula o que aconteceu de verdade: 12 itens com o MESMO score, chegando em duas ordens
// diferentes (n8n nao garante ordem entre branches paralelos). Sem desempate deterministico
// por ticket.id, cada execucao escolhia um subconjunto de 10 diferente -> mensagens
// diferentes -> hash diferente -> idempotencia nunca detectava como "mesmo dia, mesmo digest".
const ids = [38413, 38476, 38477, 38509, 36925, 37691, 37389, 38657, 75, 38611, 38583, 38658];
const ordemA = ids.map((id) => item(id, 200, 'ranking_geral', false));
const ordemB = [...ordemA].reverse();
const resultA = selectTop(ordemA, 10, {});
const resultB = selectTop(ordemB, 10, {});
const idsA = resultA.selected.map((s) => s.ticket.id);
const idsB = resultB.selected.map((s) => s.ticket.id);
assert.deepEqual(idsA, idsB, 'a mesma composicao de candidatos deveria produzir o mesmo Top10, independente da ordem de chegada');
});
test('desempate por ticket.id nao muda QUEM vence quando os scores sao diferentes (so desempata igualdade)', () => {
const scored = [item(2, 100, 'ranking_geral', false), item(1, 200, 'ranking_geral', false)];
const { selected } = selectTop(scored, 10, {});
assert.equal(selected[0].ticket.id, 1, 'score maior continua vencendo, desempate so entra quando ha empate real');
});
test('com grupos variados, ordena por score respeitando diversidade leve', () => {
const scored = [
...Array.from({ length: 8 }, (_, i) => item(`c${i}`, 300 - i, 'critico_acionavel', false)),