delegate-agent-cli 0.11.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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from delegate_agent import private_io
|
|
18
|
+
from delegate_agent.json_types import JsonObject
|
|
19
|
+
from delegate_agent.private_io import ( # noqa: F401 # re-exported
|
|
20
|
+
RegistryJsonError,
|
|
21
|
+
ensure_private_dir,
|
|
22
|
+
ensure_private_file,
|
|
23
|
+
read_json_object,
|
|
24
|
+
read_json_object_or_none,
|
|
25
|
+
supports_private_modes,
|
|
26
|
+
write_json_atomic,
|
|
27
|
+
write_private_bytes,
|
|
28
|
+
write_private_text,
|
|
29
|
+
write_text_atomic,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DELEGATE_DIR_NAME = ".delegate"
|
|
33
|
+
GIT_EXCLUDE_ENTRY = ".delegate/"
|
|
34
|
+
RUN_ID_RE = re.compile(r"^del_\d{8}T\d{6}Z_[0-9a-f]{6}$")
|
|
35
|
+
|
|
36
|
+
STDOUT_LOG = "stdout.log"
|
|
37
|
+
STDERR_LOG = "stderr.log"
|
|
38
|
+
EVENTS_JSONL = "events.jsonl"
|
|
39
|
+
MANIFEST_FILE = "manifest.json"
|
|
40
|
+
STATE_FILE = "state.json"
|
|
41
|
+
SNAPSHOT_FILE = "snapshot.json"
|
|
42
|
+
COMPLETION_REPORT_FILE = "completion-report.md"
|
|
43
|
+
INDEX_VERSION = 1
|
|
44
|
+
MANIFEST_SCHEMA = "delegate.manifest.v1"
|
|
45
|
+
STATE_SCHEMA = "delegate.state.v1"
|
|
46
|
+
SNAPSHOT_SCHEMA = "delegate.snapshot.v1"
|
|
47
|
+
RUNS_SCHEMA = "delegate.runs.v1"
|
|
48
|
+
RUN_OUTPUT_SCHEMA = "delegate.run-output.v1"
|
|
49
|
+
REGISTRY_LOCK_NAME = ".registry.lock"
|
|
50
|
+
REGISTRY_LOCK_TIMEOUT_SECONDS = 30.0
|
|
51
|
+
REGISTRY_LOCK_POLL_SECONDS = 0.05
|
|
52
|
+
PRIVATE_DIR_MODE = private_io.PRIVATE_DIR_MODE
|
|
53
|
+
PRIVATE_FILE_MODE = private_io.PRIVATE_FILE_MODE
|
|
54
|
+
GIT_INFO_EXCLUDE_TIMEOUT_SECONDS = 5.0
|
|
55
|
+
BYTES_PER_KIB = 1 << 10
|
|
56
|
+
BYTES_PER_MIB = BYTES_PER_KIB * BYTES_PER_KIB
|
|
57
|
+
HARNESS_NAMES = frozenset({"codex", "cursor", "grok", "kimi", "claude", "droid"})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_run_id(now: datetime | None = None) -> str:
|
|
61
|
+
moment = now or datetime.now(UTC)
|
|
62
|
+
stamp = moment.strftime("%Y%m%dT%H%M%SZ")
|
|
63
|
+
suffix = secrets.token_hex(3)
|
|
64
|
+
run_id = f"del_{stamp}_{suffix}"
|
|
65
|
+
if not RUN_ID_RE.match(run_id):
|
|
66
|
+
raise ValueError(f"generated run id does not match expected format: {run_id}")
|
|
67
|
+
return run_id
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def delegate_root(workspace: Path) -> Path:
|
|
71
|
+
return workspace / DELEGATE_DIR_NAME
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def registry_root(workspace: Path) -> Path:
|
|
75
|
+
return delegate_root(workspace)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def registry_root_if_exists(workspace: Path) -> Path | None:
|
|
79
|
+
root = registry_root(workspace)
|
|
80
|
+
if not index_path(root).exists():
|
|
81
|
+
return None
|
|
82
|
+
return root
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def git_info_exclude_path(git_root: Path) -> Path | None:
|
|
86
|
+
try:
|
|
87
|
+
completed = subprocess.run( # nosec B603 B607 - fixed git argv is executed with shell=False.
|
|
88
|
+
["git", "-C", str(git_root), "rev-parse", "--git-path", "info/exclude"],
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
check=True,
|
|
92
|
+
timeout=GIT_INFO_EXCLUDE_TIMEOUT_SECONDS,
|
|
93
|
+
)
|
|
94
|
+
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
95
|
+
return None
|
|
96
|
+
relative = completed.stdout.strip()
|
|
97
|
+
if not relative:
|
|
98
|
+
return None
|
|
99
|
+
exclude_file = Path(relative)
|
|
100
|
+
if not exclude_file.is_absolute():
|
|
101
|
+
exclude_file = (git_root / exclude_file).resolve()
|
|
102
|
+
return exclude_file
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def aliases_dir(registry_root: Path) -> Path:
|
|
106
|
+
return registry_root / "aliases"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def runs_dir(registry_root: Path) -> Path:
|
|
110
|
+
return registry_root / "runs"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def index_path(registry_root: Path) -> Path:
|
|
114
|
+
return registry_root / "index.json"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def empty_index() -> JsonObject:
|
|
118
|
+
return {"version": INDEX_VERSION, "aliases": {}, "runs": {}}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_index(registry_root: Path) -> JsonObject:
|
|
122
|
+
path = index_path(registry_root)
|
|
123
|
+
data = read_json_object(path)
|
|
124
|
+
if data is None:
|
|
125
|
+
return empty_index()
|
|
126
|
+
aliases = data.get("aliases")
|
|
127
|
+
runs = data.get("runs")
|
|
128
|
+
if not isinstance(aliases, dict) or not isinstance(runs, dict):
|
|
129
|
+
raise RegistryJsonError(f"{path} must contain aliases and runs objects")
|
|
130
|
+
return {
|
|
131
|
+
"version": data.get("version", INDEX_VERSION),
|
|
132
|
+
"aliases": aliases,
|
|
133
|
+
"runs": runs,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def save_index(registry_root: Path, index: JsonObject) -> None:
|
|
138
|
+
write_json_atomic(index_path(registry_root), index)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def ensure_git_delegate_exclude(git_root: Path) -> None:
|
|
142
|
+
exclude_file = git_info_exclude_path(git_root)
|
|
143
|
+
if exclude_file is None or not exclude_file.parent.exists():
|
|
144
|
+
return
|
|
145
|
+
existing = exclude_file.read_text(encoding="utf-8") if exclude_file.exists() else ""
|
|
146
|
+
lines = existing.splitlines()
|
|
147
|
+
if any(
|
|
148
|
+
line.strip() == GIT_EXCLUDE_ENTRY.rstrip("/") or line.strip() == GIT_EXCLUDE_ENTRY
|
|
149
|
+
for line in lines
|
|
150
|
+
):
|
|
151
|
+
return
|
|
152
|
+
if existing and not existing.endswith("\n"):
|
|
153
|
+
existing += "\n"
|
|
154
|
+
write_text_atomic(exclude_file, existing + GIT_EXCLUDE_ENTRY + "\n")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ensure_registry(workspace: Path, *, workspace_kind: str) -> Path:
|
|
158
|
+
root = delegate_root(workspace)
|
|
159
|
+
with registry_lock(root):
|
|
160
|
+
ensure_private_dir(aliases_dir(root))
|
|
161
|
+
ensure_private_dir(runs_dir(root))
|
|
162
|
+
if workspace_kind == "git":
|
|
163
|
+
ensure_git_delegate_exclude(workspace)
|
|
164
|
+
if not index_path(root).exists():
|
|
165
|
+
save_index(root, empty_index())
|
|
166
|
+
else:
|
|
167
|
+
ensure_private_file(index_path(root))
|
|
168
|
+
return root
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def allocate_alias(registry_root: Path, harness: str) -> str:
|
|
172
|
+
claims = aliases_dir(registry_root)
|
|
173
|
+
ensure_private_dir(claims)
|
|
174
|
+
counter = 1
|
|
175
|
+
while True:
|
|
176
|
+
alias = f"{harness}-{counter}"
|
|
177
|
+
claim_path = claims / alias
|
|
178
|
+
try:
|
|
179
|
+
fd = os.open(claim_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, PRIVATE_FILE_MODE)
|
|
180
|
+
except FileExistsError:
|
|
181
|
+
counter += 1
|
|
182
|
+
continue
|
|
183
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
184
|
+
handle.write("")
|
|
185
|
+
return alias
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def bind_alias_claim(registry_root: Path, alias: str, run_id: str) -> None:
|
|
189
|
+
claim_path = aliases_dir(registry_root) / alias
|
|
190
|
+
write_private_text(claim_path, run_id + "\n")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def registry_lock_path(registry_root: Path) -> Path:
|
|
194
|
+
return registry_root / REGISTRY_LOCK_NAME
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@contextmanager
|
|
198
|
+
def registry_lock(
|
|
199
|
+
registry_root: Path,
|
|
200
|
+
*,
|
|
201
|
+
timeout_seconds: float = REGISTRY_LOCK_TIMEOUT_SECONDS,
|
|
202
|
+
) -> Iterator[None]:
|
|
203
|
+
"""Serialize registry mutations. flock releases on process exit (no stale locks)."""
|
|
204
|
+
ensure_private_dir(registry_root)
|
|
205
|
+
lock_path = registry_lock_path(registry_root)
|
|
206
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, PRIVATE_FILE_MODE)
|
|
207
|
+
ensure_private_file(lock_path)
|
|
208
|
+
try:
|
|
209
|
+
deadline = time.monotonic() + timeout_seconds
|
|
210
|
+
while True:
|
|
211
|
+
try:
|
|
212
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
213
|
+
break
|
|
214
|
+
except BlockingIOError:
|
|
215
|
+
if time.monotonic() >= deadline:
|
|
216
|
+
raise TimeoutError(
|
|
217
|
+
f"timed out waiting for registry lock at {lock_path} after {timeout_seconds}s"
|
|
218
|
+
) from None
|
|
219
|
+
time.sleep(REGISTRY_LOCK_POLL_SECONDS)
|
|
220
|
+
yield
|
|
221
|
+
finally:
|
|
222
|
+
try:
|
|
223
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
224
|
+
finally:
|
|
225
|
+
os.close(fd)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def register_run(
|
|
229
|
+
registry_root: Path,
|
|
230
|
+
*,
|
|
231
|
+
harness: str,
|
|
232
|
+
run_id: str | None = None,
|
|
233
|
+
metadata: JsonObject | None = None,
|
|
234
|
+
) -> tuple[str, str]:
|
|
235
|
+
run_id = run_id or generate_run_id()
|
|
236
|
+
if not RUN_ID_RE.match(run_id):
|
|
237
|
+
raise ValueError(f"run id does not match expected format: {run_id}")
|
|
238
|
+
with registry_lock(registry_root):
|
|
239
|
+
alias = allocate_alias(registry_root, harness)
|
|
240
|
+
bind_alias_claim(registry_root, alias, run_id)
|
|
241
|
+
run_dir = runs_dir(registry_root) / run_id
|
|
242
|
+
ensure_private_dir(run_dir)
|
|
243
|
+
index = load_index(registry_root)
|
|
244
|
+
# Stamp an explicit registration ordinal so the latest-run tiebreaker
|
|
245
|
+
# survives the persisted round trip. save_index writes with
|
|
246
|
+
# sort_keys=True, which reorders the ``runs`` mapping lexicographically
|
|
247
|
+
# by run_id on disk; an in-memory-only insertion index would silently
|
|
248
|
+
# degrade to random-suffix order after reload. The ordinal is
|
|
249
|
+
# 1 + max existing ordinal (0 when the index is empty or fully legacy),
|
|
250
|
+
# so newly registered runs always outrank legacy entries that lack an
|
|
251
|
+
# ordinal -- which is chronologically correct because new runs ARE
|
|
252
|
+
# later than any legacy run already in the index.
|
|
253
|
+
existing_ordinals = (
|
|
254
|
+
entry.get("registrationOrdinal")
|
|
255
|
+
for entry in index.get("runs", {}).values()
|
|
256
|
+
if isinstance(entry, dict)
|
|
257
|
+
)
|
|
258
|
+
next_ordinal = 1 + max(
|
|
259
|
+
(value for value in existing_ordinals if isinstance(value, int)),
|
|
260
|
+
default=0,
|
|
261
|
+
)
|
|
262
|
+
entry = {
|
|
263
|
+
"alias": alias,
|
|
264
|
+
"harness": harness,
|
|
265
|
+
**(metadata or {}),
|
|
266
|
+
"registrationOrdinal": next_ordinal,
|
|
267
|
+
}
|
|
268
|
+
index["aliases"][alias] = run_id
|
|
269
|
+
index["runs"][run_id] = entry
|
|
270
|
+
save_index(registry_root, index)
|
|
271
|
+
return run_id, alias
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def lookup_run_id(index: JsonObject, handle: str) -> str | None:
|
|
275
|
+
if handle in index.get("runs", {}):
|
|
276
|
+
return handle
|
|
277
|
+
aliases = index.get("aliases", {})
|
|
278
|
+
if handle in aliases:
|
|
279
|
+
return aliases[handle]
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def utc_now_iso() -> str:
|
|
287
|
+
return datetime.now(UTC).strftime(UTC_TIMESTAMP_FORMAT)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def parse_utc_timestamp(value: str | None) -> datetime | None:
|
|
291
|
+
if not value or not isinstance(value, str):
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
if value.endswith("Z"):
|
|
295
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
296
|
+
return datetime.fromisoformat(value)
|
|
297
|
+
except ValueError:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _format_age(timestamp: str, *, now: datetime | None = None) -> str:
|
|
302
|
+
parsed = parse_utc_timestamp(timestamp)
|
|
303
|
+
if parsed is None:
|
|
304
|
+
return "unknown age"
|
|
305
|
+
delta = (now or datetime.now(UTC)) - parsed
|
|
306
|
+
total_seconds = max(int(delta.total_seconds()), 0)
|
|
307
|
+
minutes, seconds = divmod(total_seconds, 60)
|
|
308
|
+
hours, minutes = divmod(minutes, 60)
|
|
309
|
+
days, hours = divmod(hours, 24)
|
|
310
|
+
if days:
|
|
311
|
+
return f"{days}d ago"
|
|
312
|
+
if hours:
|
|
313
|
+
return f"{hours}h ago"
|
|
314
|
+
if minutes:
|
|
315
|
+
return f"{minutes}m ago"
|
|
316
|
+
return f"{seconds}s ago"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def latest_handle_suggestions(
|
|
320
|
+
index: JsonObject, registry_root: Path, *, limit: int = 8
|
|
321
|
+
) -> list[str]:
|
|
322
|
+
suggestions: list[str] = []
|
|
323
|
+
seen: set[str] = set()
|
|
324
|
+
harnesses = sorted(
|
|
325
|
+
{
|
|
326
|
+
entry.get("harness")
|
|
327
|
+
for entry in index.get("runs", {}).values()
|
|
328
|
+
if isinstance(entry, dict) and isinstance(entry.get("harness"), str)
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
for harness in harnesses:
|
|
332
|
+
run_id = latest_run_id_for_harness(registry_root, index, str(harness))
|
|
333
|
+
if run_id is None:
|
|
334
|
+
continue
|
|
335
|
+
alias = alias_for_run(index, run_id) or run_id
|
|
336
|
+
if alias in seen:
|
|
337
|
+
continue
|
|
338
|
+
state = load_run_state_or_none(registry_root, run_id)
|
|
339
|
+
manifest = load_run_manifest_or_none(registry_root, run_id)
|
|
340
|
+
suggestions.append(f"{alias} ({_format_age(activity_timestamp(state, manifest, run_id))})")
|
|
341
|
+
seen.add(alias)
|
|
342
|
+
if len(suggestions) >= limit:
|
|
343
|
+
break
|
|
344
|
+
return suggestions
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def snapshot_command(alias: str, *, cwd: str | None = None) -> str:
|
|
348
|
+
if cwd is None:
|
|
349
|
+
return f"delegate snapshot {alias}"
|
|
350
|
+
return shlex.join(["delegate", "--cwd", cwd, "snapshot", alias])
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def run_output_command(
|
|
354
|
+
handle: str,
|
|
355
|
+
*,
|
|
356
|
+
completion_report: bool = False,
|
|
357
|
+
cwd: str | None = None,
|
|
358
|
+
) -> str:
|
|
359
|
+
if cwd is not None:
|
|
360
|
+
argv = ["delegate", "--cwd", cwd, "run-output", handle]
|
|
361
|
+
if completion_report:
|
|
362
|
+
argv.append("--completion-report")
|
|
363
|
+
return shlex.join(argv)
|
|
364
|
+
base = f"delegate run-output {handle}"
|
|
365
|
+
if completion_report:
|
|
366
|
+
return f"{base} --completion-report"
|
|
367
|
+
return base
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def alias_sequence_for_harness(alias: object, harness: str) -> int:
|
|
371
|
+
"""Return the monotonic alias generation for a harness alias.
|
|
372
|
+
|
|
373
|
+
Alias claims are durable and allocated with exclusive creates, so the
|
|
374
|
+
numeric suffix is a better same-timestamp tiebreaker than the random run-id
|
|
375
|
+
suffix. ``cursor-1`` is generation 1; pre-v0.10 literal ``cursor`` also
|
|
376
|
+
ranks as generation 1 so old registries sort sanely.
|
|
377
|
+
"""
|
|
378
|
+
if not isinstance(alias, str):
|
|
379
|
+
return 0
|
|
380
|
+
if alias == harness:
|
|
381
|
+
return 1
|
|
382
|
+
prefix = f"{harness}-"
|
|
383
|
+
if not alias.startswith(prefix):
|
|
384
|
+
return 0
|
|
385
|
+
suffix = alias.removeprefix(prefix)
|
|
386
|
+
if not suffix.isdecimal():
|
|
387
|
+
return 0
|
|
388
|
+
return int(suffix)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@dataclass(frozen=True)
|
|
392
|
+
class ResolveResult:
|
|
393
|
+
run_id: str | None
|
|
394
|
+
alias: str | None
|
|
395
|
+
suggestions: tuple[str, ...]
|
|
396
|
+
requested_handle: str | None = None
|
|
397
|
+
resolved_handle: str | None = None
|
|
398
|
+
resolution_kind: str = "literal"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass(frozen=True)
|
|
402
|
+
class RunTarget:
|
|
403
|
+
run_id: str
|
|
404
|
+
alias: str | None
|
|
405
|
+
requested_handle: str | None = None
|
|
406
|
+
resolved_handle: str | None = None
|
|
407
|
+
resolution_kind: str = "literal"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class RunTargetLookupError:
|
|
412
|
+
error: str
|
|
413
|
+
message: str
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def run_directory(registry_root: Path, run_id: str) -> Path:
|
|
417
|
+
return runs_dir(registry_root) / run_id
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def suggest_handles(index: JsonObject, handle: str, *, limit: int = 8) -> list[str]:
|
|
421
|
+
aliases = sorted(index.get("aliases", {}).keys())
|
|
422
|
+
if not aliases:
|
|
423
|
+
return []
|
|
424
|
+
exact_ci = [alias for alias in aliases if alias.lower() == handle.lower()]
|
|
425
|
+
if exact_ci:
|
|
426
|
+
return exact_ci[:limit]
|
|
427
|
+
prefix = [alias for alias in aliases if alias.startswith(handle) or handle.startswith(alias)]
|
|
428
|
+
substring = [alias for alias in aliases if handle in alias or alias in handle]
|
|
429
|
+
ordered: list[str] = []
|
|
430
|
+
seen: set[str] = set()
|
|
431
|
+
for candidate in prefix + substring + aliases:
|
|
432
|
+
if candidate in seen:
|
|
433
|
+
continue
|
|
434
|
+
seen.add(candidate)
|
|
435
|
+
ordered.append(candidate)
|
|
436
|
+
if len(ordered) >= limit:
|
|
437
|
+
break
|
|
438
|
+
return ordered
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _alias_for_resolved(index: JsonObject, run_id: str) -> str | None:
|
|
442
|
+
return alias_for_run(index, run_id)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def resolve_handle(
|
|
446
|
+
index: JsonObject,
|
|
447
|
+
handle: str,
|
|
448
|
+
*,
|
|
449
|
+
registry_root: Path | None = None,
|
|
450
|
+
) -> ResolveResult:
|
|
451
|
+
if registry_root is not None and handle in HARNESS_NAMES:
|
|
452
|
+
run_id = latest_run_id_for_harness(registry_root, index, handle)
|
|
453
|
+
if run_id is None:
|
|
454
|
+
return ResolveResult(None, None, tuple(suggest_handles(index, handle)))
|
|
455
|
+
alias = _alias_for_resolved(index, run_id)
|
|
456
|
+
return ResolveResult(run_id, alias, (), handle, alias or run_id, "latest")
|
|
457
|
+
if registry_root is not None and ":" in handle:
|
|
458
|
+
harness, model_alias = handle.split(":", 1)
|
|
459
|
+
if harness in HARNESS_NAMES and model_alias:
|
|
460
|
+
run_id = latest_run_id_for_harness_model(registry_root, index, harness, model_alias)
|
|
461
|
+
if run_id is None:
|
|
462
|
+
return ResolveResult(None, None, tuple(suggest_handles(index, handle)))
|
|
463
|
+
alias = _alias_for_resolved(index, run_id)
|
|
464
|
+
return ResolveResult(run_id, alias, (), handle, alias or run_id, "latest_model")
|
|
465
|
+
run_id = lookup_run_id(index, handle)
|
|
466
|
+
if run_id is None:
|
|
467
|
+
return ResolveResult(None, None, tuple(suggest_handles(index, handle)))
|
|
468
|
+
alias = index.get("runs", {}).get(run_id, {}).get("alias")
|
|
469
|
+
if not isinstance(alias, str):
|
|
470
|
+
alias = None
|
|
471
|
+
for candidate, candidate_id in index.get("aliases", {}).items():
|
|
472
|
+
if candidate_id == run_id:
|
|
473
|
+
alias = candidate
|
|
474
|
+
break
|
|
475
|
+
return ResolveResult(run_id, alias, (), handle, alias or run_id, "literal")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def resolve_run_target(
|
|
479
|
+
registry_root: Path,
|
|
480
|
+
*,
|
|
481
|
+
handle: str | None,
|
|
482
|
+
latest_harness: str | None,
|
|
483
|
+
) -> RunTarget | RunTargetLookupError:
|
|
484
|
+
index = load_index(registry_root)
|
|
485
|
+
if latest_harness is not None:
|
|
486
|
+
if ":" in latest_harness:
|
|
487
|
+
harness, model_alias = latest_harness.split(":", 1)
|
|
488
|
+
run_id = latest_run_id_for_harness_model(registry_root, index, harness, model_alias)
|
|
489
|
+
resolution_kind = "latest_model"
|
|
490
|
+
else:
|
|
491
|
+
run_id = latest_run_id_for_harness(registry_root, index, latest_harness)
|
|
492
|
+
resolution_kind = "latest"
|
|
493
|
+
if run_id is None:
|
|
494
|
+
return RunTargetLookupError(
|
|
495
|
+
"no_matching_runs",
|
|
496
|
+
f"No runs found for harness: {latest_harness}",
|
|
497
|
+
)
|
|
498
|
+
alias = alias_for_run(index, run_id)
|
|
499
|
+
return RunTarget(run_id, alias, latest_harness, alias or run_id, resolution_kind)
|
|
500
|
+
if handle is None:
|
|
501
|
+
return RunTargetLookupError(
|
|
502
|
+
"missing_handle",
|
|
503
|
+
"A run handle is required.",
|
|
504
|
+
)
|
|
505
|
+
resolved = resolve_handle(index, handle, registry_root=registry_root)
|
|
506
|
+
if resolved.run_id is None:
|
|
507
|
+
suggestion_items = latest_handle_suggestions(index, registry_root)
|
|
508
|
+
suggestion_items.extend(
|
|
509
|
+
item for item in resolved.suggestions if item not in suggestion_items
|
|
510
|
+
)
|
|
511
|
+
suggestions = ", ".join(suggestion_items[:8]) if suggestion_items else "(none)"
|
|
512
|
+
return RunTargetLookupError(
|
|
513
|
+
"unknown_handle",
|
|
514
|
+
f"Unknown run handle: {handle}. Suggestions: {suggestions}. "
|
|
515
|
+
"Runs are recorded per-workspace under <workspace>/.delegate; "
|
|
516
|
+
"if this run was launched elsewhere, pass --cwd <that workspace>.",
|
|
517
|
+
)
|
|
518
|
+
return RunTarget(
|
|
519
|
+
resolved.run_id,
|
|
520
|
+
resolved.alias,
|
|
521
|
+
resolved.requested_handle,
|
|
522
|
+
resolved.resolved_handle,
|
|
523
|
+
resolved.resolution_kind,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def load_run_state(registry_root: Path, run_id: str) -> JsonObject | None:
|
|
528
|
+
return read_json_object(run_directory(registry_root, run_id) / STATE_FILE)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def load_run_state_or_none(registry_root: Path, run_id: str) -> JsonObject | None:
|
|
532
|
+
return read_json_object_or_none(run_directory(registry_root, run_id) / STATE_FILE)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def load_run_snapshot(registry_root: Path, run_id: str) -> JsonObject | None:
|
|
536
|
+
return read_json_object(run_directory(registry_root, run_id) / SNAPSHOT_FILE)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def load_run_snapshot_or_none(registry_root: Path, run_id: str) -> JsonObject | None:
|
|
540
|
+
return read_json_object_or_none(run_directory(registry_root, run_id) / SNAPSHOT_FILE)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def load_run_manifest(registry_root: Path, run_id: str) -> JsonObject | None:
|
|
544
|
+
return read_json_object(run_directory(registry_root, run_id) / MANIFEST_FILE)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def load_run_manifest_or_none(registry_root: Path, run_id: str) -> JsonObject | None:
|
|
548
|
+
return read_json_object_or_none(run_directory(registry_root, run_id) / MANIFEST_FILE)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def alias_for_run(index: JsonObject, run_id: str) -> str | None:
|
|
552
|
+
entry = index.get("runs", {}).get(run_id)
|
|
553
|
+
if isinstance(entry, dict):
|
|
554
|
+
alias = entry.get("alias")
|
|
555
|
+
if isinstance(alias, str):
|
|
556
|
+
return alias
|
|
557
|
+
for candidate, candidate_id in index.get("aliases", {}).items():
|
|
558
|
+
if candidate_id == run_id and isinstance(candidate, str):
|
|
559
|
+
return candidate
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def timestamp_from_run_id(run_id: str) -> str:
|
|
564
|
+
match = re.match(r"^del_(\d{8}T\d{6}Z)_", run_id)
|
|
565
|
+
if not match:
|
|
566
|
+
return ""
|
|
567
|
+
raw = match.group(1)
|
|
568
|
+
return f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}T{raw[9:11]}:{raw[11:13]}:{raw[13:15]}Z"
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _write_worktree_status(
|
|
572
|
+
registry_root: Path,
|
|
573
|
+
run_id: str,
|
|
574
|
+
status: str,
|
|
575
|
+
*,
|
|
576
|
+
removed_at: str | None = None,
|
|
577
|
+
discarded_dirty_paths: list[str] | None = None,
|
|
578
|
+
) -> JsonObject:
|
|
579
|
+
valid_statuses = {"present", "removed", "missing", "unknown"}
|
|
580
|
+
if status not in valid_statuses:
|
|
581
|
+
raise ValueError(f"worktree status must be one of: {', '.join(sorted(valid_statuses))}")
|
|
582
|
+
run_path = run_directory(registry_root, run_id)
|
|
583
|
+
state_path = run_path / STATE_FILE
|
|
584
|
+
if not state_path.exists():
|
|
585
|
+
raise FileNotFoundError(f"State file not found for run {run_id}")
|
|
586
|
+
state: JsonObject = json.loads(state_path.read_text(encoding="utf-8"))
|
|
587
|
+
state["worktreeStatus"] = status
|
|
588
|
+
if removed_at is not None:
|
|
589
|
+
state["worktreeRemovedAt"] = removed_at
|
|
590
|
+
if discarded_dirty_paths is not None:
|
|
591
|
+
state["discardedDirtyPaths"] = discarded_dirty_paths
|
|
592
|
+
write_json_atomic(state_path, state)
|
|
593
|
+
return state
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def set_worktree_status(
|
|
597
|
+
registry_root: Path,
|
|
598
|
+
run_id: str,
|
|
599
|
+
status: str,
|
|
600
|
+
*,
|
|
601
|
+
removed_at: str | None = None,
|
|
602
|
+
discarded_dirty_paths: list[str] | None = None,
|
|
603
|
+
) -> JsonObject:
|
|
604
|
+
"""Set the worktreeStatus field on a run's state, and optionally worktreeRemovedAt.
|
|
605
|
+
|
|
606
|
+
Acquires the registry lock, loads the run's state, updates fields,
|
|
607
|
+
persists via write_json_atomic, and returns the updated state.
|
|
608
|
+
|
|
609
|
+
Status must be one of: 'present', 'removed', 'missing', 'unknown'.
|
|
610
|
+
"""
|
|
611
|
+
with registry_lock(registry_root):
|
|
612
|
+
return _write_worktree_status(
|
|
613
|
+
registry_root,
|
|
614
|
+
run_id,
|
|
615
|
+
status,
|
|
616
|
+
removed_at=removed_at,
|
|
617
|
+
discarded_dirty_paths=discarded_dirty_paths,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def set_worktree_status_locked(
|
|
622
|
+
registry_root: Path,
|
|
623
|
+
run_id: str,
|
|
624
|
+
status: str,
|
|
625
|
+
*,
|
|
626
|
+
removed_at: str | None = None,
|
|
627
|
+
discarded_dirty_paths: list[str] | None = None,
|
|
628
|
+
) -> JsonObject:
|
|
629
|
+
"""Set worktree status when the caller already holds the registry lock."""
|
|
630
|
+
return _write_worktree_status(
|
|
631
|
+
registry_root,
|
|
632
|
+
run_id,
|
|
633
|
+
status,
|
|
634
|
+
removed_at=removed_at,
|
|
635
|
+
discarded_dirty_paths=discarded_dirty_paths,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def latest_run_id_for_harness(registry_root: Path, index: JsonObject, harness: str) -> str | None:
|
|
640
|
+
return _latest_run_id_for_harness(registry_root, index, harness)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def latest_run_id_for_harness_model(
|
|
644
|
+
registry_root: Path,
|
|
645
|
+
index: JsonObject,
|
|
646
|
+
harness: str,
|
|
647
|
+
model_alias: str,
|
|
648
|
+
) -> str | None:
|
|
649
|
+
return _latest_run_id_for_harness(
|
|
650
|
+
registry_root,
|
|
651
|
+
index,
|
|
652
|
+
harness,
|
|
653
|
+
model_alias=model_alias,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _latest_run_id_for_harness(
|
|
658
|
+
registry_root: Path,
|
|
659
|
+
index: JsonObject,
|
|
660
|
+
harness: str,
|
|
661
|
+
*,
|
|
662
|
+
model_alias: str | None = None,
|
|
663
|
+
) -> str | None:
|
|
664
|
+
matches: list[tuple[str, int, int, str]] = []
|
|
665
|
+
# The chronology source for the exact tie this function resolves (same
|
|
666
|
+
# activity timestamp, same alias sequence -- e.g. a legacy literal ``codex``
|
|
667
|
+
# and a v0.10 ``codex-1`` that both rank as alias sequence 1 in the same
|
|
668
|
+
# second) is the explicit ``registrationOrdinal`` stamped by register_run.
|
|
669
|
+
# The ordinal is required because save_index persists with sort_keys=True,
|
|
670
|
+
# which reorders the ``runs`` mapping lexicographically by run_id on disk;
|
|
671
|
+
# an in-memory-only insertion index would silently degrade to random-suffix
|
|
672
|
+
# order after a load_index round trip (the production path). For legacy
|
|
673
|
+
# entries that predate the ordinal field, fall back to the enumeration
|
|
674
|
+
# insertion index of the loaded mapping. This keeps mixed indexes
|
|
675
|
+
# consistent: new runs always carry an explicit ordinal >= 1 + max existing
|
|
676
|
+
# ordinal, so they outrank any legacy tie (whose fallback is the
|
|
677
|
+
# insertion index of a mapping that, post-reload, is lexicographic -- but
|
|
678
|
+
# legacy entries have no ordinal at all, so their uniform fallback only
|
|
679
|
+
# orders legacy entries relative to each other, never above a newer run),
|
|
680
|
+
# which is chronologically correct because new runs ARE later. Using the
|
|
681
|
+
# run_id suffix here would be wrong because that suffix is random, so
|
|
682
|
+
# lexicographic run_id order can rank an older run as latest.
|
|
683
|
+
for insertion_index, (run_id, entry) in enumerate(index.get("runs", {}).items()):
|
|
684
|
+
if not isinstance(entry, dict) or entry.get("harness") != harness:
|
|
685
|
+
continue
|
|
686
|
+
entry_model_alias = entry.get("modelAlias")
|
|
687
|
+
if (
|
|
688
|
+
model_alias is not None
|
|
689
|
+
and entry_model_alias is not None
|
|
690
|
+
and entry_model_alias != model_alias
|
|
691
|
+
):
|
|
692
|
+
continue
|
|
693
|
+
state = load_run_state_or_none(registry_root, run_id)
|
|
694
|
+
manifest = load_run_manifest_or_none(registry_root, run_id)
|
|
695
|
+
if model_alias is not None and entry_model_alias is None:
|
|
696
|
+
manifest_model_alias = (
|
|
697
|
+
manifest.get("modelAlias") if isinstance(manifest, dict) else None
|
|
698
|
+
)
|
|
699
|
+
if manifest_model_alias != model_alias:
|
|
700
|
+
continue
|
|
701
|
+
sort_ts = run_status.activity_timestamp(state, manifest, run_id)
|
|
702
|
+
alias_sequence = alias_sequence_for_harness(entry.get("alias"), harness)
|
|
703
|
+
effective_ordinal = entry.get("registrationOrdinal", insertion_index)
|
|
704
|
+
if not isinstance(effective_ordinal, int):
|
|
705
|
+
effective_ordinal = insertion_index
|
|
706
|
+
matches.append((sort_ts, alias_sequence, effective_ordinal, run_id))
|
|
707
|
+
if not matches:
|
|
708
|
+
return None
|
|
709
|
+
# Tertiary effective_ordinal makes same-second, same-sequence ties resolve
|
|
710
|
+
# to the latest-registered run instead of falling to the random run-id
|
|
711
|
+
# suffix. reverse=True means the greatest (latest) ordinal wins. For legacy
|
|
712
|
+
# entries the fallback insertion index only ever ranks legacy entries among
|
|
713
|
+
# themselves; any explicit ordinal from a newer run outranks them.
|
|
714
|
+
matches.sort(key=lambda item: (item[0], item[1], item[2]), reverse=True)
|
|
715
|
+
return matches[0][3]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
from delegate_agent import run_status # noqa: E402 # facade re-export, placed after core defs
|
|
719
|
+
from delegate_agent.run_status import ( # noqa: E402, F401 # re-exported
|
|
720
|
+
DEFAULT_RUNS_LIMIT,
|
|
721
|
+
LARGE_LOG_WARN_BYTES,
|
|
722
|
+
LARGE_LOG_WARN_MIB,
|
|
723
|
+
STATUS_CANCELLED,
|
|
724
|
+
STATUS_FAILED,
|
|
725
|
+
STATUS_FILTER_ACTIVE,
|
|
726
|
+
STATUS_FILTER_RECENT,
|
|
727
|
+
STATUS_FILTER_RUNNING,
|
|
728
|
+
STATUS_FILTER_STALE,
|
|
729
|
+
STATUS_RUNNING,
|
|
730
|
+
STATUS_STALE,
|
|
731
|
+
STATUS_SUCCEEDED,
|
|
732
|
+
STATUS_UNKNOWN,
|
|
733
|
+
TERMINAL_STATUSES,
|
|
734
|
+
activity_datetime,
|
|
735
|
+
activity_timestamp,
|
|
736
|
+
build_run_summary,
|
|
737
|
+
effective_log_byte_sizes,
|
|
738
|
+
effective_status,
|
|
739
|
+
large_log_warnings,
|
|
740
|
+
list_run_summaries,
|
|
741
|
+
log_byte_sizes,
|
|
742
|
+
process_alive,
|
|
743
|
+
raw_logs_archived,
|
|
744
|
+
raw_status,
|
|
745
|
+
stale_next_actions,
|
|
746
|
+
status_fields,
|
|
747
|
+
)
|