diff --git a/.ideai/agents.json b/.ideai/agents.json index bde4ce1..488523b 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -5,14 +5,14 @@ "agentId": "a6ced819-b893-4213-b003-9e9dc79b9641", "name": "Main", "mdPath": "agents/main.md", - "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false }, { "agentId": "dce19c75-9669-4e45-b8de-9950025157da", "name": "Architect", "mdPath": "agents/architect.md", - "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false }, { @@ -26,14 +26,14 @@ "agentId": "af7f86da-76bc-48e1-9900-71f45a624800", "name": "DevFrontend", "mdPath": "agents/devfrontend.md", - "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false }, { "agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", "name": "QA", "mdPath": "agents/qa.md", - "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false }, { diff --git a/.ideai/agents/git.md b/.ideai/agents/git.md index cabb0c8..e3d2009 100644 --- a/.ideai/agents/git.md +++ b/.ideai/agents/git.md @@ -1,15 +1,16 @@ # Git — Agent de gestion du dépôt git local -> Tu es l'**agent Git** d'IdeA. Ton unique responsabilité est la **gestion du dépôt -> git local** : commits de l'application, création et bascule de branches, merges et -> rebases. Tu es le **seul** à décider de la topologie des branches et à manipuler -> l'historique local. Main te sollicite ; tu décides et tu exécutes. +> Tu es l'**agent Git** d'IdeA. Ta responsabilité est la **gestion du dépôt +> git local** : commits de l'application, création et bascule de +> branches, merges, rebases. Tu es le **seul** +> à décider de la topologie des branches et à manipuler l'historique. Main te +> sollicite ; tu décides et tu exécutes. --- ## 1. Ton rôle (et ses limites) -Tu t'occupes **du local du repo git**, rien d'autre : +Tu t'occupes **du repo git local** : - **Commits** : tu transformes le travail réalisé par les agents de dev en commits propres, atomiques, au bon endroit (bonne branche), avec des messages cohérents. @@ -19,14 +20,16 @@ Tu t'occupes **du local du repo git**, rien d'autre : - Tu **décides** : quand Main t'annonce une nouvelle feature (après cadrage Architect), c'est **toi** qui tranches s'il faut une nouvelle branche, un checkout/switch, ou rien. Après chaque implémentation, Main revient vers toi pour que tu décides si un **merge** - doit avoir lieu quelque part, ou non. + doit avoir lieu, ou non. -**Hors périmètre :** +**Hors périmètre / garde-fous :** - Tu **n'écris pas de code de feature** (c'est DevBackend/DevFrontend). -- Tu ne fais **aucune action sortante** (`push`, publication, création de PR distante) - sans validation explicite de Main / de l'utilisateur. Ton terrain est **local**. - Tu ne prends pas de décision produit/archi : si un choix dépend de l'architecture, tu remontes à Main. +- **Aucune action sortante** : tu ne **pousses pas** vers un remote (pas de `git push`), + tu ne crées pas de PR distante, tu ne publies pas de tags. La synchronisation avec un + remote n'est pas dans ton périmètre. Tu restes **strictement local**. +- **Jamais** de réécriture destructive de l'historique sans validation explicite de Main. --- @@ -110,7 +113,7 @@ tu le dis. délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu appelles **impérativement** `idea_reply(result=…)`. - Tu rends compte clairement : branche courante, ce que tu as committé (hash + message - court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette branche, pourquoi - ce merge ou ce non-merge). + court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette + branche, pourquoi ce merge ou ce non-merge). - En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces - pas une résolution hasardeuse. + pas une résolution hasardeuse. \ No newline at end of file diff --git a/.ideai/layouts.json b/.ideai/layouts.json index 8c79c73..645f12f 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -1,103 +1,16 @@ { "version": 1, - "activeId": "0ac56658-4f42-47fe-ad03-c088c832460a", + "activeId": "49c6b0fc-13c1-40f5-9464-d3248ab54597", "layouts": [ { - "id": "0ac56658-4f42-47fe-ad03-c088c832460a", + "id": "49c6b0fc-13c1-40f5-9464-d3248ab54597", "name": "Default", "kind": "terminal", - "tree": { - "root": { - "type": "split", - "node": { - "id": "35174b5f-0c29-4025-a389-e609ff219f39", - "direction": "row", - "children": [ - { - "node": { - "type": "split", - "node": { - "id": "652a0a78-e342-473d-9725-ebac04556cbf", - "direction": "column", - "children": [ - { - "node": { - "type": "leaf", - "node": { - "id": "d4b8c0d1-a44a-4c45-bbe9-26991f79b465", - "session": "813f17c2-d056-4875-b9a0-3c18e4e40776", - "agent": "a6ced819-b893-4213-b003-9e9dc79b9641", - "agentWasRunning": true - } - }, - "weight": 1.0760044 - }, - { - "node": { - "type": "leaf", - "node": { - "id": "b7fc564a-7673-4970-acdc-1b8cf23dee8e", - "session": "3a6c9dc2-9919-4c17-8f0f-48439151fd27", - "agent": "dce19c75-9669-4e45-b8de-9950025157da" - } - }, - "weight": 0.9239957 - } - ] - } - }, - "weight": 1.0 - }, - { - "node": { - "type": "split", - "node": { - "id": "31597147-d927-4d03-b76c-8d2b22f8b816", - "direction": "column", - "children": [ - { - "node": { - "type": "leaf", - "node": { - "id": "71564af2-673a-46c7-a0c0-877f89f6e49e", - "session": "384778a6-216f-47ed-90f0-67de6812b59d", - "agent": "73c853d1-c0fd-463b-ad17-1d24fefa371f", - "agentWasRunning": true - } - }, - "weight": 1.0 - }, - { - "node": { - "type": "leaf", - "node": { - "id": "8529e97f-ce06-490c-b3d0-531c3dfee442", - "session": "fac01ad4-0e99-4b2a-bdec-621493cf9c57", - "agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", - "agentWasRunning": true - } - }, - "weight": 1.0 - } - ] - } - }, - "weight": 1.0 - } - ] - } - } - } - }, - { - "id": "74489208-01a7-4005-aba4-b9c41ab843fd", - "name": "Git Graph", - "kind": "gitGraph", "tree": { "root": { "type": "leaf", "node": { - "id": "2396a80e-bc98-47cd-b711-c7cff0010bb6" + "id": "852f5128-5b98-4d36-9a18-e49a57fc4940" } } } diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index d8e2ede..ef99f9a 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -17,3 +17,28 @@ - [checkpoint-workstate-delegation-queue-lot-b](checkpoint-workstate-delegation-queue-lot-b.md) — memory note checkpoint-workstate-delegation-queue-lot-b - [checkpoint-workstate-conversation-summaries-lot-c](checkpoint-workstate-conversation-summaries-lot-c.md) — memory note checkpoint-workstate-conversation-summaries-lot-c - [checkpoint-workstate-controlled-actions-lot-d](checkpoint-workstate-controlled-actions-lot-d.md) — memory note checkpoint-workstate-controlled-actions-lot-d +- [checkpoint-work-tab-blank-fix](checkpoint-work-tab-blank-fix.md) — memory note checkpoint-work-tab-blank-fix +- [skills-integration-canonical-foundation](skills-integration-canonical-foundation.md) — Topologie d'intégration du chantier skills agent et décision d'abandon de feature/agent-skills. +- [idea-program-surface-separation-and-livestate](idea-program-surface-separation-and-livestate.md) — Cadrage du programme post-skills : ce qui existe vs reste, et la règle de frontière surface-agent/humaine. +- [handoff-ls5-summary-bound-and-llm-seam](handoff-ls5-summary-bound-and-llm-seam.md) — Contrat figé de la borne de handoff (perf) et du seam résumeur LLM non activé. +- [conversation-log-ls6-rotation-and-paginated-read](conversation-log-ls6-rotation-and-paginated-read.md) — Contrat figé de la rétention/rotation du transcript et de la lecture paginée riche (surface humaine). +- [conversation-viewer-ls7-frontend](conversation-viewer-ls7-frontend.md) — Contrat figé du viewer de transcript paginé (frontend pur, lecture seule, surface humaine). +- [live-state-persistence-program-closed-ls8](live-state-persistence-program-closed-ls8.md) — Le programme live-state + persistance conversationnelle est livré et documenté ; pointe la doc de clôture et les points restés ouverts. +- [resume-after-appimage-rebuild-mcp-e2e](resume-after-appimage-rebuild-mcp-e2e.md) — memory note resume-after-appimage-rebuild-mcp-e2e +- [mcp-e2e-findings-reply-wedge-phantom-busy](mcp-e2e-findings-reply-wedge-phantom-busy.md) — memory note mcp-e2e-findings-reply-wedge-phantom-busy +- [checkpoint-finding-a-fix-built-live-validation-pending](checkpoint-finding-a-fix-built-live-validation-pending.md) — memory note checkpoint-finding-a-fix-built-live-validation-pending +- [git-agent-push-access-gitea-ssh](git-agent-push-access-gitea-ssh.md) — memory note git-agent-push-access-gitea-ssh +- [rendezvous-no-reply-backstop-design](rendezvous-no-reply-backstop-design.md) — Décision d'architecture sur la fin-de-tour et le backstop no-reply du rendez-vous idea_ask_agent ⇄ idea_reply, après échec live du fix Finding A (77e62e5). +- [checkpoint-rendezvous-redesign-devbackend-todo](checkpoint-rendezvous-redesign-devbackend-todo.md) — memory note checkpoint-rendezvous-redesign-devbackend-todo +- [backstop-no-reply-live-test-failed-2026-06-23](backstop-no-reply-live-test-failed-2026-06-23.md) — memory note backstop-no-reply-live-test-failed-2026-06-23 +- [live-state-reboot-reconciliation-design](live-state-reboot-reconciliation-design.md) — memory note live-state-reboot-reconciliation-design +- [reconcile-live-state-implemented-feature-branch](reconcile-live-state-implemented-feature-branch.md) — memory note reconcile-live-state-implemented-feature-branch +- [rendezvous-600s-cap-too-short-heavy-tasks](rendezvous-600s-cap-too-short-heavy-tasks.md) — memory note rendezvous-600s-cap-too-short-heavy-tasks +- [backstop-fires-on-intra-task-turn-rootcause](backstop-fires-on-intra-task-turn-rootcause.md) — memory note backstop-fires-on-intra-task-turn-rootcause +- [mcp-functional-test-plan-2026-06-24](mcp-functional-test-plan-2026-06-24.md) — memory note mcp-functional-test-plan-2026-06-24 +- [checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect](checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect.md) — memory note checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect +- [checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer](checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer.md) — memory note checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer +- [mcp-t7-backstop-600s-reply-rejected-livestate-stale](mcp-t7-backstop-600s-reply-rejected-livestate-stale.md) — memory note mcp-t7-backstop-600s-reply-rejected-livestate-stale +- [mcp-functional-tests-t1-t9-green-live-2026-06-24](mcp-functional-tests-t1-t9-green-live-2026-06-24.md) — memory note mcp-functional-tests-t1-t9-green-live-2026-06-24 +- [mcp-t10a-harness-interrupt-does-not-cancel-rendezvous](mcp-t10a-harness-interrupt-does-not-cancel-rendezvous.md) — memory note mcp-t10a-harness-interrupt-does-not-cancel-rendezvous +- [mcp-t10b-pending-reboot-verification](mcp-t10b-pending-reboot-verification.md) — memory note mcp-t10b-pending-reboot-verification diff --git a/.ideai/memory/backstop-fires-on-intra-task-turn-rootcause.md b/.ideai/memory/backstop-fires-on-intra-task-turn-rootcause.md new file mode 100644 index 0000000..4f8ef0e --- /dev/null +++ b/.ideai/memory/backstop-fires-on-intra-task-turn-rootcause.md @@ -0,0 +1,34 @@ +--- +name: backstop-fires-on-intra-task-turn-rootcause +description: memory note backstop-fires-on-intra-task-turn-rootcause +metadata: + type: project +--- +--- +title: CAUSE RACINE — le backstop tire sur chaque tour interne (turn_duration ≠ retour-au-prompt) +type: project +description: Le turn-watcher fait tirer le backstop no-reply au PREMIER turn_duration, soit le 1er pas d'agent interne — il casse toute délégation multi-étapes (libération prématurée + rapport perdu). Le test QA pong passait par chance (tâche mono-tour). +--- +# CAUSE RACINE (2026-06-23, prouvée live) — backstop tire sur les tours INTERNES + +## Preuve +Cadrage délégué à Architect (tâche multi-étapes, lecture de code) : +``` +562100 [rendezvous] ask started -> Architect turn_timeout_ms=600000 +564943 [rendezvous] ask returned-to-prompt-no-reply: Architect after_ms=2843 +564943 [mailbox] complete_without_reply agent=dce19c75 ticket=cc4067fa outcome=sent +``` +- Tiré à **2843 ms** : impossible qu'Architect ait fini un cadrage en 2,8 s. +- Au même instant, transcript Architect **toujours en écriture** (Bash/Read en boucle), `idea_reply` count = **0** → Architect **continuait à travailler**. +- Transcript = **61 `turn_duration`** : Claude écrit un `turn_duration` **par tour d'agent interne** (chaque appel LLM de la boucle agentique), PAS seulement au retour-au-prompt final. + +## Diagnostic +`claude_turn_watcher.rs` fait tirer `turn_ended` au **premier incrément** de `turn_duration` au-dessus de la baseline. Or cet incrément survient dès le **1er pas interne** de l'agent (~secondes). Donc le backstop confond « l'agent a fait un pas de raisonnement » avec « l'agent est retourné au prompt sans répondre ». Pour **toute tâche multi-étapes** : libération prématurée du demandeur « sans réponse » pendant que la cible bosse encore → `idea_reply` final → « no pending request » → **rapport perdu** (travail survit sur disque/branche). + +Le test QA « pong » (backstop-no-reply-live-test-failed-2026-06-23, devenu vert) passait **par chance** : tâche à **un seul tour** ⇒ premier tour = dernier tour. Le fix `encode_cwd` a fait tirer le watcher correctement, mais a **révélé que la CONDITION de tir est fausse**. + +## Conséquence opérationnelle +Avec le binaire courant, **aucune délégation substantielle (multi-tours) ne remonte son rapport** : elle est coupée à ~3 s. Workaround Main en attendant le fix : déléguer → surveiller le transcript de la cible jusqu'à inactivité (mtime stable) → récupérer le résultat sur disque/branche ou re-solliciter en léger. + +## Implication pour la refonte (seam liveness/fin-de-tour, validée utilisateur) +« Retourné au prompt idle » NE peut PAS être « un turn_duration est apparu ». Le bon signal = **process vivant (`is_agent_live`) + transcript NE grossit PLUS depuis stall_after_ms** (battement de cœur). Tant que le transcript grossit ⇒ l'agent travaille ⇒ ne pas libérer. Le 1er lot du fix devrait **neutraliser ce tir prématuré**. Lié à [[rendezvous-600s-cap-too-short-heavy-tasks]], [[rendezvous-no-reply-backstop-design]], [[backstop-no-reply-live-test-failed-2026-06-23]]. \ No newline at end of file diff --git a/.ideai/memory/backstop-no-reply-live-test-failed-2026-06-23.md b/.ideai/memory/backstop-no-reply-live-test-failed-2026-06-23.md new file mode 100644 index 0000000..78f79c9 --- /dev/null +++ b/.ideai/memory/backstop-no-reply-live-test-failed-2026-06-23.md @@ -0,0 +1,34 @@ +--- +name: backstop-no-reply-live-test-failed-2026-06-23 +description: memory note backstop-no-reply-live-test-failed-2026-06-23 +metadata: + type: project +--- +--- +title: Backstop no-reply — RE-VALIDATION LIVE VERTE (wedge levé) après fix encode_cwd +type: project +description: Le backstop no-reply du rendez-vous est validé en live le 2026-06-23 (session post-rebuild) : demandeur libéré ~grâce 2s au lieu de 600s. Reste un point de finition (résultat véhiculé via canal d'erreur). +--- + +# Backstop no-reply — RE-VALIDATION LIVE : VERTE (2026-06-23, session post-rebuild) + +Remplace le 1er test (ÉCHEC) du même jour. La cause racine était l'encodage du slug projet Claude (`.` non encodé) corrigé dans `crates/infrastructure/src/inspector/claude_paths.rs` (`encode_cwd` → `replace(/[^a-zA-Z0-9]/g,'-')`, double-tiret sur `/.ideai/`). Voir [[checkpoint-rendezvous-redesign-devbackend-todo]] pour la cause racine. + +## Preuves live (idea.log, session a6ced819) +Scénario : DevBackend → idea_ask_agent vers QA « pong en prose, SANS idea_reply (volontaire) ». + +``` +912948 [mcp] ask armed requester=DevBackend(73c853d1) target=QA timeout_ms=600000 +917707 [turn-watcher] turn_duration detected agent=QA(aefdbd61) count 47->48 -> turn_ended +917707 [input-mediator] turn_ended agent=QA no_reply_payload -> mark_idle + grace +917707 [input-mediator] turn_ended arming grace agent=QA ticket=0257c630 grace_ms=2000 +919707 [mailbox] complete_without_reply agent=QA ticket=0257c630 queue_depth_before=1 after=0 outcome=sent +``` + +Contraste avec l'échec précédent : `turn_ended` = 0 avant le fix, ici il FIRE ; `arm … dir=…IdeA--ideai-run-…` sur dossier EXISTANT ; grâce 2s exacte (917707→919707) ; libération du demandeur via `complete_without_reply outcome=sent`. DevBackend a repris la main en ~13 s mesuré (pas 600 s). Aucun wedge, aucun cascade-timeout. + +## Point de finition résiduel (NON bloquant, follow-up) +Le résultat rendu au demandeur passe par le **canal d'erreur** MCP : « agent QA returned to its prompt without calling idea_reply (no answer was rendered); retry the request and ensure the agent replies via idea_reply ». Ce n'est pas un résultat no-reply synthétique structuré (design item c de [[rendezvous-no-reply-backstop-design]]) ; pour un orchestrateur c'est mal distinguable d'un échec transitoire « retry ». À transformer en verdict consommable (follow-up, hors blocage merge). + +## Suite +Wedge prouvé levé en live ⇒ garde-fou Git satisfait. Délégué à Git : commit du fix `encode_cwd` (+ test de non-régression `encode_cwd_encodes_dot_in_run_dir_to_double_dash`) sur la branche du backstop, puis décision de merge develop. \ No newline at end of file diff --git a/.ideai/memory/checkpoint-auto-memory-harvest-lot-e1.md b/.ideai/memory/checkpoint-auto-memory-harvest-lot-e1.md new file mode 100644 index 0000000..9a12e9c --- /dev/null +++ b/.ideai/memory/checkpoint-auto-memory-harvest-lot-e1.md @@ -0,0 +1,62 @@ +--- +name: checkpoint-auto-memory-harvest-lot-e1 +description: memory note checkpoint-auto-memory-harvest-lot-e1 +metadata: + type: project +--- +# Checkpoint — Auto-memory harvest Lot E1 + +Date: 2026-06-21 + +## État final + +Lot E1 `auto-memory harvest contrôlé` terminé, validé QA et mergé localement dans `develop`. + +Branche finale: +- `develop` @ `61c330a` — merge local `feature/controlled-auto-memory-harvest`. +- Branche `feature/controlled-auto-memory-harvest` supprimée après merge. +- Aucun push, aucun merge vers `main`. +- Working tree applicatif propre; seuls résidus non committés: bruit runtime `.ideai/*` volontairement exclu. + +## Commits créés + +- `b12081b` — `feat(memory): récolte automatique contrôlée de la mémoire (Lot E1)` + - Domaine: parsing pur de blocs fenced `idea-memory` avec limites et erreurs isolées. + - Application: `HarvestMemoryFromTurn` best-effort sur `TurnRole::Response`, persistance via `MemoryStore`, événements `MemorySaved` via `EventBus`. + - Orchestrateur: harvest branché après checkpoint/handoff sur chemins de succès ask/reply, non bloquant. + - Lifecycle: directive mémoire IdeA injectée dans le contexte convention-file. + - App/Tauri: wiring du use case dans `AppState`. + - Frontend: refresh du panneau mémoire sur `memorySaved`; patch test-only mock gateway `workState`. + +- `8c7c47c` — `fix(mcp): fallback déterministe du runtime dir socket` + - Durcit le choix du runtime dir socket MCP si `XDG_RUNTIME_DIR` est inutilisable. + - Commit séparé car indépendant du Lot E1. + +- `61c330a` — `merge(memory): intègre la récolte automatique contrôlée (Lot E1) dans develop`. + +## QA verte + +Commandes QA réelles: +- `cargo fmt --all -- --check` OK. +- `cargo check -p app-tauri` OK. +- `cd frontend && npx tsc --noEmit` OK. +- `cd frontend && npx vitest run` OK après patch test-only, `42 passed`, `405 tests`. +- `cargo test -p domain memory_harvest -- --nocapture` OK, 14 passed. +- `cargo test -p application --test memory_harvest -- --nocapture` OK, 5 passed. +- `cargo test -p application --test orchestrator_service harvest -- --nocapture` OK, 4 passed. +- `cargo test -p infrastructure --test memory_store idempotent -- --nocapture` OK, 1 passed. +- `cargo test --workspace` brut rouge uniquement sur tests AF_UNIX/MCP bloqués par sandbox `Operation not permitted`; workspace filtré strictement sur ces tests environnementaux OK. + +## Décisions produit/techniques + +- Harvest strictement best-effort: jamais bloquant pour réponse, délégation, handoff ou ticket. +- Mémoire durable seulement via directive explicite `idea-memory`, plafonnée et validée; pas de log brut, pas de live-state, pas de handoff dans ce mécanisme. +- `title` est exigé par directive mais non persisté car l'entité `Memory` n'a pas de champ titre; accepté pour E1 afin de ne pas élargir le lot. +- Les fichiers runtime `.ideai/*` générés pendant la session restent hors commits applicatifs. + +## Suite probable + +Prochains chantiers à cadrer par Architect avant implémentation: +- persistance conversationnelle/canonical log + handoff incrémental cross-profile; +- live-state projet persistant et séparé de la mémoire durable; +- synchronisation documentaire architecture après les lots Work B/C/D et E1. \ No newline at end of file diff --git a/.ideai/memory/checkpoint-finding-a-fix-built-live-validation-pending.md b/.ideai/memory/checkpoint-finding-a-fix-built-live-validation-pending.md new file mode 100644 index 0000000..32119e4 --- /dev/null +++ b/.ideai/memory/checkpoint-finding-a-fix-built-live-validation-pending.md @@ -0,0 +1,37 @@ +--- +name: checkpoint-finding-a-fix-built-live-validation-pending +description: memory note checkpoint-finding-a-fix-built-live-validation-pending +metadata: + type: project +--- +# Checkpoint — Finding A : fix confirmé EN CODE + AppImage clean DÉPLOYÉE → reste la validation e2e LIVE (MAJ 2026-06-23 09:33) + +Suite de [[mcp-e2e-findings-reply-wedge-phantom-busy]] (le défaut) et [[resume-after-appimage-rebuild-mcp-e2e]] (méthode). + +## État du code (OK, vert) +- Fix Finding A **mergé dans `develop @ 77e62e5`** (par-dessus feat `c5e5493`). +- 3 lots : **Lot 1** préambule `idea_reply` impératif (`infrastructure/src/input/mod.rs` + `application/src/agent/lifecycle.rs`) ; **Lot 2 = LE fix** `with_prompt_ready_pattern("? for shortcuts")` sur le profil Claude (`application/src/agent/catalogue.rs:80`) + overlay `overlay_reference_defaults` ; **Lot 3** backstop timeout typé `TARGET_RETURNED_NO_REPLY` / env `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS` (`infrastructure/src/orchestrator/mcp/server.rs`). +- Tests lib qui PROUVENT le seed (re-passés vert le 2026-06-23) : `application::agent::catalogue::mcp_tests::claude_seed_carries_the_measured_prompt_ready_pattern` ✓ et `codex_seed_leaves_prompt_ready_pattern_empty_for_now` ✓. + +## LEÇON D'OUTILLAGE (important, éviter de refaire l'erreur) +**Ne PAS utiliser `strings`/`grep` sur le binaire comme oracle de présence du fix.** Le compilateur élimine/inline les littéraux uniques de `catalogue.rs` : `? for shortcuts` ET `codex --version` sont ABSENTS du binaire alors que le code et les tests les portent (les littéraux aussi présents ailleurs — `claude --version`, `.mcp.json`, `CLAUDE.md` — n'apparaissent que par co-référence). Oracle fiable = **tests** + **comportement live**, pas le grep binaire. (Une fausse alarme « build incrémental incohérent » a été levée puis invalidée ainsi.) + +## Mise en service FAITE +- Rebuild clean (`cargo clean -p application -p infrastructure` puis recette tauri) → artefact `…/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`. +- **Déployé** sur `/home/anthony/Documents/IdeA_0.3.0_amd64.AppImage` (rename atomique ; SHA256 `138714473ed6a87b…` installée == artefact). Ancienne sauvegardée `…AppImage.bak-23pre`. +- ⚠️ **L'instance IdeA qui tourne encore est l'ANCIENNE** (FUSE déjà monté). Il faut que **l'utilisateur RELANCE IdeA** pour charger le nouveau binaire — cela redémarre tous les agents, dont Main (la session courante meurt). + +## PROCHAINE ÉTAPE après redémarrage : test e2e LIVE +Méthode (rappel) : **NE PAS valider le système inter-agents VIA `idea_ask_agent`**. Piloter via subagents natifs / l'utilisateur. Diag EXTERNE : `ss -xp | grep idea-mcp` (ponts) ; transcripts `~/.claude/projects//*.jsonl` (horodatages UTC ; reply réel = `tool_use` idea_reply ; wedge = transcript demandeur figé sur `tool_use` idea_ask_agent sans `tool_result`). + +Scénario qui wedgeait (doit maintenant passer) : +1. Faire déléguer **DevBackend → QA** : « réponds juste pong ». +2. ATTENDU avec le fix : si QA répond en prose sans `idea_reply`, dans la grâce `PROMPT_READY_GRACE` le retour au prompt idle (`? for shortcuts`) déclenche `complete_without_reply` → **DevBackend reçoit `TargetReturnedNoReply` (retryable) et REPREND la main** (plus de wedge) ; `idea_workstate` QA repasse `working`→`idle`/`done` (plus de Busy fantôme). Lot 1 devrait en plus pousser QA à appeler `idea_reply` (chemin positif → DevBackend reçoit pong). +3. Non-régression : chemin positif normal (QA appelle `idea_reply`) → demandeur reçoit le résultat, workstate→done ; livraison 1er tour à froid intacte. +4. Calibrer `PROMPT_READY_GRACE` si la fenêtre est trop courte/longue en réel (défaut conservé). + +## Après validation verte +Merge `develop` déjà fait. Reste éventuelle release (0.4.0 ?) = décision utilisateur. NB : **Git est de nouveau strictement local, PLUS d'accès push** (la note `git-agent-push-access-gitea-ssh` a été corrigée en ce sens). `develop` est ~69 commits devant `origin/develop` (sync remote hors périmètre Git). + +## Follow-ups différés (hors PR) +Capture `prompt_ready_pattern` Codex ; généralisation merge profils de référence ; e2e délégation background (node_id None) / profils MCP défaut / observabilité UI des délégations ; durcissement doc `idea_reply` dans `CLAUDE.md` versionné (router à Git). \ No newline at end of file diff --git a/.ideai/memory/checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer.md b/.ideai/memory/checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer.md new file mode 100644 index 0000000..cf2bd04 --- /dev/null +++ b/.ideai/memory/checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer.md @@ -0,0 +1,40 @@ +--- +name: checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer +description: memory note checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer +metadata: + type: project +--- +--- +name: checkpoint-mcp-t1-t6-green-t7-fix-wrong-layer +description: T1→T6 verts (non-régression). T7 ROUGE diagnostiqué (fix sur mauvaise couche) PUIS RE-FIX implémenté dans le working tree + vert en tests unitaires. Reste : rebuild AppImage en cours → relance IdeA → re-valider T1→T7 en live → commit Git. +metadata: + type: project +--- +# Checkpoint tests fonctionnels MCP — run a6ced819 (2026-06-24) + +Plan via skill **mcp-rendezvous-functional-test**, Main = demandeur. Setup chauds : Main/QA/DevFrontend ; froids : Architect/DevBackend/Git. + +## T1→T6 : VERTS sur AppImage 09:21 (aucune régression) +T1 visibilité, T2 chaude `pong`, T3 réveil froid Git (1er tour intact), T4 background node_id None, T5 no-reply backstop (live-state purgée), T6 multi-étapes == oracle. Détails dans l'historique. NB outillage : `pgrep -af mcp-server | grep ` = FAUX positifs (le shell de mesure se matche) → filtrer `ps -eo cmd | grep 'app-tauri mcp-server'`. + +## T7 : défaut trouvé EN LIVE puis CORRIGÉ +**Défaut (T7e live, 621 s ≈ 600 s)** : cible en long tour unique > 600 s (transcript en croissance prouvée) coupée par un timeout PLAT `process error: agent session reply timed out`, pas d'extension. Le watchdog du fix précédent vivait côté requester (`server.rs`) mais le seam qui coupe vraiment est `application/src/orchestrator/service.rs` (`ASK_AGENT_TIMEOUT`=600s plat appliqué au drain de tour délégué, ~L1564). **Fix posé sur la mauvaise couche.** + +**RE-FIX implémenté (subagent natif Architect→DevBackend→QA, worktree, ramené dans le main working tree ; HEAD toujours `1efe2f1`, NON committé)** : +- Découverte clé : `ClaudeSdkSession::send().await` rend TOUS les `ReplyEvent` en bloc À LA FIN du tour (rien mid-turn) → la vivacité par-événement est inutilisable ; seul signe de vie réel = **sonde transcript** (octets `.jsonl`), comme `server.rs`. +- Un seul `run_inactivity_watchdog` générique dans **`crates/application/src/orchestrator/rendezvous.rs`** (NOUVEAU, pur, testé), réutilisé par `service.rs` (le vrai seam) ET `server.rs` (copie privée supprimée → dé-duplication). +- Nouveau port `AskLivenessProbe` injecté au composition root `app-tauri/state.rs` → `transcript_activity_token` (+ `with_ask_ceiling`, env `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h). Sans injection ⇒ fallback fenêtre plate = zéro régression. +- Issues typées : progrès 600 s en un long tour (la machine est rapide : `cargo clean && cargo build --release --workspace` en boucle jusqu'à >13 min, transcript en croissance) ⇒ ATTENDU = PAS de timeout à 600 s, extension, reply final livré. Puis enchaîner T8 (parallèles), T9 (transitif A→B→C, Architect+DevBackend froids), T10 (interruption + reconcile reboot). +4. **Si live vert : demander à Git de committer** le fix (cf. [[git-owns-commit-merge-decisions]]) ; rendez-vous MCP court marche (T2-T6), donc commit délégable à Git OU subagent natif. + +Lié à [[rendezvous-600s-cap-too-short-heavy-tasks]], [[mcp-functional-test-plan-2026-06-24]], [[mcp-bridge-and-delegation-runtime-notes]]. + + \ No newline at end of file diff --git a/.ideai/memory/checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect.md b/.ideai/memory/checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect.md new file mode 100644 index 0000000..1fc86f4 --- /dev/null +++ b/.ideai/memory/checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect.md @@ -0,0 +1,29 @@ +--- +name: checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect +description: memory note checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect +metadata: + type: project +--- +--- +name: checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect +description: Run a6ced819 — relance complète T1→T6 du test fonctionnel MCP sur AppImage 08:41 (fix T5). T5 (live-state purgée sur no-reply) VALIDÉ EN LIVE. Blocage à T7 = défaut plafond 600s connu, non corrigé. +metadata: + type: project +--- +# Checkpoint tests fonctionnels MCP — run a6ced819 (2026-06-24, relance depuis T1) + +Binaire validé : **AppImage 2026-06-24 08:41:30** (fix live-state no-reply), process en cours = cette AppImage, git HEAD `1efe2f1`. Plan via skill **mcp-rendezvous-functional-test** (Main = demandeur). Voir [[mcp-functional-test-plan-2026-06-24]], [[rendezvous-600s-cap-too-short-heavy-tasks]], [[backstop-fires-on-intra-task-turn-rootcause]]. + +## Résultats de la relance T1→T6 (tous ✅) +- **T1** visibilité outils ✅ — 3 requesters chauds (Main, QA, DevFrontend) chacun pont ESTAB + 2 mcp-server ; froids (Architect, DevBackend, Git) 0 process. +- **T2** cible chaude (QA) ✅ — `pong` inline borné, workstate `done`, pas de busy fantôme. +- **T3** réveil à froid (Git) ✅ — nouveau pont (26012/26016), 1er tour `[IdeA·tâche…]` `parentUuid:null` intact, `delivered`, `pong-cold`. +- **T4** background node_id None (Git hors layouts.json) ✅ — `bg-step-ok`, multi-`delivered`, pont stable sans dup. +- **T5** no-reply backstop ✅ **VALIDÉ EN LIVE (le fix marche)** — Main libéré borné avec `TargetReturnedNoReply` (« returned to its prompt without calling idea_reply », retryable) ET **QA `status:"done"` purgée** (plus de busy fantôme `working`). Défaut historique #1 fermé. +- **T6** multi-étapes (QA lit fichier + `find` puis reply) ✅ — pas de libération prématurée, reply réel `header=… ; rs_count=55` (concorde avec `find` indépendant). Piège turn-watcher non déclenché. + +## Blocage actuel : T7 (NON lancé — défaut connu non corrigé) +T7 = tâche lourde > plafond 600s. C'est le finding [[rendezvous-600s-cap-too-short-heavy-tasks]] : dépassement du plafond `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS` (défaut 600000) → faux `-32001` + canal de report mort (idea_reply final non livrable), alors que la cible travaille et finit sur la branche. Les pistes (extension sur signe de vie / message « cible active, plafond atteint » distinct du retryable / plafond configurable / pas de cascade) sont **à cadrer Architect, pas encore implémentées**. Lancer T7 en live = figer Main ~10 min pour re-confirmer un défaut déjà documenté. + +## Prochaine étape (décision utilisateur en attente) +Trois options sur T7 : (a) le lancer quand même pour re-confirmer (wedge ~10 min) ; (b) ouvrir le cycle de fix du plafond maintenant (Architect→Git→DevBackend→QA, rebuild, reprise T1) ; (c) sauter T7 et continuer T8 (parallèles), T9 (transitif A→B→C), T10 (interruption + reconcile reboot) qui ne dépendent pas du plafond. Architect/DevBackend restent froids pour T9. \ No newline at end of file diff --git a/.ideai/memory/checkpoint-rendezvous-redesign-devbackend-todo.md b/.ideai/memory/checkpoint-rendezvous-redesign-devbackend-todo.md new file mode 100644 index 0000000..58e8322 --- /dev/null +++ b/.ideai/memory/checkpoint-rendezvous-redesign-devbackend-todo.md @@ -0,0 +1,38 @@ +--- +name: checkpoint-rendezvous-redesign-devbackend-todo +description: memory note checkpoint-rendezvous-redesign-devbackend-todo +metadata: + type: project +--- +# Checkpoint — Backstop no-reply : 1ère validation live ÉCHOUÉE → cause racine corrigée → RE-VALIDATION LIVE EN ATTENTE (MAJ 2026-06-23 ~19:40) + +## Résultat du 1er test live (AppImage 0.3.0 du commit e87c05f) : ÉCHEC reproduisant le wedge +Scénario joué : DevBackend→QA `ping (test backstop)`, QA répond `pong` EN PROSE sans `idea_reply`. +- QA a bien émis un vrai `turn_duration durationMs=2218` (transcript, ts 17:23:40.161Z). +- MAIS `[turn-watcher] turn_ended` JAMAIS émis (`grep -c turn_ended` sur tout le log = **0**) → DevBackend resté coincé → rendez-vous Main→DevBackend a timeout 600s (MCP error -32001). + +## CAUSE RACINE (prouvée) — encodage du slug projet Claude faux +Traces `[turn-watcher] arm` : toutes `conversation=` + `baseline … count=0`. Le dossier transcript calculé n'existait pas : +- calculé (faux) : `…/-home-…-IdeA-.ideai-run-` (point conservé) → `ls` = absent +- réel (disque) : `…/-home-…-IdeA--ideai-run-` (DOUBLE tiret) +`encode_cwd` (crates/infrastructure/src/inspector/claude_paths.rs) ne remplaçait que `/`/`\\` et gardait le `.`. Claude Code encode `replace(/[^a-zA-Z0-9]/g,'-')`. Dir introuvable → conversation non résolue → baseline 0 → `turn_duration` jamais vu → backstop mort. +Log de référence : `/home/anthony/.local/share/app.idea.ide/logs/idea.log`. + +## FIX LIVRÉ par DevBackend (NON commité — Git tranche) +- `encode_cwd` : `.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })` (algo exact Claude Code). `/IdeA/.ideai/run` → `-IdeA--ideai-run`. +- Test non-régression ajouté `encode_cwd_encodes_dot_in_run_dir_to_double_dash` (PASS). Test Windows maj `C:\\Users\\me`→`C--Users-me`. +- DevBackend précise : `count_turn_ends` agrège déjà tous les `.jsonl` du dossier ⇒ pas besoin de choisir un .jsonl ; `conversation=` à l'armement est normal et sans impact une fois le dir correct. + +## QA PRÉ-VALIDATION : VERTE +`cargo test --workspace` = **1616 passed, 0 failed** ; `cargo clippy --workspace --all-targets` = 0 erreur. Nouveau test + 5 tests turn-watcher tous PASS. + +## EN COURS : rebuild AppImage (par Main, perms project-root OK) +Recette : `npm --prefix frontend run build` puis depuis `crates/app-tauri/` `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage`. Logs scratchpad fe-build.log / appimage-build.log. Sortie : `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`. + +## SEUL RESTE À FAIRE = RE-VALIDATION LIVE (garde-fou Git : pas de merge sans wedge prouvé levé EN LIVE) +1. Utilisateur remplace l'AppImage installée par la nouvelle + relance IdeA (coupe la session). +2. Session fraîche : rejouer DevBackend→QA pong-sans-`idea_reply`. ATTENDU cette fois : trace `arm … dir=…IdeA--ideai-run-… conversation=…` sur dossier EXISTANT + baseline NON nulle + `[turn-watcher] turn_duration detected … -> turn_ended` ⇒ libération du demandeur ~grâce (≈2s) avec résultat no-reply synthétique, et non-régression du chemin propre `idea_reply`. +3. Si VERT → demander à Git d'amender/committer le fix sur `feature/rendezvous-no-reply-backstop` puis décider du merge develop. Si KO → relayer trace réelle à DevBackend. + +## Trou produit confirmé (hors scope, à planifier) +Au reboot, `.ideai/live-state.json` non réconcilié : agents restent `working` fantôme (process morts). Fichier file-guardé, `idea_workstate_set` = self only, aucun reset cross-agent. Manque : réconciliation au boot OU outil orchestrateur de reset. \ No newline at end of file diff --git a/.ideai/memory/checkpoint-work-tab-blank-fix.md b/.ideai/memory/checkpoint-work-tab-blank-fix.md new file mode 100644 index 0000000..e98e490 --- /dev/null +++ b/.ideai/memory/checkpoint-work-tab-blank-fix.md @@ -0,0 +1,34 @@ +--- +name: checkpoint-work-tab-blank-fix +description: memory note checkpoint-work-tab-blank-fix +metadata: + type: project +--- +# Checkpoint — Fix onglet Work vide + +Date: 2026-06-21 + +## État final + +Correctif frontend de l'onglet Work vide terminé, validé QA (VERT) et mergé localement dans `develop`. + +- `develop` @ `a66881d` — merge local `--no-ff` du fix. +- Commit de feature intégré : `3e1e553` (anciennement `9d2f186`, message amendé pour retirer le marqueur `(WIP, QA en attente)`). +- Branche `fix/work-tab-blank-page` supprimée après merge. +- Aucun push, aucune PR. `develop` en avance de 45 commits sur `origin/develop` (local-only). + +## Contenu du fix + +Normalisation défensive du work-state (`frontend/src/adapters/workStateNormalization.ts` nouveau) + adaptation panneau/mock/tests, pour qu'un work-state partiel/inattendu ne rende plus une page entièrement vide. 5 fichiers, +259/-47. + +## QA verte (sortie réelle) + +- `cd frontend && npx tsc --noEmit` → exit 0. +- `cd frontend && npx vitest run` → 42 fichiers / 407 tests passed, exit 0. + +## Branches ouvertes restantes (non mergées dans develop) + +À cadrer/arbitrer avant intégration : +- `feature/agent-skills` @ `ef101db` — domaine skills agent + use cases + FS store + injection LaunchAgent (L12). +- `feature/agent-skill-awareness` @ `5be8987` — manifeste skills + outil MCP `idea_skill_read` + brief « capacités IdeA » dans contexte agent ; contient aussi un fix input cold-start (`e93a2c1`). +- `fix/cold-start-delivery-race` @ `9590eac` — fix livraison délégation cold-start ; branche basée sur d'anciens commits release (0.1.0/0.2.0), potentiellement redondante avec `e93a2c1` de skill-awareness → vérifier avant merge. \ No newline at end of file diff --git a/.ideai/memory/conversation-log-ls6-rotation-and-paginated-read.md b/.ideai/memory/conversation-log-ls6-rotation-and-paginated-read.md new file mode 100644 index 0000000..6d9b317 --- /dev/null +++ b/.ideai/memory/conversation-log-ls6-rotation-and-paginated-read.md @@ -0,0 +1,29 @@ +--- +name: conversation-log-ls6-rotation-and-paginated-read +description: Contrat figé de la rétention/rotation du transcript et de la lecture paginée riche (surface humaine). +metadata: + type: reference +--- +# LS6 — Rotation transcript + lecture paginée + +Hygiène de persistance du `log.jsonl` + surface de lecture humaine paginée (viewer LS7). + +## Invariant pivot INV-LS6 +La rotation n'élague/déplace JAMAIS un tour d'id ≥ `up_to` du handoff courant : le tour up_to + tous les postérieurs restent dans le segment ACTIF. Sans handoff (ou up_to nil) ⇒ aucune rotation. Garantit que `ConversationLog::read(since=up_to)` (fold incrémental) et la reprise restent corrects après rotation. Motif : `read(since)` cherche le curseur par position ; retirer up_to du fichier casserait le fold. + +## Rotation +- Stratégie = **archive segmentée** (`log.jsonl` actif + `log.1.jsonl`… anciens), PAS troncature/suppression (transcript = surface humaine riche à préserver). +- Déclencheur **hors chemin chaud** : use case `RotateConversationLog` à la reprise/ouverture, best-effort, idempotent. L'`append` ne déclenche JAMAIS la rotation (zéro latence ajoutée). +- Seuils sur segment actif : `ROTATE_AFTER_TURNS=500` OU `ROTATE_AFTER_BYTES=1MiB`. Backstop disque `MAX_ARCHIVE_SEGMENTS=20` (drop du plus ancien seulement). Politique pure domaine `rotation_plan(...) -> Skip|Archive{keep_from=up_to}`. +- Mécanique : verrou conversation bref ; réécriture actif **atomique en dernier** (tmp+rename) ; aucun tour perdu (∪ actif+archives) ; pagination dédoublonne par TurnId (crash-safe). + +## Lecture paginée (port ISP `ConversationArchive`, impl FsConversationLog) +- `page(conversation, PageCursor{anchor,direction Forward|Backward}, limit)` → `TurnSlice{turns, has_more}`. limit clampé [1,200] défaut 50. Ordre chrono croissant. Archive-aware. +- Use case `ReadConversationPage` → DTO humain `TurnPage{turns:[TurnView{id,at_ms,role,source,text COMPLET,text_len}], has_more, next_anchor}`. **Texte complet, non borné, non distillé** = surface HUMAINE. +- `ConversationLog` (append/read/last) inchangé = vue segment actif (chemin chaud + dérivation). + +## Frontière +Lecture riche consommée UNIQUEMENT par la commande Tauri du viewer humain, jamais par compose_convention_file/handoff/agent. Rotation ne touche que le transcript brut, jamais le handoff/injection. + +## Frontend +LS6 = backend + commande Tauri `read_conversation_page`. Tout le React (viewer) = LS7. \ No newline at end of file diff --git a/.ideai/memory/conversation-viewer-ls7-frontend.md b/.ideai/memory/conversation-viewer-ls7-frontend.md new file mode 100644 index 0000000..e5db6c2 --- /dev/null +++ b/.ideai/memory/conversation-viewer-ls7-frontend.md @@ -0,0 +1,26 @@ +--- +name: conversation-viewer-ls7-frontend +description: Contrat figé du viewer de transcript paginé (frontend pur, lecture seule, surface humaine). +metadata: + type: reference +--- +# LS7 — Viewer fil-par-paire (front pur) + +Dernier lot fonctionnel du programme persistance. UI humaine qui rejoue le transcript par paire, consomme la commande LS6 `read_conversation_page`. Lecture seule, zéro impact backend/agent. + +## Gateway / adapter / mock (patron WorkStateGateway) +- Port `ConversationGateway.readPage(projectId, conversationId, {anchor?, direction?, limit?}) -> TurnPage`. Ajouter `conversation` à `Gateways`. +- Types domaine : `TurnView{id, atMs, role: prompt|response|toolActivity, source: {kind:human}|{kind:agent,agentId}, text COMPLET, textLen}`, `TurnPage{turns, hasMore, nextAnchor}`. +- Adapter `TauriConversationGateway` → invoke("read_conversation_page",{projectId,conversationId,cursor:{anchor,direction},limit}) + `conversationNormalization.ts` (mapper source Human/Agent calqué sur workStateNormalization). Mock avec pagination réelle + `_setTurns` pour tests offline. +- Frontière : composant → useGateways().conversation → adapter → invoke. Jamais d'invoke direct. + +## Modèle d'affichage +- Clé = conversationId. Entrée = drill-down depuis le Work panel (`ConversationWorkSummary.conversationId` déjà énuméré ; rendre les lignes cliquables via prop `onOpenConversation`). Pas de nouvel appel « liste conversations ». +- Rejeu chrono croissant ; distinction sobre User↔Agent vs Agent↔Agent (parties déduites des sources, noms résolus depuis l'inventaire agents) ; toolActivity atténué/replié. +- Pagination : ouverture backward/None = dernière page (bas du fil) ; scroll-up = backward ancré sur le tour le plus ANCIEN du buffer (préfixe, dédup par id, hasMore gate) ; refresh queue = forward ancré sur le plus récent (manuel ou sur domain events delegationReady/orchestratorRequestProcessed). + +## Intégration +- État local `viewerConversationId` dans ProjectsView ; swap main-area (rend ConversationViewer à la place de LayoutGrid, PAS un nouveau kind backend) ; bouton retour ; reset au changement de projet. viewerConversationId=null ⇒ UI actuelle inchangée (non-régression terminal). + +## Frontière / backend +Lecture seule (read_conversation_page LS6, hors chemin chaud), jamais injecté dans un contexte agent. **Aucune part backend** : juste vérifier la signature de la commande exposée et aligner l'adapter si écart. \ No newline at end of file diff --git a/.ideai/memory/git-agent-push-access-gitea-ssh.md b/.ideai/memory/git-agent-push-access-gitea-ssh.md new file mode 100644 index 0000000..4d35f91 --- /dev/null +++ b/.ideai/memory/git-agent-push-access-gitea-ssh.md @@ -0,0 +1,15 @@ +--- +name: git-agent-push-access-gitea-ssh +description: memory note git-agent-push-access-gitea-ssh +metadata: + type: project +--- +**CORRECTION (2026-06-23) — annule et remplace la note précédente : l'agent Git N'A PLUS l'accès push. Il est de nouveau STRICTEMENT LOCAL, comme avant.** + +- Git gère **100 % du dépôt local** : commits, branches, merges/rebases locaux. Rien d'autre. +- **Aucune action sortante** : pas de `git push`, pas de PR distante, pas de publication de tags, pas de synchronisation remote. Ce périmètre n'est PAS dans ses responsabilités. +- Toute synchro avec un remote (`origin` Gitea ou autre) reste **hors périmètre Git** et sous décision/validation explicite de l'utilisateur, exécutée hors agent Git. + +Le contexte d'agent `git.md` reflète déjà ce retour au strictement-local (garde-fou « tu restes strictement local, pas de `git push` »). + +Voir [[git-owns-commit-merge-decisions]] (Git tranche toute la topologie LOCALE du dépôt). \ No newline at end of file diff --git a/.ideai/memory/handoff-ls5-summary-bound-and-llm-seam.md b/.ideai/memory/handoff-ls5-summary-bound-and-llm-seam.md new file mode 100644 index 0000000..c7da737 --- /dev/null +++ b/.ideai/memory/handoff-ls5-summary-bound-and-llm-seam.md @@ -0,0 +1,22 @@ +--- +name: handoff-ls5-summary-bound-and-llm-seam +description: Contrat figé de la borne de handoff (perf) et du seam résumeur LLM non activé. +metadata: + type: reference +--- +# LS5 — Borne handoff + seam LLM + +Ferme le must-have perf du programme persistance : un `summary_md` non borné gonflait le contexte injecté à chaque lancement. + +## Borne summary_md (déterministe, en CARACTÈRES, pas tokens) +- **Par tour** : `render_turn` tronque le texte aplati à `TURN_LINE_MAX_CHARS=240` (infra summarizer). Corrige le trou réel (un gros Response = ligne géante). +- **Totale** : fn PURE domaine `bound_handoff_summary(Handoff, max)` + `HANDOFF_SUMMARY_MAX_CHARS=4096`. Stratégie de dépassement = **troncature distillée** (garde objectif + lignes les plus récentes, drop oldest, frontière de ligne). JAMAIS re-fold compactant, JAMAIS rejet (n'bloque jamais l'append du log). `up_to`/`objective` jamais modifiés. Idempotent. +- **Appliquée à l'écriture** (`RecordTurn` après `fold`, avant `save` → borne universelle quel que soit le résumeur) **ET défensivement à l'injection** (`resolve_handoff` avant compose → protège les `handoff.md` legacy non bornés, sans migration). + +## Seam LLM : TRANCHÉ = « prêt mais NON activé » +- Pas de vrai LLM en LS5 : `fold` tourne sur le chemin chaud (par tour, dans la délégation) → un appel réseau là = régression perf interdite. Défaut runtime reste `HeuristicHandoffSummarizer`. +- Durcissement réel = la borne totale appliquée **dans RecordTurn (hors résumeur)** ⇒ un futur `LlmHandoffSummarizer` ne peut pas exploser le budget contexte. +- Contrat futur adapter LLM (documenté) : déclenchement **froid/hors chemin** (reprise/rotation/débounce, jamais par tour) ; timeout + **fallback heuristique** (trait sans `Result`) ; sélection par flag, défaut heuristique. Trait `HandoffSummarizer::fold` inchangé. + +## Frontière +Transcript = `log.jsonl` (jamais injecté). `summary_md` = dérivé distillé, désormais borné à l'écriture et à l'injection. Conforme « surface agent = borné/distillé ». \ No newline at end of file diff --git a/.ideai/memory/idea-program-surface-separation-and-livestate.md b/.ideai/memory/idea-program-surface-separation-and-livestate.md new file mode 100644 index 0000000..d199e4a --- /dev/null +++ b/.ideai/memory/idea-program-surface-separation-and-livestate.md @@ -0,0 +1,14 @@ +--- +name: idea-program-surface-separation-and-livestate +description: Cadrage du programme post-skills : ce qui existe vs reste, et la règle de frontière surface-agent/humaine. +metadata: + type: project +--- +État au cadrage (develop @ 827d477) : +- **#2a handoff incrémental cross-profile = FAIT + CÂBLÉ** (lots P1–P8) : `domain/conversation_log.rs` (`Handoff`, `HandoffStore`, `HandoffSummarizer::fold` incrémental, `ProviderSessionStore`), `application/conversation/record.rs` (`RecordTurn` = append+fold), wiring `state.rs` (`AppRecordTurnProvider`), injection au lancement via `HandoffProvider` (P7). Résumeur = `HeuristicHandoffSummarizer`, seam async pour `LlmHandoffSummarizer` (P10) déjà en place. Reste : seam LLM + borne `summary_md`. +- **#2b log canonique = FAIT** : `ConversationLog` append-only `.ideai/conversations//log.jsonl`, **jamais injecté à un agent** (doc domaine explicite). Reste : rétention/rotation + lecture paginée UI. +- **#1 live-state agent = À CONSTRUIRE (cœur)** : aujourd'hui seul `GetProjectWorkState` (read-model HUMAIN dérivé, non durable). Construire un 4ᵉ store `live-state.json` (**gitignored, runtime**) : `domain/live_state.rs` (`LiveState`/`LiveEntry`/`WorkStatus`, port `LiveStateStore`, **keyed last-writer-wins, JAMAIS append**, champs bornés, `prune` TTL+max_n), use cases `UpdateLiveState`/`GetLiveStateLean`, adapter `FsLiveStateStore`, auto-update depuis `orchestrator/service.rs` (ask→Working, reply→Done), injection bornée `# État du projet` au lancement + outils MCP `idea_workstate_read`/`idea_workstate_set`. Ne stocke QUE le non-re-dérivable (intent/progrès/dernière délégation) ; busy/queue restent calculés. + +**Règle de frontière gravée (testable)** : Surface AGENT = borné/distillé/pointeur uniquement (capacités, pointeurs mémoire, affordances skills, handoff `summary_md`, live-state lean). Surface HUMAINE = riche (ProjectWorkStatePanel, viewer fil-par-paire qui rejoue le log). **Transcript / append-only INTERDIT d'injection dans un contexte agent** ; seul un dérivé distillé franchit. 4 stores distincts : `memory/` (savoir stable, versionné) · `handoff.md` (reprise par fil) · `log.jsonl` (transcript humain) · `live-state.json` (coordination transitoire maigre). + +Découpage : LS0 doc baseline → LS1 domaine live-state → LS2 store+usecases → LS3 auto-update délégation → LS4 injection+MCP (clôt cold-start) ; LS5 seam LLM+borne handoff, LS6 rétention log+lecture UI (parallèles) ; LS7 UX fil-par-paire (front pur) ; LS8 doc clôture. \ No newline at end of file diff --git a/.ideai/memory/live-state-persistence-program-closed-ls8.md b/.ideai/memory/live-state-persistence-program-closed-ls8.md new file mode 100644 index 0000000..21f7e97 --- /dev/null +++ b/.ideai/memory/live-state-persistence-program-closed-ls8.md @@ -0,0 +1,15 @@ +--- +name: live-state-persistence-program-closed-ls8 +description: Le programme live-state + persistance conversationnelle est livré et documenté ; pointe la doc de clôture et les points restés ouverts. +metadata: + type: project +--- +Programme **live-state / persistance conversationnelle CLOS** (LS1→LS7 livrés @ fd7adbb ; LS8 = doc de clôture). + +**Doc de référence** : `docs/LS8-live-state-persistence-closure.md` + `ARCHITECTURE.md` §21 (cartographie des 4 stores) ; §19 retitré « LIVRÉ » ; §14.1 et §18.5 corrigés (catalogue MCP **14**). + +**4 stores disjoints** : `.ideai/memory/` (savoir stable, versionné) · `handoff.md` (reprise distillée bornée ≤4096) · `log.jsonl` (transcript humain riche, JAMAIS injecté) · `live-state.json` (coordination maigre runtime, gitignoré). Surface AGENT bornée/distillée/pointeur ; surface HUMAINE riche ; seul le handoff distillé franchit vers un contexte agent. + +**Restant ouvert** : (1) activation seam LLM `LlmHandoffSummarizer` (non activé, défaut heuristique, contrat ADR LS5) ; (2) sweep périodique de rotation (idempotent, non câblé) ; (3) **discordance D19-4 ↔ .gitignore** : `.ideai/conversations/` devait être gitignoré mais est suivi — à trancher Git/Main ; (4) intégration MCP e2e UX ; (5) multi-fenêtres registre sessions ; (6) auto-update mémoire/contexte en cours de session. + +**Contrainte d'écriture** : un agent lancé en run-dir isolé (`.ideai/run//`) **ne peut pas écrire l'arbre du project root** (ARCHITECTURE.md, docs/) — les modifs de doc passent par Git (write+commit authority). \ No newline at end of file diff --git a/.ideai/memory/live-state-reboot-reconciliation-design.md b/.ideai/memory/live-state-reboot-reconciliation-design.md new file mode 100644 index 0000000..e6a4d73 --- /dev/null +++ b/.ideai/memory/live-state-reboot-reconciliation-design.md @@ -0,0 +1,21 @@ +--- +name: live-state-reboot-reconciliation-design +description: memory note live-state-reboot-reconciliation-design +metadata: + type: project +--- +--- +title: Réconciliation du live-state au reboot (ReconcileLiveState) +type: reference +description: Décision d'architecture pour réconcilier les lignes live-state fantômes au redémarrage — seam, source de vérité, contrat de statut et préservation du self-only. +--- +**Problème** : au reboot, `.ideai/live-state.json` n'est pas réconcilié → agents fantômes `working/waiting/blocked` alors que leurs sessions sont mortes. `prune` (TTL 6h) ne couvre pas le fantôme récent. + +**Décision (cadrage Architect, 2026-06-23)** : +- **Seam** : étape best-effort `reconcile_live_state` dans la chaîne `open_project` (commands.rs), juste après `reconcile_layouts`. Pas d'app-boot global (live-state est par-projet). Pas d'outil MCP. Justification : un crash ne déclenche jamais `close_project`, donc **open est le seul filet fiable**. +- **Source de vérité** : le registre de sessions vivantes `LiveSessions` / trait `LiveAgentRegistry` (`application/src/terminal/registry.rs`) — la *même* vérité que `GetProjectWorkState`. Prédicat orphelin = `status ∈ {working,waiting,blocked}` ET `!is_agent_live`. Race-safe : tourne avant toute relance ; LWW gagne via `updated_at_ms` frais. +- **Contrat** : orphelin → `idle` (pas de nouveau statut, évite le ripple DTO/front). Garder `intent` (trace), vider `ticket` + `last_delegation` (rendez-vous mort, tracé ailleurs dans log/handoff), `progress` = marqueur stale, `updated_at_ms` frais. +- **Pas de nouveau port.** Use case applicative `ReconcileLiveState` (compose `LiveStateStore` + `LiveAgentRegistry` + `Clock`) + transformation **pure** `LiveState::reconcile_orphans(is_live, now)` dans `domain/src/live_state.rs`. Provider par-root côté app-tauri (calqué sur `LiveStateProvider`/`live_state_for`). +- **Self-only de `idea_workstate_set`** : invariant **de la surface MCP**, pas du store. `set_workstate` lie `agent_id`=identité handshake ; `UpdateLiveState`/`LiveStateStore` sont identity-agnostic. La réconciliation est un **acte système** dans le composition root qui appelle le port directement, **jamais** via `OrchestratorCommand::SetWorkState` → self-only préservé par construction. Même split que `snapshot_running_agents`/`reconcile_layouts`. + +Fichiers probables : `domain/src/live_state.rs`, `application/src/workstate/{mod.rs,reconcile.rs}`, `app-tauri/src/{lib.rs,state.rs,commands.rs}`. Voir [[live-state-persistence-program-closed-ls8]], [[handoff-ls5-summary-bound-and-llm-seam]]. \ No newline at end of file diff --git a/.ideai/memory/mcp-e2e-findings-reply-wedge-phantom-busy.md b/.ideai/memory/mcp-e2e-findings-reply-wedge-phantom-busy.md new file mode 100644 index 0000000..eeaa75c --- /dev/null +++ b/.ideai/memory/mcp-e2e-findings-reply-wedge-phantom-busy.md @@ -0,0 +1,47 @@ +--- +name: mcp-e2e-findings-reply-wedge-phantom-busy +description: memory note mcp-e2e-findings-reply-wedge-phantom-busy +metadata: + type: project +--- +--- +name: mcp-e2e-findings-reply-wedge-phantom-busy +description: Findings validation MCP e2e live (AppImage 0.3.0) — transport/rendez-vous SAINS ; un seul vrai défaut = wedge si l'agent délégué n'appelle pas idea_reply (pas de timeout serveur). +metadata: + type: project +--- +# Findings validation MCP e2e (live, AppImage 0.3.0) — 2026-06-22 + +Contexte : validation réelle du parcours inter-agents (cf. [[resume-after-appimage-rebuild-mcp-e2e]]). Méthode = Main observe en externe (Bash sur `ss -xp` + transcripts `~/.claude/projects//*.jsonl`), l'utilisateur déclenche dans l'UI ; on ne valide PAS via `idea_ask_agent`. + +## Scénario 1 — délégation vers agent FROID + cycle complet : VERDICT +- ✅ **Réveil à froid** : déléguer vers un agent arrêté relance son pont (`mcp-server --requester ` réapparaît, socket ESTAB recompte). +- ✅ **1er tour non perdu** : tâche livrée intacte = 1er `user` `[IdeA · tâche de · ticket ] …`. +- ✅ **Chemin positif `idea_reply`** : quand l'agent cible appelle `idea_reply(ticket,result)`, le bridge répond `reply ... delivered`, le **demandeur reçoit le `tool_result` et reprend la main** (vérifié : DevBackend reçoit `pong` puis continue). +- ✅ **Purge live-state** : dès l'`idea_reply`, `idea_workstate` repasse le cible de `working` → `done`, intent vidé. + +➡️ **Transport + protocole de rendez-vous = SAINS.** Bugs historiques 3/4/5 OK sur ce chemin. + +## LE défaut restant (racine unique) — wedge sans `idea_reply` +Si l'agent délégué **termine son tour sans appeler `idea_reply`** (ex. tâche triviale « réponds pong » → il répond `pong` en **prose** et s'arrête) : +- le **demandeur wedge indéfiniment** dans `idea_ask_agent` (transcript demandeur figé sur le `tool_use` idea_ask_agent, aucun `tool_result`) ; +- la **cible reste `working`** sur le ticket périmé dans la live-state ; +- **aucun timeout ni récupération** côté serveur. Une interruption utilisateur du `ask` libère le demandeur mais **ne purge pas** le `working` de la cible (purge seulement à l'`idea_reply`). + +➡️ Le « Busy fantôme » n'est PAS un bug d'état indépendant : c'est la **conséquence** de l'absence d'`idea_reply`. Une seule cause à traiter. + +### Pistes correctif (à arbitrer Architect) +1. **Durcir contextes agents** : « toute tâche déléguée se termine IMPÉRATIVEMENT par `idea_reply`, même triviale » (cheap, attaque la cause comportementale). +2. **Garde-fou serveur (rendez-vous)** : timeout sur l'attente `idea_ask_agent` → renvoyer au demandeur un résultat d'échec/relance explicite + purger le `working` de la cible (idempotent). Touche le contrat rendez-vous + cycle de vie live-state = domaine Architect. Voir [[idea-program-surface-separation-and-livestate]], [[mcp-bridge-and-delegation-runtime-notes]]. + +## Diagnostic / repro (rappels) +- Ponts : `pgrep -af 'mcp-server --endpoint'` (1 requester = 2 process) ; `ss -xp | grep -c idea-mcp`. +- Reply réel = `tool_use` `idea_reply` dans le transcript cible → tool_result `reply ... delivered` (⚠️ ne pas confondre avec les mentions `idea_reply` du CLAUDE.md injecté via attachments). +- Wedge demandeur = transcript demandeur s'arrête sur `tool_use` `idea_ask_agent` sans `tool_result`. +- ⚠️ Horodatages transcripts = **UTC** ; `ls %H:%M:%S` masque la date → comparer avec `date` + mtime réel (`ls -t`). + +## Restant à tester (scénarios e2e suivants) +- Délégation vers agent BACKGROUND (node_id None) — même protocole, vérifier pas de 1er tour perdu. +- `ask` sans réponse ne wedge pas durablement la CONNEXION du demandeur (vs juste l'appel) — Bug 5 côté transport. +- Profils Claude/Codex utilisent MCP par défaut ; fallback fichier+prose cohérent. +- Observabilité UI des délégations/replies (aujourd'hui opaque) — sujet UX. \ No newline at end of file diff --git a/.ideai/memory/mcp-functional-test-plan-2026-06-24.md b/.ideai/memory/mcp-functional-test-plan-2026-06-24.md new file mode 100644 index 0000000..077e211 --- /dev/null +++ b/.ideai/memory/mcp-functional-test-plan-2026-06-24.md @@ -0,0 +1,45 @@ +--- +name: mcp-functional-test-plan-2026-06-24 +description: memory note mcp-functional-test-plan-2026-06-24 +metadata: + type: project +--- +--- +name: mcp-functional-test-plan-2026-06-24 +description: Plan de test FONCTIONNEL du rendez-vous inter-agents MCP (trivial→tordu), à exécuter après rebuild develop HEAD ; méthode, ordre, setup IdeA et critères de réussite. +metadata: + type: project +--- +# Plan de test fonctionnel MCP inter-agents (2026-06-24, run a6ced819) + +Objectif : état des lieux par tests **fonctionnels** (pas unitaires) du rendez-vous `idea_ask_agent` ⇄ `idea_reply`, du trivial au plus tordu. On lance dans l'ordre, on **s'arrête au 1er blocage**, on corrige (cycle Architect→Git→Dev→QA), rebuild+relance, puis on **reprend de T1**. Contexte/historique : [[mcp-e2e-findings-reply-wedge-phantom-busy]], [[backstop-fires-on-intra-task-turn-rootcause]], [[rendezvous-no-reply-backstop-design]], [[rendezvous-600s-cap-too-short-heavy-tasks]], [[reconcile-live-state-implemented-feature-branch]]. + +## Contexte binaire au moment du plan +- AppImage qui tournait = build **23/06 19:42**, PÉRIMÉ (antérieur à `c6f0f86` encode_cwd, `744de20` backstop no-reply, `886bb0d`/`1efe2f1` reconcile). Décision utilisateur : **rebuild develop HEAD `1efe2f1` puis relance** avant de tester. +- Règle structurelle : **binaire qui tourne = AppImage**. Tout fix backend ⇒ rebuild + relance d'IdeA (tue Main). Les tests s'enchaînent SANS relance *à l'intérieur d'une même version* ; chaque cycle de fix = 1 relance. + +## Méthode +- **Main est le demandeur** (`idea_ask_agent`) = test fidèle de « si je donne un message à un agent, il me répond ». +- Doublé d'**observation externe Bash** : ponts `ss -xp | grep idea-mcp` et `pgrep -af mcp-server` ; transcripts `~/.claude/projects//*.jsonl` (horodatages **UTC** ; reply réel = `tool_use` idea_reply→tool_result `reply … delivered` ; wedge demandeur = transcript figé sur `tool_use` idea_ask_agent sans tool_result) ; live-state via `idea_workstate_read`. +- Risque résiduel : un wedge non borné fige le tour de Main → l'utilisateur peut interrompre. + +## Setup IdeA à mettre en place avant T1 (consigne utilisateur) +1. Relancer IdeA, ouvrir le projet IdeA, **Main visible** (le demandeur). +2. Garder **2–3 cellules libres visibles** pour qu'on observe à l'œil les délégations (Main y placera des cibles visibles, ou lance-toi QA + DevFrontend dans des cellules visibles). +3. **Laisser Architect, DevBackend, Git ARRÊTÉS (froids)** : T3 testera le réveil à froid ; T9 utilisera Architect+DevBackend froids. +4. Cibles chaudes pour T1/T2 : Main les réchauffe lui-même (launch) — pas d'action requise. + +## Ordre des tests (trivial → tordu) +- **T1 — Visibilité outils MCP.** Un agent lancé par IdeA voit ses outils sans action manuelle. Vérif : pont ESTAB (`ss`) + 2 process mcp-server par requester. +- **T2 — Cible CHAUDE + idea_reply trivial.** Main→ask(agent chaud, « réponds pong via idea_reply »). Attendu : réponse inline `pong`, temps borné, workstate working→done purgé. +- **T3 — Réveil à FROID + idea_reply.** Cible arrêtée. Attendu : pont relancé (nouveau mcp-server), **1er tour non perdu** (1er user `[IdeA · tâche…]` intact), reply remonte. +- **T4 — Cible BACKGROUND (node_id None).** Délégation vers agent sans cellule visible. Attendu : même protocole, pas de 1er tour perdu (historiquement non testé). +- **T5 — NO-REPLY (cible répond en prose, pas d'idea_reply).** Attendu backstop : demandeur libéré en temps borné avec `TargetReturnedNoReply` (retryable), **pas de Busy fantôme** (workstate purgé). DÉFAUT historique #1. +- **T6 — Délégation MULTI-ÉTAPES puis idea_reply.** Cible lit plusieurs fichiers / lance une cmd PUIS idea_reply. Attendu : **pas de libération prématurée** pendant le travail, rapport final livré. PIÈGE turn-watcher (cause racine : backstop tirait au 1er tour interne ~3 s). +- **T7 — Tâche LOURDE > plafond** (impl + `cargo test`/clippy). Attendu : extension sur signe de vie OU message clair « cible active, plafond atteint » (≠ faux `-32001`) ; idea_reply tardif non perdu. Plafond `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS` (défaut 600000). +- **T8 — Délégations PARALLÈLES** (2 cibles en 1 tour Main). Attendu : rendez-vous multiples simultanés OK, multiplexage pont, 2 réponses distinctes. +- **T9 — TRANSITIVE A→B→C** (Main→Architect, Architect délègue à DevBackend puis reply à Main). Attendu : rendez-vous imbriqués, 2 attentes simultanées sans deadlock. Cas le plus tordu. +- **T10 — INTERRUPTION/annulation + reconcile reboot.** (a) Main interrompt un ask en vol → `working` cible purgé, pas de cascade, idea_reply tardif non-livrable proprement. (b) Au reboot, orphelins status∈{working,waiting,blocked} & session morte → `idle` + marker `STALE_AT_RESTART_MARKER` (use case ReconcileLiveState, cf. [[reconcile-live-state-implemented-feature-branch]]). + +## Critère de fin +Tous les T1→T10 verts avec preuve réelle (réponse inline + observation externe cohérente). Sinon : 1er rouge = blocage à corriger, puis reprise de T1. \ No newline at end of file diff --git a/.ideai/memory/mcp-functional-tests-t1-t9-green-live-2026-06-24.md b/.ideai/memory/mcp-functional-tests-t1-t9-green-live-2026-06-24.md new file mode 100644 index 0000000..c4000e9 --- /dev/null +++ b/.ideai/memory/mcp-functional-tests-t1-t9-green-live-2026-06-24.md @@ -0,0 +1,33 @@ +--- +name: mcp-functional-tests-t1-t9-green-live-2026-06-24 +description: memory note mcp-functional-tests-t1-t9-green-live-2026-06-24 +metadata: + type: project +--- +--- +title: MCP tests fonctionnels T1→T9 VERTS en live (2026-06-24, AppImage 12:53 HEAD 1efe2f1 + fix T7 non committé) +type: project +description: Passe complète T1→T9 verte en conditions réelles sur AppImage du 24/06 12:53 ; T5 (backstop no-reply) et T7 (watchdog inactivité >600s) validés live ; reste T10 (interruption + reconcile reboot) qui exige action utilisateur. +--- +# Tests fonctionnels MCP rendez-vous — T1→T9 VERTS live (2026-06-24) + +Run a6ced819, plan [[mcp-functional-test-plan-2026-06-24]]. AppImage **rebuild 12:53 depuis l'arbre de travail** (HEAD `1efe2f1` + le fix T7 watchdog **non committé** mais présent dans les 11 fichiers modifiés du git status → embarqué dans le binaire). Méthode : Main demandeur + observation externe (ss/pgrep/transcripts UTC/workstate). + +## Résultats (preuve réelle) +- **T1 visibilité MCP** ✅ : agents chauds = pont ESTAB + 2 mcp-server (app-tauri parent + AppImage) chacun. +- **T2 cible chaude + reply trivial** ✅ : DevFrontend → `pong` inline, reply delivered 10:56:51 UTC, workstate done (pas de Busy fantôme). +- **T3 réveil à FROID** ✅ : Git froid → `pong-cold` ; nouveau pont spawné, **1er tour `[IdeA·tâche]` intact** (régresseur encode_cwd résolu), reply delivered 10:57:34. +- **T4 cible BACKGROUND (node_id None)** ✅ : Git background → `pong-bg`, reply delivered 10:58:14. +- **T5 NO-REPLY backstop** ✅ (défaut historique #1 résolu) : cible répond en prose sans idea_reply → demandeur libéré en ~20 s avec erreur retryable « returned to its prompt without calling idea_reply », workstate cible done (PAS de Busy fantôme). +- **T6 multi-étapes** ✅ (piège turn-watcher résolu) : DevFrontend lit fichiers + git log PUIS idea_reply ; pas de libération prématurée ; données réelles (142 fichiers, hashes corrects). +- **T7 tâche LOURDE >600s** ✅ (défaut historique #2 résolu, validation live du fix watchdog) : QA charge réelle continue 11:00:46→11:12:44 UTC = **~11m58s**, cargo clean+build release+test (1632 passed/0)+clippy+9 relances ; rendez-vous **NON expiré à 600s**, idea_reply accepté au-delà du plafond (aucun « no pending request »), reply delivered 11:13:21. +- **T8 PARALLÈLE** ✅ : 2 asks même tour (DevFrontend `alpha-8` + Git `beta-8`), réponses distinctes, multiplexage pont OK. +- **T9 TRANSITIF A→B→C** ✅ : Main→Architect→DevBackend (les 2 froids) → `relay:gamma-9:done` ; rendez-vous imbriqués + double réveil à froid sans deadlock ; DevBackend reply delivered 11:14:32. + +## Reste : T10 (exige action utilisateur) +- **T10a interruption** : Main interrompt un ask en vol → attendu : workstate cible purgé, pas de cascade, idea_reply tardif proprement non-livrable. NÉCESSITE que l'utilisateur appuie sur interrupt pendant un ask wedgé. +- **T10b reconcile reboot** : orphelin status∈{working,waiting,blocked} + session morte → au reboot, use case ReconcileLiveState le passe à idle + `STALE_AT_RESTART_MARKER` (cf. [[reconcile-live-state-implemented-feature-branch]]). NÉCESSITE un reboot d'IdeA (ferme Main). + +## Suite recommandée +1. Faire committer le fix T7 par Git (encore non committé, cf. [[git-owns-commit-merge-decisions]] + [[rendezvous-600s-cap-too-short-heavy-tasks]]). +2. Exécuter T10 avec l'utilisateur (interrupt + reboot). \ No newline at end of file diff --git a/.ideai/memory/mcp-t10a-harness-interrupt-does-not-cancel-rendezvous.md b/.ideai/memory/mcp-t10a-harness-interrupt-does-not-cancel-rendezvous.md new file mode 100644 index 0000000..9446856 --- /dev/null +++ b/.ideai/memory/mcp-t10a-harness-interrupt-does-not-cancel-rendezvous.md @@ -0,0 +1,29 @@ +--- +name: mcp-t10a-harness-interrupt-does-not-cancel-rendezvous +description: memory note mcp-t10a-harness-interrupt-does-not-cancel-rendezvous +metadata: + type: project +--- +--- +title: T10a — l'interrupt harness du demandeur n'annule PAS le rendez-vous backend (reply tardif accepté dans le vide) +type: reference +description: Finding T10a (2026-06-24) — interrompre Main (demandeur) au niveau Claude Code ne propage aucune annulation à IdeA ; la cible finit, son idea_reply est « delivered » dans un demandeur abandonné, et la live-state du demandeur reste working. +--- +# Finding T10a (2026-06-24, run a6ced819) + +## Setup +Main → `idea_ask_agent(DevFrontend, "travaille ~90s puis idea_reply late-10a")`. Pendant l'attente, l'utilisateur **interrompt Main** (Échap harness Claude Code). Le tool use `idea_ask_agent` est rejeté côté harness. + +## Observé +- DevFrontend a fini ses ~95 s de travail réel (11:20:39→11:22:14 UTC) puis `idea_reply("late-10a")` → **ACCEPTÉ « delivered »** à 11:22:18 (PAS « no pending request »). +- DevFrontend workstate = **done** (pas d'orphelin côté cible). Pas de cascade. +- **Main (demandeur) workstate resté `working`** : l'interrupt a coupé le tour de Main avant son nettoyage ; le chemin interrupt ne réconcilie pas la live-state du demandeur. + +## Interprétation +L'interrupt Claude Code annule le **tool call local** mais le **process Main survit** → la socket MCP (mcp-server du demandeur) reste connectée → IdeA ne reçoit aucun signal d'annulation → le rendez-vous reste pending → le reply tardif de la cible est livré dans un demandeur qui a déjà abandonné (perdu pour Main mais rapporté « delivered »). + +## Conséquence +- Le T10a du plan (« annulation en vol → reply tardif non-livrable proprement ») **n'est pas exerçable via interrupt harness** tant que le process demandeur survit. Une vraie annulation exigerait : tuer le process demandeur (drop socket) OU un cancel protocolaire JSON-RPC. +- Comportement néanmoins bénin : pas de wedge, pas de cascade, cible réconciliée (done). Seuls écarts : (a) reply tardif « delivered » au lieu de rejeté ; (b) live-state du demandeur laissée working par le chemin interrupt (mais reconcile-au-reboot la rattraperait, cf. [[reconcile-live-state-implemented-feature-branch]]). + +Lié à [[mcp-functional-tests-t1-t9-green-live-2026-06-24]], [[mcp-functional-test-plan-2026-06-24]]. \ No newline at end of file diff --git a/.ideai/memory/mcp-t10b-pending-reboot-verification.md b/.ideai/memory/mcp-t10b-pending-reboot-verification.md new file mode 100644 index 0000000..9d0e19e --- /dev/null +++ b/.ideai/memory/mcp-t10b-pending-reboot-verification.md @@ -0,0 +1,24 @@ +--- +name: mcp-t10b-pending-reboot-verification +description: memory note mcp-t10b-pending-reboot-verification +metadata: + type: project +--- +--- +title: T10b PASS — reconcile au reboot vérifié, inclut l'orchestrateur (2026-06-24) +type: project +description: T10b validé live après reboot. ReconcileLiveState ramène l'orphelin Main working→idle en conservant l'intent et purgeant ticket/lastDelegation. Couvre l'orchestrateur. Reste : faire committer par Git les fix non committés (T7 watchdog + reconcile). +--- +# T10b — PASS (reboot reconcile vérifié) + +## Verdict +Orphelin armé = Main `working` intent "T10b-ORPHAN-MAIN". Après reboot (run a6ced819), `idea_workstate_read` montre Main : +- `status: idle` ✅ (était working) +- `intent` conservé ✅ +- `ticket` purgé ✅ / `lastDelegation` purgé ✅ + +Signature `STALE_AT_RESTART_MARKER` complète. **ReconcileLiveState valide ET inclut l'orchestrateur** (Main n'a PAS été exclu malgré la crainte du design open_project). Champ `progress` non exposé par workstate_read mais transition working→idle+intent retenu = concluante. + +## Suite du plan +T1→T10 du plan [[mcp-functional-test-plan-2026-06-24]] terminés (T1→T9 verts cf. [[mcp-functional-tests-t1-t9-green-live-2026-06-24]], T10a finding [[mcp-t10a-harness-interrupt-does-not-cancel-rendezvous]], T10b PASS ici). +**RESTE : faire committer par Git les fix non committés (T7 backstop/watchdog + reconcile live-state).** cf. [[git-owns-commit-merge-decisions]]. \ No newline at end of file diff --git a/.ideai/memory/mcp-t7-backstop-600s-reply-rejected-livestate-stale.md b/.ideai/memory/mcp-t7-backstop-600s-reply-rejected-livestate-stale.md new file mode 100644 index 0000000..8adcb69 --- /dev/null +++ b/.ideai/memory/mcp-t7-backstop-600s-reply-rejected-livestate-stale.md @@ -0,0 +1,37 @@ +--- +name: mcp-t7-backstop-600s-reply-rejected-livestate-stale +description: memory note mcp-t7-backstop-600s-reply-rejected-livestate-stale +metadata: + type: project +--- +--- +title: MCP T7b/T7e — backstop 600s rejette le reply tardif et laisse le live-state worker non réconcilié +type: reference +description: Finding fonctionnel des tests MCP T7b/T7e (2026-06-24) — un idea_reply après dépassement de la fenêtre 600s est refusé « no pending request », et le ticket du worker reste working dans le live-state. +--- + +# Finding — tests MCP T7 (2026-06-24, QA) + +## Contexte +Série de tests du rendez-vous `idea_ask_agent ⇄ idea_reply` et du backstop no-reply (cap ~600s), demandés par Main : +- **T7d** (debug build x3, avant-plan) : cumul ~3m49s < 600s → reply OK. +- **T7b** (9 étapes `sleep 82`, entrelacées) : le harness BLOQUE tout `sleep` avant-plan → exécutées en run_in_background ; reply OK (tardif mais < fenêtre côté ticket T7b). +- **T7e** (release build répété, avant-plan, sans sleep ni bg) : 7 cycles, cumul réel **08:01:14 → 08:14:21 = 13 min 07 s**, franchit largement 600s. + +## Défaut observé +À la fin de T7e (et de T7b, terminé au même tour, après >600s d'occupation continue), les DEUX `idea_reply` (tickets `4613262f…` T7b et `d2398b22…` T7e) sont refusés : + +``` +invalid input: no pending request to reply to for agent aefdbd61-… +``` + +→ Le backstop a libéré le **demandeur** (Main) côté rendez-vous après dépassement du cap, ce qui est le comportement voulu. MAIS : +1. Le **résultat réel** (builds OK, horodatages non falsifiés) ne peut **plus être délivré** : il est perdu pour le demandeur. +2. `idea_workstate_read` montre encore le worker QA `status=working ticket=d2398b22…` **après** le rejet du reply → le live-state du **worker** n'est PAS réconcilié quand le backstop ferme le rendez-vous côté demandeur. Incohérence demandeur (libéré) vs worker (toujours working). + +## Conséquence / piste +- Le cap 600s du rendez-vous reste trop court pour des tâches lourdes légitimes (cf. [[rendezvous-600s-cap-too-short-heavy-tasks]]) : ici un travail réel et honnête >13 min est « jeté ». +- À corriger : quand le backstop ferme un rendez-vous, soit (a) accepter encore un reply tardif (le router vers le demandeur même après timeout / le persister), soit (b) réconcilier le live-state du worker (passer son ticket à done/abandon) pour ne pas laisser un `working` fantôme. Voir [[backstop-fires-on-intra-task-turn-rootcause]] et [[rendezvous-no-reply-backstop-design]]. + +## Reproductible +Charge avant-plan réelle (`cargo build --release --workspace` en boucle clean) dépassant 600s, puis tenter `idea_reply` sur le ticket d'origine → rejet systématique. \ No newline at end of file diff --git a/.ideai/memory/reconcile-live-state-implemented-feature-branch.md b/.ideai/memory/reconcile-live-state-implemented-feature-branch.md new file mode 100644 index 0000000..46818ac --- /dev/null +++ b/.ideai/memory/reconcile-live-state-implemented-feature-branch.md @@ -0,0 +1,25 @@ +--- +name: reconcile-live-state-implemented-feature-branch +description: memory note reconcile-live-state-implemented-feature-branch +metadata: + type: project +--- +--- +title: Réconciliation du live-state au reboot — implémentée (feature/reconcile-live-state) +type: project +description: Le chantier ReconcileLiveState (3 lots) est implémenté et vert (1623 tests), non committé ; détail des fichiers et de l'arbitrage project_id/provider. +--- + +Implémenté le 2026-06-23 sur `feature/reconcile-live-state` (ticket Main a7fcb5e4, dont le rendez-vous `idea_reply` a expiré pendant le build — résultat consigné ici). **Non committé** (Git tranche). Cadrage : [[live-state-reboot-reconciliation-design]]. + +**Résultats réels** : `cargo test --workspace` = **1623 passed, 0 failed**. `cargo clippy --workspace --all-targets` = 0 erreur, aucun warning sur le code ajouté (warnings restants tous pré-existants). + +**Lot 1 (domaine, `crates/domain/src/live_state.rs`)** : `LiveState::reconcile_orphans(is_live, now_ms) -> Vec` pure (ne mute pas self). Orphelin = status ∈ {Working,Waiting,Blocked} ET !is_live → réécrit status=Idle, intent gardé, ticket=None, last_delegation=None, progress=Some(`STALE_AT_RESTART_MARKER`), updated_at_ms=now. Const pub `STALE_AT_RESTART_MARKER = "(stale — session not running at restart)"`. +4 tests. + +**Lot 2 (application, nouveau `crates/application/src/workstate/reconcile.rs`)** : use case `ReconcileLiveState` sur `LiveStateStore`+`LiveAgentRegistry`(ISP)+`Clock` ; `execute(ReconcileLiveStateInput{project_id})` charge → reconcile_orphans → upsert par ligne. +3 tests. Exporté via workstate/mod.rs + application/lib.rs. + +**Lot 3 (app-tauri, `state.rs`+`commands.rs`)** : provider par-root `AppReconcileLiveState` (résout root via `ProjectStore::load_project`, `FsLiveStateStore::new(&root)`, registre = `LiveSessions` existant). Champ `reconcile_live_state` sur AppState. Hook best-effort dans `open_project` après `reconcile_layouts`. **Acte système** : écrit le port `LiveStateStore` directement, jamais via `OrchestratorCommand::SetWorkState` → self-only de `idea_workstate_set` préservé par construction. + +**Arbitrage à valider QA/Architect** : la résolution project_id→root se fait au composition root (le store est root-bound), donc `ReconcileLiveState::execute` reçoit bien `ReconcileLiveStateInput{project_id}` (conforme Lot 2 + hook Lot 3 à la lettre) mais ne relit pas project_id dans le flux pur (`_input`). Variante sans ce param redondant = ajustement trivial si souhaité. + +**Fichiers (6)** : domain/src/live_state.rs ; application/src/workstate/reconcile.rs (nouveau) ; application/src/workstate/mod.rs ; application/src/lib.rs ; app-tauri/src/state.rs ; app-tauri/src/commands.rs. \ No newline at end of file diff --git a/.ideai/memory/rendezvous-600s-cap-too-short-heavy-tasks.md b/.ideai/memory/rendezvous-600s-cap-too-short-heavy-tasks.md new file mode 100644 index 0000000..e054dfa --- /dev/null +++ b/.ideai/memory/rendezvous-600s-cap-too-short-heavy-tasks.md @@ -0,0 +1,37 @@ +--- +name: rendezvous-600s-cap-too-short-heavy-tasks +description: memory note rendezvous-600s-cap-too-short-heavy-tasks +metadata: + type: project +--- +--- +name: rendezvous-600s-cap-too-short-heavy-tasks +description: FIX LIVRÉ (2026-06-24) du plafond rendez-vous 600s trop court (T7) — watchdog d'inactivité + extension sur signe de vie + plafond absolu, message distinct cible-active vs no-reply. Built, tests verts, AppImage 09:21 ; validation live T7 en attente. +metadata: + type: project +--- +# T7 — plafond rendez-vous trop court : FIX LIVRÉ (2026-06-24) + +## Défaut (rappel, observé 2026-06-23) +Délégation dev lourde (impl + `cargo test --workspace` + clippy) → la cible bosse en **un seul long tour > 600 s sans émettre de `turn_duration`**. L'enveloppe serveur `timeout(600s, dispatch)` plate (`crates/infrastructure/src/orchestrator/mcp/server.rs`) expirait sec : (a) faux `-32001`/no-reply **retryable** alors que la cible n'est pas muette mais active ; (b) drop du `dispatch` → canal de report mort → `idea_reply` final non livrable (« no pending request »). + +## Fix (branche de travail courante, pas encore committé) +Implémenté par subagent Claude (Architect+DevBackend, hors `idea_ask_agent` car le rendez-vous était le sujet), vérifié par Main (QA réelle), AppImage rebuildée. +- **Algorithme `run_inactivity_watchdog`** dans `server.rs` : `ASK_RENDEZVOUS_TIMEOUT` (600 s, env `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`) devient une **fenêtre d'inactivité (budget de silence)**, pas un plafond absolu. Boucle `timeout(fenêtre, dispatch)` ; à chaque expiration, sonde l'activité de la cible : + - progrès & sous plafond → réarme (extension) ; + - progrès & plafond atteint → **`rendezvous_ceiling_active_error`** (nouveau code JSON-RPC `error_codes::RENDEZVOUS_CEILING_ACTIVE = -32_002`, `data.code="RENDEZVOUS_CEILING_ACTIVE"`, **`retryable:false`**, message « still actively working… do not retry blindly, check the target's in-progress work/branch ») ; + - pas de progrès (vrai silence, ou **pas de probe = fallback plat**) → `rendezvous_no_reply_error` inchangé (`retryable:true`). +- **Signe de vie = octets cumulés des `.jsonl`** du run-dir cible (couvre le long tour unique sans `turn_duration`). Nouvelle `inspector::claude_paths::transcript_activity_token(fs, home, cwd) -> Option`. +- **Plafond absolu** `ASK_RENDEZVOUS_CEILING` (défaut **4 h**, env `IDEA_ASK_RENDEZVOUS_CEILING_MS`). +- **Wiring** : sonde optionnelle `AskActivityProbe` injectée dans `McpServer` (modèle `events`/`ready_sink`, additif → zéro régression), branchée par la composition root `crates/app-tauri/src/state.rs` (qui résout nom→AgentId→run-dir via nouveau `service.resolve_agent_id_by_name`). Infra reste libre d'`AgentId`. +- Fichiers : `server.rs`, `jsonrpc.rs`, `mcp/mod.rs`, `lib.rs`, `inspector/claude_paths.rs`+`mod.rs`, `application/.../service.rs`, `app-tauri/state.rs`. + +## Validation (preuve réelle, Main) +`cargo test -p infrastructure --lib` = **251 passed/0** (dont 6 nouveaux tests : silence→no-reply, fallback sans probe→no-reply, active→extension puis resolved, active→ceiling, codes/retryable distincts). `mcp_server` 22/0, `inspector_claude` 4/0, `cargo test -p application` tout vert. `clippy -p infrastructure -p application --all-targets` = **0 warning nouveau** (résiduels pré-existants : input/mod.rs, lifecycle.rs, fileguard.rs, server.rs:132 ready_sink). **AppImage rebuildée 2026-06-24 09:21**, installée sur `/home/anthony/Documents/IdeA_0.3.0_amd64.AppImage`, backup `…​.old-pre-t7-fix` (08:41). + +## Reste à faire +1. **Validation live T7** après relance IdeA : déléguer une tâche lourde >600 s ; attendu = pas d'expiration tant que la cible écrit son transcript, et soit résolution normale, soit (si >4 h) message ceiling-active non-retryable distinct ; idea_reply tardif non perdu tant qu'on n'a pas atteint le plafond. +2. **Commit par Git** (non fait — laissé à Git, cf. [[git-owns-commit-merge-decisions]]) : décider de le faire avant ou après la validation live T7. +3. Reprise complète des tests **depuis T1** (skill mcp-rendezvous-functional-test). État pré-relance : T1→T6 verts cette passe, dont fix T5 validé live (cf. [[checkpoint-mcp-tests-t1-t5-and-t5-livestate-defect]]). + +Lié à [[rendezvous-no-reply-backstop-design]], [[backstop-fires-on-intra-task-turn-rootcause]], [[mcp-functional-test-plan-2026-06-24]]. \ No newline at end of file diff --git a/.ideai/memory/rendezvous-no-reply-backstop-design.md b/.ideai/memory/rendezvous-no-reply-backstop-design.md new file mode 100644 index 0000000..42ea593 --- /dev/null +++ b/.ideai/memory/rendezvous-no-reply-backstop-design.md @@ -0,0 +1,14 @@ +--- +name: rendezvous-no-reply-backstop-design +description: Décision d'architecture sur la fin-de-tour et le backstop no-reply du rendez-vous idea_ask_agent ⇄ idea_reply, après échec live du fix Finding A (77e62e5). +metadata: + type: reference +--- +Le watcher prompt-ready PTY (`infrastructure/src/input/mod.rs` `arm_prompt_watcher`) ne peut PAS servir de signal de fin-de-tour : il est one-shot ré-armé seulement au `bind_handle`, et matche un bandeau TUI **permanent** (`? for shortcuts`) — donc inopérant par tour. Preuve live : aucun `prompt_ready` de toute une session alors que l'agent a travaillé ⇒ c'est le handshake MCP `initialize` (→ `release_agent_cold_start`) qui libère le cold-start, pas le PTY. + +**Contrat figé** : `idea_ask_agent` est garanti libéré en temps borné par l'un de : +1. `idea_reply` (chemin propre, renforcé par le préambule comportemental injecté à chaque délégation) ; +2. backstop no-reply sur **stagnation** — transition `Alive→Stalled` (lot-2 `arm_liveness`/`sweep_stalled`) du `Busy{ticket}` ⇒ résolution synthétique « cible silencieuse sans idea_reply » + libération du demandeur ; +3. timeout absolu **fini** (dernier recours) — `ASK_RENDEZVOUS_TIMEOUT` ne doit JAMAIS être 24h ; défaut reco 600 s, env-overridable, garde `Some(0)=no-override`. La borne applicative per-tour (`resolve_turn_timeout`) doit aussi être finie. + +Le scraping d'octets ANSI bruts est interdit comme autorité de fin-de-tour (fragile, profil-spécifique). Cold-start = signal MCP `initialize`. Aucun port domaine ni contrat de cartographie n'est touché : tout est infrastructure/application (réutilise lot-2 + sink MCP). Diagnosticabilité : les logs d'armement/bind doivent être en `diag!` (pas `eprintln!`→/dev/null) pour observer armement vs match. \ No newline at end of file diff --git a/.ideai/memory/resume-after-appimage-rebuild-mcp-e2e.md b/.ideai/memory/resume-after-appimage-rebuild-mcp-e2e.md new file mode 100644 index 0000000..df89a97 --- /dev/null +++ b/.ideai/memory/resume-after-appimage-rebuild-mcp-e2e.md @@ -0,0 +1,39 @@ +--- +name: resume-after-appimage-rebuild-mcp-e2e +description: memory note resume-after-appimage-rebuild-mcp-e2e +metadata: + type: project +--- +--- +name: resume-after-appimage-rebuild-mcp-e2e +description: Point de reprise — programme live-state CLOS+RELEASÉ (0.3.0), discordance .gitignore D19-4 TRANCHÉE. Prochain chantier = validation MCP e2e UX. Chantiers restants priorisés. +metadata: + type: project +--- +# Reprise — état au 2026-06-22 (run a6ced819) + +## Où on en est +- **Programme live-state / persistance CLOS et RELEASÉ** : `develop` @ `36be0cb` (LS8), `main` @ `29232dd` = `release(0.3.0): intègre develop dans main`. Détail : [[live-state-persistence-program-closed-ls8]]. Doc : `docs/LS8-live-state-persistence-closure.md` + `ARCHITECTURE.md` §21. +- **AppImage rebuildée + tournante** : `IdeA_0.3.0_amd64.AppImage` (le nom n'est plus 0.1.0). Le binaire live embarque tout le programme ; 6 ponts MCP connectés/ESTAB observés → point 1 du chantier e2e (agent voit ses outils MCP sans action manuelle) déjà satisfait en réel. Recette rebuild (rappel) : `npm --prefix frontend run build` puis depuis `crates/app-tauri/` `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage` ; remplacer l'AppImage installée → relancer. +- **Discordance .gitignore ↔ D19-4 : TRANCHÉE** (par l'utilisateur) et exécutée par Git → commit `47b3806` sur `develop` (`chore(gitignore): désuivre .ideai/conversations/`). Périmètre réel : seul `.ideai/conversations/` était discordant (16 fichiers suivis, 8 handoff.md + 8 log.jsonl) ; `.ideai/run/` et `.ideai/live-state.json` étaient déjà ignorés/propres. Désuivis (rm --cached, conservés sur disque) + règle ajoutée `.gitignore:58`. Plus une décision en attente. + +## Prochain chantier (priorité 1) : VALIDATION MCP END-TO-END UX +Objectif = valider/durcir en situation RÉELLE le parcours inter-agents (le code est probablement bon, Bugs 1→7 corrigés cf. [[mcp-bridge-and-delegation-runtime-notes]], mais ces bugs ne sont apparus qu'en live). À vérifier sur l'AppImage 0.3.0 : +1. Un agent lancé par IdeA voit ses outils MCP sans action manuelle. (déjà OK observé) +2. Délégation vers agent FROID et vers agent BACKGROUND (node_id None) : tâche bien écrite dans le PTY, pas de 1er tour perdu, pas de `Busy` fantôme après interruption/annulation (Bugs 3/4/6/7). +3. Un `ask` sans réponse ne wedge pas la connexion du demandeur (Bug 5). +4. Profils Claude/Codex utilisent MCP par défaut ; fallback fichier+prose cohérent sinon. +5. Observabilité UI des délégations/replies (aujourd'hui opaque) — sujet UX. + +**CONTRAINTE MÉTHODO** : NE PAS réparer/valider le système inter-agents VIA le système inter-agents (`idea_ask_agent`). Piloter via les **subagents natifs (outil Agent)**, et demander à l'utilisateur de faire tourner l'AppImage + observer (Main ne peut pas valider seul depuis l'intérieur). Diag live : `ss -xp | grep idea-mcp` (ponts connectés) ; `~/.claude/projects//*.jsonl` (transcript ; pas de nouveau fichier = tâche jamais soumise). + +## Autres chantiers restants (depuis [[remaining-work-idea-agent-control-ide]]) +- **Auto-update mémoire/contexte EN COURS de session** (aujourd'hui injection au lancement seulement) — priorité 1. +- **Multi-fenêtres / déplacement d'onglet** du registre de sessions — priorité 2 (UI). +- Optionnels différés : activation réelle du seam LLM de handoff (contrat figé ADR LS5, défaut heuristique) ; sweep périodique de rotation (idempotent, non câblé). + +## Friction outillage notée (par Git, sans incidence résultat) +Le `settings.local.json` du run dir a un `deny` qui bloque Edit/Write et certaines redirections Bash pour les agents en run dir isolé ; Git a dû éditer `.gitignore` via `tee -a`. À assouplir si on veut des éditions de fichiers propres côté run dir. + +## Contrainte d'écriture (rappel) +Main + agents en run-dir isolé ne peuvent PAS écrire l'arbre du project root (docs/, ARCHITECTURE.md, .ideai/memory/*.md fichiers) — permissions déniées. Mémoire IdeA : outil `idea_memory_write` (create/replace par slug ; pas de delete). Suppression fichiers mémoire / édition MEMORY.md / CLAUDE.md / .gitignore du project root : passer par **Git**. \ No newline at end of file diff --git a/.ideai/memory/skills-integration-canonical-foundation.md b/.ideai/memory/skills-integration-canonical-foundation.md new file mode 100644 index 0000000..4b7ba37 --- /dev/null +++ b/.ideai/memory/skills-integration-canonical-foundation.md @@ -0,0 +1,11 @@ +--- +name: skills-integration-canonical-foundation +description: Topologie d'intégration du chantier skills agent et décision d'abandon de feature/agent-skills. +metadata: + type: project +--- +Le chantier « skills agent » : la fondation canonique du domaine skills est **déjà dans `develop`** (introduite par `2332b7f` : port `SkillStore`, `SkillRef` sur `ManifestEntry`, `resolve_skills` dans `LaunchAgent`, CRUD + assign/unassign, **frontend complet** `features/skills/*`). + +- `feature/agent-skills` (A, @ef101db) = réimplémentation **obsolète et incompatible** (serde `tag="type"`, `with_updated_content`, pas de `SkillRef`/frontend). **À abandonner** — ne jamais merger, elle régresse develop. +- `feature/agent-skill-awareness` (B, @5be8987) = **surensemble propre** de develop (sa base `8452333` est ancêtre de develop ; diff skill = +539/−3). Apporte : `Skill.description`/`effective_description`, manifeste, outil MCP `idea_skill_read` (variante domaine `OrchestratorCommand::ReadSkill`), brief « capacités IdeA » inconditionnel dans `compose_convention_file`. +- Intégration = cherry-pick `ab34363`(+`1a10d67` squash) puis `566bff4` sur develop ; **exclure** `e93a2c1` (cold-start, superseded par `8bb832c`) et le bruit `.ideai/*` (garder seulement `.ideai/memory/feature-agent-skill-awareness-design.md`). Dropper `e93a2c1` évite tout conflit sur `input/mod.rs`. Points chauds : `lifecycle.rs`, `state.rs`. \ No newline at end of file diff --git a/.ideai/skills/index.json b/.ideai/skills/index.json index 8da6007..8195ee9 100644 --- a/.ideai/skills/index.json +++ b/.ideai/skills/index.json @@ -4,7 +4,14 @@ { "id": "a72dac60-641c-4417-b0d7-94b8539f817a", "name": "build-appimage", + "description": null, "contentHash": "77cb33b978b242f6" + }, + { + "id": "86ea97df-1533-47e5-8809-dc2b02242567", + "name": "mcp-rendezvous-functional-test", + "description": null, + "contentHash": "9fc8260f64c3b9d5" } ] } \ No newline at end of file diff --git a/.ideai/skills/md/86ea97df-1533-47e5-8809-dc2b02242567.md b/.ideai/skills/md/86ea97df-1533-47e5-8809-dc2b02242567.md new file mode 100644 index 0000000..5c0a525 --- /dev/null +++ b/.ideai/skills/md/86ea97df-1533-47e5-8809-dc2b02242567.md @@ -0,0 +1,42 @@ +# Skill — Test fonctionnel du rendez-vous inter-agents MCP + +Workflow **ré-exécutable et évolutif** pour tester de bout en bout le rendez-vous `idea_ask_agent` ⇄ `idea_reply` d'IdeA, du trivial au plus tordu. À relancer après chaque rebuild qui touche l'orchestration MCP / le backstop / la live-state. **Ce plan évolue** : à chaque nouveau souci rencontré, ajoute/raffine un test Tn et note le défaut dans la section « Journal des défauts ». + +## Principe +- **Main est le demandeur** (`idea_ask_agent`) — test fidèle de « si je donne un message à un agent, il me répond ». +- Doublé d'**observation externe Bash** : + - ponts : `ss -xp | grep idea-mcp` (ESTAB) et `pgrep -af mcp-server` (2 process par requester = pont vivant). + - transcripts : `~/.claude/projects//*.jsonl` (horodatages **UTC**). Reply réel = `tool_use` idea_reply → tool_result `reply … delivered`. Wedge demandeur = transcript figé sur `tool_use` idea_ask_agent sans tool_result. 1er tour intact = 1re ligne user `parentUuid:null` = `[IdeA · tâche…]`. + - placement : `.ideai/layouts.json` (agent présent = cellule visible ; absent = background / node_id None). + - live-state : `idea_workstate_read` (last-writer-wins, pas temps réel). +- Règle structurelle : **binaire qui tourne = AppImage**. Tout fix backend ⇒ **rebuild + relance** d'IdeA (tue Main). Les tests s'enchaînent SANS relance *à l'intérieur d'une même version*. Vérifier avant T1 : `stat` mtime de l'AppImage récent + `git log -1` = HEAD attendu. + +## Setup avant T1 +1. Relancer IdeA, ouvrir le projet, **Main visible** (le demandeur). +2. Garder **2 cellules visibles libres** + lancer **QA et DevFrontend** dans des cellules visibles (cibles chaudes). +3. Laisser **Architect, DevBackend, Git ARRÊTÉS (froids)** : T3 teste le réveil à froid ; T9 utilise Architect+DevBackend froids. + +## Méthode d'exécution +On lance dans l'ordre, on **note** chaque verdict avec preuve réelle. Si un défaut **bloquant** apparaît : stop, ouvrir le cycle de fix (Architect→Git→DevBackend→QA, ou via subagents si le rendez-vous lui-même est cassé), rebuild + relance, puis **reprise depuis T1**. Un défaut **non bloquant** est consigné au Journal et on peut continuer si l'utilisateur le décide. + +## Ordre des tests (trivial → tordu) +- **T1 — Visibilité outils MCP.** Agent lancé par IdeA voit ses outils sans action manuelle. Vérif : pont ESTAB + 2 mcp-server par requester ; cibles froides = 0 process. +- **T2 — Cible CHAUDE + idea_reply trivial.** ask(agent chaud, « pong via idea_reply »). Attendu : `pong` inline borné, workstate `done`, pas de busy fantôme. +- **T3 — Réveil à FROID + idea_reply.** Cible arrêtée. Attendu : pont relancé (nouveau mcp-server), **1er tour non perdu**, reply remonte. +- **T4 — Cible BACKGROUND (node_id None).** Cible hors layout (sans cellule visible). Attendu : même protocole, multi-tours sans perte. +- **T5 — NO-REPLY (cible répond en prose, pas d'idea_reply).** Attendu : backstop libère le demandeur en temps borné avec `TargetReturnedNoReply` (retryable), **ET live-state de la cible purgée (pas de busy fantôme)**. Défaut historique #1. +- **T6 — Délégation MULTI-ÉTAPES puis idea_reply.** Cible lit plusieurs fichiers / lance une cmd PUIS idea_reply. Attendu : **pas de libération prématurée** pendant le travail, rapport final livré. (Piège turn-watcher : backstop tirait au 1er tour interne ~3 s.) +- **T7 — Tâche LOURDE > plafond** (impl + `cargo test`/clippy). Attendu : extension sur signe de vie OU message clair « cible active, plafond atteint » (≠ faux `-32001`) ; idea_reply tardif non perdu. Plafond `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS` (défaut 600000). +- **T8 — Délégations PARALLÈLES** (2 cibles en 1 tour Main). Attendu : rendez-vous multiples simultanés, multiplexage pont, 2 réponses distinctes. +- **T9 — TRANSITIVE A→B→C** (Main→Architect, Architect délègue à DevBackend puis reply à Main). Attendu : rendez-vous imbriqués, 2 attentes simultanées sans deadlock. +- **T10 — INTERRUPTION/annulation + reconcile reboot.** (a) Main interrompt un ask en vol → `working` cible purgé, pas de cascade, idea_reply tardif non-livrable proprement. (b) Au reboot, orphelins {working,waiting,blocked} & session morte → `idle` + `STALE_AT_RESTART_MARKER` (use case ReconcileLiveState). + +## Critère de fin +Tous les T1→T10 verts avec preuve réelle. Sinon : 1er défaut bloquant = corriger puis reprise T1. + +## Journal des défauts (à enrichir au fil des runs) +- **2026-06-24, run a6ced819 (AppImage 08:15, HEAD 1efe2f1)** — T1→T4 ✅. **T5 partiel** : backstop libère le demandeur en ~24 s (`TargetReturnedNoReply` retryable, wedge demandeur corrigé) MAIS la **live-state de la cible n'est pas purgée** : elle reste `status:"working"` jusqu'au prochain write réussi de la cible (re-délégation non bloquée, busy fantôme atténué mais présent → `idea_workstate_read` ment sur la dispo). Cause : backstop agit côté demandeur, ne réinitialise pas la live-state de la cible sur no-reply. → fix en cours. + +## Notes / pièges observés +- IdeA peut placer une cible réveillée en **background** même quand des cellules visibles sont libres (observé T3/T4). +- Quand le rendez-vous MCP lui-même est suspecté cassé, **ne pas** orchestrer le fix via `idea_ask_agent` (risque de blocage) : utiliser les subagents natifs de l'orchestrateur pour analyse/fix/rebuild. \ No newline at end of file diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index a4b7100..45210a7 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -617,6 +617,13 @@ pub struct AppState { /// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter /// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment. pub turn_watch_handles: Mutex>>, + /// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous + /// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que + /// l'inspecteur et le turn-watcher. + pub fs_port: Arc, + /// Répertoire home (`$HOME`) servant à dériver `/.claude/projects/` + /// pour la sonde d'activité du rendez-vous. + pub home_dir: String, } impl AppState { @@ -1028,7 +1035,7 @@ impl AppState { // — lit le même `/.claude/projects//` que l'inspecteur, via // le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`). let turn_watcher: Arc = Arc::new( - infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir), + infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir.clone()), ); let inspect_conversation = Arc::new(InspectConversation::new( Arc::clone(&contexts_port), @@ -1383,7 +1390,39 @@ impl AppState { // sa racine à la construction). Sans ça, l'outil renverrait « not configured ». .with_live_state_read(Arc::new(AppLiveStateLeanProvider { clock: Arc::clone(&clock) as Arc, - }) as Arc), + }) as Arc) + // Fenêtre d'inactivité réarmable (signe de vie) du rendez-vous délégué : la + // borne de tour dans le service n'est plus un timeout plat mais une fenêtre + // réarmée à chaque progrès observé de la cible (octets cumulés de son + // transcript Claude), sous le plafond ci-dessous. SANS cette sonde, la borne + // dégrade vers un timeout plat (fallback, zéro régression) et coupe un long + // tour unique à 600 s — c'est précisément la sonde qui rend le fix effectif. + // Keyée par (project_root, agent_id) : le run-dir transcript = `/.ideai/ + // run/`, encodé par Claude sous `/.claude/projects/...`. + .with_ask_liveness_probe({ + let fs = Arc::clone(&fs_port); + let home = home_dir.clone(); + Arc::new(move |root: domain::project::ProjectPath, agent_id| { + let fs = Arc::clone(&fs); + let home = home.clone(); + Box::pin(async move { + let run_dir = format!( + "{}/.ideai/run/{agent_id}", + root.as_str().trim_end_matches(['/', '\\']) + ); + let cwd = domain::project::ProjectPath::new(run_dir).ok()?; + infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await + }) as std::pin::Pin> + Send>> + }) as application::AskLivenessProbe + }) + // Plafond absolu du rendez-vous délégué (réglage projet via + // `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : la fenêtre réarmée ne parque + // jamais un `ask` au-delà, même contre une cible perpétuellement active. + .with_ask_ceiling(application::resolve_rendezvous_ceiling( + std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS") + .ok() + .and_then(|v| v.trim().parse::().ok()), + )), // NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici. // Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc @@ -1502,6 +1541,8 @@ impl AppState { turn_watcher, turn_watch_input: Arc::clone(&input_mediator), turn_watch_handles: Mutex::new(HashMap::new()), + fs_port: Arc::clone(&fs_port), + home_dir, move_tab, } } @@ -1595,11 +1636,50 @@ impl AppState { .ok() .and_then(|v| v.trim().parse::().ok()), ); + // Plafond absolu du rendez-vous (réglage projet optionnel via + // `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : borne dure que la fenêtre + // d'inactivité réarmée ne dépasse jamais, même contre une cible perpétuellement active. + let ask_ceiling = infrastructure::resolve_ask_rendezvous_ceiling( + std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS") + .ok() + .and_then(|v| v.trim().parse::().ok()), + ); + // Sonde d'activité de la cible (signe de vie du rendez-vous) : l'McpServer (infra) ne + // connaît la cible que par son NOM ; la composition root est la seule à savoir + // résoudre nom→AgentId puis dériver le run-dir transcript Claude. La sonde renvoie un + // jeton monotone = octets cumulés des `.jsonl` de la cible (croît même pendant un seul + // long tour sans `turn_duration`). `None` ⇒ cible/transcript introuvable ⇒ traité comme + // « pas de progrès » par le watchdog. + let service_for_probe = Arc::clone(&self.orchestrator_service); + let fs_for_probe = Arc::clone(&self.fs_port); + let home_for_probe = self.home_dir.clone(); + let project_for_probe = project.clone(); + let activity_probe: infrastructure::AskActivityProbe = Arc::new(move |target: String| { + let service = Arc::clone(&service_for_probe); + let fs = Arc::clone(&fs_for_probe); + let home = home_for_probe.clone(); + let project = project_for_probe.clone(); + Box::pin(async move { + let agent_id = service + .resolve_agent_id_by_name(&project, &target) + .await + .ok() + .flatten()?; + let run_dir = format!( + "{}/.ideai/run/{agent_id}", + project.root.as_str().trim_end_matches(['/', '\\']) + ); + let cwd = domain::project::ProjectPath::new(run_dir).ok()?; + infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await + }) + }); let handle = McpServerHandle::start( McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) .with_events(events) .with_ready_sink(ready_sink) - .with_ask_rendezvous_timeout(ask_timeout), + .with_ask_rendezvous_timeout(ask_timeout) + .with_ask_rendezvous_ceiling(ask_ceiling) + .with_activity_probe(activity_probe), endpoint, listener, project_id, diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 5d1be6c..8ae5944 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -69,6 +69,21 @@ pub enum AppError { )] TargetReturnedNoReply(String), + /// The delegation target is **still actively working** (its transcript kept + /// growing across liveness probes) but the rendezvous **absolute ceiling** was + /// reached, so the synchronous wait was abandoned. **Distinct** from + /// [`Self::TargetReturnedNoReply`]: the target is *not* silent, so a blind retry + /// would stack a second heavy turn on a target already mid-task. **Not** a mute + /// timeout — the caller should check the target's in-progress work/branch (it may + /// still finish and call `idea_reply`) before re-soliciting lightly. Carries the + /// target's display name. + #[error( + "target {0} is still actively working but the rendezvous ceiling was reached; \ + do not retry blindly — check the target's in-progress work/branch (it may still \ + finish and call idea_reply), then re-solicit lightly if needed" + )] + TargetCeilingActive(String), + /// An unexpected internal error. #[error("internal error: {0}")] Internal(String), @@ -89,6 +104,7 @@ impl AppError { Self::Remote(_) => "REMOTE", Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING", Self::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY", + Self::TargetCeilingActive(_) => "RENDEZVOUS_CEILING_ACTIVE", Self::Internal(_) => "INTERNAL", } } @@ -173,3 +189,31 @@ impl From for AppError { Self::Process(e.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// La cible-active-au-plafond porte un code **stable et distinct** du no-reply + /// (l'appelant peut brancher sans parser le message) et un message « ne retente + /// pas à l'aveugle » différent du retour-au-prompt-sans-reply. + #[test] + fn ceiling_active_code_is_stable_and_distinct() { + let ceiling = AppError::TargetCeilingActive("architect".to_owned()); + assert_eq!(ceiling.code(), "RENDEZVOUS_CEILING_ACTIVE"); + + let no_reply = AppError::TargetReturnedNoReply("architect".to_owned()); + assert_eq!(no_reply.code(), "TARGET_RETURNED_NO_REPLY"); + + // Deux issues TYPÉES distinctes (le cœur du cadrage Architect) : un plafond + // atteint sur cible active n'est jamais confondu avec un silence/no-reply. + assert_ne!(ceiling.code(), no_reply.code()); + // Et distinct d'un timeout muet (`Process` via AgentSessionError::Timeout). + assert_ne!( + ceiling.code(), + AppError::from(AgentSessionError::Timeout).code() + ); + // Le message guide explicitement vers « ne pas retenter à l'aveugle ». + assert!(ceiling.to_string().contains("do not retry blindly")); + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index c8a8aae..1c37995 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -85,9 +85,11 @@ pub use memory::{ UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; pub use orchestrator::{ - ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, - OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory, - RecordTurnProvider, WriteMemory, + resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, + AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, + McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, + ReadMemory, RecordTurnProvider, WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING, + DEFAULT_RENDEZVOUS_WINDOW, }; pub use permission::{ GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index 963fbed..a204525 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -5,13 +5,18 @@ //! ever spawning a process itself. See [`service::OrchestratorService`]. mod context_guard; +pub mod rendezvous; mod service; pub use context_guard::{ ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, ReadMemoryInput, WriteMemory, WriteMemoryInput, }; -pub use service::{ - ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, - OrchestratorOutcome, OrchestratorService, RecordTurnProvider, +pub use rendezvous::{ + resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, + WatchdogOutcome, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW, +}; +pub use service::{ + AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, + McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider, }; diff --git a/crates/application/src/orchestrator/rendezvous.rs b/crates/application/src/orchestrator/rendezvous.rs new file mode 100644 index 0000000..7411b1b --- /dev/null +++ b/crates/application/src/orchestrator/rendezvous.rs @@ -0,0 +1,294 @@ +//! Inactivity watchdog for the synchronous inter-agent rendezvous +//! (`idea_ask_agent` ⇄ `idea_reply`). +//! +//! # Why this exists (root cause) +//! +//! A delegation target can legitimately work for a **very long time in a single +//! turn** (implement + `cargo test`/`clippy`), without ever returning to its prompt +//! nor emitting an intermediate signal the application can race against: the AI CLI +//! turn ([`domain::ports::AgentSession::send`]) only resolves **once the whole turn +//! is done** — its `ReplyEvent`s arrive as one batch at the end, not incrementally. +//! A **flat absolute timeout** around that wait therefore cuts a target that is +//! manifestly alive (its transcript keeps growing) at the cap, surfacing a generic, +//! non-actionable timeout. +//! +//! The fix is to bound the wait with an **inactivity window** re-armed on an observed +//! **sign of life**, under an **absolute ceiling**, with **distinct typed outcomes**: +//! +//! - progress observed under the ceiling ⇒ re-arm the window (no timeout); +//! - progress observed but the absolute ceiling is reached ⇒ a distinct +//! [`WatchdogOutcome::CeilingActive`] (the target is *active*, not silent — a blind +//! retry would stack a second heavy turn), never a mute timeout; +//! - genuine silence (no progress across a whole window) ⇒ +//! [`WatchdogOutcome::NoReply`], exactly the previous timeout semantics. +//! +//! # Sign of life +//! +//! Liveness cannot come from the event stream (it is batched at end-of-turn, see +//! above), so it is supplied by an injected **monotonic activity probe** (in practice +//! the cumulative byte size of the target's transcript, which grows mid-turn). The +//! probe is a pure closure here — the algorithm stays free of infrastructure and is +//! unit-testable with a synthetic future + probe. +//! +//! # DRY +//! +//! This is the **single** implementation of that algorithm. The application layer +//! (`OrchestratorService`) governs the *delegated turn* with it (the seam that +//! actually cuts a long turn), and the infrastructure MCP adapter re-uses the very +//! same function as its outer safety net — no second, diverging copy. + +use std::future::Future; +use std::time::{Duration, Instant}; + +/// Verdict of [`run_inactivity_watchdog`] over an arbitrary `dispatch` future, +/// decoupled from any concrete service/adapter so the algorithm is unit-testable +/// with a synthetic future + probe. +#[derive(Debug)] +pub enum WatchdogOutcome { + /// The dispatch resolved before the watchdog tripped — carries its value. + Resolved(T), + /// Genuine silence (no observable progress across a window), or the flat-window + /// fallback when no probe is wired ⇒ the **retryable** no-reply / timeout verdict. + NoReply, + /// The absolute ceiling was reached **while still observing progress** ⇒ the + /// distinct, **non-retryable-blind** "target active, ceiling reached" verdict. + CeilingActive, +} + +/// Races `dispatch` against an inactivity `window`, re-arming the window each time the +/// target shows **progress** (the monotonic token from `probe` strictly increased, or +/// became observable for the first time) until the dispatch resolves or the absolute +/// `ceiling` (measured from `started`) is reached. +/// +/// - dispatch resolves first ⇒ [`WatchdogOutcome::Resolved`]; +/// - window elapses with **no progress** (token unchanged/regressed/lost, or +/// `has_probe` is false ⇒ flat-window fallback) ⇒ [`WatchdogOutcome::NoReply`]; +/// - window elapses **with progress** but `started.elapsed() >= ceiling` ⇒ +/// [`WatchdogOutcome::CeilingActive`]; otherwise the window is re-armed. +/// +/// `has_probe` distinguishes "no probe wired at all" (flat fallback: never progress, +/// = the previous flat-timeout behaviour ⇒ zero regression) from "probe wired but +/// currently returns `None`" (cannot prove liveness ⇒ treated as no progress). +/// +/// `on_extended` / `on_expired` / `on_ceiling` are best-effort observation hooks +/// (diagnostics beacons); pass no-ops to ignore them. +#[allow(clippy::too_many_arguments)] +pub async fn run_inactivity_watchdog( + dispatch: impl Future, + window: Duration, + ceiling: Duration, + started: Instant, + has_probe: bool, + mut probe: F, + mut on_extended: impl FnMut(Duration), + mut on_expired: impl FnMut(Duration), + mut on_ceiling: impl FnMut(Duration), +) -> WatchdogOutcome +where + F: FnMut() -> Fut, + Fut: Future>, +{ + tokio::pin!(dispatch); + // Baseline token before the first window; progress is judged relative to it. + let mut last_token = if has_probe { probe().await } else { None }; + loop { + match tokio::time::timeout(window, &mut dispatch).await { + Ok(result) => return WatchdogOutcome::Resolved(result), + Err(_elapsed) => { + let elapsed = started.elapsed(); + let now = if has_probe { probe().await } else { None }; + // Progress iff a probe is wired AND the token strictly advanced (or + // became observable for the first time). No probe ⇒ never progress + // (flat-window fallback). Token lost ⇒ no proof of life ⇒ no progress. + let progressed = match (has_probe, last_token, now) { + (false, _, _) => false, + (true, Some(prev), Some(cur)) => cur > prev, + (true, None, Some(_)) => true, + (true, _, None) => false, + }; + if progressed { + if elapsed >= ceiling { + on_ceiling(elapsed); + return WatchdogOutcome::CeilingActive; + } + on_extended(elapsed); + last_token = now; + continue; + } + on_expired(elapsed); + return WatchdogOutcome::NoReply; + } + } + } +} + +/// Default inactivity window (silence budget) of the inter-agent rendezvous: the size +/// of **one** no-progress probe window, NOT an absolute cap. 600 s, matching the +/// historical flat cap so a genuinely silent target still expires in the same delay. +pub const DEFAULT_RENDEZVOUS_WINDOW: Duration = Duration::from_secs(600); + +/// Default absolute ceiling of the inter-agent rendezvous: the extending inactivity +/// window never parks a call past this even against a perpetually-busy target. +/// Generous (4 h) so a heavy-but-real single turn finishes well within it. +pub const DEFAULT_RENDEZVOUS_CEILING: Duration = Duration::from_secs(4 * 60 * 60); + +/// Resolves the effective rendezvous **inactivity window** from an optional override in +/// **milliseconds**: `Some(ms>0)` ⇒ that window, else the [`DEFAULT_RENDEZVOUS_WINDOW`]. +/// `Some(0)` is treated as "no override" so a misconfigured zero can never collapse the +/// window to an instant expiry. Pure and unit-testable without wiring. +#[must_use] +pub fn resolve_rendezvous_window(override_ms: Option) -> Duration { + match override_ms { + Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)), + _ => DEFAULT_RENDEZVOUS_WINDOW, + } +} + +/// Resolves the effective rendezvous **absolute ceiling** from an optional override in +/// **milliseconds**: `Some(ms>0)` ⇒ that ceiling, else [`DEFAULT_RENDEZVOUS_CEILING`]. +/// `Some(0)` is treated as "no override". Pure and unit-testable without wiring. +#[must_use] +pub fn resolve_rendezvous_ceiling(override_ms: Option) -> Duration { + match override_ms { + Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)), + _ => DEFAULT_RENDEZVOUS_CEILING, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + /// A `dispatch` future that never resolves — stands in for a target busy in one + /// long turn (or wedged) that has not yet produced a reply. + async fn never() -> Result { + std::future::pending::<()>().await; + unreachable!() + } + + const WINDOW: Duration = Duration::from_millis(30); + + fn noop(_: Duration) {} + + /// (c) Vrai silence : un probe est câblé mais son token n'avance jamais ⇒ le + /// watchdog expire en ~une fenêtre avec le verdict no-reply (pas d'extension), + /// soit exactement l'ancien timeout plat. + #[tokio::test] + async fn silent_target_expires_no_reply() { + let started = Instant::now(); + let probe = || async { Some(42_u64) }; + let outcome = run_inactivity_watchdog( + never(), + WINDOW, + Duration::from_secs(3600), + started, + true, + probe, + noop, + noop, + noop, + ) + .await; + assert!(matches!(outcome, WatchdogOutcome::NoReply)); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + /// (d) Non-régression du fallback fenêtre plate : sans probe câblé, la première + /// expiration de fenêtre est un no-reply (= ancien timeout plat). + #[tokio::test] + async fn no_probe_falls_back_to_flat_window() { + let started = Instant::now(); + let probe = || async { None }; + let outcome = run_inactivity_watchdog( + never(), + WINDOW, + Duration::from_secs(3600), + started, + false, + probe, + noop, + noop, + noop, + ) + .await; + assert!(matches!(outcome, WatchdogOutcome::NoReply)); + } + + /// (a) Cible qui progresse AU-DELÀ de l'ancien plafond : le probe avance à chaque + /// tick (extension) bien au-delà d'une fenêtre, puis la dispatch se résout ⇒ AUCUN + /// timeout prématuré, le résultat est rendu. C'est le défaut central corrigé. + #[tokio::test] + async fn progressing_target_extends_then_resolves() { + let started = Instant::now(); + let tick = Arc::new(AtomicU64::new(0)); + let tick2 = Arc::clone(&tick); + let probe = move || { + let tick = Arc::clone(&tick2); + async move { Some(tick.fetch_add(1, Ordering::SeqCst) + 1) } + }; + // Résout après ~plusieurs fenêtres (110 ms ≫ 30 ms) : sans réarmement, un + // timeout plat de 30 ms aurait coupé. Le watchdog doit étendre et rendre 7. + let dispatch = async { + tokio::time::sleep(Duration::from_millis(110)).await; + Ok::(7) + }; + let outcome = run_inactivity_watchdog( + dispatch, + WINDOW, + Duration::from_secs(3600), + started, + true, + probe, + noop, + noop, + noop, + ) + .await; + match outcome { + WatchdogOutcome::Resolved(Ok(v)) => assert_eq!(v, 7), + _ => panic!("expected Resolved(Ok(7)) — la fenêtre aurait dû être réarmée"), + } + } + + /// (b) Plafond absolu atteint malgré progrès : le probe avance toujours (cible + /// active) mais le plafond est minuscule ⇒ verdict DISTINCT CeilingActive (pas un + /// faux no-reply muet), et l'appel termine (jamais parqué à l'infini). + #[tokio::test] + async fn progressing_target_hits_ceiling_distinctly() { + let started = Instant::now(); + let tick = Arc::new(AtomicU64::new(0)); + let tick2 = Arc::clone(&tick); + let probe = move || { + let tick = Arc::clone(&tick2); + async move { Some(tick.fetch_add(1, Ordering::SeqCst) + 1) } + }; + let outcome = run_inactivity_watchdog( + never(), + WINDOW, + Duration::from_millis(45), + started, + true, + probe, + noop, + noop, + noop, + ) + .await; + assert!(matches!(outcome, WatchdogOutcome::CeilingActive)); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + /// Les résolveurs honorent un override et retombent sur leurs défauts finis ; + /// `Some(0)` ⇒ "pas d'override" (jamais d'effondrement instantané). + #[test] + fn resolvers_apply_override_else_finite_default() { + assert_eq!(resolve_rendezvous_window(Some(1234)), Duration::from_millis(1234)); + assert_eq!(resolve_rendezvous_window(Some(0)), DEFAULT_RENDEZVOUS_WINDOW); + assert_eq!(resolve_rendezvous_window(None), DEFAULT_RENDEZVOUS_WINDOW); + assert_eq!(resolve_rendezvous_ceiling(Some(9999)), Duration::from_millis(9999)); + assert_eq!(resolve_rendezvous_ceiling(Some(0)), DEFAULT_RENDEZVOUS_CEILING); + assert_eq!(resolve_rendezvous_ceiling(None), DEFAULT_RENDEZVOUS_CEILING); + } +} diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 93baf2a..cb3edd8 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -39,6 +39,7 @@ use crate::agent::{ UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS, }; use crate::error::AppError; +use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome}; use crate::orchestrator::{ ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, ReadMemoryInput, WriteMemory, WriteMemoryInput, @@ -85,6 +86,35 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig { /// ([`resolve_turn_timeout`]). const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(600); +/// **Sonde de vivacité** d'une cible de délégation, keyée par son [`AgentId`] : renvoie +/// un **jeton d'activité** monotone non-décroissant (en pratique la taille cumulée du +/// transcript de la cible). Un jeton strictement plus grand entre deux sondes ⇒ la cible +/// **progresse** (vivante et au travail, **même dans un seul long tour** qui n'émet aucun +/// événement intermédiaire — cf. [`rendezvous`](crate::orchestrator::rendezvous)). +/// `None` ⇒ vivacité non observable (cible/transcript introuvable) ⇒ traité comme +/// « pas de progrès ». +/// +/// **Pourquoi un port et pas le flux d'événements** : le flux de +/// [`domain::ports::AgentSession::send`] est **batché en fin de tour** (les `ReplyEvent` +/// arrivent tous à la fin, pas en continu), donc inutilisable comme signe de vie *pendant* +/// un long tour. La seule preuve de vie disponible est cette sonde externe (transcript). +/// Closure abstraite (frontière hexagonale) construite à la composition root — la seule +/// couche qui sait dériver le run-dir transcript d'un `AgentId` ; l'application reste pure. +pub type AskLivenessProbe = Arc< + dyn Fn( + domain::project::ProjectPath, + AgentId, + ) -> std::pin::Pin> + Send>> + + Send + + Sync, +>; + +/// Plafond **absolu** par défaut du rendez-vous délégué : la fenêtre d'inactivité +/// réarmée ne parque jamais un `ask` au-delà, même contre une cible perpétuellement +/// active. Généreux (4 h) pour qu'un tour unique lourd mais réel finisse bien avant. +/// Surchargeable via `IDEA_ASK_RENDEZVOUS_CEILING_MS` à la composition root. +const ASK_AGENT_CEILING: Duration = crate::orchestrator::rendezvous::DEFAULT_RENDEZVOUS_CEILING; + /// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0, /// cadrage v5 §4). /// @@ -362,6 +392,17 @@ pub struct OrchestratorService { /// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur /// typée « not configured » (call sites/tests legacy restent verts). live_state_read: Option>, + /// **Sonde de vivacité** de la cible (signe de vie) pilotant la fenêtre d'inactivité + /// du rendez-vous délégué (cf. [`AskLivenessProbe`] et + /// [`rendezvous`](crate::orchestrator::rendezvous)). Injectée via + /// [`Self::with_ask_liveness_probe`]. `None` ⇒ la borne de tour reste un **timeout + /// plat** (fallback fenêtre plate = comportement historique, zéro régression). + ask_liveness_probe: Option, + /// Plafond **absolu** du rendez-vous délégué (cf. [`ASK_AGENT_CEILING`]) : la fenêtre + /// d'inactivité réarmée ne parque jamais un `ask` au-delà. Surchargeable via + /// [`Self::with_ask_ceiling`] (la composition root résout l'override d'env) ; les + /// tests le rétrécissent à quelques ms. Défaut = 4 h. + ask_ceiling: Duration, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés @@ -429,9 +470,32 @@ impl OrchestratorService { read_skill: None, live_state: None, live_state_read: None, + ask_liveness_probe: None, + ask_ceiling: ASK_AGENT_CEILING, } } + /// Branche la **sonde de vivacité** (signe de vie) du rendez-vous délégué : la borne + /// de tour devient une **fenêtre d'inactivité** réarmée à chaque progrès observé, + /// sous le plafond [`Self::with_ask_ceiling`]. Builder additif (signature de + /// [`Self::new`] inchangée) ; sans sonde, la borne reste un timeout plat (fallback, + /// zéro régression). Cf. [`AskLivenessProbe`]. + #[must_use] + pub fn with_ask_liveness_probe(mut self, probe: AskLivenessProbe) -> Self { + self.ask_liveness_probe = Some(probe); + self + } + + /// Surcharge le **plafond absolu** du rendez-vous délégué (défaut [`ASK_AGENT_CEILING`] + /// = 4 h) au-delà duquel la fenêtre d'inactivité réarmée ne parque jamais un `ask`. + /// La composition root y applique l'override d'env ; les tests le rétrécissent. + /// Builder additif. + #[must_use] + pub fn with_ask_ceiling(mut self, ceiling: Duration) -> Self { + self.ask_ceiling = ceiling; + self + } + /// Branche le use case [`ReadSkill`] (`skill.read`/`idea_skill_read`). Builder /// additif (signature de [`Self::new`] inchangée) ; `None` ⇒ la commande /// renvoie une erreur typée « not configured ». @@ -1382,10 +1446,16 @@ impl OrchestratorService { // turn into the bound handle). The service no longer writes the PTY directly — // no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1). - // 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ le garde retire le ticket - // (cible laissée vivante) et la ramène `Idle` au Drop ; renvoie une erreur typée. - match tokio::time::timeout(turn_timeout, pending).await { - Ok(Ok(TurnResolution::Replied(result))) => { + // 3. Attendre la réponse, bornée par la **fenêtre d'inactivité réarmable** + // (signe de vie = sonde de transcript) au lieu d'un timeout plat : un long + // tour unique qui progresse n'est plus coupé à `turn_timeout`. Vrai silence + // ⇒ timeout typé (comme avant) ; plafond atteint malgré progrès ⇒ erreur + // typée distincte. Canal fermé ⇒ le garde retire le ticket au Drop. + let verdict = self + .run_ask_with_watchdog(pending, turn_timeout, &project.root, agent_id, &target, started) + .await; + match verdict { + WatchdogOutcome::Resolved(Ok(TurnResolution::Replied(result))) => { // Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de // déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est // elle qui a rendu le tour) ; même conversation que le Prompt. @@ -1424,9 +1494,14 @@ impl OrchestratorService { // déjà retiré (`complete_without_reply`) et la cible déjà `Idle` (prompt-ready // `mark_idle`) ⇒ on **désarme** le garde (pas de `cancel_head`/`mark_idle` // redondant ni de beacon « busy-guard freed » trompeur) et on renvoie une - // erreur typée claire/retryable, en ~G au lieu du timeout long. - Ok(Ok(TurnResolution::ReturnedToPromptNoReply)) => { + // erreur typée claire/retryable, en ~G au lieu du timeout long. On repasse + // aussi la live-state de la cible à `Done` (best-effort) : le tour est conclu + // sans réponse, donc `idea_workstate_read` ne doit pas la voir `Working` + // (busy fantôme) comme sur la branche succès. + WatchdogOutcome::Resolved(Ok(TurnResolution::ReturnedToPromptNoReply)) => { busy_guard.disarm(); + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; crate::diag!( "[rendezvous] ask returned-to-prompt-no-reply: target={target} \ (agent {agent_id}) ticket={ticket_id} after_ms={}", @@ -1434,9 +1509,23 @@ impl OrchestratorService { ); Err(AppError::TargetReturnedNoReply(target)) } + // Plafond absolu atteint alors que la cible **progresse encore** (sonde de vie + // croissante) : verdict DISTINCT, non un faux timeout muet. Le garde fait + // `cancel_head` + `mark_idle` au Drop ; on réconcilie aussi la live-state à + // `Done` pour qu'un abandon ne laisse pas un busy fantôme. + WatchdogOutcome::CeilingActive => { + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; + crate::diag!( + "[rendezvous] ask CEILING (still active): target={target} \ + (agent {agent_id}) ticket={ticket_id} after_ms={}", + started.elapsed().as_millis(), + ); + Err(AppError::TargetCeilingActive(target)) + } // Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au // Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent). - Ok(Err(_cancelled)) => { + WatchdogOutcome::Resolved(Err(_cancelled)) => { crate::diag!( "[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \ ticket={ticket_id} after_ms={}", @@ -1446,11 +1535,17 @@ impl OrchestratorService { "agent {target} : canal de réponse fermé avant un résultat" ))) } - Err(_elapsed) => { + WatchdogOutcome::NoReply => { + // Vrai silence (aucun progrès sur une fenêtre) : sémantique identique à + // l'ancien timeout plat. On réconcilie la live-state à `Done` pour qu'un + // abandon ne laisse pas la cible `Working` (busy fantôme) ; le garde fait + // `cancel_head` + `mark_idle` au Drop. + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; crate::diag!( "[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \ ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \ - ni atteint son prompt-ready dans le turn_timeout)", + ni atteint son prompt-ready, et aucun progrès observé sur la fenêtre)", started.elapsed().as_millis(), ); Err(AppError::from(domain::ports::AgentSessionError::Timeout)) @@ -1552,74 +1647,90 @@ impl OrchestratorService { turn_timeout.as_millis(), ); - // Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le - // **même** garde-fou que le chemin PTY (profil ou défaut). On attend la - // **première** issue : le tour structuré OU un `idea_reply` explicite. - let drain = - drain_with_readiness(session, &task, Some(turn_timeout), input.as_ref(), agent_id); + // Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`). Le drain lui-même + // est **non borné** (`None`) : la borne de tour est désormais portée par la + // **fenêtre d'inactivité réarmable** autour de l'attente (signe de vie = sonde de + // transcript), pas par un timeout plat interne — un long tour unique qui progresse + // n'est plus coupé à `turn_timeout`. On attend la **première** issue : le tour + // structuré OU un `idea_reply` explicite. + let drain = drain_with_readiness(session, &task, None, input.as_ref(), agent_id); - let result = tokio::select! { - biased; - // Issue déterministe : la session a rendu son `Final`. - drained = drain => match drained { - Ok(content) => { - crate::diag!( - "[rendezvous] ask resolved (structured/final): target={target} \ - (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}", - started.elapsed().as_millis(), - content.len(), - ); - content - } - // Erreur de drain : le garde fait `cancel_head` + `mark_idle` au Drop. - Err(err) => { - crate::diag!( - "[rendezvous] ask drain-error (structured): target={target} \ - (agent {agent_id}) ticket={ticket_id} after_ms={} err={err}", - started.elapsed().as_millis(), - ); - return Err(AppError::from(err)); - } - }, - // Issue alternative : un `idea_reply` explicite a résolu le ticket d'abord. - replied = pending => match replied { - Ok(TurnResolution::Replied(content)) => { - // La session draine encore en arrière-plan ; on s'assure que la FIFO - // avance même si le `Final` n'est pas encore observé. - input.mark_idle(agent_id); - crate::diag!( - "[rendezvous] ask resolved (structured/reply): target={target} \ - (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}", - started.elapsed().as_millis(), - content.len(), - ); - content - } - // Retour au prompt sans `idea_reply` : très rare sur le chemin structuré - // (la cible n'a pas de PTY/watcher), mais on traite la résolution comme - // sur le chemin PTY — erreur typée claire/retryable, garde désarmé. - Ok(TurnResolution::ReturnedToPromptNoReply) => { - input.mark_idle(agent_id); - busy_guard.disarm(); - crate::diag!( - "[rendezvous] ask returned-to-prompt-no-reply (structured): \ - target={target} (agent {agent_id}) ticket={ticket_id} after_ms={}", - started.elapsed().as_millis(), - ); - return Err(AppError::TargetReturnedNoReply(target.to_owned())); - } - // Canal fermé : le garde fait `cancel_head` + `mark_idle` au Drop. - Err(_cancelled) => { - crate::diag!( - "[rendezvous] ask channel-closed (structured): target={target} \ - (agent {agent_id}) ticket={ticket_id} after_ms={}", - started.elapsed().as_millis(), - ); - return Err(AppError::Process(format!( + // L'attente du rendez-vous (drain OU reply), rendue comme `Result` + // pour être enveloppée par le watchdog (au lieu de `return` directs dans le `select!`). + let wait = async { + tokio::select! { + biased; + drained = drain => match drained { + Ok(content) => Ok(content), + Err(err) => Err(AppError::from(err)), + }, + replied = pending => match replied { + Ok(TurnResolution::Replied(content)) => { + // La session draine encore en arrière-plan ; on fait avancer la FIFO. + input.mark_idle(agent_id); + Ok(content) + } + Ok(TurnResolution::ReturnedToPromptNoReply) => { + input.mark_idle(agent_id); + Err(AppError::TargetReturnedNoReply(target.to_owned())) + } + Err(_cancelled) => Err(AppError::Process(format!( "agent {target} : canal de réponse fermé avant un résultat" - ))); - } - }, + ))), + }, + } + }; + + // Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu. + let result = match self + .run_ask_with_watchdog(wait, turn_timeout, &project.root, agent_id, target, started) + .await + { + WatchdogOutcome::Resolved(Ok(content)) => { + crate::diag!( + "[rendezvous] ask resolved (structured): target={target} \ + (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}", + started.elapsed().as_millis(), + content.len(), + ); + content + } + // Erreur de tour (drain-error, no-reply, canal fermé) : le garde fait + // `cancel_head` + `mark_idle` au Drop. On réconcilie la live-state à `Done` + // pour qu'un abandon ne laisse pas un busy fantôme. + WatchdogOutcome::Resolved(Err(err)) => { + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; + crate::diag!( + "[rendezvous] ask error (structured): target={target} (agent {agent_id}) \ + ticket={ticket_id} after_ms={} err={err}", + started.elapsed().as_millis(), + ); + return Err(err); + } + // Plafond absolu atteint alors que la cible progresse encore : verdict distinct. + WatchdogOutcome::CeilingActive => { + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; + crate::diag!( + "[rendezvous] ask CEILING (structured, still active): target={target} \ + (agent {agent_id}) ticket={ticket_id} after_ms={}", + started.elapsed().as_millis(), + ); + return Err(AppError::TargetCeilingActive(target.to_owned())); + } + // Vrai silence (aucun progrès sur la fenêtre) : timeout typé, identique à + // l'ancienne borne plate. Live-state réconciliée à `Done`. + WatchdogOutcome::NoReply => { + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; + crate::diag!( + "[rendezvous] ask TIMEOUT (structured): target={target} (agent {agent_id}) \ + ticket={ticket_id} after_ms={} (aucun progrès observé sur la fenêtre)", + started.elapsed().as_millis(), + ); + return Err(AppError::from(domain::ports::AgentSessionError::Timeout)); + } }; // Succès : désarmer le garde (le `mark_idle` propre est déjà porté par le `Final` @@ -1793,6 +1904,22 @@ impl OrchestratorService { } } + /// Résout l'`AgentId` d'un agent par son **nom d'affichage** (insensible à la casse) + /// dans le manifeste du projet, pour la composition root (qui ne connaît la cible d'un + /// `idea_ask_agent` que par son nom). Expose la résolution privée `find_agent_id_by_name` + /// afin que le filet serveur MCP puisse dériver le run-dir transcript de la cible (sonde + /// d'activité du rendez-vous). `None` si aucun agent ne porte ce nom. + /// + /// # Errors + /// Propage l'[`AppError`] du listing des agents (lecture du manifeste). + pub async fn resolve_agent_id_by_name( + &self, + project: &Project, + name: &str, + ) -> Result, AppError> { + self.find_agent_id_by_name(project, name).await + } + /// Déclare si une **cellule terminal frontend** est montée pour `agent` (write-portal /// actif). Pont entre le write-portal (adapter UI) et le médiateur : un agent avec /// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un @@ -2396,6 +2523,77 @@ impl OrchestratorService { resolve_turn_timeout(turn_timeout_ms) } + /// Borne un `wait` (l'attente synchrone du rendez-vous délégué) par la **fenêtre + /// d'inactivité réarmable** (cf. [`rendezvous`](crate::orchestrator::rendezvous)), + /// au lieu d'un timeout plat. La fenêtre vaut `window` (profil sinon défaut, la + /// **même** valeur que l'ancien timeout plat) ; à chaque expiration de fenêtre, la + /// sonde de vivacité de la cible est consultée : progrès ⇒ réarmement jusqu'au + /// plafond absolu [`Self::ask_ceiling`]. Verdict : + /// - [`WatchdogOutcome::Resolved`] : le `wait` a résolu (succès/erreur de tour) ⇒ rendu tel quel ; + /// - [`WatchdogOutcome::NoReply`] : vrai silence (aucun progrès sur une fenêtre) **ou** + /// fallback fenêtre plate (pas de sonde câblée) — sémantique **identique** à l'ancien + /// timeout de tour (l'appelant mappe vers le timeout typé) ; + /// - [`WatchdogOutcome::CeilingActive`] : progrès observé mais plafond atteint ⇒ verdict + /// distinct (l'appelant mappe vers [`AppError::TargetCeilingActive`]). + async fn run_ask_with_watchdog( + &self, + wait: impl std::future::Future, + window: Duration, + project_root: &domain::project::ProjectPath, + agent_id: AgentId, + target: &str, + started: Instant, + ) -> WatchdogOutcome { + let has_probe = self.ask_liveness_probe.is_some(); + let probe = self.ask_liveness_probe.clone(); + let root = project_root.clone(); + let probe_fn = move || { + let probe = probe.clone(); + let root = root.clone(); + async move { + match &probe { + Some(p) => p(root, agent_id).await, + None => None, + } + } + }; + let ext_target = target.to_owned(); + let exp_target = target.to_owned(); + let ceil_target = target.to_owned(); + let window_ms = window.as_millis(); + let ceiling_ms = self.ask_ceiling.as_millis(); + run_inactivity_watchdog( + wait, + window, + self.ask_ceiling, + started, + has_probe, + probe_fn, + move |elapsed| { + crate::diag!( + "[rendezvous] ask window extended (signe de vie): target={ext_target} \ + (agent {agent_id}) window_ms={window_ms} elapsed_ms={}", + elapsed.as_millis(), + ); + }, + move |elapsed| { + crate::diag!( + "[rendezvous] ask EXPIRED (no progress): target={exp_target} \ + (agent {agent_id}) window_ms={window_ms} elapsed_ms={}", + elapsed.as_millis(), + ); + }, + move |elapsed| { + crate::diag!( + "[rendezvous] ask CEILING active: target={ceil_target} (agent {agent_id}) \ + ceiling_ms={ceiling_ms} elapsed_ms={}", + elapsed.as_millis(), + ); + }, + ) + .await + } + /// Resolves a human-friendly profile reference (slug like `claude-code`, /// command like `claude`, or display name like `Claude Code`) to a configured /// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by diff --git a/crates/infrastructure/src/inspector/claude_paths.rs b/crates/infrastructure/src/inspector/claude_paths.rs index 0371b12..66b271a 100644 --- a/crates/infrastructure/src/inspector/claude_paths.rs +++ b/crates/infrastructure/src/inspector/claude_paths.rs @@ -17,7 +17,7 @@ //! [`ClaudeTranscriptInspector`]: super::ClaudeTranscriptInspector //! [`ClaudeTranscriptTurnWatcher`]: super::ClaudeTranscriptTurnWatcher -use domain::ports::RemotePath; +use domain::ports::{FileSystem, RemotePath}; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::ProjectPath; @@ -74,6 +74,38 @@ pub(crate) fn supports_claude(profile: &AgentProfile) -> bool { ) } +/// Cumulative byte size of **all** `.jsonl` transcripts in a Claude agent's per-run-dir +/// folder (`/.claude/projects//`) — a monotonic **activity token** for +/// the `idea_ask_agent` rendezvous inactivity watchdog (server-side safety net). +/// +/// Why bytes, not the `turn_duration` count: a target working in **one long turn** never +/// emits a `turn_duration` record until that turn ends, yet its transcript file keeps +/// **growing** as the model streams output. Summing transcript bytes therefore detects +/// "alive and working" even mid-turn — exactly the wedge the flat timeout mis-handled. +/// Best-effort and side-effect-free: a missing folder (cold start) or any unreadable file +/// contributes `0`; `None` only when the folder cannot be listed at all (treated by the +/// watchdog as "no observable progress"). Lives beside the path encoder so the transcript +/// layout knowledge stays in one tested seam. +pub async fn transcript_activity_token( + fs: &dyn FileSystem, + home_dir: &str, + cwd: &ProjectPath, +) -> Option { + let dir = claude_project_dir(home_dir, cwd); + let entries = fs.list(&dir).await.ok()?; + let mut total: u64 = 0; + for entry in entries { + if entry.is_dir || !entry.name.ends_with(".jsonl") { + continue; + } + let path = RemotePath::new(format!("{}/{}", dir.0, entry.name)); + if let Ok(bytes) = fs.read(&path).await { + total = total.saturating_add(bytes.len() as u64); + } + } + Some(total) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/infrastructure/src/inspector/mod.rs b/crates/infrastructure/src/inspector/mod.rs index fc23515..68263f5 100644 --- a/crates/infrastructure/src/inspector/mod.rs +++ b/crates/infrastructure/src/inspector/mod.rs @@ -12,4 +12,5 @@ mod claude_paths; mod claude_turn_watcher; pub use claude::ClaudeTranscriptInspector; +pub use claude_paths::transcript_activity_token; pub use claude_turn_watcher::ClaudeTranscriptTurnWatcher; diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 27ce230..efe9ac5 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -48,10 +48,11 @@ pub use fs::LocalFileSystem; pub use git::Git2Repository; pub use id::UuidGenerator; pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; -pub use inspector::{ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher}; +pub use inspector::{transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher}; pub use mailbox::InMemoryMailbox; pub use orchestrator::mcp::{ - resolve_ask_rendezvous_timeout, McpServer, MemoryTransport, StdioTransport, + resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer, + MemoryTransport, StdioTransport, }; pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, diff --git a/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs b/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs index babf697..5031d3e 100644 --- a/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs +++ b/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs @@ -130,6 +130,16 @@ pub mod error_codes { /// may re-ask, reminding the target to answer via `idea_reply`. The accompanying /// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`. pub const RENDEZVOUS_NO_REPLY: i32 = -32_001; + + /// **Server-defined** (JSON-RPC reserved range -32000..=-32099): the synchronous + /// `idea_ask_agent` rendezvous hit the **absolute ceiling** while the target was + /// **still actively working** (its transcript kept growing). **Distinct** from + /// [`RENDEZVOUS_NO_REPLY`]: the target is *not* silent -- it simply outran the hard + /// ceiling. It is therefore **non-retryable** (a blind re-ask would pile a second + /// heavy turn on a target already mid-task); the caller must inspect the target's + /// in-progress work/branch before re-soliciting lightly. The accompanying `data` + /// carries `{ "code": "RENDEZVOUS_CEILING_ACTIVE", "retryable": false }`. + pub const RENDEZVOUS_CEILING_ACTIVE: i32 = -32_002; } /// Errors a [`Transport`] may surface. diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index 86b8fa3..c775be4 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -36,6 +36,8 @@ pub mod transport; pub use jsonrpc::{ JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, }; -pub use server::{resolve_ask_rendezvous_timeout, McpServer}; +pub use server::{ + resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer, +}; pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError}; pub use transport::{MemoryTransport, StdioTransport}; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 999d38a..b743dd6 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -17,6 +17,8 @@ //! at the composition root (M3), exactly like the watcher — no application logic is //! duplicated here. +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -34,29 +36,51 @@ use super::tools::{self, ToolMapError}; /// The MCP protocol version this server speaks (advertised on `initialize`). const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; -/// **Server-side safety net** bounding the synchronous `idea_ask_agent` rendezvous. +/// **Inactivity window (silence budget)** for the synchronous `idea_ask_agent` +/// rendezvous — the size of one *no-progress* probe window, NOT an absolute cap. /// /// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another /// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application -/// layer). The application layer already bounds the rendezvous itself, but this -/// adapter adds its own **finite** outer bound so a wedged target can never park a -/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error -/// instead of hanging. +/// layer). Rather than a flat outer timeout (which wrongly fired on a single long +/// delegated turn that never returns to its prompt nor emits `turn_duration`), the +/// adapter runs an **inactivity watchdog**: it races the dispatch against this window +/// and, on each window expiry, probes whether the target made progress (its transcript +/// grew). Progress ⇒ the window is re-armed (extension) up to the absolute +/// [`ASK_RENDEZVOUS_CEILING`]; genuine silence ⇒ the no-reply error fires. 600 s by +/// default, overridable per project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other +/// tool (`idea_reply`, `idea_list_agents`, …) is left untouched — they don't rendezvous. /// -/// **Plancher universel FINI (backstop no-reply).** The real turn-end signal now exists -/// (the transcript `turn_duration` watcher), but only Claude emits it; this outer bound -/// stays the last resort for any silent target that never emits it (Codex & co) or that -/// wedges. It must therefore NEVER be (quasi-)infinite. 600 s by default, overridable per -/// project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other tool (`idea_reply`, -/// `idea_list_agents`, …) is left untouched — they don't rendezvous. +/// **Fallback (no activity probe).** When no probe is wired (legacy call sites, tests +/// that shrink the bound), this degrades to the previous *flat* timeout: the first +/// window expiry with no probe is treated as a no-reply expiry — zero regression. const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600); -/// Resolves the effective `idea_ask_agent` rendezvous bound from an optional -/// configured override in **milliseconds** (project/profile level), mirroring the -/// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound, -/// `None`/absent ⇒ the finite [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is -/// treated as "no override" so a misconfigured zero can never collapse the safety net to -/// an instant expiry. Pure and unit-testable without wiring. +/// **Absolute ceiling (hard cap)** of the `idea_ask_agent` rendezvous, so an extending +/// inactivity window can never park a `tools/call` forever even against a target that +/// stays *busy* indefinitely. Reaching it while the target is still actively working +/// yields the distinct, **non-retryable** [`rendezvous_ceiling_active_error`] (never a +/// blind-retry suggestion). Generous by default (4 h), overridable per project via +/// `IDEA_ASK_RENDEZVOUS_CEILING_MS`. +const ASK_RENDEZVOUS_CEILING: Duration = Duration::from_secs(4 * 60 * 60); + +/// A monotonic **activity probe** of a target agent, keyed by its display name. +/// +/// Returns an opaque, monotonically non-decreasing **activity token** (in practice the +/// cumulative byte size of the target's transcript `.jsonl` files): a strictly larger +/// token between two probes means the target made progress (it is alive and working, +/// even within a single long turn that never emits `turn_duration`). `None` ⇒ the +/// target/transcript could not be resolved (cold start, unknown name) — treated as "no +/// observable progress" so a truly stuck call still expires. Async because resolving the +/// token reads the filesystem; built at the composition root (the only layer that knows +/// the name→run-dir mapping), keeping this adapter free of `AgentId`. +pub type AskActivityProbe = + Arc Pin> + Send>> + Send + Sync>; + +/// Resolves the effective `idea_ask_agent` **inactivity window** from an optional +/// configured override in **milliseconds**: `Some(ms)` ⇒ that window, `None`/absent ⇒ +/// the [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is treated as "no override" +/// so a misconfigured zero can never collapse the window to an instant expiry. Pure and +/// unit-testable without wiring. #[must_use] pub fn resolve_ask_rendezvous_timeout(override_ms: Option) -> Duration { match override_ms { @@ -65,6 +89,19 @@ pub fn resolve_ask_rendezvous_timeout(override_ms: Option) -> Duration { } } +/// Resolves the effective `idea_ask_agent` **absolute ceiling** from an optional +/// configured override in **milliseconds**: `Some(ms)` ⇒ that ceiling, `None`/absent ⇒ +/// the [`ASK_RENDEZVOUS_CEILING`] default (4 h). `Some(0)` is treated as "no override". +/// Pure and unit-testable without wiring. +#[must_use] +pub fn resolve_ask_rendezvous_ceiling(override_ms: Option) -> Duration { + match override_ms { + Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)), + _ => ASK_RENDEZVOUS_CEILING, + } +} + + /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. /// /// Cheap to clone the dependencies it holds; one instance serves one project's @@ -93,11 +130,21 @@ pub struct McpServer { /// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la /// composition root parse le `requester`. `None` ⇒ no-op. ready_sink: Option>, - /// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see - /// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a - /// few milliseconds without polluting the public API; production code always uses - /// the default. + /// **Inactivity window (silence budget)** of the `idea_ask_agent` rendezvous (see + /// [`ASK_RENDEZVOUS_TIMEOUT`]). One probe window, re-armed on observed progress. + /// Carried per instance so tests can shrink it to a few milliseconds without + /// polluting the public API; production code uses the resolved default. ask_rendezvous_timeout: Duration, + /// **Absolute ceiling** of the `idea_ask_agent` rendezvous (see + /// [`ASK_RENDEZVOUS_CEILING`]): the extending inactivity window never parks a call + /// past this. Per instance so tests can shrink it; production uses the resolved + /// default (4 h). + ask_rendezvous_ceiling: Duration, + /// Optional monotonic **activity probe** of the target (see [`AskActivityProbe`]), + /// injected by the composition root. Drives window extension: progress between two + /// probes re-arms the window. `None` ⇒ no observability ⇒ the rendezvous degrades to + /// a single flat window (legacy behaviour, zero regression). + activity_probe: Option, } impl McpServer { @@ -113,6 +160,8 @@ impl McpServer { requester: String::new(), ready_sink: None, ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT, + ask_rendezvous_ceiling: ASK_RENDEZVOUS_CEILING, + activity_probe: None, } } @@ -154,24 +203,50 @@ impl McpServer { requester: requester.into(), ready_sink: self.ready_sink.clone(), ask_rendezvous_timeout: self.ask_rendezvous_timeout, + ask_rendezvous_ceiling: self.ask_rendezvous_ceiling, + activity_probe: self.activity_probe.clone(), } } - /// Overrides the `idea_ask_agent` rendezvous safety-net bound (default - /// [`ASK_RENDEZVOUS_TIMEOUT`]). + /// Overrides the `idea_ask_agent` **inactivity window** (silence budget, default + /// [`ASK_RENDEZVOUS_TIMEOUT`] = 600 s). /// /// A genuine **configuration knob** (project/profile level, modelled on the /// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root /// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies - /// it here. Absent override ⇒ the generous 24 h default is preserved unchanged (zero - /// regression on legitimately long delegated turns). Tests also use it to shrink the - /// bound to milliseconds. + /// it here. This is the size of one *no-progress* window: as long as the activity + /// probe sees the target advancing, the window is re-armed up to the absolute + /// [`ASK_RENDEZVOUS_CEILING`]. Absent override ⇒ the 600 s default. Tests also use it + /// to shrink the window to milliseconds. #[must_use] pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self { self.ask_rendezvous_timeout = timeout; self } + /// Overrides the `idea_ask_agent` **absolute ceiling** (hard cap, default + /// [`ASK_RENDEZVOUS_CEILING`] = 4 h) past which the extending inactivity window never + /// parks a call, even against a perpetually-busy target. The composition root resolves + /// an optional override with [`resolve_ask_rendezvous_ceiling`]. Tests use it to shrink + /// the ceiling to milliseconds. Additive: callers that don't set it keep the default. + #[must_use] + pub fn with_ask_rendezvous_ceiling(mut self, ceiling: Duration) -> Self { + self.ask_rendezvous_ceiling = ceiling; + self + } + + /// Attaches the monotonic **activity probe** (see [`AskActivityProbe`]) driving the + /// inactivity-window extension: between two window expiries, a strictly larger token + /// means the target is alive and working, so the window is re-armed instead of + /// expiring. Built by the composition root (the only layer mapping a display name to a + /// run-dir transcript). Additive: without a probe the rendezvous degrades to a single + /// flat window (legacy behaviour, zero regression). + #[must_use] + pub fn with_activity_probe(mut self, probe: AskActivityProbe) -> Self { + self.activity_probe = Some(probe); + self + } + /// Serves JSON-RPC messages from `transport`, tagging every processed /// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4). /// @@ -406,43 +481,40 @@ impl McpServer { } }; - // `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous - // (the target's `idea_reply`). Bound *only* that path with a finite outer - // timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry - // return a clean JSON-RPC error instead of parking the task forever. Every - // other tool dispatches unbounded — they never rendezvous. + // `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous (the + // target's `idea_reply`). Bound *only* that path with the **inactivity watchdog** + // (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]): + // race the dispatch against an inactivity window; on each window expiry, probe + // whether the target progressed (transcript grew). Progress ⇒ re-arm the window + // (extension) up to the absolute ceiling; genuine silence ⇒ the retryable no-reply + // error; ceiling reached while still active ⇒ the distinct non-retryable ceiling + // error. Every other tool dispatches unbounded — they never rendezvous. let dispatch = self.service.dispatch(&self.project, command); let result = if name == "idea_ask_agent" { - // Armed beacon : le rendezvous synchrone est désormais borné par le filet - // de sécurité serveur. Si « end » n'apparaît jamais après ce « armed », la - // cible n'a ni appelé `idea_reply` ni atteint son prompt-ready dans le délai. application::diag!( "[mcp] ask armed requester={requester_label} target={arg_target} \ - task_len={task_len} timeout_ms={}", + task_len={task_len} window_ms={} ceiling_ms={}", self.ask_rendezvous_timeout.as_millis(), + self.ask_rendezvous_ceiling.as_millis(), ); - match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await { - Ok(result) => result, - Err(_elapsed) => { - // RISQUE #1 (Architect) : on `return` ici ⇒ le futur `dispatch` (déplacé - // dans `timeout`) est **droppé**, jamais détaché dans un `spawn`. C'est ce - // Drop qui libère le `BusyTurnGuard` RAII de `ask_agent` (cancel_head + - // mark_idle) et purge le `Busy`/ticket de la cible — aucune fuite `Busy`. - application::diag!( - "[mcp] ask EXPIRED requester={requester_label} target={arg_target} \ - timeout_ms={} elapsed_ms={}", - self.ask_rendezvous_timeout.as_millis(), - started.elapsed().as_millis(), - ); - // Filet serveur : ne plus renvoyer un INTERNAL_ERROR opaque, mais la même - // sémantique typée et **retryable** que `AppError::TargetReturnedNoReply`, - // sous un code JSON-RPC DISTINCT (RENDEZVOUS_NO_REPLY) + `data` structuré. - return Err(rendezvous_no_reply_error(&arg_target)); + match self + .run_ask_rendezvous(dispatch, &arg_target, &requester_label, started) + .await + { + // RISQUE #1 (Architect) : sur NoReply/CeilingActive, le futur `dispatch` a déjà + // été **droppé** dans `run_ask_rendezvous` (il le possède et le pin), ce Drop + // libère le `BusyTurnGuard` RAII et purge le `Busy`/ticket de la cible (aucune + // fuite `Busy`). + AskRendezvousOutcome::Resolved(result) => result, + AskRendezvousOutcome::NoReply => return Err(rendezvous_no_reply_error(&arg_target)), + AskRendezvousOutcome::CeilingActive => { + return Err(rendezvous_ceiling_active_error(&arg_target)) } } } else { dispatch.await }; + // Surface the processed delegation on the bus, tagged as the MCP door — the // twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the // action is the tool name. No-op when no sink is attached. @@ -500,6 +572,77 @@ impl McpServer { }); } } + + /// Drives the `idea_ask_agent` synchronous rendezvous under the **inactivity + /// watchdog** (cf. [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]). + /// + /// Thin adapter over the testable free function [`run_inactivity_watchdog`]: it + /// supplies this server's window, ceiling and (optional) activity probe, and maps the + /// watchdog verdict to an [`AskRendezvousOutcome`]. With no probe wired the watchdog + /// degrades to a single flat window (legacy behaviour, zero regression). + async fn run_ask_rendezvous( + &self, + dispatch: impl Future>, + target: &str, + requester_label: &str, + started: Instant, + ) -> AskRendezvousOutcome { + let probe = self.activity_probe.clone(); + let target_owned = target.to_owned(); + let probe_fn = move || { + let probe = probe.clone(); + let target = target_owned.clone(); + async move { + match &probe { + Some(p) => p(target).await, + None => None, + } + } + }; + // DRY : on réutilise l'**unique** implémentation du watchdog (couche application), + // la même que celle qui gouverne réellement le tour délégué dans le service. Ici + // c'est le filet de sécurité externe de l'adaptateur MCP. + let window_ms = self.ask_rendezvous_timeout.as_millis(); + let ceiling_ms = self.ask_rendezvous_ceiling.as_millis(); + let (t_ext, r_ext) = (target.to_owned(), requester_label.to_owned()); + let (t_exp, r_exp) = (target.to_owned(), requester_label.to_owned()); + let (t_ceil, r_ceil) = (target.to_owned(), requester_label.to_owned()); + match application::run_inactivity_watchdog( + dispatch, + self.ask_rendezvous_timeout, + self.ask_rendezvous_ceiling, + started, + self.activity_probe.is_some(), + probe_fn, + move |elapsed| { + application::diag!( + "[mcp] ask window extended (signe de vie) requester={r_ext} target={t_ext} \ + window_ms={window_ms} elapsed_ms={}", + elapsed.as_millis(), + ); + }, + move |elapsed| { + application::diag!( + "[mcp] ask EXPIRED (no progress) requester={r_exp} target={t_exp} \ + window_ms={window_ms} elapsed_ms={}", + elapsed.as_millis(), + ); + }, + move |elapsed| { + application::diag!( + "[mcp] ask CEILING active requester={r_ceil} target={t_ceil} \ + ceiling_ms={ceiling_ms} elapsed_ms={}", + elapsed.as_millis(), + ); + }, + ) + .await + { + application::WatchdogOutcome::Resolved(r) => AskRendezvousOutcome::Resolved(r), + application::WatchdogOutcome::NoReply => AskRendezvousOutcome::NoReply, + application::WatchdogOutcome::CeilingActive => AskRendezvousOutcome::CeilingActive, + } + } } /// Builds an MCP `tools/call` result with a single text content block. @@ -537,6 +680,44 @@ fn rendezvous_no_reply_error(target: &str) -> JsonRpcError { } } +/// Outcome of the inactivity-watchdog rendezvous ([`McpServer::run_ask_rendezvous`]), +/// folded into a `tools/call` result by the caller. +enum AskRendezvousOutcome { + /// The dispatch completed (success or a typed `AppError`) — returned unchanged. + Resolved(Result), + /// The inactivity window elapsed with no observable progress ⇒ the retryable + /// [`rendezvous_no_reply_error`]. Also the flat-window fallback when no probe is wired. + NoReply, + /// The absolute ceiling was reached while the target was still actively working ⇒ the + /// distinct, non-retryable [`rendezvous_ceiling_active_error`]. + CeilingActive, +} + +/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous reaches the +/// **absolute ceiling** while the target is **still actively working** (its transcript +/// kept growing across probes). +/// +/// **Distinct** from [`rendezvous_no_reply_error`]: the target is not silent, so a blind +/// retry would stack a second heavy turn on a target already mid-task. Carried under the +/// dedicated [`error_codes::RENDEZVOUS_CEILING_ACTIVE`] code with `data.retryable = false` +/// so a caller can branch without parsing the message. +fn rendezvous_ceiling_active_error(target: &str) -> JsonRpcError { + let message = format!( + "target {target} is still actively working but the rendezvous ceiling was reached; \ + do not retry blindly — check the target's in-progress work/branch (it may still \ + finish and call idea_reply), then re-solicit lightly if needed" + ); + JsonRpcError { + code: error_codes::RENDEZVOUS_CEILING_ACTIVE, + message, + data: Some(json!({ + "code": "RENDEZVOUS_CEILING_ACTIVE", + "retryable": false, + "target": target, + })), + } +} + /// Maps a [`ToolMapError`] to the right JSON-RPC error code. fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { match err { @@ -548,3 +729,49 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + // NOTE: the inactivity-watchdog **algorithm** now lives in (and is unit-tested by) + // the `application` crate (`application::run_inactivity_watchdog`), the single source + // of truth that also governs the delegated turn. This adapter only maps its verdict to + // an `AskRendezvousOutcome`, so we keep here only the adapter-specific concerns: the + // error builders and the override resolvers. + + /// The two error builders carry distinct JSON-RPC codes and the documented + /// `data.retryable` flags (no-reply ⇒ retryable; ceiling-active ⇒ not). + #[test] + fn error_builders_carry_distinct_codes_and_retryable_flags() { + let no_reply = rendezvous_no_reply_error("architect"); + assert_eq!(no_reply.code, error_codes::RENDEZVOUS_NO_REPLY); + assert_eq!(no_reply.data.as_ref().unwrap()["retryable"], serde_json::json!(true)); + assert_eq!( + no_reply.data.as_ref().unwrap()["code"], + serde_json::json!("TARGET_RETURNED_NO_REPLY") + ); + + let ceiling = rendezvous_ceiling_active_error("architect"); + assert_eq!(ceiling.code, error_codes::RENDEZVOUS_CEILING_ACTIVE); + assert_eq!(ceiling.data.as_ref().unwrap()["retryable"], serde_json::json!(false)); + assert_eq!( + ceiling.data.as_ref().unwrap()["code"], + serde_json::json!("RENDEZVOUS_CEILING_ACTIVE") + ); + assert_ne!(no_reply.code, ceiling.code); + } + + /// The resolvers honour an override and fall back to their finite defaults; `Some(0)` + /// is treated as "no override" (never an instant collapse). + #[test] + fn resolvers_apply_override_else_finite_default() { + assert_eq!(resolve_ask_rendezvous_timeout(Some(1234)), Duration::from_millis(1234)); + assert_eq!(resolve_ask_rendezvous_timeout(Some(0)), ASK_RENDEZVOUS_TIMEOUT); + assert_eq!(resolve_ask_rendezvous_timeout(None), ASK_RENDEZVOUS_TIMEOUT); + assert_eq!(resolve_ask_rendezvous_ceiling(Some(9999)), Duration::from_millis(9999)); + assert_eq!(resolve_ask_rendezvous_ceiling(Some(0)), ASK_RENDEZVOUS_CEILING); + assert_eq!(resolve_ask_rendezvous_ceiling(None), ASK_RENDEZVOUS_CEILING); + } +}