fix(backend,domain,infrastructure,frontend): réconcilie les états et tâches fantômes au démarrage — #307 (cause: l'activation projet balayait tous les documents background-task sous le root sans filtre project_id et LiveState::reconcile_orphans conservait correlation_id des lignes mortes, laissant après reboot des LED working fantômes et des tâches Running orphelines reprojetées par GetProjectWorkState, tandis qu'un conversationId isolé affichait indéfiniment « Conversation liée — récupération du transcript en cours. »; fix backend: FsBackgroundTaskStore::reconcile_project_boot borne la réconciliation boot au document du projet activé (API reconcile_boot historique préservée), AppBackgroundTaskStore filtre list_undelivered_completions sur project_id, et une ligne active orpheline efface désormais aussi correlation_id en plus de ticket/last_delegation; garde-fou frontend: CustomAgentChatView affiche l'état terminal « Conversation liée — transcript indisponible. » quand openOrAttach terminé n'a récupéré aucun tour, spinner borné à opening, conversationId et cellule préservés; 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 dont project_reconcile_does_not_repair_or_report_foreign_project_tasks, -p domain --lib live_state 15/15, -p backend ticket307_activation_reconcile_tests 1/1 (activate_project_clears_stale_live_correlation_and_terminalizes_orphan_task) et background_tasks_b7 1/1 renforcé (orphan évicté de list_open_for_agent), -p application --test workstate 29/29, cargo check -p backend, frontend npm test CustomAgentChatView 72/72 dont garde-fou transcript indisponible, npm run typecheck; risque résiduel documenté: le garde-fou suppose que openOrAttach finit par se résoudre, pas de timeout transport ajouté; fichiers .ideai/tickets/296/*, tickets/299-307/*, counter/index, attachments/ et scripts/ hors périmètre et préservés en worktree; aucun push)
This commit is contained in:
@ -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`].
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user