@@ -220,6 +220,7 @@ def __init__(self, app: Any):
220220 self ._runs : dict [str , asyncio .Task ] = {}
221221 self ._cancel_requested : set [str ] = set ()
222222 self ._holding : set [str ] = set () # runs whose task was paused: they stop at their next safe point (#408)
223+ self ._paused_scripts : set [str ] = set () # watch checks whose task was paused: they don't start a run
223224 self ._locks : dict [str , asyncio .Lock ] = {}
224225 self ._sem : asyncio .Semaphore | None = None
225226 self ._bus_subscription : Any = None
@@ -706,6 +707,7 @@ async def update(self, task_id: str, fields: dict, *, reapprove_script: bool = F
706707 if resuming :
707708 for run in await self .repo .processing_runs (task_id ): # resumed before it reached a safe point: it goes on
708709 self ._holding .discard (run ["id" ])
710+ self ._paused_scripts .discard (task_id )
709711 await self ._carry_on (task_id , held )
710712 if needs_approval :
711713 await self .publish (task_id )
@@ -860,7 +862,7 @@ async def run_now(self, task_id: str) -> dict:
860862 if task .get ("task_type" ) == "script" :
861863 if not self ._runnable (task ):
862864 raise TaskConflict ("Task has no check script to run." )
863- await self ._start_script (task )
865+ await self ._start_script (task , manual = True )
864866 return await self .get_and_publish (task_id )
865867 if not task .get ("plan" ):
866868 raise TaskConflict ("Task has no plan to run." )
@@ -1488,11 +1490,19 @@ def _waits_on_approval(run: dict) -> bool:
14881490 async def _pause_work (self , task_id : str ) -> None :
14891491 """The user paused the task (#408): a run working right now stops at its next safe point, before its next
14901492 model or tool call, never in the middle of one (``holding``, ``_held_by_pause``), and a run waiting on an
1491- approval is held at once. Both end like work a restart held (#304). A swarm's run, and a one-call run whose
1493+ approval is held at once. Both end like work a restart held (#304). A task being planned stays in planning
1494+ with the note, and a running check script does not start a run. A swarm's run, and a one-call run whose
14921495 call already started, can't stop part-way: they finish, and their log says so."""
14931496 task = await self .repo .get_task (task_id )
14941497 if task is None :
14951498 return
1499+ if task ["status" ] == "planning" :
1500+ if task .get ("error" ) != PLAN_PAUSED_NOTE :
1501+ await self ._set (task_id , {"error" : PLAN_PAUSED_NOTE })
1502+ await self .publish (task_id )
1503+ return
1504+ if self ._job_running ("script" , task_id ):
1505+ self ._paused_scripts .add (task_id )
14961506 for run in await self .repo .processing_runs (task_id ):
14971507 live = self ._runs .get (run ["id" ])
14981508 if live is None or live .done ():
@@ -1613,7 +1623,24 @@ async def _refine(self, prompt: str) -> dict:
16131623 "schedule" : schedule ,
16141624 }
16151625
1626+ async def _planning_paused (self , task_id : str ) -> bool :
1627+ """True when the task is not in planning or has been paused: leaves the note and stops planning (#405)."""
1628+ task = await self .repo .get_task (task_id )
1629+ if task is None or task ["status" ] != "planning" :
1630+ return True
1631+ if not task ["enabled" ]:
1632+ if task .get ("error" ) != PLAN_PAUSED_NOTE :
1633+ await self ._set (task_id , {"error" : PLAN_PAUSED_NOTE })
1634+ await self .publish (task_id )
1635+ return True
1636+ if task .get ("error" ) == PLAN_PAUSED_NOTE :
1637+ await self ._set (task_id , {"error" : None })
1638+ await self .publish (task_id )
1639+ return False
1640+
16161641 async def _refine_and_plan (self , task_id : str , * , auto_approve : bool = False ) -> None :
1642+ if await self ._planning_paused (task_id ):
1643+ return
16171644 task = await self .repo .get_task (task_id )
16181645 if task is None or task ["status" ] != "planning" :
16191646 return
@@ -1625,6 +1652,8 @@ async def _refine_and_plan(self, task_id: str, *, auto_approve: bool = False) ->
16251652 details = {"schedule" : spoken_schedule (normalize_schedule (None , self .tz_name ()), prompt )}
16261653 await self ._set (task_id , details )
16271654 await self .publish (task_id )
1655+ if await self ._planning_paused (task_id ):
1656+ return
16281657 await self ._plan_job (task_id , auto_approve = auto_approve )
16291658
16301659 async def _plan_job (self , task_id : str , * , auto_approve : bool = False ) -> None :
@@ -1639,8 +1668,8 @@ async def _plan_job(self, task_id: str, *, auto_approve: bool = False) -> None:
16391668
16401669 async def _plan_failed (self , task_id : str , error : str ) -> None :
16411670 task = await self .repo .get_task (task_id )
1642- if task is None or task ["status" ] != "planning" :
1643- return # the user declined/archived/edited it meanwhile
1671+ if task is None or task ["status" ] != "planning" or not task [ "enabled" ] :
1672+ return # the user declined/archived/edited/paused it meanwhile
16441673 if not await self .repo .update_task_if_status (
16451674 task_id , {"planning" }, {"status" : "error" , "error" : error , "updated_at" : self .now_iso ()}
16461675 ):
@@ -1649,6 +1678,8 @@ async def _plan_failed(self, task_id: str, error: str) -> None:
16491678 await self ._notify (task , f"I couldn't create a plan for '{ task .get ('name' )} '. { error } " , "Planning failed" , "planning_failed" )
16501679
16511680 async def _plan (self , task_id : str , * , auto_approve : bool = False ) -> None :
1681+ if await self ._planning_paused (task_id ):
1682+ return
16521683 task = await self .repo .get_task (task_id )
16531684 if task is None or task ["status" ] != "planning" or task .get ("task_type" ) == "swarm" :
16541685 return
@@ -1688,14 +1719,20 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
16881719 parts .append ("Answers to your clarifying questions:\n " + "\n " .join (
16891720 f"- Q: { q .get ('text' )} \n A: { q .get ('answer' )} " for q in answered
16901721 ))
1722+ if await self ._planning_paused (task_id ):
1723+ return
16911724 data = await complete_json_object (self .app .llm ,
16921725 "planner" ,
16931726 [{"role" : "system" , "content" : system }, {"role" : "user" , "content" : "\n \n " .join (parts )}],
16941727 keys = ("plan" , "steps" , "clarifying_questions" ),
16951728 )
1729+ if await self ._planning_paused (task_id ):
1730+ return
16961731 if not isinstance (data , dict ):
16971732 raise TypeError (f"Planner agent returned invalid JSON: { str (data )[:200 ]} " )
16981733 script , data , script_tools = await self ._planned_script (task , data , system , parts )
1734+ if await self ._planning_paused (task_id ):
1735+ return
16991736 no_script = script_tools is not None
17001737 raw_plan = data .get ("plan" ) or data .get ("steps" )
17011738 if raw_plan is None and data .get ("tool" ): # a lone step object
@@ -1705,6 +1742,8 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
17051742 plan = self ._no_script_plan (task , script_tools , plan )
17061743 new_questions = normalize_questions (data .get ("clarifying_questions" ), start = len (questions ) if answered else 0 )
17071744
1745+ if await self ._planning_paused (task_id ):
1746+ return
17081747 current = await self .repo .get_task (task_id )
17091748 if current is None or current ["status" ] != "planning" :
17101749 return # deleted or changed while the model was thinking
@@ -1763,6 +1802,8 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
17631802 fields ["name" ] = name
17641803 if str (data .get ("description" ) or "" ).strip ():
17651804 fields ["description" ] = str (data ["description" ]).strip ()
1805+ if await self ._planning_paused (task_id ):
1806+ return
17661807 if not await self .repo .update_task_if_status (task_id , {"planning" }, {** fields , "updated_at" : self .now_iso ()}):
17671808 return # declined, archived or edited while the planner was running
17681809 # a plan written from someone's email always waits for the user's approval (#159)
@@ -1792,6 +1833,8 @@ async def _planned_script(
17921833 script = normalize_script (raw , previous , model_written = True , request = request )
17931834 return self ._default_condition (task , script ), data , None
17941835 except ScriptInvalid as exc :
1836+ if await self ._planning_paused (task ["id" ]):
1837+ return previous , data , None
17951838 log .info ("planner script for task %s rejected, asking for a fix: %s" , task ["id" ], exc )
17961839 retry = [* parts , (
17971840 f"Your previous reply included this `script` code:\n ```python\n { str (raw .get ('code' ))[:4000 ]} \n ```\n "
@@ -1803,6 +1846,8 @@ async def _planned_script(
18031846 [{"role" : "system" , "content" : system }, {"role" : "user" , "content" : "\n \n " .join (retry )}],
18041847 keys = ("plan" , "steps" , "clarifying_questions" ),
18051848 )
1849+ if await self ._planning_paused (task ["id" ]):
1850+ return previous , data , None
18061851 if not isinstance (fixed , dict ):
18071852 raise TypeError (f"Planner agent returned invalid JSON: { str (fixed )[:200 ]} " )
18081853 codes = [str (raw .get ("code" ))]
@@ -2336,9 +2381,9 @@ async def _publish_run_finished(self, task_id: str, run_id: str, status: str) ->
23362381 log .exception ("could not publish task.run_finished for run %s" , run_id )
23372382
23382383 # ------------------------------------------------------------------ script jobs
2339- async def _start_script (self , task : dict , * , trigger_data : dict | None = None ) -> None :
2384+ async def _start_script (self , task : dict , * , trigger_data : dict | None = None , manual : bool = False ) -> None :
23402385 await self ._set (task ["id" ], {"status" : "processing" , "last_execution_at" : self .now_iso (), "error" : None })
2341- self ._spawn (self ._script_job (task ["id" ], trigger_data ), f"script:{ task ['id' ]} " )
2386+ self ._spawn (self ._script_job (task ["id" ], trigger_data , manual = manual ), f"script:{ task ['id' ]} " )
23422387
23432388 def _script_tool_names (self ) -> list [str ]:
23442389 return [
@@ -2365,10 +2410,10 @@ async def _sandbox_run(self, code: str, trigger_data: dict | None = None) -> dic
23652410 return {** SANDBOX_RESULT , "error" : "the code runner returned an unexpected result" }
23662411 return {** SANDBOX_RESULT , ** out }
23672412
2368- async def _script_job (self , task_id : str , trigger_data : dict | None ) -> None :
2413+ async def _script_job (self , task_id : str , trigger_data : dict | None , * , manual : bool = False ) -> None :
23692414 try :
23702415 async with self ._lock (task_id ):
2371- await self ._script_check (task_id , trigger_data )
2416+ await self ._script_check (task_id , trigger_data , manual = manual )
23722417 except asyncio .CancelledError :
23732418 if self .app .stopped : # Stop everything: settle the task now (a shutdown leaves it to recovery)
23742419 await self ._after_run (task_id , "cancelled" , None )
@@ -2378,6 +2423,8 @@ async def _script_job(self, task_id: str, trigger_data: dict | None) -> None:
23782423 log .exception ("script job %s crashed" , task_id )
23792424 await self ._after_run (task_id , "error" , "The check script could not be run." )
23802425 await self .publish (task_id )
2426+ finally :
2427+ self ._paused_scripts .discard (task_id )
23812428
23822429 async def _save_script_state (self , task_id : str , code : str , state : dict ) -> None :
23832430 """Store check state unless the code was edited while the check ran (that resets the state)."""
@@ -2390,11 +2437,13 @@ async def _save_script_state(self, task_id: str, code: str, state: dict) -> None
23902437 script .update (state )
23912438 await self ._set (task_id , {"script" : script })
23922439
2393- async def _script_check (self , task_id : str , trigger_data : dict | None ) -> None :
2440+ async def _script_check (self , task_id : str , trigger_data : dict | None , * , manual : bool = False ) -> None :
23942441 """One check: run the code with no model, act on the condition, record state, settle the task."""
23952442 task = await self .repo .get_task (task_id )
23962443 if task is None or task .get ("task_type" ) != "script" :
23972444 return
2445+ if manual :
2446+ self ._paused_scripts .discard (task_id )
23982447 script = dict (task .get ("script" ) or {})
23992448 code = str (script .get ("code" ) or "" )
24002449 name = task .get ("name" ) or "Untitled task"
@@ -2422,28 +2471,38 @@ async def _script_check(self, task_id: str, trigger_data: dict | None) -> None:
24222471 act = scripts .should_act (condition , value , previous )
24232472 message = scripts .alert_message (name , condition , value ) if act else ""
24242473 outcome_name , note = self ._check_outcome (condition , act , value , previous )
2425- if act and condition == "noteworthy" : # something changed: the fast model judges whether it matters
2426- act , change = await self ._judge_change (task , previous , value )
2427- outcome_name , note = ("changed" , change ) if act else ("minor" , change )
2428- if act and change :
2429- message = change
24302474 current = await self .repo .get_task (task_id )
24312475 if current is None or current .get ("task_type" ) != "script" or _check_settings (current ) != _check_settings (task ):
24322476 # the user changed the check while it ran (or while the model judged it): drop this stale decision, the
24332477 # next check decides with the new settings
24342478 await self ._after_run (task_id , "completed" , None )
24352479 await self .publish (task_id )
24362480 return
2481+ paused = task_id in self ._paused_scripts or (not current ["enabled" ] and not manual )
2482+ if act and condition == "noteworthy" : # something changed: the fast model judges whether it matters
2483+ if paused :
2484+ act = False
2485+ else :
2486+ act , change = await self ._judge_change (task , previous , value )
2487+ outcome_name , note = ("changed" , change ) if act else ("minor" , change )
2488+ if act and change :
2489+ message = change
2490+ current = await self .repo .get_task (task_id )
2491+ if current is None or current .get ("task_type" ) != "script" or _check_settings (current ) != _check_settings (task ):
2492+ await self ._after_run (task_id , "completed" , None )
2493+ await self .publish (task_id )
2494+ return
2495+ paused = paused or task_id in self ._paused_scripts or (not current ["enabled" ] and not manual )
24372496 state : dict [str , Any ] = {
24382497 "last_run_at" : now , "last_error" : None ,
24392498 "checks" : scripts .add_check (script .get ("checks" ), outcome_name , now , note ),
24402499 }
24412500 if value is not None or condition not in scripts .COMPARING : # an empty result keeps the baseline
24422501 state ["last_result" ] = value
24432502 await self ._save_script_state (task_id , code , state )
2444- if previous_error :
2503+ if previous_error and not paused :
24452504 await self ._notify (task , f"The check for '{ name } ' is working again." , "Check recovered" , "script_recovered" )
2446- if act :
2505+ if act and not paused :
24472506 if script .get ("then" ) == "run" :
24482507 context : dict [str , Any ] = {"script_result" : value , "message" : message }
24492508 if trigger_data :
@@ -2452,6 +2511,7 @@ async def _script_check(self, task_id: str, trigger_data: dict | None) -> None:
24522511 await self ._start_run (fresh , trigger_data = context , next_execution_at = _KEEP )
24532512 else :
24542513 await self ._notify (task , message , name , "script_alert" , {"result" : value })
2514+ self ._paused_scripts .discard (task_id )
24552515 await self ._after_run (task_id , "completed" , None )
24562516 await self .publish (task_id )
24572517
0 commit comments