merge(runtime): réconcilie les états et tâches fantômes au démarrage + garde-fou transcript indisponible — #307 (QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p infrastructure --test background_task_store 12/12, -p domain --lib live_state 15/15, -p backend ticket307_activation_reconcile_tests 1/1 + background_tasks_b7 1/1, -p application --test workstate 29/29, cargo check -p backend, frontend npm test CustomAgentChatView 72/72, npm run typecheck; branche fix/307-startup-ghost-states-reconciliation commit 57cf825; correctif borné 6 fichiers: domain live_state correlation_id effacé des orphelins, infrastructure reconcile_project_boot borné par projet + test multi-projets, backend filtrage project_id des complétions + test intégration activate_project + B7 renforcé, frontend CustomAgentChatView état terminal « transcript indisponible » après récupération vide sans spinner infini; artefact: l'AppImage v0.46.0 (99b400a, buildée 14:21 depuis ce worktree incluant le WIP #307 complet) correspond au contenu de develop post-merge — restant validation live fermeture/relance depuis l'app installée; fichiers .ideai/tickets/*, attachments/ et scripts/ hors périmètre et préservés en worktree; aucun push)

This commit is contained in:
2026-09-10 16:33:16 +02:00
6 changed files with 241 additions and 6 deletions

View File

@ -729,7 +729,12 @@ impl AppBackgroundTaskStore {
project_id: ProjectId, project_id: ProjectId,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> { ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
let store = self.store_for_project(project_id).await?; let store = self.store_for_project(project_id).await?;
let tasks = store.list_undelivered_completions().await?; let tasks = store
.list_undelivered_completions()
.await?
.into_iter()
.filter(|task| task.project_id == project_id)
.collect::<Vec<_>>();
for task in &tasks { for task in &tasks {
self.remember(task.id, task.project_id); self.remember(task.id, task.project_id);
} }
@ -743,7 +748,9 @@ impl AppBackgroundTaskStore {
now_ms: u64, now_ms: u64,
) -> Result<(), BackgroundTaskPortError> { ) -> Result<(), BackgroundTaskPortError> {
let store = self.store_for_project(project_id).await?; let store = self.store_for_project(project_id).await?;
let report = store.reconcile_boot(live_task_ids, now_ms).await?; let report = store
.reconcile_project_boot(project_id, live_task_ids, now_ms)
.await?;
for task_id in report for task_id in report
.failed_task_ids .failed_task_ids
.into_iter() .into_iter()
@ -5878,6 +5885,10 @@ mod background_tasks_b7_tests {
let orphan_after = store.get(orphan.id).await.unwrap().unwrap(); let orphan_after = store.get(orphan.id).await.unwrap().unwrap();
assert_eq!(orphan_after.state, BackgroundTaskState::Failed); assert_eq!(orphan_after.state, BackgroundTaskState::Failed);
assert!(orphan_after.has_pending_completion_delivery()); assert!(orphan_after.has_pending_completion_delivery());
assert!(
store.list_open_for_agent(owner).await.unwrap().is_empty(),
"activation must evict the orphan from the open-task projection"
);
assert!(matches!( assert!(matches!(
orphan_after.result, orphan_after.result,
Some(BackgroundTaskResult::Failure { Some(BackgroundTaskResult::Failure {
@ -5888,6 +5899,116 @@ mod background_tasks_b7_tests {
} }
} }
#[cfg(test)]
mod ticket307_activation_reconcile_tests {
use std::path::{Path, PathBuf};
use application::CreateProjectInput;
use domain::ports::LiveStateStore;
use domain::{
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskState, BackgroundTaskWakePolicy,
LiveEntry, Project, ProjectPath, TaskId, WorkStatus,
};
use infrastructure::FsLiveStateStore;
use uuid::Uuid;
use super::BackendCore;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("idea-ticket307-{tag}-{}", Uuid::new_v4()))
}
async fn create_project(core: &BackendCore, root: &Path) -> Project {
core.create_project
.execute(CreateProjectInput {
name: "ticket-307".to_owned(),
root: root.to_string_lossy().into_owned(),
remote: None,
default_profile_id: None,
})
.await
.expect("project creation succeeds")
.project
}
#[tokio::test]
async fn activate_project_clears_stale_live_correlation_and_terminalizes_orphan_task() {
let app_data = temp_path("appdata");
let root = temp_path("project");
let core = BackendCore::build(app_data.clone());
let project = create_project(&core, &root).await;
let owner = AgentId::from_uuid(Uuid::from_u128(307));
let live_store =
FsLiveStateStore::new(&ProjectPath::new(root.to_string_lossy().into_owned()).unwrap());
live_store
.upsert(
LiveEntry::new(
owner,
None,
"stale startup work",
WorkStatus::Working,
None,
None,
1_000,
)
.unwrap()
.with_correlation_id(Some("stale-correlation".to_owned()))
.unwrap(),
)
.await
.unwrap();
let task = BackgroundTask::new(
TaskId::from_uuid(Uuid::from_u128(308)),
project.id,
owner,
BackgroundTaskKind::Command {
label: "stale startup task".to_owned(),
},
BackgroundTaskWakePolicy::RecordOnly,
1_000,
None,
)
.unwrap()
.transition(BackgroundTaskState::Running, 1_001)
.unwrap();
core.background_task_store.create(&task).await.unwrap();
core.activate_project(&project).await;
let state = live_store.load().await.unwrap();
let row = state
.entries
.iter()
.find(|entry| entry.agent_id == owner)
.unwrap();
assert_eq!(row.status, WorkStatus::Idle);
assert_eq!(row.correlation_id, None);
assert_eq!(row.ticket, None);
assert_eq!(row.last_delegation, None);
let task_after = core
.background_task_store
.get(task.id)
.await
.unwrap()
.unwrap();
assert_eq!(task_after.state, BackgroundTaskState::Failed);
assert!(
core.background_task_store
.list_open_for_agent(owner)
.await
.unwrap()
.is_empty(),
"GetProjectWorkState's open-task source must not see the orphan after activation"
);
let _ = std::fs::remove_dir_all(app_data);
let _ = std::fs::remove_dir_all(root);
}
}
#[cfg(test)] #[cfg(test)]
mod mcp_serve_peer_tests { mod mcp_serve_peer_tests {
//! M5c — server side of the bind transport: [`serve_peer`] + [`parse_handshake`]. //! M5c — server side of the bind transport: [`serve_peer`] + [`parse_handshake`].

View File

@ -226,8 +226,9 @@ impl LiveState {
/// "ghost" rows for agents whose sessions are dead. This pure transform returns /// "ghost" rows for agents whose sessions are dead. This pure transform returns
/// the **rewritten** rows to upsert (it does **not** mutate `self`): each orphan /// the **rewritten** rows to upsert (it does **not** mutate `self`): each orphan
/// is downgraded to [`WorkStatus::Idle`], keeping its `intent` (trace), clearing /// is downgraded to [`WorkStatus::Idle`], keeping its `intent` (trace), clearing
/// the now-dead `ticket` and `last_delegation`, stamping a `progress` stale /// the now-dead `ticket`, `last_delegation` and `correlation_id`, stamping a
/// marker and a fresh `updated_at_ms = now_ms` (last-writer-wins). /// `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 /// 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 /// **never** touched and are absent from the result. The caller persists each
@ -253,7 +254,10 @@ impl LiveState {
status: WorkStatus::Idle, status: WorkStatus::Idle,
progress: Some(STALE_AT_RESTART_MARKER.to_owned()), progress: Some(STALE_AT_RESTART_MARKER.to_owned()),
last_delegation: None, last_delegation: None,
correlation_id: e.correlation_id.clone(), // A correlation identifies an active cross-project request. If
// the owning session is gone, retaining it would keep exposing a
// relationship that no runtime can resume or cancel.
correlation_id: None,
updated_at_ms: now_ms, updated_at_ms: now_ms,
}) })
.collect() .collect()
@ -451,6 +455,8 @@ mod tests {
Some(tid(8)), Some(tid(8)),
100, 100,
) )
.unwrap()
.with_correlation_id(Some("cross-project-request".to_owned()))
.unwrap(), .unwrap(),
); );
@ -468,6 +474,7 @@ mod tests {
assert_eq!(row.intent, "implementing lot 2", "intent kept as trace"); assert_eq!(row.intent, "implementing lot 2", "intent kept as trace");
assert_eq!(row.ticket, None, "dead ticket cleared"); assert_eq!(row.ticket, None, "dead ticket cleared");
assert_eq!(row.last_delegation, None, "dead delegation cleared"); assert_eq!(row.last_delegation, None, "dead delegation cleared");
assert_eq!(row.correlation_id, None, "dead correlation cleared");
assert_eq!(row.progress.as_deref(), Some(STALE_AT_RESTART_MARKER)); assert_eq!(row.progress.as_deref(), Some(STALE_AT_RESTART_MARKER));
assert_eq!(row.updated_at_ms, 999, "stamped with the fresh now"); assert_eq!(row.updated_at_ms, 999, "stamped with the fresh now");
} }

View File

@ -120,6 +120,31 @@ impl FsBackgroundTaskStore {
&self, &self,
live_task_ids: &[TaskId], live_task_ids: &[TaskId],
now_ms: u64, now_ms: u64,
) -> Result<BackgroundTaskReconcileReport, BackgroundTaskPortError> {
self.reconcile_boot_inner(None, live_task_ids, now_ms).await
}
/// Reconciles only the document owned by `project_id`.
///
/// The filesystem adapter is normally rooted at one project, but documents
/// remain keyed by project id. Activation must therefore stay explicitly
/// project-scoped and must not repair or report a foreign/stale document that
/// happens to exist below the same root.
pub async fn reconcile_project_boot(
&self,
project_id: ProjectId,
live_task_ids: &[TaskId],
now_ms: u64,
) -> Result<BackgroundTaskReconcileReport, BackgroundTaskPortError> {
self.reconcile_boot_inner(Some(project_id), live_task_ids, now_ms)
.await
}
async fn reconcile_boot_inner(
&self,
only_project_id: Option<ProjectId>,
live_task_ids: &[TaskId],
now_ms: u64,
) -> Result<BackgroundTaskReconcileReport, BackgroundTaskPortError> { ) -> Result<BackgroundTaskReconcileReport, BackgroundTaskPortError> {
let live: HashSet<TaskId> = live_task_ids.iter().copied().collect(); let live: HashSet<TaskId> = live_task_ids.iter().copied().collect();
let mut state = self.read_all_docs().await?; let mut state = self.read_all_docs().await?;
@ -127,6 +152,9 @@ impl FsBackgroundTaskStore {
let mut changed_projects = BTreeSet::new(); let mut changed_projects = BTreeSet::new();
for (project_id, doc) in &mut state.docs { for (project_id, doc) in &mut state.docs {
if only_project_id.is_some_and(|expected| expected != *project_id) {
continue;
}
for task in &mut doc.tasks { for task in &mut doc.tasks {
match task.state { match task.state {
BackgroundTaskState::Running BackgroundTaskState::Running

View File

@ -381,6 +381,34 @@ async fn reconcile_marks_running_without_live_handle_failed_and_pending() {
); );
} }
#[tokio::test]
async fn project_reconcile_does_not_repair_or_report_foreign_project_tasks() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let owned = running_task(1, 10, 100, 1_000);
let foreign = running_task(2, 20, 100, 1_000);
store.create(&owned).await.unwrap();
store.create(&foreign).await.unwrap();
let report = store
.reconcile_project_boot(owned.project_id, &[], 2_000)
.await
.unwrap();
assert_eq!(report.failed_task_ids, vec![owned.id]);
assert_eq!(report.delivery_pending_task_ids, vec![owned.id]);
assert_eq!(
store.get(owned.id).await.unwrap().unwrap().state,
BackgroundTaskState::Failed
);
assert_eq!(
store.get(foreign.id).await.unwrap().unwrap().state,
BackgroundTaskState::Running,
"activating one project must not mutate another project's persisted task"
);
}
#[tokio::test] #[tokio::test]
async fn reconcile_marks_cancelling_without_live_handle_cancel_failed_and_pending() { async fn reconcile_marks_cancelling_without_live_handle_cancel_failed_and_pending() {
let tmp = TempDir::new(); let tmp = TempDir::new();

View File

@ -197,6 +197,57 @@ describe("CustomAgentChatView", () => {
__resetProjectCliInteractionActivityForTests(); __resetProjectCliInteractionActivityForTests();
}); });
it("settles an isolated conversation id without recovered turns as unavailable", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="orphan-conversation"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith(
"project-1",
"agent-1",
expect.objectContaining({ conversationId: "orphan-conversation" }),
),
);
expect(
await screen.findByText("Conversation liée — transcript indisponible."),
).toBeTruthy();
expect(
screen.queryByText("Conversation liée — récupération du transcript en cours."),
).toBeNull();
expect(screen.queryByRole("progressbar")).toBeNull();
expect(screen.queryByRole("button", { name: "Réessayer" })).toBeNull();
});
it("projects self-restart lifecycle separators in the custom CLI only once", async () => { it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const agent = { const agent = {

View File

@ -2504,7 +2504,7 @@ export function CustomAgentChatView({
</div> </div>
) : displayTurns.length === 0 && hasLinkedConversation ? ( ) : displayTurns.length === 0 && hasLinkedConversation ? (
<p className="m-auto text-xs text-muted"> <p className="m-auto text-xs text-muted">
Conversation liée — récupération du transcript en cours. Conversation liée — transcript indisponible.
</p> </p>
) : displayTurns.length === 0 ? ( ) : displayTurns.length === 0 ? (
<p className="m-auto text-xs text-muted"> <p className="m-auto text-xs text-muted">