Skip to content

Commit 019f154

Browse files
feat(agent): attach to the canonical PTY by ID
agent-identity: dev3.direct.omp.2ahzpbs3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
1 parent f3db35e commit 019f154

5 files changed

Lines changed: 579 additions & 12 deletions

File tree

src/eval_run.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use crate::expand::expand_catalog;
1818
use crate::flapping::FlappingCap;
1919
#[cfg(test)]
2020
use crate::reconcile::compile_generated_ding_tasks;
21-
use crate::reconcile::{TaskCompileContext, compile_generated_tasks, reconcile};
21+
use crate::reconcile::{TaskCompileContext, compile_generated_tasks, reconcile, task_runtime_id};
2222
use crate::run::{Runner, SystemRunner, UpReport, detect_host, execute};
2323
use agent_spec::spec::{AgentDesiredState, AgentSpec, JobType, Task, TaskKind, TaskLifecycle};
2424

@@ -173,12 +173,6 @@ fn admitted_route<'a>(
173173
.unwrap_or_else(|| panic!("strict canonical admission did not freeze route for `{id}`"))
174174
}
175175

176-
fn task_runtime_id(spec: &AgentSpec, task: &Task, host: &str) -> String {
177-
task.id
178-
.clone()
179-
.unwrap_or_else(|| format!("{}.{}", spec.agent_id(host), task.name))
180-
}
181-
182176
fn task_is_launchable(task: &Task) -> bool {
183177
task.command.is_some() || task.argv.is_some()
184178
}

src/main.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,23 @@ enum AgentCmd {
438438
/// The old address stops resolving as soon as the new catalog generation is visible and may be
439439
/// claimed by another subject. There is no alias, redirect, history, or expiry.
440440
Address(AddressArgs),
441+
/// Attach this terminal to a running agent's canonical PTY session.
442+
///
443+
/// Resolves the agent's canonical `agent` PTY task, waits (bounded) for positive evidence that
444+
/// its session is live, then **replaces** this process with `pty attach --force <runtime-id>`.
445+
/// From that point `pty` owns the tty, signals, detach keys, and the exit status; st2 adds
446+
/// nothing to the session itself.
447+
Attach {
448+
/// Exact catalog-global agent ID. Never parsed as an address.
449+
#[arg(long, value_name = "AGENT-ID")]
450+
id: String,
451+
/// Host used to resolve declarations whose host is omitted. Defaults to the local hostname.
452+
#[arg(long)]
453+
host: Option<String>,
454+
/// Seconds to wait for the session to become live before giving up.
455+
#[arg(long, value_name = "SECONDS")]
456+
wait: Option<u64>,
457+
},
441458
/// Author reversible whole-agent lifecycle intent in one canonical KDL declaration.
442459
DesiredState {
443460
/// Exact catalog-global agent ID of the declaration to edit.
@@ -1416,6 +1433,7 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result<
14161433
presentation_cmd(st2::agent_author::PresentationField::Description, args)
14171434
}
14181435
Command::Agent(AgentCmd::Address(args)) => address_cmd(args),
1436+
Command::Agent(AgentCmd::Attach { id, host, wait }) => attach_cmd(&id, host, wait),
14191437
Command::Agent(AgentCmd::DesiredState {
14201438
id,
14211439
state,
@@ -2109,6 +2127,117 @@ fn pty_cmd(args: &[String]) -> Result<()> {
21092127
Err(anyhow::anyhow!("failed to exec `pty`: {err}"))
21102128
}
21112129

2130+
/// How long `st2 agent attach` waits for positive evidence that the target session is live. st2
2131+
/// owns this bound so callers that just spawned an agent (`new-agent`) need no flag at all.
2132+
const ATTACH_READY_WAIT: Duration = Duration::from_secs(30);
2133+
2134+
/// Readiness poll cadence. A pidfile read plus `kill(pid, 0)` is cheap enough to check often, and
2135+
/// the loop is deadline-clamped so the last sleep never overshoots `--wait`.
2136+
const ATTACH_READY_POLL: Duration = Duration::from_millis(100);
2137+
2138+
/// `st2 agent attach --id <AGENT-ID>` — hand this terminal to a running agent's canonical PTY.
2139+
///
2140+
/// Selection is exact-ID only: an address must never resolve here, because attaching to the wrong
2141+
/// live session is a destructive mistake a route cutover could otherwise cause. st2's whole added
2142+
/// surface is resolve → wait → exec: it derives the canonical `agent` task's runtime id from the
2143+
/// declaration (never guesses it), waits for *positive* liveness evidence, then **replaces** this
2144+
/// process with `pty attach --force <runtime-id>`. Everything terminal-shaped — signals, detach,
2145+
/// restart prompting, exit status — is `pty`'s from that instant on. A wait timeout is a human
2146+
/// diagnostic only; nothing is authored, recorded, or retried.
2147+
fn attach_cmd(id: &str, host: Option<String>, wait: Option<u64>) -> Result<()> {
2148+
use std::os::unix::process::CommandExt;
2149+
2150+
let root = catalog_root_for_env()?;
2151+
let host = host.unwrap_or_else(detect_host);
2152+
let wait = wait.map(Duration::from_secs).unwrap_or(ATTACH_READY_WAIT);
2153+
2154+
let runtime_id = {
2155+
let _catalog_lock = st2::CatalogLock::shared(&root)
2156+
.context("acquire shared catalog-authoring lock for agent attach")?;
2157+
let found = st2::discover_strict(&root);
2158+
// Attaching selects one exact declaration; a partially readable catalog could hide the very
2159+
// subject named, so refuse rather than attach to whatever did parse.
2160+
if !found.errors.is_empty() {
2161+
let errors = found
2162+
.errors
2163+
.iter()
2164+
.map(|error| format!("{}: {}", error.path.display(), error.message))
2165+
.collect::<Vec<_>>()
2166+
.join("; ");
2167+
anyhow::bail!(
2168+
"cannot attach to an exact Agent Spec while catalog discovery has {} error(s): {errors}",
2169+
found.errors.len()
2170+
);
2171+
}
2172+
let spec = resolve_agent_spec(&found, &st2::AgentSelector::id(id), &host)?;
2173+
let agent_id = spec.agent_id(&host);
2174+
let spec_host = spec.resolved_host(&host);
2175+
anyhow::ensure!(
2176+
spec_host == host,
2177+
"agent '{agent_id}' is homed on host '{spec_host}'; its pty registry is not observable \
2178+
from '{host}' — attach from that host (or pass --host '{spec_host}' there)"
2179+
);
2180+
let mut candidates = spec
2181+
.tasks
2182+
.iter()
2183+
.filter(|task| !task.derived && task.name == "agent");
2184+
let task = candidates
2185+
.next()
2186+
.with_context(|| format!("agent '{agent_id}' has no canonical `agent` task"))?;
2187+
anyhow::ensure!(
2188+
candidates.next().is_none(),
2189+
"agent '{agent_id}' has more than one canonical `agent` task"
2190+
);
2191+
anyhow::ensure!(
2192+
task.kind == st2::TaskKind::Pty,
2193+
"agent '{agent_id}' canonical task is not a PTY; there is no terminal to attach to"
2194+
);
2195+
st2::reconcile::task_runtime_id(spec, task, &host)
2196+
};
2197+
2198+
// The catalog's own registry, exactly as the runner resolves it. This one value is both probed
2199+
// for liveness AND handed to `pty` below, so the session st2 proved alive is provably the
2200+
// session `pty` then looks for.
2201+
let pty_root = st2::agents::probe_pty_root(&root);
2202+
// A `--wait` large enough to overflow the monotonic clock is a typo, not a request for an
2203+
// unbounded wait: refuse with the bound named rather than panicking inside the runtime.
2204+
let deadline = std::time::Instant::now().checked_add(wait).with_context(|| {
2205+
format!(
2206+
"--wait {}s overflows the monotonic clock; pass a bound this machine can represent",
2207+
wait.as_secs()
2208+
)
2209+
})?;
2210+
loop {
2211+
if ding::session_liveness_in(&pty_root, &runtime_id) == st2::harness_state::SessionLiveness::Alive
2212+
{
2213+
break;
2214+
}
2215+
let now = std::time::Instant::now();
2216+
if now >= deadline {
2217+
anyhow::bail!(
2218+
"pty session '{runtime_id}' is not live after {}s; nothing was attached — check \
2219+
`st2 tasks --json` and run `st2 up` if the agent is not running",
2220+
wait.as_secs()
2221+
);
2222+
}
2223+
std::thread::sleep(deadline.saturating_duration_since(now).min(ATTACH_READY_POLL));
2224+
}
2225+
2226+
let mut cmd = std::process::Command::new("pty");
2227+
// `--force`: the caller is themselves inside a pty session, which `pty attach` otherwise
2228+
// refuses to nest. `--no-restart` is deliberately not passed — pty owns restart semantics.
2229+
cmd.args(["attach", "--force", &runtime_id]);
2230+
with_bus_env(&mut cmd, &root);
2231+
// `with_bus_env` renders PTY_ROOT from the catalog declaration alone, while the liveness probe
2232+
// resolves it the way the *runner* does — an ambient PTY_ROOT outranks the declaration there.
2233+
// Pin the probed root so the two can never disagree: otherwise a caller with an exported
2234+
// PTY_ROOT would have st2 prove one session alive and hand `pty` a different registry.
2235+
cmd.env("PTY_ROOT", &pty_root);
2236+
// exec() only returns on failure (e.g. `pty` not on PATH).
2237+
let err = cmd.exec();
2238+
Err(anyhow::anyhow!("failed to exec `pty`: {err}"))
2239+
}
2240+
21122241
/// `st2 shell [<args>…]` — drop into `$SHELL` with the native catalog environment set. The general
21132242
/// form of `st2 pty`.
21142243
fn shell_cmd(args: &[String]) -> Result<()> {

src/reconcile.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
1616
use anyhow::{Context, Result};
1717

1818
use agent_spec::spec::{
19-
AgentSpec, DeliveryTransport, Driver, TaskKind, TaskLifecycle, stream_name_of_task,
19+
AgentSpec, DeliveryTransport, Driver, Task, TaskKind, TaskLifecycle, stream_name_of_task,
2020
};
2121
use crate::supervisor_chain::{resolve_edge, supervisor_edge};
2222
use crate::AddressBook;
@@ -84,6 +84,16 @@ impl TaskCompileContext {
8484
}
8585
}
8686

87+
/// The single rule projecting a declared task onto its runtime session id: an explicitly pinned
88+
/// `id` wins, otherwise the id is derived as `<agent-id>.<task-name>`. Every reader that has to
89+
/// name a live session — runtime inventory, eval projection, `agent attach` — resolves it here so
90+
/// the derivation cannot drift between the writer and its observers.
91+
pub fn task_runtime_id(spec: &AgentSpec, task: &Task, this_host: &str) -> String {
92+
task.id
93+
.clone()
94+
.unwrap_or_else(|| format!("{}.{}", spec.agent_id(this_host), task.name))
95+
}
96+
8797
/// Compile every runner-owned launch marker into an exact invocation of this st2 binary.
8898
pub fn compile_generated_tasks(
8999
specs: &mut [AgentSpec],

src/task_inventory.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,10 +311,7 @@ pub fn inventory(
311311
if spec.desired_state.is_running() && task.command.is_none() && task.argv.is_none() {
312312
continue;
313313
}
314-
let runtime_id = task
315-
.id
316-
.clone()
317-
.unwrap_or_else(|| format!("{agent_id}.{}", task.name));
314+
let runtime_id = crate::reconcile::task_runtime_id(spec, task, host);
318315
runtime_owners
319316
.entry(runtime_id.clone())
320317
.or_default()

0 commit comments

Comments
 (0)