Skip to content

Commit 4383075

Browse files
committed
fix(autonomy): preserve deferred work across runtime admission
1 parent ca9855a commit 4383075

4 files changed

Lines changed: 233 additions & 3 deletions

File tree

core/agency/autonomous_task_engine.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ class StepStatus(Enum):
4040
ROLLED_BACK = "rolled_back"
4141

4242

43+
class TaskExecutionDeferred(RuntimeError):
44+
"""Execution admission was postponed without consuming a task attempt."""
45+
46+
def __init__(self, reason: str):
47+
self.reason = str(reason or "execution_admission_deferred")[:240]
48+
super().__init__(self.reason)
49+
50+
4351
def _persistable_context(context: Any) -> dict[str, Any]:
4452
"""Plan context reduced to what can survive a restart.
4553
@@ -468,6 +476,19 @@ def _repair_plan_argument_contracts(self, plan: TaskPlan) -> list[str]:
468476
errors.append(f"{step.step_id or step.tool}:{step.tool}:{','.join(missing)}")
469477
return errors
470478

479+
@staticmethod
480+
def _tool_result_deferral_reason(result: Any) -> str:
481+
if not isinstance(result, dict):
482+
return ""
483+
if str(result.get("status", "") or "").strip().lower() != "deferred":
484+
return ""
485+
return str(
486+
result.get("reason")
487+
or result.get("message")
488+
or result.get("error")
489+
or "execution_admission_deferred"
490+
)[:240]
491+
471492
def _persist_active_plans(self) -> None:
472493
try:
473494
payload = {
@@ -1036,6 +1057,34 @@ async def execute_goal(
10361057
# 2. Execute steps
10371058
await self._execute_plan(plan, on_progress)
10381059

1060+
if plan.status == "deferred":
1061+
deferred_reason = str(
1062+
plan.context.get("execution_deferred_reason", "")
1063+
or "execution_admission_deferred"
1064+
)[:240]
1065+
await self._report_progress_event(
1066+
on_progress,
1067+
{
1068+
"event": "execution_deferred",
1069+
"plan_id": plan.plan_id,
1070+
"status": "deferred",
1071+
"reason": deferred_reason,
1072+
"steps_completed": len(plan.succeeded_steps),
1073+
"steps_total": len(plan.steps),
1074+
},
1075+
)
1076+
self._persist_plan_state(plan)
1077+
return TaskResult(
1078+
plan_id=plan.plan_id,
1079+
goal=goal,
1080+
succeeded=False,
1081+
summary="Execution remains queued until runtime admission clears.",
1082+
steps_completed=len(plan.succeeded_steps),
1083+
steps_total=len(plan.steps),
1084+
trace_id=trace_id,
1085+
deferred_reason=deferred_reason,
1086+
)
1087+
10391088
# 3. Synthesize result
10401089
result = await self._synthesize_result(plan, time.time() - plan.created_at)
10411090
self._record_coding_execution(
@@ -2587,6 +2636,8 @@ async def _execute_plan(
25872636
completed_ids.add(step.step_id)
25882637
await self._emit_step_progress(step, on_progress, plan)
25892638
self._persist_plan_state(plan)
2639+
if plan.status == "deferred":
2640+
return
25902641
failed_step = next(
25912642
(step for step in parallel_wave if step.status == StepStatus.FAILED), None
25922643
)
@@ -2612,6 +2663,9 @@ async def _execute_plan(
26122663
await self._emit_step_progress(step, on_progress, plan)
26132664
self._persist_plan_state(plan)
26142665

2666+
if plan.status == "deferred":
2667+
return
2668+
26152669
if step.status == StepStatus.FAILED:
26162670
await self._fail_plan(
26172671
plan,
@@ -2735,6 +2789,10 @@ async def _execute_step_with_retry(self, step: TaskStep, plan: TaskPlan) -> None
27352789
timeout=step_timeout,
27362790
)
27372791

2792+
deferred_reason = self._tool_result_deferral_reason(raw_result)
2793+
if deferred_reason:
2794+
raise TaskExecutionDeferred(deferred_reason)
2795+
27382796
step.raw_result = self._compact_tool_result(raw_result)
27392797
step.result_summary = step.raw_result
27402798

@@ -2846,6 +2904,24 @@ async def _execute_step_with_retry(self, step: TaskStep, plan: TaskPlan) -> None
28462904
)
28472905
self._persist_plan_state(plan)
28482906

2907+
except TaskExecutionDeferred as exc:
2908+
# Admission pressure and boot grace are scheduling facts, not
2909+
# failed actions. Preserve the exact step and its attempt
2910+
# budget so the plan can resume when the runtime is ready.
2911+
step.attempts = max(0, step.attempts - 1)
2912+
step.status = StepStatus.PENDING
2913+
step.error = f"execution deferred: {exc.reason}"
2914+
step.completed_at = None
2915+
plan.status = "deferred"
2916+
plan.context["execution_deferred_reason"] = exc.reason
2917+
plan.context["execution_deferred_at"] = time.time()
2918+
self._persist_plan_state(plan)
2919+
logger.info(
2920+
"TaskEngine: step '%s' deferred without consuming an attempt: %s",
2921+
step.description[:40],
2922+
exc.reason,
2923+
)
2924+
return
28492925
except TimeoutError:
28502926
step.error = f"timeout after {step_timeout}s"
28512927
self._record_coding_execution(
@@ -3006,6 +3082,7 @@ async def _verify_step(self, step: TaskStep, result: Any) -> bool:
30063082
f"Result: {result_str}\n\n"
30073083
"Answer with ONLY 'YES' or 'NO' followed by one sentence of evidence."
30083084
)
3085+
verification_started_at = time.time()
30093086
raw = await asyncio.wait_for(
30103087
llm.think(
30113088
prompt,
@@ -3017,6 +3094,12 @@ async def _verify_step(self, step: TaskStep, result: Any) -> bool:
30173094
timeout=15.0,
30183095
)
30193096
if not str(raw or "").strip():
3097+
deferral = take_deferral(
3098+
origin="autonomous_task_engine",
3099+
not_before=verification_started_at,
3100+
)
3101+
if deferral is not None:
3102+
raise TaskExecutionDeferred(str(deferral.reason or "verifier_deferred"))
30203103
# Blank verifier output is NOT a pass: an empty verdict is a
30213104
# verifier outage, and treating "nothing came back" as success
30223105
# let unrelated or failure output satisfy arbitrary criteria.
@@ -3033,6 +3116,8 @@ async def _verify_step(self, step: TaskStep, result: Any) -> bool:
30333116
# Cancellation is shutdown/caller intent, never a verification
30343117
# outcome — propagate it rather than converting it into a pass.
30353118
raise
3119+
except TaskExecutionDeferred:
3120+
raise
30363121
except (RuntimeError, TimeoutError, AttributeError) as e:
30373122
# Verifier exception or timeout: FAIL CLOSED. Assuming pass here
30383123
# let every verifier outage certify arbitrary output as correct.
@@ -3106,7 +3191,13 @@ async def _get_alternative_approach(self, step: TaskStep) -> dict | None:
31063191
"Alternative approach returned identical args for '%s'", step.description[:40]
31073192
)
31083193
except (json.JSONDecodeError, TypeError, ValueError) as e:
3109-
record_degradation("autonomous_task_engine", e)
3194+
record_degradation(
3195+
"autonomous_task_engine_retry",
3196+
e,
3197+
severity="warning",
3198+
action="failed the step after retry synthesis produced no executable alternative",
3199+
enforce_failure_policy=False,
3200+
)
31103201
logger.debug("Alternative approach generation failed: %s", e)
31113202

31123203
# Signal exhaustion rather than silently looping identically

core/runtime/overt_action_loop.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,15 +207,18 @@ async def run_once(self, *, force: bool = False) -> dict[str, Any]:
207207
return action.to_dict()
208208

209209
def _background_reason(self) -> str:
210+
allow_boot_anchor = os.getenv(
211+
"AURA_OVERT_ACTION_ALLOW_BOOT_ANCHOR",
212+
"1",
213+
).strip().lower() not in {"0", "false", "off", "no"}
210214
reason = background_activity_reason(
211215
self._orchestrator(),
212216
min_idle_seconds=float(os.getenv("AURA_OVERT_ACTION_IDLE_S", "30")),
213217
max_memory_percent=float(os.getenv("AURA_OVERT_ACTION_MAX_MEMORY_PERCENT", "88")),
214218
max_failure_pressure=float(os.getenv("AURA_OVERT_ACTION_MAX_FAILURE_PRESSURE", "0.35")),
215219
require_conversation_ready=False,
220+
allow_no_user_anchor=allow_boot_anchor,
216221
)
217-
if reason == "no_user_anchor" and os.getenv("AURA_OVERT_ACTION_ALLOW_BOOT_ANCHOR", "1").strip().lower() not in {"0", "false", "off", "no"}:
218-
return ""
219222
return str(reason or "")
220223

221224
def _record_skip(self, reason: str) -> OvertActionResult:

tests/test_autonomous_task_engine_runtime.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from core.agency.autonomous_task_engine import (
88
AutonomousTaskEngine,
99
StepStatus,
10+
TaskExecutionDeferred,
1011
TaskPlan,
1112
TaskStep,
1213
)
@@ -672,6 +673,37 @@ async def test_task_engine_verifier_fails_closed_on_blank_llm_verdict():
672673
llm.think.assert_awaited()
673674

674675

676+
@pytest.mark.asyncio
677+
async def test_task_engine_verifier_propagates_inference_admission_deferral():
678+
from core.brain.llm import deferral_record
679+
680+
async def deferred_think(*_args, **_kwargs):
681+
deferral_record.record_deferral(
682+
origin="autonomous_task_engine",
683+
reason="foreground_quiet_window",
684+
)
685+
return ""
686+
687+
engine = AutonomousTaskEngine.__new__(AutonomousTaskEngine)
688+
engine.kernel = SimpleNamespace(
689+
organs={"llm": SimpleNamespace(get_instance=lambda: SimpleNamespace(think=deferred_think))}
690+
)
691+
step = TaskStep(
692+
step_id="s1",
693+
description="Verify grounded research",
694+
tool="web_search",
695+
args={"query": "consensus history"},
696+
success_criterion="sources support the claim",
697+
)
698+
699+
deferral_record.reset_for_test()
700+
try:
701+
with pytest.raises(TaskExecutionDeferred, match="foreground_quiet_window"):
702+
await engine._verify_step(step, {"ok": True, "results": ["source"]})
703+
finally:
704+
deferral_record.reset_for_test()
705+
706+
675707
@pytest.mark.asyncio
676708
async def test_task_engine_records_execution_repair_pressure(monkeypatch):
677709
events: list[tuple[str, dict]] = []
@@ -740,6 +772,74 @@ async def test_task_engine_fails_fast_when_no_alternative_args_exist():
740772
assert step.attempts == 1
741773

742774

775+
@pytest.mark.asyncio
776+
async def test_task_engine_preserves_tool_deferral_without_spending_retry_budget():
777+
engine = AutonomousTaskEngine.__new__(AutonomousTaskEngine)
778+
engine._invoke_tool = AsyncCallRecorder(
779+
return_value={"ok": False, "status": "deferred", "reason": "boot_grace_17s"}
780+
)
781+
engine._verify_step = AsyncCallRecorder(return_value=False)
782+
engine._persist_plan_state = lambda plan: None
783+
engine._record_coding_execution = lambda *_args, **_kwargs: None
784+
785+
step = TaskStep(
786+
step_id="plan-deferred_s0",
787+
description="Search for consensus history",
788+
tool="web_search",
789+
args={"query": "distributed systems consensus history"},
790+
success_criterion="recent sources returned",
791+
)
792+
plan = TaskPlan(
793+
plan_id="plan-deferred",
794+
goal="Research consensus history",
795+
steps=[step],
796+
trace_id="trace",
797+
)
798+
799+
await AutonomousTaskEngine._execute_step_with_retry(engine, step, plan)
800+
801+
assert plan.status == "deferred"
802+
assert plan.context["execution_deferred_reason"] == "boot_grace_17s"
803+
assert step.status == StepStatus.PENDING
804+
assert step.attempts == 0
805+
assert "boot_grace_17s" in step.error
806+
engine._verify_step.assert_not_awaited()
807+
808+
809+
@pytest.mark.asyncio
810+
async def test_task_engine_scheduler_retains_deferred_plan_for_resume():
811+
engine = AutonomousTaskEngine.__new__(AutonomousTaskEngine)
812+
engine._safety_registry = SimpleNamespace(is_allowed=AsyncCallRecorder(return_value=True))
813+
engine._invoke_tool = AsyncCallRecorder(
814+
return_value={"ok": False, "status": "deferred", "reason": "boot_grace_17s"}
815+
)
816+
engine._verify_step = AsyncCallRecorder(return_value=False)
817+
engine._persist_plan_state = lambda plan: None
818+
engine._record_coding_execution = lambda *_args, **_kwargs: None
819+
engine._can_run_in_parallel = lambda _step: False
820+
821+
step = TaskStep(
822+
step_id="plan-deferred_s0",
823+
description="Search for consensus history",
824+
tool="web_search",
825+
args={"query": "distributed systems consensus history"},
826+
success_criterion="recent sources returned",
827+
)
828+
plan = TaskPlan(
829+
plan_id="plan-deferred",
830+
goal="Research consensus history",
831+
steps=[step],
832+
trace_id="trace",
833+
)
834+
835+
await AutonomousTaskEngine._execute_plan(engine, plan, on_progress=None)
836+
837+
assert plan.status == "deferred"
838+
assert step.status == StepStatus.PENDING
839+
assert step.attempts == 0
840+
assert plan.any_failed is False
841+
842+
743843
@pytest.mark.asyncio
744844
async def test_task_engine_verify_step_uses_deterministic_result_checks_before_llm():
745845
llm = SimpleNamespace(think=AsyncCallRecorder(return_value="NO"))

tests/test_substrate_primary_architecture.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,42 @@ def test_overt_action_selection_requires_explicit_web_intent_or_structured_skill
374374
assert incidental.reason == "retained_memory_is_evidence_not_an_action"
375375

376376

377+
def test_overt_action_boot_anchor_relaxes_only_user_anchor_policy(monkeypatch):
378+
import core.runtime.overt_action_loop as overt_action_module
379+
380+
calls = []
381+
382+
def policy_reason(*args, **kwargs):
383+
calls.append((args, kwargs))
384+
return "boot_grace_17s"
385+
386+
monkeypatch.setenv("AURA_OVERT_ACTION_ALLOW_BOOT_ANCHOR", "1")
387+
monkeypatch.setattr(overt_action_module, "background_activity_reason", policy_reason)
388+
389+
reason = overt_action_module.OvertActionLoop()._background_reason()
390+
391+
assert reason == "boot_grace_17s"
392+
assert calls[0][1]["allow_no_user_anchor"] is True
393+
394+
395+
def test_overt_action_can_require_a_real_user_anchor(monkeypatch):
396+
import core.runtime.overt_action_loop as overt_action_module
397+
398+
calls = []
399+
400+
def policy_reason(*args, **kwargs):
401+
calls.append((args, kwargs))
402+
return "no_user_anchor"
403+
404+
monkeypatch.setenv("AURA_OVERT_ACTION_ALLOW_BOOT_ANCHOR", "0")
405+
monkeypatch.setattr(overt_action_module, "background_activity_reason", policy_reason)
406+
407+
reason = overt_action_module.OvertActionLoop()._background_reason()
408+
409+
assert reason == "no_user_anchor"
410+
assert calls[0][1]["allow_no_user_anchor"] is False
411+
412+
377413
def test_overt_action_selection_semantically_plans_paraphrased_objectives(tmp_path):
378414
from core.runtime.overt_action_loop import OvertActionLoop
379415
from core.runtime.receipts import ReceiptStore

0 commit comments

Comments
 (0)