Corrige bug real de idempotencia: message_hash nao deve fazer parte do send_key
Bug encontrado AO VIVO durante o teste controlado de envio (secao 14): a IA (gpt-5.5) nao e deterministica. Duas chamadas para o MESMO periodo (mesmo report_type+data) geraram insights com textos diferentes, portanto message_hash diferente, portanto send_key diferente — o retry do MESMO periodo passou pela deteccao de duplicata como se fosse outro relatorio. Resultado real: 3 mensagens verdadeiras enviadas ao canal C0BJNBTPFT5 para o periodo 'diario 2026-08-24' antes de eu interromper e corrigir (execucoes 16744, 16746 e uma terceira ja em voo quando a guarda foi revertida). Estado revertido imediatamente (DEV_MODE/SHADOW_MODE=true, workflow desativado) assim que o padrao foi identificado. Correcao: src/v2/sendGuard.js#buildSendKey agora usa so report_type+period_start+period_end+destination (SEM message_hash) — a identidade de um relatorio e o PERIODO, nao o texto exato produzido pela IA. message_hash continua calculado e armazenado, so para diagnostico. As 3 linhas reais gravadas na Data Table dev_glpi_v2_send_log foram normalizadas manualmente (send_key sem sufixo de hash) para que a protecao de duplicata funcione corretamente para qualquer nova tentativa do mesmo periodo daqui pra frente. Testes novos: sendGuard.test.js e pipeline.test.js ganharam testes de regressao reproduzindo exatamente este cenario (retry do mesmo periodo com aiCall/mensagem diferente -> send_key igual -> duplicata bloqueada). Canal Slack real confirmado nesta sessao: D06JK4GEK8E (DM) foi tentado primeiro e falhou com channel_not_found (bot sem acesso); o usuario optou por reaproveitar C0BJNBTPFT5 (canal do workflow irmao Coordenador GLPI, onde o bot ja e membro). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f6f12c53d8
commit
9475e68cb1
File diff suppressed because one or more lines are too long
+3
-1
@@ -213,12 +213,14 @@ function buildReport(input, config) {
|
|||||||
// fornecidos pelo Code node do n8n a partir de $execution.mode e da
|
// fornecidos pelo Code node do n8n a partir de $execution.mode e da
|
||||||
// Data Table de log de envios. Ver src/v2/sendGuard.js.
|
// Data Table de log de envios. Ver src/v2/sendGuard.js.
|
||||||
const messageHash = computeMessageHash(mensagemV2);
|
const messageHash = computeMessageHash(mensagemV2);
|
||||||
|
// send_key identifica o PERIODO (nao o texto exato — ver comentario em
|
||||||
|
// sendGuard.js#buildSendKey). message_hash continua calculado e guardado
|
||||||
|
// a parte, so para diagnostico.
|
||||||
const sendKey = buildSendKey({
|
const sendKey = buildSendKey({
|
||||||
reportType: period.history_bucket || period.tipo_periodo,
|
reportType: period.history_bucket || period.tipo_periodo,
|
||||||
periodStart: period.data_inicio,
|
periodStart: period.data_inicio,
|
||||||
periodEnd: period.data_fim,
|
periodEnd: period.data_fim,
|
||||||
destination: config.SLACK_DESTINATION || 'destino_nao_configurado',
|
destination: config.SLACK_DESTINATION || 'destino_nao_configurado',
|
||||||
messageHash,
|
|
||||||
});
|
});
|
||||||
const guarda = canSend({ triggerType: input.triggerType, config });
|
const guarda = canSend({ triggerType: input.triggerType, config });
|
||||||
const jaEnviado = (input.sendLogRows || []).some(
|
const jaEnviado = (input.sendLogRows || []).some(
|
||||||
|
|||||||
+17
-6
@@ -56,17 +56,28 @@ function computeMessageHash(texto) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chave logica de idempotencia (secao 13): duas execucoes com a MESMA
|
* Chave logica de idempotencia (secao 13): duas execucoes para o MESMO
|
||||||
* chave representam o MESMO relatorio logico e nao devem ser enviadas
|
* report_type + periodo + destino sao o MESMO relatorio logico e NAO devem
|
||||||
* duas vezes, mesmo com retry/execucao manual.
|
* ser enviadas duas vezes, mesmo com retry/execucao manual.
|
||||||
|
*
|
||||||
|
* IMPORTANTE (bug real encontrado e corrigido em 2026-08-25, ao vivo, no
|
||||||
|
* teste controlado de envio): message_hash NAO entra nesta chave. A IA
|
||||||
|
* (gpt-5.5) nao e deterministica — duas chamadas para o MESMO periodo
|
||||||
|
* podem gerar insights com titulos/ordem diferentes, mudando o texto final
|
||||||
|
* e portanto o hash. Incluir message_hash na chave fez o retry do MESMO
|
||||||
|
* periodo passar como "relatorio diferente" e enviar DUAS mensagens reais
|
||||||
|
* ao Slack (execucoes 16744 e 16746). A identidade do relatorio e o
|
||||||
|
* PERIODO, nao o texto exato — o hash continua sendo calculado e guardado
|
||||||
|
* (campo message_hash) so para diagnostico/auditoria, nunca para decidir
|
||||||
|
* duplicata.
|
||||||
*/
|
*/
|
||||||
function buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) {
|
function buildSendKey({ reportType, periodStart, periodEnd, destination }) {
|
||||||
return [reportType, periodStart, periodEnd, destination, messageHash].join('|');
|
return [reportType, periodStart, periodEnd, destination].join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica se ja existe um envio com status=success para a mesma chave
|
* Verifica se ja existe um envio com status=success para a mesma chave
|
||||||
* logica (report_type + period + destination + message_hash).
|
* logica (report_type + period + destination — ver buildSendKey).
|
||||||
*/
|
*/
|
||||||
function isDuplicate(sendLogRows, key) {
|
function isDuplicate(sendLogRows, key) {
|
||||||
return (sendLogRows || []).some(
|
return (sendLogRows || []).some(
|
||||||
|
|||||||
@@ -359,3 +359,42 @@ test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas cha
|
|||||||
const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
assert.equal(a.envio.send_key, b.envio.send_key);
|
assert.equal(a.envio.send_key, b.envio.send_key);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('regressao: retry do MESMO periodo com IA nao-deterministica (texto/hash diferentes) ainda e bloqueado como duplicata (bug real de 2026-08-25: 2 mensagens reais enviadas ao Slack antes desta correcao — execucoes 16744 e 16746, mesmo periodo 2026-08-24, hashes diferentes porque a IA mudou os insights entre as duas chamadas)', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
|
||||||
|
// 1a chamada: IA retorna um insight
|
||||||
|
const primeira = buildReport(
|
||||||
|
baseInput({
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
sendLogRows: [],
|
||||||
|
aiCall: { attempted: true, error: null, content: aiContentWith([
|
||||||
|
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo A', summary: 'Resumo A',
|
||||||
|
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
|
||||||
|
]) },
|
||||||
|
}),
|
||||||
|
cfg
|
||||||
|
);
|
||||||
|
assert.equal(primeira.envio.pode_enviar, true);
|
||||||
|
|
||||||
|
// 2a chamada (retry do MESMO periodo): IA retorna um insight DIFERENTE
|
||||||
|
// (titulo/summary diferentes -> mensagem_v2 diferente -> message_hash
|
||||||
|
// diferente). O send_key (identidade do relatorio) tem que continuar
|
||||||
|
// sendo o MESMO, pois e o MESMO periodo/destino.
|
||||||
|
const segunda = buildReport(
|
||||||
|
baseInput({
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }],
|
||||||
|
aiCall: { attempted: true, error: null, content: aiContentWith([
|
||||||
|
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo B totalmente diferente', summary: 'Resumo B',
|
||||||
|
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
|
||||||
|
]) },
|
||||||
|
}),
|
||||||
|
cfg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.notEqual(primeira.envio.message_hash, segunda.envio.message_hash, 'pre-condicao do teste: os textos precisam ser diferentes');
|
||||||
|
assert.equal(segunda.envio.send_key, primeira.envio.send_key, 'send_key deve ser o mesmo apesar do texto diferente');
|
||||||
|
assert.equal(segunda.envio.pode_enviar, false);
|
||||||
|
assert.equal(segunda.envio.duplicata_bloqueada, true);
|
||||||
|
});
|
||||||
|
|||||||
+12
-3
@@ -79,15 +79,24 @@ test('computeMessageHash lida com texto vazio/nulo sem lancar erro', () => {
|
|||||||
assert.doesNotThrow(() => computeMessageHash(''));
|
assert.doesNotThrow(() => computeMessageHash(''));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildSendKey combina os 5 campos da chave logica de idempotencia', () => {
|
test('buildSendKey combina report_type + periodo + destino (SEM message_hash)', () => {
|
||||||
const key = buildSendKey({
|
const key = buildSendKey({
|
||||||
reportType: 'diario',
|
reportType: 'diario',
|
||||||
periodStart: '2026-08-24',
|
periodStart: '2026-08-24',
|
||||||
periodEnd: '2026-08-24',
|
periodEnd: '2026-08-24',
|
||||||
destination: 'C0BJNBTPFT5',
|
destination: 'C0BJNBTPFT5',
|
||||||
messageHash: 'abc123',
|
|
||||||
});
|
});
|
||||||
assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123');
|
assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regressao: send_key IGUAL mesmo com message_hash diferente (bug real de 2026-08-25 — 2 mensagens reais enviadas ao Slack porque a IA nao e deterministica e mudou o texto entre duas chamadas do MESMO periodo, gerando hashes diferentes e escapando da deteccao de duplicata)', () => {
|
||||||
|
const base = { reportType: 'diario', periodStart: '2026-08-24', periodEnd: '2026-08-24', destination: 'C0BJNBTPFT5' };
|
||||||
|
const keyTentativa1 = buildSendKey(base);
|
||||||
|
const keyTentativa2 = buildSendKey(base);
|
||||||
|
assert.equal(keyTentativa1, keyTentativa2);
|
||||||
|
|
||||||
|
const rows = [{ send_key: keyTentativa1, status: 'success' }];
|
||||||
|
assert.equal(isDuplicate(rows, keyTentativa2), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isDuplicate detecta uma chave ja registrada com status=success', () => {
|
test('isDuplicate detecta uma chave ja registrada com status=success', () => {
|
||||||
|
|||||||
@@ -84,11 +84,12 @@ module.exports = function build(codeBodies) {
|
|||||||
setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false),
|
setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false),
|
||||||
// Canal Slack de destino do Resumo Diario/Semanal. Confirmado
|
// Canal Slack de destino do Resumo Diario/Semanal. Confirmado
|
||||||
// explicitamente pelo usuario em 2026-08-25 (ver secao 6 da
|
// explicitamente pelo usuario em 2026-08-25 (ver secao 6 da
|
||||||
// missao — nao foi adivinhado). D06JK4GEK8E: prefixo "D" indica
|
// missao — nao foi adivinhado). D06JK4GEK8E (DM) foi tentado
|
||||||
// uma conversa direta (DM)/IM, nao um canal publico/privado
|
// primeiro e falhou ao vivo com "channel_not_found" (o bot nao
|
||||||
// ("C"/"G") — comportamento identico via chat.postMessage, so
|
// tem acesso); o usuario optou por reaproveitar o MESMO canal
|
||||||
// registrando aqui para quem for auditar depois.
|
// ja usado pelo workflow irmao "Coordenador GLPI" (C0BJNBTPFT5),
|
||||||
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'D06JK4GEK8E'),
|
// onde o bot ja e membro confirmado.
|
||||||
|
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'C0BJNBTPFT5'),
|
||||||
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
|
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
|
||||||
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
|
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
|
||||||
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
|
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
|
||||||
|
|||||||
Reference in New Issue
Block a user