diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index dc059417..7c1dd799 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -1462,51 +1462,6 @@ color: var(--success); } - .control-button.icon-button { - width: 26px; - min-width: 26px; - height: 26px; - padding: 0; - border-radius: 999px; - } - - .status-line .run-stop-button { - width: 18px; - min-width: 18px; - height: 18px; - min-height: 18px; - margin-left: -7px; - padding: 0; - border: 0; - border-radius: 2px; - background: transparent; - color: color-mix(in srgb, var(--danger) 86%, var(--text)); - opacity: 0.92; - } - - .status-line .run-stop-button:hover { - border: 0; - background: transparent; - color: color-mix(in srgb, var(--danger) 88%, var(--text)); - opacity: 1; - } - - .status-line .run-stop-button:focus-visible { - outline: 1px solid color-mix(in srgb, var(--danger) 58%, transparent); - outline-offset: 2px; - } - - .control-button.icon-button svg { - display: block; - width: 13px; - height: 13px; - } - - .status-line .run-stop-button svg { - width: 14px; - height: 14px; - } - .control-button[disabled] { cursor: not-allowed; opacity: 0.5; @@ -9579,31 +9534,6 @@

${escapeHtml(item.title)}

); } - function runInterruptControlEnabled(run) { - return Boolean( - run.project_id && - run.issue_id && - run.run_id && - run.process_id && - run.process_alive === true, - ); - } - - function renderRunStopControl(run) { - const interruptEnabled = runInterruptControlEnabled(run); - if (!interruptEnabled) { - return ""; - } - - return ` - - `; - } - function renderActiveRuns(snapshot, derived) { const runs = snapshot?.active_runs ?? []; setPanelMeta( @@ -9660,12 +9590,6 @@

${escapeHtml(item.title)}

} } const attemptNumber = attemptNumberFromRun(run); - const stopControl = renderRunStopControl(run); - const statusLineParts = [...statusBits]; - if (stopControl) { - statusLineParts.splice(1, 0, stopControl); - } - return `
@@ -9681,7 +9605,7 @@

${escapeHtml(issueTitle)}

${runNeedsAttention(run) ? `${escapeHtml(runHealthText(run))}` : ""}
-
${statusLineParts.join("")}
+
${statusBits.join("")}
${summary ? `

${escapeHtml(summary)}

` : ""} ${renderRunMetaLine(run, snapshot)} ${renderChildAgentBreakdown(run)} @@ -10470,9 +10394,6 @@

${escapeHtml(worktree.branch_name)}

} function dashboardControlActionLabel(action) { - if (action === "interruptRun" || action === "interrupt") { - return "Stop"; - } return displayToken(action); } @@ -10604,21 +10525,6 @@

${escapeHtml(worktree.branch_name)}

renderWorktrees(snapshot); } - function handleDashboardControlClick(button) { - const control = button.dataset.dashboardControl; - const projectId = button.dataset.projectId || null; - const issueId = button.dataset.issueId || null; - const runId = button.dataset.runId || null; - - switch (control) { - case "interruptRun": - sendDashboardControl(control, { projectId, issueId, runId }); - break; - default: - break; - } - } - function startDashboardStream() { applyTheme(themeSelection, false); renderAccountPrivacyToggle(); @@ -10850,13 +10756,6 @@

${escapeHtml(worktree.branch_name)}

return; } - const controlButton = event.target.closest("[data-dashboard-control]"); - if (controlButton) { - event.preventDefault(); - handleDashboardControlClick(controlButton); - return; - } - const summary = event.target.closest("summary"); if (!summary) { return; diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index e833dba2..267eb2e5 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -1,14 +1,10 @@ use base64::engine::general_purpose::STANDARD; use base64::Engine as _; use sha1::{Digest as _, Sha1}; -use libc::SIGTERM; use crate::accounts; use crate::accounts::AccountUseRequest; -#[cfg(test)] -type DashboardRunInterrupterForTest = fn(u32) -> Result<()>; - const OPERATOR_DASHBOARD_HTML: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/orchestrator/operator_dashboard.html")); const OPERATOR_DASHBOARD_ICON_PNG: &[u8] = @@ -21,10 +17,6 @@ const OPERATOR_DASHBOARD_LOGO_TOUCH_PNG: &[u8] = include_bytes!(concat!( )); const OPERATOR_HTTP_READ_TIMEOUT: Duration = Duration::from_millis(250); -#[cfg(test)] -static DASHBOARD_RUN_INTERRUPTER_FOR_TEST: Mutex> = - Mutex::new(None); - #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum OperatorRequestRoute { Dashboard, @@ -157,21 +149,6 @@ struct DashboardRunActivityEvent { event: DashboardBroadcastEvent, } -#[cfg(test)] -struct DashboardRunInterrupterGuardForTest { - previous: Option, -} -#[cfg(test)] -impl Drop for DashboardRunInterrupterGuardForTest { - fn drop(&mut self) { - let mut slot = DASHBOARD_RUN_INTERRUPTER_FOR_TEST - .lock() - .expect("dashboard run interrupter test hook should not be poisoned"); - - *slot = self.previous.take(); - } -} - fn run_operator_state_endpoint( listener: TcpListener, snapshot: Arc>, @@ -781,9 +758,6 @@ fn dashboard_control_ready_payload(subscription: &DashboardClientSubscription) - "subscribe", "focus", "clearFocus", - "pauseProject", - "resumeProject", - "interruptRun", "selectAccount", "clearAccountSelection", "ack" @@ -856,12 +830,6 @@ fn handle_dashboard_control_action( "focus" => dashboard_focus_control_ack(session, message, action), "clearFocus" | "clearSubscription" => dashboard_clear_focus_control_ack(session, message, action), - "pause" | "pauseProject" => - dashboard_project_enabled_control_ack(session, state_store, message, action, false), - "resume" | "resumeProject" => - dashboard_project_enabled_control_ack(session, state_store, message, action, true), - "interrupt" | "interruptRun" => - dashboard_interrupt_control_ack(session, state_store, message, action), "selectAccount" => dashboard_account_selection_control_ack(session, state_store, message, action, true), "clearAccountSelection" => @@ -915,40 +883,6 @@ fn dashboard_clear_focus_control_ack( }) } -fn dashboard_project_enabled_control_ack( - session: &DashboardWebSocketSession, - state_store: &StateStore, - message: &DashboardClientMessage, - action: &str, - enabled: bool, -) -> Value { - let Some(project_id) = dashboard_required_project_id(message) else { - return dashboard_missing_project_control_ack(session, message, action); - }; - let result = state_store.set_project_enabled(project_id, enabled); - let (accepted, status, copy) = match (enabled, result) { - (true, Ok(())) => (true, "resumed", String::from("Project dispatch resumed.")), - (false, Ok(())) => ( - true, - "paused", - String::from("Project dispatch paused; active lanes are not killed."), - ), - (_, Err(error)) => (false, "failed", error.to_string()), - }; - - dashboard_control_ack_value(DashboardControlAck { - request_id: message.request_id.as_deref(), - action, - accepted, - status, - message: ©, - project_id: Some(project_id), - issue_id: message.issue_id.as_deref(), - run_id: message.run_id.as_deref(), - subscription: Some(&session.subscription), - }) -} - fn dashboard_account_selection_control_ack( session: &DashboardWebSocketSession, _state_store: &StateStore, @@ -1008,83 +942,6 @@ fn dashboard_account_selection_control_ack( }) } -fn dashboard_interrupt_control_ack( - session: &DashboardWebSocketSession, - state_store: &StateStore, - message: &DashboardClientMessage, - action: &str, -) -> Value { - let Some(project_id) = dashboard_required_project_id(message) else { - return dashboard_missing_project_control_ack(session, message, action); - }; - let Some(issue_id) = dashboard_required_issue_id(message) else { - return dashboard_control_ack_value(DashboardControlAck { - request_id: message.request_id.as_deref(), - action, - accepted: false, - status: "missing_issue", - message: "Stop requires an issue id.", - project_id: Some(project_id), - issue_id: message.issue_id.as_deref(), - run_id: message.run_id.as_deref(), - subscription: Some(&session.subscription), - }); - }; - let Some(run_id) = dashboard_required_run_id(message) else { - return dashboard_control_ack_value(DashboardControlAck { - request_id: message.request_id.as_deref(), - action, - accepted: false, - status: "missing_run", - message: "Stop requires a run id.", - project_id: Some(project_id), - issue_id: Some(issue_id), - run_id: message.run_id.as_deref(), - subscription: Some(&session.subscription), - }); - }; - - match interrupt_dashboard_run(state_store, project_id, issue_id, run_id) { - Ok(process_id) => dashboard_control_ack_value(DashboardControlAck { - request_id: message.request_id.as_deref(), - action, - accepted: true, - status: "interrupted", - message: &format!("Stopped run `{run_id}` by signaling process {process_id}."), - project_id: Some(project_id), - issue_id: Some(issue_id), - run_id: Some(run_id), - subscription: Some(&session.subscription), - }), - Err(error) => dashboard_control_ack_value(DashboardControlAck { - request_id: message.request_id.as_deref(), - action, - accepted: false, - status: "failed", - message: &error.to_string(), - project_id: Some(project_id), - issue_id: Some(issue_id), - run_id: Some(run_id), - subscription: Some(&session.subscription), - }), - } -} - -fn dashboard_missing_project_control_ack( - session: &DashboardWebSocketSession, - message: &DashboardClientMessage, - action: &str, -) -> Value { - dashboard_control_ack_for_message( - session, - message, - action, - false, - "missing_project", - "Control action requires a project id.", - ) -} - fn dashboard_unsupported_control_ack( session: &DashboardWebSocketSession, message: &DashboardClientMessage, @@ -1153,18 +1010,6 @@ fn dashboard_subscription_payload(subscription: &DashboardClientSubscription) -> }) } -fn dashboard_required_project_id(message: &DashboardClientMessage) -> Option<&str> { - message.project_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) -} - -fn dashboard_required_issue_id(message: &DashboardClientMessage) -> Option<&str> { - message.issue_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) -} - -fn dashboard_required_run_id(message: &DashboardClientMessage) -> Option<&str> { - message.run_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) -} - fn dashboard_required_account_selector(message: &DashboardClientMessage) -> Option<&str> { message .account_selector @@ -1180,100 +1025,6 @@ fn dashboard_clean_scope_value(value: Option<&str>) -> Option { .map(str::to_owned) } -fn interrupt_dashboard_run( - state_store: &StateStore, - project_id: &str, - issue_id: &str, - run_id: &str, -) -> Result { - let run_attempt = state_store - .run_attempt(run_id)? - .ok_or_else(|| eyre::eyre!("Decodex run `{run_id}` is not recorded."))?; - - if run_attempt.issue_id() != issue_id { - eyre::bail!( - "Decodex run `{run_id}` belongs to issue `{}`, not `{issue_id}`.", - run_attempt.issue_id() - ); - } - if !matches!(run_attempt.status(), "starting" | "running") { - eyre::bail!( - "Decodex run `{run_id}` is `{}` and cannot be stopped.", - run_attempt.status() - ); - } - - let worktree = state_store - .worktree_for_issue(issue_id)? - .ok_or_else(|| eyre::eyre!("Issue `{issue_id}` has no recorded worktree."))?; - - if worktree.project_id() != project_id { - eyre::bail!( - "Issue `{issue_id}` belongs to project `{}`, not `{project_id}`.", - worktree.project_id() - ); - } - - let marker = state::read_run_activity_marker_snapshot(worktree.worktree_path())? - .ok_or_else(|| eyre::eyre!("Run `{run_id}` has no activity marker."))?; - - if marker.run_id() != run_id || marker.attempt_number() != run_attempt.attempt_number() { - eyre::bail!("Run `{run_id}` activity marker does not match the active attempt."); - } - - let process_id = marker - .process_id() - .ok_or_else(|| eyre::eyre!("Run `{run_id}` has no recorded process id."))?; - - interrupt_dashboard_process(process_id)?; - - state_store.update_run_status(run_id, "interrupted")?; - state_store.clear_lease(issue_id)?; - - Ok(process_id) -} - -fn interrupt_dashboard_process(process_id: u32) -> Result<()> { - #[cfg(test)] - if let Some(interrupter) = *DASHBOARD_RUN_INTERRUPTER_FOR_TEST - .lock() - .expect("dashboard run interrupter test hook should not be poisoned") - { - return interrupter(process_id); - } - - if process_id == process::id() { - eyre::bail!("Refusing to stop the Decodex control-plane process."); - } - - let process_id = pid_t::try_from(process_id) - .map_err(|error| eyre::eyre!("Run process id is out of range: {error}"))?; - - if process_id <= 0 { - eyre::bail!("Run process id must be positive."); - } - - let result = unsafe { libc::kill(process_id, SIGTERM) }; - - if result == 0 { - return Ok(()); - } - - eyre::bail!("Failed to stop run process `{process_id}`."); -} - -#[cfg(test)] -fn install_dashboard_run_interrupter_for_test( - interrupter: DashboardRunInterrupterForTest, -) -> DashboardRunInterrupterGuardForTest { - let mut slot = DASHBOARD_RUN_INTERRUPTER_FOR_TEST - .lock() - .expect("dashboard run interrupter test hook should not be poisoned"); - let previous = slot.replace(interrupter); - - DashboardRunInterrupterGuardForTest { previous } -} - fn dashboard_event_for_subscription( event: &DashboardBroadcastEvent, subscription: &DashboardClientSubscription, diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 040f8683..3dc1c024 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -1088,7 +1088,7 @@ fn operator_dashboard_accounts_keeps_debug_credit_and_reset_copy_compact() { } #[test] -fn operator_dashboard_omits_watch_and_project_pause_controls() { +fn operator_dashboard_omits_lane_mutation_controls() { let response = dashboard_response(); assert!(!response.contains("function dashboardSubscriptionMatches(subscription)")); @@ -1106,17 +1106,16 @@ fn operator_dashboard_omits_watch_and_project_pause_controls() { assert!(!response.contains(">Resume")); assert!(!response.contains("data-dashboard-control=\"retryRun\"")); assert!(!response.contains(">Retry now")); - assert!(response.contains("data-dashboard-control=\"interruptRun\"")); - assert!(response.contains("aria-label=\"Stop this active Decodex work\"")); - assert!(response.contains("const statusLineParts = [...statusBits];")); - assert!(response.contains("statusLineParts.splice(1, 0, stopControl);")); - assert!(response.contains(".status-line .run-stop-button {")); - assert!(response.contains("width: 18px;")); - assert!(response.contains("border: 0;")); - assert!(response.contains("background: transparent;")); - assert!(response.contains("color: color-mix(in srgb, var(--danger) 86%, var(--text));")); - assert!(response.contains("${statusBits.join(\"\")}")); assert!(!response.contains(" Vec { frame } -fn dashboard_run_interrupter_calls_for_test() -> &'static Mutex> { - static CALLS: std::sync::OnceLock>> = std::sync::OnceLock::new(); - - CALLS.get_or_init(|| Mutex::new(Vec::new())) -} - -fn fake_dashboard_run_interrupter(process_id: u32) -> Result<()> { - dashboard_run_interrupter_calls_for_test() - .lock() - .expect("dashboard run interrupter calls should not be poisoned") - .push(process_id); - - Ok(()) -} - fn read_websocket_json_until( client: &mut TcpStream, frame: &mut Vec, diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 42ee501a..f681e4f7 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -157,11 +157,11 @@ renders active-lane state, protocol activity, liveness, private-evidence referen and local acknowledgement/account controls, but it is not the supported place to author steer, retry, task replacement, or lifecycle mutations. CLI/API is the first operator-control surface for lane control, governed by -[`../spec/lane-control.md`](../spec/lane-control.md). Existing low-level WebSocket -control handlers, including the hard stop fallback, are not the broad lane-control -contract and must not be expanded into dashboard steer/retry/task controls in this -rollout. Project watch, project pause/resume buttons, manual retry controls, and active -lane steer controls are intentionally not shown. `runActivity.activeRunsComplete` +[`../spec/lane-control.md`](../spec/lane-control.md). The browser UI does not show or +accept active-lane stop/interrupt controls, project pause/resume controls, manual retry +controls, or active-lane steer controls. Account-pool selection remains available +because it changes the global Codex account selector, not an active lane. +`runActivity.activeRunsComplete` marks whether a payload is the complete active-run list; subscription-filtered payloads set it to `false`, so consumers must not treat a missing run in that payload as ended. @@ -170,11 +170,7 @@ operator action, snapshots may also include `warning_details` entries with the affected `project_id`, `repo_root`, reason, and next action; for example, a stale registered project whose repo path is no longer a Git checkout can explain the bad project instead of only surfacing `worktree_hygiene_unavailable`. -The existing hard stop fallback, where available, signals the recorded child process -for that run, marks the local attempt interrupted, and releases the local queue lease. -It is an emergency fallback, not the preferred lane-control path. Soft interruption -through CLI/API `turn/interrupt` should become the preferred active-turn control once -implemented. `ack` is dashboard-local acknowledgement only. The socket is not a browser +Dashboard `ack` is dashboard-local acknowledgement only. The socket is not a browser connection to Codex app-server, GitHub, or Linear, and it does not make high-frequency protocol activity durable outside the local operator surface. diff --git a/docs/spec/lane-control.md b/docs/spec/lane-control.md index 8c4ae84d..5a86561e 100644 --- a/docs/spec/lane-control.md +++ b/docs/spec/lane-control.md @@ -34,11 +34,11 @@ agent-facing skills must guide responsible use. | Capability | Contract status | Current implementation evidence | Required behavior | | --- | --- | --- | --- | | Inspect lane state | Supported | `decodex status`, `decodex status --json`, `decodex diagnose --json`, `decodex evidence `, operator snapshots, and dashboard views | Always inspect before mutating or steering. Inspection must not mutate tracker state, runtime DB rows, worktrees, or app-server turns. | -| Project dispatch pause | Supported for future dispatch | `decodex project disable ` and the runtime project enabled flag; dashboard control handlers can also toggle the same flag | Pause prevents new dispatch for the project. It must not kill or rewrite already active lanes. | -| Project dispatch resume | Supported for future dispatch | `decodex project enable ` and the runtime project enabled flag; dashboard control handlers can also toggle the same flag | Resume re-enables future dispatch after the operator has inspected blockers, capacity, and queue state. | +| Project dispatch pause | Supported for future dispatch | `decodex project disable ` and the runtime project enabled flag | Pause prevents new dispatch for the project. It must not kill or rewrite already active lanes. | +| Project dispatch resume | Supported for future dispatch | `decodex project enable ` and the runtime project enabled flag | Resume re-enables future dispatch after the operator has inspected blockers, capacity, and queue state. | | Linear scan request | Supported | `POST /api/linear-scan` with optional `projectId` | Queue a scan for the next control-plane tick while respecting tracker backoff. This is an intake/status refresh request, not an execution command. | | Soft interrupt | Planned CLI/API control; bottom-layer method allowed | Decodex does not currently send `turn/interrupt` from its app-server client | Prefer soft interrupt before hard interruption when the active turn id is known and the app-server capability is present. Soft interrupt requests a graceful turn stop and must leave classification to the runtime. | -| Hard interrupt fallback | Emergency fallback only | Current dashboard `interruptRun` signals the recorded process and marks the attempt `interrupted` | Use only when soft interrupt is unavailable, timed out, or impossible because the process or app-server boundary cannot be reached. Preserve retained worktree evidence and runtime classification. | +| Hard interrupt fallback | Emergency fallback only | No dashboard or CLI/API lane-control path exposes hard interrupt in this rollout; runtime recovery can still classify attempts as `interrupted` | Use only when soft interrupt is unavailable, timed out, or impossible because the process or app-server boundary cannot be reached. Preserve retained worktree evidence and runtime classification. | | Steer active lane | Planned CLI/API control; bottom-layer method must stay broad | Decodex does not currently send `turn/steer` from its app-server client | Pass operator-supplied steer text through the CLI/API when available. Do not narrow the protocol to a fixed set of task-content categories. Apply policy, audit, privacy, and lifecycle guardrails above the protocol. | | Retained resume/retry | Supported through runtime lifecycle | `decodex run `, retry scheduling, retained worktree recovery, and `thread/resume` for same-thread app-server continuation | Resume only when retained worktree, issue, branch, PR, and runtime evidence still prove the same lane. Treat ambiguous lineage as manual attention. | | Manual attention | Supported terminal control path | `decodex:needs-attention`, `issue_comment(kind = "manual_attention")`, and `issue_terminal_finalize(path = "manual_attention")` | Stop automation when policy requires a human decision. Explain the blocker through structured public fields and keep private evidence local. | @@ -150,10 +150,11 @@ Linear unless a schema-controlled public projection explicitly allows it. ## Implementation Status For This Rollout This document specifies capabilities that the CLI/API should expose first. Current code -already supports inspect, project enable/disable, Linear scan requests, retained +already supports inspect, CLI project enable/disable, Linear scan requests, retained resume/retry lifecycle paths, and manual-attention finalization. Current code does not -yet implement Decodex CLI/API controls that send `turn/interrupt` or `turn/steer`, and -it does not expose raw `thread/inject_items` as an operator feature. +expose dashboard lane-mutation controls, does not yet implement Decodex CLI/API +controls that send `turn/interrupt` or `turn/steer`, and does not expose raw +`thread/inject_items` as an operator feature. When implementation work adds the missing CLI/API controls, update this spec, [`app-server.md`](./app-server.md), the operator reference, and the Decodex plugin