Skip to content
Open
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
96 changes: 73 additions & 23 deletions apps/signalboxd/src/context_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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(()),
}
Expand All @@ -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",
Expand Down Expand Up @@ -669,6 +682,7 @@ where
&activation,
&model_calls,
preview,
None,
)
.await
.map_err(|source| {
Expand Down Expand Up @@ -697,6 +711,7 @@ where
&activation,
&model_calls,
preview,
None,
)
.await
.map_err(|source| {
Expand All @@ -722,6 +737,7 @@ where
&activation,
&model_calls,
preview,
compaction_recovery_cause(&error),
)
.await
.map_err(|source| {
Expand Down Expand Up @@ -881,14 +897,20 @@ async fn close_failed_compaction_turn(
activation: &StartEligibleTurnRepository,
model_calls: &PostgresModelCallRepository,
preview: PreparedActivationPreview,
recovery_cause: Option<GoalExecutionFailureRecoveryCause>,
) -> Result<CommitCompactionFailurePreviewOutcome, CommitActivationPreviewError> {
loop {
let identities = FailedModelCallTurnIdentities::new(
SemanticTranscriptEntryId::from_uuid(uuid::Uuid::now_v7()),
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) => {}
Expand All @@ -897,6 +919,16 @@ async fn close_failed_compaction_turn(
}
}

fn compaction_recovery_cause(
error: &crate::process_runtime::AutomaticContextCompactionError,
) -> Option<GoalExecutionFailureRecoveryCause> {
matches!(
error,
crate::process_runtime::AutomaticContextCompactionError::InputDoesNotFit
)
.then_some(GoalExecutionFailureRecoveryCause::ContextCompactionInputDoesNotFit)
}

fn compaction_failure_closure_collision_is_retryable(error: &CommitActivationPreviewError) -> bool {
matches!(
error,
Expand All @@ -918,6 +950,7 @@ mod tests {
};
use signalbox_persistence::{
context_compaction::ContextCompactionRepositoryError,
goal::GoalExecutionFailureRecoveryCause,
model_execution::{ModelCallIdentityCollision, ModelCallRepositoryError},
start_eligible_turn::{
CommitActivationPreviewError, StartEligibleTurnIdentityCollision,
Expand All @@ -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,
Expand Down
66 changes: 53 additions & 13 deletions apps/signalboxd/src/goal_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ use signalbox_domain::{
};
use signalbox_persistence::{
goal::{
GoalCommandHandlingOutcome, GoalRepository, GoalRepositoryError, GoalTransitionOutcome,
GoalCommandHandlingOutcome, GoalExecutionFailureRecoveryCause, GoalRepository,
GoalRepositoryError, GoalTransitionOutcome,
},
goal_turn::{GoalTurnCandidates, GoalTurnContinuationOutcome},
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -918,9 +932,18 @@ impl GoalPassDisposition for PostgresGoalPassDisposition {
) -> impl Future<Output = Result<(), Self::Error>> + 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?
Comment on lines +935 to +938

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Consult the durable cause during reconciliations

This cause-aware selection runs only in the direct failure callback. If the daemon restarts after the failed-turn transaction commits but before that callback disposes the goal, reconcile_success still selects Scheduled; likewise, an ambiguous block commit is reread by reconcile_ambiguous_block, which also unconditionally plans automatic resumption. Both paths therefore arm a resume for a turn carrying context_compaction_input_does_not_fit, defeating the durable parking behavior and potentially repeating the impossible turn until the attempt budget is exhausted. Reuse the cause-aware selection in both reconciliation paths, deriving the failed turn from the current terminal turn or block provenance.

Useful? React with 👍 / 👎.

{
Some(cause) => AutomaticResumption::OperatorRequired { cause },
None => {
adapter
.plan_automatic_resumption(session, Some(turn))
.await?
}
};
let outcome = match adapter
.repository
.block_execution_failure(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading