Skip to content

Commit 89bae87

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 a8c5712 commit 89bae87

8 files changed

Lines changed: 1258 additions & 0 deletions

File tree

INVARIANTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ materialization, messaging, DING, or presence must preserve them.
1111
| **Clean exec teardown** | Killing an exec task reaps its whole process group. | `tests/exec_backend.rs::exec_kill_reaps_the_whole_process_group_not_just_the_leader` |
1212
| **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` |
1313
| **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` |
14+
| **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` |
1415
| **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. Every other receipt retains staged ownership. Retry never re-pastes and may send one bare Return only after two adjacent `RetainedSafe` observations; human, modal, active, changed, timed-out, and unknown retry states receive no input. Ownership prevents duplicate paste across command failures, receipt ambiguity, archive races, and restart adoption. 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_ownership_survives_archive_and_never_repastes`; `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` |
1516
| **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` |
1617
| **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
@@ -324,6 +324,40 @@ intentional catalog-less fallback used by isolated folder evals. In a catalog-ba
324324
`st2 message ls` rejects an absent identity; recovery inspection of a deliberately orphaned flat
325325
box must be explicit with `st2 message ls <identity> --orphan` (and optionally `--archive`).
326326

327+
Services use a separate declared request surface; they do not borrow an Agent
328+
Spec identity. Declare the endpoint without creating a task:
329+
330+
```kdl
331+
// <catalog>/principals/dev3/hypermerge/principal.kdl
332+
principal "hypermerge" host="dev3"
333+
```
334+
335+
Then publish once, let the addressed agent reply from its normal inbox, and
336+
observe the typed result. Bodies are JSON and `--tag` is a repeatable
337+
`key=value` map:
338+
339+
```sh
340+
st2 request send dev3.repair-agent \
341+
--as dev3.hypermerge \
342+
--idempotency-key 'escalate:repo#7:abc' \
343+
--tag kind=hypermerge.escalation \
344+
-m '{"candidate":"abc"}' --json
345+
346+
st2 request read <request-filename> --json
347+
348+
st2 request reply <request-filename> \
349+
--tag outcome=needs-human \
350+
-m '{"outcome":"needs-human"}' --json
351+
352+
st2 request status --as dev3.hypermerge \
353+
--idempotency-key 'escalate:repo#7:abc' --json
354+
```
355+
356+
The key atomically reserves one message filename and exact envelope before
357+
publication. Exact retries return that filename with `deduplicated: true`; a
358+
different request under the same key fails. Replies route only to the declared
359+
principal's canonical `resources/inbox`, never a flat orphan mailbox.
360+
327361
Adopters should cut directly to the native layout. Before launching a migrated identity, install and
328362
verify hooks, validate and materialize its hand-authored declaration, stop any predecessor transport,
329363
and decide how any unread predecessor backlog will be archived or forwarded. Never run predecessor

docs/vrs/spec.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,33 @@ materialization, frozen routing after declaration removal, singleton
6060
completion, custom task-ID supervision/logging/teardown, and the no-opt-in
6161
legacy control in `tests/eval_run_e2e.rs`.
6262

63+
## Service-principal request transport
64+
65+
A non-agent service that needs bounded judgment work may declare only its bus
66+
endpoint at
67+
`principals/<host>/<identity>/principal.kdl`:
68+
69+
```kdl
70+
principal "hypermerge" host="dev3"
71+
```
72+
73+
The declaration creates no task, presence, persona, or Agent Spec authority.
74+
Its content must exactly match its canonical path. `st2 request send` accepts
75+
only such a principal as the caller and only a discovered Agent Spec as the
76+
recipient, so a service neither impersonates an agent nor depends on the flat
77+
orphan-recovery layout.
78+
79+
The caller supplies an idempotency key, a JSON body, and typed string tags.
80+
Before the native message is published, st2 atomically reserves one random
81+
canonical message filename and the exact request envelope under the
82+
principal's `resources/request-state/`. Replays finish that same publication;
83+
reuse of the key with different caller, recipient, body, or tags fails. An
84+
agent's `st2 request reply` similarly publishes at most one typed reply to the
85+
principal's canonical inbox. `st2 request status --json` returns the tagged
86+
union `pending | replied`, suitable for a durable workflow to observe between
87+
its own durable waits. st2 provides no wait loop or timer and does not turn the
88+
request into agent lifecycle authority.
89+
6390
## Resource bindings (R20-R21)
6491

6592
An agent may directly declare zero or more generic Resource bindings:

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub mod materialize;
2121
pub mod message;
2222
pub mod pretrust;
2323
pub mod reconcile;
24+
pub mod request;
2425
pub mod resource;
2526
pub mod run;
2627
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),
@@ -531,6 +534,62 @@ enum MessageCmd {
531534
},
532535
}
533536

537+
#[derive(Subcommand)]
538+
enum RequestCmd {
539+
/// Publish one idempotent JSON request from a declared service principal to an agent.
540+
Send {
541+
/// Recipient agent: a bus id (`<host>.<identity>`) or a local bare identity.
542+
to: String,
543+
#[arg(long = "idempotency-key")]
544+
idempotency_key: String,
545+
/// Typed request tag as `key=value` (repeatable).
546+
#[arg(long = "tag")]
547+
tags: Vec<String>,
548+
/// JSON body. Read from stdin when omitted.
549+
#[arg(short = 'm', long = "message")]
550+
body: Option<String>,
551+
/// Emit the machine receipt as JSON.
552+
#[arg(long)]
553+
json: bool,
554+
#[command(flatten)]
555+
ctx: MsgCtx,
556+
},
557+
/// Decode one typed request from an agent's inbox.
558+
Read {
559+
request_filename: String,
560+
/// Emit the request envelope as JSON.
561+
#[arg(long)]
562+
json: bool,
563+
#[command(flatten)]
564+
ctx: MsgCtx,
565+
},
566+
/// Reply once to a typed request in an agent's inbox.
567+
Reply {
568+
request_filename: String,
569+
/// Typed reply tag as `key=value` (repeatable).
570+
#[arg(long = "tag")]
571+
tags: Vec<String>,
572+
/// JSON body. Read from stdin when omitted.
573+
#[arg(short = 'm', long = "message")]
574+
body: Option<String>,
575+
/// Emit the machine receipt as JSON.
576+
#[arg(long)]
577+
json: bool,
578+
#[command(flatten)]
579+
ctx: MsgCtx,
580+
},
581+
/// Observe the typed reply for one previously published request.
582+
Status {
583+
#[arg(long = "idempotency-key")]
584+
idempotency_key: String,
585+
/// Emit the tagged status union as JSON.
586+
#[arg(long)]
587+
json: bool,
588+
#[command(flatten)]
589+
ctx: MsgCtx,
590+
},
591+
}
592+
534593
fn main() -> Result<()> {
535594
let Cli {
536595
catalog_path,
@@ -562,6 +621,7 @@ fn main() -> Result<()> {
562621
up(&root, host, once, materialize_only, interval, agent, task)
563622
}
564623
Command::Message(cmd) => message_cmd(cmd),
624+
Command::Request(cmd) => request_cmd(cmd),
565625
Command::Context(cmd) => context_cmd(cmd),
566626
Command::Resource(cmd) => resource_cmd(cmd),
567627
Command::Service(cmd) => service_cmd(cmd),
@@ -1506,6 +1566,132 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> {
15061566
}
15071567
}
15081568

1569+
fn request_cmd(cmd: RequestCmd) -> Result<()> {
1570+
match cmd {
1571+
RequestCmd::Send {
1572+
to,
1573+
idempotency_key,
1574+
tags,
1575+
body,
1576+
json,
1577+
ctx,
1578+
} => {
1579+
let (root, host) = resolve_ctx(&ctx)?;
1580+
let principal = acting_id(&ctx)?;
1581+
let body = parse_json_body(body)?;
1582+
let receipt = st2::request::publish(
1583+
&root,
1584+
&host,
1585+
&principal,
1586+
&to,
1587+
&idempotency_key,
1588+
parse_typed_tags(tags)?,
1589+
body,
1590+
)?;
1591+
print_publish_receipt(&receipt, json)
1592+
}
1593+
RequestCmd::Reply {
1594+
request_filename,
1595+
tags,
1596+
body,
1597+
json,
1598+
ctx,
1599+
} => {
1600+
let (root, host) = resolve_ctx(&ctx)?;
1601+
let agent = acting_id(&ctx)?;
1602+
let body = parse_json_body(body)?;
1603+
let receipt = st2::request::reply(
1604+
&root,
1605+
&host,
1606+
&agent,
1607+
&request_filename,
1608+
parse_typed_tags(tags)?,
1609+
body,
1610+
)?;
1611+
print_publish_receipt(&receipt, json)
1612+
}
1613+
RequestCmd::Read {
1614+
request_filename,
1615+
json,
1616+
ctx,
1617+
} => {
1618+
let (root, host) = resolve_ctx(&ctx)?;
1619+
let agent = acting_id(&ctx)?;
1620+
let request = st2::request::read(&root, &host, &agent, &request_filename)?;
1621+
if json {
1622+
println!("{}", serde_json::to_string(&request)?);
1623+
} else {
1624+
println!(
1625+
"request {} from {} ({})",
1626+
request.idempotency_key, request.from, request.request_filename
1627+
);
1628+
}
1629+
Ok(())
1630+
}
1631+
RequestCmd::Status {
1632+
idempotency_key,
1633+
json,
1634+
ctx,
1635+
} => {
1636+
let (root, host) = resolve_ctx(&ctx)?;
1637+
let principal = acting_id(&ctx)?;
1638+
let status = st2::request::status(
1639+
&root,
1640+
&host,
1641+
&principal,
1642+
&idempotency_key,
1643+
)?;
1644+
if json {
1645+
println!("{}", serde_json::to_string(&status)?);
1646+
} else {
1647+
match status {
1648+
st2::request::RequestStatus::Pending {
1649+
idempotency_key,
1650+
request_filename,
1651+
} => println!("pending {idempotency_key} ({request_filename})"),
1652+
st2::request::RequestStatus::Replied {
1653+
idempotency_key,
1654+
request_filename,
1655+
from,
1656+
..
1657+
} => println!("replied {idempotency_key} ({request_filename}) from {from}"),
1658+
}
1659+
}
1660+
Ok(())
1661+
}
1662+
}
1663+
}
1664+
1665+
fn parse_json_body(body: Option<String>) -> Result<serde_json::Value> {
1666+
let body = body_or_stdin(body)?;
1667+
serde_json::from_str(&body).context("request body must be valid JSON")
1668+
}
1669+
1670+
fn parse_typed_tags(tags: Vec<String>) -> Result<std::collections::BTreeMap<String, String>> {
1671+
let mut parsed = std::collections::BTreeMap::new();
1672+
for tag in tags {
1673+
let (key, value) = tag
1674+
.split_once('=')
1675+
.with_context(|| format!("typed tag must be `key=value`, got `{tag}`"))?;
1676+
if key.is_empty() || value.is_empty() {
1677+
anyhow::bail!("typed tag must have a non-empty key and value: `{tag}`");
1678+
}
1679+
if parsed.insert(key.to_string(), value.to_string()).is_some() {
1680+
anyhow::bail!("duplicate typed tag key `{key}`");
1681+
}
1682+
}
1683+
Ok(parsed)
1684+
}
1685+
1686+
fn print_publish_receipt(receipt: &st2::request::PublishReceipt, json: bool) -> Result<()> {
1687+
if json {
1688+
println!("{}", serde_json::to_string(receipt)?);
1689+
} else {
1690+
println!("{}", receipt.filename);
1691+
}
1692+
Ok(())
1693+
}
1694+
15091695
/// `st2 message ls --json` row (stable st2 wire contract).
15101696
#[derive(serde::Serialize)]
15111697
struct LsItemJson<'a> {

src/message.rs

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

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

227267
fn tmp_name() -> String {
@@ -648,6 +688,22 @@ mod tests {
648688
assert_eq!(fs::read(archive.join(&filename)).unwrap(), receipt);
649689
}
650690

691+
#[test]
692+
fn reserved_message_materialization_is_idempotent_but_never_clobbers() {
693+
let tmp = tempfile::tempdir().unwrap();
694+
let inbox = tmp.path().join("inbox");
695+
let filename = "1784649988123-abc23z.md";
696+
let contents = render_message("service", Some("request"), None, &[], "{}\n");
697+
698+
assert!(materialize_message_once(&inbox, filename, &contents).unwrap());
699+
assert!(!materialize_message_once(&inbox, filename, &contents).unwrap());
700+
let error = materialize_message_once(&inbox, filename, "different")
701+
.unwrap_err()
702+
.to_string();
703+
assert!(error.contains("collision with different bytes"));
704+
assert_eq!(fs::read_to_string(inbox.join(filename)).unwrap(), contents);
705+
}
706+
651707
#[test]
652708
fn resolve_inbox_falls_back_to_the_flat_bus_when_catalog_less() {
653709
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)