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

@ -32,6 +32,12 @@ pub const FIELD_PREVIEW_MAX_CHARS: usize = 160;
/// live state. Distinct semantics: soft = truncate, hard = reject.
pub const FIELD_MAX_BYTES: usize = 2 * 1024;
/// `progress` marker stamped on an orphan row reconciled at restart (see
/// [`LiveState::reconcile_orphans`]): it records *why* a `Working`/`Waiting`/
/// `Blocked` row was downgraded to `Idle` — its session was not running when the
/// project reopened. Well under both field bounds.
pub const STALE_AT_RESTART_MARKER: &str = "(stale — session not running at restart)";
/// What an agent is doing right now, at a glance.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -183,6 +189,46 @@ impl LiveState {
}
}
/// Computes the **reconciliation** of orphan rows at restart: rows whose
/// status implies a live session (`Working`/`Waiting`/`Blocked`) but whose
/// agent is **not** currently live according to `is_live`.
///
/// At reboot a crash never runs `close_project`, so `live-state.json` can keep
/// "ghost" rows for agents whose sessions are dead. This pure transform returns
/// the **rewritten** rows to upsert (it does **not** mutate `self`): each orphan
/// is downgraded to [`WorkStatus::Idle`], keeping its `intent` (trace), clearing
/// the now-dead `ticket` and `last_delegation`, stamping a `progress` stale
/// marker and a fresh `updated_at_ms = now_ms` (last-writer-wins).
///
/// Rows that are already `Idle`/`Done`, or whose agent is still live, are
/// **never** touched and are absent from the result. The caller persists each
/// returned entry through the keyed [`upsert`](LiveState::upsert) (best-effort).
#[must_use]
pub fn reconcile_orphans(
&self,
is_live: impl Fn(&AgentId) -> bool,
now_ms: u64,
) -> Vec<LiveEntry> {
self.entries
.iter()
.filter(|e| {
matches!(
e.status,
WorkStatus::Working | WorkStatus::Waiting | WorkStatus::Blocked
) && !is_live(&e.agent_id)
})
.map(|e| LiveEntry {
agent_id: e.agent_id,
ticket: None,
intent: e.intent.clone(),
status: WorkStatus::Idle,
progress: Some(STALE_AT_RESTART_MARKER.to_owned()),
last_delegation: None,
updated_at_ms: now_ms,
})
.collect()
}
/// Drops entries older than `ttl_ms` relative to `now_ms`, then bounds the
/// result to `max_n`, keeping the most recently updated rows.
///
@ -361,6 +407,94 @@ mod tests {
assert_eq!(kept, vec![50, 40], "most recent first, oldest dropped");
}
#[test]
fn reconcile_orphans_downgrades_dead_active_rows_and_rewrites_fields() {
let mut state = LiveState::default();
// An orphan: Working but its agent is not live.
state.upsert(
LiveEntry::new(
aid(1),
Some(tid(7)),
"implementing lot 2",
WorkStatus::Working,
Some("half done".to_owned()),
Some(tid(8)),
100,
)
.unwrap(),
);
// Nothing is live ⇒ aid(1) is an orphan.
let rewritten = state.reconcile_orphans(|_| false, 999);
assert_eq!(rewritten.len(), 1, "the single active-but-dead row is an orphan");
let row = &rewritten[0];
assert_eq!(row.agent_id, aid(1));
assert_eq!(row.status, WorkStatus::Idle, "downgraded to idle");
assert_eq!(row.intent, "implementing lot 2", "intent kept as trace");
assert_eq!(row.ticket, None, "dead ticket cleared");
assert_eq!(row.last_delegation, None, "dead delegation cleared");
assert_eq!(row.progress.as_deref(), Some(STALE_AT_RESTART_MARKER));
assert_eq!(row.updated_at_ms, 999, "stamped with the fresh now");
}
#[test]
fn reconcile_orphans_covers_working_waiting_blocked_only() {
let mut state = LiveState::default();
state.upsert(
LiveEntry::new(aid(1), None, "w", WorkStatus::Working, None, None, 1).unwrap(),
);
state.upsert(
LiveEntry::new(aid(2), None, "a", WorkStatus::Waiting, None, None, 1).unwrap(),
);
state.upsert(
LiveEntry::new(aid(3), None, "b", WorkStatus::Blocked, None, None, 1).unwrap(),
);
// idle/done are never orphans, even when the agent is dead.
state.upsert(LiveEntry::new(aid(4), None, "i", WorkStatus::Idle, None, None, 1).unwrap());
state.upsert(LiveEntry::new(aid(5), None, "d", WorkStatus::Done, None, None, 1).unwrap());
let rewritten = state.reconcile_orphans(|_| false, 50);
let mut ids: Vec<AgentId> = rewritten.iter().map(|e| e.agent_id).collect();
ids.sort();
assert_eq!(
ids,
vec![aid(1), aid(2), aid(3)],
"only working/waiting/blocked rows are reconciled; idle/done untouched"
);
}
#[test]
fn reconcile_orphans_spares_live_agents() {
let mut state = LiveState::default();
state.upsert(
LiveEntry::new(aid(1), None, "alive", WorkStatus::Working, None, None, 1).unwrap(),
);
state.upsert(
LiveEntry::new(aid(2), None, "dead", WorkStatus::Working, None, None, 1).unwrap(),
);
// aid(1) is still live ⇒ spared; only aid(2) is reconciled.
let rewritten = state.reconcile_orphans(|a| *a == aid(1), 50);
assert_eq!(rewritten.len(), 1);
assert_eq!(rewritten[0].agent_id, aid(2), "only the dead agent is reconciled");
}
#[test]
fn reconcile_orphans_does_not_mutate_self() {
let mut state = LiveState::default();
state.upsert(
LiveEntry::new(aid(1), Some(tid(1)), "x", WorkStatus::Working, None, None, 1).unwrap(),
);
let before = state.clone();
let _ = state.reconcile_orphans(|_| false, 999);
assert_eq!(state, before, "reconcile_orphans is a pure read (returns rows to upsert)");
}
#[test]
fn serde_round_trip_is_camel_case() {
let mut state = LiveState::default();