Repository navigation
feat(messaging): caller-declared idempotency key + TTL on human-facing sends (abilityai/trinity-enterprise#665) - #3275
Merged
Conversation
…g sends (Abilityai/trinity-enterprise#665) send_message, call_user and send_group_message (Telegram, Slack) accept an optional idempotency_key + idempotency_ttl (60-86400 s, default 86400). Two sends with the same (agent, target, key) inside the TTL, from any executions, deliver once. The per-turn effect_guard (#1084) could not catch this: a recurring agent's runs are separate executions. - idempotency_service.intent_guard: scope intent:{agent}, key sha256(effect_type, target, key). The message text is never part of the key (#1422). Nested inside effect_guard for send_message/call_user; the key joins effect_guard's identity so two keys in one turn are two effects. - db/idempotency.claim(ttl_seconds, in_flight_lease_seconds): on the intent path the TTL expires only completed rows (the checking call's TTL decides) and an in_flight row is reclaimed after a 300 s lease. Other callers keep the 24 h rule. A row that vanishes between INSERT-fail and SELECT is re-claimed instead of returning NEW unrecorded. - Outcomes: completed -> sent:false, suppressed_by, first_sent_at, first_execution_id; in_flight -> retryable 409 (IntentInProgressError); failed send -> claim released. Keyless requests and responses unchanged (response_model_exclude_unset on the messages route). - Order: consent -> key -> rate limit -> deliver. - Visibility: audit event per sink, plus a Trinity-labelled system row in the first send's conversation session (scrubbed, sender_email=None). - MCP tools teach the cross-run case in their descriptions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fail-open, document late release (Abilityai/trinity-enterprise#665) - routers/voip.py: an IntentInProgressError keeps its own message (names the key, says retry) instead of the per-execution duplicate text. - intent_guard: a keyed send that went out unguarded because the store was down still reports sent: true. - db/idempotency.py + effect-idempotency.md: a stalled sender's late release() can delete the reclaimer's row, alongside the late complete(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
obasilakis
force-pushed
the
feature/ent665-send-idempotency-key
branch
from
October 6, 2026 14:11
adf9d2c to
1459347
Compare
Contributor
|
merge-train (2026-10-06): riding this train. PR body only: |
vybe
pushed a commit
that referenced
this pull request
Oct 6, 2026
vybe
pushed a commit
that referenced
this pull request
Oct 6, 2026
dev merge The merge-train's registry rebuild for #3275 took the member branch's list plus dev's new entries, so dev-side EDITS to existing entries were lost: #3276's trinity-enterprise#792 text on test_files_guardrail_bypass.py and test_ent596_skill_manager.py was reverted when #3275 landed (58c7b80). Recomputed with a per-entry three-way merge from dev before that landing: both descriptions are restored and #3276/#3273's entries return to their positions. Metadata only; no test behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vybe
added a commit
that referenced
this pull request
Oct 6, 2026
… per subject (#3246, part 1) (#3255) * feat(operator-queue): platform alert registry and sweep planner (#3246) C1 of #3246 (one pending row per platform-alert subject). Adds the stdlib-only leaf services/platform_alerts.py, which both migration tracks and the service graph will import: - Kind registry for every in-repo platform emitter, with its id prefix and lifetime class: 14 days by default (OPERATOR_PLATFORM_ALERT_LIFETIME_DAYS), a fixed 30-day net for edge-triggered kinds with a clear hook, or none for the four a person must act on. EXTERNAL_PREFIXES keeps role-drift- out; register() takes kinds from outside this module. - Snooze window after a person ends an alert: 7 days (OPERATOR_PLATFORM_ALERT_SNOOZE_DAYS), per the operator's T5 ruling. - Key belt and subject_for(): "<kind>:<key>", id-shaped keys kept, anything else sha256[:16]; event kinds have no subject. - derive_legacy_subject(): reads every pre-#3246 id shape back to its subject, or to "known kind, no subject" when the id does not say which condition (git-bloat-, rows missing their context key). Unknown, gate and external prefixes are never derived. - plan_sweep(): pure planner for the upgrade sweep. Keeps the newest pending row per (agent, subject) (created_at, then id; NULL oldest; a row that already holds a subject keeps it), ends the rest, stamps survivors, gives subject-less known kinds a lifetime stamp, stamps person-ended rows inside the snooze window, and plans nothing on its own output. Nothing calls the leaf yet; the migration (C2) and the seam (C4) do. Tests: tests/unit/test_3246_platform_alerts_leaf.py (78), including a bare-interpreter load by path. Mutation-checked: survivor min instead of max, greedy sid regex, no snooze window, and reversed prefix order each go red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): subject columns, backlog sweep and one-pending-per-subject index (#3246) C2 of #3246, both schema tracks in one migration, ordered columns → sweep → index: the partial unique index can only be created after the sweep has collapsed the duplicates an installed backlog holds. - `operator_queue.subject TEXT` + `last_seen_at TEXT` (nullable, no backfill beyond the sweep) in schema.py, tables.py and both migration tracks. - `run_platform_alert_sweep(run)` in db/migrations.py — one function both tracks call over their own connection (sqlite3 cursor / SQLAlchemy `text()`), so the survivor rule cannot drift. It hands the fetched rows to the leaf's pure `plan_sweep` (function-local import; neither track pulls the service graph into `init_database()`), stamps the newest pending row per derived subject, ends the rest as ONE batch in the ent#611 vocabulary (`cancelled` / `platform` / `superseded` / NULL email / `batch_id`) with a compare-and-set on `status='pending'`, stamps a lifetime only on known kinds whose subject cannot be derived (never merged on a guess), and stamps `subject` on person-ended rows inside the 7-day snooze window. Agent-raised rows, `gate-` rows and external prefixes never derive. - `uq_operator_queue_pending_subject` (partial: pending + subject) and `idx_operator_queue_agent_subject`, created after the sweep. - Alembic `0090_platform_alert_subjects` chained after the live head `0089_supersede_queue_flood_backlog`. Test (red first at collection — the migration did not exist): tests/unit/test_3246_platform_alert_sweep.py drives the registered entry against the pre-upgrade `schema.py` DDL holding duplicates: survivor and stamps, one batch, untouched rows, snooze stamp, a second run changes nothing, the index exists only after the run and bites on a second pending row, DDL parity with schema.py, both tracks registered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(operator-queue): locked platform-alert accessors and the platform ending (#3246) C3 of #3246 — the DB seam the platform-alert service (C4) will stand on. db/operator_queue.py: - `create_platform_item(agent, item, *, subject, max_pending_for_type)`: find → touch → count → insert in ONE `_lock_agent_for_create` transaction. A reading of a subject with a pending row updates it in place by a compare-and-set on `status='pending'` (title / question / priority / context with `seen_count`+1 / `last_seen_at` / `expires_at`); a lost CAS falls through to a fresh row, so a row a person ended is never overwritten. The find runs BEFORE the #1677 per-type count, so an update is never refused at budget. An `IntegrityError` from the partial unique index (a lock that failed open) re-finds and touches the winner. Returns `{"outcome": created|updated|refused_at_budget, "row", "changed"}`; `changed` is false on a bare repeat reading so nothing broadcasts. - `find_pending_by_subject`, `find_person_ended_by_subject` (the seam's snooze read, newest person ending at or after `since`). - `end_items_by_platform(ids, *, reason, batch_id)` — the `bulk_cancel_items` shape with `disposed_by='platform'`, NULL email, re-selected by batch id so only CAS-won rows come back. - `mark_expired`'s per-id CAS gains `expires_at < now`: a row refreshed between the candidate select and the CAS keeps its new deadline. - `_insert_values` takes keyword-only `subject` / `last_seen_at`; `_row_to_item` / `_SELECT_COLS` carry both. services/ask_service.py: `clear_platform(ids, *, reason, batch_id=None)` mirroring `expire()` — no Actor, `PLATFORM_ENDING_REASONS = (condition_cleared, superseded)` enforced, one `platform_cleared` audit row, one thin `operator_queue_cancelled` trigger per agent, observers get only the rows this call won. database.py facade for the four accessors. `test_ent329_operator_resume.py` G2 list gains the new CAS accessor. #3130 / #3220: `_own_pending_conds`, the flood guard and `create_bounded_alert_outcome` are untouched; their suites stay green. Test (red first — the accessors did not exist): tests/unit/test_3246_platform_alerts_db.py on the unit island's real SQLite: two readings → one row, bare repeat → unchanged, person-ended row never overwritten, budget refuses only with nothing to update, the index refuses a second pending row, `mark_expired` keeps a refreshed row and still expires an overdue one, the platform ending skips a person-ended row and hands observers only CAS-won rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(operator-queue): register the #3246 C2/C3 unit files (#3246) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(operator-queue): platform alert seam, reserved prefixes and the emitter ratchet (#3246) C4. `platform_alerts.observe / clear / reconcile` on top of C3's locked accessors: one pending row per (agent, subject), updated in place; the kind's lifetime as `expires_at`; the T5 rule (after a person ended a subject's row, nothing is filed for 7 days unless the priority rose or a material key changed); never raises. Imports stay function-local, so the module is still a stdlib-only leaf at import time. `skills-reconcile-`, `skills-fleet-reinject-`, `retention-guard-` and `ent615-git-token-scrub-` become reserved prefixes. Guards (test_3246_platform_alerts_registry.py): two-way prefix parity, the EMITTERS_NOT_YET_ON_SEAM ratchet (every platform emitter not yet on the seam, `create_bounded_alert_outcome` included, each naming its follow-up), the clear-site check, and the caller guard on the #3246 accessors. Also places C3's `subject` / `last_seen_at` on the machine side of the ent#715 row allowlist (not person data); test_ent715 re-pinned — it was red since C3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): agent circuit breaker reports through the platform alert seam (#3246) The dormant transition reports through `platform_alerts.observe` (kind `circuit_dormant`, keyed by agent, 30-day net) and the two places the code already knows the circuit closed — `record_success` recovering from dormant, and the admin `reset_circuit` — end the row through `platform_alerts.clear`. Leaves the #1677 allowlist and the ratchet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): system agent stale base image reports through the platform alert seam (#3246) Both #1816 alarms report through `platform_alerts.observe`, keyed by the system agent: `base_image_stale` (high) and `system_agent_start_failed` (critical), 30-day net. A newer reading updates the one pending row, so the N-workers-N-rows boot and the per-bucket start-failure rows collapse to one row each. Cleared where the code already knows: a running boot that finds the image current, and a start that adopted the rebuilt image (`image_drift`), end `base_image_stale`; a successful start (delegated or the pre-flight plain start) ends `system_agent_start_failed`. The per-process staleness cooldown stays as emission spacing. `START_FAILED_ALERT_BUCKET_SECONDS` no longer shapes the id (only its own constant test reads it). Five test_1816 tests re-pinned from the direct DB write to the seam call; the cross-process dedup test now proves one pending row on the real DB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): subscription headroom reports through the platform alert seam (#3246) Both ent#434 alerts report through `platform_alerts.observe` on the `_sub-headroom` host: a subscription keys on the same cleaned id the legacy `sub-headroom-{sid}-…` ids carried (`subject_key`), so a row the upgrade sweep re-subjected matches a new reading; the fleet-wide alert keys on `fleet`. A later reading updates the one pending row in place and a critical reading replaces the warning (`tier` is the kind's material key), which retires the documented "a 75% row still reads 75% at 92%" residual. The reset day moves from the id to the row's context (`window`). The evaluation pass had no place that named a recovery, so it now ends with a reconcile (`clear_recovered`): only a subscription MEASURED back under its threshold (`HAS_HEADROOM`) loses its row — an unassessable member keeps it — and the fleet row survives until some member is measured with room. Off the #1677 allowlist and the ratchet. ent#434's id-identity tests are re-pinned to the subject; test_433_headroom_history asserted nothing about the id shape and needed no change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): legacy skills-library adoption reports through the platform alert seam (#3246) `_record_adoption_failure` reports through `platform_alerts.observe` on the `_skills-sync` host with ONE subject per URL (`sha256(raw url)[:12]`), shared by the steady-state refusal and both failure branches. A repeat — any branch — updates the one pending row (latest message and priority, seen count) rather than filing another, so one sync never files a second row (#2744's guarantee, now stated on the subject). Severity per branch is unchanged (steady state low/info, failures high/error); the URL echo stays scrubbed. Cleared where the code knows: adoption success and a URL that already names a source end the URL's row (`_clear_adoption_alert`); every adoption pass reconciles against the current setting (`_reconcile_adoption_alerts`), so a removed or changed `skills_library_url` ends the rows it no longer backs. Off the #1677 allowlist and the ratchet, which now holds only PR-2 entries. test_2744 re-pinned from request_id to subject (test 4 now asserts the failure branches keep `high` on the URL's one subject); three ent#346 tests re-pinned from the direct DB write to the seam call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): platform-ended alerts and the seen count on the queue surfaces (#3246) A row the platform ended (disposed_by='platform', reason condition_cleared | superseded) now reads "Ended by the platform — <why>" on ResolvedCard, QueueItemDetail, /m and the Workspace ask views (one rule: utils/operatorQueue.js::queueEnding/queueEndingText) — never a person's answer, never a timeout, never the raw reason token as an operator note. An expired alert reads "nobody acted on it". client_portal/asks/service.py::_ending_of returns "platform" for such a row instead of falling through to "operator". A pending alert seen more than once shows "seen N times · last seen <when>" (queueSeenLine) on QueueCard, QueueItemDetail and /m; the Workspace asks panel never renders platform alerts, so it gets no line. Canary _PLATFORM_ALARM_SENTINELS gains _skills-sync (L-03 would otherwise read the seam's rows as orphans). Stale "person | timeout" strings corrected in dependencies.py, mcp-server types.ts and the get_my_ask description. Tests: platformAlertSurfaces.spec.js (mounted; 12/15 red with the util change reverted), test_3246_platform_alert_surfaces.py (4/7 red with the backend change reverted). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(operator-queue): platform alerts are conditions — one row per subject (#3246) Requirements §26.14 (OPS-001-PLATFORM-ALERTS), the operating-room flow (status lifecycle + a Platform alerts section), the platform_alerts.py catalog line, the subject/last_seen_at columns and the `platform` ending in the database doc, and a learnings fragment. States plainly that only four emitter families are on the seam in this PR (headroom, skills adoption, system agent, circuit breaker); the rest follow in PR 2. Corrects claims this branch made false: the headroom alert's id as its state machine (security §20.x, backend catalog), #2744's one-row-per-URL that a dismissal holds forever (skills), the base-image alarm filing one item per worker that stays until acknowledged (internal-system-agent), and disposed_by as an enum of two. OPERATOR_PLATFORM_ALERT_LIFETIME_DAYS (14) and OPERATOR_PLATFORM_ALERT_SNOOZE_DAYS (7) in .env.example and the dev, prod and hosted compose files (hosted is the prod twin, #2280 parity). tests/registry.json registers test_3246_platform_alert_surfaces.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): the upgrade sweep reads raised_by, never an id shape alone (#3246) The sweep derived a subject from the request id and never read raised_by, so an agent's own ask under a prefix that was unreserved before this branch (hosted on the agent's own name) grouped with the platform's alarm and, if newer, survived while the platform row was ended as superseded. The SELECT now takes only platform-raised rows, every write carries the same predicate, and plan_sweep skips any row with raised_by set even when handed one. The survivor stamp no longer binds a NULL into COALESCE on the SQLAlchemy track: expires_at is in the statement only when the planner set one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): the platform-alert sync broadcast names its host agent (#3246) platform_alerts._broadcast_sync carried no agent key, so the trigger reached every logged-in client (ent#467). It is now keyed by the row's host agent the way the create path's operator_queue_new is, so the scope filter delivers it only to clients who may see that agent's queue. The poller's fleet-level operator_queue_sync stays allowlisted: it is one trigger per cycle that spans agents; this one is always about one row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): pin the snooze window at the headroom emitter (#3246) Mutation check: a zero-day snooze window left every emitter test green. A warning a person just ended must not be re-filed by the next identical reading, while an escalation to critical still reaches a person. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): the #3246 refresh-vs-CAS test patched a module name, not the dict mark_expired reads (#3246) `test_a_refresh_between_select_and_cas_keeps_the_row` monkeypatched `db.operator_queue.select` by module name. Under the full suite a sibling file evicts or stubs `sys.modules["db.operator_queue"]`, so the name resolves to a re-imported or stand-in module while the `database` singleton's class still reads the original module dict — the patch never fires, the refresh never lands, and `assert state["refreshed"]` fails as `assert False` (CI seed 12345, one failure in 21,569). Alone, the file is green on every seed. Same claim, proved deterministically: patch `type(real_db._operator_queue_ops).mark_expired.__globals__["select"]`, the dict the running method looks names up in — the suite's established shape (test_1632 `_patch_engine`, test_ent611 race tests). `mark_expired` itself is unchanged: its per-id CAS already re-checks `expires_at < now`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(operator-queue): the platform ending reaches the loop from any thread, not only anyio's (#3246 merge-train) `spawn_on_loop` handled a running loop or an anyio worker thread and re-raised from anything else — "not a shape any production caller has". #3246 made it one: the headroom sweep (`subscription_recovery_service`) and the skills reconcile (`skills_sync_service`, `routers/skills.py`) end platform alerts through `asyncio.to_thread`, the loop's DEFAULT executor, which anyio does not own. The anyio portal raised, `platform_alerts._spawn` / `ask_service._ended` swallowed it, and every such ending committed with no `platform_cleared` audit row and no `operator_queue_cancelled` trigger, behind a WARNING traceback. Reproduced: loop thread OK, `anyio.to_thread` OK, `asyncio.to_thread` → RuntimeError. Fixed in the helper rather than at the call sites: a captured host loop with a `call_soon_threadsafe` fallback. `main.py::lifespan` records the loop before any boot phase, and every on-loop spawn refreshes it, so the next thread-origin caller — a `threading.Thread` a service starts itself included — inherits the fix instead of each site having to remember `anyio.to_thread.run_sync`. The task is still created ON the loop thread (`_schedule_on_loop`'s contract) and the caller never waits for the work. A closed or non-running captured loop is not a target (the private-loop footgun below), and with nothing to hop to the helper still raises loudly — never the silent no-op ent#430 removed. Pinned by `test_3246_platform_alerts_thread_origin.py`: `clear` / `observe` driven through `asyncio.to_thread` land the audit row and the trigger with no "could not schedule" warning; the spawn from the default executor and from a plain thread; the lifespan capture is first in the boot sequence; the loud failure with no loop; a closed captured loop is ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(1816): a private loop cancels and flushes what the run spawned before closing (#3246 merge-train) `test_1816_system_agent_adoption` ran `ensure_deployed` on a `new_event_loop()` and closed it with the #3246 follow-ups (audit, broadcast) still registered in `operator_resume_service._inflight` — pending tasks bound to a dead loop, or finished ones whose `_inflight.discard` callback was queued when `stop()` landed and never ran. The next test to gather `_inflight` on ITS loop failed deterministically with "The future belongs to a different loop" (`test_ent329_operator_resume::test_spawn_keeps_a_strong_reference`); CI shuffles, so it recurred. `_run` now cancels the leftover tasks (cancelled, not awaited: a follow-up may wait on a transport this island never provides), gathers them, and runs one more tick so the done callbacks drain, before closing. Verified in both file orders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(operator-queue): an open but stopped host loop is not a spawn target (#3246) Merge-train validation found the `not loop.is_running()` rung of `_live_host_loop` unpinned: deleting it left all 104 tests green. Such a loop accepts `call_soon_threadsafe` and parks the task forever. The new case captures one, expects the loud RuntimeError, and asserts nothing was queued on it. Deleting the rung now turns it red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * merge-train: restore #3276's registry descriptions reverted by the #3275 dev merge The merge-train's registry rebuild for #3275 took the member branch's list plus dev's new entries, so dev-side EDITS to existing entries were lost: #3276's trinity-enterprise#792 text on test_files_guardrail_bypass.py and test_ent596_skill_manager.py was reverted when #3275 landed (58c7b80). Recomputed with a per-entry three-way merge from dev before that landing: both descriptions are restored and #3276/#3273's entries return to their positions. Metadata only; no test behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
send_message,call_userandsend_group_message(Telegram, Slack) take an optionalidempotency_key+idempotency_ttl(60–86400 s, default 86400). The same (agent, target, key) inside the TTL, from any execution, is delivered once. The per-turneffect_guard(feat: effect-scoped idempotency keys for outbound side effects #1084) cannot catch this case: a recurring agent's runs are separate executions.success: true, sent: false, suppressed_by: "idempotency_key", first_sent_at, first_execution_id, writes an audit event, and adds aTrinity-labelledsystemnote to the first send's conversation (calls: audit only). Another run mid-send on the same key gets a retryable 409.Changes
services/idempotency_service.py—intent_guard,derive_intent_key,IntentInProgressError, TTL bounds.db/idempotency.py—claim(ttl_seconds, in_flight_lease_seconds): on the intent path the TTL expires only completed rows; an in-flight row is reclaimed after a 300 s lease. Vanished-row fallback re-claims instead of returning NEW unrecorded.services/proactive_message_service.py,services/voip_service.py,routers/{messages,voip,telegram,slack}.py— wiring. Order: consent → key → rate limit → deliver. The key joinseffect_guard's identity, so two keys in one turn are two effects.services/channel_history.py—persist_suppressed_note,record_group_suppression.models.py—IntentKeyFieldsmixin (StrictInt TTL, key charset), new optional response fields.tools/intent_key.ts(shared params + description text),messages.ts,voip.ts,channels.ts,client.ts.feature-flows/effect-idempotency.md(new section + failure modes),proactive-messaging.md,requirements/public-access.md,architecture/backend.md, one learnings fragment.Known limits (documented)
ponytail:comment names the upgrade).expires_atcolumn.Test Plan
cd tests && pytest unit/test_ent665_send_idempotency_key.py -v— 45 tests on the real schema viadb_harness(store TTL/lease, guard, race held inside the critical section, send_message end to end, request validation, messages/voip/Telegram/Slack routes incl. 409)cd src/mcp-server && npm test— 711/711 (addstools/intent_key.test.ts)tests/uniton this branch vsorigin/dev, both with-p no:randomly: identical failure sets (93 pre-existing on this machine's Python 3.11); +45 passingidempotency_keytwice across two runs; the second returnssent: falseand the Telegram chat shows one message plus theTrinitynoteFixes abilityai/trinity-enterprise#665 (cross-repo:
status-in-devis set by hand after merge)🤖 Generated with Claude Code