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,75 @@
|
|
|
1
|
+
"""OpenAI / Codex error-code taxonomy for capacity classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorClass(Enum):
|
|
9
|
+
"""Coarse classification of vendor error codes / types."""
|
|
10
|
+
|
|
11
|
+
AUTH = auto()
|
|
12
|
+
QUOTA = auto()
|
|
13
|
+
WINDOW = auto()
|
|
14
|
+
THROTTLE = auto()
|
|
15
|
+
TRANSIENT = auto()
|
|
16
|
+
FATAL = auto()
|
|
17
|
+
UNKNOWN = auto()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
QUOTA_CODES: frozenset[str] = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"insufficient_quota",
|
|
23
|
+
"credit_balance_exhausted",
|
|
24
|
+
"usage_not_included",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
AUTH_CODES: frozenset[str] = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"invalid_api_key",
|
|
31
|
+
"token_expired",
|
|
32
|
+
"refresh_token_expired",
|
|
33
|
+
"refresh_token_reused",
|
|
34
|
+
"refresh_token_invalidated",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
WINDOW_CODES: frozenset[str] = frozenset({"usage_limit_reached"})
|
|
39
|
+
|
|
40
|
+
THROTTLE_CODES: frozenset[str] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"rate_limit_exceeded",
|
|
43
|
+
"slow_down",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
TRANSIENT_CODES: frozenset[str] = frozenset({"server_is_overloaded"})
|
|
48
|
+
|
|
49
|
+
FATAL_CODES: frozenset[str] = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"context_length_exceeded",
|
|
52
|
+
"invalid_prompt",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_CODE_TO_CLASS: dict[str, ErrorClass] = {
|
|
57
|
+
**dict.fromkeys(QUOTA_CODES, ErrorClass.QUOTA),
|
|
58
|
+
**dict.fromkeys(AUTH_CODES, ErrorClass.AUTH),
|
|
59
|
+
**dict.fromkeys(WINDOW_CODES, ErrorClass.WINDOW),
|
|
60
|
+
**dict.fromkeys(THROTTLE_CODES, ErrorClass.THROTTLE),
|
|
61
|
+
**dict.fromkeys(TRANSIENT_CODES, ErrorClass.TRANSIENT),
|
|
62
|
+
**dict.fromkeys(FATAL_CODES, ErrorClass.FATAL),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_code(code: str | None, type_: str | None) -> ErrorClass:
|
|
67
|
+
"""Map an OpenAI/Codex ``error.code`` / ``error.type`` to an :class:`ErrorClass`.
|
|
68
|
+
|
|
69
|
+
Prefers ``code`` when present; consults ``type_`` only when ``code`` is absent.
|
|
70
|
+
Unrecognised values yield :attr:`ErrorClass.UNKNOWN`.
|
|
71
|
+
"""
|
|
72
|
+
token = code if code is not None else type_
|
|
73
|
+
if token is None:
|
|
74
|
+
return ErrorClass.UNKNOWN
|
|
75
|
+
return _CODE_TO_CLASS.get(token, ErrorClass.UNKNOWN)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Domain exception hierarchy for codexloop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CodexloopError(Exception):
|
|
7
|
+
"""Base error for all codexloop domain failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(CodexloopError):
|
|
11
|
+
"""Invalid or incomplete configuration."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CapacityError(CodexloopError):
|
|
15
|
+
"""Capacity, quota, or rate-limit related failure."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthError(CodexloopError):
|
|
19
|
+
"""Authentication or credential failure."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodexBinaryError(CodexloopError):
|
|
23
|
+
"""Codex CLI binary missing, broken, or unusable."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodexProtocolError(CodexloopError):
|
|
27
|
+
"""Malformed or unexpected Codex protocol / event stream."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BudgetExceeded(CodexloopError):
|
|
31
|
+
"""Operator-configured budget or spend limit reached."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WaitDeadlineExceeded(CodexloopError):
|
|
35
|
+
"""Wait / probe loop hit the configured max-wait deadline."""
|
codexloop/domain/loop.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Pure run-loop state machine: (RunState, LoopOutcome, now) -> Decision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from codexloop.domain.budget import BudgetLedger
|
|
11
|
+
from codexloop.domain.capacity import (
|
|
12
|
+
AuthFailed,
|
|
13
|
+
Available,
|
|
14
|
+
CapacityState,
|
|
15
|
+
QuotaExhausted,
|
|
16
|
+
ThrottleExhausted,
|
|
17
|
+
TransientBackendError,
|
|
18
|
+
WindowExhausted,
|
|
19
|
+
)
|
|
20
|
+
from codexloop.domain.completion import Blocked, CompletionVerdict, Continue, Done
|
|
21
|
+
from codexloop.domain.control import ControlCommand, Stop
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RunState(StrEnum):
|
|
25
|
+
Preflight = "Preflight"
|
|
26
|
+
Running = "Running"
|
|
27
|
+
Evaluating = "Evaluating"
|
|
28
|
+
ThrottleBackoff = "ThrottleBackoff"
|
|
29
|
+
Waiting = "Waiting"
|
|
30
|
+
Probing = "Probing"
|
|
31
|
+
Stopping = "Stopping"
|
|
32
|
+
Complete = "Complete"
|
|
33
|
+
Failed = "Failed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class SendTurn:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Probe:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class WaitUntil:
|
|
48
|
+
until: datetime
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class BackoffUntil:
|
|
53
|
+
until: datetime
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Finish:
|
|
58
|
+
success: bool
|
|
59
|
+
reason: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Drain:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Decision = SendTurn | Probe | WaitUntil | BackoffUntil | Finish | Drain
|
|
68
|
+
|
|
69
|
+
_TERMINAL = frozenset({RunState.Complete, RunState.Failed})
|
|
70
|
+
_DEADLINE_STATES = frozenset(
|
|
71
|
+
{
|
|
72
|
+
RunState.Preflight,
|
|
73
|
+
RunState.Waiting,
|
|
74
|
+
RunState.Probing,
|
|
75
|
+
RunState.ThrottleBackoff,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
_WAKE_STATES = frozenset({RunState.Waiting, RunState.ThrottleBackoff})
|
|
79
|
+
_TURN_STATES = frozenset({RunState.Running, RunState.Evaluating})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class LoopOutcome:
|
|
84
|
+
"""Domain-side turn/probe result. Application TurnOutcome is not imported."""
|
|
85
|
+
|
|
86
|
+
capacity: CapacityState
|
|
87
|
+
completion: CompletionVerdict | None = None
|
|
88
|
+
deadline_exceeded: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RunLoopStateMachine:
|
|
92
|
+
"""Total function over run state, capacity, completion, budget, and controls."""
|
|
93
|
+
|
|
94
|
+
def advance(
|
|
95
|
+
self,
|
|
96
|
+
state: RunState,
|
|
97
|
+
outcome: LoopOutcome,
|
|
98
|
+
now: datetime,
|
|
99
|
+
ledger: BudgetLedger,
|
|
100
|
+
controls: Sequence[ControlCommand],
|
|
101
|
+
) -> tuple[RunState, Decision]:
|
|
102
|
+
if state in _TERMINAL:
|
|
103
|
+
return _stay_terminal(state)
|
|
104
|
+
|
|
105
|
+
if state is RunState.Stopping:
|
|
106
|
+
return RunState.Failed, Finish(success=False, reason="stop")
|
|
107
|
+
|
|
108
|
+
if _has_stop(controls):
|
|
109
|
+
return RunState.Stopping, Drain()
|
|
110
|
+
|
|
111
|
+
if outcome.deadline_exceeded and state in _DEADLINE_STATES:
|
|
112
|
+
return RunState.Failed, Finish(success=False, reason="max_wait")
|
|
113
|
+
|
|
114
|
+
return _route_capacity(state, outcome, now, ledger)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stay_terminal(state: RunState) -> tuple[RunState, Decision]:
|
|
118
|
+
if state is RunState.Complete:
|
|
119
|
+
return RunState.Complete, Finish(success=True, reason="done")
|
|
120
|
+
return RunState.Failed, Finish(success=False, reason="failed")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _has_stop(controls: Sequence[ControlCommand]) -> bool:
|
|
124
|
+
return any(isinstance(command, Stop) for command in controls)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _route_capacity(
|
|
128
|
+
state: RunState,
|
|
129
|
+
outcome: LoopOutcome,
|
|
130
|
+
now: datetime,
|
|
131
|
+
ledger: BudgetLedger,
|
|
132
|
+
) -> tuple[RunState, Decision]:
|
|
133
|
+
capacity = outcome.capacity
|
|
134
|
+
if isinstance(capacity, AuthFailed):
|
|
135
|
+
return RunState.Failed, Finish(success=False, reason="auth")
|
|
136
|
+
|
|
137
|
+
if state in _WAKE_STATES:
|
|
138
|
+
return RunState.Probing, Probe()
|
|
139
|
+
|
|
140
|
+
if isinstance(capacity, ThrottleExhausted):
|
|
141
|
+
return RunState.ThrottleBackoff, BackoffUntil(until=now)
|
|
142
|
+
|
|
143
|
+
if isinstance(capacity, (WindowExhausted, QuotaExhausted, TransientBackendError)):
|
|
144
|
+
return RunState.Waiting, WaitUntil(until=now)
|
|
145
|
+
|
|
146
|
+
if isinstance(capacity, Available):
|
|
147
|
+
return _route_available(state, outcome, ledger)
|
|
148
|
+
|
|
149
|
+
# Closed-union exhaustiveness: CapacityState is a finite ADT. This assert is
|
|
150
|
+
# a precondition on that union, not a security gate.
|
|
151
|
+
assert False, f"unhandled capacity state: {capacity!r}" # noqa: B011 # nosec B101 # pragma: no cover
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _route_available(
|
|
155
|
+
state: RunState,
|
|
156
|
+
outcome: LoopOutcome,
|
|
157
|
+
ledger: BudgetLedger,
|
|
158
|
+
) -> tuple[RunState, Decision]:
|
|
159
|
+
if state is RunState.Preflight or state is RunState.Probing:
|
|
160
|
+
return _send_turn_or_budget(ledger)
|
|
161
|
+
if state in _TURN_STATES:
|
|
162
|
+
return _evaluate_completion(outcome, ledger)
|
|
163
|
+
# Closed-union exhaustiveness: remaining RunState values are a finite ADT.
|
|
164
|
+
# This assert is a precondition on that union, not a security gate.
|
|
165
|
+
assert False, f"unhandled run state: {state!r}" # noqa: B011 # nosec B101 # pragma: no cover
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _send_turn_or_budget(ledger: BudgetLedger) -> tuple[RunState, Decision]:
|
|
169
|
+
exceeded = ledger.exceeded()
|
|
170
|
+
if exceeded is not None:
|
|
171
|
+
return RunState.Failed, Finish(success=False, reason=exceeded)
|
|
172
|
+
return RunState.Running, SendTurn()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _evaluate_completion(outcome: LoopOutcome, ledger: BudgetLedger) -> tuple[RunState, Decision]:
|
|
176
|
+
completion = outcome.completion
|
|
177
|
+
if isinstance(completion, Done):
|
|
178
|
+
return RunState.Complete, Finish(success=True, reason="done")
|
|
179
|
+
if isinstance(completion, Blocked):
|
|
180
|
+
return RunState.Failed, Finish(success=False, reason=completion.reason)
|
|
181
|
+
exceeded = ledger.exceeded()
|
|
182
|
+
if exceeded is not None:
|
|
183
|
+
return RunState.Failed, Finish(success=False, reason=exceeded)
|
|
184
|
+
if isinstance(completion, Continue):
|
|
185
|
+
return RunState.Running, SendTurn()
|
|
186
|
+
if completion is None:
|
|
187
|
+
return RunState.Evaluating, Probe()
|
|
188
|
+
# Closed-union exhaustiveness: CompletionVerdict | None is a finite ADT.
|
|
189
|
+
# This assert is a precondition on that union, not a security gate.
|
|
190
|
+
assert False, f"unhandled completion verdict: {completion!r}" # noqa: B011 # nosec B101 # pragma: no cover
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Model + reasoning-effort profile with low/medium/high presets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Effort(StrEnum):
|
|
10
|
+
LOW = "low"
|
|
11
|
+
MEDIUM = "medium"
|
|
12
|
+
HIGH = "high"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ModelEffortProfile:
|
|
17
|
+
model: str
|
|
18
|
+
effort: Effort
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def low(cls, model: str) -> ModelEffortProfile:
|
|
22
|
+
return cls(model=model, effort=Effort.LOW)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def medium(cls, model: str) -> ModelEffortProfile:
|
|
26
|
+
return cls(model=model, effort=Effort.MEDIUM)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def high(cls, model: str) -> ModelEffortProfile:
|
|
30
|
+
return cls(model=model, effort=Effort.HIGH)
|
codexloop/domain/plan.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Markdown work-plan parsing. Checkboxes are the load-bearing items."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from codexloop.domain.errors import ConfigurationError
|
|
9
|
+
|
|
10
|
+
_CHECKBOX = re.compile(r"^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+(\S.*)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class PlanItem:
|
|
15
|
+
name: str
|
|
16
|
+
done: bool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class WorkPlan:
|
|
21
|
+
items: tuple[PlanItem, ...]
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def parse(cls, text: str) -> WorkPlan:
|
|
25
|
+
items: list[PlanItem] = []
|
|
26
|
+
for line in text.splitlines():
|
|
27
|
+
matched = _CHECKBOX.match(line.rstrip())
|
|
28
|
+
if matched is None:
|
|
29
|
+
continue
|
|
30
|
+
marker, name = matched.group(1), matched.group(2).strip()
|
|
31
|
+
items.append(PlanItem(name=name, done=marker in "xX"))
|
|
32
|
+
if not items:
|
|
33
|
+
raise ConfigurationError("work plan has no checkbox items")
|
|
34
|
+
return cls(items=tuple(items))
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def remaining_work(self) -> tuple[str, ...]:
|
|
38
|
+
return tuple(item.name for item in self.items if not item.done)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Git save-point value objects — migration-like snapshots of the worktree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class SavePointRef:
|
|
11
|
+
n: int
|
|
12
|
+
ref: str
|
|
13
|
+
sha: str
|
|
14
|
+
label: str
|
|
15
|
+
at: datetime
|
|
16
|
+
plan_item: str | None = None
|
|
17
|
+
committed: bool = False
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if self.n < 1:
|
|
21
|
+
raise ValueError("save point number must be >= 1")
|
|
22
|
+
if not self.ref.strip():
|
|
23
|
+
raise ValueError("save point ref must not be blank")
|
|
24
|
+
if not self.sha.strip():
|
|
25
|
+
raise ValueError("save point sha must not be blank")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class UnwindResult:
|
|
30
|
+
to: SavePointRef
|
|
31
|
+
backup_ref: str | None
|
|
32
|
+
restored_sha: str
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pure formatting for git savepoint commit subjects and bodies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_SUBJECT_MAX = 72
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_savepoint_commit_message(
|
|
9
|
+
*,
|
|
10
|
+
run_id: str,
|
|
11
|
+
attempt: int,
|
|
12
|
+
verdict_name: str,
|
|
13
|
+
summary: str,
|
|
14
|
+
remaining_work: tuple[str, ...] = (),
|
|
15
|
+
changed_paths: tuple[str, ...] = (),
|
|
16
|
+
label: str = "",
|
|
17
|
+
) -> tuple[str, str]:
|
|
18
|
+
"""Return (subject, body) using Conventional Commits.
|
|
19
|
+
|
|
20
|
+
Subject: ``chore(codexloop): turn {n} — {headline}``
|
|
21
|
+
"""
|
|
22
|
+
headline = _headline(summary=summary, changed_paths=changed_paths)
|
|
23
|
+
subject = f"chore(codexloop): turn {attempt} — {headline}"
|
|
24
|
+
if len(subject) > _SUBJECT_MAX:
|
|
25
|
+
subject = subject[: _SUBJECT_MAX - 1].rstrip() + "…"
|
|
26
|
+
|
|
27
|
+
remaining_lines = (
|
|
28
|
+
"\n".join(f"- {item}" for item in remaining_work) if remaining_work else "- (none)"
|
|
29
|
+
)
|
|
30
|
+
path_lines = "\n".join(f"- {path}" for path in changed_paths) if changed_paths else "- (none)"
|
|
31
|
+
summary_block = summary.strip() if summary.strip() else "(none)"
|
|
32
|
+
body = (
|
|
33
|
+
f"Run: {run_id}\n"
|
|
34
|
+
f"Attempt: {attempt}\n"
|
|
35
|
+
f"Verdict: {verdict_name}\n"
|
|
36
|
+
f"Label: {label}\n"
|
|
37
|
+
f"\n"
|
|
38
|
+
f"Summary:\n{summary_block}\n"
|
|
39
|
+
f"\n"
|
|
40
|
+
f"Remaining work:\n{remaining_lines}\n"
|
|
41
|
+
f"\n"
|
|
42
|
+
f"Changed paths:\n{path_lines}\n"
|
|
43
|
+
)
|
|
44
|
+
return subject, body
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _headline(*, summary: str, changed_paths: tuple[str, ...]) -> str:
|
|
48
|
+
for line in summary.splitlines():
|
|
49
|
+
cleaned = line.strip()
|
|
50
|
+
if cleaned:
|
|
51
|
+
return cleaned
|
|
52
|
+
if changed_paths:
|
|
53
|
+
name = changed_paths[0].rstrip("/").rsplit("/", 1)[-1]
|
|
54
|
+
if name:
|
|
55
|
+
return name
|
|
56
|
+
return "workspace checkpoint"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Thread identity and how a run selects a session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class ThreadRef:
|
|
11
|
+
thread_id: str
|
|
12
|
+
cwd: str
|
|
13
|
+
started_at: datetime
|
|
14
|
+
model: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class PlanFile:
|
|
19
|
+
path: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class MostRecent:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Explicit:
|
|
29
|
+
thread_id: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
SessionSelector = PlanFile | MostRecent | Explicit
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Turn-level signals consumed by capacity classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from codexloop.domain.capacity import PlanWindows
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class TurnSignals:
|
|
12
|
+
"""Bundle of independent turn signals assembled at the infrastructure edge."""
|
|
13
|
+
|
|
14
|
+
error_code: str | None = None
|
|
15
|
+
error_type: str | None = None
|
|
16
|
+
http_status: int | None = None
|
|
17
|
+
retry_after_s: float | None = None
|
|
18
|
+
plan_windows: PlanWindows | None = None
|
|
19
|
+
completed: bool = False
|
|
20
|
+
failed: bool = False
|
|
21
|
+
final_message: str | None = None
|
|
22
|
+
structured_output: object | None = None
|
|
23
|
+
usage: object | None = None
|
|
24
|
+
exit_code: int | None = None
|
|
25
|
+
stderr_tail: str | None = None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Adaptive wait policy: the next instant to probe, never a blind sleep."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from random import random
|
|
9
|
+
from typing import assert_never
|
|
10
|
+
|
|
11
|
+
from codexloop.domain.backoff import backoff
|
|
12
|
+
from codexloop.domain.capacity import (
|
|
13
|
+
AuthFailed,
|
|
14
|
+
Available,
|
|
15
|
+
CapacityState,
|
|
16
|
+
QuotaExhausted,
|
|
17
|
+
ThrottleExhausted,
|
|
18
|
+
TransientBackendError,
|
|
19
|
+
WindowExhausted,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class WaitConfig:
|
|
25
|
+
"""Cadence knobs for :class:`AdaptiveWaitPolicy`.
|
|
26
|
+
|
|
27
|
+
Defaults match the waiting table: throttle 60s, aggressive 300s, transient
|
|
28
|
+
120s, unknown-window / quota probe 120s → 600s.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
grace: timedelta = timedelta(seconds=2)
|
|
32
|
+
window_probe_interval: timedelta = timedelta(seconds=60)
|
|
33
|
+
quota_probe_base: timedelta = timedelta(seconds=120)
|
|
34
|
+
quota_probe_ceiling: timedelta = timedelta(seconds=600)
|
|
35
|
+
throttle_ceiling: timedelta = timedelta(seconds=60)
|
|
36
|
+
aggressive_ceiling: timedelta = timedelta(seconds=300)
|
|
37
|
+
transient_ceiling: timedelta = timedelta(seconds=120)
|
|
38
|
+
jitter_ratio: float = 0.1
|
|
39
|
+
backoff_base: timedelta = timedelta(seconds=1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AdaptiveWaitPolicy:
|
|
43
|
+
"""Map a capacity state to the next probe instant."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: WaitConfig, *, rand: Callable[[], float] = random) -> None:
|
|
46
|
+
self._config = config
|
|
47
|
+
self._rand = rand
|
|
48
|
+
|
|
49
|
+
def next_probe_at(
|
|
50
|
+
self,
|
|
51
|
+
state: CapacityState,
|
|
52
|
+
now: datetime,
|
|
53
|
+
attempt: int,
|
|
54
|
+
deadline: datetime,
|
|
55
|
+
) -> datetime:
|
|
56
|
+
match state:
|
|
57
|
+
case Available():
|
|
58
|
+
return now
|
|
59
|
+
case AuthFailed():
|
|
60
|
+
return deadline
|
|
61
|
+
case ThrottleExhausted() as throttle:
|
|
62
|
+
return _clamp(now + self._throttle_delay(throttle, attempt), now, deadline)
|
|
63
|
+
case TransientBackendError():
|
|
64
|
+
delay = self._capped_backoff(attempt, self._config.transient_ceiling)
|
|
65
|
+
return _clamp(now + delay, now, deadline)
|
|
66
|
+
case WindowExhausted() as window:
|
|
67
|
+
return _clamp(self._window_instant(window, now, attempt), now, deadline)
|
|
68
|
+
case QuotaExhausted():
|
|
69
|
+
delay = self._quota_delay(attempt)
|
|
70
|
+
return _clamp(now + delay, now, deadline)
|
|
71
|
+
case _: # pragma: no cover — match is exhaustive over CapacityState
|
|
72
|
+
assert_never(state)
|
|
73
|
+
|
|
74
|
+
def _throttle_delay(self, state: ThrottleExhausted, attempt: int) -> timedelta:
|
|
75
|
+
ceiling = (
|
|
76
|
+
self._config.aggressive_ceiling if state.aggressive else self._config.throttle_ceiling
|
|
77
|
+
)
|
|
78
|
+
delay = self._capped_backoff(attempt, ceiling)
|
|
79
|
+
if state.retry_after is not None:
|
|
80
|
+
# Retry-After is a minimum; add a small random delay so clients do not
|
|
81
|
+
# wake together when the header is the binding wait (R2).
|
|
82
|
+
retry_s = max(state.retry_after.total_seconds(), 0.0)
|
|
83
|
+
spread_s = retry_s * self._config.jitter_ratio * self._rand()
|
|
84
|
+
delay = max(delay, timedelta(seconds=retry_s + spread_s))
|
|
85
|
+
return min(delay, ceiling)
|
|
86
|
+
|
|
87
|
+
def _window_instant(self, state: WindowExhausted, now: datetime, attempt: int) -> datetime:
|
|
88
|
+
if state.resets_at is None:
|
|
89
|
+
return now + self._quota_delay(attempt)
|
|
90
|
+
target = state.resets_at + self._config.grace
|
|
91
|
+
interval_wake = now + self._config.window_probe_interval
|
|
92
|
+
if target <= now:
|
|
93
|
+
return interval_wake
|
|
94
|
+
return min(target, interval_wake)
|
|
95
|
+
|
|
96
|
+
def _quota_delay(self, attempt: int) -> timedelta:
|
|
97
|
+
return self._capped_backoff(
|
|
98
|
+
attempt,
|
|
99
|
+
self._config.quota_probe_ceiling,
|
|
100
|
+
base=self._config.quota_probe_base,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _capped_backoff(
|
|
104
|
+
self,
|
|
105
|
+
attempt: int,
|
|
106
|
+
ceiling: timedelta,
|
|
107
|
+
*,
|
|
108
|
+
base: timedelta | None = None,
|
|
109
|
+
) -> timedelta:
|
|
110
|
+
return backoff(
|
|
111
|
+
attempt,
|
|
112
|
+
base=self._config.backoff_base if base is None else base,
|
|
113
|
+
ceiling=ceiling,
|
|
114
|
+
jitter_ratio=self._config.jitter_ratio,
|
|
115
|
+
rand=self._rand,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _clamp(instant: datetime, now: datetime, deadline: datetime) -> datetime:
|
|
120
|
+
return min(deadline, max(now, instant))
|
|
File without changes
|
|
File without changes
|