feat(backend,frontend): tâches planifiées projet — tool MCP idea_scheduled_task_* et menu utilisateur #305, fix transcript lié indisponible après reboot #314 (QA verte)

#305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité <app_data_dir>/scheduled-project-tasks/<projectId>.json, schedules bornés V1 interval/daily/weekly avec fuseau, cycle À approuver/Active/Suspendue avec approbation obligatoire des tâches demandées par agent; use cases application/scheduled_task (list/create/update/delete/run_now/reconcile/fire) réutilisant BackgroundTask pour les runs avec distinction planifié/manuel; store infrastructure dédié + scheduler reconcile au boot sans rattrapage en rafale, cwd confiné au projet; 7 tools MCP idea_scheduled_task_* (list/read lecture seule, create/update/delete/run_now/set_enabled write-actions soumises aux permissions natives); commandes Tauri + routes HTTP aux contrats DTO identiques (camelCase nextRunAtMs/lastRunAtMs/lastTaskId/createdBy/updatedBy); frontend: gateways Tauri/HTTP/mock + types ports/domain, surface projet 'Tâches planifiées' (liste utilisateur+agent unifiée, création/édition avec récapitulatif avant activation, approbation/refus, exécuter maintenant, suspendre/reprendre, suppression), catalogage surface web (webSurfaceCatalog/viewPlacement/ViewPanelBody/WebWorkspace); docs/scheduled-project-tasks.md et sdk docs/services.md (047c38b: pas un service plugin V1, pas d'émulation via cron système).

#314: correction de 'Conversation liée — transcript indisponible' après reboot: restauration du transcript lié côté application (orchestrator service, lifecycle, session_limit, background sink, terminal registry) et côté frontend (adapters agent/http frames/stream/requestResponse, workStateNormalization, CustomAgentChatView avec état et action utile), tests dto_chat/agent_lifecycle/structured_registry_d1/background_completion_sink étendus.

QA verte rapportée par QA sur feature/305-scheduled-project-tasks-mcp, sans bloquant, pour #305 et #314.

Hors périmètre worktree: tickets non liés, attachments/, scripts/; aucun push.
This commit is contained in:
2026-09-11 10:32:01 +02:00
parent 72792bb02b
commit 7007b2cc7e
63 changed files with 3887 additions and 100 deletions

View File

@ -55,17 +55,18 @@ use application::{
RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore,
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder,
SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopAgentRuntime, StructuredRoutingMode,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSettings,
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateUserSettings,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
SaveProfile, ScheduledProjectTaskService, SessionLimitService, SetActiveLayout,
SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry,
SnapshotOpenWindows, SnapshotRunningAgents, SnapshotRunningAgentsInput, SpawnBackgroundCommand,
StopAgentRuntime, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable,
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator,
UpdateProjectPermissions, UpdateProjectSettings, UpdateProjectSystemPermissions, UpdateSkill,
UpdateTemplate, UpdateUserSettings, WakeSessionProvider, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::events::AgentRestartPhase;
@ -107,9 +108,9 @@ use infrastructure::{
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
@ -2165,6 +2166,8 @@ pub struct BackendCore {
pub background_command_runner: Arc<CommandBackgroundRunner>,
/// Store handle used by `list_background_tasks` to read the task read-model.
pub background_task_store: Arc<dyn BackgroundTaskStore>,
/// Durable definitions plus their runtime one-shot reconciliation.
pub scheduled_project_tasks: Arc<ScheduledProjectTaskService>,
// --- Plugins (#43) ---
/// Review a local plugin package before install.
pub review_plugin_package: Arc<ReviewPluginPackage>,
@ -2470,6 +2473,9 @@ impl BackendCore {
Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(),
));
let scheduled_task_store = Arc::new(FsScheduledProjectTaskStore::new(&app_data_dir));
let scheduled_task_store_port =
Arc::clone(&scheduled_task_store) as Arc<dyn domain::ScheduledProjectTaskStore>;
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
Arc::clone(&clock) as Arc<dyn Clock>
));
@ -3945,10 +3951,19 @@ impl BackendCore {
}) as Arc<dyn AgentResumer>;
let session_limit_service = Arc::new(SessionLimitService::new(
Arc::clone(&clock) as Arc<dyn Clock>,
scheduler,
Arc::clone(&scheduler),
Arc::clone(&events_port),
resumer,
));
let scheduled_project_tasks = Arc::new(ScheduledProjectTaskService::new(
Arc::clone(&scheduled_task_store_port),
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&scheduler),
Arc::clone(&spawn_background_command),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let restart_rendezvous = Arc::new(application::RestartRendezvousCoordinator::new());
let self_restart = Arc::new(AppAgentSelfRestarter {
launch_agent: Arc::clone(&launch_agent),
@ -3967,9 +3982,24 @@ impl BackendCore {
// peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app.
{
let service = Arc::clone(&session_limit_service);
let scheduled = Arc::clone(&scheduled_project_tasks);
spawn_detached(async move {
while let Some(task) = resume_rx.recv().await {
if let Err(e) = service.execute_resume(task).await {
let result = match task {
task @ ScheduledTask::ResumeAgent { .. } => {
service.execute_resume(task).await
}
ScheduledTask::FireProjectTask {
project_id,
scheduled_task_id,
planned_at_ms,
} => {
scheduled
.fire(project_id, scheduled_task_id, planned_at_ms)
.await
}
};
if let Err(e) = result {
// Best-effort : une relance qui échoue ne fige pas le drain.
eprintln!("[session-limit] reprise auto échouée : {e}");
}
@ -4129,7 +4159,8 @@ impl BackendCore {
.with_session_limits(Arc::clone(&session_limit_service))
// Producteur B8 côté agent MCP : `idea_run_in_background` passe par le
// même use case que la commande desktop, sans aller-retour frontend.
.with_spawn_background_command(Arc::clone(&spawn_background_command)),
.with_spawn_background_command(Arc::clone(&spawn_background_command))
.with_scheduled_project_tasks(Arc::clone(&scheduled_project_tasks)),
);
openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new(
Arc::clone(&orchestrator_service),
@ -4375,6 +4406,7 @@ impl BackendCore {
retry_background_task,
background_command_runner: Arc::clone(&background_runner),
background_task_store: Arc::clone(&background_tasks_port),
scheduled_project_tasks,
review_plugin_package,
install_plugin_from_archive,
install_plugin_from_directory,
@ -4435,6 +4467,10 @@ impl BackendCore {
.execute(application::ReconcileLiveStateInput { project_id })
.await;
let _ = self.reconcile_background_tasks.execute(project_id).await;
let _ = self
.scheduled_project_tasks
.reconcile_project(project_id)
.await;
self.ensure_orchestrator_watch(project);
self.reconcile_claude_run_dirs(project).await;
}
@ -4708,6 +4744,7 @@ impl BackendCore {
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
/// are torn down together.
pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
self.scheduled_project_tasks.cancel_project(*project_id);
if let Some(handle) = self
.orchestrator_watchers
.lock()
@ -8797,3 +8834,107 @@ mod open_project_restore_tests {
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod ticket305_scheduled_task_tests {
use super::BackendCore;
use application::CreateProjectInput;
use domain::{
AgentId, OrchestratorCommand, ScheduledTaskApproval, ScheduledTaskCommand,
ScheduledTaskSchedule,
};
use uuid::Uuid;
#[tokio::test]
async fn agent_request_is_machine_local_pending_and_cannot_be_enabled_by_agent() {
let app_data = std::env::temp_dir().join(format!("idea-ticket305-app-{}", Uuid::new_v4()));
let root = std::env::temp_dir().join(format!("idea-ticket305-project-{}", Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let core = BackendCore::build(app_data.clone());
let project = core
.create_project
.execute(CreateProjectInput {
name: "scheduled".to_owned(),
root: root.to_string_lossy().into_owned(),
remote: None,
default_profile_id: None,
})
.await
.unwrap()
.project;
let requester = AgentId::new_random();
let outcome = core
.orchestrator_service
.dispatch(
&project,
OrchestratorCommand::CreateScheduledTask {
requester,
name: "cleanup".to_owned(),
description: Some("remove generated files".to_owned()),
command: ScheduledTaskCommand {
command: "find".to_owned(),
args: vec!["build".to_owned()],
cwd: Some(".".to_owned()),
},
schedule: ScheduledTaskSchedule::Interval { every_minutes: 15 },
timezone: "UTC".to_owned(),
timeout_ms: Some(60_000),
},
)
.await
.unwrap();
let task: domain::ScheduledProjectTask =
serde_json::from_str(outcome.reply.as_deref().unwrap()).unwrap();
assert_eq!(task.approval, ScheduledTaskApproval::Pending);
assert!(!task.enabled);
assert!(task.next_run_at_ms.is_none());
assert!(app_data
.join("scheduled-project-tasks")
.join(format!("{}.json", project.id))
.is_file());
let error = core
.scheduled_project_tasks
.set_enabled(
project.id,
task.id,
true,
domain::ScheduledTaskActor::Agent {
agent_id: requester,
},
)
.await
.unwrap_err();
assert!(error.to_string().contains("only a human can approve"));
let approved = core
.scheduled_project_tasks
.set_enabled(project.id, task.id, true, domain::ScheduledTaskActor::Human)
.await
.unwrap();
let planned_at_ms = approved.next_run_at_ms.unwrap();
core.scheduled_project_tasks
.fire(project.id, task.id, planned_at_ms)
.await
.unwrap();
let fired = core
.scheduled_project_tasks
.get(project.id, task.id)
.await
.unwrap();
let concrete_task = core
.background_task_store
.get(fired.last_task_id.unwrap())
.await
.unwrap()
.unwrap();
assert!(matches!(
concrete_task.origin,
domain::BackgroundTaskOrigin::Scheduled {
scheduled_task_id,
planned_at_ms: occurrence,
} if scheduled_task_id == task.id && occurrence == planned_at_ms
));
let _ = tokio::fs::remove_dir_all(app_data).await;
let _ = tokio::fs::remove_dir_all(root).await;
}
}