From c15570ed02d65fdd9365e0764fe010e61c270102 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 1 Jun 2026 00:49:31 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Limit Linear scan cadence","authority":"manual"} --- README.md | 10 +- apps/decodex/src/cli.rs | 104 +++-------- apps/decodex/src/orchestrator.rs | 2 + apps/decodex/src/orchestrator/entrypoints.rs | 166 ++++++++++++++++-- .../decodex/src/orchestrator/operator_http.rs | 148 ++++++++++++---- .../tests/operator/status/control_plane.rs | 38 +++- .../tests/operator/status/http.rs | 51 ++++++ apps/decodex/src/orchestrator/types.rs | 46 ++++- docs/reference/operator-control-plane.md | 26 ++- docs/runbook/self-dogfood-pilot.md | 21 ++- plugins/decodex/skills/automation/SKILL.md | 20 ++- plugins/decodex/skills/manual-cli/SKILL.md | 19 +- 12 files changed, 503 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 04d9c842..2727f6bc 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,10 @@ cargo run -p decodex --bin decodex -- serve --listen-address 127.0.0.1:8912 Project-scoped commands accept `--config ` after the subcommand when the operator wants to override registry-based project resolution for that command. -`decodex serve` owns the default scheduler cadence of 15 seconds; pass -`--interval ` only when deliberately overriding that poll interval. +`decodex serve` uses hardcoded scheduler cadences: the local control-plane loop +publishes snapshots every 15 seconds, and Linear-backed queue/status scans run at +most every 5 minutes per project unless an operator or agent requests an explicit +scan with `POST /api/linear-scan`. ### Install from Source @@ -230,9 +232,9 @@ node dev/operator-dashboard-mock.mjs --listen-address 127.0.0.1:57399 --use-code Use hidden `decodex serve --dev --listen-address 127.0.0.1:8912` only when developing local account/app snapshot APIs against real runtime state while explicitly avoiding scheduler activity. Dev mode deliberately does not register projects, poll -Linear, dispatch work, or accept `--config` or `--interval`. Decodex App's normal +Linear, dispatch work, or accept `--config`. Decodex App's normal fallback server is ordinary `decodex serve --listen-address 127.0.0.1:8912`; the CLI -owns the default scheduler interval, currently 15 seconds. App launch connects to an +owns the default scheduler cadences. App launch connects to an existing live default listener instead of starting a duplicate server. For dashboard-only UI work, prefer the mock server above. diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 88cb5ac8..8c64fd58 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -2,7 +2,6 @@ use std::{ fs, io::{self, Read as _}, path::{Path, PathBuf}, - time::Duration, }; use clap::{ @@ -23,7 +22,7 @@ use crate::{ orchestrator::{ self, DiagnoseRequest, EvidenceRequest, IssueDispatchMode, RunOnceRequest, ServeRequest, }, - prelude::eyre, + prelude::{Result, eyre}, recovery::{self, ReviewHandoffDiagnoseRequest, ReviewHandoffRebindRequest}, runtime, }; @@ -49,7 +48,7 @@ pub(crate) struct Cli { command: Command, } impl Cli { - pub(crate) fn run(&self) -> crate::prelude::Result<()> { + pub(crate) fn run(&self) -> Result<()> { match &self.command { Command::Commit(args) => args.run(), Command::Land(args) => args.run(), @@ -107,7 +106,7 @@ struct AccountCommand { command: AccountSubcommand, } impl AccountCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { match &self.command { AccountSubcommand::List(args) => accounts::run_account_list(args.json), AccountSubcommand::Select(args) => @@ -219,7 +218,7 @@ struct CommitCommand { breaking: bool, } impl CommitCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { manual::run_commit( self.project_config.as_path(), &ManualCommitRequest { @@ -259,7 +258,7 @@ struct LandCommand { breaking: bool, } impl LandCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { manual::run_land( self.project_config.as_path(), &ManualLandRequest { @@ -289,7 +288,7 @@ struct RunCommand { explain: bool, } impl RunCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { orchestrator::run_once(RunOnceRequest { config_path: self.project_config.as_path(), dry_run: self.dry_run, @@ -314,9 +313,6 @@ impl RunCommand { struct ServeCommand { #[command(flatten)] project_config: ProjectConfigArgs, - /// Override the scheduler poll interval, for example `15s` or `5m`. - #[arg(long, value_name = "INTERVAL", value_parser = parse_duration_arg)] - interval: Option, /// Operator UI listen address. #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8912")] listen_address: String, @@ -325,16 +321,9 @@ struct ServeCommand { dev: bool, } impl ServeCommand { - fn run(&self) -> crate::prelude::Result<()> { - if self.dev && self.interval.is_some() { - eyre::bail!( - "serve --dev does not accept --interval because dev mode does not poll projects." - ); - } - + fn run(&self) -> Result<()> { orchestrator::run_control_plane(ServeRequest { config_path: self.project_config.as_path(), - poll_interval: if self.dev { None } else { self.interval }, listen_address: &self.listen_address, dev: self.dev, }) @@ -347,7 +336,7 @@ struct ProjectCommand { command: ProjectSubcommand, } impl ProjectCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { let state_store = runtime::open_runtime_store()?; match &self.command { @@ -434,7 +423,7 @@ struct StatusCommand { limit: usize, } impl StatusCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { orchestrator::print_status(self.project_config.as_path(), self.json, self.limit) } } @@ -451,7 +440,7 @@ struct DiagnoseCommand { limit: usize, } impl DiagnoseCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { orchestrator::run_diagnose(DiagnoseRequest { config_path: self.project_config.as_path(), json: self.json, @@ -480,7 +469,7 @@ struct EvidenceCommand { include_payload: bool, } impl EvidenceCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { orchestrator::print_private_evidence(EvidenceRequest { config_path: self.project_config.as_path(), issue: &self.issue, @@ -500,7 +489,7 @@ struct RecoverCommand { command: RecoverSubcommand, } impl RecoverCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { match &self.command { RecoverSubcommand::ReviewHandoff(args) => args.run(self.project_config.as_path()), } @@ -513,7 +502,7 @@ struct ReviewHandoffRecoveryCommand { command: ReviewHandoffRecoverySubcommand, } impl ReviewHandoffRecoveryCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self, config_path: Option<&Path>) -> Result<()> { match &self.command { ReviewHandoffRecoverySubcommand::Diagnose(args) => recovery::run_review_handoff_diagnose( @@ -570,7 +559,7 @@ struct ArchiveLinearCommand { execute: bool, } impl ArchiveLinearCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { archive_hygiene::run( self.project_config.as_path(), &ArchiveHygieneRequest { @@ -588,7 +577,7 @@ struct MaintenanceCommand { command: MaintenanceSubcommand, } impl MaintenanceCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { match &self.command { MaintenanceSubcommand::Prune(args) => args.run(), } @@ -608,7 +597,7 @@ struct MaintenancePruneCommand { json: bool, } impl MaintenancePruneCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { let mode = if self.apply { MaintenanceMode::Apply } else { MaintenanceMode::DryRun }; maintenance::run_prune_command(MaintenancePruneRequest { @@ -626,7 +615,7 @@ struct ProbeCommand { transport: String, } impl ProbeCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { let report = agent::probe_app_server(&self.transport)?; println!( @@ -655,7 +644,7 @@ struct AttemptCommand { request: String, } impl AttemptCommand { - fn run(&self) -> crate::prelude::Result<()> { + fn run(&self) -> Result<()> { let request = read_attempt_request(&self.request)?; orchestrator::run_once(RunOnceRequest { @@ -793,37 +782,7 @@ enum MaintenanceSubcommand { Prune(MaintenancePruneCommand), } -fn parse_duration_arg(raw: &str) -> std::result::Result { - let (number, unit) = raw - .strip_suffix('s') - .map(|value| (value, "s")) - .or_else(|| raw.strip_suffix('m').map(|value| (value, "m"))) - .or_else(|| raw.strip_suffix('h').map(|value| (value, "h"))) - .unwrap_or((raw, "s")); - let value = number.parse::().map_err(|_| { - format!("invalid duration `{raw}`; expected ``, `s`, `m`, or `h`") - })?; - - if value == 0 { - return Err(String::from("duration must be greater than zero")); - } - - match unit { - "s" => Ok(Duration::from_secs(value)), - "m" => value - .checked_mul(60) - .map(Duration::from_secs) - .ok_or_else(|| format!("duration `{raw}` is too large")), - "h" => value - .checked_mul(60) - .and_then(|minutes| minutes.checked_mul(60)) - .map(Duration::from_secs) - .ok_or_else(|| format!("duration `{raw}` is too large")), - _ => Err(format!("unsupported duration unit in `{raw}`")), - } -} - -fn read_attempt_request(request: &str) -> crate::prelude::Result { +fn read_attempt_request(request: &str) -> Result { let raw = if request == "-" { let mut raw = String::new(); @@ -849,7 +808,7 @@ fn styles() -> Styles { #[cfg(test)] mod tests { - use std::{path::Path, time::Duration}; + use std::path::Path; use clap::Parser; @@ -994,14 +953,12 @@ mod tests { } #[test] - fn parses_serve_with_interval_listen_address_and_project_config() { + fn parses_serve_with_listen_address_and_project_config() { let cli = Cli::parse_from([ "decodex", "serve", "--config", "./project.toml", - "--interval", - "30s", "--listen-address", "127.0.0.1:9000", ]); @@ -1010,12 +967,10 @@ mod tests { cli.command, Command::Serve(ServeCommand { project_config: ProjectConfigArgs { config: Some(config) }, - interval, listen_address, dev, }) - if interval == Some(Duration::from_secs(30)) - && listen_address == "127.0.0.1:9000" + if listen_address == "127.0.0.1:9000" && !dev && config == Path::new("./project.toml") )); @@ -1025,22 +980,15 @@ mod tests { fn parses_serve_dev() { let cli = Cli::parse_from(["decodex", "serve", "--dev"]); - assert!(matches!( - cli.command, - Command::Serve(ServeCommand { interval: None, dev: true, .. }) - )); + assert!(matches!(cli.command, Command::Serve(ServeCommand { dev: true, .. }))); } #[test] - fn rejects_serve_dev_with_interval() { - let cli = Cli::parse_from(["decodex", "serve", "--dev", "--interval", "30s"]); - let Command::Serve(command) = cli.command else { - panic!("expected serve command"); - }; - let error = command.run().expect_err("dev serve must reject interval configuration"); + fn rejects_serve_interval_argument() { + let error = Cli::try_parse_from(["decodex", "serve", "--interval", "30s"]) + .expect_err("serve interval override should be removed"); let message = error.to_string(); - assert!(message.contains("--dev")); assert!(message.contains("--interval")); } diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index 444a7a10..ea992660 100644 --- a/apps/decodex/src/orchestrator.rs +++ b/apps/decodex/src/orchestrator.rs @@ -82,6 +82,7 @@ const OPERATOR_DASHBOARD_WS_ENDPOINT_PATH: &str = "/dashboard/control"; const OPERATOR_LIVE_ENDPOINT_PATH: &str = "/livez"; const OPERATOR_ACCOUNTS_ENDPOINT_PATH: &str = "/api/accounts"; const OPERATOR_APP_SNAPSHOT_ENDPOINT_PATH: &str = "/api/operator-snapshot"; +const OPERATOR_LINEAR_SCAN_ENDPOINT_PATH: &str = "/api/linear-scan"; const OPERATOR_STATE_MAX_REQUEST_BYTES: usize = 8_192; const OPERATOR_DASHBOARD_WS_CLIENT_MESSAGE_MAX_BYTES: usize = 64 * 1_024; const OPERATOR_STATE_HEADER_TERMINATOR: &[u8] = b"\r\n\r\n"; @@ -89,6 +90,7 @@ const OPERATOR_DASHBOARD_WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2 const OPERATOR_RUN_ACTIVITY_STREAM_INTERVAL: Duration = Duration::from_secs(1); const OPERATOR_DEV_SNAPSHOT_STREAM_INTERVAL: Duration = Duration::from_secs(1); const DEFAULT_CONTROL_PLANE_POLL_INTERVAL: Duration = Duration::from_secs(15); +const LINEAR_CONTROL_PLANE_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); const PULL_REQUEST_REVIEW_STATE_QUERY: &str = r#" query($owner: String!, $name: String!, $number: Int!, $reviewThreadsAfter: String) { repository(owner: $owner, name: $name) { diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 56f1d2ba..65103f9f 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -165,11 +165,6 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { "serve --dev does not accept --config because it must not register or poll projects." ); } - if request.dev && request.poll_interval.is_some() { - eyre::bail!( - "serve --dev does not accept --interval because dev mode does not poll projects." - ); - } validate_daemon_runtime()?; @@ -205,14 +200,6 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { } } - let poll_interval = request - .poll_interval - .unwrap_or(DEFAULT_CONTROL_PLANE_POLL_INTERVAL); - - if poll_interval.is_zero() { - eyre::bail!("serve interval must be greater than zero."); - } - if let Some(config_path) = request.config_path { let Some(config_path) = resolve_config_path(Some(config_path), &state_store)? else { eyre::bail!( @@ -232,10 +219,12 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { let mut next_maintenance_at = Instant::now() + Duration::from_secs(60 * 60); tracing::info!( - poll_interval_s = poll_interval.as_secs(), + local_tick_interval_s = DEFAULT_CONTROL_PLANE_POLL_INTERVAL.as_secs(), + linear_poll_interval_s = LINEAR_CONTROL_PLANE_POLL_INTERVAL.as_secs(), listen_address = %operator_state_endpoint.listen_address(), path = OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, ws_path = OPERATOR_DASHBOARD_WS_ENDPOINT_PATH, + linear_scan_path = OPERATOR_LINEAR_SCAN_ENDPOINT_PATH, dev = false, runtime_db_path = %runtime_db_path.display(), global_config_path = %global_config_path.display(), @@ -252,10 +241,13 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { next_maintenance_at = tick_started_at + Duration::from_secs(60 * 60); } - let snapshot = run_control_plane_tick(&state_store, &mut project_runtimes)?; + let linear_scan_requests = + drain_operator_linear_scan_requests_best_effort(&operator_state_endpoint); + let snapshot = + run_control_plane_tick(&state_store, &mut project_runtimes, &linear_scan_requests)?; publish_operator_snapshot(&operator_state_endpoint, &snapshot); - sleep_until_next_tick(poll_interval, tick_started_at); + sleep_until_next_tick(DEFAULT_CONTROL_PLANE_POLL_INTERVAL, tick_started_at); } } @@ -760,16 +752,38 @@ where fn run_control_plane_tick( state_store: &StateStore, project_runtimes: &mut HashMap, + linear_scan_requests: &[OperatorLinearScanRequest], ) -> Result { let registered_projects = state_store.list_projects()?; + let now = Instant::now(); Ok(collect_control_plane_snapshot(registered_projects, |project, project_warnings| { let runtime = project_runtimes.entry(project.service_id().to_owned()).or_default(); - run_control_plane_project_tick(project, state_store, runtime, project_warnings) + run_control_plane_project_tick( + project, + state_store, + runtime, + project_warnings, + linear_scan_requests, + now, + ) })) } +fn drain_operator_linear_scan_requests_best_effort( + operator_state_endpoint: &OperatorStateEndpoint, +) -> Vec { + match operator_state_endpoint.drain_linear_scan_requests() { + Ok(requests) => requests, + Err(error) => { + tracing::warn!(?error, "Skipped operator-triggered Linear scan requests."); + + Vec::new() + }, + } +} + fn run_control_plane_dev_tick(state_store: &StateStore) -> Result { let registered_projects = state_store.list_projects()?; let mut snapshot = empty_control_plane_snapshot(DEFAULT_OPERATOR_DASHBOARD_RUN_LIMIT); @@ -923,8 +937,10 @@ fn run_control_plane_project_tick( state_store: &StateStore, runtime: &mut ProjectDaemonRuntime, snapshot_warnings: &mut Vec<&'static str>, + linear_scan_requests: &[OperatorLinearScanRequest], + now: Instant, ) -> ControlPlaneProjectTick { - if tracker_backoff_active(runtime, Instant::now()) { + if tracker_backoff_active(runtime, now) { snapshot_warnings.push(TRACKER_RATE_LIMIT_WARNING); let connector_backoffs = active_connector_backoff_statuses(project.service_id(), runtime); @@ -952,6 +968,12 @@ fn run_control_plane_project_tick( ); } + if !linear_scan_due(project.service_id(), runtime, linear_scan_requests, now) { + return control_plane_project_deferred_snapshot(project, state_store, runtime); + } + + remember_next_linear_scan(runtime, now); + match load_daemon_tick_context(project.config_path(), &mut runtime.workflow_cache) { Ok(context) => control_plane_project_snapshot(project, state_store, runtime, &context, snapshot_warnings), @@ -983,6 +1005,35 @@ fn tracker_backoff_active(runtime: &mut ProjectDaemonRuntime, now: Instant) -> b false } +fn linear_scan_due( + project_id: &str, + runtime: &ProjectDaemonRuntime, + linear_scan_requests: &[OperatorLinearScanRequest], + now: Instant, +) -> bool { + if linear_scan_requested(project_id, linear_scan_requests) { + return true; + } + + runtime.next_linear_scan_at.is_none_or(|next_scan_at| now >= next_scan_at) +} + +fn linear_scan_requested( + project_id: &str, + linear_scan_requests: &[OperatorLinearScanRequest], +) -> bool { + linear_scan_requests.iter().any(|request| { + request + .project_id + .as_deref() + .is_none_or(|requested_project_id| requested_project_id == project_id) + }) +} + +fn remember_next_linear_scan(runtime: &mut ProjectDaemonRuntime, now: Instant) { + runtime.next_linear_scan_at = Some(now + LINEAR_CONTROL_PLANE_POLL_INTERVAL); +} + fn remember_tracker_backoff( runtime: &mut ProjectDaemonRuntime, state_store: &StateStore, @@ -1051,6 +1102,85 @@ fn parse_linear_rate_limit_reset_unix_epoch(message: &str) -> Option { reset.parse().ok() } +fn control_plane_project_deferred_snapshot( + project: &ProjectRegistration, + state_store: &StateStore, + runtime: &mut ProjectDaemonRuntime, +) -> ControlPlaneProjectTick { + match load_daemon_tick_context(project.config_path(), &mut runtime.workflow_cache) { + Ok(context) => match build_operator_state_snapshot_without_live_observers( + &context.config, + &context.workflow, + state_store, + DEFAULT_OPERATOR_DASHBOARD_RUN_LIMIT, + ) { + Ok(snapshot) => { + write_agent_evidence_best_effort(&snapshot, AgentEvidenceSource::ServeTick); + + ControlPlaneProjectTick { + project_status: snapshot + .projects + .first() + .cloned() + .map(|status| complete_project_status(project, status)), + snapshot: Some(snapshot), + } + }, + Err(error) => { + let _ = error; + + tracing::warn!( + project_id = project.service_id(), + "Deferred control-plane snapshot build failed; sensitive runtime details were withheld." + ); + + ControlPlaneProjectTick { + snapshot: None, + project_status: Some(operator_project_status_from_registration(project, 1)), + } + }, + }, + Err(error) => { + let _ = error; + + tracing::warn!( + project_id = project.service_id(), + "Deferred control-plane snapshot context failed; sensitive runtime details were withheld." + ); + + ControlPlaneProjectTick { + snapshot: None, + project_status: Some(operator_project_status_from_registration(project, 1)), + } + }, + } +} + +fn build_operator_state_snapshot_without_live_observers( + project: &ServiceConfig, + workflow: &WorkflowDocument, + state_store: &StateStore, + limit: usize, +) -> Result { + state_store.configure_dispatch_slot_root( + project.service_id(), + project.worktree_root(), + workflow.frontmatter().execution().max_concurrent_agents(), + )?; + + let mut snapshot = build_operator_status_snapshot_with_account_mode( + project, + state_store, + limit, + AccountActivityMode::Snapshot, + )?; + + hydrate_history_lanes_from_local_ledger(project, state_store, &mut snapshot)?; + refresh_operator_project_summary(&mut snapshot); + + Ok(snapshot) +} + fn control_plane_project_local_snapshot( project: &ProjectRegistration, state_store: &StateStore, diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index 08d51d6f..89ab6497 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -34,6 +34,7 @@ enum OperatorRequestRoute { DashboardWs, Live, AppSnapshot, + LinearScan, AccountList { force_refresh: bool }, AccountSelect, AccountClear, @@ -133,6 +134,12 @@ struct OperatorAccountRequest { random_name_offset: Option, } +#[derive(Deserialize)] +struct OperatorLinearScanHttpRequest { + #[serde(alias = "projectId")] + project_id: Option, +} + struct DashboardControlAck<'a> { request_id: Option<&'a str>, action: &'a str, @@ -169,6 +176,7 @@ fn run_operator_state_endpoint( listener: TcpListener, snapshot: Arc>, dashboard_events: DashboardEventHub, + control_requests: OperatorControlRequests, state_store: Arc, shutdown_rx: Receiver<()>, ) { @@ -178,22 +186,24 @@ fn run_operator_state_endpoint( } match listener.accept() { - Ok((stream, _peer_addr)) => { - let connection_snapshot = Arc::clone(&snapshot); - let connection_dashboard_events = dashboard_events.clone(); - let connection_state_store = Arc::clone(&state_store); - - thread::spawn(move || { - if let Err(error) = handle_operator_state_endpoint_connection( - stream, - &connection_snapshot, - &connection_dashboard_events, - &connection_state_store, - ) { - tracing::warn!(?error, "Operator state endpoint request failed."); - } - }); - }, + Ok((stream, _peer_addr)) => { + let connection_snapshot = Arc::clone(&snapshot); + let connection_dashboard_events = dashboard_events.clone(); + let connection_control_requests = control_requests.clone(); + let connection_state_store = Arc::clone(&state_store); + + thread::spawn(move || { + if let Err(error) = handle_operator_state_endpoint_connection( + stream, + &connection_snapshot, + &connection_dashboard_events, + &connection_control_requests, + &connection_state_store, + ) { + tracing::warn!(?error, "Operator state endpoint request failed."); + } + }); + }, Err(error) if error.kind() == ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(20)); }, @@ -210,6 +220,7 @@ fn handle_operator_state_endpoint_connection( mut stream: TcpStream, snapshot: &Arc>, dashboard_events: &DashboardEventHub, + control_requests: &OperatorControlRequests, state_store: &Arc, ) -> Result<()> { stream.set_nonblocking(false)?; @@ -233,6 +244,13 @@ fn handle_operator_state_endpoint_connection( return Ok(()); } + if route == OperatorRequestRoute::LinearScan { + let response = build_operator_linear_scan_http_response(control_requests, &request); + + stream.write_all(&response)?; + + return Ok(()); + } if route == OperatorRequestRoute::AppSnapshot { let response = build_operator_app_snapshot_http_response(snapshot); @@ -1430,6 +1448,16 @@ fn operator_http_content_length(headers: &[u8]) -> Result { #[cfg(test)] fn build_operator_state_http_response(request: &[u8]) -> Result> { + let control_requests = OperatorControlRequests::default(); + + build_operator_state_http_response_with_control_requests(request, &control_requests) +} + +#[cfg(test)] +fn build_operator_state_http_response_with_control_requests( + request: &[u8], + control_requests: &OperatorControlRequests, +) -> Result> { let route = match parse_operator_state_request_route(request) { Ok(route) => route, Err(response) => return Ok(response), @@ -1438,6 +1466,9 @@ fn build_operator_state_http_response(request: &[u8]) -> Result> { if operator_request_route_is_account_api(&route) { return Ok(build_operator_account_http_response(route, request)); } + if route == OperatorRequestRoute::LinearScan { + return Ok(build_operator_linear_scan_http_response(control_requests, request)); + } Ok(build_operator_state_http_response_for_route(route)) } @@ -1577,6 +1608,58 @@ fn operator_http_request_body(request: &[u8]) -> Result<&[u8]> { Ok(&request[body_offset..]) } +fn build_operator_linear_scan_http_response( + control_requests: &OperatorControlRequests, + request: &[u8], +) -> Vec { + match operator_linear_scan_http_response_body(control_requests, request) { + Ok(body) => http_response_bytes("202 Accepted", "application/json", &body), + Err(error) => { + let body = serde_json::to_vec(&json!({ "error": error.to_string() })) + .unwrap_or_else(|_| br#"{"error":"linear scan request failed"}"#.to_vec()); + + http_response_bytes("400 Bad Request", "application/json", &body) + }, + } +} + +fn operator_linear_scan_http_response_body( + control_requests: &OperatorControlRequests, + request: &[u8], +) -> Result> { + let project_id = operator_linear_scan_request_project_id(request)?; + let scope = project_id.as_deref().unwrap_or("all"); + + control_requests.request_linear_scan(project_id.clone())?; + + serde_json::to_vec(&json!({ + "status": "queued", + "scope": scope, + "project_id": project_id, + "next_action": "Decodex will run the requested Linear scan on the next control-plane tick unless the tracker connector is rate-limited.", + })) + .map_err(Into::into) +} + +fn operator_linear_scan_request_project_id(request: &[u8]) -> Result> { + let body = operator_http_request_body(request)?; + + if body.is_empty() { + return Ok(None); + } + + let request: OperatorLinearScanHttpRequest = serde_json::from_slice(body) + .map_err(|error| eyre::eyre!("Linear scan request body was not valid JSON: {error}"))?; + + match request.project_id { + Some(project_id) if project_id.trim().is_empty() => { + eyre::bail!("Linear scan request project_id must not be blank.") + }, + Some(project_id) => Ok(Some(project_id.trim().to_owned())), + None => Ok(None), + } +} + fn build_operator_state_http_response_for_route(route: OperatorRequestRoute) -> Vec { match route { OperatorRequestRoute::Dashboard => { @@ -1592,11 +1675,14 @@ fn build_operator_state_http_response_for_route(route: OperatorRequestRoute) -> http_response_bytes("200 OK", "image/png", OPERATOR_DASHBOARD_LOGO_TOUCH_PNG) }, OperatorRequestRoute::DashboardWs => websocket_upgrade_required_response(), - OperatorRequestRoute::AppSnapshot => { - http_response_bytes("200 OK", "application/json", b"{}") - }, - OperatorRequestRoute::Live => { - http_response_bytes("200 OK", "text/plain; charset=utf-8", b"ok") + OperatorRequestRoute::AppSnapshot => { + http_response_bytes("200 OK", "application/json", b"{}") + }, + OperatorRequestRoute::LinearScan => { + http_response_bytes("405 Method Not Allowed", "text/plain; charset=utf-8", b"method not allowed") + }, + OperatorRequestRoute::Live => { + http_response_bytes("200 OK", "text/plain; charset=utf-8", b"ok") }, OperatorRequestRoute::AccountList { .. } | OperatorRequestRoute::AccountSelect @@ -1651,12 +1737,13 @@ fn parse_operator_state_request_route( ("GET", "/assets/logo.ico") => Ok(OperatorRequestRoute::DashboardLogoIco), ("GET", "/assets/logo-touch.png") => Ok(OperatorRequestRoute::DashboardLogoTouchPng), ("GET", OPERATOR_DASHBOARD_WS_ENDPOINT_PATH) => Ok(OperatorRequestRoute::DashboardWs), - ("GET", OPERATOR_LIVE_ENDPOINT_PATH) => Ok(OperatorRequestRoute::Live), - ("GET", OPERATOR_APP_SNAPSHOT_ENDPOINT_PATH) => Ok(OperatorRequestRoute::AppSnapshot), - ("GET", OPERATOR_ACCOUNTS_ENDPOINT_PATH) => Ok(OperatorRequestRoute::AccountList { - force_refresh: operator_query_has_flag(query, "refresh"), - }), - ("POST", "/api/accounts/select") => Ok(OperatorRequestRoute::AccountSelect), + ("GET", OPERATOR_LIVE_ENDPOINT_PATH) => Ok(OperatorRequestRoute::Live), + ("GET", OPERATOR_APP_SNAPSHOT_ENDPOINT_PATH) => Ok(OperatorRequestRoute::AppSnapshot), + ("GET", OPERATOR_ACCOUNTS_ENDPOINT_PATH) => Ok(OperatorRequestRoute::AccountList { + force_refresh: operator_query_has_flag(query, "refresh"), + }), + ("POST", OPERATOR_LINEAR_SCAN_ENDPOINT_PATH) => Ok(OperatorRequestRoute::LinearScan), + ("POST", "/api/accounts/select") => Ok(OperatorRequestRoute::AccountSelect), ("POST", "/api/accounts/clear") => Ok(OperatorRequestRoute::AccountClear), ("POST", "/api/accounts/logout") => Ok(OperatorRequestRoute::AccountLogout), ("POST", "/api/accounts/import") => Ok(OperatorRequestRoute::AccountImport), @@ -1665,9 +1752,10 @@ fn parse_operator_state_request_route( (_, OPERATOR_DASHBOARD_ENDPOINT_PATH | OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH | OPERATOR_DASHBOARD_WS_ENDPOINT_PATH - | OPERATOR_LIVE_ENDPOINT_PATH - | OPERATOR_APP_SNAPSHOT_ENDPOINT_PATH - | OPERATOR_ACCOUNTS_ENDPOINT_PATH + | OPERATOR_LIVE_ENDPOINT_PATH + | OPERATOR_APP_SNAPSHOT_ENDPOINT_PATH + | OPERATOR_LINEAR_SCAN_ENDPOINT_PATH + | OPERATOR_ACCOUNTS_ENDPOINT_PATH | "/api/accounts/select" | "/api/accounts/clear" | "/api/accounts/logout" diff --git a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs index bbab888f..52d57e29 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs @@ -1,3 +1,5 @@ +use orchestrator::ProjectDaemonRuntime; + #[test] fn control_plane_snapshot_lists_disabled_registered_projects() { let (temp_dir, config, _workflow) = temp_project_layout(); @@ -15,7 +17,7 @@ fn control_plane_snapshot_lists_disabled_registered_projects() { state_store.upsert_project(®istration).expect("project should register"); let mut project_runtimes = HashMap::new(); - let snapshot = orchestrator::run_control_plane_tick(&state_store, &mut project_runtimes) + let snapshot = orchestrator::run_control_plane_tick(&state_store, &mut project_runtimes, &[]) .expect("control-plane snapshot should build"); let project = snapshot.projects.first().expect("disabled project should be listed"); @@ -32,6 +34,40 @@ fn control_plane_snapshot_lists_disabled_registered_projects() { assert!(project_runtimes.is_empty(), "disabled projects should not be ticked"); } +#[test] +fn control_plane_linear_scan_cadence_uses_fixed_window_and_manual_override() { + let now = Instant::now(); + let mut runtime = ProjectDaemonRuntime::default(); + + assert!(orchestrator::linear_scan_due("pubfi", &runtime, &[], now)); + + orchestrator::remember_next_linear_scan(&mut runtime, now); + + assert!(!orchestrator::linear_scan_due("pubfi", &runtime, &[], now)); + assert!(orchestrator::linear_scan_due( + "pubfi", + &runtime, + &[orchestrator::OperatorLinearScanRequest { project_id: None }], + now + )); + assert!(orchestrator::linear_scan_due( + "pubfi", + &runtime, + &[orchestrator::OperatorLinearScanRequest { + project_id: Some(String::from("pubfi")), + }], + now + )); + assert!(!orchestrator::linear_scan_due( + "pubfi", + &runtime, + &[orchestrator::OperatorLinearScanRequest { + project_id: Some(String::from("rsnap")), + }], + now + )); +} + #[test] fn control_plane_dev_snapshot_does_not_tick_enabled_projects() { let (temp_dir, config, _workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/http.rs b/apps/decodex/src/orchestrator/tests/operator/status/http.rs index 572f04ad..b307ebea 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/http.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/http.rs @@ -1,6 +1,8 @@ use std::io::ErrorKind; use std::net::SocketAddr; +use orchestrator::OperatorControlRequests; + use crate::runtime; #[test] @@ -230,6 +232,7 @@ fn operator_dashboard_websocket_pushes_broadcast_events() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -303,6 +306,7 @@ fn operator_dashboard_websocket_sends_current_snapshot_on_connect() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -393,6 +397,7 @@ fn operator_dashboard_websocket_sends_current_run_activity_on_connect() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -457,6 +462,7 @@ fn operator_dashboard_websocket_accepts_subscription_and_project_pause_control() stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -623,6 +629,7 @@ fn operator_dashboard_websocket_controls_focus_and_clear_subscription() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -686,6 +693,7 @@ fn operator_dashboard_websocket_filters_run_activity_by_subscription() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -787,6 +795,7 @@ fn operator_dashboard_websocket_interrupt_control_stops_active_run_process() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -867,6 +876,7 @@ fn operator_dashboard_websocket_interrupt_control_reports_validation_errors() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("websocket handler should complete after client disconnect"); @@ -1197,6 +1207,7 @@ fn operator_state_endpoint_reads_complete_headers_before_parsing() { stream, &server_snapshot, &dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("handler should accept segmented headers"); @@ -1262,6 +1273,7 @@ fn operator_state_endpoint_overlays_live_account_control_on_published_snapshot() stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("handler should serve websocket snapshot"); @@ -1343,6 +1355,7 @@ fn operator_state_endpoint_serves_large_app_snapshot_without_truncation() { stream, &server_snapshot, &server_dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("handler should serve the complete large app snapshot"); @@ -1416,6 +1429,7 @@ fn operator_state_endpoint_livez_ignores_poisoned_snapshot_lock() { stream, &server_snapshot, &dashboard_events, + &OperatorControlRequests::default(), &server_state_store, ) .expect("live probe should not require snapshot lock"); @@ -1458,6 +1472,43 @@ fn operator_state_endpoint_serves_only_liveness_probe() { assert!(live_response.ends_with("ok")); } +#[test] +fn operator_state_endpoint_queues_linear_scan_request() { + let control_requests = OperatorControlRequests::default(); + let body = br#"{"projectId":"pubfi"}"#; + let request = format!( + "POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + orchestrator::OPERATOR_LINEAR_SCAN_ENDPOINT_PATH, + body.len(), + String::from_utf8_lossy(body) + ); + let response = String::from_utf8( + orchestrator::build_operator_state_http_response_with_control_requests( + request.as_bytes(), + &control_requests, + ) + .expect("linear scan response should build"), + ) + .expect("linear scan response should be utf-8"); + let body = response + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .expect("linear scan response should include body"); + let data: Value = serde_json::from_str(body).expect("linear scan response should be json"); + + assert!(response.starts_with("HTTP/1.1 202 Accepted\r\n")); + assert_eq!(data["status"], "queued"); + assert_eq!(data["scope"], "pubfi"); + assert_eq!( + control_requests + .drain_linear_scan_requests() + .expect("linear scan requests should drain"), + vec![orchestrator::OperatorLinearScanRequest { + project_id: Some(String::from("pubfi")), + }] + ); +} + #[test] fn operator_state_endpoint_serves_account_api_snapshot() { let temp_dir = TempDir::new().expect("temp dir should exist"); diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index fd68de5c..4c035a6e 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -30,7 +30,6 @@ pub(crate) struct RunOnceRequest<'a> { /// Multi-project local control-plane daemon request. pub(crate) struct ServeRequest<'a> { pub(crate) config_path: Option<&'a Path>, - pub(crate) poll_interval: Option, pub(crate) listen_address: &'a str, pub(crate) dev: bool, } @@ -548,6 +547,7 @@ struct ProjectDaemonRuntime { active_children: Vec, retry_queue: RetryQueue, tracker_backoff: Option, + next_linear_scan_at: Option, workflow_cache: Option, recoverable_worktree_skip_cache: RecoverableWorktreeSkipCache, } @@ -564,6 +564,7 @@ struct OperatorStateEndpoint { listen_address: SocketAddr, snapshot: Arc>, dashboard_events: DashboardEventHub, + control_requests: OperatorControlRequests, shutdown_tx: Sender<()>, activity_shutdown_tx: Sender<()>, server_thread: Option>, @@ -588,6 +589,8 @@ impl OperatorStateEndpoint { let dashboard_events = DashboardEventHub::default(); let shared_snapshot = Arc::clone(&snapshot); let server_dashboard_events = dashboard_events.clone(); + let control_requests = OperatorControlRequests::default(); + let server_control_requests = control_requests.clone(); let server_state_store = Arc::clone(&state_store); let (shutdown_tx, shutdown_rx) = mpsc::channel(); let server_thread = thread::spawn(move || { @@ -595,6 +598,7 @@ impl OperatorStateEndpoint { listener, shared_snapshot, server_dashboard_events, + server_control_requests, server_state_store, shutdown_rx, ); @@ -613,6 +617,7 @@ impl OperatorStateEndpoint { listen_address, snapshot, dashboard_events, + control_requests, shutdown_tx, activity_shutdown_tx, server_thread: Some(server_thread), @@ -648,9 +653,13 @@ impl OperatorStateEndpoint { }), ); - Ok(()) + Ok(()) + } + + fn drain_linear_scan_requests(&self) -> crate::prelude::Result> { + self.control_requests.drain_linear_scan_requests() + } } -} impl Drop for OperatorStateEndpoint { fn drop(&mut self) { @@ -672,6 +681,37 @@ struct PublishedOperatorSnapshot { last_publish_unix_epoch: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct OperatorLinearScanRequest { + project_id: Option, +} + +#[derive(Clone, Default)] +struct OperatorControlRequests { + linear_scan_requests: Arc>>, +} +impl OperatorControlRequests { + fn request_linear_scan(&self, project_id: Option) -> crate::prelude::Result<()> { + let mut requests = self + .linear_scan_requests + .lock() + .map_err(|error| eyre::eyre!("Operator control request lock poisoned: {error}"))?; + + requests.push(OperatorLinearScanRequest { project_id }); + + Ok(()) + } + + fn drain_linear_scan_requests(&self) -> crate::prelude::Result> { + let mut requests = self + .linear_scan_requests + .lock() + .map_err(|error| eyre::eyre!("Operator control request lock poisoned: {error}"))?; + + Ok(requests.drain(..).collect()) + } +} + #[derive(Clone)] struct CachedWorkflowDocument { path: PathBuf, diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index b93c764d..79933053 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -38,8 +38,28 @@ launch it connects to an existing default local listener when one is reachable; not, it starts the bundled `decodex` binary as `decodex serve --listen-address 127.0.0.1:8912`. The app fallback is a normal control-plane server: it loads the enabled project registry, uses the CLI-owned default -15-second poll interval, and serves the dashboard, account APIs, and -`GET /api/operator-snapshot` from the single local listener. +cadences, and serves the dashboard, account APIs, `GET /api/operator-snapshot`, and +`POST /api/linear-scan` from the single local listener. + +`decodex serve` has two hardcoded scheduler cadences: + +- The local control-plane loop publishes operator snapshots every 15 seconds. +- Linear-backed queue/status scans run at most every 5 minutes per project, unless + an operator or agent queues an explicit scan request with + `POST /api/linear-scan`. + +Agents that just created or relabeled queue issues can avoid waiting for the next +5-minute Linear poll by sending a targeted local request: + +```sh +curl -sS -X POST http://127.0.0.1:8912/api/linear-scan \ + -H 'Content-Type: application/json' \ + -d '{"projectId":"decodex"}' +``` + +An empty `POST /api/linear-scan` queues a scan for all enabled projects. Requests are +consumed by the next 15-second control-plane tick and still respect any active tracker +rate-limit backoff. Use `--dev` only for isolated local development: @@ -47,7 +67,7 @@ Use `--dev` only for isolated local development: and dashboard routes against local runtime state without starting automation. - Do not use `--dev` for operator automation, queue intake, retained-lane recovery, project registration refresh, or service scheduling. It is hidden from CLI help and - intentionally rejects `--config` and `--interval`. + intentionally rejects `--config`; `serve` has no interval override argument. - For browser-only dashboard UI work, use `dev/operator-dashboard-mock.mjs` instead of `--dev`. diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index 8756e382..765a7778 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -428,12 +428,22 @@ wants to observe the self-bootstrap loop without reading source code. Do not use `decodex serve --dev` for this step. Dev mode is only for local account/app snapshot API development while avoiding scheduler activity; it does not - register projects, poll Linear, dispatch work, or accept `--config` or `--interval`. + register projects, poll Linear, dispatch work, or accept `--config`. Pass `decodex serve --config ` when you want `serve` to refresh one project registration before it starts. Omit it when the registry already contains the enabled projects you want the control plane to monitor. + The scheduler keeps local snapshots on a 15-second loop and limits Linear-backed + scans to one 5-minute window per project. After creating or relabeling queue + issues, trigger the next scan explicitly instead of waiting for that window: + + ```sh + curl -sS -X POST http://127.0.0.1:8912/api/linear-scan \ + -H 'Content-Type: application/json' \ + -d '{"projectId":"decodex"}' + ``` + 5. Open the operator dashboard: ```text @@ -621,13 +631,14 @@ Decodex is intentionally Unix-only, and the control plane relies on Unix file-de decodex serve --listen-address 127.0.0.1:8912 ``` -Omit `--interval` to use the CLI default 15-second scheduler cadence. Pass -`--interval ` only when this runbook step deliberately needs a non-default -poll interval. +`serve` has no interval override. It publishes local operator snapshots every +15 seconds and runs Linear-backed queue/status scans at most every 5 minutes per +project. Use `POST /api/linear-scan` on the same listener to queue an immediate +scan request for the next 15-second tick. Use hidden `decodex serve --dev` only for local account/app snapshot API development while deliberately avoiding scheduler activity. Decodex App's fallback server uses -ordinary `decodex serve` and leaves scheduler cadence to the CLI default. Dev mode is +ordinary `decodex serve` and leaves scheduler cadence to CLI-owned defaults. Dev mode is not a scheduler and must not be used for this runbook's automation, queue intake, project registration, or retained-lane recovery steps. diff --git a/plugins/decodex/skills/automation/SKILL.md b/plugins/decodex/skills/automation/SKILL.md index 6ceb97d1..a5d9bfb2 100644 --- a/plugins/decodex/skills/automation/SKILL.md +++ b/plugins/decodex/skills/automation/SKILL.md @@ -54,9 +54,23 @@ for a deliberate one-issue automation pass; it still uses the same retained-lane eligibility and lifecycle rules. Do not use hidden `serve --dev` for automation. That mode is for isolated local development: it serves local dashboard/account/app snapshot APIs, but it does not -register projects, poll Linear, or dispatch lanes, and it rejects `--config` and -`--interval`. Decodex App's fallback server uses ordinary `serve` when no compatible -local listener is already running. +register projects, poll Linear, or dispatch lanes, and it rejects `--config`. +Decodex App's fallback server uses ordinary `serve` when no compatible local listener +is already running. + +`serve` owns hardcoded scheduler cadences: local operator snapshots publish every +15 seconds, while Linear-backed queue/status scans run at most every 5 minutes per +project. After creating or relabeling queued issues, request the next scan instead of +waiting for the 5-minute window: + +```sh +curl -sS -X POST http://127.0.0.1:8912/api/linear-scan \ + -H 'Content-Type: application/json' \ + -d '{"projectId":""}' +``` + +Omit the JSON body to queue a scan for all enabled projects. The request is consumed +on the next 15-second control-plane tick and still respects tracker rate-limit backoff. ## Intake and Ownership diff --git a/plugins/decodex/skills/manual-cli/SKILL.md b/plugins/decodex/skills/manual-cli/SKILL.md index b9d2c06a..e2998ed3 100644 --- a/plugins/decodex/skills/manual-cli/SKILL.md +++ b/plugins/decodex/skills/manual-cli/SKILL.md @@ -82,9 +82,22 @@ Manual commit and landing are separate narrow workflows: - Use `probe stdio://` before relying on the Codex app-server boundary. - Treat hidden `serve --dev` as isolated local-development infrastructure only. It serves dashboard, account, and app snapshot APIs, but it does not register projects, - poll Linear, dispatch work, or accept `--config` or `--interval`. Decodex App's - fallback server uses ordinary `serve` when no compatible local listener is already - running. + poll Linear, dispatch work, or accept `--config`. Decodex App's fallback server uses + ordinary `serve` when no compatible local listener is already running. +- `serve` has no interval override. It publishes local operator snapshots every + 15 seconds and runs Linear-backed queue/status scans at most every 5 minutes per + project. +- After creating or relabeling queue issues, request the next scan instead of waiting + for the 5-minute Linear poll: + + ```sh + curl -sS -X POST http://127.0.0.1:8912/api/linear-scan \ + -H 'Content-Type: application/json' \ + -d '{"projectId":""}' + ``` + + Omit the JSON body to scan all enabled projects. The request is consumed on the next + 15-second control-plane tick and still respects tracker rate-limit backoff. - For `skills/list` app-server preflight output, enabled skills plus scan diagnostics are local evidence, not a lane blocker. Missing cwd coverage or zero enabled skills are blockers; inspect `first_error_path` and `first_error` before changing plugin or