diff --git a/apps/signalboxd/src/context_guard.rs b/apps/signalboxd/src/context_guard.rs index 0dd5576883..1e5c7814d9 100644 --- a/apps/signalboxd/src/context_guard.rs +++ b/apps/signalboxd/src/context_guard.rs @@ -13,6 +13,7 @@ use signalbox_domain::{ }; use signalbox_model_provider_runtime::{ContextCompactionModel, RuntimeModelCatalog}; use signalbox_persistence::{ + goal::GoalExecutionFailureRecoveryCause, model_execution::{ModelCallRepositoryError, PostgresModelCallRepository}, start_eligible_turn::{ CommitActivationPreviewError, CommitActivationPreviewOutcome, @@ -188,14 +189,16 @@ impl ReportedUsageCompaction { { Ok(applied) => applied, Err(crate::process_runtime::AutomaticContextCompactionError::AlreadyAttempted) => { - match close_failed_compaction_turn(&self.activation, &self.model_calls, preview) - .await - .map_err( - |source| ReportedUsageCompactionError::CompactionFailureClosure { - turn, - source, - }, - )? { + match close_failed_compaction_turn( + &self.activation, + &self.model_calls, + preview, + None, + ) + .await + .map_err(|source| { + ReportedUsageCompactionError::CompactionFailureClosure { turn, source } + })? { CommitCompactionFailurePreviewOutcome::Failed(_) => { tracing::warn!( cause_code = "reported_usage_context_compaction_exhausted", @@ -220,11 +223,16 @@ impl ReportedUsageCompaction { commit_ambiguous: true, }) { - match close_failed_compaction_turn(&self.activation, &self.model_calls, preview) - .await - .map_err(|source| { - ReportedUsageCompactionError::CompactionFailureClosure { turn, source } - })? { + match close_failed_compaction_turn( + &self.activation, + &self.model_calls, + preview, + compaction_recovery_cause(&error), + ) + .await + .map_err(|source| { + ReportedUsageCompactionError::CompactionFailureClosure { turn, source } + })? { CommitCompactionFailurePreviewOutcome::Failed(_) => {} CommitCompactionFailurePreviewOutcome::Stale => return Ok(()), } @@ -247,14 +255,19 @@ impl ReportedUsageCompaction { return Ok(()); }; let remaining_turn = remaining.turn; - match close_failed_compaction_turn(&self.activation, &self.model_calls, remaining.preview) - .await - .map_err( - |source| ReportedUsageCompactionError::CompactionFailureClosure { - turn: remaining_turn, - source, - }, - )? { + match close_failed_compaction_turn( + &self.activation, + &self.model_calls, + remaining.preview, + None, + ) + .await + .map_err( + |source| ReportedUsageCompactionError::CompactionFailureClosure { + turn: remaining_turn, + source, + }, + )? { CommitCompactionFailurePreviewOutcome::Failed(_) => { tracing::warn!( cause_code = "reported_usage_context_still_exceeded", @@ -669,6 +682,7 @@ where &activation, &model_calls, preview, + None, ) .await .map_err(|source| { @@ -697,6 +711,7 @@ where &activation, &model_calls, preview, + None, ) .await .map_err(|source| { @@ -722,6 +737,7 @@ where &activation, &model_calls, preview, + compaction_recovery_cause(&error), ) .await .map_err(|source| { @@ -881,6 +897,7 @@ async fn close_failed_compaction_turn( activation: &StartEligibleTurnRepository, model_calls: &PostgresModelCallRepository, preview: PreparedActivationPreview, + recovery_cause: Option, ) -> Result { loop { let identities = FailedModelCallTurnIdentities::new( @@ -888,7 +905,12 @@ async fn close_failed_compaction_turn( ContextFrontierId::from_uuid(uuid::Uuid::now_v7()), ); match activation - .commit_compaction_failure_preview(preview.clone(), model_calls, identities) + .commit_compaction_failure_preview( + preview.clone(), + model_calls, + identities, + recovery_cause, + ) .await { Err(error) if compaction_failure_closure_collision_is_retryable(&error) => {} @@ -897,6 +919,16 @@ async fn close_failed_compaction_turn( } } +fn compaction_recovery_cause( + error: &crate::process_runtime::AutomaticContextCompactionError, +) -> Option { + matches!( + error, + crate::process_runtime::AutomaticContextCompactionError::InputDoesNotFit + ) + .then_some(GoalExecutionFailureRecoveryCause::ContextCompactionInputDoesNotFit) +} + fn compaction_failure_closure_collision_is_retryable(error: &CommitActivationPreviewError) -> bool { matches!( error, @@ -918,6 +950,7 @@ mod tests { }; use signalbox_persistence::{ context_compaction::ContextCompactionRepositoryError, + goal::GoalExecutionFailureRecoveryCause, model_execution::{ModelCallIdentityCollision, ModelCallRepositoryError}, start_eligible_turn::{ CommitActivationPreviewError, StartEligibleTurnIdentityCollision, @@ -927,8 +960,25 @@ mod tests { use super::{ ContextGuardedTurnPassError, compaction_failure_closure_collision_is_retryable, - guarded_failure_stage, persisted_preflight_prefix, report_guarded_ambiguity, + compaction_recovery_cause, guarded_failure_stage, persisted_preflight_prefix, + report_guarded_ambiguity, }; + + #[test] + fn no_fitting_compaction_input_requires_operator_recovery() { + assert_eq!( + compaction_recovery_cause(&AutomaticContextCompactionError::InputDoesNotFit), + Some(GoalExecutionFailureRecoveryCause::ContextCompactionInputDoesNotFit) + ); + } + + #[test] + fn transient_compaction_failure_keeps_automatic_recovery() { + assert_eq!( + compaction_recovery_cause(&AutomaticContextCompactionError::Model), + None + ); + } use crate::{ ActivatedTurnExecution, FatalExecutionSignal, FatalExecutionSupervisor, TurnPassExecutionStage, process_runtime::AutomaticContextCompactionError, diff --git a/apps/signalboxd/src/goal_mode.rs b/apps/signalboxd/src/goal_mode.rs index 16e46023ff..f898cba01d 100644 --- a/apps/signalboxd/src/goal_mode.rs +++ b/apps/signalboxd/src/goal_mode.rs @@ -18,7 +18,8 @@ use signalbox_domain::{ }; use signalbox_persistence::{ goal::{ - GoalCommandHandlingOutcome, GoalRepository, GoalRepositoryError, GoalTransitionOutcome, + GoalCommandHandlingOutcome, GoalExecutionFailureRecoveryCause, GoalRepository, + GoalRepositoryError, GoalTransitionOutcome, }, goal_turn::{GoalTurnCandidates, GoalTurnContinuationOutcome}, }; @@ -63,6 +64,7 @@ const GOAL_DECLARE_REJECTED: &str = const GOAL_DECLARE_RESULT: &str = "{\"status\":\"applied\"}"; const EXECUTION_FAILURE_NEED: &str = "Resolve the failed goal turn's execution condition, then resume the goal."; +const CONTEXT_COMPACTION_INPUT_DOES_NOT_FIT_NEED: &str = "No safe context-compaction boundary fits the configured model window. Start a fresh session or reduce the imported context before resuming this goal; no automatic resumption is scheduled."; /// Preamble for an execution-failure block automatic resumption still owes. /// /// The repair follows it rather than replacing it with a promise of automation, @@ -569,15 +571,27 @@ impl PostgresGoalPassDisposition { blocked: GoalEventOrdinal, resumption: AutomaticResumption, ) { - let AutomaticResumption::Scheduled { delay } = resumption else { - tracing::warn!( - session = %session.into_uuid(), - event_ordinal = blocked.get(), - attempt_budget = ?self.numeric_bounds.attempt_budget, - cause_code = "goal_automatic_resume_exhausted", - "blocked goal exhausted automatic resumption and awaits an operator" - ); - return; + let delay = match resumption { + AutomaticResumption::Scheduled { delay } => delay, + AutomaticResumption::Exhausted { .. } => { + tracing::warn!( + session = %session.into_uuid(), + event_ordinal = blocked.get(), + attempt_budget = ?self.numeric_bounds.attempt_budget, + cause_code = "goal_automatic_resume_exhausted", + "blocked goal exhausted automatic resumption and awaits an operator" + ); + return; + } + AutomaticResumption::OperatorRequired { cause } => { + tracing::warn!( + session = %session.into_uuid(), + event_ordinal = blocked.get(), + cause_code = cause.code(), + "blocked goal has a durable non-resumable execution failure and awaits an operator" + ); + return; + } }; let adapter = self.clone(); drop(tokio::spawn(async move { @@ -918,9 +932,18 @@ impl GoalPassDisposition for PostgresGoalPassDisposition { ) -> impl Future> + Send + 'static { let adapter = self.clone(); async move { - let resumption = adapter - .plan_automatic_resumption(session, Some(turn)) - .await?; + let resumption = match adapter + .repository + .execution_failure_recovery_cause(session, turn) + .await? + { + Some(cause) => AutomaticResumption::OperatorRequired { cause }, + None => { + adapter + .plan_automatic_resumption(session, Some(turn)) + .await? + } + }; let outcome = match adapter .repository .block_execution_failure( @@ -974,6 +997,11 @@ enum AutomaticResumption { }, /// The consecutive-attempt budget is spent; only an operator can resume. Exhausted { attempt_budget: u32 }, + /// Durable failure evidence proves unchanged automatic resumption cannot progress. + OperatorRequired { + /// Exact recorded reason the automatic path cannot make progress. + cause: GoalExecutionFailureRecoveryCause, + }, } impl AutomaticResumption { @@ -1002,6 +1030,9 @@ impl AutomaticResumption { Self::Exhausted { attempt_budget } => format!( "Automatic resumption is exhausted after {attempt_budget} consecutive execution failures. {EXECUTION_FAILURE_NEED}" ), + Self::OperatorRequired { + cause: GoalExecutionFailureRecoveryCause::ContextCompactionInputDoesNotFit, + } => String::from(CONTEXT_COMPACTION_INPUT_DOES_NOT_FIT_NEED), }; GoalNeed::try_new(text).map_err(|_| PostgresGoalPassDispositionError::InvalidStaticNeed) } @@ -1412,9 +1443,18 @@ mod tests { } .need() .expect("the exhausted need is admitted"); + let operator_required = AutomaticResumption::OperatorRequired { + cause: GoalExecutionFailureRecoveryCause::ContextCompactionInputDoesNotFit, + } + .need() + .expect("the operator-required need is admitted"); assert!(scheduled.as_str().ends_with(EXECUTION_FAILURE_NEED)); assert!(exhausted.as_str().ends_with(EXECUTION_FAILURE_NEED)); + assert_eq!( + operator_required.as_str(), + CONTEXT_COMPACTION_INPUT_DOES_NOT_FIT_NEED + ); assert_eq!( scheduled.as_str(), "The goal turn failed to execute and automatic resumption is scheduled. If the goal is still blocked here once resumption ends, it is waiting for an operator. Resolve the failed goal turn's execution condition, then resume the goal." diff --git a/crates/persistence/migrations/202608210617_goal_execution_failure_recovery.sql b/crates/persistence/migrations/202608210617_goal_execution_failure_recovery.sql new file mode 100644 index 0000000000..78ac0a061f --- /dev/null +++ b/crates/persistence/migrations/202608210617_goal_execution_failure_recovery.sql @@ -0,0 +1,69 @@ +-- Preserve execution failures that cannot make progress under an unchanged +-- goal context, so goal disposition can park them instead of scheduling the +-- ordinary bounded automatic resumption loop. + +CREATE TABLE goal_execution_failure_recovery ( + turn_id uuid PRIMARY KEY, + session_id uuid NOT NULL, + cause_kind text NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + + UNIQUE (turn_id, session_id), + CONSTRAINT goal_execution_failure_recovery_cause_kind_closed CHECK ( + cause_kind IN ('context_compaction_input_does_not_fit') + ), + CONSTRAINT goal_execution_failure_recovery_turn_fk + FOREIGN KEY (turn_id, session_id) + REFERENCES turn_lifecycle (turn_id, session_id) + ON UPDATE RESTRICT + ON DELETE RESTRICT + DEFERRABLE INITIALLY DEFERRED +); + +CREATE FUNCTION require_goal_execution_failure_recovery_terminal() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM turn_lifecycle AS lifecycle + WHERE lifecycle.turn_id = NEW.turn_id + AND lifecycle.session_id = NEW.session_id + AND lifecycle.state_kind = 'terminal' + AND lifecycle.terminal_disposition_kind = 'failed' + AND lifecycle.terminal_model_call_id IS NULL + ) THEN + RAISE EXCEPTION 'goal execution-failure recovery requires its exact call-free failed turn' + USING + ERRCODE = '23514', + CONSTRAINT = 'goal_execution_failure_recovery_exact_terminal'; + END IF; + RETURN NULL; +END; +$$; + +CREATE CONSTRAINT TRIGGER goal_execution_failure_recovery_requires_terminal +AFTER INSERT ON goal_execution_failure_recovery +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION require_goal_execution_failure_recovery_terminal(); + +CREATE TRIGGER goal_execution_failure_recovery_is_append_only +BEFORE UPDATE OR DELETE ON goal_execution_failure_recovery +FOR EACH ROW EXECUTE FUNCTION reject_immutable_record_change(); + +CREATE FUNCTION reject_goal_execution_failure_recovery_truncate() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION '% cannot be truncated', TG_TABLE_NAME + USING ERRCODE = '23514'; +END; +$$; + +CREATE TRIGGER goal_execution_failure_recovery_reject_truncate +BEFORE TRUNCATE ON goal_execution_failure_recovery +FOR EACH STATEMENT +EXECUTE FUNCTION reject_goal_execution_failure_recovery_truncate(); diff --git a/crates/persistence/src/goal.rs b/crates/persistence/src/goal.rs index 1c9f59822c..0863d0baa1 100644 --- a/crates/persistence/src/goal.rs +++ b/crates/persistence/src/goal.rs @@ -43,6 +43,32 @@ use crate::{ const STORAGE_VERSION: i16 = 1; +/// Closed durable cause for an execution failure that requires an operator. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GoalExecutionFailureRecoveryCause { + /// No safe context-compaction boundary fits the configured model window. + ContextCompactionInputDoesNotFit, +} + +impl GoalExecutionFailureRecoveryCause { + /// Returns the closed durable spelling used by storage and telemetry. + pub const fn code(self) -> &'static str { + match self { + Self::ContextCompactionInputDoesNotFit => "context_compaction_input_does_not_fit", + } + } + + fn parse(value: &str) -> Result { + match value { + "context_compaction_input_does_not_fit" => Ok(Self::ContextCompactionInputDoesNotFit), + value => Err(GoalCorruption::Unsupported { + field: "goal_execution_failure_recovery cause_kind", + value: value.to_owned(), + }), + } + } +} + /// Result of handling a user-global goal command identity. #[derive(Clone, Debug, Eq, PartialEq)] pub enum GoalCommandHandlingOutcome { @@ -225,6 +251,29 @@ impl GoalRepository { Self { pool } } + /// Loads the durable operator-required cause for one failed goal turn. + pub async fn execution_failure_recovery_cause( + &self, + session: SessionId, + turn: TurnId, + ) -> Result, GoalRepositoryError> { + let cause = sqlx::query_scalar::<_, String>( + "SELECT cause_kind + FROM goal_execution_failure_recovery + WHERE session_id = $1 + AND turn_id = $2", + ) + .bind(session_id_to_uuid(session)) + .bind(turn_id_to_uuid(turn)) + .fetch_optional(&self.pool) + .await?; + cause + .as_deref() + .map(GoalExecutionFailureRecoveryCause::parse) + .transpose() + .map_err(GoalRepositoryError::Corruption) + } + /// Claims and handles an unseen user command, atomically scheduling a turn /// for each applied pursuing transition, or resolves its durable meaning. pub async fn handle_user_command( @@ -860,6 +909,27 @@ pub(crate) async fn block_execution_failure_locked( Ok(GoalTransitionOutcome::Applied(event)) } +/// Records an operator-required recovery cause inside the transaction that +/// terminalizes the exact failed turn. +pub(crate) async fn record_execution_failure_recovery_cause( + connection: &mut PgConnection, + session: SessionId, + turn: TurnId, + cause: GoalExecutionFailureRecoveryCause, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO goal_execution_failure_recovery + (turn_id, session_id, cause_kind) + VALUES ($1, $2, $3)", + ) + .bind(turn_id_to_uuid(turn)) + .bind(session_id_to_uuid(session)) + .bind(cause.code()) + .execute(&mut *connection) + .await?; + Ok(()) +} + fn recorded_scheduler_failure(goal: &Goal, turn: TurnId) -> Option<&GoalEvent> { goal.events().iter().find(|event| match event.kind() { GoalEventKind::Blocked { block, .. } => match block { diff --git a/crates/persistence/src/model_execution.rs b/crates/persistence/src/model_execution.rs index b292a8fdd7..6a9e721817 100644 --- a/crates/persistence/src/model_execution.rs +++ b/crates/persistence/src/model_execution.rs @@ -1975,6 +1975,7 @@ impl PostgresModelCallRepository { session: SessionId, turn: TurnId, identities: FailedModelCallTurnIdentities, + recovery_cause: Option, ) -> Result { let execution = require_live_execution(connection, session, &self.targets).await?; if execution.turn() != turn || execution.current_call().is_some() { @@ -1996,6 +1997,10 @@ impl PostgresModelCallRepository { None, ) .await?; + if let Some(cause) = recovery_cause { + crate::goal::record_execution_failure_recovery_cause(connection, session, turn, cause) + .await?; + } Ok(failed) } diff --git a/crates/persistence/src/start_eligible_turn.rs b/crates/persistence/src/start_eligible_turn.rs index 4d15673b5c..41440bd4be 100644 --- a/crates/persistence/src/start_eligible_turn.rs +++ b/crates/persistence/src/start_eligible_turn.rs @@ -412,6 +412,7 @@ impl StartEligibleTurnRepository { preview: PreparedActivationPreview, model_calls: &crate::model_execution::PostgresModelCallRepository, identities: signalbox_domain::FailedModelCallTurnIdentities, + recovery_cause: Option, ) -> Result { let session = preview.prepared.turn().session(); let mut transaction = self @@ -470,7 +471,13 @@ impl StartEligibleTurnRepository { .map_err(CommitActivationPreviewError::Activation)?; let turn = activated.turn(); model_calls - .fail_automatic_compaction_in_transaction(&mut transaction, session, turn, identities) + .fail_automatic_compaction_in_transaction( + &mut transaction, + session, + turn, + identities, + recovery_cause, + ) .await .map_err(CommitActivationPreviewError::ModelCall)?; transaction.commit().await.map_err(|error| { diff --git a/crates/persistence/tests/goal_postgres.rs b/crates/persistence/tests/goal_postgres.rs index 5f27cef00c..6d58200428 100644 --- a/crates/persistence/tests/goal_postgres.rs +++ b/crates/persistence/tests/goal_postgres.rs @@ -47,7 +47,8 @@ use signalbox_persistence::{ disposable_postgres_server_args, disposable_postgres_state_tmpfs_from_example, disposable_test_container_labels, goal::{ - GoalCommandHandlingOutcome, GoalRepository, GoalRepositoryError, GoalTransitionOutcome, + GoalCommandHandlingOutcome, GoalExecutionFailureRecoveryCause, GoalRepository, + GoalRepositoryError, GoalTransitionOutcome, }, goal_turn::{GoalTurnCandidates, GoalTurnContinuationOutcome}, local_test_connection_options, migrate, @@ -62,7 +63,7 @@ use signalbox_persistence::{ ReplaceSessionDefaultsHandlingOutcome, ReplaceSessionDefaultsRepository, }, scheduler::PostgresEligibilitySweep, - start_eligible_turn::StartEligibleTurnRepository, + start_eligible_turn::{CommitCompactionFailurePreviewOutcome, StartEligibleTurnRepository}, startup::PostgresStartupScanRepository, submit_input::SubmitInputRepository, }; @@ -351,6 +352,68 @@ async fn terminalize_goal_turn_as_failed(pool: &PgPool, value: u128) -> Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires ephemeral PostgreSQL"] +async fn call_free_failure_recovery_cause_round_trips_as_a_closed_type() +-> Result<(), Box> { + let (container, pool) = migrated_postgres().await?; + CreateSessionRepository::new(pool.clone(), credential_pin()) + .handle(creation()) + .await?; + let attached_turn = turn_candidates(0xb5f); + GoalRepository::new(pool.clone()) + .handle_user_command( + GoalUserCommand::new( + command(ATTACH_COMMAND), + session(SESSION), + GoalUserAction::Attach(statement("finish the commissioned task")), + ), + Some(attached_turn), + |_| None, + ) + .await?; + let activation = StartEligibleTurnRepository::new(pool.clone()); + let preview = activation + .preview(session(SESSION), activation_identities(0xd5f)) + .await? + .expect("the queued goal turn has an activation preview"); + let targets = ModelTargetCatalog::try_from_definitions([ModelTargetDefinition::new( + DirectModelSelection::from_uuid(Uuid::from_u128(0xa01)), + ResolvedProviderTarget::naming(ProviderModelIdentity::from_uuid(Uuid::from_u128(0xa02))), + )]) + .expect("one fixture target forms a catalog"); + let model_calls = PostgresModelCallRepository::new( + pool.clone(), + targets, + ModelCallCredentialReference::new("compaction-failure-test-provider"), + ); + let expected = GoalExecutionFailureRecoveryCause::ContextCompactionInputDoesNotFit; + let closure = activation + .commit_compaction_failure_preview( + preview, + &model_calls, + FailedModelCallTurnIdentities::new( + SemanticTranscriptEntryId::from_uuid(Uuid::from_u128(0xe5f)), + ContextFrontierId::from_uuid(Uuid::from_u128(0xe60)), + ), + Some(expected), + ) + .await?; + assert_eq!( + closure, + CommitCompactionFailurePreviewOutcome::Failed(attached_turn.turn()) + ); + + let actual = GoalRepository::new(pool.clone()) + .execution_failure_recovery_cause(session(SESSION), attached_turn.turn()) + .await?; + + assert_eq!(actual, Some(expected)); + pool.close().await; + drop(container); + Ok(()) +} + /// INV-048 / INV-053: a fresh durable sweep rediscovers a pursuing goal whose /// current turn terminalized before its scheduler disposition could commit, /// and the goal-owned origin records its frozen model settings. diff --git a/docs/spec/goal-mode.md b/docs/spec/goal-mode.md index 07f8dc6764..391f8bee86 100644 --- a/docs/spec/goal-mode.md +++ b/docs/spec/goal-mode.md @@ -14,12 +14,13 @@ that authority again when a consumer commits is verified against this PR (`agent/judge-completion-recheck`). Repository-watch-composed stops are verified against this PR (`agent/daemon-ops-overnight`). This bottom specification diff owns both stack slices. Bounded automatic resumption of execution-failure blocks -is verified against this PR (`agent/goal-blocked-autoresume`), and its one -exemption — the block an unattended repository-watch approval escalation appends -— against this PR (`agent/headless-approval-escalation`). Operator-attended -parking of an operator-commissioned escalation is verified against this PR -(`agent/daemon-live-headless-approval-park`). Restart reconciliation of pending -automatic resumptions is verified against this PR +is verified against this PR (`agent/goal-blocked-autoresume`). The unattended +repository-watch approval exemption is verified against this PR +(`agent/headless-approval-escalation`), and durable non-resumable compaction +failure parking against this PR (`agent/daemon-live-compaction-terminal-park`). +Operator-attended parking of an operator-commissioned escalation is verified +against this PR (`agent/daemon-live-headless-approval-park`). Restart +reconciliation of pending automatic resumptions is verified against this PR (`agent/daemon-live-goal-resume-rearm`). Automatic-resume failure accounting and its twenty-attempt ceiling are verified against this PR (`agent/daemon-live-goal-resume-failure-budget`). Chargeable-failure resume @@ -282,23 +283,27 @@ visible durable block; individual resume attempts use the ordinary bounded reconciliation and derived command identity, so concurrent or repeated startup attempts cannot append two resumptions for one block. -**Implemented behavior.** One execution-failure block is exempt from automatic -resumption: the block an unattended repository-watch approval escalation appends -in the transaction that fails its turn, described by -[repository watch](repo-watch.md). It arms no attempt, and its need text states -that and names the operator repair directly instead. The work that block ended -is already owed a different retry — repository watch redispatches it under a -fresh dispatch while its rule and target remain eligible — so resuming this goal -would re-run an escalating turn against a request no user is attending, beside -that redispatch, until the budget ran out. Where that redispatch is withheld, -because the rule was deactivated or the pull request closed or merged, the work -is not wanted at all, and an automatic resumption would be the only thing still -pursuing it. An operator-commissioned dispatch has an attending operator and no -independent redispatch path, so its delegated approval escalation remains an -active operator-visible approval wait rather than creating an execution-failure -block. Every actual execution-failure block still owes the bounded resumption, -including one appended for a session repository watch created or an operator -commissioned. +**Implemented behavior.** Two durable execution-failure classes require an +operator instead of automatic resumption. The first is the block an unattended +repository-watch approval escalation appends in the transaction that fails its +turn, described by [repository watch](repo-watch.md). It arms no attempt, and +its need text states that and names the operator repair directly instead. The +work that block ended is already owed a different retry — repository watch +redispatches it under a fresh dispatch while its rule and target remain eligible +— so resuming this goal would re-run an escalating turn against a request no +user is attending, beside that redispatch, until the budget ran out. Where that +redispatch is withheld, because the rule was deactivated or the pull request +closed or merged, the work is not wanted at all, and an automatic resumption +would be the only thing still pursuing it. An operator-commissioned dispatch has +an attending operator and no independent redispatch path, so its delegated +approval escalation remains an active operator-visible approval wait rather than +creating an execution-failure block. The second is a call-free failed turn +carrying the append-only typed cause that no safe context-compaction boundary +fits the configured model window. An unchanged successor would encounter the +same proof, so its execution-failure block arms no attempt and tells the +operator to start a fresh session or reduce the imported context. Every other +actual execution-failure block still owes the bounded resumption, including one +appended for a session repository watch created or an operator commissioned. **Implemented behavior.** A periodic durable sweep includes a pursuing goal whose current goal turn is terminal and still owed continuation or blocking. The diff --git a/docs/spec/model-call-execution.md b/docs/spec/model-call-execution.md index 85f1929f73..4ed17cf235 100644 --- a/docs/spec/model-call-execution.md +++ b/docs/spec/model-call-execution.md @@ -26,7 +26,9 @@ terminal closure is verified against this PR recovery is verified against this PR (`agent/daemon-live-request-too-large-compaction`). Successor compaction's no-progress closure is verified against this PR -(`agent/daemon-live-compaction-no-progress`). +(`agent/daemon-live-compaction-no-progress`). Durable parking when no safe +compaction input can fit is verified against this PR +(`agent/daemon-live-compaction-terminal-park`). Non-ambiguous execution-failure containment is verified against this PR (`agent/daemon-live-nonambiguous-execution-containment`). @@ -422,12 +424,16 @@ safe boundary; no provider call is prepared when even the first safe prefix cannot fit. A successor likewise prepares no provider call when the only fitting boundary is its existing summary and the suffix through the next safe boundary would exceed the input-byte budget even if that summary were empty. Preparation -records the selected exact position. A second rendering after the claim prevents -a concurrent frontier change from sending a range outside the same bound: an -oversized mismatch terminalizes the still-unsent dedicated call. An integrity -failure does the same. After a successful provider result, the daemon retains -the summary and its usage in memory until the exact completion is durably -applied or replayed. +records the selected exact position. When no safe prefix can fit, the same +transaction that closes the call-free failed turn appends a typed +`context_compaction_input_does_not_fit` recovery cause. Goal disposition reads +that cause and parks without automatic resumption, because an unchanged +successor cannot make progress. A second rendering after the claim prevents a +concurrent frontier change from sending a range outside the same bound: an +oversized mismatch terminalizes the still-unsent dedicated call and records the +same cause. An integrity failure terminalizes without that cause. After a +successful provider result, the daemon retains the summary and its usage in +memory until the exact completion is durably applied or replayed. The explicit `compact_session` request names a session and an optional semantic transcript position. Absence selects the latest safe terminal or pre-call @@ -481,7 +487,8 @@ failure reported no usage. A queued turn spends at most one automatic attempt. If the attempt fails, still cannot make the prospective request fit, or durable evidence says it was already spent, the scheduler atomically activates and fails the turn without preparing or dispatching an ordinary call. Goal disposition can -then apply its bounded resumption policy to a fresh turn rather than either +then apply its bounded resumption policy to a fresh turn, except when the +durable no-fitting-input cause requires the operator instead, rather than either wedging the queue or sending the known-oversized request. After a nominal completion, the daemon retains adapter-reported usage and the completed observation even when reported output exceeds `max_output_tokens` or the