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>
This commit is contained in:
2026-06-23 20:18:25 +02:00
parent 15cf21c5bd
commit 886bb0d761
6 changed files with 435 additions and 4 deletions

View File

@ -19,6 +19,7 @@ use application::{
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput,
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput,
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
@ -124,6 +125,16 @@ pub async fn open_project(
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
// Réconcilie les lignes de live-state fantômes : un crash ne déclenche jamais
// `close_project`, donc l'ouverture est le seul filet fiable pour repasser en
// `idle` les agents `working`/`waiting`/`blocked` dont la session est morte.
// Acte système (appelle le port live-state directement, jamais via la surface
// MCP self-only). Best-effort : un échec ne bloque pas l'ouverture. Tourne
// AVANT toute relance d'agent (toute reprise réécrit ensuite en LWW).
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput { project_id: id })
.await;
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await;

View File

@ -27,7 +27,8 @@ use application::{
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn,
RecordTurnProvider, ReferenceProfiles,
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession,
@ -212,6 +213,43 @@ impl LiveStateReadProvider for AppLiveStateLeanProvider {
}
}
/// Provider par-root de la réconciliation du live-state au reboot (jumeau de
/// [`AppLiveStateProvider`] côté système). Résout le **project root** depuis le
/// `project_id` via le [`ProjectStore`], matérialise un [`FsLiveStateStore`] ciblant
/// `<root>/.ideai/live-state.json`, puis exécute le use case applicatif
/// [`ReconcileLiveState`] (qui croise chaque ligne avec la liveness réelle de
/// [`LiveSessions`] et ré-upsert les fantômes en `idle`).
///
/// **Acte système** : appelle le port [`domain::ports::LiveStateStore`]
/// **directement**, jamais via `OrchestratorCommand::SetWorkState` ⇒ le self-only de
/// `idea_workstate_set` reste préservé par construction. Best-effort : le call site
/// (`open_project`) ignore le résultat.
pub(crate) struct AppReconcileLiveState {
projects: Arc<dyn ProjectStore>,
registry: Arc<LiveSessions>,
clock: Arc<dyn Clock>,
}
impl AppReconcileLiveState {
/// Résout la racine du projet, lie le store par-root et lance la réconciliation.
///
/// # Errors
/// [`AppError`] si le projet est inconnu (registre) ou si le store échoue.
pub(crate) async fn execute(
&self,
input: ReconcileLiveStateInput,
) -> Result<(), AppError> {
let project = self.projects.load_project(input.project_id).await?;
let store = Arc::new(FsLiveStateStore::new(&project.root));
let uc = ReconcileLiveState::new(
store,
Arc::clone(&self.registry) as Arc<dyn LiveAgentRegistry>,
Arc::clone(&self.clock),
);
uc.execute(input).await
}
}
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
/// lancement en cours.
@ -383,6 +421,10 @@ pub struct AppState {
/// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même
/// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon.
pub reconcile_layouts: Arc<ReconcileLayouts>,
/// Réconcilie, à l'ouverture, les lignes de live-state fantômes (agent
/// `working`/`waiting`/`blocked` dont la session n'est plus vivante) : les
/// repasse en `idle`. Acte système best-effort, jamais via la surface MCP.
pub(crate) reconcile_live_state: Arc<AppReconcileLiveState>,
/// Detect which candidate profiles' CLIs are installed (first-run).
pub detect_profiles: Arc<DetectProfiles>,
/// List configured profiles.
@ -1169,6 +1211,16 @@ impl AppState {
Arc::clone(&terminal_sessions),
Arc::clone(&structured_sessions),
));
// Réconciliation du live-state au reboot : repasse en `idle` les lignes
// fantômes (working/waiting/blocked) dont la session n'est plus vivante,
// selon le MÊME registre de liveness que `GetProjectWorkState`. Provider
// par root (le store fixe sa racine à la construction). Câblé dans
// `open_project`, best-effort (cf. `reconcile_layouts`).
let reconcile_live_state = Arc::new(AppReconcileLiveState {
projects: Arc::clone(&store_port),
registry: Arc::clone(&live_sessions),
clock: Arc::clone(&clock) as Arc<dyn Clock>,
});
// Lot C — résumés de conversation best-effort : on câble les sources par
// project root (handoff = primaire, log = repli `last(_, 3)`). Lecture seule,
// aucune persistance ; un échec de preview ne bloque ni live/busy ni tickets.
@ -1374,6 +1426,7 @@ impl AppState {
set_active_layout,
snapshot_running_agents,
reconcile_layouts,
reconcile_live_state,
detect_profiles,
list_profiles,
save_profile,