Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e2d04d0
feat(tui): kill watched harnesses with confirmation
senamakel Aug 2, 2026
1af5188
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/141
senamakel Aug 2, 2026
d193928
fix(tui): resolve kill target from current selection
senamakel Aug 2, 2026
52fc942
feat(daemon): distinguish task termination from interrupt
senamakel Aug 2, 2026
af9fe0c
test(e2e_screen_stream): wait for killed session to stop
senamakel Aug 2, 2026
d242b6f
fix(tests): clone session id before reporting
senamakel Aug 2, 2026
f3dd305
feat(sdk): advertise screen kill capability and correlate kills
senamakel Aug 2, 2026
e1bc687
fix(hub): require correlation id for task kills
senamakel Aug 2, 2026
4fda36f
test: add correlation_id to Kill messages in tests
senamakel Aug 2, 2026
264ce05
test: cover screen kill capability and stale dispatch handling
senamakel Aug 2, 2026
41ce380
style: format attribute and condition for readability
senamakel Aug 2, 2026
e4ae395
fix(daemon): clarify terminate_task correlation semantics
senamakel Aug 2, 2026
026dd21
fix(daemon): stop probe from waiting on task slot permit
senamakel Aug 2, 2026
697445c
feat(hub): cache worker capabilities for kill checks
senamakel Aug 2, 2026
742878b
feat(daemon): gate screen-kill capability on router installation
senamakel Aug 2, 2026
8aa29eb
fix(sdk): add AtomicBool to daemon runtime imports
senamakel Aug 2, 2026
58a3d0f
fix(runner): scope kill support to the dispatch that negotiated it
senamakel Aug 2, 2026
61ca686
Merge remote-tracking branch 'upstream/main' into pr/141
senamakel Aug 2, 2026
345c762
feat(hub): negotiate screen-kill capability per task run
senamakel Aug 2, 2026
b346590
fix(runner): wait for contact acceptance before starting task
senamakel Aug 2, 2026
8f6e9d0
fix(runner): clean up stale abort registrations on abort
senamakel Aug 2, 2026
c31d785
test: reorder test module declarations alphabetically
senamakel Aug 2, 2026
2adb8c0
test(tinyplace): move budget window default test to capabilities
senamakel Aug 2, 2026
5e69830
fix(hub): reuse abort signal from capability negotiation
senamakel Aug 2, 2026
da5af4f
fix(runner): delegate run to run_inner
senamakel Aug 2, 2026
218c4ed
fix(pty): stop turn only when orchestrator still owns session
senamakel Aug 2, 2026
701c292
fix(hub): return abort handle even when capability negotiation fails
senamakel Aug 2, 2026
c62e32a
fix(control_socket): negotiate screen kill before dispatch
senamakel Aug 2, 2026
04d256e
fix(hub): pass visible task id through negotiated runs
senamakel Aug 2, 2026
c2b1cfc
refactor(control_socket): simplify run_negotiated call
senamakel Aug 2, 2026
35072e9
refactor(keys): extract kill arming and require unmodified y
senamakel Aug 2, 2026
dd64e13
fix(hub): use wire task id when killing screen tasks
senamakel Aug 2, 2026
c3182ba
fix(tui): ignore non-running tasks in input routing
senamakel Aug 2, 2026
3f92d04
fix(keys): restrict kill command to running tasks
senamakel Aug 2, 2026
48b27e1
test(tui): use running-task fixture in kill confirmation tests
senamakel Aug 2, 2026
5944937
fix(tests): update TuiEvent import path in test helper
senamakel Aug 2, 2026
c755bfe
test(tui): update running task fixture setup
senamakel Aug 2, 2026
b55896b
Merge remote-tracking branch 'upstream/main' into pr/141
senamakel Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/sdk/src/daemon/capabilities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub async fn probe_capabilities(options: ProbeOptions) -> AgentCapabilities {
// Filled in by the daemon from its own workflow store: this probe
// describes the harness, not what has been authored on top of it.
workflows: Vec::new(),
screen_kill: true,
Comment thread
senamakel marked this conversation as resolved.
Outdated
// Deterministic digest of CLAUDE.md/AGENTS.md/README.md — the summary
// of last resort so a failed probe still carries project context.
summary: dir.fallback_summary.clone(),
Expand Down
12 changes: 12 additions & 0 deletions src/sdk/src/daemon/providers/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub type ExistsOnPath = Box<dyn Fn(&str) -> bool + Send + Sync>;
#[derive(Clone, Default)]
pub struct Abort {
flag: Arc<AtomicBool>,
terminate: Arc<AtomicBool>,
notify: Arc<Notify>,
}

Expand All @@ -55,6 +56,17 @@ impl Abort {
self.notify.notify_waiters();
}

/// Signal cancellation that must also terminate the serving harness.
pub fn terminate(&self) {
self.terminate.store(true, Ordering::SeqCst);
self.abort();
}

/// Whether cancellation requested termination of the serving harness.
pub fn is_terminated(&self) -> bool {
self.terminate.load(Ordering::SeqCst)
}

/// Whether cancellation has been signalled.
pub fn is_aborted(&self) -> bool {
self.flag.load(Ordering::SeqCst)
Expand Down
17 changes: 17 additions & 0 deletions src/sdk/src/daemon/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,23 @@ impl DaemonRuntime {
.and_then(|task| task.session_id.clone())
}

/// Terminate the running task identified by sender, id, and dispatch receipt.
///
/// The signal remains bound to the task record while the map is locked. The
/// correlation check prevents a delayed request from terminating a later
/// dispatch that reused the same task id.
pub fn terminate_task(&self, from: &str, task_id: &str, correlation_id: &str) -> bool {
let running = self.inner.running.lock().unwrap();
let Some(task) = running.get(&Self::task_key(from, task_id)) else {
return false;
};
if task.correlation_id.as_deref() != Some(correlation_id) {
return false;
}
task.abort.terminate();
true
}

/// Record the session an executor opened for a running task.
pub(super) fn record_task_session(&self, key: &str, session_id: String) {
if let Some(task) = self.inner.running.lock().unwrap().get_mut(key) {
Expand Down
11 changes: 3 additions & 8 deletions src/sdk/src/daemon/task_loop/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,9 @@ impl DaemonRuntime {
let abort = Abort::new();
let controller_id = self.register_controller(abort.clone());
let accessible_dirs = self.inner.accessible_dirs.lock().unwrap().clone();
// Compete for the concurrency budget like a task.
let permit = self
.inner
.slots
.acquire()
.await
.expect("semaphore is never closed");
// Control-plane negotiation must not wait behind the harness-task slot
// it may be needed to terminate. The probe is separately serialized by
// the capability cache lock and bounded by its own timeout.
let capabilities = probe_capabilities(ProbeOptions {
provider,
run_task: self.inner.run_task.clone(),
Expand All @@ -91,7 +87,6 @@ impl DaemonRuntime {
router: self.inner.config.router.clone(),
})
.await;
drop(permit);
self.unregister_controller(controller_id);
*guard = Some(capabilities.clone());
capabilities
Expand Down
22 changes: 22 additions & 0 deletions src/sdk/src/hub/handle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ impl HubHandle {
sent
}

/// Ask `worker` to kill the harness serving `task_id`.
///
/// The worker resolves the task against the authenticated sender before it
/// touches a PTY, so this cannot be used to kill another controller's work.
pub async fn kill(&self, worker: &str, task_id: &str) -> Result<(), String> {
if !self.runner.supports_screen_kill(worker).await {
return Err("worker does not advertise harness termination support".to_string());
}
let correlation_id = self
.runner
.correlation_for(worker, task_id)
.await
.ok_or_else(|| format!("task {task_id} is no longer running on {worker}"))?;
let body =
crate::tinyplace::encode_screen_message(&crate::tinyplace::ScreenMessage::Kill {
task_id: task_id.to_string(),
correlation_id,
Comment thread
senamakel marked this conversation as resolved.
});
(self.log)(&format!("hub: killing task {task_id} on {worker}"));
self.relay.send(worker, &body).await
}

/// Build a handle from its wiring.
pub(super) fn new(wiring: HandleWiring) -> Self {
HubHandle {
Expand Down
8 changes: 7 additions & 1 deletion src/sdk/src/hub/runner/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ impl TaskRunner {
return Err(RunError::Transport(error));
}
match tokio::time::timeout(self.ack_window, receiver).await {
Ok(Ok(Ok(caps))) => return Ok(caps),
Ok(Ok(Ok(caps))) => {
self.capabilities
.lock()
.await
.insert(address.to_string(), caps.clone());
Comment thread
senamakel marked this conversation as resolved.
return Ok(caps);
}
Ok(Ok(Err(error))) => return Err(RunError::Worker(error)),
Ok(Err(_)) => {
return Err(RunError::Transport(
Expand Down
22 changes: 22 additions & 0 deletions src/sdk/src/hub/runner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ impl TaskRunner {
waiters,
system_info_waiters,
capabilities_waiters,
capabilities: Arc::new(Mutex::new(HashMap::new())),
aborts: Arc::new(std::sync::Mutex::new(HashMap::new())),
counter: AtomicU64::new(0),
ack_window,
Expand Down Expand Up @@ -210,6 +211,26 @@ impl TaskRunner {
}
}

/// Return the active dispatch receipt for a worker/task pair.
pub async fn correlation_for(&self, worker: &str, task_id: &str) -> Option<String> {
self.waiters
.lock()
.await
.iter()
.find(|(_, waiter)| waiter.from == worker && waiter.task_id == task_id)
.map(|(correlation, _)| correlation.clone())
}

/// Return whether the worker advertised screen termination during a
/// capability negotiation completed before its current dispatch.
pub async fn supports_screen_kill(&self, worker: &str) -> bool {
self.capabilities
.lock()
.await
.get(worker)
.is_some_and(|capabilities| capabilities.screen_kill)
}

/// Cancel every dispatch this runner has in flight.
///
/// For a caller that owns a runner serving one piece of work and wants to
Expand Down Expand Up @@ -311,6 +332,7 @@ impl TaskRunner {
self.waiters.lock().await.insert(
cid.clone(),
Waiter {
task_id: req.task_id.clone(),
from: req.worker_address.clone(),
reply: tx,
status: status.clone(),
Expand Down
4 changes: 4 additions & 0 deletions src/sdk/src/hub/runner/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
use super::*;
/// A registered dispatch awaiting its terminal frame.
pub(super) struct Waiter {
/// The task id this dispatch carries on the wire.
pub(super) task_id: String,
/// The worker address this dispatch was sent to — the only sender whose
/// frames may settle it. See [`Probe::from`].
pub(super) from: String,
Expand Down Expand Up @@ -68,6 +70,8 @@ pub struct TaskRunner {
pub(super) system_info_waiters: SystemInfoWaiters,
/// Capability probes waiting for a worker's `capabilities_result`.
pub(super) capabilities_waiters: CapabilitiesWaiters,
/// Last successfully negotiated capabilities for each worker address.
pub(super) capabilities: Arc<Mutex<HashMap<String, AgentCapabilities>>>,
/// Abort signals for in-flight dispatches, keyed by orchestrator-facing task
/// id; [`abort_task`](Self::abort_task) notifies one to cancel its dispatch.
pub(super) aborts: Aborts,
Expand Down
14 changes: 8 additions & 6 deletions src/sdk/src/hub/socket/task_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ pub(super) async fn handle_task_run(
return;
};

// Negotiate once before dispatch. Besides informing automatic provider
// selection, this records static control-plane support so an emergency kill
// never waits behind a fresh probe of a wedged worker.
let capabilities = runner.capabilities(&worker_address).await.ok();
Comment thread
senamakel marked this conversation as resolved.
Outdated

// An explicit provider is authoritative. Only an untargeted task consults
// the subscription strategy, and a failed/unknown budget probe falls open
// to the daemon's own configured default.
Expand All @@ -174,12 +179,9 @@ pub(super) async fn handle_task_run(
if strategy == crate::runtime::SubscriptionRoutingStrategy::Manual {
None
} else {
match runner.capabilities(&worker_address).await {
Ok(capabilities) => {
super::super::roster::subscription_for_strategy(&capabilities, strategy)
}
Err(_) => None,
}
capabilities.as_ref().and_then(|capabilities| {
super::super::roster::subscription_for_strategy(capabilities, strategy)
})
}
}
};
Expand Down
12 changes: 12 additions & 0 deletions src/sdk/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ pub trait Runtime: Send + Sync {
Box::pin(async { Ok(()) })
}

/// Kill the harness serving `task_id` on `worker`.
///
/// A no-op success without a hub. Interactive callers are responsible for
/// confirming the destructive action before invoking this method.
fn kill_task(
&self,
_worker: String,
_task_id: String,
) -> BoxFuture<'static, anyhow::Result<()>> {
Box::pin(async { Ok(()) })
}

/// Tell the orchestrator a harness has been handed back, with the brief.
///
/// An **error** by default rather than a silent success, unlike
Expand Down
12 changes: 12 additions & 0 deletions src/sdk/src/runtime/openhuman/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,18 @@ impl Runtime for OpenHumanRuntime {
})
}

fn kill_task(&self, worker: String, task_id: String) -> BoxFuture<'static, anyhow::Result<()>> {
let hub = self.hub();
Box::pin(async move {
let Some(hub) = hub else {
return Ok(());
};
hub.kill(&worker, &task_id)
.await
.map_err(|e| anyhow::anyhow!(e))
})
}

fn worker_op(&self, op: crate::runtime::WorkerOp) -> BoxFuture<'static, anyhow::Result<()>> {
let hub = self.hub();
Box::pin(async move {
Expand Down
14 changes: 14 additions & 0 deletions src/sdk/src/tinyplace/frames/tests/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,20 @@ fn empty_budgets_and_readiness_are_omitted_on_the_wire() {
let value = serde_json::to_value(&caps).unwrap();
assert!(value.get("budgets").is_none());
assert!(value.get("readiness").is_none());
assert!(value.get("screenKill").is_none());
}

#[test]
fn screen_kill_support_is_additive_and_defaults_off_for_older_workers() {
Comment thread
senamakel marked this conversation as resolved.
Outdated
let older = parse_agent_capabilities(r#"{"providers":["claude"]}"#).unwrap();
assert!(!older.screen_kill);

let current = crate::tinyplace::AgentCapabilities {
screen_kill: true,
..Default::default()
};
let value = serde_json::to_value(&current).unwrap();
assert_eq!(value["screenKill"], true);
}

#[test]
Expand Down
10 changes: 10 additions & 0 deletions src/sdk/src/tinyplace/frames/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,16 @@ pub struct AgentCapabilities {
/// field. Same backward-compatibility contract as the two vectors above.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workflows: Vec<WorkflowAdvert>,
/// Whether this worker accepts task-correlated harness termination requests.
///
/// Older workers omit the field and therefore deserialize as `false`, so an
/// upgraded controller never sends them an unknown screen control message.
#[serde(
rename = "screenKill",
default,
skip_serializing_if = "std::ops::Not::not"
)]
pub screen_kill: bool,
}

/// Fleet-safe description of one named custom harness.
Expand Down
4 changes: 4 additions & 0 deletions src/sdk/src/tinyplace/screen/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ fn messages_round_trip_through_the_envelope() {
ScreenMessage::Unsubscribe {
task_id: "w_1".into(),
},
ScreenMessage::Kill {
task_id: "w_1".into(),
correlation_id: "cyc/w_1/0".into(),
},
ScreenMessage::Ack {
task_id: "w_1".into(),
seq: 418,
Expand Down
14 changes: 11 additions & 3 deletions src/sdk/src/tinyplace/screen/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,9 @@ pub struct ScreenFrame {

/// Everything that can cross this protocol, in both directions.
///
/// There is no `input` and no `resize`: the viewer never reaches back into the
/// session, and the sender's geometry is authoritative. Both are additive later
/// if that changes.
/// There is no `input` and no `resize`: the viewer cannot steer the session,
/// and the sender's geometry is authoritative. The one control operation is an
/// explicit, task-scoped kill used by an operator to recover a hung harness.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScreenMessage {
Expand All @@ -199,6 +199,14 @@ pub enum ScreenMessage {
/// The task to stop watching.
task_id: String,
},
/// Viewer → sender: kill the harness serving an owned running task.
Kill {
/// The task whose harness should be killed.
task_id: String,
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
/// The unique dispatch receipt, preventing a delayed kill from matching
/// a later dispatch that reused the task id.
correlation_id: String,
},
/// Viewer → sender: the highest sequence the viewer holds, which the next
/// diff may be taken from.
Ack {
Expand Down
11 changes: 11 additions & 0 deletions src/tui/src/event_loop/cmd_dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ pub(super) fn run_cmd(
}
});
}
Cmd::KillTask { worker, task_id } => {
let rt = runtime.clone();
let tx = msg_tx.clone();
tokio::spawn(async move {
let status = match rt.kill_task(worker, task_id.clone()).await {
Ok(()) => format!("Kill requested for {task_id}"),
Err(e) => format!("Cannot kill {task_id}: {e}"),
};
let _ = tx.send(AppMsg::Status(status));
});
}
Cmd::StartLocalHost { host, index } => {
let Some(spawner) = local_hosts.cloned() else {
let _ = msg_tx.send(AppMsg::Status(
Expand Down
2 changes: 1 addition & 1 deletion src/tui/src/ui/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ impl App {
}

/// The `(worker address, task id)` the current selection asks to watch.
fn watch_target(&self) -> Option<(String, String)> {
pub(super) fn watch_target(&self) -> Option<(String, String)> {
// Only on the Agents tab: leaving it releases the subscription.
if self.tab() != "Agents" {
return None;
Expand Down
9 changes: 9 additions & 0 deletions src/tui/src/ui/app/keys/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ impl App {
let alt = k.modifiers.contains(KeyModifiers::ALT);

match k.code {
KeyCode::Char('K') => {
if let Some(target) = self.watch_target() {
self.kill_armed = Some(target);
self.set_status("Kill this harness? y confirm · any other key cancels");
Comment thread
senamakel marked this conversation as resolved.
Outdated
} else {
self.set_status("Select a running harness task first");
}
AgentsKey::Handled(None)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The bare arrows are the point of having focus at all.
KeyCode::Up | KeyCode::Down => {
self.agent_scroll = 0;
Expand Down
11 changes: 11 additions & 0 deletions src/tui/src/ui/app/keys/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ impl App {
let shift = k.modifiers.contains(KeyModifiers::SHIFT);
let alt = k.modifiers.contains(KeyModifiers::ALT);

// Killing a harness can lose in-progress work. Once armed, the prompt
// owns exactly one keypress: only a deliberate `y` proceeds.
if let Some((worker, task_id)) = self.kill_armed.take() {
if k.code == KeyCode::Char('y') {
self.set_status(format!("Killing harness for {task_id}…"));
return Some(Cmd::KillTask { worker, task_id });
Comment thread
senamakel marked this conversation as resolved.
}
self.set_status("Harness kill cancelled");
return None;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The hand-back question outranks even the attached harness, and has to:
// it is asked *while still attached*, because releasing the keyboard
// before it is answered would hide the pane the question is about. So
Expand Down
Loading
Loading