task-harbor 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.
- opus_manager/__init__.py +3 -0
- opus_manager/__main__.py +3 -0
- opus_manager/agent.py +17 -0
- opus_manager/backlog.py +128 -0
- opus_manager/claude.py +138 -0
- opus_manager/cli.py +305 -0
- opus_manager/codex.py +119 -0
- opus_manager/comparison.py +162 -0
- opus_manager/config.py +134 -0
- opus_manager/contracts.py +58 -0
- opus_manager/desktop_actions.py +22 -0
- opus_manager/evidence.py +50 -0
- opus_manager/gitops.py +218 -0
- opus_manager/help.py +59 -0
- opus_manager/models.py +110 -0
- opus_manager/offline.py +142 -0
- opus_manager/operator_commands.py +86 -0
- opus_manager/orchestrator.py +1267 -0
- opus_manager/preflight.py +92 -0
- opus_manager/prompts.py +259 -0
- opus_manager/providers.py +11 -0
- opus_manager/reporting.py +82 -0
- opus_manager/state.py +37 -0
- opus_manager/tui.py +388 -0
- opus_manager/usage.py +77 -0
- opus_manager/verification.py +39 -0
- task_harbor/__init__.py +1 -0
- task_harbor/__main__.py +3 -0
- task_harbor-0.7.0.dist-info/METADATA +294 -0
- task_harbor-0.7.0.dist-info/RECORD +34 -0
- task_harbor-0.7.0.dist-info/WHEEL +5 -0
- task_harbor-0.7.0.dist-info/entry_points.txt +4 -0
- task_harbor-0.7.0.dist-info/licenses/LICENSE +21 -0
- task_harbor-0.7.0.dist-info/top_level.txt +2 -0
opus_manager/tui.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Terminal dashboard with explicit project and run controls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
14
|
+
from textual.widgets import Button, DataTable, Footer, Select, Static, ProgressBar
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .reporting import run_report
|
|
18
|
+
from .help import GUIDE
|
|
19
|
+
from .desktop_actions import launch_run
|
|
20
|
+
from .reporting import review_text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfirmRun(ModalScreen):
|
|
24
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
25
|
+
DEFAULT_CSS = """
|
|
26
|
+
ConfirmRun { align: center middle; background: #000000 70%; }
|
|
27
|
+
#confirm-panel { width: 80%; height: auto; max-height: 90%; border: round #66e3c4; padding: 1 2; background: #111e2c; }
|
|
28
|
+
#confirm-text { height: auto; margin-bottom: 1; }
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, description: str):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.description = description
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
with VerticalScroll(id="confirm-panel"):
|
|
37
|
+
yield Static(self.description, id="confirm-text", markup=False)
|
|
38
|
+
yield Button("Start agent work", id="confirm-start", variant="success")
|
|
39
|
+
yield Button("Cancel", id="cancel-start")
|
|
40
|
+
|
|
41
|
+
def action_cancel(self) -> None:
|
|
42
|
+
self.dismiss(False)
|
|
43
|
+
|
|
44
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
45
|
+
self.dismiss(event.button.id == "confirm-start")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HelpScreen(ModalScreen):
|
|
49
|
+
BINDINGS = [("escape", "dismiss", "Close"), ("f1", "dismiss", "Close")]
|
|
50
|
+
DEFAULT_CSS = """
|
|
51
|
+
HelpScreen { align: center middle; background: #000000 70%; }
|
|
52
|
+
#help-panel { width: 85%; height: 90%; border: round #66e3c4; padding: 1 2; background: #111e2c; }
|
|
53
|
+
#help-text { height: auto; }
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, content: str = GUIDE):
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.content = content
|
|
59
|
+
|
|
60
|
+
def compose(self) -> ComposeResult:
|
|
61
|
+
with VerticalScroll(id="help-panel"):
|
|
62
|
+
yield Button("Close (Esc)", id="close-help", variant="primary")
|
|
63
|
+
yield Static(self.content, id="help-text", markup=False)
|
|
64
|
+
|
|
65
|
+
def on_button_pressed(self) -> None:
|
|
66
|
+
self.dismiss()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def runs_directory(repo: Path) -> Path:
|
|
70
|
+
# Config.load migrates files; monitoring must remain read-only.
|
|
71
|
+
path = repo / ".manager" / "config.json"
|
|
72
|
+
raw = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
73
|
+
return repo / Config(manager_dir=raw.get("manager_dir", ".manager")).manager_dir / "runs"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def event_tail(path: Path) -> str:
|
|
77
|
+
"""Bound reads even for runs with large event logs; ignore partial last writes."""
|
|
78
|
+
if not path.exists():
|
|
79
|
+
return "No events recorded yet."
|
|
80
|
+
with path.open("rb") as stream:
|
|
81
|
+
stream.seek(0, 2)
|
|
82
|
+
start = max(0, stream.tell() - 16384)
|
|
83
|
+
stream.seek(start)
|
|
84
|
+
lines = stream.read().decode("utf-8", errors="replace").splitlines()
|
|
85
|
+
if start:
|
|
86
|
+
lines = lines[1:]
|
|
87
|
+
events = []
|
|
88
|
+
for line in lines:
|
|
89
|
+
try:
|
|
90
|
+
item = json.loads(line)
|
|
91
|
+
events.append(f'{str(item.get("time", ""))[11:19]} {item.get("kind", "event")} {item.get("task_id", "")}')
|
|
92
|
+
except (ValueError, AttributeError):
|
|
93
|
+
continue
|
|
94
|
+
return "\n".join(events[-8:]) or "No events recorded yet."
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HarborApp(App):
|
|
98
|
+
TITLE = "Task Harbor"
|
|
99
|
+
BINDINGS = [("f1", "help", "Help"), ("e", "evidence", "Evidence"), ("p", "preflight", "Preflight"), ("q", "quit", "Quit"), ("r", "reload", "Refresh"), ("tab", "focus_next", "Next pane")]
|
|
100
|
+
CSS = """
|
|
101
|
+
Screen { background: #0b111b; color: #d9e4ef; }
|
|
102
|
+
#brand { height: 5; padding: 1 2; background: #111e2c; color: #66e3c4; text-style: bold; }
|
|
103
|
+
#workspace { height: 1; margin: 0 2; color: #8295aa; }
|
|
104
|
+
#actions { height: 3; margin: 1 2 0 2; }
|
|
105
|
+
#actions Button { min-width: 10; width: 1fr; margin-right: 1; }
|
|
106
|
+
#action-result { height: auto; max-height: 5; margin: 0 2; color: #f2c777; }
|
|
107
|
+
#welcome { height: auto; padding: 1 2; margin: 1 2; border: round #66e3c4; background: #111e2c; }
|
|
108
|
+
#completion { height: 1; margin: 0 2; }
|
|
109
|
+
#run-picker { background: #111e2c; }
|
|
110
|
+
#run-picker { margin: 1 2 0 2; }
|
|
111
|
+
#summary { height: 5; margin: 0 2; }
|
|
112
|
+
.metric { width: 1fr; border: round #294158; padding: 0 1; margin-right: 1; }
|
|
113
|
+
#body { height: 1fr; margin: 0 2; }
|
|
114
|
+
#tasks { width: 2fr; height: 1fr; border: round #294158; background: #101b29; }
|
|
115
|
+
#tasks, #details-pane, #events { border-title-color: #66e3c4; }
|
|
116
|
+
#details-pane { width: 1fr; min-width: 25; border: round #294158; margin-left: 1; padding: 0 1; }
|
|
117
|
+
#details { height: auto; }
|
|
118
|
+
#events { height: 11; margin: 0 2; padding: 0 1; border: round #294158; color: #8295aa; }
|
|
119
|
+
#notice { height: auto; max-height: 4; margin: 0 2; color: #f2c777; }
|
|
120
|
+
DataTable > .datatable--cursor { background: #214c52; color: #ffffff; }
|
|
121
|
+
DataTable > .datatable--header { background: #152638; color: #66e3c4; }
|
|
122
|
+
Footer { background: #111e2c; }
|
|
123
|
+
.compact #body { layout: vertical; }
|
|
124
|
+
.compact #brand { height: 3; padding: 0 2; }
|
|
125
|
+
.compact #summary { height: 4; }
|
|
126
|
+
.compact #tasks { width: 1fr; height: 1fr; }
|
|
127
|
+
.compact #details-pane { width: 1fr; height: 6; margin-left: 0; }
|
|
128
|
+
.compact #events { height: 6; }
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, repo: Path, run_id: str | None = None):
|
|
132
|
+
super().__init__()
|
|
133
|
+
self.repo = repo
|
|
134
|
+
self.runs = runs_directory(repo)
|
|
135
|
+
self.selected_run = run_id
|
|
136
|
+
self.report: dict | None = None
|
|
137
|
+
self.run_names: list[str] = []
|
|
138
|
+
self.launched_process = None
|
|
139
|
+
self.launch_log: Path | None = None
|
|
140
|
+
self.action_busy = False
|
|
141
|
+
self.resume_target: str | None = None
|
|
142
|
+
self.awaiting_new_run: set[str] | None = None
|
|
143
|
+
|
|
144
|
+
def compose(self) -> ComposeResult:
|
|
145
|
+
yield Static("▰ ▰ ▰ TASK HARBOR\n Plan → Build → Verify → Review", id="brand")
|
|
146
|
+
yield Static(str(self.repo), id="workspace", markup=False)
|
|
147
|
+
with Horizontal(id="actions"):
|
|
148
|
+
yield Button("Set up", id="setup")
|
|
149
|
+
yield Button("Validate", id="validate")
|
|
150
|
+
yield Button("New run", id="start", variant="success")
|
|
151
|
+
yield Button("Resume", id="resume")
|
|
152
|
+
yield Button("Stop", id="stop", variant="warning")
|
|
153
|
+
yield Button("Review", id="review")
|
|
154
|
+
yield Static("", id="action-result", markup=False)
|
|
155
|
+
yield Static("No runs yet.\n\n1 task-harbor init\n2 Edit BACKLOG.md and .manager/config.json\n3 task-harbor validate\n4 task-harbor manage (in another terminal)\n\nPress F1 for setup help. This dashboard will show the run when it starts.", id="welcome", markup=False)
|
|
156
|
+
yield Select([], prompt="Choose a run", id="run-picker")
|
|
157
|
+
with Horizontal(id="summary"):
|
|
158
|
+
for name in ("progress", "provider", "calls", "cost"):
|
|
159
|
+
yield Static("—", id=name, classes="metric", markup=False)
|
|
160
|
+
yield ProgressBar(total=1, show_eta=False, id="completion")
|
|
161
|
+
with Horizontal(id="body"):
|
|
162
|
+
yield DataTable(id="tasks", cursor_type="row")
|
|
163
|
+
with VerticalScroll(id="details-pane"):
|
|
164
|
+
yield Static("", id="details", markup=False)
|
|
165
|
+
yield Static("", id="events", markup=False)
|
|
166
|
+
yield Static("", id="notice", markup=False)
|
|
167
|
+
yield Footer()
|
|
168
|
+
|
|
169
|
+
def on_mount(self) -> None:
|
|
170
|
+
table = self.query_one(DataTable)
|
|
171
|
+
table.add_columns("Task", "State", "Title")
|
|
172
|
+
table.border_title = "TASKS"
|
|
173
|
+
self.query_one("#details-pane").border_title = "TASK DETAILS"
|
|
174
|
+
self.query_one("#events").border_title = "RECENT EVENTS"
|
|
175
|
+
self.action_reload()
|
|
176
|
+
self.set_interval(2, self.action_reload)
|
|
177
|
+
table.focus()
|
|
178
|
+
|
|
179
|
+
def action_help(self) -> None:
|
|
180
|
+
self.push_screen(HelpScreen())
|
|
181
|
+
|
|
182
|
+
def action_evidence(self) -> None:
|
|
183
|
+
if self.report and self.report["tasks"]:
|
|
184
|
+
row = self.query_one(DataTable).cursor_row
|
|
185
|
+
task = self.report["tasks"][min(row, len(self.report["tasks"]) - 1)]
|
|
186
|
+
self.push_screen(HelpScreen(review_text(self.report, task["id"])))
|
|
187
|
+
else:
|
|
188
|
+
self.action_message("Select a saved run and task to inspect its evidence.")
|
|
189
|
+
|
|
190
|
+
def action_preflight(self) -> None:
|
|
191
|
+
from .preflight import suggestions
|
|
192
|
+
suggested = suggestions(self.repo)
|
|
193
|
+
self.push_screen(HelpScreen("PROJECT PREFLIGHT\n\nSuggested checks (not executed):\n"
|
|
194
|
+
+ "\n".join(suggested or ["No known commands detected."])
|
|
195
|
+
+ "\n\nReview verification_commands, setup_commands, required_env and required_executables in .manager/config.json.\n"
|
|
196
|
+
"Then run: task-harbor preflight --run-checks\n\n"
|
|
197
|
+
"Set require_verification to true to pause before model calls when baseline checks do not pass."))
|
|
198
|
+
|
|
199
|
+
def action_message(self, message: str) -> None:
|
|
200
|
+
self.screen_stack[0].query_one("#action-result", Static).update(message)
|
|
201
|
+
|
|
202
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
203
|
+
action = event.button.id
|
|
204
|
+
if action == "review":
|
|
205
|
+
event.stop()
|
|
206
|
+
self.action_evidence()
|
|
207
|
+
return
|
|
208
|
+
if action not in {"setup", "validate", "start", "resume", "stop"}:
|
|
209
|
+
return
|
|
210
|
+
event.stop()
|
|
211
|
+
if self.action_busy:
|
|
212
|
+
return
|
|
213
|
+
if action in {"start", "resume"}:
|
|
214
|
+
if self.launched_process and self.launched_process.poll() is None:
|
|
215
|
+
self.action_message("A run launched here is still running. Stop it or wait before launching another.")
|
|
216
|
+
return
|
|
217
|
+
try:
|
|
218
|
+
path = self.repo / ".manager" / "config.json"
|
|
219
|
+
if not path.exists():
|
|
220
|
+
raise ValueError("Use Set up first, then edit the backlog and configuration.")
|
|
221
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
222
|
+
cfg = Config(**{k: v for k, v in raw.items() if k in Config.__dataclass_fields__})
|
|
223
|
+
self.resume_target = self.selected_run if action == "resume" else None
|
|
224
|
+
if action == "resume" and not self.resume_target:
|
|
225
|
+
raise ValueError("Select a saved run to resume.")
|
|
226
|
+
verb = f"Resume {self.resume_target}" if self.resume_target else "Start a new run"
|
|
227
|
+
self.push_screen(ConfirmRun(
|
|
228
|
+
f"{verb}\n\nProject: {self.repo}\nProvider: {cfg.provider}\nModel: {cfg.model}\n"
|
|
229
|
+
f"Agent call limit: {cfg.max_agent_calls or 'Unlimited'}\n\n"
|
|
230
|
+
"This starts model calls and changes code in isolated Git worktrees. "
|
|
231
|
+
"Review your backlog and configuration first.\n\n"
|
|
232
|
+
"Work continues if you close the dashboard. Stop waits for in-flight calls."
|
|
233
|
+
), self.confirm_launch)
|
|
234
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
235
|
+
self.action_message(str(exc))
|
|
236
|
+
return
|
|
237
|
+
command = "init" if action == "setup" else action
|
|
238
|
+
args = [sys.executable, "-m", "task_harbor", "--repo", str(self.repo), command]
|
|
239
|
+
if action == "stop":
|
|
240
|
+
if not self.selected_run:
|
|
241
|
+
self.action_message("Select a run to stop.")
|
|
242
|
+
return
|
|
243
|
+
args += ["--run-id", self.selected_run]
|
|
244
|
+
self.action_busy = True
|
|
245
|
+
self.action_message(f"Running {command}…")
|
|
246
|
+
try:
|
|
247
|
+
process = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
|
248
|
+
output, _ = await process.communicate()
|
|
249
|
+
self.action_message(output.decode("utf-8", errors="replace")[-1600:])
|
|
250
|
+
self.runs = runs_directory(self.repo)
|
|
251
|
+
except (OSError, ValueError) as exc:
|
|
252
|
+
self.action_message(str(exc))
|
|
253
|
+
finally:
|
|
254
|
+
self.action_busy = False
|
|
255
|
+
self.action_reload()
|
|
256
|
+
|
|
257
|
+
def confirm_launch(self, confirmed: bool) -> None:
|
|
258
|
+
if not confirmed:
|
|
259
|
+
return
|
|
260
|
+
try:
|
|
261
|
+
self.runs = runs_directory(self.repo)
|
|
262
|
+
self.launched_process, self.launch_log = launch_run(self.repo, self.runs, self.resume_target)
|
|
263
|
+
if not self.resume_target:
|
|
264
|
+
self.awaiting_new_run = set(self.run_names)
|
|
265
|
+
self.action_message(f"Agent work launched. Closing this window won't stop it.\nLaunch output: {self.launch_log}")
|
|
266
|
+
except (OSError, ValueError) as exc:
|
|
267
|
+
self.action_message(f"Could not launch: {exc}")
|
|
268
|
+
|
|
269
|
+
def on_resize(self) -> None:
|
|
270
|
+
self.screen.set_class(self.size.width < 95, "compact")
|
|
271
|
+
|
|
272
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
273
|
+
if event.value != Select.BLANK:
|
|
274
|
+
self.selected_run = str(event.value)
|
|
275
|
+
self.action_reload()
|
|
276
|
+
|
|
277
|
+
def action_reload(self) -> None:
|
|
278
|
+
if isinstance(self.screen, ModalScreen):
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
if self.launched_process and self.launched_process.poll() is not None:
|
|
282
|
+
code = self.launched_process.returncode
|
|
283
|
+
detail = ""
|
|
284
|
+
if code and self.launch_log and self.launch_log.exists():
|
|
285
|
+
with self.launch_log.open("rb") as stream:
|
|
286
|
+
stream.seek(0, 2)
|
|
287
|
+
stream.seek(max(0, stream.tell() - 1000))
|
|
288
|
+
detail = "\n" + stream.read().decode("utf-8", errors="replace")
|
|
289
|
+
self.action_message(f"Launched process finished (exit {code}). Output: {self.launch_log}{detail}")
|
|
290
|
+
self.launched_process = None
|
|
291
|
+
names = sorted((p.parent.name for p in self.runs.glob("*/state.json")), reverse=True)
|
|
292
|
+
if self.awaiting_new_run is not None:
|
|
293
|
+
created = [name for name in names if name not in self.awaiting_new_run]
|
|
294
|
+
if created:
|
|
295
|
+
self.selected_run = created[0]
|
|
296
|
+
self.awaiting_new_run = None
|
|
297
|
+
self.query_one("#run-picker").display = bool(names)
|
|
298
|
+
for button in ("resume", "stop"):
|
|
299
|
+
self.query_one(f"#{button}", Button).disabled = not bool(names)
|
|
300
|
+
if names != self.run_names:
|
|
301
|
+
if self.selected_run in self.run_names and self.selected_run not in names:
|
|
302
|
+
self.selected_run = names[0] if names else None
|
|
303
|
+
self.run_names = names
|
|
304
|
+
picker = self.query_one(Select)
|
|
305
|
+
picker.set_options([(name, name) for name in names])
|
|
306
|
+
if self.selected_run is None and names:
|
|
307
|
+
self.selected_run = names[0]
|
|
308
|
+
if self.selected_run in names:
|
|
309
|
+
picker.value = self.selected_run
|
|
310
|
+
if not self.selected_run:
|
|
311
|
+
config_exists = (self.repo / ".manager" / "config.json").exists()
|
|
312
|
+
self.query_one("#welcome", Static).update(
|
|
313
|
+
"No runs yet. A run is one execution of your backlog.\n\n"
|
|
314
|
+
+ ("1 Click Set up to create configuration and a sample backlog.\n" if not config_exists else "1 Project configured. Review your backlog and configuration.\n")
|
|
315
|
+
+ "2 Edit BACKLOG.md and .manager/config.json in your editor.\n"
|
|
316
|
+
"3 Click Validate to check task requirements.\n4 Click New run to review settings and start.\n\n"
|
|
317
|
+
f"Looking for runs in: {self.runs}\nPress F1 for help."
|
|
318
|
+
)
|
|
319
|
+
self.query_one("#welcome").display = True
|
|
320
|
+
for selector in ("#body", "#events", "#summary", "#completion"):
|
|
321
|
+
self.query_one(selector).display = False
|
|
322
|
+
self.clear_run("No runs yet.\nStart with: task-harbor --repo <path> init\nEdit BACKLOG.md, then run manage in another terminal.")
|
|
323
|
+
return
|
|
324
|
+
self.query_one("#welcome").display = False
|
|
325
|
+
for selector in ("#body", "#events", "#summary", "#completion"):
|
|
326
|
+
self.query_one(selector).display = True
|
|
327
|
+
self.query_one("#events").display = self.size.height >= 36
|
|
328
|
+
run_dir = self.runs / self.selected_run
|
|
329
|
+
report = run_report(run_dir)
|
|
330
|
+
events = event_tail(run_dir / "events.jsonl")
|
|
331
|
+
updated = datetime.fromtimestamp((run_dir / "state.json").stat().st_mtime).strftime("%H:%M:%S")
|
|
332
|
+
self.query_one("#events", Static).update(events)
|
|
333
|
+
state = "Paused" if report["paused"] else "Complete" if report["completed"] == report["total"] else "Saved state"
|
|
334
|
+
self.query_one("#notice", Static).update(f'{state} · saved {updated} · refreshes every 2s · {report["stop_reason"] or "Viewing saved progress. F1 for help."}')
|
|
335
|
+
if report == self.report:
|
|
336
|
+
return
|
|
337
|
+
table = self.query_one(DataTable)
|
|
338
|
+
row = table.cursor_row
|
|
339
|
+
selected = self.report["tasks"][row]["id"] if self.report and row < len(self.report["tasks"]) else None
|
|
340
|
+
self.report = report
|
|
341
|
+
self.query_one(ProgressBar).update(total=max(1, report["total"]), progress=report["completed"])
|
|
342
|
+
usage = report["usage"]
|
|
343
|
+
for name, value in {
|
|
344
|
+
"progress": f'TASKS\n{report["completed"]} / {report["total"]} complete',
|
|
345
|
+
"provider": f'PROVIDER\n{report["provider"].upper()}',
|
|
346
|
+
"calls": f'AGENT CALLS\n{usage["agent_calls"]}',
|
|
347
|
+
"cost": f'REPORTED COST\n${usage["reported_cost_usd"]:.2f}' + (" + unknown" if usage["calls_without_cost"] else ""),
|
|
348
|
+
}.items():
|
|
349
|
+
self.query_one(f"#{name}", Static).update(value)
|
|
350
|
+
table.clear()
|
|
351
|
+
for task in report["tasks"]:
|
|
352
|
+
status = "BLOCKED" if task["blocked_reason"] else task["status"]
|
|
353
|
+
color = "#66e3c4" if status == "COMPLETE" else "#f2c777" if status in {"BLOCKED", "QA_FAIL"} else "#8cbaff"
|
|
354
|
+
table.add_row(Text(task["id"]), Text(status.replace("_", " "), style=color), Text(task["title"]), key=task["id"])
|
|
355
|
+
row = next((i for i, t in enumerate(report["tasks"]) if t["id"] == selected), 0)
|
|
356
|
+
table.move_cursor(row=row)
|
|
357
|
+
self.show_task(row)
|
|
358
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
359
|
+
self.clear_run(f"Cannot read run data: {exc}\nRetrying in 2s. Press r to retry now.")
|
|
360
|
+
|
|
361
|
+
def clear_run(self, message: str) -> None:
|
|
362
|
+
self.report = None
|
|
363
|
+
self.query_one(ProgressBar).update(total=1, progress=0)
|
|
364
|
+
self.query_one(DataTable).clear()
|
|
365
|
+
for name in ("progress", "provider", "calls", "cost"):
|
|
366
|
+
self.query_one(f"#{name}", Static).update("—")
|
|
367
|
+
self.query_one("#details", Static).update(message)
|
|
368
|
+
self.query_one("#events", Static).update("")
|
|
369
|
+
self.query_one("#notice", Static).update("Viewing is free of model calls. New run / Resume starts agent work after confirmation.")
|
|
370
|
+
|
|
371
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
372
|
+
self.show_task(event.cursor_row)
|
|
373
|
+
|
|
374
|
+
def show_task(self, row: int) -> None:
|
|
375
|
+
if not self.report or row >= len(self.report["tasks"]):
|
|
376
|
+
return
|
|
377
|
+
task = self.report["tasks"][row]
|
|
378
|
+
tokens = task["usage"]["tokens"]
|
|
379
|
+
self.query_one("#details", Static).update(
|
|
380
|
+
f'{task["id"]}\n{task["title"]}\n\n'
|
|
381
|
+
f'Status: {task["status"]}\nDependencies: {", ".join(task["dependencies"]) or "None"}\n'
|
|
382
|
+
f'Generation: {task["generation"]}\n\n'
|
|
383
|
+
f'Next: {task["next_action"] or "—"}\n'
|
|
384
|
+
f'Blocked: {task["blocked_reason"] or "No"}\n\n'
|
|
385
|
+
f'Checkpoint: {task["checkpoint"] or "—"}\n\n'
|
|
386
|
+
f'Verification: {task.get("evidence", {}).get("verification_level", "not recorded")}\nPress E for evidence and diff.\n\n'
|
|
387
|
+
+ "\n".join(f'{key}: {value:,}' for key, value in tokens.items())
|
|
388
|
+
)
|
opus_manager/usage.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Durable call reservations. Unknown interrupted costs remain reserved on resume."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .state import atomic_write_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RunStopped(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UsageLedger:
|
|
20
|
+
def __init__(self, path: Path, config: Config):
|
|
21
|
+
self.path, self.config = path, config
|
|
22
|
+
self.lock = threading.Lock()
|
|
23
|
+
|
|
24
|
+
def read(self) -> dict:
|
|
25
|
+
return json.loads(self.path.read_text(encoding="utf-8")) if self.path.exists() else {"calls": []}
|
|
26
|
+
|
|
27
|
+
def reserve(self, role: str, task: str | None) -> tuple[str, float | None]:
|
|
28
|
+
with self.lock:
|
|
29
|
+
control = self.path.parent / "stop-request.json"
|
|
30
|
+
if control.exists():
|
|
31
|
+
raise RunStopped("Operator requested a stop; resume explicitly to continue")
|
|
32
|
+
data = self.read()
|
|
33
|
+
calls = data["calls"]
|
|
34
|
+
cfg = self.config
|
|
35
|
+
if cfg.max_agent_calls is not None and len(calls) >= cfg.max_agent_calls:
|
|
36
|
+
raise RunStopped("Agent call budget exhausted")
|
|
37
|
+
if task and cfg.max_task_agent_calls is not None and sum(c.get("task") == task for c in calls) >= cfg.max_task_agent_calls:
|
|
38
|
+
raise RunStopped(f"Task {task} agent call budget exhausted")
|
|
39
|
+
cap = cfg.max_call_cost_usd
|
|
40
|
+
if cfg.max_run_cost_usd is not None:
|
|
41
|
+
spent = sum(c.get("cost_usd") if c.get("cost_usd") is not None else c.get("reserved_usd", 0) or 0 for c in calls)
|
|
42
|
+
# Missing usage from an older/unbudgeted invocation cannot be assumed free.
|
|
43
|
+
if any(c.get("cost_usd") is None and not c.get("reserved_usd") for c in calls):
|
|
44
|
+
raise RunStopped("Unknown prior cost prevents enforcing the run budget")
|
|
45
|
+
remaining = cfg.max_run_cost_usd - spent
|
|
46
|
+
if remaining < 0.01:
|
|
47
|
+
raise RunStopped("Estimated run cost budget exhausted (including outstanding reservations)")
|
|
48
|
+
cap = min(cap or remaining / cfg.max_active_planners, remaining)
|
|
49
|
+
call_id = uuid.uuid4().hex
|
|
50
|
+
calls.append({"id": call_id, "role": role, "task": task, "status": "pending",
|
|
51
|
+
"provider": cfg.provider, "model": cfg.model, "started_at": time.time(), "reserved_usd": cap, "cost_usd": None})
|
|
52
|
+
atomic_write_json(self.path, data)
|
|
53
|
+
return call_id, cap
|
|
54
|
+
|
|
55
|
+
def finish(self, call_id: str, raw: dict | None, error: str | None = None) -> None:
|
|
56
|
+
with self.lock:
|
|
57
|
+
data = self.read()
|
|
58
|
+
call = next(c for c in data["calls"] if c["id"] == call_id)
|
|
59
|
+
raw = raw or {}
|
|
60
|
+
cost = raw.get("total_cost_usd")
|
|
61
|
+
if type(cost) in {int, float} and math.isfinite(cost) and cost >= 0:
|
|
62
|
+
call["cost_usd"] = cost
|
|
63
|
+
call.update(status="failed" if error else "completed", finished_at=time.time(),
|
|
64
|
+
usage=raw.get("usage", {}), session_id=raw.get("session_id"), error=error)
|
|
65
|
+
atomic_write_json(self.path, data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def summarize(data: dict) -> dict:
|
|
69
|
+
calls = data.get("calls", [])
|
|
70
|
+
known = [c["cost_usd"] for c in calls if c.get("cost_usd") is not None]
|
|
71
|
+
tokens = {}
|
|
72
|
+
for call in calls:
|
|
73
|
+
for key, value in call.get("usage", {}).items():
|
|
74
|
+
if key.endswith("tokens") and type(value) is int:
|
|
75
|
+
tokens[key] = tokens.get(key, 0) + value
|
|
76
|
+
return {"agent_calls": len(calls), "reported_cost_usd": sum(known),
|
|
77
|
+
"calls_without_cost": len(calls) - len(known), "tokens": tokens}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Run operator-configured checks without a model; keep bounded output evidence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_checks(commands: list[str], cwd: Path, *, timeout: int, limit: int,
|
|
13
|
+
env: dict[str, str] | None = None) -> list[dict]:
|
|
14
|
+
results = []
|
|
15
|
+
for command in commands:
|
|
16
|
+
started = time.monotonic()
|
|
17
|
+
with tempfile.TemporaryFile() as output:
|
|
18
|
+
process = subprocess.Popen(command, shell=True, cwd=cwd, env=env,
|
|
19
|
+
stdout=output, stderr=subprocess.STDOUT,
|
|
20
|
+
start_new_session=os.name != "nt")
|
|
21
|
+
timed_out = False
|
|
22
|
+
try:
|
|
23
|
+
process.wait(timeout=timeout)
|
|
24
|
+
except subprocess.TimeoutExpired:
|
|
25
|
+
timed_out = True
|
|
26
|
+
if os.name == "nt":
|
|
27
|
+
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True)
|
|
28
|
+
else:
|
|
29
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
30
|
+
process.wait()
|
|
31
|
+
size = output.tell()
|
|
32
|
+
output.seek(max(0, size - limit))
|
|
33
|
+
text = output.read().decode("utf-8", errors="replace")
|
|
34
|
+
results.append({"command": command, "exit_code": process.returncode,
|
|
35
|
+
"timed_out": timed_out, "duration_seconds": round(time.monotonic() - started, 3),
|
|
36
|
+
"output": text, "output_truncated": size > limit})
|
|
37
|
+
if timed_out or process.returncode != 0:
|
|
38
|
+
break
|
|
39
|
+
return results
|
task_harbor/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Public module entry point for Task Harbor."""
|
task_harbor/__main__.py
ADDED