5 Commits

Author SHA1 Message Date
d7145644d9 merge(orchestrator): intègre le watchdog d'inactivité réarmable + plafond du rendez-vous (T7)
Fenêtre d'inactivité réarmée à chaque progrès de la cible (octets cumulés du
transcript) sous un plafond absolu, en remplacement du timeout plat qui coupait
un long tour unique à 600 s. Issue typée TargetCeilingActive distincte du no-reply.
Suite Rust verte (application + infrastructure, 0 échec). Campagne MCP T1→T10 PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:33:18 +02:00
50e99e5ced chore(workstate): notes mémoire campagne MCP T1→T10 + état runtime
Capitalise la mémoire projet produite pendant la campagne de tests fonctionnels
MCP (rendez-vous inter-agents, backstop no-reply, réconciliation live-state au
reboot) et synchronise l'état runtime `.ideai/` (agents, layouts, skills, index
mémoire). Inclut la mise à jour du contexte de l'agent Git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:32:28 +02:00
6050a6da5f feat(orchestrator): watchdog d'inactivité réarmable + plafond du rendez-vous inter-agents
Redessine la borne de fin de tour du rendez-vous `idea_ask_agent` ⇄ `idea_reply` :
au lieu d'un timeout plat (qui coupait à 600 s un seul long tour de la cible, cf.
T7), la borne devient une **fenêtre d'inactivité réarmée** à chaque progrès observé
de la cible, sous un **plafond absolu** (défaut 4 h, réglable via
`IDEA_ASK_RENDEZVOUS_CEILING_MS`).

- Sonde d'activité (`transcript_activity_token`, inspector) : jeton monotone =
  octets cumulés des `.jsonl` de la cible. Croît même pendant un seul long tour
  sans `turn_duration` ⇒ détecte « vivant et au travail » mi-tour. Best-effort,
  sans effet de bord ; folder absent/illisible ⇒ « pas de progrès ».
- Watchdog (`run_inactivity_watchdog`, nouveau module `orchestrator/rendezvous`) :
  fenêtre réarmable + plafond, fallback timeout plat si aucune sonde (zéro régression).
- Issue typée distincte `TargetCeilingActive` (code `RENDEZVOUS_CEILING_ACTIVE`) :
  une cible **active** stoppée au plafond n'est jamais confondue avec un
  `TargetReturnedNoReply` (silence) ; le message guide « ne pas retenter à l'aveugle ».
- Câblage composition-root (`state.rs`) : sonde résolue nom→AgentId→run-dir transcript,
  branchée sur le service et sur l'McpServer (`AskActivityProbe`, `with_ask_ceiling`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:32:19 +02:00
1efe2f11dc merge(workstate): intègre la réconciliation du live-state au reboot (ReconcileLiveState)
Intègre feature/reconcile-live-state : à l'ouverture d'un projet, les statuts
d'agents restés `working` fantôme après un redémarrage sont réconciliés vers
`idle` (use case ReconcileLiveState, best-effort, sans nouveau port).

QA VERT : cargo test --workspace 1624 passed / 0 failed, clippy 0 erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:18:39 +02:00
886bb0d761 feat(workstate): réconcilie le live-state au reboot (use case ReconcileLiveState)
Au redémarrage, les agents qui étaient `working` lors de la fermeture restaient
figés en statut fantôme `working` alors qu'aucun tour n'est plus en cours (bug
reproductible en live : QA et DevBackend eux-mêmes sont restés `working` fantôme
après leurs tâches). On réconcilie l'état persistant à l'ouverture du projet :
les statuts orphelins sont ramenés à la cible `idle`.

- domain/live_state.rs : const STALE_AT_RESTART_MARKER + `reconcile_orphans` (+ tests).
- application/workstate/reconcile.rs (nouveau) : use case ReconcileLiveState (pas de
  nouveau port), best-effort, no-op si store vide (+ tests, dont empty_store_is_a_noop_at_boot).
- application/workstate/mod.rs + lib.rs : module + re-export.
- app-tauri/state.rs : provider par-root + champ AppState + wiring.
- app-tauri/commands.rs : hook best-effort dans open_project.

QA VERT par commande réelle : cargo test --workspace 1624 passed / 0 failed,
cargo clippy --workspace --all-targets 0 erreur sur le code ajouté.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:18:25 +02:00
48 changed files with 2358 additions and 250 deletions

View File

@ -5,14 +5,14 @@
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641", "agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"name": "Main", "name": "Main",
"mdPath": "agents/main.md", "mdPath": "agents/main.md",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false "synchronized": false
}, },
{ {
"agentId": "dce19c75-9669-4e45-b8de-9950025157da", "agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"name": "Architect", "name": "Architect",
"mdPath": "agents/architect.md", "mdPath": "agents/architect.md",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false "synchronized": false
}, },
{ {
@ -26,14 +26,14 @@
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800", "agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"name": "DevFrontend", "name": "DevFrontend",
"mdPath": "agents/devfrontend.md", "mdPath": "agents/devfrontend.md",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false "synchronized": false
}, },
{ {
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", "agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"name": "QA", "name": "QA",
"mdPath": "agents/qa.md", "mdPath": "agents/qa.md",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false "synchronized": false
}, },
{ {

View File

@ -1,15 +1,16 @@
# Git — Agent de gestion du dépôt git local # 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 > 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 et > git local** : commits de l'application, création et bascule de
> rebases. Tu es le **seul** à décider de la topologie des branches et à manipuler > branches, merges, rebases. Tu es le **seul**
> l'historique local. Main te sollicite ; tu décides et tu exécutes. > à 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) ## 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 - **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. 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), - 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. 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** 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 **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 ne prends pas de décision produit/archi : si un choix dépend de l'architecture,
tu remontes à Main. 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 délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu
appelles **impérativement** `idea_reply(result=…)`. appelles **impérativement** `idea_reply(result=…)`.
- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message - 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 court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette
ce merge ou ce non-merge). 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 - En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
pas une résolution hasardeuse. pas une résolution hasardeuse.

View File

@ -1,103 +1,16 @@
{ {
"version": 1, "version": 1,
"activeId": "0ac56658-4f42-47fe-ad03-c088c832460a", "activeId": "49c6b0fc-13c1-40f5-9464-d3248ab54597",
"layouts": [ "layouts": [
{ {
"id": "0ac56658-4f42-47fe-ad03-c088c832460a", "id": "49c6b0fc-13c1-40f5-9464-d3248ab54597",
"name": "Default", "name": "Default",
"kind": "terminal", "kind": "terminal",
"tree": { "tree": {
"root": { "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", "type": "leaf",
"node": { "node": {
"id": "d4b8c0d1-a44a-4c45-bbe9-26991f79b465", "id": "852f5128-5b98-4d36-9a18-e49a57fc4940"
"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"
} }
} }
} }

View File

@ -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-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-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-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

View 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]].

View File

@ -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.

View 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.

View File

@ -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).

View File

@ -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>

View File

@ -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.

View File

@ -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.

View 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.

View File

@ -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.

View 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.

View 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).

View 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é ».

View File

@ -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 P1P8) : `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.

View 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).

View 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]].

View 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.

View 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 **23 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.

View File

@ -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).

View File

@ -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]].

View 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]].

View File

@ -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.

View File

@ -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.

View 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]].

View 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.

View 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**.

View 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`.

View File

@ -4,7 +4,14 @@
{ {
"id": "a72dac60-641c-4417-b0d7-94b8539f817a", "id": "a72dac60-641c-4417-b0d7-94b8539f817a",
"name": "build-appimage", "name": "build-appimage",
"description": null,
"contentHash": "77cb33b978b242f6" "contentHash": "77cb33b978b242f6"
},
{
"id": "86ea97df-1533-47e5-8809-dc2b02242567",
"name": "mcp-rendezvous-functional-test",
"description": null,
"contentHash": "9fc8260f64c3b9d5"
} }
] ]
} }

View 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.

View File

@ -19,6 +19,7 @@ use application::{
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput,
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput,
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
@ -124,6 +125,16 @@ pub async fn open_project(
.reconcile_layouts .reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id }) .execute(ReconcileLayoutsInput { project_id: id })
.await; .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). // (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project); state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await; state.reconcile_claude_run_dirs(&output.project).await;

View File

@ -27,7 +27,8 @@ use application::{
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn,
RecordTurnProvider, ReferenceProfiles,
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout, RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession, 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 /// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du /// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
/// lancement en cours. /// lancement en cours.
@ -383,6 +421,10 @@ pub struct AppState {
/// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même /// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même
/// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon. /// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon.
pub reconcile_layouts: Arc<ReconcileLayouts>, 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). /// Detect which candidate profiles' CLIs are installed (first-run).
pub detect_profiles: Arc<DetectProfiles>, pub detect_profiles: Arc<DetectProfiles>,
/// List configured profiles. /// List configured profiles.
@ -575,6 +617,13 @@ pub struct AppState {
/// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter /// **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. /// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
pub turn_watch_handles: Mutex<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>, 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 { impl AppState {
@ -986,7 +1035,7 @@ impl AppState {
// — lit le même `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via // — 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`). // le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> = Arc::new( 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( let inspect_conversation = Arc::new(InspectConversation::new(
Arc::clone(&contexts_port), Arc::clone(&contexts_port),
@ -1169,6 +1218,16 @@ impl AppState {
Arc::clone(&terminal_sessions), Arc::clone(&terminal_sessions),
Arc::clone(&structured_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 // 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, // project root (handoff = primaire, log = repli `last(_, 3)`). Lecture seule,
// aucune persistance ; un échec de preview ne bloque ni live/busy ni tickets. // 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 ». // sa racine à la construction). Sans ça, l'outil renverrait « not configured ».
.with_live_state_read(Arc::new(AppLiveStateLeanProvider { .with_live_state_read(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>, 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. // 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 // 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 // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc
@ -1374,6 +1465,7 @@ impl AppState {
set_active_layout, set_active_layout,
snapshot_running_agents, snapshot_running_agents,
reconcile_layouts, reconcile_layouts,
reconcile_live_state,
detect_profiles, detect_profiles,
list_profiles, list_profiles,
save_profile, save_profile,
@ -1449,6 +1541,8 @@ impl AppState {
turn_watcher, turn_watcher,
turn_watch_input: Arc::clone(&input_mediator), turn_watch_input: Arc::clone(&input_mediator),
turn_watch_handles: Mutex::new(HashMap::new()), turn_watch_handles: Mutex::new(HashMap::new()),
fs_port: Arc::clone(&fs_port),
home_dir,
move_tab, move_tab,
} }
} }
@ -1542,11 +1636,50 @@ impl AppState {
.ok() .ok()
.and_then(|v| v.trim().parse::<u32>().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( let handle = McpServerHandle::start(
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
.with_events(events) .with_events(events)
.with_ready_sink(ready_sink) .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, endpoint,
listener, listener,
project_id, project_id,

View File

@ -69,6 +69,21 @@ pub enum AppError {
)] )]
TargetReturnedNoReply(String), 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. /// An unexpected internal error.
#[error("internal error: {0}")] #[error("internal error: {0}")]
Internal(String), Internal(String),
@ -89,6 +104,7 @@ impl AppError {
Self::Remote(_) => "REMOTE", Self::Remote(_) => "REMOTE",
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING", Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
Self::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY", Self::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY",
Self::TargetCeilingActive(_) => "RENDEZVOUS_CEILING_ACTIVE",
Self::Internal(_) => "INTERNAL", Self::Internal(_) => "INTERNAL",
} }
} }
@ -173,3 +189,31 @@ impl From<AgentSessionError> for AppError {
Self::Process(e.to_string()) 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"));
}
}

View File

@ -85,9 +85,11 @@ pub use memory::{
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
}; };
pub use orchestrator::{ pub use orchestrator::{
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider,
RecordTurnProvider, WriteMemory, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext,
ReadMemory, RecordTurnProvider, WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING,
DEFAULT_RENDEZVOUS_WINDOW,
}; };
pub use permission::{ pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
@ -128,7 +130,8 @@ pub use workstate::{
AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput,
ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview,
ConversationWorkSummary, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, ConversationWorkSummary, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput,
LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, StopLiveAgent, LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState,
StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, ReconcileLiveStateInput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput,
UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput,
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
}; };

View File

@ -5,13 +5,18 @@
//! ever spawning a process itself. See [`service::OrchestratorService`]. //! ever spawning a process itself. See [`service::OrchestratorService`].
mod context_guard; mod context_guard;
pub mod rendezvous;
mod service; mod service;
pub use context_guard::{ pub use context_guard::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput, ReadMemoryInput, WriteMemory, WriteMemoryInput,
}; };
pub use service::{ pub use rendezvous::{
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
OrchestratorOutcome, OrchestratorService, RecordTurnProvider, WatchdogOutcome, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
};
pub use service::{
AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider,
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
}; };

View 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);
}
}

View File

@ -39,6 +39,7 @@ use crate::agent::{
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS, UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
}; };
use crate::error::AppError; use crate::error::AppError;
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
use crate::orchestrator::{ use crate::orchestrator::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput, ReadMemoryInput, WriteMemory, WriteMemoryInput,
@ -85,6 +86,35 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
/// ([`resolve_turn_timeout`]). /// ([`resolve_turn_timeout`]).
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(600); 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, /// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
/// cadrage v5 §4). /// cadrage v5 §4).
/// ///
@ -362,6 +392,17 @@ pub struct OrchestratorService {
/// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur /// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur
/// typée « not configured » (call sites/tests legacy restent verts). /// typée « not configured » (call sites/tests legacy restent verts).
live_state_read: Option<Arc<dyn LiveStateReadProvider>>, 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 /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -429,9 +470,32 @@ impl OrchestratorService {
read_skill: None, read_skill: None,
live_state: None, live_state: None,
live_state_read: 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 /// Branche le use case [`ReadSkill`] (`skill.read`/`idea_skill_read`). Builder
/// additif (signature de [`Self::new`] inchangée) ; `None` ⇒ la commande /// additif (signature de [`Self::new`] inchangée) ; `None` ⇒ la commande
/// renvoie une erreur typée « not configured ». /// 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 — // 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). // 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 // 3. Attendre la réponse, bornée par la **fenêtre d'inactivité réarmable**
// (cible laissée vivante) et la ramène `Idle` au Drop ; renvoie une erreur typée. // (signe de vie = sonde de transcript) au lieu d'un timeout plat : un long
match tokio::time::timeout(turn_timeout, pending).await { // tour unique qui progresse n'est plus coupé à `turn_timeout`. Vrai silence
Ok(Ok(TurnResolution::Replied(result))) => { // ⇒ 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 // Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de
// déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est // déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est
// elle qui a rendu le tour) ; même conversation que le Prompt. // 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 // 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` // `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 // redondant ni de beacon « busy-guard freed » trompeur) et on renvoie une
// erreur typée claire/retryable, en ~G au lieu du timeout long. // erreur typée claire/retryable, en ~G au lieu du timeout long. On repasse
Ok(Ok(TurnResolution::ReturnedToPromptNoReply)) => { // 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(); busy_guard.disarm();
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
crate::diag!( crate::diag!(
"[rendezvous] ask returned-to-prompt-no-reply: target={target} \ "[rendezvous] ask returned-to-prompt-no-reply: target={target} \
(agent {agent_id}) ticket={ticket_id} after_ms={}", (agent {agent_id}) ticket={ticket_id} after_ms={}",
@ -1434,9 +1509,23 @@ impl OrchestratorService {
); );
Err(AppError::TargetReturnedNoReply(target)) 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 // Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent). // Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
Ok(Err(_cancelled)) => { WatchdogOutcome::Resolved(Err(_cancelled)) => {
crate::diag!( crate::diag!(
"[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \ "[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \
ticket={ticket_id} after_ms={}", ticket={ticket_id} after_ms={}",
@ -1446,11 +1535,17 @@ impl OrchestratorService {
"agent {target} : canal de réponse fermé avant un résultat" "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!( crate::diag!(
"[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \ "[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \
ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \ 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(), started.elapsed().as_millis(),
); );
Err(AppError::from(domain::ports::AgentSessionError::Timeout)) Err(AppError::from(domain::ports::AgentSessionError::Timeout))
@ -1552,74 +1647,90 @@ impl OrchestratorService {
turn_timeout.as_millis(), turn_timeout.as_millis(),
); );
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le // Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`). Le drain lui-même
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la // est **non borné** (`None`) : la borne de tour est désormais portée par la
// **première** issue : le tour structuré OU un `idea_reply` explicite. // **fenêtre d'inactivité réarmable** autour de l'attente (signe de vie = sonde de
let drain = // transcript), pas par un timeout plat interne — un long tour unique qui progresse
drain_with_readiness(session, &task, Some(turn_timeout), input.as_ref(), agent_id); // 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! { // 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; biased;
// Issue déterministe : la session a rendu son `Final`.
drained = drain => match drained { drained = drain => match drained {
Ok(content) => { Ok(content) => Ok(content),
crate::diag!( Err(err) => Err(AppError::from(err)),
"[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 { replied = pending => match replied {
Ok(TurnResolution::Replied(content)) => { Ok(TurnResolution::Replied(content)) => {
// La session draine encore en arrière-plan ; on s'assure que la FIFO // La session draine encore en arrière-plan ; on fait avancer la FIFO.
// avance même si le `Final` n'est pas encore observé.
input.mark_idle(agent_id); 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!( crate::diag!(
"[rendezvous] ask resolved (structured/reply): target={target} \ "[rendezvous] ask resolved (structured): target={target} \
(agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}", (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}",
started.elapsed().as_millis(), started.elapsed().as_millis(),
content.len(), content.len(),
); );
content content
} }
// Retour au prompt sans `idea_reply` : très rare sur le chemin structuré // Erreur de tour (drain-error, no-reply, canal fermé) : le garde fait
// (la cible n'a pas de PTY/watcher), mais on traite la résolution comme // `cancel_head` + `mark_idle` au Drop. On réconcilie la live-state à `Done`
// sur le chemin PTY — erreur typée claire/retryable, garde désarmé. // pour qu'un abandon ne laisse pas un busy fantôme.
Ok(TurnResolution::ReturnedToPromptNoReply) => { WatchdogOutcome::Resolved(Err(err)) => {
input.mark_idle(agent_id); self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
busy_guard.disarm(); .await;
crate::diag!( crate::diag!(
"[rendezvous] ask returned-to-prompt-no-reply (structured): \ "[rendezvous] ask error (structured): target={target} (agent {agent_id}) \
target={target} (agent {agent_id}) ticket={ticket_id} after_ms={}", ticket={ticket_id} after_ms={} err={err}",
started.elapsed().as_millis(), started.elapsed().as_millis(),
); );
return Err(AppError::TargetReturnedNoReply(target.to_owned())); return Err(err);
} }
// Canal fermé : le garde fait `cancel_head` + `mark_idle` au Drop. // Plafond absolu atteint alors que la cible progresse encore : verdict distinct.
Err(_cancelled) => { WatchdogOutcome::CeilingActive => {
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
crate::diag!( crate::diag!(
"[rendezvous] ask channel-closed (structured): target={target} \ "[rendezvous] ask CEILING (structured, still active): target={target} \
(agent {agent_id}) ticket={ticket_id} after_ms={}", (agent {agent_id}) ticket={ticket_id} after_ms={}",
started.elapsed().as_millis(), started.elapsed().as_millis(),
); );
return Err(AppError::Process(format!( return Err(AppError::TargetCeilingActive(target.to_owned()));
"agent {target} : canal de réponse fermé avant un résultat" }
))); // 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` // 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 /// 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 /// 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 /// 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) 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`, /// Resolves a human-friendly profile reference (slug like `claude-code`,
/// command like `claude`, or display name like `Claude Code`) to a configured /// command like `claude`, or display name like `Claude Code`) to a configured
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by /// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by

View File

@ -7,11 +7,13 @@
mod actions; mod actions;
mod live; mod live;
mod reconcile;
pub use actions::{ pub use actions::{
AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent,
StopLiveAgentInput, StopLiveAgentOutput, StopLiveAgentInput, StopLiveAgentOutput,
}; };
pub use reconcile::{ReconcileLiveState, ReconcileLiveStateInput};
pub use live::{ pub use live::{
GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput, GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput,
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,

View 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");
}
}

View File

@ -32,6 +32,12 @@ pub const FIELD_PREVIEW_MAX_CHARS: usize = 160;
/// live state. Distinct semantics: soft = truncate, hard = reject. /// live state. Distinct semantics: soft = truncate, hard = reject.
pub const FIELD_MAX_BYTES: usize = 2 * 1024; 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. /// What an agent is doing right now, at a glance.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[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 /// Drops entries older than `ttl_ms` relative to `now_ms`, then bounds the
/// result to `max_n`, keeping the most recently updated rows. /// 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"); 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] #[test]
fn serde_round_trip_is_camel_case() { fn serde_round_trip_is_camel_case() {
let mut state = LiveState::default(); let mut state = LiveState::default();

View File

@ -17,7 +17,7 @@
//! [`ClaudeTranscriptInspector`]: super::ClaudeTranscriptInspector //! [`ClaudeTranscriptInspector`]: super::ClaudeTranscriptInspector
//! [`ClaudeTranscriptTurnWatcher`]: super::ClaudeTranscriptTurnWatcher //! [`ClaudeTranscriptTurnWatcher`]: super::ClaudeTranscriptTurnWatcher
use domain::ports::RemotePath; use domain::ports::{FileSystem, RemotePath};
use domain::profile::{AgentProfile, ContextInjection}; use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -12,4 +12,5 @@ mod claude_paths;
mod claude_turn_watcher; mod claude_turn_watcher;
pub use claude::ClaudeTranscriptInspector; pub use claude::ClaudeTranscriptInspector;
pub use claude_paths::transcript_activity_token;
pub use claude_turn_watcher::ClaudeTranscriptTurnWatcher; pub use claude_turn_watcher::ClaudeTranscriptTurnWatcher;

View File

@ -48,10 +48,11 @@ pub use fs::LocalFileSystem;
pub use git::Git2Repository; pub use git::Git2Repository;
pub use id::UuidGenerator; pub use id::UuidGenerator;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; 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 mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{ 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::{ pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -130,6 +130,16 @@ pub mod error_codes {
/// may re-ask, reminding the target to answer via `idea_reply`. The accompanying /// may re-ask, reminding the target to answer via `idea_reply`. The accompanying
/// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`. /// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`.
pub const RENDEZVOUS_NO_REPLY: i32 = -32_001; 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. /// Errors a [`Transport`] may surface.

View File

@ -36,6 +36,8 @@ pub mod transport;
pub use jsonrpc::{ pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, 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 tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport}; pub use transport::{MemoryTransport, StdioTransport};

View File

@ -17,6 +17,8 @@
//! at the composition root (M3), exactly like the watcher — no application logic is //! at the composition root (M3), exactly like the watcher — no application logic is
//! duplicated here. //! duplicated here.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -34,29 +36,51 @@ use super::tools::{self, ToolMapError};
/// The MCP protocol version this server speaks (advertised on `initialize`). /// The MCP protocol version this server speaks (advertised on `initialize`).
const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; 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 /// `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 /// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
/// layer). The application layer already bounds the rendezvous itself, but this /// layer). Rather than a flat outer timeout (which wrongly fired on a single long
/// adapter adds its own **finite** outer bound so a wedged target can never park a /// delegated turn that never returns to its prompt nor emits `turn_duration`), the
/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error /// adapter runs an **inactivity watchdog**: it races the dispatch against this window
/// instead of hanging. /// 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 /// **Fallback (no activity probe).** When no probe is wired (legacy call sites, tests
/// (the transcript `turn_duration` watcher), but only Claude emits it; this outer bound /// that shrink the bound), this degrades to the previous *flat* timeout: the first
/// stays the last resort for any silent target that never emits it (Codex & co) or that /// window expiry with no probe is treated as a no-reply expiry — zero regression.
/// 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.
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600); const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600);
/// Resolves the effective `idea_ask_agent` rendezvous bound from an optional /// **Absolute ceiling (hard cap)** of the `idea_ask_agent` rendezvous, so an extending
/// configured override in **milliseconds** (project/profile level), mirroring the /// inactivity window can never park a `tools/call` forever even against a target that
/// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound, /// stays *busy* indefinitely. Reaching it while the target is still actively working
/// `None`/absent ⇒ the finite [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is /// yields the distinct, **non-retryable** [`rendezvous_ceiling_active_error`] (never a
/// treated as "no override" so a misconfigured zero can never collapse the safety net to /// blind-retry suggestion). Generous by default (4 h), overridable per project via
/// an instant expiry. Pure and unit-testable without wiring. /// `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] #[must_use]
pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration { pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
match override_ms { 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`]. /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
/// ///
/// Cheap to clone the dependencies it holds; one instance serves one project's /// 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 /// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
/// composition root parse le `requester`. `None` ⇒ no-op. /// composition root parse le `requester`. `None` ⇒ no-op.
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>, ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
/// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see /// **Inactivity window (silence budget)** of the `idea_ask_agent` rendezvous (see
/// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a /// [`ASK_RENDEZVOUS_TIMEOUT`]). One probe window, re-armed on observed progress.
/// few milliseconds without polluting the public API; production code always uses /// Carried per instance so tests can shrink it to a few milliseconds without
/// the default. /// polluting the public API; production code uses the resolved default.
ask_rendezvous_timeout: Duration, 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 { impl McpServer {
@ -113,6 +160,8 @@ impl McpServer {
requester: String::new(), requester: String::new(),
ready_sink: None, ready_sink: None,
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT, ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
ask_rendezvous_ceiling: ASK_RENDEZVOUS_CEILING,
activity_probe: None,
} }
} }
@ -154,24 +203,50 @@ impl McpServer {
requester: requester.into(), requester: requester.into(),
ready_sink: self.ready_sink.clone(), ready_sink: self.ready_sink.clone(),
ask_rendezvous_timeout: self.ask_rendezvous_timeout, 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 /// Overrides the `idea_ask_agent` **inactivity window** (silence budget, default
/// [`ASK_RENDEZVOUS_TIMEOUT`]). /// [`ASK_RENDEZVOUS_TIMEOUT`] = 600 s).
/// ///
/// A genuine **configuration knob** (project/profile level, modelled on the /// A genuine **configuration knob** (project/profile level, modelled on the
/// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root /// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root
/// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies /// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies
/// it here. Absent override ⇒ the generous 24 h default is preserved unchanged (zero /// it here. This is the size of one *no-progress* window: as long as the activity
/// regression on legitimately long delegated turns). Tests also use it to shrink the /// probe sees the target advancing, the window is re-armed up to the absolute
/// bound to milliseconds. /// [`ASK_RENDEZVOUS_CEILING`]. Absent override ⇒ the 600 s default. Tests also use it
/// to shrink the window to milliseconds.
#[must_use] #[must_use]
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self { pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
self.ask_rendezvous_timeout = timeout; self.ask_rendezvous_timeout = timeout;
self 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 /// Serves JSON-RPC messages from `transport`, tagging every processed
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4). /// `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 // `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous (the
// (the target's `idea_reply`). Bound *only* that path with a finite outer // target's `idea_reply`). Bound *only* that path with the **inactivity watchdog**
// timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry // (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]):
// return a clean JSON-RPC error instead of parking the task forever. Every // race the dispatch against an inactivity window; on each window expiry, probe
// other tool dispatches unbounded — they never rendezvous. // 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 dispatch = self.service.dispatch(&self.project, command);
let result = if name == "idea_ask_agent" { 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!( application::diag!(
"[mcp] ask armed requester={requester_label} target={arg_target} \ "[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_timeout.as_millis(),
self.ask_rendezvous_ceiling.as_millis(),
); );
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await { match self
Ok(result) => result, .run_ask_rendezvous(dispatch, &arg_target, &requester_label, started)
Err(_elapsed) => { .await
// RISQUE #1 (Architect) : on `return` ici ⇒ le futur `dispatch` (déplacé {
// dans `timeout`) est **droppé**, jamais détaché dans un `spawn`. C'est ce // RISQUE #1 (Architect) : sur NoReply/CeilingActive, le futur `dispatch` a déjà
// Drop qui libère le `BusyTurnGuard` RAII de `ask_agent` (cancel_head + // été **droppé** dans `run_ask_rendezvous` (il le possède et le pin), ce Drop
// mark_idle) et purge le `Busy`/ticket de la cible aucune fuite `Busy`. // libère le `BusyTurnGuard` RAII et purge le `Busy`/ticket de la cible (aucune
application::diag!( // fuite `Busy`).
"[mcp] ask EXPIRED requester={requester_label} target={arg_target} \ AskRendezvousOutcome::Resolved(result) => result,
timeout_ms={} elapsed_ms={}", AskRendezvousOutcome::NoReply => return Err(rendezvous_no_reply_error(&arg_target)),
self.ask_rendezvous_timeout.as_millis(), AskRendezvousOutcome::CeilingActive => {
started.elapsed().as_millis(), return Err(rendezvous_ceiling_active_error(&arg_target))
);
// 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));
} }
} }
} else { } else {
dispatch.await dispatch.await
}; };
// Surface the processed delegation on the bus, tagged as the MCP door — the // 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 // 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. // 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. /// 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. /// Maps a [`ToolMapError`] to the right JSON-RPC error code.
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
match err { 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);
}
}