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
codexloop/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application layer: ports and use cases. Depends only on domain."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Application DTOs. ``TurnSignals`` lives in domain and is re-exported here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from codexloop.domain.capacity import CapacityState, PlanWindows
|
|
8
|
+
from codexloop.domain.signals import TurnSignals
|
|
9
|
+
|
|
10
|
+
__all__ = ["ProbeResult", "RunResult", "TokenUsage", "TurnOutcome", "TurnSignals"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class TokenUsage:
|
|
15
|
+
input_tokens: int = 0
|
|
16
|
+
cached_input_tokens: int = 0
|
|
17
|
+
output_tokens: int = 0
|
|
18
|
+
reasoning_output_tokens: int = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class TurnOutcome:
|
|
23
|
+
signals: TurnSignals | None = None
|
|
24
|
+
usage: TokenUsage | None = None
|
|
25
|
+
exit_code: int | None = None
|
|
26
|
+
thread_id: str | None = None
|
|
27
|
+
cost_dollars: float = 0.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ProbeResult:
|
|
32
|
+
outcome: CapacityState
|
|
33
|
+
snapshot: PlanWindows | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class RunResult:
|
|
38
|
+
success: bool
|
|
39
|
+
reason: str
|
|
40
|
+
turns: int
|
|
41
|
+
thread_id: str | None
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Application ports — Protocols implemented by infrastructure, never imported from it.
|
|
2
|
+
|
|
3
|
+
``application/`` knows the shape of a collaborator, never a concrete adapter.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from codexloop.application.dto import ProbeResult, TurnOutcome
|
|
14
|
+
from codexloop.domain.control import ControlCommand
|
|
15
|
+
from codexloop.domain.model_profile import ModelEffortProfile
|
|
16
|
+
from codexloop.domain.session import ThreadRef
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AgentGateway",
|
|
20
|
+
"ApiGateway",
|
|
21
|
+
"AuditLog",
|
|
22
|
+
"CapacityProbe",
|
|
23
|
+
"Clock",
|
|
24
|
+
"Logger",
|
|
25
|
+
"Notifier",
|
|
26
|
+
"PermissionMode",
|
|
27
|
+
"ProgressReporter",
|
|
28
|
+
"RunControl",
|
|
29
|
+
"RunEventSink",
|
|
30
|
+
"RunResources",
|
|
31
|
+
"RunSnapshotSink",
|
|
32
|
+
"RunStateStore",
|
|
33
|
+
"SavePointStore",
|
|
34
|
+
"SessionLock",
|
|
35
|
+
"Sleeper",
|
|
36
|
+
"StateBus",
|
|
37
|
+
"ThreadCatalog",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PermissionMode(StrEnum):
|
|
42
|
+
"""Autonomy posture at the application boundary — not Codex CLI flag strings."""
|
|
43
|
+
|
|
44
|
+
AUTONOMOUS = "autonomous"
|
|
45
|
+
READ_ONLY = "read_only"
|
|
46
|
+
FULL_ACCESS = "full_access"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class Clock(Protocol):
|
|
51
|
+
def now(self) -> datetime: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@runtime_checkable
|
|
55
|
+
class Sleeper(Protocol):
|
|
56
|
+
async def sleep_until(self, when: datetime) -> None: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class AgentGateway(Protocol):
|
|
61
|
+
"""Vendor-agnostic agent session (exec and app-server adapters implement this)."""
|
|
62
|
+
|
|
63
|
+
async def send_turn(self, prompt: str) -> TurnOutcome: ...
|
|
64
|
+
async def close(self) -> None: ...
|
|
65
|
+
async def set_profile(self, profile: ModelEffortProfile) -> None: ...
|
|
66
|
+
async def set_permission_mode(self, mode: PermissionMode) -> None: ...
|
|
67
|
+
async def set_cwd(self, path: str) -> None: ...
|
|
68
|
+
async def set_session_resources(self, resources: Mapping[str, object]) -> None: ...
|
|
69
|
+
def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class CapacityProbe(Protocol):
|
|
74
|
+
async def probe(self) -> ProbeResult: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@runtime_checkable
|
|
78
|
+
class ThreadCatalog(Protocol):
|
|
79
|
+
"""Our run registry keyed by ``thread_id``, not vendor session discovery."""
|
|
80
|
+
|
|
81
|
+
def list_threads(self) -> Sequence[ThreadRef]: ...
|
|
82
|
+
def get(self, thread_id: str) -> ThreadRef | None: ...
|
|
83
|
+
def record(self, ref: ThreadRef) -> None: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@runtime_checkable
|
|
87
|
+
class ProgressReporter(Protocol):
|
|
88
|
+
def report(self, event: str, **detail: object) -> None: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@runtime_checkable
|
|
92
|
+
class AuditLog(Protocol):
|
|
93
|
+
def append(self, event_type: str, payload: Mapping[str, object]) -> None: ...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@runtime_checkable
|
|
97
|
+
class Notifier(Protocol):
|
|
98
|
+
def notify(self, title: str, body: str) -> None: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@runtime_checkable
|
|
102
|
+
class Logger(Protocol):
|
|
103
|
+
def bind(self, **kwargs: object) -> Logger: ...
|
|
104
|
+
def info(self, event: str, **kwargs: object) -> None: ...
|
|
105
|
+
def warning(self, event: str, **kwargs: object) -> None: ...
|
|
106
|
+
def error(self, event: str, **kwargs: object) -> None: ...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@runtime_checkable
|
|
110
|
+
class RunStateStore(Protocol):
|
|
111
|
+
def load(self, run_id: str) -> dict[str, object] | None: ...
|
|
112
|
+
def save(self, run_id: str, state: Mapping[str, object]) -> None: ...
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@runtime_checkable
|
|
116
|
+
class SessionLock(Protocol):
|
|
117
|
+
def acquire(self, thread_id: str) -> bool: ...
|
|
118
|
+
def release(self, thread_id: str) -> None: ...
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@runtime_checkable
|
|
122
|
+
class RunControl(Protocol):
|
|
123
|
+
def poll(self) -> Sequence[ControlCommand]: ...
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@runtime_checkable
|
|
127
|
+
class RunEventSink(Protocol):
|
|
128
|
+
def emit(self, event: Mapping[str, object]) -> None: ...
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@runtime_checkable
|
|
132
|
+
class StateBus(Protocol):
|
|
133
|
+
def publish(self, event_type: str, payload: Mapping[str, object]) -> None: ...
|
|
134
|
+
def subscribe(self, callback: Callable[[str, Mapping[str, object]], None]) -> None: ...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@runtime_checkable
|
|
138
|
+
class SavePointStore(Protocol):
|
|
139
|
+
def create(self, run_id: str, label: str) -> str: ...
|
|
140
|
+
def list(self, run_id: str) -> Sequence[str]: ...
|
|
141
|
+
def unwind(self, run_id: str, to: str) -> None: ...
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@runtime_checkable
|
|
145
|
+
class RunSnapshotSink(Protocol):
|
|
146
|
+
def write(self, snapshot: Mapping[str, object]) -> None: ...
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@runtime_checkable
|
|
150
|
+
class ApiGateway(Protocol):
|
|
151
|
+
def invoke(self, method_path: str, **kwargs: object) -> object: ...
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@runtime_checkable
|
|
155
|
+
class RunResources(Protocol):
|
|
156
|
+
"""Placeholder for run-scoped attachments applied mid-run."""
|
|
157
|
+
|
|
158
|
+
def as_payload(self) -> Mapping[str, object]: ...
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""AutonomousRunner — executes domain.loop Decisions against application ports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import assert_never
|
|
9
|
+
|
|
10
|
+
from codexloop.application.dto import RunResult, TurnOutcome
|
|
11
|
+
from codexloop.application.ports import (
|
|
12
|
+
AgentGateway,
|
|
13
|
+
CapacityProbe,
|
|
14
|
+
Clock,
|
|
15
|
+
Notifier,
|
|
16
|
+
PermissionMode,
|
|
17
|
+
ProgressReporter,
|
|
18
|
+
RunControl,
|
|
19
|
+
RunStateStore,
|
|
20
|
+
SessionLock,
|
|
21
|
+
Sleeper,
|
|
22
|
+
ThreadCatalog,
|
|
23
|
+
)
|
|
24
|
+
from codexloop.domain.approval import ApprovalPolicy, SandboxMode
|
|
25
|
+
from codexloop.domain.budget import Budget, BudgetLedger
|
|
26
|
+
from codexloop.domain.capacity import Available, CapacityState, QuotaExhausted
|
|
27
|
+
from codexloop.domain.classify import classify
|
|
28
|
+
from codexloop.domain.completion import (
|
|
29
|
+
DEFAULT_DONE_MARKER,
|
|
30
|
+
Blocked,
|
|
31
|
+
CompletionEvaluator,
|
|
32
|
+
CompletionVerdict,
|
|
33
|
+
Continue,
|
|
34
|
+
)
|
|
35
|
+
from codexloop.domain.control import (
|
|
36
|
+
ControlCommand,
|
|
37
|
+
Prompt,
|
|
38
|
+
ResourceMutate,
|
|
39
|
+
SetApproval,
|
|
40
|
+
SetCwd,
|
|
41
|
+
SetEffort,
|
|
42
|
+
SetModel,
|
|
43
|
+
SetSandbox,
|
|
44
|
+
Snapshot,
|
|
45
|
+
Stop,
|
|
46
|
+
)
|
|
47
|
+
from codexloop.domain.error_codes import ErrorClass, classify_code
|
|
48
|
+
from codexloop.domain.loop import (
|
|
49
|
+
BackoffUntil,
|
|
50
|
+
Drain,
|
|
51
|
+
Finish,
|
|
52
|
+
LoopOutcome,
|
|
53
|
+
Probe,
|
|
54
|
+
RunLoopStateMachine,
|
|
55
|
+
RunState,
|
|
56
|
+
SendTurn,
|
|
57
|
+
WaitUntil,
|
|
58
|
+
)
|
|
59
|
+
from codexloop.domain.model_profile import Effort, ModelEffortProfile
|
|
60
|
+
from codexloop.domain.plan import WorkPlan
|
|
61
|
+
from codexloop.domain.session import Explicit, MostRecent, PlanFile, SessionSelector, ThreadRef
|
|
62
|
+
from codexloop.domain.signals import TurnSignals
|
|
63
|
+
from codexloop.domain.waiting import AdaptiveWaitPolicy, WaitConfig
|
|
64
|
+
|
|
65
|
+
_FAR_FUTURE = timedelta(days=3650)
|
|
66
|
+
_ZERO = timedelta(0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _discard_artifact(name: str, content: str) -> None:
|
|
70
|
+
del name, content
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RunnerContext:
|
|
75
|
+
"""Ports and knobs the runner needs. Unused collaborators may be omitted."""
|
|
76
|
+
|
|
77
|
+
clock: Clock
|
|
78
|
+
sleeper: Sleeper
|
|
79
|
+
gateway: AgentGateway
|
|
80
|
+
probe: CapacityProbe
|
|
81
|
+
store: RunStateStore
|
|
82
|
+
control: RunControl
|
|
83
|
+
catalog: ThreadCatalog | None = None
|
|
84
|
+
lock: SessionLock | None = None
|
|
85
|
+
write_artifact: Callable[[str, str], None] | None = None
|
|
86
|
+
notifier: Notifier | None = None
|
|
87
|
+
reporter: ProgressReporter | None = None
|
|
88
|
+
budget: Budget = field(
|
|
89
|
+
default_factory=lambda: Budget(max_turns=None, max_dollars=None, max_wall_clock=None)
|
|
90
|
+
)
|
|
91
|
+
wait_policy: AdaptiveWaitPolicy | None = None
|
|
92
|
+
max_wait: timedelta | None = None
|
|
93
|
+
run_id: str = "anonymous"
|
|
94
|
+
cwd: str = "."
|
|
95
|
+
model: str = "unknown"
|
|
96
|
+
effort: Effort = Effort.MEDIUM
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AutonomousRunner:
|
|
100
|
+
def __init__(self, ctx: RunnerContext) -> None:
|
|
101
|
+
self._clock = ctx.clock
|
|
102
|
+
self._sleeper = ctx.sleeper
|
|
103
|
+
self._gateway = ctx.gateway
|
|
104
|
+
self._probe = ctx.probe
|
|
105
|
+
self._store = ctx.store
|
|
106
|
+
self._control = ctx.control
|
|
107
|
+
self._catalog = ctx.catalog
|
|
108
|
+
self._lock = ctx.lock
|
|
109
|
+
self._write_artifact = ctx.write_artifact or _discard_artifact
|
|
110
|
+
self._notifier = ctx.notifier
|
|
111
|
+
self._reporter = ctx.reporter
|
|
112
|
+
self._budget = ctx.budget
|
|
113
|
+
self._wait_policy = ctx.wait_policy or AdaptiveWaitPolicy(WaitConfig(), rand=lambda: 0.0)
|
|
114
|
+
self._max_wait = ctx.max_wait
|
|
115
|
+
self._run_id = ctx.run_id
|
|
116
|
+
self._cwd = ctx.cwd
|
|
117
|
+
self._model = ctx.model
|
|
118
|
+
self._effort = ctx.effort
|
|
119
|
+
self._approval = ApprovalPolicy.NEVER
|
|
120
|
+
self._sandbox = SandboxMode.WORKSPACE_WRITE
|
|
121
|
+
self._machine = RunLoopStateMachine()
|
|
122
|
+
self._evaluator = CompletionEvaluator()
|
|
123
|
+
self._ledger = BudgetLedger(ctx.budget)
|
|
124
|
+
self._quota_notified = False
|
|
125
|
+
self._queued_prompt: str | None = None
|
|
126
|
+
|
|
127
|
+
async def run(self, selector: SessionSelector, plan: str) -> RunResult:
|
|
128
|
+
thread_id = self._thread_id_from(selector)
|
|
129
|
+
plan_text, remaining, first_turn = self._restore(thread_id, plan)
|
|
130
|
+
if plan_text:
|
|
131
|
+
parsed = WorkPlan.parse(plan_text)
|
|
132
|
+
if not remaining:
|
|
133
|
+
remaining = list(parsed.remaining_work)
|
|
134
|
+
|
|
135
|
+
locked_id: str | None = None
|
|
136
|
+
if self._lock is not None and thread_id is not None:
|
|
137
|
+
if not self._lock.acquire(thread_id):
|
|
138
|
+
await self._gateway.close()
|
|
139
|
+
return RunResult(success=False, reason="lock", turns=0, thread_id=thread_id)
|
|
140
|
+
locked_id = thread_id
|
|
141
|
+
|
|
142
|
+
state = RunState.Preflight
|
|
143
|
+
outcome = LoopOutcome(capacity=Available())
|
|
144
|
+
started = self._clock.now()
|
|
145
|
+
last_mark = started
|
|
146
|
+
deadline = started + (self._max_wait if self._max_wait is not None else _FAR_FUTURE)
|
|
147
|
+
wait_attempt = 0
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
while True:
|
|
151
|
+
controls = list(self._control.poll())
|
|
152
|
+
await self._apply_controls(controls)
|
|
153
|
+
now = self._clock.now()
|
|
154
|
+
last_mark = self._record_elapsed(last_mark, now)
|
|
155
|
+
|
|
156
|
+
if state is RunState.Preflight:
|
|
157
|
+
probed = await self._probe.probe()
|
|
158
|
+
outcome = LoopOutcome(
|
|
159
|
+
capacity=probed.outcome,
|
|
160
|
+
deadline_exceeded=now >= deadline,
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
outcome = replace(outcome, deadline_exceeded=now >= deadline)
|
|
164
|
+
|
|
165
|
+
state, decision = self._machine.advance(state, outcome, now, self._ledger, controls)
|
|
166
|
+
|
|
167
|
+
match decision:
|
|
168
|
+
case SendTurn():
|
|
169
|
+
default = plan_text if first_turn else _continuation(remaining)
|
|
170
|
+
prompt = self._take_prompt(default)
|
|
171
|
+
first_turn = False
|
|
172
|
+
turn = await self._gateway.send_turn(prompt)
|
|
173
|
+
if turn.thread_id:
|
|
174
|
+
thread_id = turn.thread_id
|
|
175
|
+
locked_id = self._maybe_lock(thread_id, locked_id)
|
|
176
|
+
last_mark = self._record_elapsed(last_mark, self._clock.now())
|
|
177
|
+
self._ledger.record(turns=1, dollars=turn.cost_dollars)
|
|
178
|
+
capacity, completion = _interpret(turn, self._evaluator)
|
|
179
|
+
self._report_unknown_code(turn)
|
|
180
|
+
if isinstance(completion, Continue) and isinstance(capacity, Available):
|
|
181
|
+
remaining = list(completion.remaining)
|
|
182
|
+
self._persist(
|
|
183
|
+
key=thread_id,
|
|
184
|
+
remaining=remaining,
|
|
185
|
+
first_turn_done=True,
|
|
186
|
+
plan_text=plan_text,
|
|
187
|
+
)
|
|
188
|
+
self._record_thread(thread_id, started)
|
|
189
|
+
wait_attempt = 0
|
|
190
|
+
self._quota_notified = False
|
|
191
|
+
outcome = LoopOutcome(capacity=capacity, completion=completion)
|
|
192
|
+
case Probe():
|
|
193
|
+
probed = await self._probe.probe()
|
|
194
|
+
outcome = LoopOutcome(capacity=probed.outcome)
|
|
195
|
+
case WaitUntil() | BackoffUntil():
|
|
196
|
+
self._notify_quota_once(outcome.capacity)
|
|
197
|
+
until = self._wait_policy.next_probe_at(
|
|
198
|
+
outcome.capacity, self._clock.now(), wait_attempt, deadline
|
|
199
|
+
)
|
|
200
|
+
wait_attempt += 1
|
|
201
|
+
await self._sleeper.sleep_until(until)
|
|
202
|
+
case Drain():
|
|
203
|
+
self._write_stop_summary(thread_id, remaining)
|
|
204
|
+
self._persist(
|
|
205
|
+
key=thread_id,
|
|
206
|
+
remaining=remaining,
|
|
207
|
+
first_turn_done=not first_turn,
|
|
208
|
+
plan_text=plan_text,
|
|
209
|
+
)
|
|
210
|
+
case Finish() as finish:
|
|
211
|
+
self._persist(
|
|
212
|
+
key=thread_id,
|
|
213
|
+
remaining=remaining,
|
|
214
|
+
first_turn_done=not first_turn,
|
|
215
|
+
plan_text=plan_text,
|
|
216
|
+
reason=finish.reason,
|
|
217
|
+
)
|
|
218
|
+
return RunResult(
|
|
219
|
+
success=finish.success,
|
|
220
|
+
reason=finish.reason,
|
|
221
|
+
turns=self._ledger.turns,
|
|
222
|
+
thread_id=thread_id,
|
|
223
|
+
)
|
|
224
|
+
case _: # pragma: no cover — Decision is a closed union
|
|
225
|
+
assert_never(decision)
|
|
226
|
+
finally:
|
|
227
|
+
if locked_id is not None and self._lock is not None:
|
|
228
|
+
self._lock.release(locked_id)
|
|
229
|
+
await self._gateway.close()
|
|
230
|
+
|
|
231
|
+
async def _apply_controls(self, controls: Sequence[ControlCommand]) -> None:
|
|
232
|
+
for command in controls:
|
|
233
|
+
match command:
|
|
234
|
+
case Stop():
|
|
235
|
+
continue
|
|
236
|
+
case Prompt(text=text):
|
|
237
|
+
self._queued_prompt = text
|
|
238
|
+
case SetModel(model=model):
|
|
239
|
+
self._model = model
|
|
240
|
+
profile = ModelEffortProfile(model=model, effort=self._effort)
|
|
241
|
+
await self._gateway.set_profile(profile)
|
|
242
|
+
case SetEffort(effort=effort):
|
|
243
|
+
self._effort = effort
|
|
244
|
+
await self._gateway.set_profile(
|
|
245
|
+
ModelEffortProfile(model=self._model, effort=effort)
|
|
246
|
+
)
|
|
247
|
+
case SetApproval(policy=policy):
|
|
248
|
+
self._approval = policy
|
|
249
|
+
await self._sync_permissions()
|
|
250
|
+
case SetSandbox(sandbox=sandbox):
|
|
251
|
+
self._sandbox = sandbox
|
|
252
|
+
await self._sync_permissions()
|
|
253
|
+
case SetCwd(cwd=cwd):
|
|
254
|
+
self._cwd = cwd
|
|
255
|
+
await self._gateway.set_cwd(cwd)
|
|
256
|
+
case Snapshot():
|
|
257
|
+
self._write_artifact(
|
|
258
|
+
"snapshot.json",
|
|
259
|
+
(
|
|
260
|
+
f'{{"run_id":"{self._run_id}","model":"{self._model}",'
|
|
261
|
+
f'"effort":"{self._effort.value}","cwd":"{self._cwd}"}}\n'
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
case ResourceMutate(payload=payload):
|
|
265
|
+
await self._gateway.set_session_resources(payload)
|
|
266
|
+
case _: # pragma: no cover — ControlCommand is a closed union
|
|
267
|
+
assert_never(command)
|
|
268
|
+
|
|
269
|
+
async def _sync_permissions(self) -> None:
|
|
270
|
+
await self._gateway.set_permission_mode(_permission_mode(self._sandbox))
|
|
271
|
+
await self._gateway.set_session_resources(
|
|
272
|
+
{
|
|
273
|
+
"approval_policy": self._approval.value,
|
|
274
|
+
"sandbox_mode": self._sandbox.value,
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _take_prompt(self, default: str) -> str:
|
|
279
|
+
if self._queued_prompt is None:
|
|
280
|
+
return default
|
|
281
|
+
prompt = self._queued_prompt
|
|
282
|
+
self._queued_prompt = None
|
|
283
|
+
return prompt
|
|
284
|
+
|
|
285
|
+
def _thread_id_from(self, selector: SessionSelector) -> str | None:
|
|
286
|
+
match selector:
|
|
287
|
+
case Explicit(thread_id=thread_id):
|
|
288
|
+
return thread_id
|
|
289
|
+
case MostRecent():
|
|
290
|
+
return self._most_recent()
|
|
291
|
+
case PlanFile():
|
|
292
|
+
return None
|
|
293
|
+
case _: # pragma: no cover — SessionSelector is a closed union
|
|
294
|
+
assert_never(selector)
|
|
295
|
+
|
|
296
|
+
def _most_recent(self) -> str | None:
|
|
297
|
+
if self._catalog is None:
|
|
298
|
+
return None
|
|
299
|
+
threads = list(self._catalog.list_threads())
|
|
300
|
+
if not threads:
|
|
301
|
+
return None
|
|
302
|
+
latest = max(threads, key=lambda ref: ref.started_at)
|
|
303
|
+
return latest.thread_id
|
|
304
|
+
|
|
305
|
+
def _restore(self, thread_id: str | None, plan: str) -> tuple[str, list[str], bool]:
|
|
306
|
+
if thread_id is None:
|
|
307
|
+
return plan, [], True
|
|
308
|
+
stored = self._store.load(thread_id)
|
|
309
|
+
if stored is None:
|
|
310
|
+
return plan, [], True
|
|
311
|
+
plan_text = str(stored.get("plan_text") or plan)
|
|
312
|
+
remaining_raw = stored.get("remaining_work") or []
|
|
313
|
+
remaining = [str(item) for item in remaining_raw] if isinstance(remaining_raw, list) else []
|
|
314
|
+
first_turn = not bool(stored.get("first_turn_done"))
|
|
315
|
+
turns = _int_field(stored.get("turns"))
|
|
316
|
+
dollars = _float_field(stored.get("dollars"))
|
|
317
|
+
elapsed_s = _float_field(stored.get("elapsed_seconds"))
|
|
318
|
+
if turns or dollars or elapsed_s:
|
|
319
|
+
self._ledger.record(turns=turns, dollars=dollars, elapsed=timedelta(seconds=elapsed_s))
|
|
320
|
+
return plan_text, remaining, first_turn
|
|
321
|
+
|
|
322
|
+
def _maybe_lock(self, thread_id: str, locked_id: str | None) -> str | None:
|
|
323
|
+
if self._lock is None or locked_id is not None:
|
|
324
|
+
return locked_id
|
|
325
|
+
if self._lock.acquire(thread_id):
|
|
326
|
+
return thread_id
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def _record_elapsed(self, last_mark: datetime, now: datetime) -> datetime:
|
|
330
|
+
delta = now - last_mark
|
|
331
|
+
if delta > _ZERO:
|
|
332
|
+
self._ledger.record(elapsed=delta)
|
|
333
|
+
return now
|
|
334
|
+
|
|
335
|
+
def _notify_quota_once(self, capacity: CapacityState) -> None:
|
|
336
|
+
if not isinstance(capacity, QuotaExhausted) or self._quota_notified:
|
|
337
|
+
return
|
|
338
|
+
self._quota_notified = True
|
|
339
|
+
if self._notifier is None:
|
|
340
|
+
return
|
|
341
|
+
self._notifier.notify("Quota exhausted", capacity.reason)
|
|
342
|
+
|
|
343
|
+
def _report_unknown_code(self, turn: TurnOutcome) -> None:
|
|
344
|
+
if self._reporter is None or turn.signals is None:
|
|
345
|
+
return
|
|
346
|
+
code = turn.signals.error_code
|
|
347
|
+
if code is None:
|
|
348
|
+
return
|
|
349
|
+
if classify_code(code, None) is not ErrorClass.UNKNOWN:
|
|
350
|
+
return
|
|
351
|
+
self._reporter.report(
|
|
352
|
+
"capacity.unknown_code",
|
|
353
|
+
code=code,
|
|
354
|
+
http_status=turn.signals.http_status,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def _persist(
|
|
358
|
+
self,
|
|
359
|
+
*,
|
|
360
|
+
key: str | None,
|
|
361
|
+
remaining: Sequence[str],
|
|
362
|
+
first_turn_done: bool,
|
|
363
|
+
plan_text: str,
|
|
364
|
+
reason: str | None = None,
|
|
365
|
+
) -> None:
|
|
366
|
+
run_id = key or self._run_id
|
|
367
|
+
state: dict[str, object] = {
|
|
368
|
+
"thread_id": key,
|
|
369
|
+
"turns": self._ledger.turns,
|
|
370
|
+
"dollars": self._ledger.dollars,
|
|
371
|
+
"elapsed_seconds": self._ledger.elapsed.total_seconds(),
|
|
372
|
+
"remaining_work": list(remaining),
|
|
373
|
+
"first_turn_done": first_turn_done,
|
|
374
|
+
"plan_text": plan_text,
|
|
375
|
+
}
|
|
376
|
+
if reason is not None:
|
|
377
|
+
state["reason"] = reason
|
|
378
|
+
self._store.save(run_id, state)
|
|
379
|
+
|
|
380
|
+
def _record_thread(self, thread_id: str | None, started: datetime) -> None:
|
|
381
|
+
if self._catalog is None or thread_id is None:
|
|
382
|
+
return
|
|
383
|
+
self._catalog.record(
|
|
384
|
+
ThreadRef(
|
|
385
|
+
thread_id=thread_id,
|
|
386
|
+
cwd=self._cwd,
|
|
387
|
+
started_at=started,
|
|
388
|
+
model=self._model,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def _write_stop_summary(self, thread_id: str | None, remaining: Sequence[str]) -> None:
|
|
393
|
+
items = "\n".join(f"- {item}" for item in remaining) if remaining else "_none_"
|
|
394
|
+
body = (
|
|
395
|
+
"# Stop summary\n\n"
|
|
396
|
+
"**Reason:** stop\n"
|
|
397
|
+
f"**Thread:** `{thread_id or 'unknown'}`\n"
|
|
398
|
+
f"**Turns:** {self._ledger.turns}\n\n"
|
|
399
|
+
"## Remaining work\n\n"
|
|
400
|
+
f"{items}\n"
|
|
401
|
+
)
|
|
402
|
+
self._write_artifact("stop-summary.md", body)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _permission_mode(sandbox: SandboxMode) -> PermissionMode:
|
|
406
|
+
match sandbox:
|
|
407
|
+
case SandboxMode.READ_ONLY:
|
|
408
|
+
return PermissionMode.READ_ONLY
|
|
409
|
+
case SandboxMode.DANGER_FULL_ACCESS:
|
|
410
|
+
return PermissionMode.FULL_ACCESS
|
|
411
|
+
case SandboxMode.WORKSPACE_WRITE:
|
|
412
|
+
return PermissionMode.AUTONOMOUS
|
|
413
|
+
case _: # pragma: no cover — exhaustive StrEnum
|
|
414
|
+
assert_never(sandbox)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _continuation(remaining: Sequence[str]) -> str:
|
|
418
|
+
if remaining:
|
|
419
|
+
items = "\n".join(f"- {name}" for name in remaining)
|
|
420
|
+
body = f"Continue. Remaining work:\n{items}"
|
|
421
|
+
else:
|
|
422
|
+
body = "Continue."
|
|
423
|
+
return (
|
|
424
|
+
f"{body}\n\n"
|
|
425
|
+
"When the entire task is fully complete, output "
|
|
426
|
+
f"{DEFAULT_DONE_MARKER} on its own line."
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _interpret(
|
|
431
|
+
turn: TurnOutcome, evaluator: CompletionEvaluator
|
|
432
|
+
) -> tuple[CapacityState, CompletionVerdict]:
|
|
433
|
+
if turn.signals is None:
|
|
434
|
+
return Available(), Continue(remaining=[])
|
|
435
|
+
capacity = classify(turn.signals)
|
|
436
|
+
completion = evaluator.evaluate(turn.signals, capacity)
|
|
437
|
+
if isinstance(capacity, Available) and _is_fatal_turn(turn.signals):
|
|
438
|
+
reason = (
|
|
439
|
+
turn.signals.error_code
|
|
440
|
+
or turn.signals.error_type
|
|
441
|
+
or ("turn_failed" if turn.signals.failed else "fatal")
|
|
442
|
+
)
|
|
443
|
+
return capacity, Blocked(reason=reason)
|
|
444
|
+
return capacity, completion
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _is_fatal_turn(signals: TurnSignals) -> bool:
|
|
448
|
+
if classify_code(signals.error_code, None) is ErrorClass.FATAL:
|
|
449
|
+
return True
|
|
450
|
+
if classify_code(None, signals.error_type) is ErrorClass.FATAL:
|
|
451
|
+
return True
|
|
452
|
+
return signals.failed
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _int_field(value: object) -> int:
|
|
456
|
+
if isinstance(value, int):
|
|
457
|
+
return value
|
|
458
|
+
return 0
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _float_field(value: object) -> float:
|
|
462
|
+
if isinstance(value, int | float):
|
|
463
|
+
return float(value)
|
|
464
|
+
return 0.0
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
__all__ = ["AutonomousRunner", "RunnerContext"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application use cases invoked by the CLI via bootstrap-wired adapters."""
|