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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user