blob: adabe12323c533681d8b1c45ca121de87a718cd2 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::*;
async fn fixture(base: &str) -> Fixture {
Fixture::configured(base, None, None, None, |settings| {
settings.scheduled_tasks = true
})
.await
}
fn task(effect: Value) -> Value {
json!({"task":{"title":"Scheduled 中😀", "intentBody":"Remember the original request", "schedule":{"kind":"interval","everySeconds":3600,"startAt":now()+3_600_000},"effect":effect,"maxFires":null,"expiresAt":null},"timezone":"Asia/Singapore","misfire":"latest"})
}
async fn detail(f: &Fixture, id: &str) -> Value {
f.query("schedules", json!({"kind":"get","taskId":id}))
.await
.unwrap()["detail"]
.clone()
}
async fn change(f: &Fixture, kind: &str, id: &str, extra: Value) -> Value {
let current = detail(f, id).await;
let mut input = json!({"kind":kind,"taskId":id,"revision":current["revision"]});
input
.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
f.mutation(2, "schedule", input, Uuid::new_v4())
.await
.unwrap()
}
async fn settled(f: &Fixture, id: &str) -> Value {
loop {
let value = detail(f, id).await;
if !value["task"]["runs"].as_array().unwrap().is_empty() {
return value;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
async fn reopen(f: &mut Fixture) {
let deployment = f.app.source().unwrap().deployment;
f.app.shutdown().await.unwrap();
let mut native = NativeRegistry::default();
maka_settings_native::provide(&mut native, crate::owner::tests::bundled_catalog()).unwrap();
crate::owner::tests::provide_all(&mut native).unwrap();
f.app = Application::start_from_store(
f.store.clone(),
deployment,
native,
f.issuer.clone(),
Arc::new(Grant(f.issuer.clone(), None)),
Some(maka_host_js::factory(Default::default())),
)
.await
.unwrap();
f.app.ready().await.unwrap();
}
#[tokio::test]
async fn scheduler_tool_schema_declares_object_and_preserves_operation_constraints() {
let f = fixture("http://127.0.0.1:1/v1").await;
let tools = f.owner.scheduler.tools();
let schema = &tools[0].definition.input_schema;
assert_eq!(schema["type"], "object");
let validator = jsonschema::validator_for(schema).unwrap();
for input in [
json!({"mode":"create","title":"Model proposal","intentBody":"Original scheduled body","effect":"agent_run","schedule":{"kind":"interval","everySeconds":3600,"startAt":now()+3_600_000},"timezone":"UTC"}),
json!({"mode":"list"}),
json!({"mode":"pause","taskId":"task","revision":1}),
json!({"mode":"resume","taskId":"task","revision":1}),
json!({"mode":"delete","taskId":"task","revision":1}),
] {
let input = json!({"request": input});
assert!(
validator.is_valid(&input),
"valid task input rejected: {input}"
);
}
for input in [
json!({"mode":"create","title":"Missing task fields"}),
json!({"mode":"list","unexpected":true}),
json!({"mode":"pause","taskId":"task"}),
json!({"mode":"resume","taskId":"task","revision":"1"}),
json!({"mode":"delete","revision":1}),
json!({"mode":"unknown"}),
json!([]),
] {
let input = json!({"request": input});
assert!(
!validator.is_valid(&input),
"invalid task input accepted: {input}"
);
}
f.app.shutdown().await.unwrap();
}
#[tokio::test]
async fn scheduler_reminder_survives_lost_host_reply_restart_snooze_and_delete_with_original_receipt()
{
tokio::time::timeout(Duration::from_secs(45), async {
let mut f = fixture("http://127.0.0.1:1/v1").await;
let mut invalid = task(json!({"kind":"reminder"}));
invalid["task"]["title"] = json!(" ");
let rejected = f
.mutation(
2,
"schedule",
json!({"kind":"create","input":invalid}),
Uuid::new_v4(),
)
.await
.unwrap();
assert_eq!(
rejected["kind"], "rejected",
"known validation failure must return a durable result, never strand caller recovery"
);
assert_eq!(
rejected["reason"],
"invalid scheduled task: empty or invalid task field"
);
let input = json!({"kind":"create","input":task(json!({"kind":"reminder"}))});
let client = f.app.client();
let prepared = client
.prepare_mutation(
f.binding(2).await,
"schedule".into(),
input.clone(),
scope(&f.issuer, true),
Uuid::new_v4(),
Attachments::new().into(),
)
.await
.unwrap();
let reference = prepared.reference().clone();
f.store
.record_application_intent(
reference.source.clone(),
prepared.consumer().instance().clone(),
reference.clone(),
Value::Null,
)
.await
.unwrap();
// Fault boundary: business transaction commits but Host receives no reply.
let committed = f
.owner
.schedule_change(
serde_json::from_value(input).unwrap(),
reference.clone(),
&scope(&f.issuer, true),
)
.await
.unwrap();
let committed = serde_json::to_value(committed).unwrap();
let id = committed["taskId"].as_str().unwrap().to_owned();
assert_eq!(committed["kind"], "committed");
assert_eq!(
change(&f, "trigger_now", &id, json!({})).await["kind"],
"committed"
);
let pending = detail(&f, &id).await;
let fire = pending["pendingFire"].clone();
assert!(fire.is_string());
let mut alerts = client
.subscribe(
f.binding(2).await,
"scheduleAlerts".into(),
json!({}),
scope(&f.issuer, true),
Attachments::new().into(),
)
.await
.unwrap();
let alert = alerts.recv().await.unwrap();
assert!(format!("{alert:?}").contains("Scheduled 中😀"));
drop(alerts);
reopen(&mut f).await;
assert_eq!(detail(&f, &id).await["pendingFire"], fire);
assert_eq!(
f.query("schedules", json!({"kind":"receipt","reference":reference}))
.await
.unwrap()["result"],
committed
);
let stale = f
.mutation(
2,
"schedule",
json!({"kind":"delete","taskId":id,"revision":1}),
Uuid::new_v4(),
)
.await
.unwrap();
assert_eq!(stale["kind"], "conflict");
assert_eq!(
change(&f, "snooze", &id, json!({"delayMs":60_000})).await["kind"],
"committed"
);
assert!(detail(&f, &id).await["pendingFire"].is_null());
assert_eq!(
change(&f, "pause", &id, json!({})).await["kind"],
"committed"
);
assert_eq!(detail(&f, &id).await["task"]["status"], "paused");
assert_eq!(
change(&f, "resume", &id, json!({})).await["kind"],
"committed"
);
assert_eq!(
change(&f, "trigger_now", &id, json!({})).await["kind"],
"committed"
);
let next = detail(&f, &id).await;
assert_ne!(next["pendingFire"], fire);
assert_eq!(
change(
&f,
"acknowledge",
&id,
json!({"fireId":next["pendingFire"]})
)
.await["kind"],
"committed"
);
assert_eq!(detail(&f, &id).await["task"]["runs"][0]["outcome"], "ok");
assert_eq!(
change(&f, "clear_history", &id, json!({})).await["kind"],
"committed"
);
assert_eq!(detail(&f, &id).await["task"]["runs"], json!([]));
assert_eq!(
change(&f, "delete", &id, json!({})).await["kind"],
"committed"
);
reopen(&mut f).await;
assert!(detail(&f, &id).await.is_null());
assert_eq!(
f.query("schedules", json!({"kind":"receipt","reference":reference}))
.await
.unwrap()["result"],
committed
);
f.app.shutdown().await.unwrap();
})
.await
.unwrap();
}
#[tokio::test]
async fn scheduler_model_proposal_requires_user_approval_and_archived_source_blocks_background_execution()
{
tokio::time::timeout(Duration::from_secs(60), async {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let f = fixture(&format!("http://{}/v1",listener.local_addr().unwrap())).await;
let source = f.create("Source conversation").await;
f.send(&source).await.unwrap();
let (mut socket,_) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
assert!(sent["tools"].as_array().unwrap().iter().any(|t| t["function"]["name"]=="ScheduledTask"));
recall_tests::tool(&mut socket,"ScheduledTask",json!({"request":{"mode":"create","title":"Model proposal","intentBody":"Original scheduled body","effect":"agent_run","schedule":{"kind":"interval","everySeconds":3600,"startAt":now()+3_600_000},"timezone":"UTC"}})).await;
let (mut socket,_) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
let output: Value = serde_json::from_str(sent["messages"].as_array().unwrap().iter().rev().find(|m| m["role"]=="tool").unwrap()["content"].as_str().unwrap()).unwrap();
let page = f.query("schedules",json!({"kind":"list"})).await.unwrap();
assert_eq!(page["tasks"].as_array().unwrap().len(),1,"{output}");
let id = page["tasks"][0]["id"].as_str().unwrap().to_owned();
assert_eq!(page["tasks"][0]["approved"],false);
assert_eq!(page["tasks"][0]["status"],"paused");
recall_tests::tool(&mut socket,"ScheduledTask",json!({"request":{"mode":"resume","taskId":id,"revision":page["tasks"][0]["revision"]}})).await;
let (mut socket,_) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
assert!(sent.to_string().contains("scheduler_user_approval_required"),"{sent}");
response(&mut socket).await;
assert_eq!(change(&f,"approve",&id,json!({})).await["kind"],"committed");
assert_eq!(change(&f,"trigger_now",&id,json!({})).await["kind"],"committed");
let (mut socket,_) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
assert!(sent.to_string().contains("Original scheduled body"));
response(&mut socket).await;
let executed = settled(&f,&id).await;
let run = &executed["task"]["runs"][0];
assert_eq!(run["outcome"],"ok");
assert_eq!(run["message"],"Message admitted");
assert_ne!(run["sessionId"],source);
let actual = f.owner.session(run["sessionId"].as_str().unwrap()).await.unwrap();
let original = f.owner.session(&source).await.unwrap();
assert_eq!(actual.configuration.workspace,original.configuration.workspace);
assert_eq!(actual.configuration.permissions,original.configuration.permissions);
assert_eq!(change(&f,"clear_history",&id,json!({})).await["kind"],"committed");
f.store.set_session_archived::<SessionConfiguration>(&source,true,now()).await.unwrap();
assert_eq!(change(&f,"trigger_now",&id,json!({})).await["kind"],"committed");
let blocked = settled(&f,&id).await;
assert_eq!(blocked["task"]["runs"][0]["outcome"],"blocked");
assert!(blocked["task"]["runs"][0]["sessionId"].is_null());
assert!(tokio::time::timeout(Duration::from_millis(100),listener.accept()).await.is_err());
f.app.shutdown().await.unwrap();
}).await.unwrap();
}
#[tokio::test]
async fn scheduler_reopens_frozen_fire_and_reconciles_original_admission_after_lost_settlement() {
use maka_capabilities::storage::Data;
use maka_capabilities::storage::Mutation;
use maka_capabilities::storage::Namespace;
use maka_capabilities::storage::Store;
tokio::time::timeout(Duration::from_secs(60), async {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut f = fixture(&format!("http://{}/v1",listener.local_addr().unwrap())).await;
let source = f.create("Background authority").await;
let boundary = f.owner.session(&source).await.unwrap().configuration;
let created = f.mutation(2,"schedule",json!({"kind":"create","input":task(json!({"kind":"agent_run","target":{"sessionId":source,"workspace":boundary.workspace,"permissions":boundary.permissions,"boundaryRevision":boundary.boundary_revision},"model":f.selection().await,"thinkingLevel":null}))}),Uuid::new_v4()).await.unwrap();
let id = created["taskId"].as_str().unwrap();
f.app.shutdown().await.unwrap();
let storage = maka_host_runtime::storage::Storage::new(f.store.clone(),f.owner.prefix.clone(),Namespace::new("maka.agent.scheduler",maka_plugin_core::scope::Scope::Profile).unwrap());
let key = format!("task:{id}");
let saved = storage.read(key.clone()).await.unwrap().unwrap();
let mut pending: maka_scheduler::plan::TaskPlan<ScheduledEffect,Value> = serde_json::from_value(saved.data.value().unwrap().clone()).unwrap();
pending.task.next_fire_at = Some(now() as i64);
pending.claim(now() as i64).unwrap();
let frozen = serde_json::to_value(&pending).unwrap();
let fire = pending.pending.as_ref().unwrap().id.clone();
storage.batch(vec![Mutation{key:key.clone(),expected_revision:Some(saved.revision),data:Data::Present(frozen.clone())}]).await.unwrap();
reopen(&mut f).await;
let (mut socket,_) = listener.accept().await.unwrap();
assert!(request(&mut socket).await.to_string().contains("Remember the original request"));
response(&mut socket).await;
let first = settled(&f,id).await;
let run = first["task"]["runs"][0].clone();
assert_eq!(run["id"],fire);
let target = run["sessionId"].as_str().unwrap();
loop {
if service_tools::facts(&f,target).await.iter().any(|fact| matches!(fact,maka_agent_facts::event::Fact::InvocationEnded { .. })) { break; }
tokio::time::sleep(Duration::from_millis(10)).await;
}
f.app.shutdown().await.unwrap();
// Durable cut after original Agent admission but before scheduler settlement:
// retain the exact frozen fire/approval, as a failed settlement would.
let saved = storage.read(key.clone()).await.unwrap().unwrap();
storage.batch(vec![Mutation{key:key.clone(),expected_revision:Some(saved.revision),data:Data::Present(frozen)}]).await.unwrap();
f.store.set_session_archived::<SessionConfiguration>(&source,true,now()).await.unwrap();
reopen(&mut f).await;
let recovered = settled(&f,id).await;
let actual = &recovered["task"]["runs"][0];
assert_eq!(actual["id"],run["id"]);
assert_eq!(actual["sessionId"],run["sessionId"]);
assert_eq!(actual["runId"],run["runId"]);
assert_eq!(actual["outcome"],"ok","revocation cannot rewrite an already admitted result");
assert_eq!(recovered["task"]["fireCount"],1);
let facts = service_tools::facts(&f,target).await;
assert_eq!(facts.iter().filter(|fact| matches!(fact,maka_agent_facts::event::Fact::InvocationOpened { .. })).count(),1);
assert!(tokio::time::timeout(Duration::from_millis(100),listener.accept()).await.is_err());
f.app.shutdown().await.unwrap();
storage.finish().await.unwrap();
}).await.unwrap();
}
#[tokio::test]
async fn scheduler_rechecks_new_conversation_boundary_after_creation_before_admission() {
use maka_capabilities::storage::Data;
use maka_capabilities::storage::Mutation;
use maka_capabilities::storage::Namespace;
use maka_capabilities::storage::Store;
tokio::time::timeout(Duration::from_secs(45), async {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut f = fixture(&format!("http://{}/v1", listener.local_addr().unwrap())).await;
let source = f.create("Approved source").await;
let boundary = f.owner.session(&source).await.unwrap().configuration;
let created = f.mutation(2, "schedule", json!({"kind":"create","input":task(json!({"kind":"agent_run","target":{"sessionId":source,"workspace":boundary.workspace,"permissions":boundary.permissions,"boundaryRevision":boundary.boundary_revision},"model":f.selection().await,"thinkingLevel":null}))}), Uuid::new_v4()).await.unwrap();
let id = created["taskId"].as_str().unwrap();
f.app.shutdown().await.unwrap();
let storage = maka_host_runtime::storage::Storage::new(f.store.clone(), f.owner.prefix.clone(), Namespace::new("maka.agent.scheduler", maka_plugin_core::scope::Scope::Profile).unwrap());
let key = format!("task:{id}");
let saved = storage.read(key.clone()).await.unwrap().unwrap();
let mut pending: maka_scheduler::plan::TaskPlan<ScheduledEffect, Value> = serde_json::from_value(saved.data.value().unwrap().clone()).unwrap();
pending.task.next_fire_at = Some(now() as i64);
pending.claim(now() as i64).unwrap();
let fire = pending.pending.as_ref().unwrap();
let identity = hex::encode(Sha256::digest(fire.id.as_bytes()));
let target = format!("{}{}", f.owner.prefix, &identity[..32]);
// Durable cut after creation. An intervening user boundary change must
// not widen the scheduled invocation, even while its source is valid.
let mut changed = boundary;
changed.boundary_revision = 1;
f.store.create_session(&target, "intervening-boundary-change", &changed, now()).await.unwrap();
storage.batch(vec![Mutation { key, expected_revision: Some(saved.revision), data: Data::Present(serde_json::to_value(pending).unwrap()) }]).await.unwrap();
reopen(&mut f).await;
let blocked = settled(&f, id).await;
assert_eq!(blocked["task"]["runs"][0]["outcome"], "blocked");
assert!(f.store.message_admission(&target, &format!("m_{identity}")).await.unwrap().is_none());
assert!(service_tools::facts(&f, &target).await.is_empty());
assert!(tokio::time::timeout(Duration::from_millis(100), listener.accept()).await.is_err());
f.app.shutdown().await.unwrap();
storage.finish().await.unwrap();
}).await.unwrap();
}
#[tokio::test]
async fn sleeping_producers_allow_idle_retirement_but_active_iterations_are_fenced() {
tokio::time::timeout(Duration::from_secs(20), async {
let f = fixture("http://127.0.0.1:1/v1").await;
f.drained().await;
let producer = f.owner.producer().await.unwrap();
assert!(!f.app.shutdown_if_idle().await.unwrap());
assert!(!f.owner.stop.is_cancelled());
drop(producer);
// The guard blocks a new iteration, but an abandoned retirement restores it.
let guard = loop {
if let Some(guard) = f.owner.prepare_retirement() {
break guard;
}
tokio::task::yield_now().await;
};
assert!(f.owner.producer_gate.try_lock().is_err());
drop(guard);
assert!(!f.owner.stop.is_cancelled());
drop(f.owner.producer().await.unwrap());
while !f.app.shutdown_if_idle().await.unwrap() {
tokio::task::yield_now().await;
}
assert!(f.owner.producers.is_empty());
assert!(f.owner.workers.is_empty());
f.config.shutdown().await.unwrap();
})
.await
.unwrap();
}