Skip to content

Commit d8d8f21

Browse files
feat(request): add idempotent service principals
agent-session-id: dev3.dotfiles-cos-misc-issue-1329-upstream agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /nix/store/qlk5xbdfmj5nn1q145j0fx73f2pmdjj7-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/2km8dbiyv3wc484l1hd8n2lh65121qvq-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty
1 parent b868a07 commit d8d8f21

8 files changed

Lines changed: 1257 additions & 0 deletions

File tree

INVARIANTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ materialization, messaging, DING, or presence must preserve them.
1313
| **Bounded restart diagnostics** | Relaunching an exec task preserves the just-finished log as one prior generation while bounding retained diagnostics to current plus prior. Final retirement removes the PID and both logs. | `tests/exec_backend.rs::exec_restart_reap_keeps_bounded_diagnostics_and_final_remove_cleans_them`; `tests/run.rs::up_once_finally_removes_dead_retired_tasks_without_restarting_them` |
1414
| **Derived companion lifecycle** | A generated DING starts only with an eligible canonical agent, is suppressed or stopped while that target is held, suspended, unavailable, retired, or terminally parked, and remains coupled without changing explicit sibling task behavior. Healthy compact startup still launches both tasks in one pass, and targeted reconciliation holds a missing generated DING rather than broadening to its agent. | `tests/run.rs::fresh_compact_agent_launches_with_its_derived_ding`; `tests/run.rs::absent_adopt_only_compact_agent_holds_its_derived_ding`; `tests/run.rs::held_adopt_only_compact_agent_stops_its_live_derived_ding`; `tests/run.rs::failed_compact_agent_restart_stops_its_live_derived_ding`; `tests/run.rs::failed_compact_agent_reap_stops_its_live_derived_ding`; `tests/run.rs::parked_compact_agent_stops_its_live_derived_ding`; `tests/run.rs::parked_compact_agent_does_not_relaunch_its_exited_derived_ding`; `tests/run.rs::retired_compact_agent_stops_agent_and_derived_ding`; `tests/run.rs::suspend_and_resume_cover_derived_ding_sibling_continuity_and_inbox_retention`; `tests/run.rs::selected_missing_derived_ding_is_held_without_broadening_to_its_agent`; `tests/run.rs::up_once_collects_spawn_errors_without_aborting` |
1515
| **Exactly-once-safe native bus** | Messages use stable `<unix-ms>-<rand6>.md` files. An archive filename is a durable receipt that shadows and cleans restored inbox replicas and makes repeated archive cleanup idempotent. | `src/message.rs::filename_grammar`; `src/message.rs::archive_receipt_suppresses_and_idempotently_cleans_a_restored_inbox_copy`; `tests/message.rs` |
16+
| **Idempotent service requests** | A declared non-agent service principal publishes one exact JSON request per caller-supplied idempotency key to a canonical Agent Spec inbox. Concurrent or crash-replayed publication reuses the reserved filename; conflicting key reuse fails. The typed reply routes to the principal's canonical inbox without an Agent Spec identity or orphan mailbox. | `tests/request_cli.rs::stable_request_key_atomically_deduplicates_one_canonical_agent_message`; `tests/request_cli.rs::concurrent_replays_publish_exactly_one_request`; `tests/request_cli.rs::typed_reply_routes_to_the_principal_and_status_is_a_tagged_json_union`; `tests/request_cli.rs::request_api_rejects_agent_impersonation_and_unknown_flat_principals` |
1617
| **Fail-closed observed native DING** | Each unread message becomes one normalized `[DING]` frame. Fresh delivery records ownership, then preserves the one combined bracketed-paste, 0.5 second delay, and Return transaction. PTY and Return success are transport only: `Delivered` additionally requires adapter classification of the expected notice text in a submitted-prompt or queued-message pattern while the lowest live composer is empty or an accepted idle placeholder. Retry never re-pastes and may send one bare Return only after two adjacent `RetainedSafe` observations. A maintained adapter's positive `NotRetained` observation releases only an already archived staged head; unread, blocked, timed-out, errored, unknown, and unrecognized states retain ownership and later FIFO work remains blocked. Ownership prevents duplicate paste across command failures, receipt ambiguity, archive races, and restart adoption without letting a vanished archived head block FIFO indefinitely. Startup backlog otherwise becomes one generic recovery DING; new arrivals remain FIFO; `busy` delivers immediately; only fresh `dnd` defers. | `src/ding/mod.rs::poke_text_normalizes_and_bounds_untrusted_fields`; `src/ding/mod.rs::malicious_controls_cannot_escape_the_single_paste_frame`; `src/ding/mod.rs::pty_delivery_uses_face607_delay_order_and_seconds`; `src/ding/mod.rs::maintained_composer_classifiers_require_exact_idle_state`; `src/ding/mod.rs::successful_transport_with_retained_or_unproven_pixels_is_not_delivered`; `src/ding/mod.rs::ambiguous_transport_receipt_and_retry_errors_retain_staged_ownership`; `src/ding/mod.rs::adapter_recognized_notice_with_an_empty_live_composer_is_a_positive_receipt`; `src/ding/mod.rs::staged_retry_submits_only_retained_safe_and_requires_a_receipt`; `src/ding/mod.rs::staged_retry_keeps_unproven_and_retained_blocked_owned`; `src/ding/mod.rs::staged_ownership_survives_archive_and_never_repastes`; `src/ding/mod.rs::archived_not_retained_releases_fifo_without_repasting_owned_notice`; `src/ding/mod.rs::unread_not_retained_keeps_fifo_ownership_without_repasting`; `src/ding/mod.rs::pty_commands_have_a_real_outer_timeout`; `src/ding/mod.rs::session_watch_has_startup_grace_debounce_and_live_reset`; `src/ding/mod.rs::new_arrivals_is_fifo_and_archive_receipts_prevent_reding`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry`; `src/ding/mod.rs::startup_recovery_notice_retries_in_memory`; `src/ding/mod.rs::startup_backlog_gets_one_generic_recovery_then_new_arrivals_poke` |
1718
| **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` |
1819
| **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` |

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,40 @@ intentional catalog-less fallback used by isolated folder evals. In a catalog-ba
416416
`st2 message ls` rejects an absent identity; recovery inspection of a deliberately orphaned flat
417417
box must be explicit with `st2 message ls <identity> --orphan` (and optionally `--archive`).
418418

419+
Services use a separate declared request surface; they do not borrow an Agent
420+
Spec identity. Declare the endpoint without creating a task:
421+
422+
```kdl
423+
// <catalog>/principals/dev3/hypermerge/principal.kdl
424+
principal "hypermerge" host="dev3"
425+
```
426+
427+
Then publish once, let the addressed agent reply from its normal inbox, and
428+
observe the typed result. Bodies are JSON and `--tag` is a repeatable
429+
`key=value` map:
430+
431+
```sh
432+
st2 request send dev3.repair-agent \
433+
--as dev3.hypermerge \
434+
--idempotency-key 'escalate:repo#7:abc' \
435+
--tag kind=hypermerge.escalation \
436+
-m '{"candidate":"abc"}' --json
437+
438+
st2 request read <request-filename> --json
439+
440+
st2 request reply <request-filename> \
441+
--tag outcome=needs-human \
442+
-m '{"outcome":"needs-human"}' --json
443+
444+
st2 request status --as dev3.hypermerge \
445+
--idempotency-key 'escalate:repo#7:abc' --json
446+
```
447+
448+
The key atomically reserves one message filename and exact envelope before
449+
publication. Exact retries return that filename with `deduplicated: true`; a
450+
different request under the same key fails. Replies route only to the declared
451+
principal's canonical `resources/inbox`, never a flat orphan mailbox.
452+
419453
Adopters should cut directly to the native layout. Before launching a migrated identity, install and
420454
verify hooks, validate and materialize its hand-authored declaration, stop any predecessor transport,
421455
and decide how any unread predecessor backlog will be archived or forwarded. Never run predecessor

docs/vrs/spec.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,32 @@ not duplicated in tags. Clearing removes only the owned native value or tag,
151151
and unrelated PTY metadata is preserved. Repeating the same projection is a
152152
no-op. Failure is reported and retried by the ordinary loop, never converted
153153
into launch, teardown, garbage collection, replacement, or flapping authority.
154+
## Service-principal request transport
155+
156+
A non-agent service that needs bounded judgment work may declare only its bus
157+
endpoint at
158+
`principals/<host>/<identity>/principal.kdl`:
159+
160+
```kdl
161+
principal "hypermerge" host="dev3"
162+
```
163+
164+
The declaration creates no task, presence, persona, or Agent Spec authority.
165+
Its content must exactly match its canonical path. `st2 request send` accepts
166+
only such a principal as the caller and only a discovered Agent Spec as the
167+
recipient, so a service neither impersonates an agent nor depends on the flat
168+
orphan-recovery layout.
169+
170+
The caller supplies an idempotency key, a JSON body, and typed string tags.
171+
Before the native message is published, st2 atomically reserves one random
172+
canonical message filename and the exact request envelope under the
173+
principal's `resources/request-state/`. Replays finish that same publication;
174+
reuse of the key with different caller, recipient, body, or tags fails. An
175+
agent's `st2 request reply` similarly publishes at most one typed reply to the
176+
principal's canonical inbox. `st2 request status --json` returns the tagged
177+
union `pending | replied`, suitable for a durable workflow to observe between
178+
its own durable waits. st2 provides no wait loop or timer and does not turn the
179+
request into agent lifecycle authority.
154180

155181
## Resource bindings (R20-R21)
156182

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub mod materialize;
2424
pub mod message;
2525
pub mod pretrust;
2626
pub mod reconcile;
27+
pub mod request;
2728
pub mod resource;
2829
pub mod run;
2930
pub mod service;

src/main.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ enum Command {
6969
/// The stable wire format is a `<unix-ms>-<rand6>.md` Markdown file.
7070
#[command(subcommand)]
7171
Message(MessageCmd),
72+
/// Idempotent JSON request/reply transport for declared non-agent service principals.
73+
#[command(subcommand)]
74+
Request(RequestCmd),
7275
/// An agent's working-state context for lossless restart: read/write/append.
7376
#[command(subcommand)]
7477
Context(ContextCmd),
@@ -677,6 +680,62 @@ enum MessageCmd {
677680
},
678681
}
679682

683+
#[derive(Subcommand)]
684+
enum RequestCmd {
685+
/// Publish one idempotent JSON request from a declared service principal to an agent.
686+
Send {
687+
/// Recipient agent: a bus id (`<host>.<identity>`) or a local bare identity.
688+
to: String,
689+
#[arg(long = "idempotency-key")]
690+
idempotency_key: String,
691+
/// Typed request tag as `key=value` (repeatable).
692+
#[arg(long = "tag")]
693+
tags: Vec<String>,
694+
/// JSON body. Read from stdin when omitted.
695+
#[arg(short = 'm', long = "message")]
696+
body: Option<String>,
697+
/// Emit the machine receipt as JSON.
698+
#[arg(long)]
699+
json: bool,
700+
#[command(flatten)]
701+
ctx: MsgCtx,
702+
},
703+
/// Decode one typed request from an agent's inbox.
704+
Read {
705+
request_filename: String,
706+
/// Emit the request envelope as JSON.
707+
#[arg(long)]
708+
json: bool,
709+
#[command(flatten)]
710+
ctx: MsgCtx,
711+
},
712+
/// Reply once to a typed request in an agent's inbox.
713+
Reply {
714+
request_filename: String,
715+
/// Typed reply tag as `key=value` (repeatable).
716+
#[arg(long = "tag")]
717+
tags: Vec<String>,
718+
/// JSON body. Read from stdin when omitted.
719+
#[arg(short = 'm', long = "message")]
720+
body: Option<String>,
721+
/// Emit the machine receipt as JSON.
722+
#[arg(long)]
723+
json: bool,
724+
#[command(flatten)]
725+
ctx: MsgCtx,
726+
},
727+
/// Observe the typed reply for one previously published request.
728+
Status {
729+
#[arg(long = "idempotency-key")]
730+
idempotency_key: String,
731+
/// Emit the tagged status union as JSON.
732+
#[arg(long)]
733+
json: bool,
734+
#[command(flatten)]
735+
ctx: MsgCtx,
736+
},
737+
}
738+
680739
fn main() -> Result<()> {
681740
let Cli {
682741
catalog_path,
@@ -708,6 +767,7 @@ fn main() -> Result<()> {
708767
up(&root, host, once, materialize_only, interval, agent, task)
709768
}
710769
Command::Message(cmd) => message_cmd(cmd),
770+
Command::Request(cmd) => request_cmd(cmd),
711771
Command::Context(cmd) => context_cmd(cmd),
712772
Command::Resource(cmd) => resource_cmd(cmd),
713773
Command::Service(cmd) => service_cmd(cmd),
@@ -2017,6 +2077,132 @@ fn send_resolved_message(
20172077
}
20182078
}
20192079

2080+
fn request_cmd(cmd: RequestCmd) -> Result<()> {
2081+
match cmd {
2082+
RequestCmd::Send {
2083+
to,
2084+
idempotency_key,
2085+
tags,
2086+
body,
2087+
json,
2088+
ctx,
2089+
} => {
2090+
let (root, host) = resolve_ctx(&ctx)?;
2091+
let principal = acting_id(&ctx)?;
2092+
let body = parse_json_body(body)?;
2093+
let receipt = st2::request::publish(
2094+
&root,
2095+
&host,
2096+
&principal,
2097+
&to,
2098+
&idempotency_key,
2099+
parse_typed_tags(tags)?,
2100+
body,
2101+
)?;
2102+
print_publish_receipt(&receipt, json)
2103+
}
2104+
RequestCmd::Reply {
2105+
request_filename,
2106+
tags,
2107+
body,
2108+
json,
2109+
ctx,
2110+
} => {
2111+
let (root, host) = resolve_ctx(&ctx)?;
2112+
let agent = acting_id(&ctx)?;
2113+
let body = parse_json_body(body)?;
2114+
let receipt = st2::request::reply(
2115+
&root,
2116+
&host,
2117+
&agent,
2118+
&request_filename,
2119+
parse_typed_tags(tags)?,
2120+
body,
2121+
)?;
2122+
print_publish_receipt(&receipt, json)
2123+
}
2124+
RequestCmd::Read {
2125+
request_filename,
2126+
json,
2127+
ctx,
2128+
} => {
2129+
let (root, host) = resolve_ctx(&ctx)?;
2130+
let agent = acting_id(&ctx)?;
2131+
let request = st2::request::read(&root, &host, &agent, &request_filename)?;
2132+
if json {
2133+
println!("{}", serde_json::to_string(&request)?);
2134+
} else {
2135+
println!(
2136+
"request {} from {} ({})",
2137+
request.idempotency_key, request.from, request.request_filename
2138+
);
2139+
}
2140+
Ok(())
2141+
}
2142+
RequestCmd::Status {
2143+
idempotency_key,
2144+
json,
2145+
ctx,
2146+
} => {
2147+
let (root, host) = resolve_ctx(&ctx)?;
2148+
let principal = acting_id(&ctx)?;
2149+
let status = st2::request::status(
2150+
&root,
2151+
&host,
2152+
&principal,
2153+
&idempotency_key,
2154+
)?;
2155+
if json {
2156+
println!("{}", serde_json::to_string(&status)?);
2157+
} else {
2158+
match status {
2159+
st2::request::RequestStatus::Pending {
2160+
idempotency_key,
2161+
request_filename,
2162+
} => println!("pending {idempotency_key} ({request_filename})"),
2163+
st2::request::RequestStatus::Replied {
2164+
idempotency_key,
2165+
request_filename,
2166+
from,
2167+
..
2168+
} => println!("replied {idempotency_key} ({request_filename}) from {from}"),
2169+
}
2170+
}
2171+
Ok(())
2172+
}
2173+
}
2174+
}
2175+
2176+
fn parse_json_body(body: Option<String>) -> Result<serde_json::Value> {
2177+
let body = body_or_stdin(body)?;
2178+
serde_json::from_str(&body).context("request body must be valid JSON")
2179+
}
2180+
2181+
fn parse_typed_tags(tags: Vec<String>) -> Result<std::collections::BTreeMap<String, String>> {
2182+
let mut parsed = std::collections::BTreeMap::new();
2183+
for tag in tags {
2184+
let (key, value) = tag
2185+
.split_once('=')
2186+
.with_context(|| format!("typed tag must be `key=value`, got `{tag}`"))?;
2187+
if key.is_empty() || value.is_empty() {
2188+
anyhow::bail!("typed tag must have a non-empty key and value: `{tag}`");
2189+
}
2190+
if parsed.insert(key.to_string(), value.to_string()).is_some() {
2191+
anyhow::bail!("duplicate typed tag key `{key}`");
2192+
}
2193+
}
2194+
Ok(parsed)
2195+
}
2196+
2197+
fn print_publish_receipt(receipt: &st2::request::PublishReceipt, json: bool) -> Result<()> {
2198+
if json {
2199+
println!("{}", serde_json::to_string(receipt)?);
2200+
} else {
2201+
println!("{}", receipt.filename);
2202+
}
2203+
Ok(())
2204+
}
2205+
20202206
/// `st2 message ls --json` row (stable st2 wire contract).
20212207
#[derive(serde::Serialize)]
20222208
struct LsItemJson<'a> {

src/message.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,46 @@ pub fn send_to_inbox(
224224
)
225225
}
226226

227+
/// Atomically materialize one already-rendered canonical message filename.
228+
///
229+
/// Repeating the same filename and bytes is success; the same filename with different bytes is an
230+
/// error. Request publication uses this after durably reserving its random filename, so a replay
231+
/// finishes an interrupted send without allocating a second bus message.
232+
pub fn materialize_message_once(
233+
inbox_dir: &Path,
234+
filename: &str,
235+
contents: &str,
236+
) -> anyhow::Result<bool> {
237+
if !is_message_filename(filename) {
238+
anyhow::bail!("invalid canonical message filename `{filename}`");
239+
}
240+
fs::create_dir_all(inbox_dir)?;
241+
let destination = inbox_dir.join(filename);
242+
if destination.is_file() {
243+
let existing = fs::read_to_string(&destination)?;
244+
if existing == contents {
245+
return Ok(false);
246+
}
247+
anyhow::bail!("message filename collision with different bytes: {filename}");
248+
}
249+
let temporary = inbox_dir.join(tmp_name());
250+
fs::write(&temporary, contents)?;
251+
let result = match fs::hard_link(&temporary, &destination) {
252+
Ok(()) => Ok(true),
253+
Err(_) if destination.is_file() => {
254+
let existing = fs::read_to_string(&destination)?;
255+
if existing == contents {
256+
Ok(false)
257+
} else {
258+
anyhow::bail!("message filename collision with different bytes: {filename}")
259+
}
260+
}
261+
Err(error) => Err(error.into()),
262+
};
263+
let _ = fs::remove_file(temporary);
264+
result
265+
}
266+
227267
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
228268

229269
fn tmp_name() -> String {
@@ -1161,6 +1201,22 @@ mod tests {
11611201
assert_eq!(fs::read(archive.join(&filename)).unwrap(), receipt);
11621202
}
11631203

1204+
#[test]
1205+
fn reserved_message_materialization_is_idempotent_but_never_clobbers() {
1206+
let tmp = tempfile::tempdir().unwrap();
1207+
let inbox = tmp.path().join("inbox");
1208+
let filename = "1784649988123-abc23z.md";
1209+
let contents = render_message("service", Some("request"), None, &[], "{}\n");
1210+
1211+
assert!(materialize_message_once(&inbox, filename, &contents).unwrap());
1212+
assert!(!materialize_message_once(&inbox, filename, &contents).unwrap());
1213+
let error = materialize_message_once(&inbox, filename, "different")
1214+
.unwrap_err()
1215+
.to_string();
1216+
assert!(error.contains("collision with different bytes"));
1217+
assert_eq!(fs::read_to_string(inbox.join(filename)).unwrap(), contents);
1218+
}
1219+
11641220
#[test]
11651221
fn resolve_inbox_falls_back_to_the_flat_bus_when_catalog_less() {
11661222
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)