From 52ad7affb0a5b5a39f8bf98a954b18979685ccfb Mon Sep 17 00:00:00 2001 From: yuxino Date: Mon, 21 Sep 2026 20:35:11 +0800 Subject: [PATCH] fix(scheduled): preserve manual task workspace bindings Distinguish a saved workspace, including an explicit null binding, from a legacy task without one. Manual runs and editor updates must not retarget the task when the foreground project changes. Cover project and temporary bindings across restart, dispatch and edits, while retaining legacy fallback behavior. --- crates/host-core/src/rpc/scheduled_rpc.rs | 88 ++++++++++++++++++- crates/host-core/src/scheduled.rs | 4 + docs/spec/04-ux/01-ui-ia.md | 5 +- docs/spec/06-delivery/04-e2e-test-plan.md | 18 ++++ docs/zh-CN/spec/04-ux/01-ui-ia.md | 6 +- .../spec/06-delivery/04-e2e-test-plan.md | 18 ++++ scripts/e2e-scheduled-workspace.mjs | 46 ++++++++++ 7 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 scripts/e2e-scheduled-workspace.mjs diff --git a/crates/host-core/src/rpc/scheduled_rpc.rs b/crates/host-core/src/rpc/scheduled_rpc.rs index 701dba6c97..3b47a7fef3 100644 --- a/crates/host-core/src/rpc/scheduled_rpc.rs +++ b/crates/host-core/src/rpc/scheduled_rpc.rs @@ -41,7 +41,7 @@ pub(super) fn handle_in_workspace( .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; if existing .as_ref() - .is_some_and(|task| task.schedule.is_none()) + .is_some_and(|task| !task.workspace_bound && task.schedule.is_none()) { params["workspacePath"] = json!(workspace); } else if let Some(object) = params.as_object_mut() { @@ -138,7 +138,7 @@ pub(super) fn handle_in_workspace( .get("defaultModelId") .and_then(|v| v.as_str()) .map(str::to_string), - project_path: if task.schedule.is_some() { + project_path: if task.workspace_bound || task.schedule.is_some() { task.workspace_path.clone() } else { st.workspace.get().map(|w| w.path) @@ -233,6 +233,90 @@ fn validate_schedule_input(params: &Value) -> Result<(), JsonRpcError> { mod tests { use super::*; + #[test] + fn manual_task_keeps_saved_workspace_across_run_edit_and_restart() { + for project_bound in [true, false] { + let dir = tempfile::tempdir().unwrap(); + let project_a = tempfile::tempdir().unwrap(); + let project_b = tempfile::tempdir().unwrap(); + let saved_path = project_bound.then(|| { + project_a + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned() + }); + let state = AppState::open(dir.path()).unwrap(); + let task = handle_in_workspace( + &state, + "scheduled.create", + json!({ + "title":"A task", "prompt":"Reply OK", "cadence":"manual", "schedule":null + }), + saved_path.clone(), + ) + .unwrap()["task"] + .clone(); + let id = task["id"].as_str().unwrap(); + drop(state); + let mut state = AppState::open(dir.path()).unwrap(); + state.workspace.set(project_b.path()); + let run = handle(&state, "scheduled.run", json!({"id":id})).unwrap(); + let session = sessions::get_session(&state.db, run["sessionId"].as_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(session.summary.project_path, saved_path); + handle( + &state, + "scheduled.finishRun", + json!({"runId":run["runId"],"status":"completed"}), + ) + .unwrap(); + let edited = handle( + &state, + "scheduled.update", + json!({ + "id":id, "title":"Renamed", "cadence":"manual", "schedule":null + }), + ) + .unwrap(); + assert_eq!(edited["task"]["workspacePath"], json!(saved_path)); + let recurring = handle( + &state, + "scheduled.update", + json!({ + "id":id, "cadence":"hourly", "schedule":{"hour":9,"minute":0,"weekday":0} + }), + ) + .unwrap(); + assert_eq!(recurring["task"]["workspacePath"], json!(saved_path)); + } + } + + #[test] + fn legacy_task_uses_current_workspace_until_explicitly_configured() { + let dir = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let mut state = AppState::open(dir.path()).unwrap(); + let task = handle( + &state, + "scheduled.create", + json!({"prompt":"Reply OK","cadence":"manual"}), + ) + .unwrap()["task"] + .clone(); + let id = task["id"].as_str().unwrap(); + let path = state.workspace.set(project.path()).path; + let run = handle(&state, "scheduled.run", json!({"id":id})).unwrap(); + let session = sessions::get_session(&state.db, run["sessionId"].as_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(session.summary.project_path, Some(path.clone())); + let edited = handle(&state, "scheduled.update", json!({"id":id,"schedule":null})).unwrap(); + assert_eq!(edited["task"]["workspacePath"], path); + } + #[test] fn selected_weekdays_round_trip_and_invalid_edits_preserve_saved_schedule() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/host-core/src/scheduled.rs b/crates/host-core/src/scheduled.rs index 45a57665ce..2b83a446b1 100644 --- a/crates/host-core/src/scheduled.rs +++ b/crates/host-core/src/scheduled.rs @@ -31,6 +31,9 @@ pub struct ScheduledTask { pub next_run_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Presence distinguishes a saved project (including null) from legacy tasks. + #[serde(skip)] + pub(crate) workspace_bound: bool, } #[derive(Debug, Clone, Serialize)] @@ -169,6 +172,7 @@ fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { .get("nextRunAt") .and_then(Value::as_i64) .map(ms_to_ts), + workspace_bound: config.get("workspacePath").is_some(), workspace_path: config .get("workspacePath") .and_then(Value::as_str) diff --git a/docs/spec/04-ux/01-ui-ia.md b/docs/spec/04-ux/01-ui-ia.md index 813d808d31..2dca120dbf 100644 --- a/docs/spec/04-ux/01-ui-ia.md +++ b/docs/spec/04-ux/01-ui-ia.md @@ -266,7 +266,10 @@ rearms future occurrences only. Hourly schedules wait a full hour after saving, enabling, startup or the preceding automatic admission; Run now leaves the automatic occurrence unchanged. Legacy cadence-only tasks require explicit schedule configuration. The current project is captured when first configured; -subsequent foreground project changes do not retarget it. Automatic runs use +subsequent foreground project changes do not retarget it. This includes Manual +tasks and tasks saved without a project: Run now, renaming, and cadence changes +preserve that binding, including after restart. Only legacy tasks without a saved +binding capture the current project on their first explicit configuration. Automatic runs use Ask permissions and may wait for input in their conversation. New tasks default to Agent. A migrated Plan or Goal task is allowed to remain stored, but an unattended run is explicitly rejected before provider, artifact, diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 996981fdb9..19f5202ce1 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -7929,6 +7929,7 @@ identify the platform validation still needed. | Acceptance | Scenarios | |---|---| +| C / F / Quality — Saved project isolation | E2E-SCHEDULED-manual-workspace-binding | | C / F / Quality — Desktop automations | E2E-SCHEDULED-desktop-automation-lifecycle | | A / C — Unicode stdio framing | E2E-RPC-unicode-separators | | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | @@ -14286,3 +14287,20 @@ the latest destination. These assertions measure work counts, not device FPS. - **Expected**: The complete URL, including `(software)`, opens the React software article. The outer prose closing parenthesis and sentence-ending punctuation after the URL parenthesis are not part of the link. Adjacent references remain independently clickable. Nested parentheses, query/fragment parentheses and percent-encoded parentheses remain intact. - **Coverage**: `chat-links.test.mjs`; native desktop click-through with the normal browser destination. - **Specs linked**: `04-ux/08-component-spec.md` §8.3. + +### E2E-SCHEDULED-manual-workspace-binding + +- **Preconditions:** Built request candidate; isolated host data and two project + directories. No provider credentials or paid API. +- **Steps:** Create Manual and Hourly tasks in project A and without a project; + restart the host, switch to B, Run now, rename through the editor payload, + and Run now again. +- **Expected:** Each result session and edited task retain the original binding, + including no-project tasks. Legacy cadence-only tasks keep their previous + fallback until explicitly configured (covered by host RPC tests). +- **Specs:** 04-ux/01-ui-ia §3.4. +- **Acceptance:** Saved workspace binding across run, edit and restart. +- **Milestone:** Maintenance. +- **Status:** Automated by `node --experimental-strip-types + scripts/e2e-scheduled-workspace.mjs`, using production Electron dispatch and + real Rust/stdio/SQLite. Only external inference is replaced with an observer. diff --git a/docs/zh-CN/spec/04-ux/01-ui-ia.md b/docs/zh-CN/spec/04-ux/01-ui-ia.md index 304fd1fad1..87e5e4c2ac 100644 --- a/docs/zh-CN/spec/04-ux/01-ui-ia.md +++ b/docs/zh-CN/spec/04-ux/01-ui-ia.md @@ -217,7 +217,11 @@ 应用必须保持运行。每 30 秒检查一次,超过 90 秒的错过时段与重叠运行被跳过; 重启只安排未来时段。旧任务需明确保存周期后才自动执行。首次设置时保存当前项目, -前台项目切换不会改变绑定。自动执行使用 Ask 权限模式,不自动授权或抢占当前页面。 +前台项目切换不会改变绑定。 +Manual tasks, including tasks saved without a project, retain that binding across +Run now, renaming, cadence changes, and restart. Only legacy tasks without a +saved binding capture the current project on their first explicit configuration. +自动执行使用 Ask 权限模式,不自动授权或抢占当前页面。 新任务默认为 Agent。允许保留已迁移的 Plan 或 Goal 任务 已存储,但在提供商、工件之前明确拒绝无人值守的运行, 或使用 `PLAN_REQUIRES_INTERACTIVE_SESSION` 排队工作;它无法显示或 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 9a68c4ab5b..babd402d59 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5184,6 +5184,7 @@ eleven-tool-round desktop paths are verified by | 验收 | 应用场景 | |---|---| +| C / F / Quality — Saved project isolation | E2E-SCHEDULED-manual-workspace-binding | | C / F / Quality — 桌面定时任务 | E2E-SCHEDULED-desktop-automation-lifecycle | | A / C — Unicode stdio 成帧 | E2E-RPC-unicode-separators | | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | @@ -8457,3 +8458,20 @@ the latest destination. These assertions measure work counts, not device FPS. - **预期**:打开包含 `(software)` 的完整网址,进入 React 软件词条;正文外层的右括号和紧跟 URL 右括号的句末标点不属于链接,相邻引用仍能独立点击。嵌套圆括号、查询和片段内的圆括号、百分号编码的圆括号均保持完整。 - **覆盖**:`chat-links.test.mjs`;桌面端通过正常浏览器目标实际点击验证。 - **链接规格**:`04-ux/08-component-spec.md` §8.3。 + +### E2E-SCHEDULED-manual-workspace-binding + +- **Preconditions:** Built request candidate; isolated host data and two project + directories. No provider credentials or paid API. +- **Steps:** Create Manual and Hourly tasks in project A and without a project; + restart the host, switch to B, Run now, rename through the editor payload, + and Run now again. +- **Expected:** Each result session and edited task retain the original binding, + including no-project tasks. Legacy cadence-only tasks keep their previous + fallback until explicitly configured (covered by host RPC tests). +- **Specs:** 04-ux/01-ui-ia §3.4. +- **Acceptance:** Saved workspace binding across run, edit and restart. +- **Milestone:** Maintenance. +- **Status:** Automated by `node --experimental-strip-types + scripts/e2e-scheduled-workspace.mjs`, using production Electron dispatch and + real Rust/stdio/SQLite. Only external inference is replaced with an observer. diff --git a/scripts/e2e-scheduled-workspace.mjs b/scripts/e2e-scheduled-workspace.mjs new file mode 100644 index 0000000000..7604150cf7 --- /dev/null +++ b/scripts/e2e-scheduled-workspace.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Real Electron dispatch service, Rust host, stdio and SQLite; inference is observed only. +import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Host, resolveHostBinary } from "./e2e/host.mjs"; +import { executeScheduledTask } from "../apps/desktop/electron/main/runtime/scheduled-runner.ts"; + +const root = await mkdtemp(join(tmpdir(), "pi-scheduled-workspace-")); +const projectA = join(root, "a"); +const projectB = join(root, "b"); +await Promise.all([mkdir(projectA), mkdir(projectB)]); +const host = new Host(resolveHostBinary(), join(root, "data")); +async function dispatch(task, expected) { + const launch = await executeScheduledTask({ + host, id: task.id, automatic: false, runs: new Map(), isCurrent: () => true, + prompt: async (sessionId, content) => { + assert.equal(content, "Reply OK"); + const { session } = await host.call("session.get", { id: sessionId }); + assert.equal(session.projectPath ?? null, expected ?? null); + }, + }); + await host.call("scheduled.finishRun", { runId: launch.runId, status: "completed" }); +} +try { + await host.start(); + for (const cadence of ["manual", "hourly"]) { + for (const bound of [true, false]) { + await host.call(bound ? "workspace.set" : "workspace.clear", bound ? { path: projectA } : {}); + const input = { title: "Workspace regression", prompt: "Reply OK", cadence, + schedule: cadence === "manual" ? null : { hour: 9, minute: 0, weekday: 0 } }; + const { task } = await host.call("scheduled.create", input); + await host.stop(); + await host.start(); + await host.call("workspace.set", { path: projectB }); + await dispatch(task, task.workspacePath); + const { task: renamed } = await host.call("scheduled.update", { ...input, id: task.id, title: "Renamed" }); + assert.equal(renamed.workspacePath, task.workspacePath); + await dispatch(renamed, task.workspacePath); + console.log(`PASS ${cadence}: ${bound ? "project" : "temporary"} binding survives restart, run and edit`); + } + } +} finally { + await host.stop(); +}