Skip to content

Commit 248f733

Browse files
author
“Rishaan-Saini”
committed
fix(tasks): stop planning on pause and ignore watch checks on paused tasks
1 parent 52c7e74 commit 248f733

3 files changed

Lines changed: 210 additions & 18 deletions

File tree

‎changes/413.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**Pausing a task stops planning and ignores in-flight watch checks.** Pausing a task while it is being planned now stops before the next model call and leaves a note to resume, resuming picks up planning where it left off. A running watch check that finishes while its task is paused no longer starts an unexpected run.

‎sentient/tasks/service.py‎

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)