Skip to content

Commit 0ec6a22

Browse files
fix(eval): close canonical causal admission
agent-session-id: dev3.dotfiles-cos-misc-agent-runtime-simplification agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /home/schickling/.config/coding-agents/profile.json agent-skills-manifest: /nix/store/nk9iml2841l1yjjg0f6f0d3y60zkg1nn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@4a0515f
1 parent a40af17 commit 0ec6a22

4 files changed

Lines changed: 127 additions & 31 deletions

File tree

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -409,13 +409,14 @@ eval {
409409
`canonical-agents` then discovers and materializes declarations at
410410
`agents/<host>/<identity>/agent.kdl`. It is mutually exclusive with compact `team` / `agent` seats:
411411
the discovered vector is the sole authority for launch, kickoff routing, supervision, logs, and
412-
teardown. Strict catalog validation, main-PTY admission, and warning-free materialization all finish
413-
before a seat starts; backend launch errors are fatal. The native inbox/archive paths are frozen from
414-
that admitted vector, so later catalog mutation cannot redirect eval traffic. A multi-seat team
415-
completes only after the existing worker-report ordering; a singleton completes on its first
416-
interviewer-to-requester confirmation that post-dates the exact kickoff receipt. Canonical completion
417-
gates the verdict. Without the directive, Agent Spec-shaped files inside a fixture remain inert and
418-
compact evals retain their flat bus and completion semantics.
412+
teardown. Strict catalog validation, main-PTY admission, fleet-unique nonempty task runtime IDs, and
413+
warning-free materialization all finish before a seat starts; backend launch errors are fatal. The
414+
native inbox/archive paths are frozen from that admitted vector, so later catalog mutation cannot
415+
redirect eval traffic. A multi-seat team completes only after the existing worker-report ordering.
416+
For a singleton, the requester inbox is snapshotted before kickoff; only a newly appearing
417+
interviewer reply at-or-after the exact kickoff receipt completes it. Canonical completion gates the
418+
verdict. Without the directive, Agent Spec-shaped files inside a fixture remain inert and compact
419+
evals retain their flat bus and completion semantics.
419420

420421
`st2 compile-agent` remains experimental. Hand-authored KDL is the canonical st2 authoring
421422
interface, and generated output must be reviewed before materialization.

docs/vrs/spec.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ and teardown.
2929
Admission applies `validate_for_host` strictly, then fails before spawn when
3030
discovery is empty, malformed, warning-bearing, duplicate, retired, nonlocal,
3131
noncanonical, root-overriding, unrunnable, or does not expose exactly one
32-
independently launchable service main PTY per declaration. Main runtime IDs
33-
must be nonempty and fleet-unique. Materialization warnings and backend launch
34-
errors are fatal. The kickoff target must resolve to exactly one member of the
32+
independently launchable service main PTY per declaration. Every resolved task
33+
runtime ID must be nonempty and fleet-unique, including collisions between a
34+
main PTY and another declaration's sidecar. Materialization warnings and
35+
backend launch errors are fatal. The kickoff target must resolve to exactly one member of the
3536
discovered fleet. The eval owns one native `CATALOG` / `ST_ROOT` and its
3637
`<catalog>/pty` registry; declarations cannot override those roots. Workspace
3738
renders are materialized before any seat starts.
@@ -40,9 +41,12 @@ Native inbox/archive paths are derived once from the admitted Agent Spec paths
4041
and carried as frozen data; routing never re-discovers the mutable catalog.
4142
The requester alone is an explicit eval-owned flat mailbox. Multi-seat
4243
completion retains the worker-report-before-supervisor-confirmation ordering.
43-
A singleton canonical team completes when its interviewer-to-requester
44-
confirmation post-dates the exact kickoff receipt. Canonical completion is a
45-
gating judge, so a timeout cannot pass on unrelated final-state checks alone.
44+
For a singleton, the eval snapshots the requester inbox before kickoff and
45+
completes only for a newly appearing interviewer reply whose timestamp is
46+
at-or-after the exact kickoff receipt. The filename snapshot rejects
47+
future-dated pre-seeded messages while `>=` accepts a causally new same-ms
48+
reply. Canonical completion is a gating judge, so a timeout cannot pass on
49+
unrelated final-state checks alone.
4650

4751
Without `canonical-agents`, fixture declarations are not discovered or launched
4852
and compact evals retain their catalog-less flat bus. This explicit opt-in keeps

src/eval_run.rs

Lines changed: 92 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ fn admitted_route<'a>(
128128
.unwrap_or_else(|| panic!("strict canonical admission did not freeze route for `{id}`"))
129129
}
130130

131+
fn task_runtime_id(spec: &AgentSpec, task: &Task, host: &str) -> String {
132+
task.id
133+
.clone()
134+
.unwrap_or_else(|| format!("{}.{}", spec.bus_id(host), task.name))
135+
}
136+
131137
fn main_pty_id(spec: &AgentSpec, host: &str) -> Result<String> {
132138
let bus_id = spec.bus_id(host);
133139
let main = spec
@@ -151,10 +157,7 @@ fn main_pty_id(spec: &AgentSpec, host: &str) -> Result<String> {
151157
"Agent Spec `{bus_id}` main PTY named `agent` must be an independently launchable service"
152158
);
153159
}
154-
let id = main
155-
.id
156-
.clone()
157-
.unwrap_or_else(|| format!("{bus_id}.agent"));
160+
let id = task_runtime_id(spec, main, host);
158161
if id.trim().is_empty() {
159162
anyhow::bail!("Agent Spec `{bus_id}` main PTY id must be nonempty");
160163
}
@@ -211,7 +214,7 @@ fn load_canonical_eval_team(catalog: &Path, host: &str) -> Result<CanonicalEvalT
211214

212215
let mut paths = BTreeMap::<PathBuf, usize>::new();
213216
let mut bus_ids = HashSet::new();
214-
let mut main_ids = HashSet::new();
217+
let mut runtime_ids = BTreeMap::<String, (String, bool)>::new();
215218
let mut seat_ids = Vec::new();
216219
let mut routes = BTreeMap::new();
217220
for spec in &found.specs {
@@ -254,8 +257,27 @@ fn load_canonical_eval_team(catalog: &Path, host: &str) -> Result<CanonicalEvalT
254257
}
255258
}
256259
let main_id = main_pty_id(spec, host)?;
257-
if !main_ids.insert(main_id.clone()) {
258-
anyhow::bail!("canonical-agents found duplicate main PTY id `{main_id}`");
260+
for task in &spec.tasks {
261+
let runtime_id = task_runtime_id(spec, task, host);
262+
if runtime_id.trim().is_empty() {
263+
anyhow::bail!(
264+
"canonical-agents Agent Spec `{bus_id}` task `{}` runtime task id must be nonempty",
265+
task.name
266+
);
267+
}
268+
let is_main = task.kind == TaskKind::Pty && task.name == "agent";
269+
if let Some((previous, previous_is_main)) =
270+
runtime_ids.insert(runtime_id.clone(), (bus_id.clone(), is_main))
271+
{
272+
let kind = if is_main && previous_is_main {
273+
"duplicate main PTY runtime task id"
274+
} else {
275+
"duplicate runtime task id"
276+
};
277+
anyhow::bail!(
278+
"canonical-agents found {kind} `{runtime_id}` in `{previous}` and `{bus_id}`"
279+
);
280+
}
259281
}
260282
seat_ids.push(main_id);
261283
let agent_dir = spec
@@ -519,15 +541,17 @@ fn from_is(from: Option<&str>, id: &str) -> bool {
519541

520542
/// Wait for the DONE signal, message-driven (not grade-poll). Multi-seat teams require a
521543
/// `sup → requester` confirmation whose timestamp follows a `worker → sup` report. A canonical
522-
/// singleton instead requires its confirmation to post-date the exact kickoff receipt. Compact
523-
/// singleton semantics remain unchanged. Bounded by `timeout`. Returns whether done fired.
544+
/// singleton instead requires a causally new requester-inbox entry at-or-after the exact kickoff
545+
/// receipt. Compact singleton semantics remain unchanged. Bounded by `timeout`. Returns whether done
546+
/// fired.
524547
fn wait_done(
525548
bus: &Path,
526549
canonical_routes: Option<&BTreeMap<String, CanonicalRoute>>,
527550
sup: &str,
528551
requester: &str,
529552
workers: &[String],
530553
kickoff_ts: Option<u64>,
554+
requester_before_kickoff: Option<&HashSet<String>>,
531555
timeout: Duration,
532556
on_tick: &mut dyn FnMut(),
533557
) -> bool {
@@ -559,11 +583,16 @@ fn wait_done(
559583
let sup_archived = crate::message::list_dir(&sup_archive).unwrap_or_default();
560584
if workers.is_empty()
561585
&& let Some(kickoff_ts) = kickoff_ts
586+
&& let Some(before) = requester_before_kickoff
562587
{
563588
let confirmed = crate::message::list_dir(&req_inbox)
564589
.unwrap_or_default()
565590
.iter()
566-
.any(|m| from_is(m.from.as_deref(), sup) && m.ts_ms > kickoff_ts);
591+
.any(|m| {
592+
!before.contains(&m.filename)
593+
&& from_is(m.from.as_deref(), sup)
594+
&& m.ts_ms >= kickoff_ts
595+
});
567596
if confirmed {
568597
return true;
569598
}
@@ -1074,6 +1103,13 @@ fn run_eval_inner(spec: &Spec, eval: &Eval, spec_dir: &Path, catalog: &Path, hos
10741103
Some(routes) => admitted_route(routes, &sup).inbox.clone(),
10751104
None => bus.join(&sup).join("inbox"),
10761105
};
1106+
let requester_before_kickoff = eval.canonical_agents.then(|| {
1107+
crate::message::list_dir(&bus.join(&msg.from).join("inbox"))
1108+
.unwrap_or_default()
1109+
.into_iter()
1110+
.map(|message| message.filename)
1111+
.collect::<HashSet<_>>()
1112+
});
10771113
let kickoff_receipt =
10781114
crate::message::send_to_inbox(&to_inbox, &msg.from, None, None, &[], &body)
10791115
.with_context(|| format!("seeding kickoff into {}", to_inbox.display()))?;
@@ -1185,6 +1221,7 @@ fn run_eval_inner(spec: &Spec, eval: &Eval, spec_dir: &Path, catalog: &Path, hos
11851221
&msg.from,
11861222
&workers,
11871223
kickoff_ts,
1224+
requester_before_kickoff.as_ref(),
11881225
eval.max_timeout,
11891226
&mut tick,
11901227
)
@@ -1559,6 +1596,28 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
15591596
identity "two"
15601597
host "evalhost"
15611598
pty "agent" { id "shared"; command "sleep 60" }
1599+
}"#,
1600+
),
1601+
],
1602+
),
1603+
(
1604+
"duplicate runtime task id",
1605+
vec![
1606+
(
1607+
"agents/evalhost/one/agent.kdl",
1608+
r#"agent "one" {
1609+
identity "one"
1610+
host "evalhost"
1611+
pty "agent" { id "shared"; command "sleep 60" }
1612+
}"#,
1613+
),
1614+
(
1615+
"agents/evalhost/two/agent.kdl",
1616+
r#"agent "two" {
1617+
identity "two"
1618+
host "evalhost"
1619+
pty "agent" { id "two-main"; command "sleep 60" }
1620+
exec "poison" { id "shared"; command "true" }
15621621
}"#,
15631622
),
15641623
],
@@ -1777,6 +1836,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
17771836
req,
17781837
&workers,
17791838
None,
1839+
None,
17801840
Duration::from_millis(100),
17811841
noop,
17821842
));
@@ -1791,6 +1851,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
17911851
req,
17921852
&workers,
17931853
None,
1854+
None,
17941855
Duration::from_millis(100),
17951856
noop,
17961857
));
@@ -1803,6 +1864,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
18031864
req,
18041865
&workers,
18051866
None,
1867+
None,
18061868
Duration::from_millis(2000),
18071869
noop,
18081870
));
@@ -1830,6 +1892,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
18301892
req,
18311893
&workers,
18321894
None,
1895+
None,
18331896
Duration::from_millis(2000),
18341897
noop,
18351898
),
@@ -1852,6 +1915,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
18521915
"req",
18531916
&["w".to_string()],
18541917
None,
1918+
None,
18551919
Duration::from_millis(400),
18561920
&mut tick,
18571921
);
@@ -1860,7 +1924,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
18601924
}
18611925

18621926
#[test]
1863-
fn canonical_singleton_done_requires_a_confirmation_after_the_exact_kickoff() {
1927+
fn canonical_singleton_done_is_new_after_kickoff_and_allows_the_same_millisecond() {
18641928
let root = tempfile::tempdir().unwrap();
18651929
let agent_dir = root.path().join("agents/h/interviewer");
18661930
let routes = BTreeMap::from([(
@@ -1871,7 +1935,15 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
18711935
},
18721936
)]);
18731937
let requester = root.path().join("requester/inbox");
1874-
seed_msg(&requester, 1_700_000_001_000, "aaaaaa", "h.interviewer");
1938+
let kickoff_ts = 1_700_000_002_000;
1939+
// An already-present message may even claim a FUTURE timestamp. Causality comes from the
1940+
// pre-kickoff filename snapshot, not wall-clock trust.
1941+
seed_msg(&requester, kickoff_ts + 1_000, "aaaaaa", "h.interviewer");
1942+
let before = crate::message::list_dir(&requester)
1943+
.unwrap()
1944+
.into_iter()
1945+
.map(|message| message.filename)
1946+
.collect::<HashSet<_>>();
18751947
let noop = &mut (|| {}) as &mut dyn FnMut();
18761948
assert!(
18771949
!wait_done(
@@ -1880,25 +1952,28 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" }
18801952
"h.interviewer",
18811953
"requester",
18821954
&[],
1883-
Some(1_700_000_002_000),
1955+
Some(kickoff_ts),
1956+
Some(&before),
18841957
Duration::from_millis(100),
18851958
noop,
18861959
),
1887-
"a pre-kickoff acknowledgement must not complete a singleton"
1960+
"a future-dated pre-kickoff acknowledgement must not complete a singleton"
18881961
);
1889-
seed_msg(&requester, 1_700_000_003_000, "bbbbbb", "h.interviewer");
1962+
// Filename novelty establishes after-kickoff causality; `>=` keeps a valid same-ms reply.
1963+
seed_msg(&requester, kickoff_ts, "bbbbbb", "h.interviewer");
18901964
assert!(
18911965
wait_done(
18921966
root.path(),
18931967
Some(&routes),
18941968
"h.interviewer",
18951969
"requester",
18961970
&[],
1897-
Some(1_700_000_002_000),
1971+
Some(kickoff_ts),
1972+
Some(&before),
18981973
Duration::from_secs(1),
18991974
noop,
19001975
),
1901-
"a post-kickoff singleton confirmation should complete promptly"
1976+
"a newly appearing same-ms singleton confirmation should complete promptly"
19021977
);
19031978
}
19041979

tests/eval_run_e2e.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ eval {
236236
exec "test -f $CATALOG/sup/roots-ok && test -f $CATALOG/worker/roots-ok"
237237
}
238238
judge "render materialized before launch" {
239-
exec "test \"$(cat $CATALOG/sup/materialized.txt)\" = rendered"
239+
exec "test -f $CATALOG/sup/render-seen-at-process-start"
240240
}
241241
judge "custom main id survived supervision" {
242242
exec "test -f $CATALOG/worker/restarted-once"
@@ -306,6 +306,8 @@ exec sleep 60
306306
std::fs::write(
307307
fixture.join("scripts/sup.sh"),
308308
r#"#!/bin/sh
309+
test "$(cat "$CATALOG/sup/materialized.txt")" = rendered || exit 41
310+
: > "$CATALOG/sup/render-seen-at-process-start"
309311
echo "canonical supervisor main"
310312
test "$CATALOG" = "$ST_ROOT" &&
311313
test "$PTY_ROOT" = "$CATALOG/pty" &&
@@ -726,6 +728,20 @@ fn canonical_agents_fail_closed_matrix_is_pre_spawn_and_non_vacuous() {
726728
),
727729
],
728730
),
731+
(
732+
"duplicate runtime task id",
733+
"evalhost.one",
734+
vec![
735+
(
736+
"one",
737+
r#"agent "one" { identity "one"; host "evalhost"; pty "agent" { id "shared"; command "touch \"$CATALOG/SPAWNED\"; sleep 60" } }"#,
738+
),
739+
(
740+
"two",
741+
r#"agent "two" { identity "two"; host "evalhost"; pty "agent" { id "two-main"; command "sleep 60" }; exec "poison" { id "shared"; command "touch \"$CATALOG/SPAWNED\"; sleep 60" } }"#,
742+
),
743+
],
744+
),
729745
(
730746
"retired Agent Spec",
731747
"evalhost.worker",

0 commit comments

Comments
 (0)