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.
- openloom/__init__.py +1 -0
- openloom/__main__.py +3 -0
- openloom/cli.py +212 -0
- openloom/config.py +62 -0
- openloom/core/__init__.py +0 -0
- openloom/core/checker.py +20 -0
- openloom/core/events.py +47 -0
- openloom/core/harness.py +290 -0
- openloom/core/registry.py +56 -0
- openloom/core/sink.py +11 -0
- openloom/core/source.py +10 -0
- openloom/core/store.py +158 -0
- openloom/levels/__init__.py +0 -0
- openloom/levels/config/__init__.py +0 -0
- openloom/levels/config/spec.py +29 -0
- openloom/levels/manual/__init__.py +0 -0
- openloom/levels/manual/checker.py +21 -0
- openloom/levels/manual/sink.py +44 -0
- openloom/levels/manual/source.py +19 -0
- openloom/levels/manual/watch.py +84 -0
- openloom/levels/openspec/__init__.py +0 -0
- openloom/levels/openspec/cold.py +9 -0
- openloom/levels/server/__init__.py +0 -0
- openloom/levels/server/monitor.py +103 -0
- openloom/levels/server/serve.py +140 -0
- openloom/levels/ui/__init__.py +0 -0
- openloom/levels/ui/sink.py +43 -0
- openloom/py.typed +0 -0
- openloom/runtime/__init__.py +0 -0
- openloom/runtime/opencode.py +343 -0
- openloom/runtime/planner.py +201 -0
- openloom/runtime/prompts.py +453 -0
- openloom/runtime/session_status.py +76 -0
- openloom/runtime/telemetry.py +188 -0
- openloom/server/__init__.py +0 -0
- openloom/server/app.py +380 -0
- openloom/server/cold.py +16 -0
- openloom/server/recent.py +88 -0
- openloom/server/routes/sessions.py +87 -0
- openloom/server/routes/tasks.py +186 -0
- openloom/server/static/app/assets/index-2UrBppYG.css +1 -0
- openloom/server/static/app/assets/index-i3AFSfyv.js +4 -0
- openloom/server/static/app/index.html +13 -0
- openloom/server/static/index.html +133 -0
- openloom-0.7.0.dist-info/METADATA +142 -0
- openloom-0.7.0.dist-info/RECORD +49 -0
- openloom-0.7.0.dist-info/WHEEL +4 -0
- openloom-0.7.0.dist-info/entry_points.txt +2 -0
- 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
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
|
openloom/core/checker.py
ADDED
|
@@ -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
|
+
...
|
openloom/core/events.py
ADDED
|
@@ -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)
|
openloom/core/harness.py
ADDED
|
@@ -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()
|