Skip to content

fix: retain admission ownership while cancelled handler workers remain active #728

Closed

Description

@cdeust

Problem and evidence

safe_handler owns admission around asyncio.to_thread. Cancelling the awaiting task exits admission while the dispatched worker continues. An event-controlled probe with the real safe_handler and synthetic handlers returned:

{"declared_admission_budget":4,"cancelled_callers":4,"active_workers_after_cancel":4,"active_workers_after_new_request":5,"peak_active_workers":5}

No store, model or elapsed-time verdict was used. This establishes the internal boundary behavior; native MCP cancellation delivery must be verified separately.

Acceptance evidence

  • Keep work counted until completion or a documented cooperative cancellation boundary.
  • A cancelled caller cannot allow active workers to exceed the declared budget.
  • Define and report in-flight mutation status and work completing after client abandonment.
  • Exercise real MCP cancellation/disconnect with isolated stores and verify side effects and teardown.
  • Synchronize correctness cases on events rather than machine-load-dependent delays.

Scope and related work

#722 concerns recall latency. This concerns active-work ownership under cancellation.

Raised by the global Hindsight/Cortex audit. The target is a measurable local-first experience with the same supported semantics in Claude and Codex. Acceptance criteria describe a target, not current conformance. Static findings refer to the pinned source; probes identify their boundary.

Primary references

mcp_server/tool_error_handler.py:120; mcp_server/handlers/admission.py:60; hindsight-api-slim/hindsight_api/cancellation.py:1

Activity

  1. cdeust commented on Nov 5, 2026

    @cdeust
    OwnerAuthor

    Tranche 1 : #885 (Refs #728, ne ferme pas l'issue).

    • Cause racine : safe_handler rendait le créneau d'admission à la sortie de async with admit(...), donc à l'annulation de l'appelant, alors que le thread de asyncio.to_thread continuait. run_admitted libère désormais le créneau depuis le callback de fin du worker (l'appelant attend via asyncio.shield) et journalise l'issue d'un worker abandonné.
    • Preuve : la sonde de l'issue (budget 4, quatre appelants annulés, cinquième requête) est un test via le vrai safe_handler ; rouge sans le correctif (sémaphore [unlocked, value:4]), vert avec (pic 4). Gate de mutation ADR-1122 local : 0 survivant dans les fonctions modifiées.

    Tranches restantes :

    1. Statut des mutations en vol et du travail terminé après abandon du client (au-delà du log).
    2. Annulation/déconnexion MCP réelle avec stores isolés : effets de bord et teardown.
    3. Frontière d'annulation coopérative documentée pour les handlers qui peuvent s'arrêter tôt.

    Generated by Claude Code

  2. added 2 commits that reference this issue on Nov 6, 2026
  3. cdeust commented on Nov 6, 2026

    @cdeust
    OwnerAuthor

    Tranche 2 : #902 (Refs #728, ne ferme pas l'issue).

    • Cause : depuis fix(admission): a cancelled caller keeps its slot until its worker ends (#728, slice 1) #885, un worker dont l'appelant est annulé continue, et ses écritures arrivent pour un outil mutant. Seul un log en gardait la trace.
    • Changement : worker_ledger tient par outil les workers actifs, ceux qui tournent encore sans appelant et l'issue des abandonnés (completed / raised / cancelled). memory_stats les renvoie sous tool_workers. Un seul callback de fin met le ledger à jour sous un seul verrou.
    • Preuve : tests sur événements via le vrai run_admitted, rouges sans le câblage. Gate de mutation ADR-1122 local à 0 survivant, y compris les 58 survivants préexistants de memory_stats.handler, tués par un test de correspondance exact.

    Tranches restantes :

    1. Annulation/déconnexion MCP réelle avec stores isolés : effets de bord et teardown.
    2. Frontière d'annulation coopérative documentée pour les handlers qui peuvent s'arrêter tôt.

    Generated by Claude Code

  4. cdeust commented on Nov 6, 2026

    @cdeust
    OwnerAuthor

    Tranche 2 : #903 (Refs #728, ne ferme pas l'issue).

    • Ce qu'elle fait : mcp_server/handlers/abandoned_work.py compte, par outil, les workers encore en vol après l'annulation de leur appelant, et l'issue de chacun de ceux qui ont fini (completed / raised / cancelled). Le log de _log_abandoned indique combien de workers abandonnés de l'outil restent en cours.
    • Preuve : 4 tests pilotés par threading.Event, rouges sans le changement de admission.py. Gate de mutation ADR-1122 local : 0 survivant dans les fonctions modifiées. 1848 tests passés sur tests_py/handlers, tests_py/server et I10.
    • Pas fait dans cette tranche : exposer ce compte à un client. L'ajouter à memory_stats met tout handler dans le gate de mutation, où 58 mutants préexistants survivent.

    Tranches restantes :

    1. Exposer abandoned_work.snapshot() dans memory_stats, après avoir renforcé test_memory_stats.py contre ces 58 survivants.
    2. Annulation/déconnexion MCP réelle avec stores isolés : effets de bord et teardown.
    3. Frontière d'annulation coopérative documentée pour les handlers qui peuvent s'arrêter tôt.

    Generated by Claude Code

  5. cdeust commented on Nov 7, 2026

    @cdeust
    OwnerAuthor

    Tranche 3 : #936 (Refs #728, ne ferme pas l'issue). Tests seuls, aucun changement de code de production.

    • Ce qu'elle prouve : un tools/call passe par le client et le serveur du SDK MCP (transport en mémoire) et par le vrai safe_handler. Il est annulé une fois par notifications/cancelled, une fois par la déconnexion du client. Dans les deux cas le worker garde son créneau d'admission jusqu'à sa fin, abandoned_work le compte puis l'enregistre completed, et son écriture SQLite arrive après que le client a cessé d'écouter. La session survit à l'appel annulé, et le teardown n'attend pas le thread et ne le tue pas.
    • Preuve : un mutant qui rend le créneau à l'annulation fait échouer les deux tests ; ils sont verts sur main, 5 exécutions de suite.
    • Pas vérifié : le store est un fichier SQLite simple, pas un store Cortex (le modèle d'embedding n'a pas pu être installé ici), et le transport n'est pas stdio avec un vrai client.

    Tranches restantes :

    1. Le même scénario avec un outil mutant réel (remember) sur un store SQLite Cortex isolé.
    2. Une frontière d'annulation coopérative documentée pour les handlers qui peuvent s'arrêter tôt.

    Generated by Claude Code

  6. cdeust commented on Nov 7, 2026

    @cdeust
    OwnerAuthor

    Tranche 4 : #943 (Refs #728, ne ferme pas l'issue). Ce sont des tests seuls, sans changement du code de production.

    • Ce qu'elle prouve : c'est le scénario de test(admission): MCP client cancel and disconnect keep the worker's slot and land its write (#728, slice 3) #936 (annulation par notifications/cancelled, puis déconnexion du client), mais avec le vrai outil remember enregistré par tool_registry_memory.register, sur le store SQLite Cortex isolé de la suite. Le seul double est une barrière sur insert_memory_exact : le handler a déjà franchi la write gate et la curation, puis il bloque juste avant l'écriture. Dans les deux cas, le worker garde le créneau remember. abandoned_work l'enregistre completed, rien n'est écrit avant la libération, et la ligne arrive ensuite avec le contenu envoyé.
    • Preuve : un mutant qui rend le créneau à l'annulation fait échouer les deux tests (assert 4 == (4 - 1)). Ils sont verts sur main, 5 exécutions de suite. 72 tests voisins passent.
    • Pas vérifié : la suite complète (installée sans torch, dont l'hôte de téléchargement est bloqué ici), un store PostgreSQL, et le transport stdio avec un vrai client.

    Tranche restante :

    1. Une frontière d'annulation coopérative documentée pour les handlers qui peuvent s'arrêter tôt.

    Les tranches 1 à 3 sont fusionnées (#885, #902/#903, #936), donc pas de hold.


    Generated by Claude Code

  7. cdeust commented on Nov 7, 2026

    @cdeust
    OwnerAuthor

    Tranche 5 : #946 (Closes #728). Documentation seule : ADR-1125.


    Generated by Claude Code

  8. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    Closed by #946, merged by the resolver (its GITHUB_TOKEN merge does not trigger GitHub's closing keywords).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions