Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions crates/host-core/src/rpc/scheduled_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ fn execute_inner(st: &AppState, p: &ToolsExecuteParams) -> Result<Value, JsonRpc
input["schedule"] = Value::Null;
}
}
// An echoed cadence during legacy maintenance is not an arming request.
if args.get("cadence").and_then(Value::as_str) == Some("hourly")
&& (p.tool_name == "ScheduledTaskCreate"
|| existing
.as_ref()
.is_some_and(|task| task.cadence != "hourly")
|| args.get("enabled").and_then(Value::as_bool) == Some(true))
&& !args.contains_key("schedule")
&& existing
.as_ref()
Expand All @@ -141,6 +147,13 @@ fn execute_inner(st: &AppState, p: &ToolsExecuteParams) -> Result<Value, JsonRpc
input["schedule"] = json!({"hour":0,"minute":0,"weekday":0});
}
if p.tool_name != "ScheduledTaskDelete"
&& (p.tool_name == "ScheduledTaskCreate"
|| (args.contains_key("cadence")
&& existing
.as_ref()
.is_some_and(|task| task.cadence != cadence))
|| args.contains_key("schedule")
|| args.get("enabled").and_then(Value::as_bool) == Some(true))
&& cadence != "manual"
&& input.get("schedule").is_none()
&& existing
Expand Down Expand Up @@ -568,4 +581,92 @@ mod tests {
Some(PermissionDecision::AllowOnce)
));
}

#[tokio::test]
async fn review_legacy_task_can_be_paused_without_arming_it() {
let dir = tempfile::tempdir().unwrap();
let mut st = AppState::open(dir.path()).unwrap();
st.handshook = true;
st.db
.set_setting("app", &json!({"defaultPermissionMode":"auto"}))
.unwrap();
scheduled::import_tasks(
&st.db,
&[json!({"id":"legacy", "prompt":"Review", "cadence":"daily"})],
)
.unwrap();
let session =
sessions::create_session(&st.db, None, Some("agent".into()), None, None, None).unwrap();
let state = Arc::new(Mutex::new(st));
let listed = call(&state, &session.id, "ScheduledTaskList", json!({})).await;
assert_eq!(listed["content"]["tasks"][0]["id"], "legacy");
let paused = call(
&state,
&session.id,
"ScheduledTaskUpdate",
json!({"id":"legacy","enabled":false}),
)
.await;
assert_eq!(
paused["ok"], true,
"Pausing must not require an automatic schedule: {paused}"
);
assert_eq!(paused["content"]["task"]["enabled"], false);
assert!(paused["content"]["task"].get("nextRunAt").is_none());
}

#[tokio::test]
async fn legacy_maintenance_preserves_data_but_does_not_silently_enable() {
for cadence in ["hourly", "daily", "weekly"] {
for enabled in [false, true] {
let dir = tempfile::tempdir().unwrap();
let mut st = AppState::open(dir.path()).unwrap();
st.handshook = true;
st.db
.set_setting("app", &json!({"defaultPermissionMode":"auto"}))
.unwrap();
scheduled::import_tasks(&st.db,&[json!({"id":"old","title":"Old","prompt":"Review","cadence":cadence,"enabled":enabled})]).unwrap();
let sid =
sessions::create_session(&st.db, None, Some("agent".into()), None, None, None)
.unwrap()
.id;
let state = Arc::new(Mutex::new(st));
for args in [
json!({"id":"old","title":"Renamed"}),
json!({"id":"old","prompt":"Updated"}),
json!({"id":"old","enabled":false}),
json!({"id":"old","cadence":cadence,"enabled":false}),
] {
let result = call(&state, &sid, "ScheduledTaskUpdate", args).await;
assert_eq!(result["ok"], true, "{result}");
assert!(result["content"]["task"].get("schedule").is_none());
assert!(result["content"]["task"].get("nextRunAt").is_none());
}
let refused = call(
&state,
&sid,
"ScheduledTaskUpdate",
json!({"id":"old","enabled":true}),
)
.await;
assert_eq!(refused["errorCode"], "INVALID_PARAMS");
drop(state);
let mut st = AppState::open(dir.path()).unwrap();
st.handshook = true;
let saved = scheduled::get_task(&st.db, "old").unwrap().unwrap();
assert_eq!(saved.title, "Renamed");
assert_eq!(saved.prompt, "Updated");
assert!(!saved.enabled);
assert!(saved.schedule.is_none());
assert!(!saved.workspace_bound);
let state = Arc::new(Mutex::new(st));
let configured=call(&state,&sid,"ScheduledTaskUpdate",json!({"id":"old","cadence":cadence,"schedule":{"hour":9,"minute":30,"weekday":0},"enabled":true})).await;
assert_eq!(configured["ok"], true, "{configured}");
assert_eq!(
call(&state, &sid, "ScheduledTaskDelete", json!({"id":"old"})).await["ok"],
true
);
}
}
}
}
3 changes: 3 additions & 0 deletions docs/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,9 @@ a schedule. Update takes an existing ID and partial fields, preserving all
unspecified configuration. Exact local times remain supported despite the
UI's four period presets. No new DB schema or transport is introduced.

### Scheduled tasks: legacy task maintenance

Agent tools allow title, prompt and pause updates on legacy automatic tasks without a schedule, including an echoed unchanged cadence. These edits do not arm the task or capture the foreground workspace. Explicit enabling, a cadence change or a supplied schedule still follows schedule validation. Resume requires an explicit valid schedule; Manual-to-Hourly retains its existing default interval behavior.
### Scheduled tasks: calendar intent

The optional config_json.calendarConfigured boolean distinguishes an explicitly configured Daily/Weekly calendar from Hourly's internal schedule placeholder. Without the key, legacy Daily/Weekly schedules are treated as configured; legacy Hourly schedules retain their values but require an explicit schedule when converting to Daily/Weekly. Known calendar intent survives Hourly and restart, including midnight. Clearing or replacing the calendar with a different non-calendar placeholder clears intent. This additive extension needs no table/schema migration; older versions ignore it and cannot enforce the new conversion guard. Metadata-only edits and Manual-to-Hourly remain unchanged.
Expand Down
5 changes: 5 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -14454,6 +14454,11 @@ the latest destination. These assertions measure work counts, not device FPS.
cover ownership, deletion, repeated/bounded forks, retained checkpoint paths,
expired inputs, rollback, and symlink rejection.

### E2E-SCHEDULED-legacy-pause

- **Scenario:** Legacy task maintenance.
- **Expected:** Agent tools allow title, prompt and pause updates on legacy automatic tasks without a schedule, including an echoed unchanged cadence. These edits do not arm the task or capture the foreground workspace. Explicit enabling, a cadence change or a supplied schedule still follows schedule validation. Resume requires an explicit valid schedule; Manual-to-Hourly retains its existing default interval behavior.
- **Automation:** `node --experimental-strip-types scripts/e2e-scheduled-legacy-maintenance.mjs` uses an isolated real Host and SQLite profile. Inference is not sent to a live provider.
### E2E-SCHEDULED-calendar-intent

- **Scenario:** Calendar intent.
Expand Down
6 changes: 6 additions & 0 deletions docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,12 @@ Host 重新检查会话的持久化模式,按调用会话的项目限制访问
每天/每周自动任务需 schedule。修改使用已存在的 ID 并保留未指定字段。界面虽只提供四个
时段,工具仍支持具体本地时间。不新增数据库 schema 或传输协议。

### 定时任务:旧任务维护

Agent 工具允许对缺少 schedule 的旧版自动任务修改标题、提示词或暂停,也允许回传
未变化的 cadence。这些维护操作不会启用任务,也不会捕获前台工作区。显式启用、
改变 cadence 或提供 schedule 时仍执行 schedule 校验;恢复任务需要明确的合法
schedule,Manual 转 Hourly 继续使用现有默认间隔行为。
### 定时任务:日历配置意图

可选的 `config_json.calendarConfigured` 布尔值用于区分用户明确设置的每日/每周
Expand Down
7 changes: 7 additions & 0 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -8595,6 +8595,13 @@ the latest destination. These assertions measure work counts, not device FPS.
- **验收 / 里程碑**:C、Quality / M6+。
- **状态**:组件与状态层用户路径由 `queue-pending-actions.test.mjs` 覆盖。

### E2E-SCHEDULED-legacy-pause

- **场景**:旧任务维护。
- **预期**:Agent 工具可对缺少 schedule 的旧版自动任务修改标题、提示词或暂停,
包括回传未变化的 cadence;维护不会启用任务或捕获前台工作区。显式启用、改变
cadence 或提供 schedule 时仍执行校验,Manual 转 Hourly 的默认间隔行为不变。
- **自动化**:`node --experimental-strip-types scripts/e2e-scheduled-legacy-maintenance.mjs`
### E2E-SCHEDULED-calendar-intent

- **场景**:日历配置意图。
Expand Down
22 changes: 22 additions & 0 deletions scripts/e2e-scheduled-legacy-maintenance.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { Host, resolveHostBinary } from "./e2e/host.mjs";
const host=new Host(resolveHostBinary(),await mkdtemp(join(tmpdir(),"pi-scheduled-legacy-")));
try {
await host.start();await host.call("settings.set",{defaultPermissionMode:"auto"});
const {session}=await host.call("session.create",{mode:"agent"});
const tool=(toolName,args)=>host.call("tools.execute",{sessionId:session.id,toolName,args,mode:"agent",toolCallId:randomUUID()});
for(const cadence of ["hourly","daily","weekly"]) {
const id=randomUUID();await host.call("scheduled.import",{tasks:[{id,title:"Legacy",prompt:"Old",cadence}]});
for(const fields of [{title:"Renamed"},{prompt:"Updated"},{enabled:false},{cadence,enabled:false}]) assert.equal((await tool("ScheduledTaskUpdate",{id,...fields})).ok,true);
await host.restart();const saved=(await host.call("scheduled.list")).tasks.find(task=>task.id===id);
assert.equal(saved.title,"Renamed");assert.equal(saved.prompt,"Updated");assert.equal(saved.enabled,false);assert.equal(saved.schedule,undefined);assert.equal(saved.nextRunAt,undefined);
assert.equal((await tool("ScheduledTaskUpdate",{id,enabled:true})).errorCode,"INVALID_PARAMS");
assert.equal((await tool("ScheduledTaskUpdate",{id,cadence,enabled:true,schedule:{hour:9,minute:15,weekday:0}})).ok,true);
assert.equal((await tool("ScheduledTaskDelete",{id})).ok,true);
console.log(`PASS ${cadence}: import, AI rename/prompt/pause, restart, guarded resume, configure and delete`);
}
} finally {await host.stop();}
Loading