Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ Headless `run`/`resume`에서 도구 인자 검증이 거절되면 기존 verdic
값·실제 경로·원문 오류는 출력하지 않으며, 이 정보는 자동 재시도 권한이나 durable 증빙이 아니다.
호스트 연동 계약과 제한은 [ADR-0038](docs/adr/0038-safe-invocation-diagnostics.md)을 따른다.

불확정 모델 호출은 `xgeny recover RUN_ID`로 오프라인 조회할 수 있습니다. 응답 수락을 명시적으로
포기하려면 조회한 정확한 ID로 `xgeny recover RUN_ID --discard-model-call CALL_ID`를 실행합니다.
소비한 호출 예산은 복구되지 않으며 이 명령은 모델·도구를 호출하거나 Run을 재개하지 않습니다.
이후 재개에도 원래 workspace·권한·남은 예산이 필요합니다.
[복구 절차와 호스트 연동 경계](docs/development/local-model-call-recovery.md)를 먼저 확인하세요.

## 제품 원칙

- 사용자는 `xgeny` 하나만 설치합니다.
Expand Down
122 changes: 122 additions & 0 deletions crates/xgeny-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,128 @@ pub enum PublicRunError {
Internal,
}

/// Bounded offline recovery view, never a provider response or a completion claim.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ModelCallRecoveryReport {
pub format_version: u32,
pub run_id: String,
pub journal_sequence: u64,
pub journal_head_digest: String,
pub active_call: Option<RecoveryModelCall>,
pub lifecycle_configured: bool,
pub reserved_calls: u32,
pub max_model_calls: u32,
pub settled_calls: u32,
pub unknown_calls: u32,
pub effect_recovery_required: bool,
pub discarded_call_id: Option<String>,
}

/// Only a Core-generated identity and the durable closed status taxonomy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RecoveryModelCall {
pub call_id: String,
pub status: ModelCallStatus,
}

/// Inspect model-call recovery under the Run lease without changing journal state.
/// Does not resolve credentials, open the workspace, or invoke providers/tools.
///
/// # Errors
/// Returns a closed public error for an invalid layout, held lease, or corrupt state.
pub fn inspect_local_model_call(run_id: &str) -> Result<ModelCallRecoveryReport, PublicRunError> {
local_model_call_recovery(run_id, None)
}

/// Explicitly stop accepting the exact active call's response. Never refunds a slot,
/// asserts non-delivery, retries a request, or resumes the Run.
///
/// # Errors
/// Also fails before mutation for a missing/stale call ID or a completed Run.
pub fn discard_local_model_call(
run_id: &str,
call_id: &str,
) -> Result<ModelCallRecoveryReport, PublicRunError> {
local_model_call_recovery(run_id, Some(call_id))
}

fn local_model_call_recovery(
run_id: &str,
discard_call_id: Option<&str>,
) -> Result<ModelCallRecoveryReport, PublicRunError> {
let state_root = discover_state_root().map_err(|_| PublicRunError::Configuration)?;
let layout =
RunLayout::existing(&state_root, run_id).map_err(|_| PublicRunError::Configuration)?;
let lease = acquire_lease(&layout, run_id)?;
let manifest = layout
.read_manifest()
.map_err(|_| PublicRunError::Integrity)?;
let store = SqliteRunStore::open_existing_read_only(layout.database_path())
.map_err(|_| PublicRunError::Integrity)?;
let mut state = store
.load_current()
.map_err(|_| PublicRunError::Integrity)?
.ok_or(PublicRunError::Integrity)?;
verify_manifest_state(&manifest, &state)?;
let completed = load_offline_completion(&store, &state)?.is_some();
if let Some(call_id) = discard_call_id {
let active = state
.agent_loop
.as_ref()
.and_then(|agent| agent.model_calls.as_ref())
.and_then(|calls| calls.active_call.as_ref());
if completed || active.is_none_or(|call| call.reservation.call_id() != call_id) {
return Err(PublicRunError::Configuration);
}
drop(store);
let mut store = reopen_writable_verified(&layout, &manifest, &state)?;
let runtime = AgentLoop::with_model_call_budget(
manifest
.budget()
.agent_loop()
.map_err(|_| PublicRunError::Integrity)?,
manifest
.budget()
.model_calls()
.map_err(|_| PublicRunError::Integrity)?,
);
let tick = runtime
.abandon_model_call(&mut store, &mut HostEventFactory, &lease, call_id)
.map_err(|_| PublicRunError::Integrity)?;
if !matches!(tick, AgentLoopTick::ModelCallAbandoned { .. }) {
return Err(PublicRunError::Integrity);
}
state = store
.load_current()
.map_err(|_| PublicRunError::Integrity)?
.ok_or(PublicRunError::Integrity)?;
}
let calls = state
.agent_loop
.as_ref()
.and_then(|agent| agent.model_calls.as_ref());
Ok(ModelCallRecoveryReport {
format_version: 1,
run_id: state.run_id.clone(),
journal_sequence: state.journal_sequence,
journal_head_digest: state.journal_head_digest.clone(),
active_call: calls
.and_then(|calls| calls.active_call.as_ref())
.map(|call| RecoveryModelCall {
call_id: call.reservation.call_id().to_owned(),
status: call.status,
}),
lifecycle_configured: calls.is_some(),
reserved_calls: calls.map_or(0, |calls| calls.reserved_calls),
max_model_calls: manifest.budget().max_model_calls,
settled_calls: calls.map_or(0, |calls| calls.settled_calls),
unknown_calls: calls.map_or(0, |calls| calls.unknown_calls),
effect_recovery_required: executing_step_id(&state).is_some()
|| has_effect_uncertainty(&state),
discarded_call_id: discard_call_id.map(str::to_owned),
})
}

impl PublicRunError {
#[must_use]
pub const fn code(self) -> &'static str {
Expand Down
39 changes: 38 additions & 1 deletion crates/xgeny-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use xgeny_cli::{
LocalProcessSession, LocalResumeRequest, LocalRunRequest, ModelCheckError, ModelCheckRequest,
ModelCredentialStore, ModelProfile, ModelProfileError, ModelProfileStore,
OsModelCredentialStore, PublicRunError, check_openai_compatibility, check_openai_model,
list_openai_models, new_credential_reference, prepare_local_process_session, resume_local,
discard_local_model_call, inspect_local_model_call, list_openai_models,
new_credential_reference, prepare_local_process_session, resume_local,
resume_local_with_model_resolver, resume_local_with_model_resolver_and_progress,
resume_local_with_process_session_and_model_resolver_progress,
run_local_with_process_session_progress, run_local_with_started,
Expand Down Expand Up @@ -57,6 +58,20 @@ enum Command {
Run(RunArgs),
/// Continue an existing Run, or replay its durable completion without model access.
Resume(ResumeArgs),
/// Inspect or explicitly discard an unresolved model call offline; never resumes the Run.
Recover(RecoverArgs),
}

#[derive(Debug, Args)]
#[command(
after_long_help = "Without --discard-model-call this command only inspects verified journal state. Discard stops accepting the exact call's response; it does NOT prove non-delivery or refund consumed budget. Neither form calls a model or tool. Continue separately with resume and the original workspace, catalogs, profile, permissions and remaining budget. Output is a bounded JSON report, not a completion result."
)]
struct RecoverArgs {
/// Durable Run identifier printed by `xgeny run`.
run_id: String,
/// Explicitly discard this exact active call ID obtained from a prior inspection.
#[arg(long, value_name = "CALL_ID")]
discard_model_call: Option<String>,
}

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -284,6 +299,7 @@ fn main() -> ExitCode {
Some(Command::Model { command }) => run_model_command(command),
Some(Command::Run(args)) => run_command(args),
Some(Command::Resume(args)) => resume_command(args),
Some(Command::Recover(args)) => recover_command(&args),
}
}

Expand Down Expand Up @@ -620,6 +636,27 @@ fn resume_command(args: ResumeArgs) -> ExitCode {
}
}

fn recover_command(args: &RecoverArgs) -> ExitCode {
let result = match &args.discard_model_call {
Some(call_id) => discard_local_model_call(&args.run_id, call_id),
None => inspect_local_model_call(&args.run_id),
};
match result {
Ok(report) => {
// A failed stdout write can follow a committed discard. Inspect before retrying.
let mut stdout = std::io::stdout().lock();
if serde_json::to_writer(&mut stdout, &report).is_err()
|| stdout.write_all(b"\n").is_err()
|| stdout.flush().is_err()
{
return present(Err(PublicRunError::Internal));
}
ExitCode::SUCCESS
}
Err(error) => present(Err(error)),
}
}

const MAX_TOKEN_INPUT_BYTES: u64 = 16 * 1024;

struct ResolvedModel {
Expand Down
Loading