openloom 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. openloom/__init__.py +1 -0
  2. openloom/__main__.py +3 -0
  3. openloom/cli.py +212 -0
  4. openloom/config.py +62 -0
  5. openloom/core/__init__.py +0 -0
  6. openloom/core/checker.py +20 -0
  7. openloom/core/events.py +47 -0
  8. openloom/core/harness.py +290 -0
  9. openloom/core/registry.py +56 -0
  10. openloom/core/sink.py +11 -0
  11. openloom/core/source.py +10 -0
  12. openloom/core/store.py +158 -0
  13. openloom/levels/__init__.py +0 -0
  14. openloom/levels/config/__init__.py +0 -0
  15. openloom/levels/config/spec.py +29 -0
  16. openloom/levels/manual/__init__.py +0 -0
  17. openloom/levels/manual/checker.py +21 -0
  18. openloom/levels/manual/sink.py +44 -0
  19. openloom/levels/manual/source.py +19 -0
  20. openloom/levels/manual/watch.py +84 -0
  21. openloom/levels/openspec/__init__.py +0 -0
  22. openloom/levels/openspec/cold.py +9 -0
  23. openloom/levels/server/__init__.py +0 -0
  24. openloom/levels/server/monitor.py +103 -0
  25. openloom/levels/server/serve.py +140 -0
  26. openloom/levels/ui/__init__.py +0 -0
  27. openloom/levels/ui/sink.py +43 -0
  28. openloom/py.typed +0 -0
  29. openloom/runtime/__init__.py +0 -0
  30. openloom/runtime/opencode.py +343 -0
  31. openloom/runtime/planner.py +201 -0
  32. openloom/runtime/prompts.py +453 -0
  33. openloom/runtime/session_status.py +76 -0
  34. openloom/runtime/telemetry.py +188 -0
  35. openloom/server/__init__.py +0 -0
  36. openloom/server/app.py +380 -0
  37. openloom/server/cold.py +16 -0
  38. openloom/server/recent.py +88 -0
  39. openloom/server/routes/sessions.py +87 -0
  40. openloom/server/routes/tasks.py +186 -0
  41. openloom/server/static/app/assets/index-2UrBppYG.css +1 -0
  42. openloom/server/static/app/assets/index-i3AFSfyv.js +4 -0
  43. openloom/server/static/app/index.html +13 -0
  44. openloom/server/static/index.html +133 -0
  45. openloom-0.7.0.dist-info/METADATA +142 -0
  46. openloom-0.7.0.dist-info/RECORD +49 -0
  47. openloom-0.7.0.dist-info/WHEEL +4 -0
  48. openloom-0.7.0.dist-info/entry_points.txt +2 -0
  49. openloom-0.7.0.dist-info/licenses/LICENSE +21 -0
openloom/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
openloom/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from openloom.cli import main
2
+
3
+ main()
openloom/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import importlib.util
6
+ import sys
7
+ from typing import Any
8
+
9
+ from openloom.config import Settings
10
+ from openloom.core.events import EventBus
11
+ from openloom.core.store import Store
12
+
13
+
14
+ def _print_table(headers: list[str], rows: list[list[str]]) -> None:
15
+ col_widths = [len(h) for h in headers]
16
+ for row in rows:
17
+ for i, cell in enumerate(row):
18
+ col_widths[i] = max(col_widths[i], len(str(cell)))
19
+ fmt = " ".join(f"{{:<{w}}}" for w in col_widths)
20
+ print(fmt.format(*headers))
21
+ print(fmt.format(*["─" * w for w in col_widths]))
22
+ for row in rows:
23
+ print(fmt.format(*[str(c) for c in row]))
24
+
25
+
26
+ def _web_extras_available() -> bool:
27
+ return all(
28
+ importlib.util.find_spec(name) is not None
29
+ for name in ("fastapi", "uvicorn", "sse_starlette")
30
+ )
31
+
32
+
33
+ def _require_web_extras() -> None:
34
+ if not _web_extras_available():
35
+ raise ImportError(
36
+ "Web UI requires FastAPI extras. Install with: pip install openloom[ui]"
37
+ )
38
+
39
+
40
+ async def _run_watch_with_ui(
41
+ spec: str | None,
42
+ settings: Settings,
43
+ web_sink: Any,
44
+ *,
45
+ store_path: str,
46
+ ) -> None:
47
+ from openloom.server.app import create_app
48
+ import uvicorn
49
+ from openloom.levels.manual.watch import run_watch
50
+
51
+ store = Store(store_path)
52
+ bus = EventBus()
53
+ bus.subscribe_all(web_sink.on_event)
54
+
55
+ app = create_app(harness=None, store=store, bus=bus, web_sink=web_sink)
56
+ config = uvicorn.Config(
57
+ app, host=settings.ui_host, port=settings.ui_port, log_level="warning"
58
+ )
59
+ server = uvicorn.Server(config)
60
+ server_task = asyncio.create_task(server.serve())
61
+
62
+ try:
63
+ await run_watch(
64
+ spec,
65
+ settings,
66
+ store_path=store_path,
67
+ bus=bus,
68
+ web_sink=web_sink,
69
+ )
70
+ finally:
71
+ server.should_exit = True
72
+ server_task.cancel()
73
+ with asyncio.suppress(asyncio.CancelledError):
74
+ await server_task
75
+
76
+
77
+ def main() -> None:
78
+ parser = argparse.ArgumentParser(
79
+ prog="openloom",
80
+ description="OpenLoom — lightweight agent task harness",
81
+ )
82
+ sub = parser.add_subparsers(dest="command")
83
+
84
+ watch_p = sub.add_parser("watch", help="Watch a task spec and manage the agent session")
85
+ watch_p.add_argument("spec", nargs="?", help="Path to task spec YAML (reads openloom.yaml if omitted)")
86
+ watch_p.add_argument("--ui", action="store_true", help="Start web UI on http://127.0.0.1:55413")
87
+
88
+ serve_p = sub.add_parser("serve", help="Start OpenLoom server (multi-task, web dashboard)")
89
+ serve_p.add_argument("--host", help="Bind host (default: 127.0.0.1)")
90
+ serve_p.add_argument("--port", type=int, help="Bind port (default: 55413)")
91
+
92
+ init_p = sub.add_parser("init", help="Generate openloom.yaml in the current directory")
93
+ init_p.add_argument("--path", help="Target path (default: ./openloom.yaml)")
94
+
95
+ sub.add_parser("status", help="List all tasks from the store")
96
+
97
+ log_p = sub.add_parser("log", help="Show check log for a task")
98
+ log_p.add_argument("task_id", help="Task ID (prefix match supported)")
99
+
100
+ args = parser.parse_args()
101
+ settings = Settings.from_env()
102
+
103
+ if args.command == "init":
104
+ from openloom.levels.config.spec import generate_config
105
+
106
+ try:
107
+ path = generate_config(args.path)
108
+ print(f"Created {path}")
109
+ except FileExistsError as e:
110
+ print(f"ERROR: {e}")
111
+ sys.exit(1)
112
+
113
+ elif args.command == "watch":
114
+ store_path = str(settings.database_path)
115
+ if args.ui:
116
+ try:
117
+ _require_web_extras()
118
+ except ImportError as e:
119
+ print(f"ERROR: {e}")
120
+ sys.exit(1)
121
+ from openloom.levels.ui.sink import WebSink
122
+
123
+ web_sink = WebSink()
124
+ asyncio.run(
125
+ _run_watch_with_ui(
126
+ args.spec, settings, web_sink, store_path=store_path
127
+ )
128
+ )
129
+ else:
130
+ from openloom.levels.manual.watch import run_watch
131
+
132
+ asyncio.run(run_watch(args.spec, settings, store_path=store_path))
133
+
134
+ elif args.command == "serve":
135
+ import openloom.levels.manual.checker # noqa: F401
136
+ import openloom.levels.manual.sink # noqa: F401
137
+ import openloom.levels.ui.sink # noqa: F401
138
+ from openloom.levels.server.serve import run_serve
139
+
140
+ if args.host:
141
+ settings = Settings(
142
+ opencode_url=settings.opencode_url,
143
+ opencode_username=settings.opencode_username,
144
+ opencode_password=settings.opencode_password,
145
+ database_path=settings.database_path,
146
+ allowed_roots=settings.allowed_roots,
147
+ strict_roots=settings.strict_roots,
148
+ ui_host=args.host,
149
+ ui_port=args.port or settings.ui_port,
150
+ )
151
+ elif args.port:
152
+ settings = Settings(
153
+ opencode_url=settings.opencode_url,
154
+ opencode_username=settings.opencode_username,
155
+ opencode_password=settings.opencode_password,
156
+ database_path=settings.database_path,
157
+ allowed_roots=settings.allowed_roots,
158
+ strict_roots=settings.strict_roots,
159
+ ui_port=args.port,
160
+ )
161
+
162
+ asyncio.run(run_serve(settings))
163
+
164
+ elif args.command == "status":
165
+ store = Store(settings.database_path)
166
+ tasks = store.list_tasks()
167
+ if not tasks:
168
+ print("No tasks found.")
169
+ return
170
+ headers = ["ID", "Name", "Status", "Progress", "Last Summary"]
171
+ rows = [
172
+ [
173
+ t["id"][:12],
174
+ t.get("name", "")[:40],
175
+ t.get("status", ""),
176
+ f"{t.get('progress', 0):.0%}",
177
+ (t.get("last_summary") or "")[:50],
178
+ ]
179
+ for t in tasks
180
+ ]
181
+ _print_table(headers, rows)
182
+
183
+ elif args.command == "log":
184
+ import datetime
185
+
186
+ store = Store(settings.database_path)
187
+ prefix = args.task_id
188
+ tasks = store.list_tasks()
189
+ match = next((t for t in tasks if t["id"].startswith(prefix)), None)
190
+ if not match:
191
+ print(f"No task found matching '{prefix}'")
192
+ sys.exit(1)
193
+ task = store.get_task(match["id"])
194
+ print(f"Task: {task['id']} — {task.get('name', '')}")
195
+ print(f"Status: {task.get('status', '')} Progress: {task.get('progress', 0):.0%}")
196
+ print()
197
+ log = task.get("check_log") or []
198
+ for entry in log[-20:]:
199
+ ts = entry.get("at", 0)
200
+ dt = datetime.datetime.fromtimestamp(ts).strftime("%H:%M:%S")
201
+ print(f" [{dt}] {entry.get('status', '')}")
202
+ print(f" {entry.get('summary', '')}")
203
+ if len(log) > 20:
204
+ print(f" ... ({len(log) - 20} more entries)")
205
+
206
+ else:
207
+ parser.print_help()
208
+ sys.exit(1)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
openloom/config.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ def _split_paths(value: str) -> list[Path]:
9
+ paths: list[Path] = []
10
+ for item in value.split(":"):
11
+ item = item.strip()
12
+ if not item:
13
+ continue
14
+ paths.append(Path(item).expanduser().resolve())
15
+ return paths
16
+
17
+
18
+ def _env_bool(name: str, default: bool = False) -> bool:
19
+ raw = os.getenv(name)
20
+ if raw is None:
21
+ return default
22
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Settings:
27
+ opencode_url: str
28
+ opencode_username: str
29
+ opencode_password: str
30
+ database_path: Path
31
+ allowed_roots: list[Path]
32
+ strict_roots: bool
33
+ ui_host: str = "127.0.0.1"
34
+ ui_port: int = 55413
35
+
36
+ @classmethod
37
+ def from_env(cls) -> Settings:
38
+ database = Path(os.getenv("OPENLOOM_DATABASE", ".openloom/openloom.sqlite3")).expanduser()
39
+ if not database.is_absolute():
40
+ database = Path.cwd() / database
41
+ roots = _split_paths(os.getenv("OPENLOOM_ALLOWED_ROOTS", ""))
42
+
43
+ return cls(
44
+ opencode_url=os.getenv("OPENLOOM_OPENCODE_URL", "http://127.0.0.1:14096").rstrip("/"),
45
+ opencode_username=os.getenv("OPENLOOM_OPENCODE_USERNAME", "opencode"),
46
+ opencode_password=os.getenv("OPENLOOM_OPENCODE_PASSWORD", "xxx"),
47
+ database_path=database,
48
+ allowed_roots=roots,
49
+ strict_roots=_env_bool("OPENLOOM_STRICT_ROOTS", default=False),
50
+ ui_host=os.getenv("OPENLOOM_UI_HOST", "127.0.0.1"),
51
+ ui_port=int(os.getenv("OPENLOOM_UI_PORT", "55413")),
52
+ )
53
+
54
+ def is_allowed_workspace(self, cwd: str) -> bool:
55
+ path = Path(cwd).expanduser().resolve()
56
+ if not path.exists() or not path.is_dir():
57
+ return False
58
+ if not self.strict_roots:
59
+ return True
60
+ if not self.allowed_roots:
61
+ return False
62
+ return any(path == root or root in path.parents for root in self.allowed_roots)
File without changes
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class CheckResult:
10
+ task_complete: bool = False
11
+ step_done: int = 0
12
+ acceptance_checked: int = 0
13
+ acceptance_total: int = 0
14
+ acceptance_progress: float = 0.0
15
+
16
+
17
+ class Checker(ABC):
18
+ @abstractmethod
19
+ def check(self, messages: list[dict[str, Any]], spec: dict[str, Any]) -> CheckResult:
20
+ ...
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum, auto
8
+ from typing import Any, Callable
9
+
10
+ _logger = logging.getLogger("openloom.events")
11
+
12
+
13
+ class EventType(Enum):
14
+ TASK_CREATED = auto()
15
+ TASK_STARTED = auto()
16
+ TASK_UPDATED = auto()
17
+ TASK_COMPLETED = auto()
18
+ TASK_FAILED = auto()
19
+ LOG_LINE = auto()
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Event:
24
+ type: EventType
25
+ task_id: str
26
+ timestamp: float = field(default_factory=time.time)
27
+ store_version: int = 0
28
+ data: dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ class EventBus:
32
+ def __init__(self) -> None:
33
+ self._subscribers: dict[EventType, list[Callable[[Event], None]]] = defaultdict(list)
34
+ self._wildcards: list[Callable[[Event], None]] = []
35
+
36
+ def subscribe(self, event_type: EventType, handler: Callable[[Event], None]) -> None:
37
+ self._subscribers[event_type].append(handler)
38
+
39
+ def subscribe_all(self, handler: Callable[[Event], None]) -> None:
40
+ self._wildcards.append(handler)
41
+
42
+ def emit(self, event: Event) -> None:
43
+ for handler in (*self._subscribers.get(event.type, ()), *self._wildcards):
44
+ try:
45
+ handler(event)
46
+ except Exception:
47
+ _logger.exception("Event handler %r failed for %s", handler, event.type.name)
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from .checker import CheckResult
8
+ from .events import Event, EventBus, EventType
9
+
10
+
11
+ class HarnessRunner:
12
+ def __init__(self, opencode: Any, bus: EventBus, store: Any, checker: Any, prompts: Any, status: Any, allowed_workspace: Any = None) -> None:
13
+ self.opencode = opencode
14
+ self.bus = bus
15
+ self.store = store
16
+ self.checker = checker
17
+ self.prompts = prompts
18
+ self.status = status
19
+ self.allowed_workspace = allowed_workspace or (lambda _: True)
20
+
21
+ def add_task(self, spec: Any, task_id: str | None = None) -> str:
22
+ if not hasattr(spec, "to_dict"):
23
+ spec = self.prompts.TaskSpec.from_dict(spec)
24
+
25
+ task_id = task_id or f"task_{uuid.uuid4().hex[:12]}"
26
+ now = time.time()
27
+ task = {
28
+ "id": task_id,
29
+ "name": spec.name,
30
+ "spec": spec.to_dict(),
31
+ "workspace": spec.workspace,
32
+ "status": "pending",
33
+ "current_step": 0,
34
+ "completed_steps": [],
35
+ "idle_checks": 0,
36
+ "progress": 0.0,
37
+ "check_interval_seconds": spec.check_interval_seconds,
38
+ "last_check_at": None,
39
+ "next_check_at": now,
40
+ "active_session_id": None,
41
+ "session_ids": [],
42
+ "last_summary": None,
43
+ "error": None,
44
+ "check_log": [],
45
+ }
46
+ result = self.store.create_task(task)
47
+ sv = result.get("store_version", 0)
48
+
49
+ self.bus.emit(Event(type=EventType.TASK_CREATED, task_id=task_id, store_version=sv,
50
+ data={"spec": spec.to_dict(), "workspace": spec.workspace}))
51
+ return task_id
52
+
53
+ async def tick(self) -> None:
54
+ due = self.store.list_due_tasks()
55
+ for task in due:
56
+ try:
57
+ await self._check_task(task)
58
+ except Exception as exc: # noqa: BLE001
59
+ sv = self.store.update_task(task["id"], status="failed", error=str(exc))
60
+ self.store.append_check_log(
61
+ task["id"], status="failed", summary="Harness check failed", detail=str(exc),
62
+ )
63
+ self.bus.emit(Event(
64
+ type=EventType.TASK_FAILED,
65
+ task_id=task["id"],
66
+ store_version=sv,
67
+ data={"error": str(exc), "summary": "Harness check failed"},
68
+ ))
69
+
70
+ async def _check_task(self, task: dict[str, Any]) -> None:
71
+ task_id = task["id"]
72
+ spec_data = task["spec"]
73
+ now = time.time()
74
+
75
+ if task["status"] == "pending":
76
+ await self._start_task(task)
77
+ return
78
+
79
+ if task["status"] in ("paused", "completed", "failed", "archived"):
80
+ return
81
+
82
+ session_id = task.get("active_session_id")
83
+ if not session_id:
84
+ await self._start_task(task)
85
+ return
86
+
87
+ spec = self.prompts.TaskSpec.from_dict(spec_data)
88
+
89
+ live_raw = await self.opencode.session_status()
90
+ live = live_raw.get(session_id)
91
+ status_text = self.status.normalize_session_status(live) or ""
92
+
93
+ messages = await self.opencode.messages(session_id, limit=50)
94
+ is_busy = self.prompts.messages_indicate_busy(messages)
95
+
96
+ result: CheckResult = self.checker.check(messages, spec_data)
97
+
98
+ current_step = int(task.get("current_step") or 0)
99
+ if result.step_done > 0:
100
+ current_step = max(current_step, min(result.step_done, len(spec.steps) - 1))
101
+
102
+ completed_steps = list(task.get("completed_steps") or [])
103
+ all_steps_reported = len(spec.steps) > 0 and result.step_done >= len(spec.steps)
104
+ task_finished = (
105
+ result.task_complete
106
+ or (spec.acceptance and result.acceptance_checked >= len(spec.acceptance))
107
+ or all_steps_reported
108
+ )
109
+
110
+ if is_busy:
111
+ status = "running"
112
+ summary = "Agent is busy"
113
+ elif status_text == self.status.RETRY or "wait" in status_text or "permission" in status_text:
114
+ status = "waiting"
115
+ summary = "Waiting for permission or input"
116
+ elif task_finished:
117
+ status = "completed"
118
+ summary = "Agent reported TASK COMPLETE" if result.task_complete else "All steps appear complete"
119
+ else:
120
+ status = "running"
121
+ summary = "Session idle — verifying progress"
122
+ agent_name: str | None = None if spec.agent == "opencode" else spec.agent
123
+ nudge: str | None = None
124
+
125
+ if self.prompts.needs_continue_reply(messages):
126
+ step_name = spec.steps[min(current_step, len(spec.steps) - 1)]
127
+ nudge = (
128
+ f"Yes — proceed autonomously with step {current_step + 1}: {step_name}. "
129
+ "Do not ask for confirmation between harness steps; implement directly. "
130
+ f"Reply with STEP DONE: {current_step + 1} when this step is finished."
131
+ )
132
+ summary = f"Auto-continued to step {current_step + 1}"
133
+ elif (
134
+ result.step_done > 0
135
+ and result.step_done not in completed_steps
136
+ and current_step < len(spec.steps) - 1
137
+ ):
138
+ next_index = min(result.step_done, len(spec.steps) - 1)
139
+ nudge = (
140
+ f"Continue harness task '{spec.name}'. "
141
+ f"Focus on step {next_index + 1}: {spec.steps[next_index]}. "
142
+ "Proceed without asking for permission. "
143
+ "Reply with STEP DONE: <number> or TASK COMPLETE when appropriate."
144
+ )
145
+ current_step = next_index
146
+ summary = f"Nudged agent toward step {next_index + 1}"
147
+ else:
148
+ nudge = self.prompts.build_periodic_check_prompt(
149
+ spec,
150
+ current_step=current_step,
151
+ progress={
152
+ "task_complete": result.task_complete,
153
+ "step_done": result.step_done,
154
+ "acceptance_checked": result.acceptance_checked,
155
+ "acceptance_total": result.acceptance_total,
156
+ "acceptance_progress": result.acceptance_progress,
157
+ },
158
+ completed_steps=completed_steps,
159
+ )
160
+ summary = "Periodic check — requested status confirmation"
161
+
162
+ if nudge:
163
+ await self.opencode.send_prompt_async(
164
+ session_id=session_id,
165
+ prompt=nudge,
166
+ agent=agent_name,
167
+ )
168
+
169
+ if result.step_done > 0 and result.step_done not in completed_steps:
170
+ completed_steps.append(result.step_done)
171
+
172
+ total_steps = len(spec.steps)
173
+ if status == "completed":
174
+ step_progress = 1.0
175
+ elif total_steps:
176
+ step_progress = min(1.0, len(completed_steps) / total_steps)
177
+ else:
178
+ step_progress = 1.0 if task_finished else 0.0
179
+
180
+ idle_checks = int(task.get("idle_checks") or 0)
181
+ if not is_busy:
182
+ idle_checks += 1
183
+ else:
184
+ idle_checks = 0
185
+
186
+ interval = int(task.get("check_interval_seconds", 300))
187
+ next_check = None if status in ("completed", "failed", "archived") else now + interval
188
+
189
+ sv = self.store.update_task(
190
+ task_id,
191
+ status=status,
192
+ current_step=current_step,
193
+ completed_steps=completed_steps,
194
+ idle_checks=idle_checks,
195
+ progress=step_progress,
196
+ last_check_at=now,
197
+ next_check_at=next_check,
198
+ last_summary=summary,
199
+ )
200
+ self.store.append_check_log(task_id, status=status, summary=summary, detail=status_text)
201
+
202
+ self.bus.emit(Event(
203
+ type=EventType.TASK_UPDATED,
204
+ task_id=task_id,
205
+ store_version=sv,
206
+ data={
207
+ "status": status,
208
+ "current_step": current_step,
209
+ "progress": step_progress,
210
+ "summary": summary,
211
+ },
212
+ ))
213
+
214
+ if status == "completed":
215
+ self.bus.emit(Event(type=EventType.TASK_COMPLETED, task_id=task_id, store_version=sv,
216
+ data={"summary": summary, "progress": step_progress}))
217
+ elif status == "failed":
218
+ self.bus.emit(Event(type=EventType.TASK_FAILED, task_id=task_id, store_version=sv, data={"summary": summary}))
219
+
220
+ async def _start_task(self, task: dict[str, Any]) -> None:
221
+ task_id = task["id"]
222
+ spec = self.prompts.TaskSpec.from_dict(task["spec"])
223
+ session_id = task.get("active_session_id")
224
+ attached = bool(session_id)
225
+
226
+ if session_id:
227
+ try:
228
+ sessions = await self.opencode.list_sessions()
229
+ except Exception:
230
+ sessions = []
231
+ match = next((s for s in sessions if s.get("id") == session_id), None)
232
+ if not match:
233
+ raise ValueError(f"Session {session_id} no longer exists")
234
+ directory = match.get("directory") or spec.workspace
235
+ if directory and self.allowed_workspace(directory):
236
+ spec = self.prompts.TaskSpec.from_dict({**spec.to_dict(), "workspace": directory})
237
+ else:
238
+ if not self.allowed_workspace(spec.workspace):
239
+ raise ValueError("Workspace is outside allowed roots")
240
+ session = await self.opencode.create_session(cwd=spec.workspace, title=spec.name)
241
+ session_id = session["id"]
242
+
243
+ raw_interval = task.get("check_interval_seconds")
244
+ interval = int(raw_interval if raw_interval is not None else spec.check_interval_seconds)
245
+ one_shot = interval <= 0
246
+ structured = bool(spec.steps or spec.acceptance or spec.step_acceptance)
247
+ if structured or not one_shot:
248
+ prompt = self.prompts.build_bootstrap_prompt(
249
+ spec, current_step=int(task.get("current_step") or 0),
250
+ )
251
+ else:
252
+ prompt = spec.initial_prompt or spec.goal
253
+ if not prompt:
254
+ raise ValueError("Task requires a prompt or structured plan")
255
+
256
+ await self.opencode.send_prompt_async(
257
+ session_id=session_id,
258
+ prompt=prompt,
259
+ agent=None if spec.agent == "opencode" else spec.agent,
260
+ )
261
+
262
+ session_ids = list(task.get("session_ids") or [])
263
+ if session_id not in session_ids:
264
+ session_ids.append(session_id)
265
+
266
+ now = time.time()
267
+ summary = (
268
+ (f"Prompt sent to session {session_id}" if attached else "Prompt sent")
269
+ if one_shot
270
+ else (f"Harness attached to session {session_id}" if attached else "Harness started and bootstrap prompt sent")
271
+ )
272
+ sv = self.store.update_task(
273
+ task_id,
274
+ status="running",
275
+ active_session_id=session_id,
276
+ session_ids=session_ids,
277
+ spec=spec.to_dict(),
278
+ last_check_at=now,
279
+ next_check_at=None if one_shot else now + interval,
280
+ error=None,
281
+ )
282
+ self.store.append_check_log(task_id, status="running", summary=summary, detail=f"session={session_id}")
283
+ self.bus.emit(Event(type=EventType.TASK_STARTED, task_id=task_id, store_version=sv,
284
+ data={"session_id": session_id, "summary": summary}))
285
+
286
+ def get_task(self, task_id: str) -> dict[str, Any] | None:
287
+ return self.store.get_task(task_id)
288
+
289
+ def list_tasks(self) -> list[dict[str, Any]]:
290
+ return self.store.list_tasks()