From ffbe3eb77d5e3ed55732f9f25852206d040f8621 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 18 May 2026 00:59:29 +0100 Subject: [PATCH] Retire duplicate reply candidates --- packages/core/src/void-self-state-service.ts | 38 +++++++++++++++++++ .../smoke-void-rumination-speech-fixture.ps1 | 36 ++++++++++++++++-- state/evidence.jsonl | 1 + 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/core/src/void-self-state-service.ts b/packages/core/src/void-self-state-service.ts index c449b1a..4bfdbc9 100644 --- a/packages/core/src/void-self-state-service.ts +++ b/packages/core/src/void-self-state-service.ts @@ -419,9 +419,47 @@ function markCandidateInterventionSpoken( intervention.spokenAt = operation.receipt.sentAt; intervention.updatedAt = operation.receipt.sentAt; } + retireSiblingCandidatesForReceipt(state.candidateInterventions, operation); state.candidateInterventions.updatedAt = operation.receipt.sentAt; } +function retireSiblingCandidatesForReceipt( + candidates: VoidCandidateInterventions, + operation: Extract, +): void { + const { receipt } = operation; + if (!receipt.replyToMessageId) { + return; + } + + for (const intervention of candidates.interventions) { + if ( + intervention.interventionId === operation.interventionId || + intervention.status !== "queued" || + intervention.spokenAt || + !candidateDeliveryTargetMatchesReceipt(intervention.deliveryTarget, receipt) + ) { + continue; + } + + intervention.status = "retired"; + intervention.retiredAt = receipt.sentAt; + intervention.updatedAt = receipt.sentAt; + intervention.tags = Array.from(new Set([...intervention.tags, "retired:duplicate reply target already answered"])); + } +} + +function candidateDeliveryTargetMatchesReceipt( + deliveryTarget: VoidCandidateInterventions["interventions"][number]["deliveryTarget"], + receipt: VoidSpeechReceipts["recentReceipts"][number], +): boolean { + return ( + deliveryTarget?.mode === "channel" && + deliveryTarget.channelId === receipt.channelId && + deliveryTarget.replyToMessageId === receipt.replyToMessageId + ); +} + function upsertBy(entries: T[], value: T, keyOf: (value: T) => string): void { const key = keyOf(value); const index = entries.findIndex((entry) => keyOf(entry) === key); diff --git a/scripts/smoke-void-rumination-speech-fixture.ps1 b/scripts/smoke-void-rumination-speech-fixture.ps1 index bc23cd9..5258a9a 100644 --- a/scripts/smoke-void-rumination-speech-fixture.ps1 +++ b/scripts/smoke-void-rumination-speech-fixture.ps1 @@ -64,6 +64,32 @@ process.stdin.on("end", () => { tags: ["fixture"], }, }, + { + operation: "queue_candidate_intervention", + intervention: { + interventionId: "fixture-duplicate-same-reply-target", + kind: "direct_reply", + status: "queued", + target: { + kind: "room", + id: "fixture-channel", + label: "Fixture channel", + }, + summary: "Duplicate same-target candidate should retire when the sibling speaks.", + draft: "This duplicate should never be delivered after the same reply target is answered.", + deliveryTarget: { + mode: "channel", + channelId: "fixture-channel-id", + replyToMessageId: "fixture-message-id", + personaName: "Void", + }, + priority: 0.7, + mustEventuallyShare: false, + createdAt: "2026-05-17T03:00:01.000Z", + updatedAt: "2026-05-17T03:00:01.000Z", + tags: ["fixture", "duplicate"], + }, + }, ]; mkdirSync(dirname(operationOutputPath), { recursive: true }); @@ -154,18 +180,22 @@ process.stdout.write(JSON.stringify({ ok: true, mode: "channel", channelId }) + if ($status.status -ne "ok") { throw "Speech fixture did not finish ok." } - if ([int]$status.proposedOperationCount -ne 1 -or [int]$status.appliedOperationCount -ne 2 -or [int]$status.deliveredCandidateCount -ne 1) { - throw "Speech fixture expected one proposal, two applied operations, and one delivered candidate." + if ([int]$status.proposedOperationCount -ne 2 -or [int]$status.appliedOperationCount -ne 3 -or [int]$status.deliveredCandidateCount -ne 1) { + throw "Speech fixture expected two proposals, three applied operations, and one delivered candidate." } $stateJson = node -e "const core=require('./packages/core/dist/index.js'); core.loadVoidSelfStateTypedDocuments({canonicalPath: process.argv[1]}).then((state)=>console.log(JSON.stringify(state))).catch((error)=>{ console.error(error); process.exit(1); })" $stateFilePath $state = $stateJson | ConvertFrom-Json - $candidate = @($state.candidateInterventions.interventions)[0] + $candidate = @($state.candidateInterventions.interventions | Where-Object { $_.interventionId -eq "fixture-parent-owned-speech" })[0] + $duplicateCandidate = @($state.candidateInterventions.interventions | Where-Object { $_.interventionId -eq "fixture-duplicate-same-reply-target" })[0] $receipt = @($state.speechReceipts.recentReceipts)[0] if ($candidate.status -ne "spoken") { throw "Speech fixture did not mark the candidate spoken." } + if ($duplicateCandidate.status -ne "retired") { + throw "Speech fixture did not retire the duplicate same-target candidate." + } if ($receipt.channelId -ne "fixture-channel-id" -or $receipt.replyToMessageId -ne "fixture-message-id") { throw "Speech fixture did not preserve the delivery receipt target." } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9b51e14..c0003ac 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -127,3 +127,4 @@ {"ts":"2026-05-17T19:15:56Z","type":"implementation","status":"ok","note":"Live-loop inspection complaints resolved: candidate speech receipts now link back to the spoken intervention, rumination report text is refreshed by the runner, watchdog Repixelizer SSH checks no longer hang indefinitely, and offsite sync recovered with a fresh remote archive."} {"ts":"2026-05-17T22:41:56Z","type":"implementation","status":"ok","note":"Persona webhook delivery now supports configured per-channel webhook URLs and preserves Discord failure details in the parent rumination runner; live probe confirms current #general blocker is Discord 403 Missing Permissions unless Manage Webhooks is granted or DISCORD_PERSONA_WEBHOOK_URL_113786069023064068 is configured."} {"ts":"2026-05-17T23:50:11Z","type":"implementation","status":"ok","note":"Persona webhook delivery now resolves avatar URLs from per-persona env keys such as DISCORD_PERSONA_AVATAR_URL_VOID, falling back to DISCORD_PERSONA_AVATAR_URL; live Void probe used the bot's Discord CDN avatar without an explicit operation-level avatar URL."} +{"ts":"2026-05-17T23:59:13Z","type":"implementation","status":"ok","note":"Repeated logo reply at 2026-05-17T23:56Z was caused by two queued candidates sharing the same Discord reply target after webhook delivery surgery. Marking one candidate spoken now retires queued sibling candidates for the same channel/reply target; speech fixture covers the duplicate-target case."}