mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
Verify live Claude and Codex ACPX eligibility before parking (#19295)
This commit is contained in:
1 parent
b918ee165d
commit
205ffcb606
8 files changed
+563
-35
No files matched your search
@@ -157,12 +157,20 @@ for the existing process that completed the current turn. An eligibility check
|
||||
never selects or restores a provider. A restored process must complete its own
|
||||
authoritative turn before it can be eligible. Reconstructing an accepted finish
|
||||
during recovery preserves result delivery but does not qualify that process.
|
||||
ACPX remains ineligible in this slice, including after qualified recovery.
|
||||
Active turns, goals, startup attempts, tool calls, pending credentials, and
|
||||
unsettled events block parking. Known helper threads also block it: their
|
||||
diagnostic lineage does not prove that their work finished. The bounded
|
||||
completed-turn tail drain retains late accounting and goal changes for durable
|
||||
state and normal event delivery.
|
||||
|
||||
Claude and Codex through ACPX also need a successful turn in the live sidecar.
|
||||
Runner checks its qualified profile, session identity and source run A. It gives
|
||||
that sidecar 100 ms to report its goal and session state. If the sidecar is slow
|
||||
or declines, Runner stays attached. Runner keeps at most one unanswered read and
|
||||
recognizes its late reply. Events received during that read still belong to A
|
||||
and are delivered before B attaches. The existing policy still replaces the
|
||||
sidecar at the next turn or run attachment; the saved provider identity continues.
|
||||
|
||||
The check also inspects the live provider
|
||||
after that drain, so a late active-goal notification cannot be hidden by the
|
||||
older executor journal. This hook does not detach a controller, stop a process,
|
||||
|
||||
@@ -2307,6 +2307,11 @@ impl AcpxCommandExecutor {
|
||||
}
|
||||
|
||||
fn retain_buffered_provider_events(&mut self) -> Result<(), DurableRunnerError> {
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.settle_park_observation().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("ACPX observation settlement failed: {error}"))
|
||||
})?;
|
||||
}
|
||||
loop {
|
||||
let count = self
|
||||
.session
|
||||
@@ -2516,6 +2521,59 @@ fn goal_terminal_disposition<'a>(
|
||||
}
|
||||
|
||||
impl CommandExecutor for AcpxCommandExecutor {
|
||||
fn host_park_ready(&mut self) -> Result<bool, DurableRunnerError> {
|
||||
self.ensure_persistence_healthy()?;
|
||||
let Some(state) = self.state.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !matches!(state.descriptor.agent.as_str(), "codex" | "claude")
|
||||
|| self.session.is_none()
|
||||
|| state.lifecycle != "session_open"
|
||||
|| state.provider_exit_unconfirmed
|
||||
|| state.descriptor.run_id != self.context.run_id
|
||||
|| state.identity.is_none()
|
||||
|| state.active_turn_id.is_some()
|
||||
|| !state.pending_events.is_empty()
|
||||
|| !state.pending_runtime_requests.is_empty()
|
||||
|| (!state.goal_projection.is_null() && !goal_is_dormant(&state.goal_projection))
|
||||
|| !state.descriptor.names_qualified_release()
|
||||
|| self.candidate_admission().is_some()
|
||||
|| !acpx_release_profile(&state.descriptor.agent)
|
||||
.is_some_and(|profile| !profile.qualification_pending)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
state.validate(&self.context, &self.launch_profile_digest()?)?;
|
||||
let session = self
|
||||
.session
|
||||
.as_mut()
|
||||
.expect("checked surviving ACPX session");
|
||||
if state.identity.as_ref() != Some(session.identity()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let event_sequence = session.observed_event_sequence();
|
||||
let ready = session
|
||||
.host_park_ready(std::time::Instant::now() + Duration::from_millis(100))
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("ACPX parking observation failed: {error}"))
|
||||
})?;
|
||||
// Commit new provider observations through the ordinary event path.
|
||||
// A retained prefix must be delivered and ACKed before a later park.
|
||||
self.poll_provider(false)?;
|
||||
Ok(ready
|
||||
&& self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.observed_event_sequence() == event_sequence)
|
||||
&& self.state.as_ref().is_some_and(|state| {
|
||||
state.pending_events.is_empty()
|
||||
&& state.lifecycle == "session_open"
|
||||
&& state.active_turn_id.is_none()
|
||||
&& !state.provider_exit_unconfirmed
|
||||
&& (state.goal_projection.is_null() || goal_is_dormant(&state.goal_projection))
|
||||
}))
|
||||
}
|
||||
|
||||
fn execute(&mut self, command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
self.restore()?;
|
||||
if self
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::generated_acpx_profiles::acpx_release_profile;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -351,6 +351,7 @@ pub struct AcpxProviderSession {
|
||||
failure: Option<LocalRunnerError>,
|
||||
transport_terminated: bool,
|
||||
runtime_retired: bool,
|
||||
completed_turn_observed: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn goal_is_dormant(projection: &Value) -> bool {
|
||||
@@ -407,6 +408,7 @@ impl AcpxProviderSession {
|
||||
failure: None,
|
||||
transport_terminated: false,
|
||||
runtime_retired: false,
|
||||
completed_turn_observed: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -446,6 +448,25 @@ impl AcpxProviderSession {
|
||||
let snapshot = self
|
||||
.transport
|
||||
.request(GeneratedAcpxSidecarCommand::SessionSnapshot, json!({}))?;
|
||||
self.validate_dormant_snapshot(&snapshot)?;
|
||||
// The status barrier can queue a newer goal while the snapshot is in flight.
|
||||
// Inspect without consuming facts that ordinary polling still owns.
|
||||
let mut checked_state = self.state.clone();
|
||||
for event in self.transport.buffered_events().skip(prior_events) {
|
||||
if event.event_type != GeneratedAcpxSidecarEventType::RuntimeGoal {
|
||||
continue;
|
||||
}
|
||||
checked_state.accept_event(event)?;
|
||||
if !goal_is_dormant(&event.payload) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"saved dormant ACPX received an active or unknown goal during its snapshot",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_dormant_snapshot(&self, snapshot: &Value) -> Result<(), LocalRunnerError> {
|
||||
let identity: AcpxProviderSessionIdentity =
|
||||
serde_json::from_value(snapshot["identity"].clone()).map_err(|_| {
|
||||
LocalRunnerError::invalid("saved dormant ACPX snapshot identity is invalid")
|
||||
@@ -463,23 +484,49 @@ impl AcpxProviderSession {
|
||||
"saved dormant ACPX snapshot has unexpected work or identity",
|
||||
));
|
||||
}
|
||||
// The status barrier can queue a newer goal while the snapshot is in flight.
|
||||
// Inspect without consuming facts that ordinary polling still owns.
|
||||
let mut checked_state = self.state.clone();
|
||||
for event in self.transport.buffered_events().skip(prior_events) {
|
||||
if event.event_type != GeneratedAcpxSidecarEventType::RuntimeGoal {
|
||||
continue;
|
||||
}
|
||||
checked_state.accept_event(event)?;
|
||||
if !goal_is_dormant(&event.payload) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"saved dormant ACPX received an active or unknown goal during its snapshot",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn observed_event_sequence(&self) -> u64 {
|
||||
self.transport.observed_event_sequence()
|
||||
}
|
||||
|
||||
pub(crate) fn settle_park_observation(&mut self) -> Result<(), LocalRunnerError> {
|
||||
self.transport.settle_observation()
|
||||
}
|
||||
|
||||
pub(crate) fn host_park_ready(&mut self, deadline: Instant) -> Result<bool, LocalRunnerError> {
|
||||
if self.closed
|
||||
|| self.failure.is_some()
|
||||
|| self.transport_terminated
|
||||
|| self.runtime_retired
|
||||
|| !self.completed_turn_observed
|
||||
|| self.state.active_turn_id().is_some()
|
||||
|| self.state.has_pending_requests()
|
||||
|| self.transport.buffered_events().len() != 0
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(goal) = self
|
||||
.transport
|
||||
.observe(GeneratedAcpxSidecarCommand::SessionGoalGet, deadline)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !goal_is_dormant(&goal) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(snapshot) = self
|
||||
.transport
|
||||
.observe(GeneratedAcpxSidecarCommand::SessionSnapshot, deadline)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Polling still owns every frame observed during these reads.
|
||||
Ok(self.validate_dormant_snapshot(&snapshot).is_ok()
|
||||
&& self.transport.buffered_events().len() == 0)
|
||||
}
|
||||
|
||||
/// Read the same live sidecar before exposing its in-memory pending ledger.
|
||||
/// A durable file alone cannot authorize a request after process loss.
|
||||
pub fn verify_live_request_snapshot(
|
||||
@@ -661,6 +708,7 @@ impl AcpxProviderSession {
|
||||
}
|
||||
self.tool_bridge = next_tool_bridge;
|
||||
self.reserved_tool_bridge = next_reserved_tool_bridge;
|
||||
self.completed_turn_observed = false;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -956,6 +1004,16 @@ impl AcpxProviderSession {
|
||||
reconciled_events.push(event);
|
||||
}
|
||||
}
|
||||
for event in &reconciled_events {
|
||||
if let AcpxProviderStateEvent::TurnTerminal {
|
||||
turn_id, status, ..
|
||||
} = event
|
||||
{
|
||||
self.completed_turn_observed = *status
|
||||
== crate::acpx_event_payload::AcpxTurnStatus::Completed
|
||||
&& self.state.active_turn_id() == Some(turn_id.as_str());
|
||||
}
|
||||
}
|
||||
self.state = next_state;
|
||||
self.tool_bridge = next_bridge;
|
||||
self.reserved_tool_bridge = next_reserved_bridge;
|
||||
@@ -2170,6 +2228,7 @@ mod mode_tests {
|
||||
failure: None,
|
||||
transport_terminated: false,
|
||||
runtime_retired: false,
|
||||
completed_turn_observed: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+130
-19
@@ -86,6 +86,7 @@ pub struct AcpxSidecarTransport {
|
||||
stderr_tail: BoundedLogBuffer,
|
||||
stderr_categories: BTreeSet<&'static str>,
|
||||
poisoned: bool,
|
||||
abandoned_observation: Option<u64>,
|
||||
}
|
||||
|
||||
// A fresh Pi process verifies and copies its native closure before ACP admission.
|
||||
@@ -387,6 +388,7 @@ impl AcpxSidecarTransport {
|
||||
stderr_tail: BoundedLogBuffer::new(32, 8 * 1024),
|
||||
stderr_categories: BTreeSet::new(),
|
||||
poisoned: false,
|
||||
abandoned_observation: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -469,6 +471,86 @@ impl AcpxSidecarTransport {
|
||||
command: GeneratedAcpxSidecarCommand,
|
||||
params: Value,
|
||||
) -> Result<CommandOutcome, LocalRunnerError> {
|
||||
let request_id = self.send_request(command, params)?;
|
||||
self.receive_response(
|
||||
command,
|
||||
request_id,
|
||||
Instant::now() + self.command_timeout(command),
|
||||
)?
|
||||
.ok_or_else(|| self.request_timeout_error(command))
|
||||
}
|
||||
|
||||
/// A park observation may expire without revoking a healthy provider.
|
||||
/// Retain one ID so its late reply cannot masquerade as an ordinary reply.
|
||||
pub(crate) fn observe(
|
||||
&mut self,
|
||||
command: GeneratedAcpxSidecarCommand,
|
||||
deadline: Instant,
|
||||
) -> Result<Option<Value>, LocalRunnerError> {
|
||||
if self.poisoned {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX sidecar transport is unavailable",
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
command,
|
||||
GeneratedAcpxSidecarCommand::SessionGoalGet
|
||||
| GeneratedAcpxSidecarCommand::SessionSnapshot
|
||||
) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX observation must be read-only",
|
||||
));
|
||||
}
|
||||
if self.abandoned_observation.is_some() || Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
let result = self
|
||||
.send_request(command, json!({}))
|
||||
.and_then(|request_id| {
|
||||
let result = self.receive_response(command, request_id, deadline)?;
|
||||
if result.is_none() {
|
||||
self.abandoned_observation = Some(request_id);
|
||||
}
|
||||
Ok(result)
|
||||
});
|
||||
match result {
|
||||
Ok(Some(CommandOutcome::Success(value))) => Ok(Some(value)),
|
||||
Ok(Some(CommandOutcome::Rejected(_))) => Ok(None),
|
||||
Ok(None) => Ok(None),
|
||||
Err(error) => {
|
||||
self.poison();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observed_event_sequence(&self) -> u64 {
|
||||
self.last_event_sequence
|
||||
}
|
||||
|
||||
/// Ordinary turn admission drains an expired read before replacing its sidecar.
|
||||
pub(crate) fn settle_observation(&mut self) -> Result<(), LocalRunnerError> {
|
||||
let Some(request_id) = self.abandoned_observation.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let command = GeneratedAcpxSidecarCommand::SessionSnapshot;
|
||||
match self.receive_response(command, request_id, Instant::now() + self.request_timeout) {
|
||||
Ok(Some(_)) => Ok(()),
|
||||
result => {
|
||||
self.poison();
|
||||
Err(match result {
|
||||
Err(error) => error,
|
||||
_ => self.request_timeout_error(command),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_request(
|
||||
&mut self,
|
||||
command: GeneratedAcpxSidecarCommand,
|
||||
params: Value,
|
||||
) -> Result<u64, LocalRunnerError> {
|
||||
if !params.is_object() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX sidecar command params must be an object",
|
||||
@@ -502,19 +584,30 @@ impl AcpxSidecarTransport {
|
||||
})?;
|
||||
self.next_request_id = request_id + 1;
|
||||
|
||||
let timeout = self.command_timeout(command);
|
||||
let deadline = Instant::now() + timeout;
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
fn receive_response(
|
||||
&mut self,
|
||||
command: GeneratedAcpxSidecarCommand,
|
||||
request_id: u64,
|
||||
deadline: Instant,
|
||||
) -> Result<Option<CommandOutcome>, LocalRunnerError> {
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(self.request_timeout_error(command));
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(line) = self.receive_stdout_line(remaining, command.as_str())? else {
|
||||
return Err(self.request_timeout_error(command));
|
||||
return Ok(None);
|
||||
};
|
||||
match parse_frame(&line)? {
|
||||
ParsedFrame::Event(event) => self.buffer_event(event)?,
|
||||
ParsedFrame::Response(response) => {
|
||||
if self.abandoned_observation == Some(response.id) {
|
||||
self.abandoned_observation = None;
|
||||
continue;
|
||||
}
|
||||
if response.id != request_id {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"ACPX sidecar response id mismatch: expected {request_id}, received {}",
|
||||
@@ -522,20 +615,20 @@ impl AcpxSidecarTransport {
|
||||
)));
|
||||
}
|
||||
if response.ok {
|
||||
return Ok(CommandOutcome::Success(
|
||||
return Ok(Some(CommandOutcome::Success(
|
||||
response.result.unwrap_or_else(|| json!({})),
|
||||
));
|
||||
)));
|
||||
}
|
||||
let error = response.error.expect("failed response has validated error");
|
||||
let provider_failure = error.provider_failure.clone();
|
||||
return Ok(CommandOutcome::Rejected(LocalRunnerError::invalid(
|
||||
return Ok(Some(CommandOutcome::Rejected(LocalRunnerError::invalid(
|
||||
format!(
|
||||
"ACPX sidecar command {} was rejected (retryable={}, classification={})",
|
||||
command.as_str(),
|
||||
error.retryable,
|
||||
response_error_classification(&error),
|
||||
),
|
||||
).with_provider_failure(provider_failure)));
|
||||
).with_provider_failure(provider_failure))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,18 +638,32 @@ impl AcpxSidecarTransport {
|
||||
&mut self,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<AcpxSidecarEvent>, LocalRunnerError> {
|
||||
let Some(line) = self.receive_stdout_line(timeout, "event.poll")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
match parse_frame(&line)? {
|
||||
ParsedFrame::Event(event) => {
|
||||
self.validate_event_sequence(event.sequence)?;
|
||||
Ok(Some(event))
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let Some(line) = self.receive_stdout_line(
|
||||
deadline.saturating_duration_since(Instant::now()),
|
||||
"event.poll",
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
match parse_frame(&line)? {
|
||||
ParsedFrame::Event(event) => {
|
||||
self.validate_event_sequence(event.sequence)?;
|
||||
return Ok(Some(event));
|
||||
}
|
||||
ParsedFrame::Response(response)
|
||||
if self.abandoned_observation == Some(response.id) =>
|
||||
{
|
||||
self.abandoned_observation = None;
|
||||
}
|
||||
ParsedFrame::Response(response) => {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"ACPX sidecar emitted response {} without a pending request",
|
||||
response.id
|
||||
)))
|
||||
}
|
||||
}
|
||||
ParsedFrame::Response(response) => Err(LocalRunnerError::invalid(format!(
|
||||
"ACPX sidecar emitted response {} without a pending request",
|
||||
response.id
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,3 +1511,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "tests/acpx_park_observation.rs"]
|
||||
mod park_observation_tests;
|
||||
@@ -124,6 +124,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"pendingToolCount":if fault == "tool" || source_pending_completion.is_some() {1} else {0}, "pendingInputCount":0,
|
||||
"pendingRuntimeRequests":[]});
|
||||
}
|
||||
if command == "session.snapshot" && fault == "foreign-snapshot" {
|
||||
response["result"]["identity"]["agentSessionId"] = json!("foreign-session");
|
||||
}
|
||||
if command == "session.goal.get" {
|
||||
source_goal_revision += 1;
|
||||
response["result"]["providerRevision"] = json!(source_goal_revision);
|
||||
@@ -153,6 +156,16 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
)?;
|
||||
next_sequence += 1;
|
||||
}
|
||||
if command == "session.snapshot" && fault == "slow-snapshot" {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
write_json(
|
||||
&mut stdout,
|
||||
&json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"sequence":next_sequence,"eventType":"runtime.diagnostic","runId":source_run,"turnId":null,
|
||||
"payload":{"code":"slow_park_observation","message":"Retained after a slow read."}}),
|
||||
)?;
|
||||
next_sequence += 1;
|
||||
}
|
||||
if command == "session.snapshot" && fault == "goal-during-snapshot" {
|
||||
source_goal_revision += 1;
|
||||
let frame = json!({
|
||||
@@ -172,6 +185,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
response["result"]["sessionGoals"] = json!({"availability":"available", "actions":["set","pause","resume","clear"],
|
||||
"autonomousUpdates":true,"persistentAcrossResume":true,"maxObjectiveChars":4000,"tokenBudgetControl":false,"usageReporting":false});
|
||||
}
|
||||
if (command == "session.goal.get" && fault == "reject-goal")
|
||||
|| (command == "session.snapshot" && fault == "reject-snapshot")
|
||||
{
|
||||
response = json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,"id":id,"ok":false,
|
||||
"error":{"code":"temporarily_unavailable","message":"Optional read is temporarily unavailable.","retryable":true}});
|
||||
}
|
||||
write_json(&mut stdout, &response)?;
|
||||
if command == "turn.start" {
|
||||
let turn = request["params"]["turnId"].as_str().unwrap();
|
||||
|
||||
@@ -332,6 +332,7 @@ impl CommandExecutor for NativeProviderCommandExecutor {
|
||||
}
|
||||
match self.selected.as_mut() {
|
||||
Some(SelectedExecutor::LocalFacade(executor)) => executor.host_park_ready(),
|
||||
Some(SelectedExecutor::Acpx(executor)) => executor.host_park_ready(),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
use super::*;
|
||||
|
||||
fn scripted_transport(reply_id: u64) -> AcpxSidecarTransport {
|
||||
let event = json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"sequence":1,"eventType":"runtime.diagnostic","runId":null,"turnId":null,
|
||||
"payload":{"code":"park_tail","message":"Preserve this exact event."}});
|
||||
let late = json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,"id":1,"ok":true,"result":{"late":true}});
|
||||
let ordinary = json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,"id":reply_id,"ok":true,"result":{"ordinary":true}});
|
||||
AcpxSidecarTransport::start(&AcpxSidecarTransportConfig {
|
||||
command: "/bin/sh".into(),
|
||||
args: vec!["-c".to_owned(), format!("read first; sleep 0.1; printf '%s\\n' '{event}' '{late}'; read second; printf '%s\\n' '{ordinary}'; cat >/dev/null")],
|
||||
verified_launch: None,
|
||||
request_timeout: Duration::from_secs(1),
|
||||
shutdown_grace: Duration::from_millis(20),
|
||||
}).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_park_observation_reply_preserves_ordinary_commands_and_event_polling() {
|
||||
for poll_first in [false, true] {
|
||||
let mut transport = scripted_transport(2);
|
||||
let start = Instant::now();
|
||||
assert!(transport
|
||||
.observe(
|
||||
GeneratedAcpxSidecarCommand::SessionSnapshot,
|
||||
start + Duration::from_millis(10)
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(transport.abandoned_observation, Some(1));
|
||||
assert!(transport
|
||||
.observe(
|
||||
GeneratedAcpxSidecarCommand::SessionGoalGet,
|
||||
Instant::now() + Duration::from_millis(10)
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(transport.next_request_id, 2);
|
||||
let event = if poll_first {
|
||||
let event = transport
|
||||
.poll_event(Duration::from_secs(1))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(transport
|
||||
.poll_event(Duration::from_millis(10))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
event
|
||||
} else {
|
||||
assert_eq!(
|
||||
transport
|
||||
.request(GeneratedAcpxSidecarCommand::SessionGoalGet, json!({}))
|
||||
.unwrap(),
|
||||
json!({"ordinary":true})
|
||||
);
|
||||
transport.poll_event(Duration::ZERO).unwrap().unwrap()
|
||||
};
|
||||
assert_eq!(event.sequence, 1);
|
||||
assert_eq!(
|
||||
event.payload,
|
||||
json!({"code":"park_tail","message":"Preserve this exact event."})
|
||||
);
|
||||
assert_eq!(transport.abandoned_observation, None);
|
||||
if poll_first {
|
||||
assert_eq!(
|
||||
transport
|
||||
.request(GeneratedAcpxSidecarCommand::SessionGoalGet, json!({}))
|
||||
.unwrap(),
|
||||
json!({"ordinary":true})
|
||||
);
|
||||
}
|
||||
assert!(!transport.poisoned);
|
||||
transport.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_park_observation_does_not_relax_ordinary_reply_identity() {
|
||||
for wrong_id in [1, 99] {
|
||||
let mut transport = scripted_transport(wrong_id);
|
||||
assert!(transport
|
||||
.observe(
|
||||
GeneratedAcpxSidecarCommand::SessionSnapshot,
|
||||
Instant::now() + Duration::from_millis(10)
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let error = transport
|
||||
.request(GeneratedAcpxSidecarCommand::SessionGoalGet, json!({}))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("response id mismatch"));
|
||||
assert!(transport.poisoned);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,169 @@ fn settle(executor: &mut NativeProviderCommandExecutor) -> Vec<serde_json::Value
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_park_slow_acpx_observation_preserves_the_provider_and_next_turn() {
|
||||
for agent in ["codex", "claude"] {
|
||||
let (fixture, _config, mut executor, opened) = fixture(agent);
|
||||
assert!(
|
||||
!executor.host_park_ready().unwrap(),
|
||||
"opening is not completed work"
|
||||
);
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"turnId":"first", "text":"Complete first work."}),
|
||||
))
|
||||
.unwrap();
|
||||
settle(&mut executor);
|
||||
assert!(
|
||||
executor.host_park_ready().unwrap(),
|
||||
"completed surviving ACPX must become eligible"
|
||||
);
|
||||
fs::write(fixture.0.join("fault"), "slow-snapshot").unwrap();
|
||||
let start = Instant::now();
|
||||
assert!(!executor.host_park_ready().unwrap());
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_millis(500),
|
||||
"park probe blocked ordinary work"
|
||||
);
|
||||
assert!(
|
||||
!executor.host_park_ready().unwrap(),
|
||||
"an unfinished observation cannot prove readiness"
|
||||
);
|
||||
assert!(std::process::Command::new("kill")
|
||||
.args(["-0", &opened["processId"].to_string()])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
fs::write(fixture.0.join("fault"), "").unwrap();
|
||||
let second = executor
|
||||
.execute(&command(
|
||||
5,
|
||||
"turn.start",
|
||||
json!({"turnId":"second", "text":"Continue on the healthy session."}),
|
||||
))
|
||||
.unwrap();
|
||||
let events = settle(&mut executor);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| event["payload"]["code"] == "slow_park_observation")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| event["payload"]["message"] == "Retained after a slow read."));
|
||||
let snapshot = executor
|
||||
.execute(&command(6, "session.snapshot", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
snapshot.result["providerSessionId"],
|
||||
opened["providerSessionId"]
|
||||
);
|
||||
// Existing MCP receipt epoch isolation replaces the sidecar on the next turn.
|
||||
assert!(second.result["status"] == "accepted");
|
||||
assert!(executor.host_park_ready().unwrap());
|
||||
let journal = fs::read_to_string(fixture.0.join("sidecar.jsonl")).unwrap();
|
||||
assert_eq!(
|
||||
journal
|
||||
.lines()
|
||||
.filter(|line| line.contains("processStart"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
journal
|
||||
.lines()
|
||||
.filter(|line| line.contains("\"command\":\"turn.start\""))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
executor.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_park_acpx_preserves_late_goals_and_rejects_foreign_or_busy_observations() {
|
||||
for agent in ["codex", "claude"] {
|
||||
let (fixture, _config, mut executor, _) = fixture(agent);
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"turnId":"first", "text":"Complete."}),
|
||||
))
|
||||
.unwrap();
|
||||
settle(&mut executor);
|
||||
for fault in [
|
||||
"active",
|
||||
"tool",
|
||||
"goal",
|
||||
"foreign-snapshot",
|
||||
"reject-goal",
|
||||
"reject-snapshot",
|
||||
] {
|
||||
fs::write(fixture.0.join("fault"), fault).unwrap();
|
||||
assert!(
|
||||
!executor.host_park_ready().unwrap(),
|
||||
"{agent} admitted {fault}"
|
||||
);
|
||||
}
|
||||
fs::write(fixture.0.join("fault"), "").unwrap();
|
||||
assert!(
|
||||
executor.host_park_ready().unwrap(),
|
||||
"a refused optional read must leave the provider healthy"
|
||||
);
|
||||
fs::write(fixture.0.join("fault"), "goal-during-snapshot").unwrap();
|
||||
assert!(!executor.host_park_ready().unwrap());
|
||||
let retained = executor.retained_events().unwrap();
|
||||
assert!(retained.iter().any(|event| event
|
||||
.payload
|
||||
.pointer("/goal/status")
|
||||
.and_then(Value::as_str)
|
||||
== Some("active")));
|
||||
assert_eq!(executor.retained_events().unwrap(), retained);
|
||||
assert!(!executor.host_park_ready().unwrap());
|
||||
executor.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_park_acpx_requires_completion_in_this_lifetime_and_exact_authority() {
|
||||
let (fixture, mut config, mut executor, _) = fixture("claude");
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"turnId":"first", "text":"Complete."}),
|
||||
))
|
||||
.unwrap();
|
||||
settle(&mut executor);
|
||||
assert!(executor.host_park_ready().unwrap());
|
||||
config.run_id = "other-run".to_owned();
|
||||
executor.rotate_authority(&config);
|
||||
assert!(!executor.host_park_ready().unwrap());
|
||||
config.run_id = "run-1".to_owned();
|
||||
drop(executor);
|
||||
let before = fs::read_to_string(fixture.0.join("sidecar.jsonl")).unwrap();
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&fixture.0, &config);
|
||||
assert!(!recovered.host_park_ready().unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("sidecar.jsonl")).unwrap(),
|
||||
before
|
||||
);
|
||||
recovered
|
||||
.execute(&command(4, "session.snapshot", json!({})))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!recovered.host_park_ready().unwrap(),
|
||||
"restored identity is not a completed current-generation turn"
|
||||
);
|
||||
recovered.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_acpx_read_tail_survives_immediate_next_turn_without_a_park_probe() {
|
||||
let (fixture, _config, mut executor, _) = fixture("claude");
|
||||
@@ -291,3 +454,18 @@ fn rejected_read_tail_returns_a_command_error_without_starting_another_turn() {
|
||||
assert_eq!(state["lifecycle"], "closed");
|
||||
assert_eq!(state["activeTurnId"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_park_acpx_does_not_enable_an_unqualified_park_provider() {
|
||||
let (_fixture, _config, mut executor, _) = fixture("grok");
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"turnId":"first", "text":"Complete on an otherwise qualified release."}),
|
||||
))
|
||||
.unwrap();
|
||||
settle(&mut executor);
|
||||
assert!(!executor.host_park_ready().unwrap());
|
||||
executor.shutdown().unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user