Skip to content

Commit c393c81

Browse files
schicklingclaude
andcommitted
feat(harness-state): publish the Codex observed-state projection
The delivery pump's watcher narrows to its two genuine inputs (inbox + status) so runtime records written into the agent dir - presence temp siblings, harness-state transitions - can never wake it. The pump projects CodexObservedState into the generic record on every persisted change: Held never leaks (human-blocking holds read active+blockedOn, positively-active holds read active, unprovable holds withhold), the heartbeat rides the existing presence cadence and stops on evidence loss, and the wrapper publishes a terminal record once the pump is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b0d7495 commit c393c81

2 files changed

Lines changed: 289 additions & 2 deletions

File tree

src/codex_app_server.rs

Lines changed: 226 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use serde_json::{Value, json};
3030
use sha2::{Digest as _, Sha256};
3131
use tungstenite::{Message as WebSocketMessage, WebSocket};
3232

33-
use crate::{ding, message, run, status};
33+
use crate::{ding, harness_state, message, run, status};
3434

3535
/// Every admitted version has a delivery-critical schema comparison and live remote-TUI evidence.
3636
/// A later version stays rejected until both checks are repeated; semantic-version proximity is
@@ -210,6 +210,55 @@ pub enum CodexTerminalError {
210210
SystemError,
211211
}
212212

213+
impl CodexObservedState {
214+
/// Driver-side projection into the generic observed-harness-state vocabulary (#162). `Held` is
215+
/// a delivery predicate — the complement of steerable — and never leaks into the published
216+
/// record: holds Codex positively reported as work project to `active` (with the human-blocking
217+
/// ones setting the blocked axis), while holds that only mean "st2 cannot currently prove
218+
/// anything" project to `None`, the indeterminate observation that writes nothing.
219+
pub fn harness_observation(&self) -> Option<harness_state::Observation> {
220+
use crate::harness_state::{Activity, BlockedOn, InputBuffer, Observation};
221+
let observation = |state, blocked_on| {
222+
// This producer reads the app-server control stream and cannot see the composer.
223+
Observation::new(state, blocked_on, InputBuffer::Unknown)
224+
};
225+
match self {
226+
CodexObservedState::AwaitingStatus => None,
227+
CodexObservedState::Idle => Some(observation(Activity::Idle, BlockedOn::None)),
228+
CodexObservedState::TerminalError { .. } => {
229+
Some(observation(Activity::Ended, BlockedOn::None).with_reason("systemError"))
230+
}
231+
CodexObservedState::Active { .. } => {
232+
Some(observation(Activity::Active, BlockedOn::None))
233+
}
234+
CodexObservedState::Held { reason, .. } => match reason {
235+
CodexHoldReason::Review => {
236+
Some(observation(Activity::Active, BlockedOn::Human).with_reason("review"))
237+
}
238+
CodexHoldReason::WaitingOnApproval => Some(
239+
observation(Activity::Active, BlockedOn::Human)
240+
.with_reason("waitingOnApproval"),
241+
),
242+
CodexHoldReason::WaitingOnUserInput => Some(
243+
observation(Activity::Active, BlockedOn::Human)
244+
.with_reason("waitingOnUserInput"),
245+
),
246+
CodexHoldReason::Compaction => {
247+
Some(observation(Activity::Active, BlockedOn::None).with_reason("compaction"))
248+
}
249+
// Codex positively reported active; st2 merely cannot name a steerable turn.
250+
CodexHoldReason::ActiveWithoutTurn => Some(
251+
observation(Activity::Active, BlockedOn::None).with_reason("activeWithoutTurn"),
252+
),
253+
CodexHoldReason::ConflictingTurn => Some(
254+
observation(Activity::Active, BlockedOn::None).with_reason("conflictingTurn"),
255+
),
256+
CodexHoldReason::NotLoaded | CodexHoldReason::SystemError => None,
257+
},
258+
}
259+
}
260+
}
261+
213262
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214263
#[serde(rename_all = "camelCase", deny_unknown_fields)]
215264
pub struct CodexControlState {
@@ -330,6 +379,11 @@ struct CodexInboxDelivery {
330379
pending: Option<PendingCodexDelivery>,
331380
rejected: Option<RejectedCodexDelivery>,
332381
next_request_id: u64,
382+
harness_writer: harness_state::Writer,
383+
/// Whether the latest projection carried evidence. Indeterminate observations write nothing
384+
/// and stop the heartbeat, so a state the pump can no longer see ages out instead of staying
385+
/// artificially fresh.
386+
harness_evidence: bool,
333387
}
334388

335389
impl CodexInboxDelivery {
@@ -345,8 +399,17 @@ impl CodexInboxDelivery {
345399
)
346400
})?;
347401
let (wake_tx, wake) = mpsc::channel();
348-
let watcher = crate::watch::watch_recursive_mutations(&config.agent_dir, wake_tx);
402+
// Scoped to inbox + status: this pump's own process group writes runtime records (presence
403+
// refreshes, harness-state transitions) into the same agent dir, and those must not wake it.
404+
let watcher = crate::watch::watch_delivery_inputs(&config.agent_dir, wake_tx);
349405
let state = load_delivery_state(&state_path, &config.identity, runtime.runtime_id())?;
406+
// An agent IS its pty: the session whose liveness vouches for the record is the identity.
407+
let harness_writer = harness_state::Writer::new(
408+
&config.agent_dir,
409+
config.identity.clone(),
410+
"codex",
411+
Some(config.identity.clone()),
412+
);
350413
Ok(Self {
351414
config,
352415
state_path,
@@ -361,9 +424,23 @@ impl CodexInboxDelivery {
361424
pending: None,
362425
rejected: None,
363426
next_request_id: FIRST_DELIVERY_REQUEST_ID,
427+
harness_writer,
428+
harness_evidence: false,
364429
})
365430
}
366431

432+
/// Publish the generic observed-harness-state projection of a control-state change. Best-effort
433+
/// like the presence refresh: a failed record write must not disturb delivery.
434+
fn observe_harness(&mut self, observed: &CodexObservedState) {
435+
match observed.harness_observation() {
436+
Some(observation) => {
437+
let _ = self.harness_writer.observe(observation);
438+
self.harness_evidence = true;
439+
}
440+
None => self.harness_evidence = false,
441+
}
442+
}
443+
367444
fn write_state(&mut self, state: CodexDeliveryState) -> Result<()> {
368445
atomic_json(&self.state_path, &state)?;
369446
self.state = Some(state);
@@ -382,6 +459,9 @@ impl CodexInboxDelivery {
382459
// This wrapper owns the live provider session. It therefore owns the presence lease.
383460
// Preserve busy or available, and let dnd age out.
384461
let _ = status::refresh(&status::status_path(&self.config.agent_dir));
462+
if self.harness_evidence {
463+
let _ = self.harness_writer.heartbeat();
464+
}
385465
self.next_presence_refresh = now + status::STATUS_REFRESH;
386466
}
387467
let mut due = now >= self.next_inbox_refresh;
@@ -1226,6 +1306,8 @@ fn run_connected(
12261306
} else {
12271307
(None, None)
12281308
};
1309+
let harness_agent_dir = delivery.agent_dir.clone();
1310+
let harness_identity = delivery.identity.clone();
12291311
let event_thread = thread::spawn(move || {
12301312
let resume = expected_resume
12311313
.as_deref()
@@ -1287,6 +1369,25 @@ fn run_connected(
12871369
drop(resume_ready_tx);
12881370
let _ = shutdown.shutdown(Shutdown::Both);
12891371
let _ = event_thread.join();
1372+
// The pump is gone, so nothing can observe this session again: publish the terminal
1373+
// observation with the outcome the wrapper actually saw, before any staleness horizon.
1374+
let mut harness_writer = harness_state::Writer::new(
1375+
&harness_agent_dir,
1376+
harness_identity.clone(),
1377+
"codex",
1378+
Some(harness_identity),
1379+
);
1380+
let _ = match &result {
1381+
Ok(()) => harness_writer.ended("exit 0"),
1382+
Err(error) => harness_writer.observe(
1383+
harness_state::Observation::new(
1384+
harness_state::Activity::Ended,
1385+
harness_state::BlockedOn::None,
1386+
harness_state::InputBuffer::Unknown,
1387+
)
1388+
.with_reason(format!("{error}")),
1389+
),
1390+
};
12901391
result
12911392
}
12921393

@@ -2034,6 +2135,9 @@ fn pump_control(
20342135
.context("persisting Codex resume binding")?;
20352136
atomic_json(control_state_path, &bound)
20362137
.context("persisting Codex control state")?;
2138+
if let Some(delivery) = delivery.as_mut() {
2139+
delivery.observe_harness(&bound.observed);
2140+
}
20372141
control_state = Some(bound);
20382142
let _ = events.send(ControlEvent::Bound);
20392143
continue;
@@ -2056,6 +2160,9 @@ fn pump_control(
20562160
bound.subscribed = true;
20572161
atomic_json(control_state_path, &bound)
20582162
.context("persisting Codex fresh control state")?;
2163+
if let Some(delivery) = delivery.as_mut() {
2164+
delivery.observe_harness(&bound.observed);
2165+
}
20592166
control_state = Some(bound);
20602167
let _ = events.send(ControlEvent::Bound);
20612168
}
@@ -2106,6 +2213,9 @@ fn pump_control(
21062213
if changed {
21072214
atomic_json(control_state_path, state)
21082215
.context("persisting Codex observed control state")?;
2216+
if let Some(delivery) = delivery.as_mut() {
2217+
delivery.observe_harness(&state.observed);
2218+
}
21092219
let _ = events.send(ControlEvent::Observed);
21102220
}
21112221
if !state.subscribed
@@ -2691,6 +2801,120 @@ mod tests {
26912801
assert!(steer["params"].get("approvalPolicy").is_none());
26922802
}
26932803

2804+
/// Behavioral oracle for the #268 §B projection: a projection that withheld every row — or
2805+
/// that reported the two misclassified rows as indeterminate — fails here, because each
2806+
/// emitting row is asserted positively.
2807+
#[test]
2808+
fn harness_projection_is_faithful_and_withholds_only_unprovable_rows() {
2809+
use crate::harness_state::{Activity, BlockedOn, InputBuffer};
2810+
let held = |reason| CodexObservedState::Held {
2811+
reason,
2812+
turn_id: None,
2813+
};
2814+
2815+
// Rows with no provable observation are withheld — and no absence may derive idle.
2816+
for state in [
2817+
CodexObservedState::AwaitingStatus,
2818+
held(CodexHoldReason::NotLoaded),
2819+
held(CodexHoldReason::SystemError),
2820+
] {
2821+
assert_eq!(state.harness_observation(), None, "{state:?}");
2822+
}
2823+
2824+
// Codex positively reported work: active, even where st2 cannot name a steerable turn
2825+
// (the two rows a naive steerability decomposition reported as unknown) or where the
2826+
// delivery gate holds.
2827+
for state in [
2828+
CodexObservedState::Active {
2829+
turn_id: "turn-current".into(),
2830+
},
2831+
held(CodexHoldReason::ActiveWithoutTurn),
2832+
held(CodexHoldReason::ConflictingTurn),
2833+
held(CodexHoldReason::Compaction),
2834+
] {
2835+
let observation = state
2836+
.harness_observation()
2837+
.unwrap_or_else(|| panic!("{state:?} must emit"));
2838+
assert_eq!(observation.state, Activity::Active, "{state:?}");
2839+
assert_eq!(observation.blocked_on, BlockedOn::None, "{state:?}");
2840+
assert_eq!(observation.input_buffer, InputBuffer::Unknown, "{state:?}");
2841+
}
2842+
2843+
// The holds a human resolves set the blocked axis instead of disappearing into active.
2844+
for reason in [
2845+
CodexHoldReason::Review,
2846+
CodexHoldReason::WaitingOnApproval,
2847+
CodexHoldReason::WaitingOnUserInput,
2848+
] {
2849+
let observation = held(reason)
2850+
.harness_observation()
2851+
.unwrap_or_else(|| panic!("{reason:?} must emit"));
2852+
assert_eq!(observation.state, Activity::Active, "{reason:?}");
2853+
assert_eq!(observation.blocked_on, BlockedOn::Human, "{reason:?}");
2854+
}
2855+
2856+
let idle = CodexObservedState::Idle.harness_observation().unwrap();
2857+
assert_eq!(idle.state, Activity::Idle);
2858+
assert_eq!(idle.blocked_on, BlockedOn::None);
2859+
2860+
let ended = CodexObservedState::TerminalError {
2861+
reason: CodexTerminalError::SystemError,
2862+
}
2863+
.harness_observation()
2864+
.unwrap();
2865+
assert_eq!(ended.state, Activity::Ended);
2866+
assert_eq!(ended.reason.as_deref(), Some("systemError"));
2867+
}
2868+
2869+
#[test]
2870+
fn pump_publishes_observations_and_stops_heartbeating_on_evidence_loss() {
2871+
use crate::harness_state::{self, Activity};
2872+
let tmp = tempfile::tempdir().unwrap();
2873+
let config = delivery_config(tmp.path());
2874+
let agent_dir = config.agent_dir.clone();
2875+
let record_path = harness_state::harness_state_path(&agent_dir);
2876+
let mut delivery = inbox_delivery(tmp.path(), config);
2877+
2878+
delivery.observe_harness(&CodexObservedState::Active {
2879+
turn_id: "turn-current".into(),
2880+
});
2881+
let observed = harness_state::read(&record_path, None).expect("record written");
2882+
assert_eq!(observed.state, Activity::Active);
2883+
assert_eq!(observed.harness.as_deref(), Some("codex"));
2884+
2885+
// An indeterminate projection writes nothing and stops the heartbeat: the presence
2886+
// refresh still runs, but the record's bytes stay untouched and age toward unknown.
2887+
delivery.observe_harness(&CodexObservedState::Held {
2888+
reason: CodexHoldReason::NotLoaded,
2889+
turn_id: None,
2890+
});
2891+
let before = fs::read(&record_path).unwrap();
2892+
delivery.refresh_if_due().unwrap();
2893+
assert!(
2894+
status::read_state(&status::status_path(&agent_dir)) != status::State::Offline,
2895+
"presence refresh must still run"
2896+
);
2897+
assert_eq!(
2898+
fs::read(&record_path).unwrap(),
2899+
before,
2900+
"no heartbeat without evidence"
2901+
);
2902+
2903+
// Evidence returning resumes both observation and heartbeat.
2904+
delivery.observe_harness(&CodexObservedState::Idle);
2905+
assert_eq!(
2906+
harness_state::read(&record_path, None).unwrap().state,
2907+
Activity::Idle
2908+
);
2909+
delivery.next_presence_refresh = Instant::now();
2910+
delivery.refresh_if_due().unwrap();
2911+
assert_ne!(
2912+
fs::read(&record_path).unwrap(),
2913+
before,
2914+
"heartbeat resumes with evidence"
2915+
);
2916+
}
2917+
26942918
#[test]
26952919
fn delivery_client_id_is_stable_and_binds_every_identity_component() {
26962920
let id =

src/watch.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,32 @@ pub(crate) fn watch_recursive_mutations(
2525
Some(watcher)
2626
}
2727

28+
/// Watch only the inputs a native delivery pump consumes: the agent's `resources/inbox` subtree
29+
/// and its `status` file. Runtime records written beside them by the pump's own process group —
30+
/// the presence refresh's temp siblings, `harness-state`, stream state — must never wake delivery,
31+
/// or a writer that observes on every turn boundary pumps itself continuously.
32+
pub(crate) fn watch_delivery_inputs(
33+
agent_dir: &Path,
34+
tx: Sender<()>,
35+
) -> Option<notify::RecommendedWatcher> {
36+
let inbox = agent_dir.join("resources").join("inbox");
37+
let status = agent_dir.join("status");
38+
let mut watcher = notify::recommended_watcher(move |result: notify::Result<Event>| {
39+
if result.is_ok_and(|event| {
40+
is_mutation(&event)
41+
&& event
42+
.paths
43+
.iter()
44+
.any(|path| path.starts_with(&inbox) || *path == status)
45+
}) {
46+
let _ = tx.send(());
47+
}
48+
})
49+
.ok()?;
50+
watcher.watch(agent_dir, RecursiveMode::Recursive).ok()?;
51+
Some(watcher)
52+
}
53+
2854
/// Watch only declaration inputs for the supervisor. Runtime state (PTY registry, bus, logs,
2955
/// locks, inboxes, and generated materializations) must never wake reconciliation.
3056
pub(crate) fn watch_catalog_declarations(
@@ -156,6 +182,43 @@ mod tests {
156182
assert!(!is_declaration_path(root, &root.join("team/rendered.kdl")));
157183
}
158184

185+
#[cfg(target_os = "linux")]
186+
#[test]
187+
fn delivery_watcher_ignores_runtime_records_but_wakes_on_inbox_and_status() {
188+
use std::sync::mpsc::channel;
189+
use std::time::Duration;
190+
191+
let dir = tempfile::tempdir().unwrap();
192+
let agent_dir = dir.path();
193+
std::fs::create_dir_all(agent_dir.join("resources/inbox")).unwrap();
194+
195+
let (tx, rx) = channel();
196+
let _watcher = watch_delivery_inputs(agent_dir, tx).expect("start inotify watcher");
197+
198+
// Runtime records the pump's own process group writes must stay silent: the observed
199+
// harness state, its atomic temp siblings, the presence temp sibling, stream state.
200+
std::fs::write(agent_dir.join("harness-state"), "{}").unwrap();
201+
std::fs::write(agent_dir.join(".harness-state.tmp-1-0"), "{}").unwrap();
202+
std::fs::write(agent_dir.join(".status.tmp-1-0"), "available\n").unwrap();
203+
std::fs::create_dir_all(agent_dir.join("resources/streams/s")).unwrap();
204+
std::fs::write(agent_dir.join("resources/streams/s/state.json"), "{}").unwrap();
205+
assert!(
206+
rx.recv_timeout(Duration::from_millis(200)).is_err(),
207+
"runtime-record writes must not wake the delivery pump"
208+
);
209+
210+
// The two genuine delivery inputs wake it: an inbox arrival…
211+
std::fs::write(agent_dir.join("resources/inbox/0001-msg.md"), "hi").unwrap();
212+
rx.recv_timeout(Duration::from_secs(1))
213+
.expect("inbox write must wake");
214+
while rx.try_recv().is_ok() {}
215+
216+
// …and a presence change, including one landing via atomic tmp+rename.
217+
std::fs::rename(agent_dir.join(".status.tmp-1-0"), agent_dir.join("status")).unwrap();
218+
rx.recv_timeout(Duration::from_secs(1))
219+
.expect("status rename must wake");
220+
}
221+
159222
#[cfg(target_os = "linux")]
160223
#[test]
161224
fn linux_reads_are_silent_but_real_mutations_wake() {

0 commit comments

Comments
 (0)