codex-flow 2.1.13__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.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Durable task-level budget ledger for FlowPilot.
|
|
3
|
+
|
|
4
|
+
General implementation reservations close at the task soft deadline. Read-only
|
|
5
|
+
`review_attempt` reservations may use a stricter phase-owned admission deadline.
|
|
6
|
+
All reservation deadline gates run only after exact idempotent replay detection,
|
|
7
|
+
so a committed reservation can always be acknowledged without creating work.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import asdict
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Iterator
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from .base import TaskBudgetPolicy
|
|
28
|
+
except ImportError:
|
|
29
|
+
from base import TaskBudgetPolicy
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = 2
|
|
32
|
+
GENERAL_RESERVATION_KINDS = (
|
|
33
|
+
"work_unit",
|
|
34
|
+
"implementation_attempt",
|
|
35
|
+
"replan",
|
|
36
|
+
"replacement",
|
|
37
|
+
)
|
|
38
|
+
REQUIRED_COMPLETION_RESERVATION_KINDS = ("review_attempt",)
|
|
39
|
+
RESERVATION_KINDS = GENERAL_RESERVATION_KINDS + REQUIRED_COMPLETION_RESERVATION_KINDS
|
|
40
|
+
POLICY_LIMIT_FIELDS = {
|
|
41
|
+
"work_unit": "max_work_units",
|
|
42
|
+
"implementation_attempt": "max_implementation_attempts",
|
|
43
|
+
"replan": "max_replans",
|
|
44
|
+
"replacement": "max_replacements",
|
|
45
|
+
"review_attempt": "max_review_attempts",
|
|
46
|
+
}
|
|
47
|
+
STATE_FIELDS = {
|
|
48
|
+
"schema_version",
|
|
49
|
+
"task_id",
|
|
50
|
+
"policy_fingerprint",
|
|
51
|
+
"started_at",
|
|
52
|
+
"soft_deadline",
|
|
53
|
+
"hard_deadline",
|
|
54
|
+
"closed",
|
|
55
|
+
"outcome",
|
|
56
|
+
"limits",
|
|
57
|
+
"reservations",
|
|
58
|
+
}
|
|
59
|
+
RESERVATION_FIELDS = {"reservation_id", "fingerprint", "reserved_at"}
|
|
60
|
+
HASH_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
61
|
+
SAFE_OUTCOME_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
|
|
62
|
+
EPOCH_MILLISECONDS_THRESHOLD = 100_000_000_000
|
|
63
|
+
LOCK_TIMEOUT_SECONDS = 10.0
|
|
64
|
+
LOCK_RETRY_SECONDS = 0.025
|
|
65
|
+
STALE_LOCK_SECONDS = 30.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LedgerError(ValueError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sha256(value: str) -> str:
|
|
73
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _strict_string(value: Any, label: str) -> str:
|
|
77
|
+
if type(value) is not str:
|
|
78
|
+
raise LedgerError(f"{label} must be a string")
|
|
79
|
+
result = value.strip()
|
|
80
|
+
if not result:
|
|
81
|
+
raise LedgerError(f"{label} must be non-empty")
|
|
82
|
+
if "\x00" in result:
|
|
83
|
+
raise LedgerError(f"{label} must not contain NUL")
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _seconds(value: Any, label: str) -> float:
|
|
88
|
+
if isinstance(value, bool):
|
|
89
|
+
raise LedgerError(f"{label} must be finite seconds")
|
|
90
|
+
try:
|
|
91
|
+
number = float(value)
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
raise LedgerError(f"{label} must be finite seconds") from None
|
|
94
|
+
if not math.isfinite(number):
|
|
95
|
+
raise LedgerError(f"{label} must be finite seconds")
|
|
96
|
+
if number < 0:
|
|
97
|
+
raise LedgerError(f"{label} cannot be negative")
|
|
98
|
+
if number > EPOCH_MILLISECONDS_THRESHOLD:
|
|
99
|
+
raise LedgerError(f"{label} must use Unix seconds, not milliseconds")
|
|
100
|
+
return number
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _now(value: Any) -> float:
|
|
104
|
+
return _seconds(value, "now")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _policy(value: Any) -> TaskBudgetPolicy:
|
|
108
|
+
try:
|
|
109
|
+
return TaskBudgetPolicy.from_dict(value)
|
|
110
|
+
except (TypeError, ValueError) as exc:
|
|
111
|
+
raise LedgerError(str(exc)) from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _policy_fingerprint(policy: TaskBudgetPolicy) -> str:
|
|
115
|
+
encoded = json.dumps(
|
|
116
|
+
asdict(policy),
|
|
117
|
+
ensure_ascii=False,
|
|
118
|
+
sort_keys=True,
|
|
119
|
+
separators=(",", ":"),
|
|
120
|
+
).encode("utf-8")
|
|
121
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _task_hash(task_id: Any) -> str:
|
|
125
|
+
return _sha256(_strict_string(task_id, "task_id"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _reservation_hash(value: Any, label: str = "reservation_id") -> str:
|
|
129
|
+
return _sha256(_strict_string(value, label))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _fingerprint_hash(value: Any) -> str:
|
|
133
|
+
return _sha256(_strict_string(value, "fingerprint"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _state_path(value: Any) -> Path:
|
|
137
|
+
raw = _strict_string(value, "state_file")
|
|
138
|
+
path = Path(raw)
|
|
139
|
+
return path if path.is_absolute() else Path.cwd() / path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _reject_symlink(path: Path, label: str) -> None:
|
|
143
|
+
try:
|
|
144
|
+
mode = os.lstat(path).st_mode
|
|
145
|
+
except FileNotFoundError:
|
|
146
|
+
return
|
|
147
|
+
except OSError as exc:
|
|
148
|
+
raise LedgerError(f"cannot inspect {label}: {exc}") from None
|
|
149
|
+
if stat.S_ISLNK(mode):
|
|
150
|
+
raise LedgerError(f"{label} must not be a symlink")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _ensure_parent(path: Path) -> None:
|
|
154
|
+
try:
|
|
155
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
raise LedgerError(f"cannot create state parent: {exc}") from None
|
|
158
|
+
if not path.parent.is_dir():
|
|
159
|
+
raise LedgerError("state-file parent is not a directory")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _ensure_regular_state(path: Path) -> None:
|
|
163
|
+
_reject_symlink(path, "state file")
|
|
164
|
+
try:
|
|
165
|
+
mode = os.lstat(path).st_mode
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
return
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
raise LedgerError(f"cannot inspect state file: {exc}") from None
|
|
170
|
+
if not stat.S_ISREG(mode):
|
|
171
|
+
raise LedgerError("state file must be a regular file")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _lock_path(path: Path) -> Path:
|
|
175
|
+
return Path(f"{path}.lock")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _lock_is_stale(path: Path) -> bool:
|
|
179
|
+
try:
|
|
180
|
+
info = os.lstat(path)
|
|
181
|
+
except FileNotFoundError:
|
|
182
|
+
return False
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
raise LedgerError(f"cannot inspect lock file: {exc}") from None
|
|
185
|
+
if stat.S_ISLNK(info.st_mode):
|
|
186
|
+
raise LedgerError("lock file must not be a symlink")
|
|
187
|
+
if not stat.S_ISREG(info.st_mode):
|
|
188
|
+
raise LedgerError("lock file must be a regular file")
|
|
189
|
+
return max(0.0, time.time() - info.st_mtime) > STALE_LOCK_SECONDS
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@contextmanager
|
|
193
|
+
def _ledger_lock(path: Path) -> Iterator[None]:
|
|
194
|
+
_ensure_parent(path)
|
|
195
|
+
_ensure_regular_state(path)
|
|
196
|
+
lock = _lock_path(path)
|
|
197
|
+
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
|
|
198
|
+
while True:
|
|
199
|
+
_reject_symlink(lock, "lock file")
|
|
200
|
+
try:
|
|
201
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
202
|
+
if hasattr(os, "O_BINARY"):
|
|
203
|
+
flags |= os.O_BINARY
|
|
204
|
+
fd = os.open(lock, flags, 0o600)
|
|
205
|
+
try:
|
|
206
|
+
os.write(fd, f"pid={os.getpid()}\n".encode("ascii"))
|
|
207
|
+
os.fsync(fd)
|
|
208
|
+
finally:
|
|
209
|
+
os.close(fd)
|
|
210
|
+
break
|
|
211
|
+
except FileExistsError:
|
|
212
|
+
if _lock_is_stale(lock):
|
|
213
|
+
_reject_symlink(lock, "lock file")
|
|
214
|
+
try:
|
|
215
|
+
os.unlink(lock)
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
pass
|
|
218
|
+
continue
|
|
219
|
+
if time.monotonic() >= deadline:
|
|
220
|
+
raise LedgerError("timed out waiting for task budget lock")
|
|
221
|
+
time.sleep(LOCK_RETRY_SECONDS)
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
raise LedgerError(f"cannot acquire task budget lock: {exc}") from None
|
|
224
|
+
try:
|
|
225
|
+
yield
|
|
226
|
+
finally:
|
|
227
|
+
_reject_symlink(lock, "lock file")
|
|
228
|
+
try:
|
|
229
|
+
os.unlink(lock)
|
|
230
|
+
except FileNotFoundError:
|
|
231
|
+
pass
|
|
232
|
+
except OSError as exc:
|
|
233
|
+
raise LedgerError(f"cannot remove task budget lock: {exc}") from None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _atomic_write(path: Path, data: dict[str, Any]) -> None:
|
|
237
|
+
_reject_symlink(path, "state file")
|
|
238
|
+
encoded = (json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
|
239
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
240
|
+
fd: int | None = None
|
|
241
|
+
try:
|
|
242
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
243
|
+
if hasattr(os, "O_BINARY"):
|
|
244
|
+
flags |= os.O_BINARY
|
|
245
|
+
fd = os.open(temporary, flags, 0o600)
|
|
246
|
+
offset = 0
|
|
247
|
+
while offset < len(encoded):
|
|
248
|
+
written = os.write(fd, encoded[offset:])
|
|
249
|
+
if written <= 0:
|
|
250
|
+
raise OSError("short write")
|
|
251
|
+
offset += written
|
|
252
|
+
os.fsync(fd)
|
|
253
|
+
os.close(fd)
|
|
254
|
+
fd = None
|
|
255
|
+
_reject_symlink(path, "state file")
|
|
256
|
+
os.replace(temporary, path)
|
|
257
|
+
try:
|
|
258
|
+
os.chmod(path, 0o600)
|
|
259
|
+
except OSError:
|
|
260
|
+
pass
|
|
261
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
262
|
+
try:
|
|
263
|
+
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
264
|
+
except OSError:
|
|
265
|
+
parent_fd = None
|
|
266
|
+
if parent_fd is not None:
|
|
267
|
+
try:
|
|
268
|
+
os.fsync(parent_fd)
|
|
269
|
+
finally:
|
|
270
|
+
os.close(parent_fd)
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
raise LedgerError(f"cannot write task budget state: {exc}") from None
|
|
273
|
+
finally:
|
|
274
|
+
if fd is not None:
|
|
275
|
+
try:
|
|
276
|
+
os.close(fd)
|
|
277
|
+
except OSError:
|
|
278
|
+
pass
|
|
279
|
+
try:
|
|
280
|
+
os.unlink(temporary)
|
|
281
|
+
except FileNotFoundError:
|
|
282
|
+
pass
|
|
283
|
+
except OSError:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _read_state(path: Path) -> dict[str, Any]:
|
|
288
|
+
_ensure_regular_state(path)
|
|
289
|
+
try:
|
|
290
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
291
|
+
except FileNotFoundError:
|
|
292
|
+
raise LedgerError("task budget state does not exist; run init first") from None
|
|
293
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
294
|
+
raise LedgerError(f"invalid task budget state: {exc}") from None
|
|
295
|
+
_validate_state(value)
|
|
296
|
+
return value
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _validate_hash(value: Any, label: str) -> str:
|
|
300
|
+
if type(value) is not str or not HASH_RE.fullmatch(value):
|
|
301
|
+
raise LedgerError(f"invalid {label}")
|
|
302
|
+
return value
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _validate_state(value: Any) -> None:
|
|
306
|
+
if type(value) is not dict:
|
|
307
|
+
raise LedgerError("task budget state must be an object")
|
|
308
|
+
if set(value) != STATE_FIELDS:
|
|
309
|
+
missing = sorted(STATE_FIELDS.difference(value))
|
|
310
|
+
unknown = sorted(set(value).difference(STATE_FIELDS))
|
|
311
|
+
details = []
|
|
312
|
+
if missing:
|
|
313
|
+
details.append(f"missing fields: {missing}")
|
|
314
|
+
if unknown:
|
|
315
|
+
details.append(f"unknown fields: {unknown}")
|
|
316
|
+
raise LedgerError("invalid task budget state (" + "; ".join(details) + ")")
|
|
317
|
+
if type(value["schema_version"]) is not int or value["schema_version"] != SCHEMA_VERSION:
|
|
318
|
+
raise LedgerError("unsupported task budget state schema")
|
|
319
|
+
_validate_hash(value["task_id"], "task id hash")
|
|
320
|
+
_validate_hash(value["policy_fingerprint"], "policy fingerprint")
|
|
321
|
+
started = _seconds(value["started_at"], "started_at")
|
|
322
|
+
soft = _seconds(value["soft_deadline"], "soft_deadline")
|
|
323
|
+
hard = _seconds(value["hard_deadline"], "hard_deadline")
|
|
324
|
+
if soft <= started:
|
|
325
|
+
raise LedgerError("soft_deadline must be after started_at")
|
|
326
|
+
if hard <= soft:
|
|
327
|
+
raise LedgerError("hard_deadline must be after soft_deadline")
|
|
328
|
+
if type(value["closed"]) is not bool:
|
|
329
|
+
raise LedgerError("closed must be boolean")
|
|
330
|
+
outcome = value["outcome"]
|
|
331
|
+
if outcome is not None and (type(outcome) is not str or not SAFE_OUTCOME_RE.fullmatch(outcome)):
|
|
332
|
+
raise LedgerError("outcome must be a short status token or null")
|
|
333
|
+
if value["closed"] and outcome is None:
|
|
334
|
+
raise LedgerError("closed state requires an outcome")
|
|
335
|
+
if not value["closed"] and outcome is not None:
|
|
336
|
+
raise LedgerError("open state cannot have an outcome")
|
|
337
|
+
|
|
338
|
+
limits = value["limits"]
|
|
339
|
+
if type(limits) is not dict or set(limits) != set(RESERVATION_KINDS):
|
|
340
|
+
raise LedgerError("invalid task budget limits")
|
|
341
|
+
for kind in RESERVATION_KINDS:
|
|
342
|
+
limit = limits[kind]
|
|
343
|
+
if type(limit) is not int or limit < 0:
|
|
344
|
+
raise LedgerError(f"invalid limit for {kind}")
|
|
345
|
+
if kind in {"work_unit", "implementation_attempt"} and limit == 0:
|
|
346
|
+
raise LedgerError(f"limit for {kind} must be positive")
|
|
347
|
+
|
|
348
|
+
reservations = value["reservations"]
|
|
349
|
+
if type(reservations) is not dict or set(reservations) != set(RESERVATION_KINDS):
|
|
350
|
+
raise LedgerError("invalid task budget reservations")
|
|
351
|
+
for kind in RESERVATION_KINDS:
|
|
352
|
+
entries = reservations[kind]
|
|
353
|
+
if type(entries) is not list:
|
|
354
|
+
raise LedgerError(f"reservations.{kind} must be a list")
|
|
355
|
+
if len(entries) > limits[kind]:
|
|
356
|
+
raise LedgerError(f"reservations.{kind} exceeds its limit")
|
|
357
|
+
seen: set[str] = set()
|
|
358
|
+
latest = started
|
|
359
|
+
for entry in entries:
|
|
360
|
+
if type(entry) is not dict or set(entry) != RESERVATION_FIELDS:
|
|
361
|
+
raise LedgerError(f"invalid {kind} reservation")
|
|
362
|
+
reservation_id = _validate_hash(entry["reservation_id"], f"{kind} reservation id")
|
|
363
|
+
_validate_hash(entry["fingerprint"], f"{kind} fingerprint")
|
|
364
|
+
if reservation_id in seen:
|
|
365
|
+
raise LedgerError(f"duplicate {kind} reservation id")
|
|
366
|
+
seen.add(reservation_id)
|
|
367
|
+
reserved_at = _seconds(entry["reserved_at"], f"{kind}.reserved_at")
|
|
368
|
+
if reserved_at < started:
|
|
369
|
+
raise LedgerError(f"{kind}.reserved_at cannot precede started_at")
|
|
370
|
+
if reserved_at < latest:
|
|
371
|
+
raise LedgerError("reservation timestamps must be non-decreasing")
|
|
372
|
+
latest = reserved_at
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _latest_timestamp(state: dict[str, Any]) -> float:
|
|
376
|
+
latest = _seconds(state["started_at"], "started_at")
|
|
377
|
+
for kind in RESERVATION_KINDS:
|
|
378
|
+
for entry in state["reservations"][kind]:
|
|
379
|
+
latest = max(latest, _seconds(entry["reserved_at"], "reserved_at"))
|
|
380
|
+
return latest
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _check_now(state: dict[str, Any], now: float) -> None:
|
|
384
|
+
if now < _latest_timestamp(state):
|
|
385
|
+
raise LedgerError("now must be non-decreasing")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _new_state(task_hash: str, policy: TaskBudgetPolicy, now: float) -> dict[str, Any]:
|
|
389
|
+
limits = {kind: getattr(policy, POLICY_LIMIT_FIELDS[kind]) for kind in RESERVATION_KINDS}
|
|
390
|
+
return {
|
|
391
|
+
"schema_version": SCHEMA_VERSION,
|
|
392
|
+
"task_id": task_hash,
|
|
393
|
+
"policy_fingerprint": _policy_fingerprint(policy),
|
|
394
|
+
"started_at": now,
|
|
395
|
+
"soft_deadline": now + policy.soft_timeout_seconds,
|
|
396
|
+
"hard_deadline": now + policy.hard_timeout_seconds,
|
|
397
|
+
"closed": False,
|
|
398
|
+
"outcome": None,
|
|
399
|
+
"limits": limits,
|
|
400
|
+
"reservations": {kind: [] for kind in RESERVATION_KINDS},
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _counters(state: dict[str, Any]) -> dict[str, int]:
|
|
405
|
+
return {kind: len(state["reservations"][kind]) for kind in RESERVATION_KINDS}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _status(state: dict[str, Any], now: float) -> dict[str, Any]:
|
|
409
|
+
_check_now(state, now)
|
|
410
|
+
closed = state["closed"]
|
|
411
|
+
if closed:
|
|
412
|
+
action = "stop"
|
|
413
|
+
elif now < state["soft_deadline"]:
|
|
414
|
+
action = "continue"
|
|
415
|
+
elif now < state["hard_deadline"]:
|
|
416
|
+
action = "converge"
|
|
417
|
+
else:
|
|
418
|
+
action = "stop"
|
|
419
|
+
remaining_hard = max(0.0, float(state["hard_deadline"]) - now)
|
|
420
|
+
counters = _counters(state)
|
|
421
|
+
return {
|
|
422
|
+
"schema_version": SCHEMA_VERSION,
|
|
423
|
+
"task_id": state["task_id"],
|
|
424
|
+
"policy_fingerprint": state["policy_fingerprint"],
|
|
425
|
+
"started_at": state["started_at"],
|
|
426
|
+
"soft_deadline": state["soft_deadline"],
|
|
427
|
+
"hard_deadline": state["hard_deadline"],
|
|
428
|
+
"closed": closed,
|
|
429
|
+
"outcome": state["outcome"],
|
|
430
|
+
"action": action,
|
|
431
|
+
"remaining_seconds": remaining_hard,
|
|
432
|
+
"remaining_soft_seconds": max(0.0, float(state["soft_deadline"]) - now),
|
|
433
|
+
"remaining_hard_seconds": remaining_hard,
|
|
434
|
+
"counters": counters,
|
|
435
|
+
"limits": dict(state["limits"]),
|
|
436
|
+
"permits_new_work": action == "continue",
|
|
437
|
+
"permits_new_review": (
|
|
438
|
+
not closed
|
|
439
|
+
and now < state["hard_deadline"]
|
|
440
|
+
and counters["review_attempt"] < state["limits"]["review_attempt"]
|
|
441
|
+
),
|
|
442
|
+
"cancel_required": action == "stop" and not closed and now >= state["hard_deadline"],
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def init_ledger(state_file: str | Path, task_id: str, policy: TaskBudgetPolicy, now: Any) -> dict[str, Any]:
|
|
447
|
+
path = _state_path(state_file)
|
|
448
|
+
task_hash = _task_hash(task_id)
|
|
449
|
+
policy.validate()
|
|
450
|
+
current_now = _now(now)
|
|
451
|
+
expected_policy = _policy_fingerprint(policy)
|
|
452
|
+
with _ledger_lock(path):
|
|
453
|
+
if os.path.lexists(path):
|
|
454
|
+
state = _read_state(path)
|
|
455
|
+
_check_now(state, current_now)
|
|
456
|
+
if state["task_id"] != task_hash:
|
|
457
|
+
raise LedgerError("task budget task_id mismatch; refusing to reuse ledger")
|
|
458
|
+
if state["policy_fingerprint"] != expected_policy:
|
|
459
|
+
raise LedgerError("task budget policy mismatch; refusing to reset ledger")
|
|
460
|
+
result = _status(state, current_now)
|
|
461
|
+
result["initialized"] = False
|
|
462
|
+
result["idempotent"] = True
|
|
463
|
+
return result
|
|
464
|
+
state = _new_state(task_hash, policy, current_now)
|
|
465
|
+
_validate_state(state)
|
|
466
|
+
_atomic_write(path, state)
|
|
467
|
+
result = _status(state, current_now)
|
|
468
|
+
result["initialized"] = True
|
|
469
|
+
result["idempotent"] = False
|
|
470
|
+
return result
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def ledger_status(state_file: str | Path, task_id: str, now: Any) -> dict[str, Any]:
|
|
474
|
+
path = _state_path(state_file)
|
|
475
|
+
task_hash = _task_hash(task_id)
|
|
476
|
+
current_now = _now(now)
|
|
477
|
+
with _ledger_lock(path):
|
|
478
|
+
state = _read_state(path)
|
|
479
|
+
_check_now(state, current_now)
|
|
480
|
+
if state["task_id"] != task_hash:
|
|
481
|
+
raise LedgerError("task budget task_id mismatch")
|
|
482
|
+
return _status(state, current_now)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def reserve(
|
|
486
|
+
state_file: str | Path,
|
|
487
|
+
task_id: str,
|
|
488
|
+
kind: str,
|
|
489
|
+
reservation_id: str,
|
|
490
|
+
fingerprint: str,
|
|
491
|
+
now: Any,
|
|
492
|
+
*,
|
|
493
|
+
new_reservation_deadline: Any | None = None,
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
path = _state_path(state_file)
|
|
496
|
+
task_hash = _task_hash(task_id)
|
|
497
|
+
current_now = _now(now)
|
|
498
|
+
admission_deadline = (
|
|
499
|
+
None
|
|
500
|
+
if new_reservation_deadline is None
|
|
501
|
+
else _seconds(new_reservation_deadline, "new_reservation_deadline")
|
|
502
|
+
)
|
|
503
|
+
if kind not in RESERVATION_KINDS:
|
|
504
|
+
raise LedgerError(f"invalid reservation kind: {kind}")
|
|
505
|
+
reservation_hash = _reservation_hash(reservation_id)
|
|
506
|
+
fp_hash = _fingerprint_hash(fingerprint)
|
|
507
|
+
with _ledger_lock(path):
|
|
508
|
+
state = _read_state(path)
|
|
509
|
+
_check_now(state, current_now)
|
|
510
|
+
if state["task_id"] != task_hash:
|
|
511
|
+
raise LedgerError("task budget task_id mismatch")
|
|
512
|
+
if state["closed"]:
|
|
513
|
+
raise LedgerError("task budget is closed")
|
|
514
|
+
entries = state["reservations"][kind]
|
|
515
|
+
for entry in entries:
|
|
516
|
+
if entry["reservation_id"] == reservation_hash:
|
|
517
|
+
if entry["fingerprint"] != fp_hash:
|
|
518
|
+
raise LedgerError("reservation id already used with a different fingerprint")
|
|
519
|
+
result = _status(state, current_now)
|
|
520
|
+
result.update({
|
|
521
|
+
"kind": kind,
|
|
522
|
+
"reservation_id": reservation_hash,
|
|
523
|
+
"fingerprint": fp_hash,
|
|
524
|
+
"reserved": True,
|
|
525
|
+
"idempotent": True,
|
|
526
|
+
})
|
|
527
|
+
return result
|
|
528
|
+
if admission_deadline is not None:
|
|
529
|
+
if admission_deadline > state["hard_deadline"]:
|
|
530
|
+
raise LedgerError("new reservation deadline cannot exceed task hard deadline")
|
|
531
|
+
if current_now >= admission_deadline:
|
|
532
|
+
raise LedgerError("reservation admission deadline reached; new reservation is not permitted")
|
|
533
|
+
if current_now >= state["hard_deadline"]:
|
|
534
|
+
raise LedgerError("task hard deadline reached; new reservations are not permitted")
|
|
535
|
+
if kind in GENERAL_RESERVATION_KINDS and current_now >= state["soft_deadline"]:
|
|
536
|
+
raise LedgerError("task soft deadline reached; new general-work reservations are not permitted")
|
|
537
|
+
limit = state["limits"][kind]
|
|
538
|
+
if len(entries) >= limit:
|
|
539
|
+
raise LedgerError(f"{kind} reservation limit reached")
|
|
540
|
+
entries.append({
|
|
541
|
+
"reservation_id": reservation_hash,
|
|
542
|
+
"fingerprint": fp_hash,
|
|
543
|
+
"reserved_at": current_now,
|
|
544
|
+
})
|
|
545
|
+
_validate_state(state)
|
|
546
|
+
_atomic_write(path, state)
|
|
547
|
+
result = _status(state, current_now)
|
|
548
|
+
result.update({
|
|
549
|
+
"kind": kind,
|
|
550
|
+
"reservation_id": reservation_hash,
|
|
551
|
+
"fingerprint": fp_hash,
|
|
552
|
+
"reserved": True,
|
|
553
|
+
"idempotent": False,
|
|
554
|
+
})
|
|
555
|
+
return result
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def finish(state_file: str | Path, task_id: str, outcome: str, now: Any) -> dict[str, Any]:
|
|
559
|
+
path = _state_path(state_file)
|
|
560
|
+
task_hash = _task_hash(task_id)
|
|
561
|
+
current_now = _now(now)
|
|
562
|
+
outcome_value = _strict_string(outcome, "outcome")
|
|
563
|
+
if not SAFE_OUTCOME_RE.fullmatch(outcome_value):
|
|
564
|
+
raise LedgerError("outcome must be a short status token")
|
|
565
|
+
with _ledger_lock(path):
|
|
566
|
+
state = _read_state(path)
|
|
567
|
+
_check_now(state, current_now)
|
|
568
|
+
if state["task_id"] != task_hash:
|
|
569
|
+
raise LedgerError("task budget task_id mismatch")
|
|
570
|
+
if state["closed"]:
|
|
571
|
+
if state["outcome"] != outcome_value:
|
|
572
|
+
raise LedgerError("task budget is already closed with a different outcome")
|
|
573
|
+
result = _status(state, current_now)
|
|
574
|
+
result["finished"] = False
|
|
575
|
+
result["idempotent"] = True
|
|
576
|
+
return result
|
|
577
|
+
state["closed"] = True
|
|
578
|
+
state["outcome"] = outcome_value
|
|
579
|
+
_validate_state(state)
|
|
580
|
+
_atomic_write(path, state)
|
|
581
|
+
result = _status(state, current_now)
|
|
582
|
+
result["finished"] = True
|
|
583
|
+
result["idempotent"] = False
|
|
584
|
+
return result
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _json_argument(raw: str) -> Any:
|
|
588
|
+
try:
|
|
589
|
+
return json.loads(raw)
|
|
590
|
+
except json.JSONDecodeError as exc:
|
|
591
|
+
raise LedgerError(f"invalid policy JSON: {exc.msg}") from None
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _common_args(command: argparse.ArgumentParser) -> None:
|
|
595
|
+
command.add_argument("--state-file", required=True)
|
|
596
|
+
command.add_argument("--task-id", required=True)
|
|
597
|
+
command.add_argument("--now", required=True)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _parser() -> argparse.ArgumentParser:
|
|
601
|
+
parser = argparse.ArgumentParser(
|
|
602
|
+
prog="task_budget_runtime.py",
|
|
603
|
+
description="durable FlowPilot task budget ledger",
|
|
604
|
+
)
|
|
605
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
606
|
+
init_cmd = commands.add_parser("init")
|
|
607
|
+
_common_args(init_cmd)
|
|
608
|
+
init_cmd.add_argument("--policy-json", required=True)
|
|
609
|
+
status_cmd = commands.add_parser("status")
|
|
610
|
+
_common_args(status_cmd)
|
|
611
|
+
reserve_cmd = commands.add_parser("reserve")
|
|
612
|
+
_common_args(reserve_cmd)
|
|
613
|
+
reserve_cmd.add_argument("--kind", choices=RESERVATION_KINDS, required=True)
|
|
614
|
+
reserve_cmd.add_argument("--reservation-id", required=True)
|
|
615
|
+
reserve_cmd.add_argument("--fingerprint", required=True)
|
|
616
|
+
finish_cmd = commands.add_parser("finish")
|
|
617
|
+
_common_args(finish_cmd)
|
|
618
|
+
finish_cmd.add_argument("--outcome", default="success")
|
|
619
|
+
return parser
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def main(argv: list[str] | None = None) -> int:
|
|
623
|
+
parser = _parser()
|
|
624
|
+
ns = parser.parse_args(argv)
|
|
625
|
+
try:
|
|
626
|
+
if ns.command == "init":
|
|
627
|
+
result = init_ledger(ns.state_file, ns.task_id, _policy(_json_argument(ns.policy_json)), ns.now)
|
|
628
|
+
elif ns.command == "status":
|
|
629
|
+
result = ledger_status(ns.state_file, ns.task_id, ns.now)
|
|
630
|
+
elif ns.command == "reserve":
|
|
631
|
+
result = reserve(ns.state_file, ns.task_id, ns.kind, ns.reservation_id, ns.fingerprint, ns.now)
|
|
632
|
+
elif ns.command == "finish":
|
|
633
|
+
result = finish(ns.state_file, ns.task_id, ns.outcome, ns.now)
|
|
634
|
+
else: # pragma: no cover
|
|
635
|
+
raise LedgerError(f"unsupported command: {ns.command}")
|
|
636
|
+
except (LedgerError, ValueError, OSError) as exc:
|
|
637
|
+
print(f"task-budget: {exc}", file=sys.stderr)
|
|
638
|
+
return 2
|
|
639
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
|
|
640
|
+
return 0
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
if __name__ == "__main__":
|
|
644
|
+
raise SystemExit(main())
|