disable scheduler by default for acp (#10781)

This commit is contained in:
Lifei Zhou
2026-07-29 19:54:49 +10:00
committed by GitHub
parent 8b73e1a1b6
commit 0b234bdcfe
17 changed files with 204 additions and 54 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ To debug the external ACP backend, run it from an IDE. The configuration will de
```
export GOOSE_SERVER__SECRET_KEY=test
cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
cargo run --package goose-cli --bin goose -- serve --platform desktop --enable-scheduler --host 127.0.0.1 --port 3000
```
The `debug-ui` recipe connects to `http://127.0.0.1:3000` by default. If the
+1 -1
View File
@@ -145,7 +145,7 @@ run-docs:
# Run server
run-server:
@echo "Running external ACP backend..."
GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --enable-scheduler --host 127.0.0.1 --port 3000
# Check if generated ACP schema and TypeScript types are up-to-date
check-acp-schema: generate-acp-types
+18 -2
View File
@@ -838,6 +838,9 @@ enum Command {
value_delimiter = ','
)]
builtins: Vec<String>,
#[arg(long, help = "Enable scheduled recipe execution")]
enable_scheduler: bool,
},
/// Start ACP server over HTTP and WebSocket
@@ -884,6 +887,9 @@ enum Command {
help = "Allow an exact Origin value for ACP CORS; may be specified multiple times and replaces the default loopback origins"
)]
allowed_origins: Vec<String>,
#[arg(long, help = "Enable scheduled recipe execution")]
enable_scheduler: bool,
},
/// Start or resume interactive chat sessions
@@ -1388,6 +1394,7 @@ struct ServeCommandArgs {
builtins: Vec<String>,
dangerously_unauthenticated: bool,
allowed_origins: Vec<String>,
enable_scheduler: bool,
}
async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
@@ -1409,6 +1416,7 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
builtins,
dangerously_unauthenticated,
allowed_origins,
enable_scheduler,
} = args;
let builtins = if builtins.is_empty() {
@@ -1435,6 +1443,7 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
config_dir: Paths::config_dir(),
goose_platform: platform.into(),
additional_source_roots,
enable_scheduler,
}));
let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV)
.ok()
@@ -1839,7 +1848,9 @@ fn parse_run_input(
Ok(Some((input_config, Some(recipe))))
}
(None, None, None) => {
eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin.");
eprintln!(
"Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin."
);
std::process::exit(1);
}
}
@@ -2228,7 +2239,10 @@ pub async fn cli() -> anyhow::Result<()> {
Some(Command::Doctor {}) => crate::commands::doctor::handle_doctor().await,
Some(Command::Info { verbose, check }) => handle_info(verbose, check).await,
Some(Command::Mcp { server }) => handle_mcp_command(server).await,
Some(Command::Acp { builtins }) => goose::acp::server::run(builtins).await,
Some(Command::Acp {
builtins,
enable_scheduler,
}) => goose::acp::server::run(builtins, enable_scheduler).await,
Some(Command::Serve {
host,
port,
@@ -2239,6 +2253,7 @@ pub async fn cli() -> anyhow::Result<()> {
builtins,
dangerously_unauthenticated,
allowed_origins,
enable_scheduler,
}) => {
handle_serve_command(ServeCommandArgs {
host,
@@ -2250,6 +2265,7 @@ pub async fn cli() -> anyhow::Result<()> {
builtins,
dangerously_unauthenticated,
allowed_origins,
enable_scheduler,
})
.await
}
+4 -3
View File
@@ -185,7 +185,7 @@ pub struct GooseAcpAgentOptions {
pub disable_session_naming: bool,
pub goose_platform: GoosePlatform,
pub additional_source_roots: Vec<SourceRoot>,
pub scheduler: Arc<dyn SchedulerTrait>,
pub scheduler: Option<Arc<dyn SchedulerTrait>>,
}
pub struct GooseAcpAgent {
@@ -604,7 +604,7 @@ impl GooseAcpAgent {
let agent_config = AgentConfig::new(
Arc::clone(&session_manager),
Arc::clone(&permission_manager),
Some(options.scheduler),
options.scheduler,
Config::global().get_goose_mode().unwrap_or_default(),
options.disable_session_naming,
options.goose_platform.clone(),
@@ -2285,7 +2285,7 @@ impl agent_client_protocol::ConnectTo<Client> for GooseAgentConnection {
}
}
pub async fn run(builtins: Vec<String>) -> Result<()> {
pub async fn run(builtins: Vec<String>, enable_scheduler: bool) -> Result<()> {
info!("listening on stdio");
let outgoing = tokio::io::stdout().compat_write();
@@ -2298,6 +2298,7 @@ pub async fn run(builtins: Vec<String>) -> Result<()> {
config_dir: Paths::config_dir(),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
enable_scheduler,
},
);
let agent = server.create_agent().await?;
+6 -4
View File
@@ -153,7 +153,10 @@ impl GooseAcpAgent {
.collect();
*self.recipe_path_cache.lock().await = recipe_file_hash_map;
let scheduled_jobs = self.agent_manager.scheduler().list_scheduled_jobs().await;
let scheduled_jobs = match self.agent_manager.scheduler() {
Some(scheduler) => scheduler.list_scheduled_jobs().await,
None => Vec::new(),
};
let schedule_map: HashMap<_, _> = scheduled_jobs
.into_iter()
.map(|job| (PathBuf::from(job.source), job.cron))
@@ -198,10 +201,9 @@ impl GooseAcpAgent {
&self,
req: ScheduleRecipeRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
let scheduler = self.require_scheduler()?;
let file_path = self.resolve_recipe_path_by_id(&req.id).await?;
if let Err(err) = self
.agent_manager
.scheduler()
if let Err(err) = scheduler
.schedule_recipe(file_path, req.cron_schedule)
.await
{
+95 -23
View File
@@ -12,6 +12,8 @@ use super::{build_session_info, GooseAcpAgent, ResultExt};
use crate::recipe::validate_recipe::validate_recipe_template_from_content;
use crate::recipe::Recipe;
use crate::scheduler::{get_default_scheduled_recipes_dir, ScheduledJob, SchedulerError};
use crate::scheduler_trait::SchedulerTrait;
use std::sync::Arc;
fn validate_schedule_id(id: &str) -> Result<(), agent_client_protocol::Error> {
let is_valid = !id.is_empty()
@@ -121,13 +123,21 @@ fn scheduled_job_to_dto(job: ScheduledJob) -> ScheduledJobDto {
}
impl GooseAcpAgent {
pub(super) fn require_scheduler(
&self,
) -> Result<Arc<dyn SchedulerTrait>, agent_client_protocol::Error> {
self.agent_manager.scheduler().ok_or_else(|| {
agent_client_protocol::Error::method_not_found()
.data("Scheduled recipe execution is not enabled")
})
}
pub(super) async fn on_list_schedules(
&self,
_req: ListSchedulesRequest,
) -> Result<ListSchedulesResponse, agent_client_protocol::Error> {
let jobs = self
.agent_manager
.scheduler()
.require_scheduler()?
.list_scheduled_jobs()
.await
.into_iter()
@@ -142,8 +152,7 @@ impl GooseAcpAgent {
req: ListScheduleSessionsRequest,
) -> Result<ListScheduleSessionsResponse, agent_client_protocol::Error> {
let sessions = self
.agent_manager
.scheduler()
.require_scheduler()?
.sessions(&req.schedule_id, req.limit)
.await
.internal_err_ctx("Failed to fetch schedule sessions")?
@@ -158,6 +167,7 @@ impl GooseAcpAgent {
&self,
req: CreateScheduleRequest,
) -> Result<CreateScheduleResponse, agent_client_protocol::Error> {
let scheduler = self.require_scheduler()?;
let id = req.id.trim().to_string();
validate_schedule_id(&id)?;
@@ -200,8 +210,7 @@ impl GooseAcpAgent {
recipe_base_dir: None,
};
self.agent_manager
.scheduler()
scheduler
.add_scheduled_job(job.clone(), false)
.await
.map_err(create_schedule_error)?;
@@ -215,8 +224,7 @@ impl GooseAcpAgent {
&self,
req: DeleteScheduleRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
self.agent_manager
.scheduler()
self.require_scheduler()?
.remove_scheduled_job(&req.schedule_id, false)
.await
.map_err(schedule_not_found_or_internal)?;
@@ -228,8 +236,7 @@ impl GooseAcpAgent {
&self,
req: PauseScheduleRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
self.agent_manager
.scheduler()
self.require_scheduler()?
.pause_schedule(&req.schedule_id)
.await
.map_err(schedule_state_error)?;
@@ -241,8 +248,7 @@ impl GooseAcpAgent {
&self,
req: UnpauseScheduleRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
self.agent_manager
.scheduler()
self.require_scheduler()?
.unpause_schedule(&req.schedule_id)
.await
.map_err(schedule_not_found_or_internal)?;
@@ -256,7 +262,7 @@ impl GooseAcpAgent {
) -> Result<UpdateScheduleResponse, agent_client_protocol::Error> {
let schedule_id = req.schedule_id;
let cron = req.cron;
let scheduler = self.agent_manager.scheduler();
let scheduler = self.require_scheduler()?;
scheduler
.update_schedule(&schedule_id, cron)
.await
@@ -281,12 +287,7 @@ impl GooseAcpAgent {
&self,
req: RunScheduleNowRequest,
) -> Result<RunScheduleNowResponse, agent_client_protocol::Error> {
match self
.agent_manager
.scheduler()
.run_now(&req.schedule_id)
.await
{
match self.require_scheduler()?.run_now(&req.schedule_id).await {
Ok(session_id) => Ok(RunScheduleNowResponse {
status: RunScheduleNowStatus::Completed,
session_id: Some(session_id),
@@ -299,8 +300,7 @@ impl GooseAcpAgent {
&self,
req: KillRunningJobRequest,
) -> Result<KillRunningJobResponse, agent_client_protocol::Error> {
self.agent_manager
.scheduler()
self.require_scheduler()?
.kill_running_job(&req.job_id)
.await
.map_err(schedule_state_error)?;
@@ -315,8 +315,7 @@ impl GooseAcpAgent {
req: InspectRunningJobRequest,
) -> Result<InspectRunningJobResponse, agent_client_protocol::Error> {
let job = self
.agent_manager
.scheduler()
.require_scheduler()?
.list_scheduled_jobs()
.await
.into_iter()
@@ -341,3 +340,76 @@ impl GooseAcpAgent {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use crate::agents::GoosePlatform;
use goose_sdk_types::custom_requests::{ListRecipesRequest, ScheduleRecipeRequest};
use serial_test::serial;
fn assert_scheduler_disabled(error: agent_client_protocol::Error) {
assert_eq!(
error.code,
agent_client_protocol::Error::method_not_found().code
);
assert_eq!(
error.data.as_ref().and_then(serde_json::Value::as_str),
Some("Scheduled recipe execution is not enabled")
);
}
#[tokio::test]
#[serial]
async fn disabled_scheduler_rejects_schedule_operations_without_recipe_writes() {
let root = tempfile::tempdir().unwrap();
let _guard = env_lock::lock_env([
("GOOSE_DISABLE_KEYRING", Some("true")),
("GOOSE_PATH_ROOT", root.path().to_str()),
]);
let server = AcpServer::new(AcpServerFactoryConfig {
builtins: Vec::new(),
data_dir: root.path().join("data"),
config_dir: root.path().join("config"),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
enable_scheduler: false,
});
let agent = server.create_agent().await.unwrap();
let list_error = agent
.on_list_schedules(ListSchedulesRequest {})
.await
.expect_err("schedule listing must be unsupported");
assert_scheduler_disabled(list_error);
agent
.on_list_recipes(ListRecipesRequest {})
.await
.expect("recipe listing must remain available");
let create_error = agent
.on_create_schedule(CreateScheduleRequest {
id: "nightly".to_string(),
recipe: Default::default(),
cron: "0 0 0 * * *".to_string(),
})
.await
.expect_err("schedule creation must be unsupported");
assert_scheduler_disabled(create_error);
assert!(!get_default_scheduled_recipes_dir()
.unwrap()
.join("nightly.yaml")
.exists());
let schedule_recipe_error = agent
.on_schedule_recipe(ScheduleRecipeRequest {
id: "missing-recipe".to_string(),
cron_schedule: Some("0 0 0 * * *".to_string()),
})
.await
.expect_err("recipe scheduling must be unsupported");
assert_scheduler_disabled(schedule_recipe_error);
}
}
+40 -1
View File
@@ -14,6 +14,7 @@ pub struct AcpServerFactoryConfig {
pub config_dir: std::path::PathBuf,
pub goose_platform: GoosePlatform,
pub additional_source_roots: Vec<SourceRoot>,
pub enable_scheduler: bool,
}
pub struct AcpServer {
@@ -29,7 +30,11 @@ impl AcpServer {
}
}
async fn scheduler(&self) -> Result<Arc<dyn SchedulerTrait>> {
async fn scheduler(&self) -> Result<Option<Arc<dyn SchedulerTrait>>> {
if !self.config.enable_scheduler {
return Ok(None);
}
let data_dir = self.config.data_dir.clone();
self.scheduler
.get_or_try_init(|| async move {
@@ -43,6 +48,7 @@ impl AcpServer {
})
.await
.cloned()
.map(Some)
}
pub async fn create_agent(&self) -> Result<Arc<GooseAcpAgent>> {
@@ -83,3 +89,36 @@ impl AcpServer {
Ok(Arc::new(agent))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn server(data_dir: std::path::PathBuf, enable_scheduler: bool) -> AcpServer {
AcpServer::new(AcpServerFactoryConfig {
builtins: Vec::new(),
config_dir: data_dir.clone(),
data_dir,
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
enable_scheduler,
})
}
#[tokio::test]
async fn disabled_server_does_not_construct_scheduler() {
let root = tempfile::tempdir().unwrap();
let server = server(root.path().to_path_buf(), false);
assert!(server.scheduler().await.unwrap().is_none());
assert!(!root.path().join("schedule.json").exists());
}
#[tokio::test]
async fn automatic_server_constructs_scheduler() {
let root = tempfile::tempdir().unwrap();
let server = server(root.path().to_path_buf(), true);
assert!(server.scheduler().await.unwrap().is_some());
}
}
+3 -14
View File
@@ -1,9 +1,7 @@
use crate::agents::mcp_client::GooseMcpHostInfo;
use crate::agents::{Agent, AgentConfig, ExtensionLoadResult, GoosePlatform};
use crate::config::paths::Paths;
use crate::config::permission::PermissionManager;
use crate::config::Config;
use crate::scheduler::Scheduler;
use crate::scheduler_trait::SchedulerTrait;
use crate::session::{SessionManager, SessionNameUpdate};
use anyhow::Result;
@@ -72,15 +70,11 @@ impl AgentManager {
.get_goose_max_active_agents()
.unwrap_or(DEFAULT_MAX_SESSION);
let default_mode = config.get_goose_mode().unwrap_or_default();
let schedule_file_path = Paths::data_dir().join("schedule.json");
let session_manager = Arc::new(SessionManager::instance());
let scheduler = Scheduler::new(schedule_file_path, Arc::clone(&session_manager))
.await
.map(|scheduler| scheduler as Arc<dyn SchedulerTrait>)?;
let agent_config = AgentConfig::new(
session_manager,
PermissionManager::instance(),
Some(scheduler),
None,
default_mode,
config.get_goose_disable_session_naming().unwrap_or(false),
GoosePlatform::GooseDesktop,
@@ -92,13 +86,8 @@ impl AgentManager {
.cloned()
}
pub fn scheduler(&self) -> Arc<dyn SchedulerTrait> {
Arc::clone(
self.agent_config
.scheduler_service
.as_ref()
.expect("AgentManager scheduler is not configured"),
)
pub fn scheduler(&self) -> Option<Arc<dyn SchedulerTrait>> {
self.agent_config.scheduler_service.as_ref().map(Arc::clone)
}
/// Get the shared SessionManager for session-only operations
+1 -1
View File
@@ -378,7 +378,7 @@ pub async fn spawn_acp_server_in_process(
disable_session_naming,
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
scheduler: Arc::new(FixtureScheduler::new()),
scheduler: Some(Arc::new(FixtureScheduler::new())),
})
.await
.unwrap();
@@ -21,6 +21,7 @@ fn test_acp_router(dir: &tempfile::TempDir) -> Router {
config_dir: dir.path().join("config"),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
enable_scheduler: false,
}));
create_acp_router(server)
}
@@ -32,6 +33,7 @@ fn test_authenticated_acp_router(dir: &tempfile::TempDir) -> Router {
config_dir: dir.path().join("config"),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
enable_scheduler: false,
}));
create_router(server, SECRET.to_string(), true, Vec::new())
}
@@ -47,6 +49,7 @@ fn test_router_with_origins(
config_dir: dir.path().join("config"),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
enable_scheduler: false,
}));
create_router(
server,
@@ -0,0 +1,20 @@
use goose::execution::manager::AgentManager;
#[tokio::test]
async fn global_agent_manager_does_not_construct_scheduler() {
let root = tempfile::tempdir().unwrap();
let _guard = env_lock::lock_env([
("GOOSE_DISABLE_KEYRING", Some("true")),
("GOOSE_PATH_ROOT", root.path().to_str()),
]);
let data_dir = root.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let schedule_path = data_dir.join("schedule.json");
let sentinel = b"do not touch";
std::fs::write(&schedule_path, sentinel).unwrap();
let manager = AgentManager::instance().await.unwrap();
assert!(manager.scheduler().is_none());
assert_eq!(std::fs::read(schedule_path).unwrap(), sentinel);
}
@@ -478,7 +478,7 @@ These variables configure the `goose serve` ACP server process. They are alterna
```bash
# Start a goose ACP server reachable on the local network over TLS
GOOSE_SERVER__SECRET_KEY='a-long-random-secret' \
goose serve --platform desktop --host 0.0.0.0 --port 3000 --tls
goose serve --platform desktop --enable-scheduler --host 0.0.0.0 --port 3000 --tls
```
When TLS is enabled, `goose serve` prints a `GOOSED_CERT_FINGERPRINT=...` line on startup. goose Desktop can use this fingerprint to pin the server certificate. See [Running a Remote goose Server](/docs/guides/remote-goose-server) for the full setup.
@@ -683,6 +683,9 @@ Run goose as an Agent Client Protocol (ACP) agent server over stdio. This enable
ACP is an emerging protocol specification that standardizes communication between AI agents and client applications, making it easier for clients to integrate with various AI agents.
**Options:**
- **`--enable-scheduler`**: Enable scheduled recipe execution. Disabled by default.
**Usage:**
```bash
goose acp
@@ -702,6 +705,7 @@ Start goose as an Agent Client Protocol (ACP) server over HTTP and WebSocket.
- **`--port <PORT>`**: Port to listen on. Defaults to `3284`
- **`--with-builtin <NAME>`**: Enable built-in extensions by name. Can be passed multiple times or as a comma-separated list. Defaults to `developer` when omitted.
- **`--dangerously-unauthenticated`**: Run without ACP authentication. Use only for local trusted clients.
- **`--enable-scheduler`**: Enable scheduled recipe execution. Disabled by default.
**Usage:**
```bash
@@ -31,7 +31,7 @@ On the remote machine, launch `goose serve` with the host, port, TLS, and a shar
```bash
GOOSE_SERVER__SECRET_KEY='YOUR_SECRET' \
goose serve --platform desktop --host 0.0.0.0 --port 3000 --tls
goose serve --platform desktop --enable-scheduler --host 0.0.0.0 --port 3000 --tls
```
If you are using the binary bundled with the macOS app, the command path is `/Applications/Goose.app/Contents/Resources/bin/goose`.
@@ -128,6 +128,7 @@ Create a LaunchAgent plist at `~/Library/LaunchAgents/com.goose.serve.external.p
<string>serve</string>
<string>--platform</string>
<string>desktop</string>
<string>--enable-scheduler</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>--port</string>
+1 -1
View File
@@ -95,7 +95,7 @@ Use the existing Windows build process as documented.
From the project root, start the ACP backend:
```bash
GOOSE_SERVER__SECRET_KEY=test cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
GOOSE_SERVER__SECRET_KEY=test cargo run -p goose-cli --bin goose -- serve --platform desktop --enable-scheduler --host 127.0.0.1 --port 3000
```
Then start the desktop app from `ui/desktop`:
+3 -1
View File
@@ -242,7 +242,9 @@ describe('startGooseServe', () => {
expect(readinessUrls[0]).toMatch(/^https:\/\/127\.0\.0\.1:\d+\/status$/);
expect(result.acpUrl).toMatch(/^wss:\/\/127\.0\.0\.1:\d+\/acp\?token=test-secret$/);
expect(result.certFingerprint).toBe('DD:EE:FF');
await expect(waitForFileLines(argsPath)).resolves.toContain('--tls');
const args = await waitForFileLines(argsPath);
expect(args).toContain('--tls');
expect(args).toContain('--enable-scheduler');
} finally {
await result.cleanup();
}
+1
View File
@@ -360,6 +360,7 @@ export const startGooseServe = async ({
...(tls ? ['--tls'] : []),
'--platform',
'desktop',
'--enable-scheduler',
'--host',
'127.0.0.1',
'--port',