Skip to content

Commit 3229298

Browse files
fix: recover and execute durable tasks
1 parent 184d6df commit 3229298

6 files changed

Lines changed: 269 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,15 @@ The migration creates a `.v1-backup` copy and writes the v2 database under `~/.k
381381

382382
### v2 durable tasks and learning
383383

384-
Tasks persist across process restarts and use `pending`, `running`, `succeeded`, `failed`, `blocked`, and `cancelled` states. `TaskDone` is only a completion request; a successful tool result, test, file check, or explicit confirmation must provide evidence before a task can succeed.
384+
Tasks persist across process restarts and use `pending`, `running`, `succeeded`, `failed`, `blocked`, and `cancelled` states. `TaskDone` is only a completion request; a successful tool result, test, file check, or explicit confirmation must provide evidence before a task can succeed. API-created tasks with an explicit safe `action` and string `args` are picked up by the durable worker after server restart; failed or blocked tasks require an explicit resume request.
385+
386+
```bash
387+
curl -X POST http://127.0.0.1:8000/api/v2/tasks \
388+
-H 'Content-Type: application/json' \
389+
-d '{"description":"write a marker","action":"write_file","args":"task-marker.txt|completed","acceptance":["marker written"]}'
390+
# Resume a failed/blocked task:
391+
curl -X POST http://127.0.0.1:8000/api/v2/tasks/<task-id>/resume
392+
```
385393

386394
Run a frozen clean-versus-evolved benchmark with identical case order and runner settings:
387395

‎event_store.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,8 @@ def list_tasks(self, *, workspace_id: str = "default", session_id: str | None =
402402
result = []
403403
for row in rows:
404404
item = dict(row)
405+
if item["status"] == "done":
406+
item["status"] = "succeeded"
405407
item["dependencies"] = self._loads(item["dependencies"], [])
406408
item["acceptance"] = self._loads(item["acceptance"], [])
407409
item["checkpoint"] = self._loads(item["checkpoint"], {})

‎main.py‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def _terminal_supports_unicode() -> bool:
9393
from rich import print as rprint
9494

9595
from memory import MemoryBank
96-
from task_engine import TaskManager
96+
from task_engine import TaskManager, TaskWorker
9797
from event_store import stable_hash
9898
from learning_engine import LearningEngine
9999
from skill_registry import SkillRegistry
@@ -2431,6 +2431,22 @@ def _run_tool(action: str, args: str) -> str:
24312431
return result
24322432

24332433

2434+
def _execute_durable_task(task: dict[str, Any]) -> dict[str, Any]:
2435+
"""Run one explicitly stored task action through the normal CLI gates."""
2436+
checkpoint = task.get("checkpoint", {})
2437+
action = TOOL_ALIASES.get(str(checkpoint.get("action", "")).strip(),
2438+
str(checkpoint.get("action", "")).strip())
2439+
args = checkpoint.get("args", "")
2440+
if not action:
2441+
return {"success": False,
2442+
"result": "No executable action stored; create the task with action and string args."}
2443+
if not isinstance(args, str):
2444+
return {"success": False, "action": action, "result": "Task args must be a plain string."}
2445+
result = str(_run_tool(action, args))[:2000]
2446+
return {"success": not _is_tool_error(result), "action": action, "args": args, "result": result,
2447+
"acceptance": str(checkpoint.get("acceptance") or "durable task action completed")}
2448+
2449+
24342450
def _select_model(user_input: str) -> str:
24352451
"""Auto-select the best DeepSeek model based on task complexity.
24362452
Simple queries → fast model (deepseek-chat / V4).
@@ -3599,6 +3615,14 @@ def _print_banner() -> None:
35993615
console.print(f" [{_MUTED}]Platform: {platform_tag} {_DOT} Python {sys.version_info.major}.{sys.version_info.minor}[/{_MUTED}]")
36003616

36013617

3618+
def _run_recovered_tasks(*, max_tasks: int = 20) -> list[dict[str, Any]]:
3619+
"""Recover pending CLI tasks and execute only the bounded safe queue."""
3620+
recovered = tasks.recover()
3621+
if recovered:
3622+
console.print(f"[{_MUTED}]Recovered {len(recovered)} durable task(s).[/{_MUTED}]")
3623+
return TaskWorker(tasks, _execute_durable_task, max_tasks=max_tasks).run_until_idle(max_tasks=max_tasks)
3624+
3625+
36023626
def main() -> None:
36033627
# Bytecode cache cleared already at module level (see top of file)
36043628
global _provider_config, llm_provider, DEEPSEEK_MODEL, DEEPSEEK_MODEL_SIMPLE, DEEPSEEK_MODEL_COMPLEX, MODEL_NAME
@@ -3653,6 +3677,9 @@ def main() -> None:
36533677
sys.exit(1)
36543678
# Set workspace root for sandbox
36553679
_set_workspace_root(os.getcwd())
3680+
task_results = _run_recovered_tasks()
3681+
for item in task_results:
3682+
console.print(f"[{_SUCCESS}]Durable task {item['task']['id']}: {item['status']}[/{_SUCCESS}]")
36563683
# Load project files synchronously to avoid ChromaDB thread conflicts
36573684
_load_project_files_into_memory()
36583685
console.print(f"[{_MUTED}]Project files loaded into memory.[/{_MUTED}]")
@@ -3711,6 +3738,21 @@ def main() -> None:
37113738
_show_self_learning_menu()
37123739
continue
37133740

3741+
if user_input.lower().startswith("/tasks"):
3742+
parts = user_input.split(maxsplit=2)
3743+
if len(parts) == 1 or (len(parts) > 1 and parts[1].lower() == "list"):
3744+
console.print(tasks.format())
3745+
elif len(parts) == 3 and parts[1].lower() == "resume":
3746+
resumed = tasks.resume(parts[2].strip())
3747+
if not resumed:
3748+
console.print(f"[{_ERROR}]Failed or blocked task not found.[/{_ERROR}]")
3749+
else:
3750+
result = TaskWorker(tasks, _execute_durable_task, max_tasks=1).run_once()
3751+
console.print(json.dumps(result or {"status": "queued"}, ensure_ascii=False, indent=2, default=str))
3752+
else:
3753+
console.print("Usage: /tasks list | /tasks resume <task-id>")
3754+
continue
3755+
37143756
if user_input.lower().startswith("/agent"):
37153757
parts = user_input.split(maxsplit=1)
37163758
if len(parts) == 1:

‎server.py‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@
3434
import main as _agent
3535
from providers import get_cost_summary, reset_cost_tracker
3636
from memory import MemoryBank
37-
from task_engine import TaskManager
37+
from task_engine import TaskManager, TaskWorker
3838
from scheduler import JobScheduler
3939
from tools import allowed_tool_names, resolve_capabilities, tool_capability
40+
from capability_tokens import issue_capability_token
4041

4142
try:
4243
from fastapi import FastAPI, Request, HTTPException, Depends
@@ -144,6 +145,39 @@ def _audit(event: str, detail: str = "", user: str = "anonymous") -> None:
144145
user_id=_SERVER_ACTOR_ID)
145146

146147

148+
def _execute_durable_task(task: dict[str, Any]) -> dict[str, Any]:
149+
"""Execute only an explicitly stored, Web-profile action."""
150+
checkpoint = task.get("checkpoint", {})
151+
action = str(checkpoint.get("action", "")).strip()
152+
args = checkpoint.get("args", "")
153+
if not action:
154+
return {"success": False,
155+
"result": "No executable action stored; create the task with action and string args."}
156+
action = _agent.TOOL_ALIASES.get(action, action)
157+
if not isinstance(args, str):
158+
return {"success": False, "action": action,
159+
"result": "Task args must be a plain string."}
160+
if action not in _allowed_server_tools("web"):
161+
return {"success": False, "action": action,
162+
"result": f"Error: task action '{action}' is not allowed by the Web capability profile."}
163+
_agent._execution_capability_token = issue_capability_token(
164+
"surface:web:durable-task", _server_capabilities("web"), ttl_seconds=300,
165+
)
166+
result = str(_agent._run_tool(action, args))[:2000]
167+
return {
168+
"success": not result.lower().startswith("error:"), "action": action, "args": args,
169+
"result": result,
170+
"acceptance": str(checkpoint.get("acceptance") or "durable task action completed"),
171+
}
172+
173+
174+
_task_worker = TaskWorker(
175+
TaskManager(_agent.memory_bank.store, workspace_id=_agent.memory_bank.workspace_id,
176+
user_id=_SERVER_ACTOR_ID),
177+
_execute_durable_task,
178+
)
179+
180+
147181
def _run_scheduled_job(job: dict[str, Any]) -> None:
148182
payload = job.get("payload", {})
149183
if payload.get("type") == "learning_cycle":
@@ -152,6 +186,9 @@ def _run_scheduled_job(job: dict[str, Any]) -> None:
152186
if _agent._SELF_LEARNING_FLAGS.get("outcome_verified_evolution", True):
153187
_agent._review_evolution_runs()
154188
return
189+
if payload.get("type") == "task_worker":
190+
_task_worker.run_once()
191+
return
155192
if payload.get("type") == "chat":
156193
session_id = _normalise_session_id(payload.get("session_id"))
157194
session = _get_or_create_session(session_id, _SERVER_ACTOR_ID)
@@ -661,13 +698,36 @@ async def api_v2_create_task(request: Request):
661698
session = _normalise_session_id(body.get("session_id"))
662699
manager = TaskManager(_agent.memory_bank.store, workspace_id=_agent.memory_bank.workspace_id,
663700
session_id=session, user_id=_SERVER_ACTOR_ID)
701+
checkpoint: dict[str, Any] = {}
702+
if body.get("action") is not None:
703+
action = _agent.TOOL_ALIASES.get(str(body.get("action")).strip(), str(body.get("action")).strip())
704+
if action not in _allowed_server_tools("web"):
705+
raise HTTPException(403, f"Task action '{action}' is not allowed by the Web capability profile")
706+
if not isinstance(body.get("args", ""), str):
707+
raise HTTPException(400, "args must be a plain string")
708+
checkpoint = {"action": action, "args": body.get("args", "")}
709+
if isinstance(body.get("acceptance"), list) and body["acceptance"]:
710+
checkpoint["acceptance"] = body["acceptance"][0]
664711
index = manager.add_task(
665712
str(body["description"]),
666713
dependencies=body.get("dependencies") if isinstance(body.get("dependencies"), list) else None,
667714
acceptance=body.get("acceptance") if isinstance(body.get("acceptance"), list) else None,
668715
priority=int(body.get("priority", 0)),
716+
checkpoint=checkpoint,
669717
)
670-
return {"task": manager.tasks[index]}
718+
if not any(job.get("payload", {}).get("type") == "task_worker" for job in _scheduler.list_jobs()):
719+
_scheduler.schedule_every("durable-task-worker", 1.0, payload={"type": "task_worker"},
720+
job_id="job_durable_task_worker", delay_seconds=0)
721+
return {"task": manager.tasks[index], "queued": True}
722+
723+
724+
@app.post("/api/v2/tasks/{task_id}/resume", dependencies=[Depends(require_api_access)])
725+
async def api_v2_resume_task(task_id: str):
726+
manager = _task_worker.manager
727+
task = manager.resume(task_id)
728+
if task is None:
729+
raise HTTPException(404, "Failed or blocked task not found in this scope")
730+
return {"task": task, "queued": True}
671731

672732

673733
@app.get("/api/v2/learning", dependencies=[Depends(require_api_access)])
@@ -1148,6 +1208,12 @@ async def startup():
11481208
_agent._load_project_files_into_memory()
11491209
_load_plugins()
11501210
_trigger_hook("on_startup", agent=_agent)
1211+
recovered = _task_worker.recover()
1212+
if recovered:
1213+
_audit("TASK_RECOVERY", f"recovered={len(recovered)} pending_or_resumable tasks")
1214+
if not any(job.get("payload", {}).get("type") == "task_worker" for job in _scheduler.list_jobs()):
1215+
_scheduler.schedule_every("durable-task-worker", 1.0, payload={"type": "task_worker"},
1216+
job_id="job_durable_task_worker", delay_seconds=0)
11511217
_scheduler.start()
11521218
_audit("STARTUP", "server started")
11531219
print("[Server] Ready — http://127.0.0.1:8000 (set KYROZEN_SERVER_TOKEN for remote access)")

‎task_engine.py‎

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def _event(self, task: dict[str, Any], event_type: str, payload: Any) -> None:
8787

8888
def add_task(self, description: str, *, dependencies: list[str] | None = None,
8989
acceptance: list[dict[str, Any]] | None = None, priority: int = 0,
90-
task_id: str | None = None) -> int:
90+
task_id: str | None = None, checkpoint: dict[str, Any] | None = None) -> int:
9191
description = str(description).strip()
9292
if not description:
9393
raise ValueError("Task description cannot be empty")
@@ -104,7 +104,7 @@ def add_task(self, description: str, *, dependencies: list[str] | None = None,
104104
task = {
105105
"id": task_id, "description": description, "status": "pending", "priority": priority,
106106
"dependencies": dependencies or [], "acceptance": acceptance or [], "attempts": 0,
107-
"checkpoint": {}, "evidence": [], "created_at": utc_now(), "updated_at": utc_now(),
107+
"checkpoint": checkpoint or {}, "evidence": [], "created_at": utc_now(), "updated_at": utc_now(),
108108
}
109109
self.tasks.append(task)
110110
self._persist(task)
@@ -167,6 +167,78 @@ def active_task_id(self) -> str | None:
167167
return task["id"]
168168
return None
169169

170+
def _replace_task(self, task: dict[str, Any]) -> int:
171+
for index, existing in enumerate(self.tasks):
172+
if existing["id"] == task["id"]:
173+
self.tasks[index] = task
174+
return index
175+
self.tasks.append(task)
176+
return len(self.tasks) - 1
177+
178+
def claim_next(self) -> dict[str, Any] | None:
179+
"""Atomically claim one pending task in this exact durable scope."""
180+
with self.store._lock, self.store.connection() as db:
181+
row = db.execute(
182+
"SELECT * FROM tasks WHERE user_id=? AND workspace_id=? AND session_id IS ? AND status=? "
183+
"ORDER BY priority DESC, updated_at LIMIT 1",
184+
(self.user_id, self.workspace_id, self.session_id, "pending"),
185+
).fetchone()
186+
if row is None:
187+
return None
188+
now = utc_now()
189+
claimed = db.execute(
190+
"UPDATE tasks SET status=?,attempts=attempts+1,updated_at=? WHERE id=? AND user_id=? "
191+
"AND workspace_id=? AND session_id IS ? AND status=?",
192+
("running", now, row["id"], self.user_id, self.workspace_id, self.session_id, "pending"),
193+
).rowcount
194+
if claimed != 1:
195+
return None
196+
task = dict(row)
197+
task["status"] = "running"
198+
task["attempts"] = int(task.get("attempts", 0)) + 1
199+
for key in ("dependencies", "acceptance", "checkpoint", "evidence"):
200+
task[key] = self.store._loads(task[key], [] if key in {"dependencies", "acceptance", "evidence"} else {})
201+
task["updated_at"] = now
202+
self._replace_task(task)
203+
self._event(task, "task.claimed", {"attempt": task["attempts"]})
204+
return task
205+
206+
def update_checkpoint(self, task_id: str, checkpoint: dict[str, Any]) -> bool:
207+
"""Persist a bounded resumable checkpoint for one task."""
208+
if not isinstance(checkpoint, dict):
209+
raise ValueError("checkpoint must be an object")
210+
if not self.tasks:
211+
self.recover()
212+
index = next((i for i, task in enumerate(self.tasks) if task["id"] == str(task_id)), None)
213+
if index is None:
214+
return False
215+
task = self.tasks[index]
216+
task["checkpoint"] = {**task.get("checkpoint", {}), **checkpoint}
217+
task["updated_at"] = utc_now()
218+
self._persist(task)
219+
self._event(task, "task.checkpoint", {"checkpoint": task["checkpoint"]})
220+
return True
221+
222+
def resume(self, task_id: str) -> dict[str, Any] | None:
223+
"""Move one failed or blocked task back to pending in this scope."""
224+
if not self.tasks:
225+
self.recover()
226+
index = next((i for i, task in enumerate(self.tasks) if task["id"] == str(task_id)), None)
227+
if index is None:
228+
self.recover()
229+
index = next((i for i, task in enumerate(self.tasks) if task["id"] == str(task_id)), None)
230+
if index is None:
231+
return None
232+
task = self.tasks[index]
233+
if task.get("status") not in {"failed", "blocked"}:
234+
return task if task.get("status") == "pending" else None
235+
previous = task["status"]
236+
task["status"] = "pending"
237+
task["updated_at"] = utc_now()
238+
self._persist(task)
239+
self._event(task, "task.resumed", {"previous_status": previous})
240+
return task
241+
170242
def reconcile_completions(self, task_ids: set[str] | None = None) -> None:
171243
for idx in list(self._pending_completions):
172244
if task_ids is not None and self.tasks[idx]["id"] not in task_ids:
@@ -256,3 +328,53 @@ def recover(self) -> list[dict[str, Any]]:
256328
self._event(task, "task.recovered", {"previous_status": "running"})
257329
self.tasks = rows
258330
return rows
331+
332+
333+
class TaskWorker:
334+
"""Execute at most a bounded number of durable task actions per call."""
335+
336+
def __init__(self, manager: TaskManager, executor: Any, *, max_tasks: int = 1):
337+
self.manager = manager
338+
self.executor = executor
339+
self.max_tasks = max(1, min(int(max_tasks), 20))
340+
341+
def recover(self) -> list[dict[str, Any]]:
342+
return self.manager.recover()
343+
344+
def run_once(self) -> dict[str, Any] | None:
345+
task = self.manager.claim_next()
346+
if task is None:
347+
return None
348+
task_id = task["id"]
349+
try:
350+
outcome = self.executor(task)
351+
if not isinstance(outcome, dict):
352+
outcome = {"success": False, "result": str(outcome)}
353+
except Exception as exc:
354+
outcome = {"success": False, "result": f"Error: {exc}"}
355+
success = bool(outcome.get("success"))
356+
result = str(outcome.get("result", ""))[:2000]
357+
action = str(outcome.get("action") or task.get("checkpoint", {}).get("action") or "task.execute")
358+
acceptance = str(outcome.get("acceptance") or "durable task action completed") if success else None
359+
evidence = self.manager.record_evidence(
360+
task_id=task_id, action=action, args=outcome.get("args") or task.get("checkpoint", {}).get("args"),
361+
result=result, success=success, acceptance=acceptance,
362+
receipt_id=outcome.get("receipt_id"),
363+
)
364+
index = next(i for i, item in enumerate(self.manager.tasks) if item["id"] == task_id)
365+
status = "succeeded" if success and self.manager.set_status(index, "succeeded") else "failed"
366+
if status == "failed" and self.manager.tasks[index].get("status") != "failed":
367+
self.manager.set_status(index, "failed")
368+
self.manager._event(self.manager.tasks[index], "task.execution_completed", {
369+
"success": status == "succeeded", "result": result, "evidence": evidence,
370+
})
371+
return {"task": self.manager.tasks[index], "status": status, "result": result, "evidence": evidence}
372+
373+
def run_until_idle(self, *, max_tasks: int | None = None) -> list[dict[str, Any]]:
374+
results = []
375+
for _ in range(max(1, min(int(max_tasks or self.max_tasks), 20))):
376+
result = self.run_once()
377+
if result is None:
378+
break
379+
results.append(result)
380+
return results

0 commit comments

Comments
 (0)