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.
Files changed (104) hide show
  1. codexloop/__init__.py +3 -0
  2. codexloop/application/__init__.py +1 -0
  3. codexloop/application/dto.py +41 -0
  4. codexloop/application/ports.py +158 -0
  5. codexloop/application/runner.py +467 -0
  6. codexloop/application/usecases/__init__.py +1 -0
  7. codexloop/application/usecases/doctor.py +37 -0
  8. codexloop/application/usecases/list_threads.py +14 -0
  9. codexloop/application/usecases/preflight.py +10 -0
  10. codexloop/application/usecases/resume_thread.py +11 -0
  11. codexloop/application/usecases/run_control.py +20 -0
  12. codexloop/application/usecases/run_plan.py +11 -0
  13. codexloop/bootstrap.py +461 -0
  14. codexloop/cli/__init__.py +1 -0
  15. codexloop/cli/app.py +94 -0
  16. codexloop/cli/asyncio.py +80 -0
  17. codexloop/cli/commands/__init__.py +1 -0
  18. codexloop/cli/commands/approval_cmd.py +24 -0
  19. codexloop/cli/commands/capacity.py +33 -0
  20. codexloop/cli/commands/cwd_cmd.py +22 -0
  21. codexloop/cli/commands/doctor.py +27 -0
  22. codexloop/cli/commands/effort_cmd.py +24 -0
  23. codexloop/cli/commands/logs.py +15 -0
  24. codexloop/cli/commands/model_cmd.py +22 -0
  25. codexloop/cli/commands/prompt.py +32 -0
  26. codexloop/cli/commands/reset.py +25 -0
  27. codexloop/cli/commands/resume.py +38 -0
  28. codexloop/cli/commands/run.py +50 -0
  29. codexloop/cli/commands/runs.py +13 -0
  30. codexloop/cli/commands/sandbox_cmd.py +24 -0
  31. codexloop/cli/commands/savepoints.py +25 -0
  32. codexloop/cli/commands/snapshot.py +26 -0
  33. codexloop/cli/commands/status.py +15 -0
  34. codexloop/cli/commands/stop.py +21 -0
  35. codexloop/cli/commands/threads.py +15 -0
  36. codexloop/cli/commands/unwind.py +24 -0
  37. codexloop/cli/commands/watch.py +66 -0
  38. codexloop/cli/render.py +39 -0
  39. codexloop/domain/__init__.py +26 -0
  40. codexloop/domain/approval.py +38 -0
  41. codexloop/domain/backoff.py +34 -0
  42. codexloop/domain/budget.py +56 -0
  43. codexloop/domain/capacity.py +81 -0
  44. codexloop/domain/classify.py +98 -0
  45. codexloop/domain/completion.py +130 -0
  46. codexloop/domain/control.py +167 -0
  47. codexloop/domain/error_codes.py +75 -0
  48. codexloop/domain/errors.py +35 -0
  49. codexloop/domain/loop.py +190 -0
  50. codexloop/domain/model_profile.py +30 -0
  51. codexloop/domain/plan.py +38 -0
  52. codexloop/domain/savepoint.py +32 -0
  53. codexloop/domain/savepoint_message.py +56 -0
  54. codexloop/domain/session.py +32 -0
  55. codexloop/domain/signals.py +25 -0
  56. codexloop/domain/waiting.py +120 -0
  57. codexloop/infrastructure/__init__.py +0 -0
  58. codexloop/infrastructure/agent/__init__.py +0 -0
  59. codexloop/infrastructure/agent/argv.py +102 -0
  60. codexloop/infrastructure/agent/events.py +274 -0
  61. codexloop/infrastructure/agent/gateway.py +189 -0
  62. codexloop/infrastructure/agent/probe.py +74 -0
  63. codexloop/infrastructure/agent/process.py +208 -0
  64. codexloop/infrastructure/agent/schema.py +31 -0
  65. codexloop/infrastructure/agent/scripted.py +201 -0
  66. codexloop/infrastructure/agent/translate.py +120 -0
  67. codexloop/infrastructure/api/__init__.py +26 -0
  68. codexloop/infrastructure/api/api_baseline.json +340 -0
  69. codexloop/infrastructure/api/binder.py +170 -0
  70. codexloop/infrastructure/api/gateway.py +142 -0
  71. codexloop/infrastructure/api/introspect.py +248 -0
  72. codexloop/infrastructure/api/json_io.py +26 -0
  73. codexloop/infrastructure/api/params.py +162 -0
  74. codexloop/infrastructure/api/providers.py +70 -0
  75. codexloop/infrastructure/api/registry.py +13 -0
  76. codexloop/infrastructure/appserver/__init__.py +6 -0
  77. codexloop/infrastructure/appserver/client.py +245 -0
  78. codexloop/infrastructure/appserver/gateway.py +437 -0
  79. codexloop/infrastructure/appserver/ratelimits.py +100 -0
  80. codexloop/infrastructure/audit.py +26 -0
  81. codexloop/infrastructure/capacity_probe.py +57 -0
  82. codexloop/infrastructure/clock.py +26 -0
  83. codexloop/infrastructure/config.py +150 -0
  84. codexloop/infrastructure/control.py +89 -0
  85. codexloop/infrastructure/doctor_env.py +239 -0
  86. codexloop/infrastructure/events.py +23 -0
  87. codexloop/infrastructure/git_savepoints.py +176 -0
  88. codexloop/infrastructure/lock.py +88 -0
  89. codexloop/infrastructure/logging.py +124 -0
  90. codexloop/infrastructure/notify.py +27 -0
  91. codexloop/infrastructure/progress.py +14 -0
  92. codexloop/infrastructure/redact.py +52 -0
  93. codexloop/infrastructure/rollout.py +113 -0
  94. codexloop/infrastructure/rundir.py +57 -0
  95. codexloop/infrastructure/snapshot.py +39 -0
  96. codexloop/infrastructure/state.py +32 -0
  97. codexloop/infrastructure/state_bus.py +27 -0
  98. codexloop/infrastructure/stream_ui.py +44 -0
  99. codexloop/py.typed +0 -0
  100. codexloop-0.1.0.dist-info/METADATA +104 -0
  101. codexloop-0.1.0.dist-info/RECORD +104 -0
  102. codexloop-0.1.0.dist-info/WHEEL +4 -0
  103. codexloop-0.1.0.dist-info/entry_points.txt +2 -0
  104. 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."""
@@ -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)
@@ -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