codexloop 0.1.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.
- codexloop/__init__.py +3 -0
- codexloop/application/__init__.py +1 -0
- codexloop/application/dto.py +41 -0
- codexloop/application/ports.py +158 -0
- codexloop/application/runner.py +467 -0
- codexloop/application/usecases/__init__.py +1 -0
- codexloop/application/usecases/doctor.py +37 -0
- codexloop/application/usecases/list_threads.py +14 -0
- codexloop/application/usecases/preflight.py +10 -0
- codexloop/application/usecases/resume_thread.py +11 -0
- codexloop/application/usecases/run_control.py +20 -0
- codexloop/application/usecases/run_plan.py +11 -0
- codexloop/bootstrap.py +461 -0
- codexloop/cli/__init__.py +1 -0
- codexloop/cli/app.py +94 -0
- codexloop/cli/asyncio.py +80 -0
- codexloop/cli/commands/__init__.py +1 -0
- codexloop/cli/commands/approval_cmd.py +24 -0
- codexloop/cli/commands/capacity.py +33 -0
- codexloop/cli/commands/cwd_cmd.py +22 -0
- codexloop/cli/commands/doctor.py +27 -0
- codexloop/cli/commands/effort_cmd.py +24 -0
- codexloop/cli/commands/logs.py +15 -0
- codexloop/cli/commands/model_cmd.py +22 -0
- codexloop/cli/commands/prompt.py +32 -0
- codexloop/cli/commands/reset.py +25 -0
- codexloop/cli/commands/resume.py +38 -0
- codexloop/cli/commands/run.py +50 -0
- codexloop/cli/commands/runs.py +13 -0
- codexloop/cli/commands/sandbox_cmd.py +24 -0
- codexloop/cli/commands/savepoints.py +25 -0
- codexloop/cli/commands/snapshot.py +26 -0
- codexloop/cli/commands/status.py +15 -0
- codexloop/cli/commands/stop.py +21 -0
- codexloop/cli/commands/threads.py +15 -0
- codexloop/cli/commands/unwind.py +24 -0
- codexloop/cli/commands/watch.py +66 -0
- codexloop/cli/render.py +39 -0
- codexloop/domain/__init__.py +26 -0
- codexloop/domain/approval.py +38 -0
- codexloop/domain/backoff.py +34 -0
- codexloop/domain/budget.py +56 -0
- codexloop/domain/capacity.py +81 -0
- codexloop/domain/classify.py +98 -0
- codexloop/domain/completion.py +130 -0
- codexloop/domain/control.py +167 -0
- codexloop/domain/error_codes.py +75 -0
- codexloop/domain/errors.py +35 -0
- codexloop/domain/loop.py +190 -0
- codexloop/domain/model_profile.py +30 -0
- codexloop/domain/plan.py +38 -0
- codexloop/domain/savepoint.py +32 -0
- codexloop/domain/savepoint_message.py +56 -0
- codexloop/domain/session.py +32 -0
- codexloop/domain/signals.py +25 -0
- codexloop/domain/waiting.py +120 -0
- codexloop/infrastructure/__init__.py +0 -0
- codexloop/infrastructure/agent/__init__.py +0 -0
- codexloop/infrastructure/agent/argv.py +102 -0
- codexloop/infrastructure/agent/events.py +274 -0
- codexloop/infrastructure/agent/gateway.py +189 -0
- codexloop/infrastructure/agent/probe.py +74 -0
- codexloop/infrastructure/agent/process.py +208 -0
- codexloop/infrastructure/agent/schema.py +31 -0
- codexloop/infrastructure/agent/scripted.py +201 -0
- codexloop/infrastructure/agent/translate.py +120 -0
- codexloop/infrastructure/api/__init__.py +26 -0
- codexloop/infrastructure/api/api_baseline.json +340 -0
- codexloop/infrastructure/api/binder.py +170 -0
- codexloop/infrastructure/api/gateway.py +142 -0
- codexloop/infrastructure/api/introspect.py +248 -0
- codexloop/infrastructure/api/json_io.py +26 -0
- codexloop/infrastructure/api/params.py +162 -0
- codexloop/infrastructure/api/providers.py +70 -0
- codexloop/infrastructure/api/registry.py +13 -0
- codexloop/infrastructure/appserver/__init__.py +6 -0
- codexloop/infrastructure/appserver/client.py +245 -0
- codexloop/infrastructure/appserver/gateway.py +437 -0
- codexloop/infrastructure/appserver/ratelimits.py +100 -0
- codexloop/infrastructure/audit.py +26 -0
- codexloop/infrastructure/capacity_probe.py +57 -0
- codexloop/infrastructure/clock.py +26 -0
- codexloop/infrastructure/config.py +150 -0
- codexloop/infrastructure/control.py +89 -0
- codexloop/infrastructure/doctor_env.py +239 -0
- codexloop/infrastructure/events.py +23 -0
- codexloop/infrastructure/git_savepoints.py +176 -0
- codexloop/infrastructure/lock.py +88 -0
- codexloop/infrastructure/logging.py +124 -0
- codexloop/infrastructure/notify.py +27 -0
- codexloop/infrastructure/progress.py +14 -0
- codexloop/infrastructure/redact.py +52 -0
- codexloop/infrastructure/rollout.py +113 -0
- codexloop/infrastructure/rundir.py +57 -0
- codexloop/infrastructure/snapshot.py +39 -0
- codexloop/infrastructure/state.py +32 -0
- codexloop/infrastructure/state_bus.py +27 -0
- codexloop/infrastructure/stream_ui.py +44 -0
- codexloop/py.typed +0 -0
- codexloop-0.1.0.dist-info/METADATA +104 -0
- codexloop-0.1.0.dist-info/RECORD +104 -0
- codexloop-0.1.0.dist-info/WHEEL +4 -0
- codexloop-0.1.0.dist-info/entry_points.txt +2 -0
- codexloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Use case: pre-flight doctor checks before a long unattended run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class DoctorCheck:
|
|
13
|
+
name: str
|
|
14
|
+
passed: bool
|
|
15
|
+
detail: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class DoctorReport:
|
|
20
|
+
checks: tuple[DoctorCheck, ...]
|
|
21
|
+
auth_mode: str
|
|
22
|
+
probe_strategies: Mapping[str, bool] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def all_passed(self) -> bool:
|
|
26
|
+
return all(check.passed for check in self.checks)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DoctorEnvironment(Protocol):
|
|
30
|
+
def diagnose(self, *, cwd: Path) -> DoctorReport: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_doctor(env: DoctorEnvironment, *, cwd: Path) -> DoctorReport:
|
|
34
|
+
return env.diagnose(cwd=cwd)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["DoctorCheck", "DoctorEnvironment", "DoctorReport", "run_doctor"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Use case: list this product's run registry, not vendor sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from codexloop.application.runner import RunnerContext
|
|
8
|
+
from codexloop.domain.session import ThreadRef
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_threads(ctx: RunnerContext) -> Sequence[ThreadRef]:
|
|
12
|
+
if ctx.catalog is None:
|
|
13
|
+
return ()
|
|
14
|
+
return ctx.catalog.list_threads()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Use case: probe capacity without spending a real turn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codexloop.application.dto import ProbeResult
|
|
6
|
+
from codexloop.application.runner import RunnerContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def preflight(ctx: RunnerContext) -> ProbeResult:
|
|
10
|
+
return await ctx.probe.probe()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Use case: resume an existing thread by explicit id."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codexloop.application.dto import RunResult
|
|
6
|
+
from codexloop.application.runner import AutonomousRunner, RunnerContext
|
|
7
|
+
from codexloop.domain.session import Explicit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def resume_thread(ctx: RunnerContext, thread_id: str, plan: str = "") -> RunResult:
|
|
11
|
+
return await AutonomousRunner(ctx).run(Explicit(thread_id), plan)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Use cases: enqueue operator control commands via a port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from codexloop.domain.control import ControlCommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ControlInbox(Protocol):
|
|
12
|
+
def enqueue(self, command: ControlCommand) -> Path: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def enqueue_control(inbox: ControlInbox, command: ControlCommand) -> Path:
|
|
16
|
+
"""Enqueue ``command`` and return the path written."""
|
|
17
|
+
return inbox.enqueue(command)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["ControlInbox", "enqueue_control"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Use case: drive a new run from a markdown work plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codexloop.application.dto import RunResult
|
|
6
|
+
from codexloop.application.runner import AutonomousRunner, RunnerContext
|
|
7
|
+
from codexloop.domain.session import SessionSelector
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def run_plan(ctx: RunnerContext, selector: SessionSelector, plan: str) -> RunResult:
|
|
11
|
+
return await AutonomousRunner(ctx).run(selector, plan)
|
codexloop/bootstrap.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Composition root — the only module permitted to import every onion layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
|
|
15
|
+
from codexloop.application.ports import AgentGateway, CapacityProbe
|
|
16
|
+
from codexloop.application.runner import RunnerContext
|
|
17
|
+
from codexloop.application.usecases.doctor import DoctorReport, run_doctor
|
|
18
|
+
from codexloop.application.usecases.run_control import enqueue_control
|
|
19
|
+
from codexloop.domain.budget import Budget
|
|
20
|
+
from codexloop.domain.capacity import PlanWindows
|
|
21
|
+
from codexloop.domain.control import ControlCommand, Stop
|
|
22
|
+
from codexloop.domain.errors import ConfigurationError
|
|
23
|
+
from codexloop.domain.savepoint import SavePointRef, UnwindResult
|
|
24
|
+
from codexloop.domain.session import ThreadRef
|
|
25
|
+
from codexloop.domain.waiting import AdaptiveWaitPolicy, WaitConfig
|
|
26
|
+
from codexloop.infrastructure.agent.argv import ExecOpts
|
|
27
|
+
from codexloop.infrastructure.agent.gateway import CodexExecGateway
|
|
28
|
+
from codexloop.infrastructure.agent.probe import ExecCapacityProbe
|
|
29
|
+
from codexloop.infrastructure.agent.scripted import resolve_test_agent_from_env
|
|
30
|
+
from codexloop.infrastructure.api.binder import build_api_typer_app as _build_api_typer_app
|
|
31
|
+
from codexloop.infrastructure.appserver.client import AppServerClient
|
|
32
|
+
from codexloop.infrastructure.appserver.gateway import probe_app_server_transport
|
|
33
|
+
from codexloop.infrastructure.capacity_probe import CompositeCapacityProbe
|
|
34
|
+
from codexloop.infrastructure.clock import AnyioSleeper, SystemClock
|
|
35
|
+
from codexloop.infrastructure.config import RunnerConfig, load_config
|
|
36
|
+
from codexloop.infrastructure.control import CompositeRunControl, FileRunControl
|
|
37
|
+
from codexloop.infrastructure.doctor_env import CodexDoctorEnvironment
|
|
38
|
+
from codexloop.infrastructure.git_savepoints import GitSavePointStore
|
|
39
|
+
from codexloop.infrastructure.lock import AdvisoryFileLock
|
|
40
|
+
from codexloop.infrastructure.logging import configure_logging
|
|
41
|
+
from codexloop.infrastructure.notify import CommandNotifier
|
|
42
|
+
from codexloop.infrastructure.progress import LoggingProgressReporter
|
|
43
|
+
from codexloop.infrastructure.rollout import read_rollout_rate_limits
|
|
44
|
+
from codexloop.infrastructure.rundir import RunDirectory, runs_root_for
|
|
45
|
+
from codexloop.infrastructure.snapshot import create_snapshot, restore_snapshot
|
|
46
|
+
from codexloop.infrastructure.state import FileRunStateStore
|
|
47
|
+
from codexloop.infrastructure.state_bus import read_state
|
|
48
|
+
from codexloop.infrastructure.stream_ui import run_stream_ui
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DrainControl",
|
|
52
|
+
"RunnerConfig",
|
|
53
|
+
"build_api_typer_app",
|
|
54
|
+
"build_runner",
|
|
55
|
+
"current_drain",
|
|
56
|
+
"create_savepoint",
|
|
57
|
+
"enqueue_run_control",
|
|
58
|
+
"events_path_for_run",
|
|
59
|
+
"list_run_records",
|
|
60
|
+
"list_savepoints",
|
|
61
|
+
"read_capacity_windows",
|
|
62
|
+
"read_run_events",
|
|
63
|
+
"read_run_record",
|
|
64
|
+
"read_run_state",
|
|
65
|
+
"register_drain",
|
|
66
|
+
"restore_run_snapshot",
|
|
67
|
+
"run_doctor_checks",
|
|
68
|
+
"run_is_live",
|
|
69
|
+
"run_stream_ui_for_events",
|
|
70
|
+
"take_snapshot",
|
|
71
|
+
"unwind_savepoint",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
_ACTIVE_DRAIN: DrainControl | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DrainControl:
|
|
78
|
+
"""RunControl that surfaces SIGINT/SIGTERM as a domain ``Stop``."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self._stop = False
|
|
82
|
+
|
|
83
|
+
def request_stop(self) -> None:
|
|
84
|
+
self._stop = True
|
|
85
|
+
|
|
86
|
+
def poll(self) -> Sequence[ControlCommand]:
|
|
87
|
+
if not self._stop:
|
|
88
|
+
return []
|
|
89
|
+
self._stop = False
|
|
90
|
+
return [Stop()]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def current_drain() -> DrainControl | None:
|
|
94
|
+
return _ACTIVE_DRAIN
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def register_drain(control: DrainControl | None = None) -> DrainControl:
|
|
98
|
+
"""Install the process-wide drain. Pass a control to replace; omit to reuse."""
|
|
99
|
+
global _ACTIVE_DRAIN
|
|
100
|
+
if control is not None:
|
|
101
|
+
_ACTIVE_DRAIN = control
|
|
102
|
+
return control
|
|
103
|
+
if _ACTIVE_DRAIN is None:
|
|
104
|
+
_ACTIVE_DRAIN = DrainControl()
|
|
105
|
+
return _ACTIVE_DRAIN
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _JsonThreadCatalog:
|
|
109
|
+
def __init__(self, path: Path) -> None:
|
|
110
|
+
self._path = path
|
|
111
|
+
self._threads: dict[str, ThreadRef] = {}
|
|
112
|
+
self._load()
|
|
113
|
+
|
|
114
|
+
def _load(self) -> None:
|
|
115
|
+
if not self._path.is_file():
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
return
|
|
121
|
+
if not isinstance(raw, list):
|
|
122
|
+
return
|
|
123
|
+
for item in raw:
|
|
124
|
+
if not isinstance(item, dict):
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
ref = ThreadRef(
|
|
128
|
+
thread_id=str(item["thread_id"]),
|
|
129
|
+
cwd=str(item["cwd"]),
|
|
130
|
+
started_at=datetime.fromisoformat(str(item["started_at"])),
|
|
131
|
+
model=str(item["model"]),
|
|
132
|
+
)
|
|
133
|
+
except (KeyError, TypeError, ValueError):
|
|
134
|
+
continue
|
|
135
|
+
self._threads[ref.thread_id] = ref
|
|
136
|
+
|
|
137
|
+
def _save(self) -> None:
|
|
138
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
payload = [
|
|
140
|
+
{
|
|
141
|
+
"thread_id": ref.thread_id,
|
|
142
|
+
"cwd": ref.cwd,
|
|
143
|
+
"started_at": ref.started_at.isoformat(),
|
|
144
|
+
"model": ref.model,
|
|
145
|
+
}
|
|
146
|
+
for ref in self._threads.values()
|
|
147
|
+
]
|
|
148
|
+
self._path.write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
def list_threads(self) -> Sequence[ThreadRef]:
|
|
151
|
+
return list(self._threads.values())
|
|
152
|
+
|
|
153
|
+
def get(self, thread_id: str) -> ThreadRef | None:
|
|
154
|
+
return self._threads.get(thread_id)
|
|
155
|
+
|
|
156
|
+
def record(self, ref: ThreadRef) -> None:
|
|
157
|
+
self._threads[ref.thread_id] = ref
|
|
158
|
+
self._save()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _select_gateway(transport: str, *, cwd: Path, config: RunnerConfig) -> AgentGateway:
|
|
162
|
+
if transport == "app-server":
|
|
163
|
+
|
|
164
|
+
async def _probe() -> tuple[AgentGateway | None, str | None]:
|
|
165
|
+
return await probe_app_server_transport(cwd=cwd)
|
|
166
|
+
|
|
167
|
+
gateway, reason = anyio.run(_probe)
|
|
168
|
+
if gateway is not None:
|
|
169
|
+
return gateway
|
|
170
|
+
if reason:
|
|
171
|
+
print(f"codexloop: {reason}", file=sys.stderr)
|
|
172
|
+
return CodexExecGateway(
|
|
173
|
+
cwd=cwd,
|
|
174
|
+
opts=ExecOpts(prompt="", model=config.model, add_dirs=config.add_dirs),
|
|
175
|
+
)
|
|
176
|
+
if transport != "exec":
|
|
177
|
+
raise ConfigurationError(f"unknown transport {transport!r}")
|
|
178
|
+
return CodexExecGateway(
|
|
179
|
+
cwd=cwd,
|
|
180
|
+
opts=ExecOpts(prompt="", model=config.model, add_dirs=config.add_dirs),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_runner(
|
|
185
|
+
config: RunnerConfig | None = None,
|
|
186
|
+
*,
|
|
187
|
+
transport: str = "exec",
|
|
188
|
+
cwd: Path | None = None,
|
|
189
|
+
flags: Mapping[str, object] | None = None,
|
|
190
|
+
ensure_run: bool = True,
|
|
191
|
+
) -> RunnerContext:
|
|
192
|
+
"""Wire ports for one CLI invocation. ``cli/`` must not import infrastructure."""
|
|
193
|
+
cwd = Path.cwd() if cwd is None else cwd
|
|
194
|
+
if config is None:
|
|
195
|
+
config = load_config(cwd=cwd, flags=flags)
|
|
196
|
+
|
|
197
|
+
configure_logging(
|
|
198
|
+
level=config.log_level,
|
|
199
|
+
json_logs=config.json_logs,
|
|
200
|
+
log_file=Path(config.log_file) if config.log_file else None,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
runs_root = runs_root_for(cwd)
|
|
204
|
+
rundir: RunDirectory | None = RunDirectory.create(runs_root) if ensure_run else None
|
|
205
|
+
clock = SystemClock()
|
|
206
|
+
|
|
207
|
+
def write_artifact(name: str, content: str) -> None:
|
|
208
|
+
if rundir is None:
|
|
209
|
+
return
|
|
210
|
+
(rundir.root / name).write_text(content, encoding="utf-8")
|
|
211
|
+
|
|
212
|
+
app_server = AppServerClient(cwd=cwd)
|
|
213
|
+
gateway: AgentGateway
|
|
214
|
+
probe: CapacityProbe
|
|
215
|
+
scripted = resolve_test_agent_from_env()
|
|
216
|
+
if scripted is not None:
|
|
217
|
+
gateway, probe = scripted
|
|
218
|
+
wait_policy = AdaptiveWaitPolicy(
|
|
219
|
+
WaitConfig(
|
|
220
|
+
jitter_ratio=0.0,
|
|
221
|
+
quota_probe_base=timedelta(milliseconds=50),
|
|
222
|
+
quota_probe_ceiling=timedelta(milliseconds=200),
|
|
223
|
+
window_probe_interval=timedelta(milliseconds=50),
|
|
224
|
+
throttle_ceiling=timedelta(milliseconds=200),
|
|
225
|
+
aggressive_ceiling=timedelta(milliseconds=200),
|
|
226
|
+
transient_ceiling=timedelta(milliseconds=200),
|
|
227
|
+
backoff_base=timedelta(milliseconds=50),
|
|
228
|
+
grace=timedelta(0),
|
|
229
|
+
),
|
|
230
|
+
rand=lambda: 0.0,
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
gateway = _select_gateway(transport, cwd=cwd, config=config)
|
|
234
|
+
probe = CompositeCapacityProbe(
|
|
235
|
+
ExecCapacityProbe(cwd=cwd),
|
|
236
|
+
app_server=app_server.read_rate_limits,
|
|
237
|
+
rollout=read_rollout_rate_limits,
|
|
238
|
+
)
|
|
239
|
+
wait_policy = AdaptiveWaitPolicy(WaitConfig())
|
|
240
|
+
|
|
241
|
+
drain = register_drain()
|
|
242
|
+
if rundir is not None:
|
|
243
|
+
inbox = FileRunControl(rundir.inbox)
|
|
244
|
+
control: DrainControl | CompositeRunControl = CompositeRunControl(drain, inbox)
|
|
245
|
+
else:
|
|
246
|
+
control = drain
|
|
247
|
+
|
|
248
|
+
return RunnerContext(
|
|
249
|
+
clock=clock,
|
|
250
|
+
sleeper=AnyioSleeper(clock),
|
|
251
|
+
gateway=gateway,
|
|
252
|
+
probe=probe,
|
|
253
|
+
store=FileRunStateStore(runs_root),
|
|
254
|
+
control=control,
|
|
255
|
+
catalog=_JsonThreadCatalog(cwd / ".codexloop" / "threads.json"),
|
|
256
|
+
lock=AdvisoryFileLock(cwd / ".codexloop" / "locks"),
|
|
257
|
+
write_artifact=write_artifact,
|
|
258
|
+
notifier=CommandNotifier(config.notify_command),
|
|
259
|
+
reporter=LoggingProgressReporter(),
|
|
260
|
+
budget=Budget(max_turns=config.max_turns, max_dollars=None, max_wall_clock=None),
|
|
261
|
+
wait_policy=wait_policy,
|
|
262
|
+
max_wait=config.max_wait,
|
|
263
|
+
run_id=rundir.run_id if rundir is not None else "anonymous",
|
|
264
|
+
cwd=str(cwd),
|
|
265
|
+
model=config.model or "codex-default",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def list_run_records(cwd: Path | None = None) -> list[dict[str, Any]]:
|
|
270
|
+
root = runs_root_for(Path.cwd() if cwd is None else cwd)
|
|
271
|
+
if not root.is_dir():
|
|
272
|
+
return []
|
|
273
|
+
return [_record_from_dir(child) for child in sorted(root.iterdir()) if child.is_dir()]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _latest_run_key(record: dict[str, Any]) -> datetime:
|
|
277
|
+
meta = record.get("meta")
|
|
278
|
+
if isinstance(meta, dict):
|
|
279
|
+
raw = meta.get("started_at")
|
|
280
|
+
if isinstance(raw, str):
|
|
281
|
+
try:
|
|
282
|
+
return datetime.fromisoformat(raw)
|
|
283
|
+
except ValueError:
|
|
284
|
+
pass
|
|
285
|
+
try:
|
|
286
|
+
return datetime.fromtimestamp(Path(str(record["root"])).stat().st_mtime, tz=UTC)
|
|
287
|
+
except OSError:
|
|
288
|
+
return datetime.min.replace(tzinfo=UTC)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_run_record(run_id: str | None = None, *, cwd: Path | None = None) -> dict[str, Any] | None:
|
|
292
|
+
records = list_run_records(cwd)
|
|
293
|
+
if not records:
|
|
294
|
+
return None
|
|
295
|
+
if run_id is None:
|
|
296
|
+
return max(records, key=_latest_run_key)
|
|
297
|
+
for record in records:
|
|
298
|
+
if record["run_id"] == run_id:
|
|
299
|
+
return record
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def read_run_events(run_id: str | None = None, *, cwd: Path | None = None) -> str:
|
|
304
|
+
record = read_run_record(run_id, cwd=cwd)
|
|
305
|
+
if record is None:
|
|
306
|
+
return ""
|
|
307
|
+
events_path = Path(str(record["root"])) / "events.jsonl"
|
|
308
|
+
if not events_path.is_file():
|
|
309
|
+
return ""
|
|
310
|
+
return events_path.read_text(encoding="utf-8")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _record_from_dir(root: Path) -> dict[str, Any]:
|
|
314
|
+
meta: dict[str, Any] = {}
|
|
315
|
+
state: dict[str, Any] = {}
|
|
316
|
+
meta_path = root / "meta.json"
|
|
317
|
+
state_path = root / "state.json"
|
|
318
|
+
if meta_path.is_file():
|
|
319
|
+
loaded = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
320
|
+
if isinstance(loaded, dict):
|
|
321
|
+
meta = loaded
|
|
322
|
+
if state_path.is_file():
|
|
323
|
+
loaded = json.loads(state_path.read_text(encoding="utf-8"))
|
|
324
|
+
if isinstance(loaded, dict):
|
|
325
|
+
state = loaded
|
|
326
|
+
return {"run_id": root.name, "root": str(root), "meta": meta, "state": state}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _run_directory(run_id: str | None = None, *, cwd: Path | None = None) -> RunDirectory:
|
|
330
|
+
record = read_run_record(run_id, cwd=cwd)
|
|
331
|
+
if record is None:
|
|
332
|
+
raise ConfigurationError("no run found — start one with `codexloop run` first")
|
|
333
|
+
directory = RunDirectory(Path(str(record["root"])))
|
|
334
|
+
directory.ensure_layout()
|
|
335
|
+
return directory
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def enqueue_run_control(
|
|
339
|
+
command: ControlCommand,
|
|
340
|
+
*,
|
|
341
|
+
run_id: str | None = None,
|
|
342
|
+
cwd: Path | None = None,
|
|
343
|
+
) -> Path:
|
|
344
|
+
directory = _run_directory(run_id, cwd=cwd)
|
|
345
|
+
return enqueue_control(FileRunControl(directory.inbox), command)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def run_doctor_checks(*, cwd: Path | None = None) -> DoctorReport:
|
|
349
|
+
root = Path.cwd() if cwd is None else cwd
|
|
350
|
+
env = CodexDoctorEnvironment(
|
|
351
|
+
rollout_live=lambda: (Path.home() / ".codex").is_dir(),
|
|
352
|
+
)
|
|
353
|
+
return run_doctor(env, cwd=root)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def read_capacity_windows(*, cwd: Path | None = None) -> PlanWindows | None:
|
|
357
|
+
del cwd
|
|
358
|
+
return read_rollout_rate_limits()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def read_run_state(run_id: str | None = None, *, cwd: Path | None = None) -> dict[str, object]:
|
|
362
|
+
record = read_run_record(run_id, cwd=cwd)
|
|
363
|
+
if record is None:
|
|
364
|
+
return {}
|
|
365
|
+
return read_state(Path(str(record["root"])) / "state.json")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def run_is_live(run_id: str | None = None, *, cwd: Path | None = None) -> bool:
|
|
369
|
+
record = read_run_record(run_id, cwd=cwd)
|
|
370
|
+
if record is None:
|
|
371
|
+
return False
|
|
372
|
+
meta = record.get("meta")
|
|
373
|
+
if not isinstance(meta, dict):
|
|
374
|
+
return False
|
|
375
|
+
pid = meta.get("pid")
|
|
376
|
+
if not isinstance(pid, int):
|
|
377
|
+
return False
|
|
378
|
+
try:
|
|
379
|
+
os.kill(pid, 0)
|
|
380
|
+
except OSError:
|
|
381
|
+
return False
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def list_savepoints(run_id: str | None = None, *, cwd: Path | None = None) -> list[SavePointRef]:
|
|
386
|
+
root = Path.cwd() if cwd is None else cwd
|
|
387
|
+
directory = _run_directory(run_id, cwd=root)
|
|
388
|
+
store = GitSavePointStore(cwd=root, index_path=directory.savepoints_path)
|
|
389
|
+
return store.list_points(directory.run_id)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def create_savepoint(
|
|
393
|
+
*,
|
|
394
|
+
label: str = "manual",
|
|
395
|
+
run_id: str | None = None,
|
|
396
|
+
cwd: Path | None = None,
|
|
397
|
+
attempt: int | None = None,
|
|
398
|
+
summary: str = "",
|
|
399
|
+
) -> SavePointRef | None:
|
|
400
|
+
root = Path.cwd() if cwd is None else cwd
|
|
401
|
+
directory = _run_directory(run_id, cwd=root)
|
|
402
|
+
store = GitSavePointStore(cwd=root, index_path=directory.savepoints_path)
|
|
403
|
+
return store.create(
|
|
404
|
+
run_id=directory.run_id,
|
|
405
|
+
label=label,
|
|
406
|
+
attempt=attempt,
|
|
407
|
+
summary=summary,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def unwind_savepoint(
|
|
412
|
+
to: str,
|
|
413
|
+
*,
|
|
414
|
+
run_id: str | None = None,
|
|
415
|
+
cwd: Path | None = None,
|
|
416
|
+
backup: bool = True,
|
|
417
|
+
) -> UnwindResult:
|
|
418
|
+
root = Path.cwd() if cwd is None else cwd
|
|
419
|
+
directory = _run_directory(run_id, cwd=root)
|
|
420
|
+
if run_is_live(directory.run_id, cwd=root):
|
|
421
|
+
raise ConfigurationError("unwind refuses while a run is live")
|
|
422
|
+
store = GitSavePointStore(cwd=root, index_path=directory.savepoints_path)
|
|
423
|
+
return store.unwind(run_id=directory.run_id, to=to, backup=backup, live=False)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def take_snapshot(
|
|
427
|
+
*,
|
|
428
|
+
run_id: str | None = None,
|
|
429
|
+
cwd: Path | None = None,
|
|
430
|
+
name: str | None = None,
|
|
431
|
+
) -> Path:
|
|
432
|
+
root = Path.cwd() if cwd is None else cwd
|
|
433
|
+
directory = _run_directory(run_id, cwd=root)
|
|
434
|
+
stamp = name or datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
435
|
+
dest = directory.snapshots / stamp
|
|
436
|
+
return create_snapshot(cwd=root, dest=dest)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def restore_run_snapshot(
|
|
440
|
+
name: str,
|
|
441
|
+
*,
|
|
442
|
+
run_id: str | None = None,
|
|
443
|
+
cwd: Path | None = None,
|
|
444
|
+
) -> None:
|
|
445
|
+
root = Path.cwd() if cwd is None else cwd
|
|
446
|
+
directory = _run_directory(run_id, cwd=root)
|
|
447
|
+
restore_snapshot(snapshot=directory.snapshots / name, cwd=root)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def build_api_typer_app() -> Any:
|
|
451
|
+
"""Compose the generated ``codexloop api`` Typer sub-app (M4)."""
|
|
452
|
+
return _build_api_typer_app()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def run_stream_ui_for_events(path: Path) -> None:
|
|
456
|
+
"""Launch the optional Textual stream UI against an events JSONL file."""
|
|
457
|
+
run_stream_ui(path)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def events_path_for_run(run_id: str | None = None, *, cwd: Path | None = None) -> Path:
|
|
461
|
+
return _run_directory(run_id, cwd=cwd).events_path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer CLI package. Must not import ``infrastructure``."""
|
codexloop/cli/app.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Typer root app and console-script entry point.
|
|
2
|
+
|
|
3
|
+
Registered in pyproject.toml as:
|
|
4
|
+
[project.scripts]
|
|
5
|
+
codexloop = "codexloop.cli.app:main"
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from codexloop import __version__
|
|
13
|
+
from codexloop.bootstrap import build_api_typer_app
|
|
14
|
+
from codexloop.cli.commands.approval_cmd import approval_cmd
|
|
15
|
+
from codexloop.cli.commands.capacity import capacity
|
|
16
|
+
from codexloop.cli.commands.cwd_cmd import cwd_cmd
|
|
17
|
+
from codexloop.cli.commands.doctor import doctor
|
|
18
|
+
from codexloop.cli.commands.effort_cmd import effort_cmd
|
|
19
|
+
from codexloop.cli.commands.logs import logs
|
|
20
|
+
from codexloop.cli.commands.model_cmd import model_cmd
|
|
21
|
+
from codexloop.cli.commands.prompt import prompt
|
|
22
|
+
from codexloop.cli.commands.reset import reset
|
|
23
|
+
from codexloop.cli.commands.resume import resume
|
|
24
|
+
from codexloop.cli.commands.run import run
|
|
25
|
+
from codexloop.cli.commands.runs import runs
|
|
26
|
+
from codexloop.cli.commands.sandbox_cmd import sandbox_cmd
|
|
27
|
+
from codexloop.cli.commands.savepoints import savepoints
|
|
28
|
+
from codexloop.cli.commands.snapshot import snapshot
|
|
29
|
+
from codexloop.cli.commands.status import status
|
|
30
|
+
from codexloop.cli.commands.stop import stop
|
|
31
|
+
from codexloop.cli.commands.threads import threads
|
|
32
|
+
from codexloop.cli.commands.unwind import unwind
|
|
33
|
+
from codexloop.cli.commands.watch import watch
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
name="codexloop",
|
|
37
|
+
help=(
|
|
38
|
+
"Onion-architected, autonomous OpenAI Codex session runner — never "
|
|
39
|
+
"blocks on a human, distinguishes rate limits from exhausted credits, "
|
|
40
|
+
"and resumes safely across usage windows."
|
|
41
|
+
),
|
|
42
|
+
add_completion=False,
|
|
43
|
+
no_args_is_help=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _version_callback(value: bool) -> None:
|
|
48
|
+
if value:
|
|
49
|
+
typer.echo(f"codexloop {__version__}")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.callback()
|
|
54
|
+
def main_callback(
|
|
55
|
+
version: bool = typer.Option(
|
|
56
|
+
False,
|
|
57
|
+
"--version",
|
|
58
|
+
callback=_version_callback,
|
|
59
|
+
is_eager=True,
|
|
60
|
+
help="Show the installed codexloop version and exit.",
|
|
61
|
+
),
|
|
62
|
+
) -> None:
|
|
63
|
+
del version
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
app.command()(run)
|
|
67
|
+
app.command()(resume)
|
|
68
|
+
app.command()(threads)
|
|
69
|
+
app.command()(status)
|
|
70
|
+
app.command()(logs)
|
|
71
|
+
app.command()(runs)
|
|
72
|
+
app.command()(prompt)
|
|
73
|
+
app.command()(stop)
|
|
74
|
+
app.command()(capacity)
|
|
75
|
+
app.command()(doctor)
|
|
76
|
+
app.command()(watch)
|
|
77
|
+
app.command()(savepoints)
|
|
78
|
+
app.command()(unwind)
|
|
79
|
+
app.command()(reset)
|
|
80
|
+
app.command()(snapshot)
|
|
81
|
+
app.command("model")(model_cmd)
|
|
82
|
+
app.command("effort")(effort_cmd)
|
|
83
|
+
app.command("approval")(approval_cmd)
|
|
84
|
+
app.command("sandbox")(sandbox_cmd)
|
|
85
|
+
app.command("cwd")(cwd_cmd)
|
|
86
|
+
app.add_typer(build_api_typer_app(), name="api")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> None: # pragma: no cover — process entrypoint
|
|
90
|
+
app()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__": # pragma: no cover
|
|
94
|
+
main()
|