Compare commits
5 Commits
15cf21c5bd
...
d7145644d9
| Author | SHA1 | Date | |
|---|---|---|---|
| d7145644d9 | |||
| 50e99e5ced | |||
| 6050a6da5f | |||
| 1efe2f11dc | |||
| 886bb0d761 |
@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@ -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.
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
34
.ideai/memory/backstop-fires-on-intra-task-turn-rootcause.md
Normal file
34
.ideai/memory/backstop-fires-on-intra-task-turn-rootcause.md
Normal file
@ -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]].
|
||||
@ -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.
|
||||
62
.ideai/memory/checkpoint-auto-memory-harvest-lot-e1.md
Normal file
62
.ideai/memory/checkpoint-auto-memory-harvest-lot-e1.md
Normal file
@ -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.
|
||||
@ -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/<run-dir-encodé>/*.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).
|
||||
@ -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 <uuid>` = 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<plafond → réarme ; progrès+plafond → `AppError::TargetCeilingActive` (code `RENDEZVOUS_CEILING_ACTIVE`, non-retry-aveugle) ; vrai silence → `AgentSessionError::Timeout` inchangé.
|
||||
- Défaut secondaire (busy fantôme) : `mark_target_done_best_effort` sur toutes les branches d'abandon (PTY + structuré).
|
||||
- Fichiers : application `{rendezvous.rs(new), service.rs, error.rs, lib.rs, orchestrator/mod.rs}`, infra `mcp/server.rs`, `app-tauri/state.rs` (+ les modifs infra inspector/lib pré-existantes du 1er fix).
|
||||
- **Preuve réelle (re-vérifiée par Main dans le MAIN TREE)** : `cargo test -p application --lib` 75/0 (dont progressing_target_extends_then_resolves, _hits_ceiling_distinctly, silent_target_expires_no_reply, no_probe_falls_back_to_flat_window, ceiling_active_code) ; `cargo test -p infrastructure --lib` 247/0 ; clippy 0 nouveau warning.
|
||||
|
||||
## PROCHAINE ÉTAPE (reprise après relance IdeA)
|
||||
1. **Rebuild AppImage EN COURS** (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` ; artefact `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage` → remplace `/home/anthony/Documents/IdeA_0.3.0_amd64.AppImage`, backup l'ancienne). Log build : scratchpad/rebuild.log.
|
||||
2. **Utilisateur relance IdeA** (tue Main).
|
||||
3. **Reprendre le test depuis T1** (skill). Le but immédiat = **re-valider T7 EN LIVE** : déléguer une charge réelle > 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]].
|
||||
</content>
|
||||
</invoke>
|
||||
@ -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.
|
||||
@ -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=<none>` + `baseline … count=0`. Le dossier transcript calculé n'existait pas :
|
||||
- calculé (faux) : `…/-home-…-IdeA-.ideai-run-<uuid>` (point conservé) → `ls` = absent
|
||||
- réel (disque) : `…/-home-…-IdeA--ideai-run-<uuid>` (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=<none>` à 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.
|
||||
34
.ideai/memory/checkpoint-work-tab-blank-fix.md
Normal file
34
.ideai/memory/checkpoint-work-tab-blank-fix.md
Normal file
@ -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.
|
||||
@ -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.
|
||||
26
.ideai/memory/conversation-viewer-ls7-frontend.md
Normal file
26
.ideai/memory/conversation-viewer-ls7-frontend.md
Normal file
@ -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.
|
||||
15
.ideai/memory/git-agent-push-access-gitea-ssh.md
Normal file
15
.ideai/memory/git-agent-push-access-gitea-ssh.md
Normal file
@ -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).
|
||||
22
.ideai/memory/handoff-ls5-summary-bound-and-llm-seam.md
Normal file
22
.ideai/memory/handoff-ls5-summary-bound-and-llm-seam.md
Normal file
@ -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é ».
|
||||
@ -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/<id>/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.
|
||||
15
.ideai/memory/live-state-persistence-program-closed-ls8.md
Normal file
15
.ideai/memory/live-state-persistence-program-closed-ls8.md
Normal file
@ -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/<id>/`) **ne peut pas écrire l'arbre du project root** (ARCHITECTURE.md, docs/) — les modifs de doc passent par Git (write+commit authority).
|
||||
21
.ideai/memory/live-state-reboot-reconciliation-design.md
Normal file
21
.ideai/memory/live-state-reboot-reconciliation-design.md
Normal file
@ -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]].
|
||||
47
.ideai/memory/mcp-e2e-findings-reply-wedge-phantom-busy.md
Normal file
47
.ideai/memory/mcp-e2e-findings-reply-wedge-phantom-busy.md
Normal file
@ -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/<run-dir-encodé>/*.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 <id>` réapparaît, socket ESTAB recompte).
|
||||
- ✅ **1er tour non perdu** : tâche livrée intacte = 1er `user` `[IdeA · tâche de <agent> · ticket <uuid>] …`.
|
||||
- ✅ **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.
|
||||
45
.ideai/memory/mcp-functional-test-plan-2026-06-24.md
Normal file
45
.ideai/memory/mcp-functional-test-plan-2026-06-24.md
Normal file
@ -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/<run-dir-encodé>/*.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.
|
||||
@ -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).
|
||||
@ -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]].
|
||||
24
.ideai/memory/mcp-t10b-pending-reboot-verification.md
Normal file
24
.ideai/memory/mcp-t10b-pending-reboot-verification.md
Normal file
@ -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]].
|
||||
@ -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.
|
||||
@ -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<LiveEntry>` 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.
|
||||
37
.ideai/memory/rendezvous-600s-cap-too-short-heavy-tasks.md
Normal file
37
.ideai/memory/rendezvous-600s-cap-too-short-heavy-tasks.md
Normal file
@ -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<u64>`.
|
||||
- **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]].
|
||||
14
.ideai/memory/rendezvous-no-reply-backstop-design.md
Normal file
14
.ideai/memory/rendezvous-no-reply-backstop-design.md
Normal file
@ -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.
|
||||
39
.ideai/memory/resume-after-appimage-rebuild-mcp-e2e.md
Normal file
39
.ideai/memory/resume-after-appimage-rebuild-mcp-e2e.md
Normal file
@ -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/<encoded-run-dir>/*.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**.
|
||||
11
.ideai/memory/skills-integration-canonical-foundation.md
Normal file
11
.ideai/memory/skills-integration-canonical-foundation.md
Normal file
@ -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`.
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
.ideai/skills/md/86ea97df-1533-47e5-8809-dc2b02242567.md
Normal file
42
.ideai/skills/md/86ea97df-1533-47e5-8809-dc2b02242567.md
Normal file
@ -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/<run-dir-encodé>/*.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.
|
||||
@ -19,6 +19,7 @@ use application::{
|
||||
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||
ReconcileLiveStateInput,
|
||||
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||
@ -124,6 +125,16 @@ pub async fn open_project(
|
||||
.reconcile_layouts
|
||||
.execute(ReconcileLayoutsInput { project_id: id })
|
||||
.await;
|
||||
// Réconcilie les lignes de live-state fantômes : un crash ne déclenche jamais
|
||||
// `close_project`, donc l'ouverture est le seul filet fiable pour repasser en
|
||||
// `idle` les agents `working`/`waiting`/`blocked` dont la session est morte.
|
||||
// Acte système (appelle le port live-state directement, jamais via la surface
|
||||
// MCP self-only). Best-effort : un échec ne bloque pas l'ouverture. Tourne
|
||||
// AVANT toute relance d'agent (toute reprise réécrit ensuite en LWW).
|
||||
let _ = state
|
||||
.reconcile_live_state
|
||||
.execute(ReconcileLiveStateInput { project_id: id })
|
||||
.await;
|
||||
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
|
||||
state.ensure_orchestrator_watch(&output.project);
|
||||
state.reconcile_claude_run_dirs(&output.project).await;
|
||||
|
||||
@ -27,7 +27,8 @@ use application::{
|
||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||
ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
|
||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn,
|
||||
RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
||||
@ -212,6 +213,43 @@ impl LiveStateReadProvider for AppLiveStateLeanProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider par-root de la réconciliation du live-state au reboot (jumeau de
|
||||
/// [`AppLiveStateProvider`] côté système). Résout le **project root** depuis le
|
||||
/// `project_id` via le [`ProjectStore`], matérialise un [`FsLiveStateStore`] ciblant
|
||||
/// `<root>/.ideai/live-state.json`, puis exécute le use case applicatif
|
||||
/// [`ReconcileLiveState`] (qui croise chaque ligne avec la liveness réelle de
|
||||
/// [`LiveSessions`] et ré-upsert les fantômes en `idle`).
|
||||
///
|
||||
/// **Acte système** : appelle le port [`domain::ports::LiveStateStore`]
|
||||
/// **directement**, jamais via `OrchestratorCommand::SetWorkState` ⇒ le self-only de
|
||||
/// `idea_workstate_set` reste préservé par construction. Best-effort : le call site
|
||||
/// (`open_project`) ignore le résultat.
|
||||
pub(crate) struct AppReconcileLiveState {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
registry: Arc<LiveSessions>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl AppReconcileLiveState {
|
||||
/// Résout la racine du projet, lie le store par-root et lance la réconciliation.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] si le projet est inconnu (registre) ou si le store échoue.
|
||||
pub(crate) async fn execute(
|
||||
&self,
|
||||
input: ReconcileLiveStateInput,
|
||||
) -> Result<(), AppError> {
|
||||
let project = self.projects.load_project(input.project_id).await?;
|
||||
let store = Arc::new(FsLiveStateStore::new(&project.root));
|
||||
let uc = ReconcileLiveState::new(
|
||||
store,
|
||||
Arc::clone(&self.registry) as Arc<dyn LiveAgentRegistry>,
|
||||
Arc::clone(&self.clock),
|
||||
);
|
||||
uc.execute(input).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
|
||||
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
|
||||
/// lancement en cours.
|
||||
@ -383,6 +421,10 @@ pub struct AppState {
|
||||
/// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même
|
||||
/// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon.
|
||||
pub reconcile_layouts: Arc<ReconcileLayouts>,
|
||||
/// Réconcilie, à l'ouverture, les lignes de live-state fantômes (agent
|
||||
/// `working`/`waiting`/`blocked` dont la session n'est plus vivante) : les
|
||||
/// repasse en `idle`. Acte système best-effort, jamais via la surface MCP.
|
||||
pub(crate) reconcile_live_state: Arc<AppReconcileLiveState>,
|
||||
/// Detect which candidate profiles' CLIs are installed (first-run).
|
||||
pub detect_profiles: Arc<DetectProfiles>,
|
||||
/// List configured profiles.
|
||||
@ -575,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<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>,
|
||||
/// 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<dyn FileSystem>,
|
||||
/// Répertoire home (`$HOME`) servant à dériver `<home>/.claude/projects/<encoded-run-dir>`
|
||||
/// pour la sonde d'activité du rendez-vous.
|
||||
pub home_dir: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -986,7 +1035,7 @@ impl AppState {
|
||||
// — lit le même `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
|
||||
// le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
|
||||
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> = 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),
|
||||
@ -1169,6 +1218,16 @@ impl AppState {
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&structured_sessions),
|
||||
));
|
||||
// Réconciliation du live-state au reboot : repasse en `idle` les lignes
|
||||
// fantômes (working/waiting/blocked) dont la session n'est plus vivante,
|
||||
// selon le MÊME registre de liveness que `GetProjectWorkState`. Provider
|
||||
// par root (le store fixe sa racine à la construction). Câblé dans
|
||||
// `open_project`, best-effort (cf. `reconcile_layouts`).
|
||||
let reconcile_live_state = Arc::new(AppReconcileLiveState {
|
||||
projects: Arc::clone(&store_port),
|
||||
registry: Arc::clone(&live_sessions),
|
||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
});
|
||||
// Lot C — résumés de conversation best-effort : on câble les sources par
|
||||
// project root (handoff = primaire, log = repli `last(_, 3)`). Lecture seule,
|
||||
// aucune persistance ; un échec de preview ne bloque ni live/busy ni tickets.
|
||||
@ -1331,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<dyn Clock>,
|
||||
}) as Arc<dyn LiveStateReadProvider>),
|
||||
}) as Arc<dyn LiveStateReadProvider>)
|
||||
// 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 = `<root>/.ideai/
|
||||
// run/<agent_id>`, encodé par Claude sous `<home>/.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<Box<dyn std::future::Future<Output = Option<u64>> + 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::<u32>().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
|
||||
@ -1374,6 +1465,7 @@ impl AppState {
|
||||
set_active_layout,
|
||||
snapshot_running_agents,
|
||||
reconcile_layouts,
|
||||
reconcile_live_state,
|
||||
detect_profiles,
|
||||
list_profiles,
|
||||
save_profile,
|
||||
@ -1449,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,
|
||||
}
|
||||
}
|
||||
@ -1542,11 +1636,50 @@ impl AppState {
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().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::<u32>().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,
|
||||
|
||||
@ -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<AgentSessionError> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
@ -128,7 +130,8 @@ pub use workstate::{
|
||||
AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput,
|
||||
ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview,
|
||||
ConversationWorkSummary, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput,
|
||||
LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, StopLiveAgent,
|
||||
StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState,
|
||||
UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
|
||||
LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState,
|
||||
ReconcileLiveStateInput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput,
|
||||
TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput,
|
||||
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
294
crates/application/src/orchestrator/rendezvous.rs
Normal file
294
crates/application/src/orchestrator/rendezvous.rs
Normal file
@ -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<T> {
|
||||
/// 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<T, F, Fut>(
|
||||
dispatch: impl Future<Output = T>,
|
||||
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<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Option<u64>>,
|
||||
{
|
||||
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<u32>) -> 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<u32>) -> 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<u32, u32> {
|
||||
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::<u32, u32>(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);
|
||||
}
|
||||
}
|
||||
@ -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<Box<dyn std::future::Future<Output = Option<u64>> + 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<Arc<dyn LiveStateReadProvider>>,
|
||||
/// **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<AskLivenessProbe>,
|
||||
/// 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<String, AppError>`
|
||||
// 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<Option<domain::AgentId>, 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<R>(
|
||||
&self,
|
||||
wait: impl std::future::Future<Output = R>,
|
||||
window: Duration,
|
||||
project_root: &domain::project::ProjectPath,
|
||||
agent_id: AgentId,
|
||||
target: &str,
|
||||
started: Instant,
|
||||
) -> WatchdogOutcome<R> {
|
||||
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
|
||||
|
||||
@ -7,11 +7,13 @@
|
||||
|
||||
mod actions;
|
||||
mod live;
|
||||
mod reconcile;
|
||||
|
||||
pub use actions::{
|
||||
AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent,
|
||||
StopLiveAgentInput, StopLiveAgentOutput,
|
||||
};
|
||||
pub use reconcile::{ReconcileLiveState, ReconcileLiveStateInput};
|
||||
pub use live::{
|
||||
GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput,
|
||||
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
|
||||
|
||||
230
crates/application/src/workstate/reconcile.rs
Normal file
230
crates/application/src/workstate/reconcile.rs
Normal file
@ -0,0 +1,230 @@
|
||||
//! [`ReconcileLiveState`] — réconcilie, à l'**ouverture** d'un projet, les lignes
|
||||
//! de live-state **fantômes** : un agent marqué `working`/`waiting`/`blocked`
|
||||
//! alors que sa session n'est plus vivante (un crash ne déclenche jamais
|
||||
//! `close_project`, donc `open` est le seul filet fiable).
|
||||
//!
|
||||
//! C'est la **jumelle** de [`super::live::UpdateLiveState`] côté système : là où
|
||||
//! `UpdateLiveState` publie une transition déclarée par un agent, cette
|
||||
//! réconciliation est un **acte système** du composition root qui appelle le port
|
||||
//! [`LiveStateStore`] **directement** — jamais via `OrchestratorCommand::SetWorkState`.
|
||||
//! Le self-only de l'outil MCP `idea_workstate_set` (un agent ne peut écrire que sa
|
||||
//! propre ligne) est un invariant **de la surface MCP**, pas du store : il reste
|
||||
//! préservé par construction, exactement comme `snapshot_running_agents` /
|
||||
//! `reconcile_layouts`.
|
||||
//!
|
||||
//! Le use case est un mince orchestrateur au-dessus de :
|
||||
//! - le store du live-state ([`LiveStateStore`], par project root),
|
||||
//! - le registre des sessions vivantes ([`LiveAgentRegistry`] — la *même* vérité
|
||||
//! que `GetProjectWorkState`, dépendance restreinte par ISP : on n'a besoin que
|
||||
//! de « cet agent est-il vivant ? »),
|
||||
//! - l'opération **pure** du domaine [`LiveState::reconcile_orphans`] (qui porte la
|
||||
//! politique d'orphelin et la réécriture exacte des champs),
|
||||
//! - l'horloge ([`Clock`]) pour estampiller `updated_at_ms` (last-writer-wins).
|
||||
//!
|
||||
//! **Best-effort** : appelé après `reconcile_layouts` dans la chaîne `open_project`,
|
||||
//! un échec ne doit jamais bloquer l'ouverture (le call site ignore le résultat).
|
||||
//! **Race-safe** : tourne **avant** toute relance d'agent ; toute reprise ultérieure
|
||||
//! réécrit la ligne avec un `updated_at_ms` plus frais (LWW).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{Clock, LiveStateStore};
|
||||
use domain::ProjectId;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::LiveAgentRegistry;
|
||||
|
||||
/// Input de [`ReconcileLiveState::execute`].
|
||||
///
|
||||
/// `project_id` identifie le projet **au point d'appel** ; le [`LiveStateStore`]
|
||||
/// par-root est lié par le provider du composition root (cf. `AppLiveStateProvider`),
|
||||
/// si bien que le flux pur ci-dessous n'a plus de résolution à faire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReconcileLiveStateInput {
|
||||
/// Le projet dont le live-state doit être réconcilié.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Réconcilie les lignes de live-state fantômes d'un projet à l'ouverture.
|
||||
pub struct ReconcileLiveState {
|
||||
store: Arc<dyn LiveStateStore>,
|
||||
registry: Arc<dyn LiveAgentRegistry>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl ReconcileLiveState {
|
||||
/// Construit le use case à partir de ses ports injectés (store par-root,
|
||||
/// registre de liveness, horloge).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn LiveStateStore>,
|
||||
registry: Arc<dyn LiveAgentRegistry>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
registry,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge le live-state, calcule les orphelins via la transformation pure du
|
||||
/// domaine ([`LiveState::reconcile_orphans`]) en croisant chaque ligne avec la
|
||||
/// liveness réelle, puis ré-upsert chaque ligne réécrite (keyed LWW).
|
||||
///
|
||||
/// `_input` ne sert qu'à l'identité du projet au point d'appel : la racine est
|
||||
/// déjà liée dans le store injecté.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] sur défaillance de chargement ou d'upsert du store.
|
||||
pub async fn execute(&self, _input: ReconcileLiveStateInput) -> Result<(), AppError> {
|
||||
let state = self.store.load().await?;
|
||||
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
|
||||
let reconciled = state.reconcile_orphans(|a| self.registry.is_agent_live(a), now_ms);
|
||||
for entry in reconciled {
|
||||
self.store.upsert(entry).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::live_state::{LiveEntry, LiveState, WorkStatus, STALE_AT_RESTART_MARKER};
|
||||
use domain::ports::StoreError;
|
||||
use domain::{AgentId, NodeId};
|
||||
|
||||
/// In-memory [`LiveStateStore`] double (keyed upsert + prune), no I/O.
|
||||
#[derive(Default)]
|
||||
struct InMemoryLiveStateStore {
|
||||
state: Mutex<LiveState>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LiveStateStore for InMemoryLiveStateStore {
|
||||
async fn load(&self) -> Result<LiveState, StoreError> {
|
||||
Ok(self.state.lock().unwrap().clone())
|
||||
}
|
||||
async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> {
|
||||
self.state.lock().unwrap().upsert(entry);
|
||||
Ok(())
|
||||
}
|
||||
async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
|
||||
self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Liveness fake: only the agents in the set are considered live.
|
||||
struct FakeRegistry {
|
||||
live: HashSet<AgentId>,
|
||||
}
|
||||
impl LiveAgentRegistry for FakeRegistry {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.live.contains(agent_id)
|
||||
}
|
||||
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock(i64);
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn pid() -> ProjectId {
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(42))
|
||||
}
|
||||
|
||||
async fn run(store: Arc<InMemoryLiveStateStore>, live: HashSet<AgentId>, now: i64) {
|
||||
let uc = ReconcileLiveState::new(
|
||||
store,
|
||||
Arc::new(FakeRegistry { live }),
|
||||
Arc::new(FixedClock(now)),
|
||||
);
|
||||
uc.execute(ReconcileLiveStateInput { project_id: pid() })
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconciles_orphan_with_dead_session() {
|
||||
let store = Arc::new(InMemoryLiveStateStore::default());
|
||||
store
|
||||
.upsert(
|
||||
LiveEntry::new(aid(1), None, "work", WorkStatus::Working, None, None, 1).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// No agent live, now = 999.
|
||||
run(store.clone(), HashSet::new(), 999).await;
|
||||
|
||||
let after = store.load().await.unwrap();
|
||||
assert_eq!(after.entries.len(), 1, "still one keyed row (upsert, not append)");
|
||||
let row = &after.entries[0];
|
||||
assert_eq!(row.status, WorkStatus::Idle, "orphan downgraded to idle");
|
||||
assert_eq!(row.progress.as_deref(), Some(STALE_AT_RESTART_MARKER));
|
||||
assert_eq!(row.updated_at_ms, 999, "stamped from the clock");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spares_a_live_agent() {
|
||||
let store = Arc::new(InMemoryLiveStateStore::default());
|
||||
store
|
||||
.upsert(
|
||||
LiveEntry::new(aid(1), None, "work", WorkStatus::Working, None, None, 7).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// aid(1) is still live ⇒ untouched.
|
||||
run(store.clone(), HashSet::from([aid(1)]), 999).await;
|
||||
|
||||
let row = &store.load().await.unwrap().entries[0];
|
||||
assert_eq!(row.status, WorkStatus::Working, "live agent left as-is");
|
||||
assert_eq!(row.updated_at_ms, 7, "not restamped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_store_is_a_noop_at_boot() {
|
||||
// First boot / crashed before any publish ⇒ the live-state file is absent
|
||||
// and `load()` yields an empty state. Reconciliation must be a clean no-op:
|
||||
// no upsert, no panic — `open_project` must never choke on a missing file.
|
||||
let store = Arc::new(InMemoryLiveStateStore::default());
|
||||
|
||||
run(store.clone(), HashSet::new(), 999).await;
|
||||
|
||||
assert!(
|
||||
store.load().await.unwrap().entries.is_empty(),
|
||||
"nothing to reconcile ⇒ store stays empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaves_idle_row_unchanged() {
|
||||
let store = Arc::new(InMemoryLiveStateStore::default());
|
||||
store
|
||||
.upsert(LiveEntry::new(aid(1), None, "rest", WorkStatus::Idle, None, None, 3).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
run(store.clone(), HashSet::new(), 999).await;
|
||||
|
||||
let row = &store.load().await.unwrap().entries[0];
|
||||
assert_eq!(row.status, WorkStatus::Idle);
|
||||
assert_eq!(row.updated_at_ms, 3, "idle row never reconciled, not restamped");
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,12 @@ pub const FIELD_PREVIEW_MAX_CHARS: usize = 160;
|
||||
/// live state. Distinct semantics: soft = truncate, hard = reject.
|
||||
pub const FIELD_MAX_BYTES: usize = 2 * 1024;
|
||||
|
||||
/// `progress` marker stamped on an orphan row reconciled at restart (see
|
||||
/// [`LiveState::reconcile_orphans`]): it records *why* a `Working`/`Waiting`/
|
||||
/// `Blocked` row was downgraded to `Idle` — its session was not running when the
|
||||
/// project reopened. Well under both field bounds.
|
||||
pub const STALE_AT_RESTART_MARKER: &str = "(stale — session not running at restart)";
|
||||
|
||||
/// What an agent is doing right now, at a glance.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -183,6 +189,46 @@ impl LiveState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the **reconciliation** of orphan rows at restart: rows whose
|
||||
/// status implies a live session (`Working`/`Waiting`/`Blocked`) but whose
|
||||
/// agent is **not** currently live according to `is_live`.
|
||||
///
|
||||
/// At reboot a crash never runs `close_project`, so `live-state.json` can keep
|
||||
/// "ghost" rows for agents whose sessions are dead. This pure transform returns
|
||||
/// the **rewritten** rows to upsert (it does **not** mutate `self`): each orphan
|
||||
/// is downgraded to [`WorkStatus::Idle`], keeping its `intent` (trace), clearing
|
||||
/// the now-dead `ticket` and `last_delegation`, stamping a `progress` stale
|
||||
/// marker and a fresh `updated_at_ms = now_ms` (last-writer-wins).
|
||||
///
|
||||
/// Rows that are already `Idle`/`Done`, or whose agent is still live, are
|
||||
/// **never** touched and are absent from the result. The caller persists each
|
||||
/// returned entry through the keyed [`upsert`](LiveState::upsert) (best-effort).
|
||||
#[must_use]
|
||||
pub fn reconcile_orphans(
|
||||
&self,
|
||||
is_live: impl Fn(&AgentId) -> bool,
|
||||
now_ms: u64,
|
||||
) -> Vec<LiveEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e.status,
|
||||
WorkStatus::Working | WorkStatus::Waiting | WorkStatus::Blocked
|
||||
) && !is_live(&e.agent_id)
|
||||
})
|
||||
.map(|e| LiveEntry {
|
||||
agent_id: e.agent_id,
|
||||
ticket: None,
|
||||
intent: e.intent.clone(),
|
||||
status: WorkStatus::Idle,
|
||||
progress: Some(STALE_AT_RESTART_MARKER.to_owned()),
|
||||
last_delegation: None,
|
||||
updated_at_ms: now_ms,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops entries older than `ttl_ms` relative to `now_ms`, then bounds the
|
||||
/// result to `max_n`, keeping the most recently updated rows.
|
||||
///
|
||||
@ -361,6 +407,94 @@ mod tests {
|
||||
assert_eq!(kept, vec![50, 40], "most recent first, oldest dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_orphans_downgrades_dead_active_rows_and_rewrites_fields() {
|
||||
let mut state = LiveState::default();
|
||||
// An orphan: Working but its agent is not live.
|
||||
state.upsert(
|
||||
LiveEntry::new(
|
||||
aid(1),
|
||||
Some(tid(7)),
|
||||
"implementing lot 2",
|
||||
WorkStatus::Working,
|
||||
Some("half done".to_owned()),
|
||||
Some(tid(8)),
|
||||
100,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// Nothing is live ⇒ aid(1) is an orphan.
|
||||
let rewritten = state.reconcile_orphans(|_| false, 999);
|
||||
|
||||
assert_eq!(rewritten.len(), 1, "the single active-but-dead row is an orphan");
|
||||
let row = &rewritten[0];
|
||||
assert_eq!(row.agent_id, aid(1));
|
||||
assert_eq!(row.status, WorkStatus::Idle, "downgraded to idle");
|
||||
assert_eq!(row.intent, "implementing lot 2", "intent kept as trace");
|
||||
assert_eq!(row.ticket, None, "dead ticket cleared");
|
||||
assert_eq!(row.last_delegation, None, "dead delegation cleared");
|
||||
assert_eq!(row.progress.as_deref(), Some(STALE_AT_RESTART_MARKER));
|
||||
assert_eq!(row.updated_at_ms, 999, "stamped with the fresh now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_orphans_covers_working_waiting_blocked_only() {
|
||||
let mut state = LiveState::default();
|
||||
state.upsert(
|
||||
LiveEntry::new(aid(1), None, "w", WorkStatus::Working, None, None, 1).unwrap(),
|
||||
);
|
||||
state.upsert(
|
||||
LiveEntry::new(aid(2), None, "a", WorkStatus::Waiting, None, None, 1).unwrap(),
|
||||
);
|
||||
state.upsert(
|
||||
LiveEntry::new(aid(3), None, "b", WorkStatus::Blocked, None, None, 1).unwrap(),
|
||||
);
|
||||
// idle/done are never orphans, even when the agent is dead.
|
||||
state.upsert(LiveEntry::new(aid(4), None, "i", WorkStatus::Idle, None, None, 1).unwrap());
|
||||
state.upsert(LiveEntry::new(aid(5), None, "d", WorkStatus::Done, None, None, 1).unwrap());
|
||||
|
||||
let rewritten = state.reconcile_orphans(|_| false, 50);
|
||||
|
||||
let mut ids: Vec<AgentId> = rewritten.iter().map(|e| e.agent_id).collect();
|
||||
ids.sort();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![aid(1), aid(2), aid(3)],
|
||||
"only working/waiting/blocked rows are reconciled; idle/done untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_orphans_spares_live_agents() {
|
||||
let mut state = LiveState::default();
|
||||
state.upsert(
|
||||
LiveEntry::new(aid(1), None, "alive", WorkStatus::Working, None, None, 1).unwrap(),
|
||||
);
|
||||
state.upsert(
|
||||
LiveEntry::new(aid(2), None, "dead", WorkStatus::Working, None, None, 1).unwrap(),
|
||||
);
|
||||
|
||||
// aid(1) is still live ⇒ spared; only aid(2) is reconciled.
|
||||
let rewritten = state.reconcile_orphans(|a| *a == aid(1), 50);
|
||||
|
||||
assert_eq!(rewritten.len(), 1);
|
||||
assert_eq!(rewritten[0].agent_id, aid(2), "only the dead agent is reconciled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_orphans_does_not_mutate_self() {
|
||||
let mut state = LiveState::default();
|
||||
state.upsert(
|
||||
LiveEntry::new(aid(1), Some(tid(1)), "x", WorkStatus::Working, None, None, 1).unwrap(),
|
||||
);
|
||||
let before = state.clone();
|
||||
|
||||
let _ = state.reconcile_orphans(|_| false, 999);
|
||||
|
||||
assert_eq!(state, before, "reconcile_orphans is a pure read (returns rows to upsert)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_is_camel_case() {
|
||||
let mut state = LiveState::default();
|
||||
|
||||
@ -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 (`<home>/.claude/projects/<encoded-cwd>/`) — 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<u64> {
|
||||
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::*;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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<dyn Fn(String) -> Pin<Box<dyn Future<Output = Option<u64>> + 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<u32>) -> Duration {
|
||||
match override_ms {
|
||||
@ -65,6 +89,19 @@ pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> 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<u32>) -> 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<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
/// 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<AskActivityProbe>,
|
||||
}
|
||||
|
||||
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<Output = Result<application::OrchestratorOutcome, application::AppError>>,
|
||||
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<application::OrchestratorOutcome, application::AppError>),
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user