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.
Files changed (113) hide show
  1. codex_flow/__init__.py +28 -0
  2. codex_flow/__main__.py +9 -0
  3. codex_flow/cli.py +242 -0
  4. codex_flow/data/LICENSE +21 -0
  5. codex_flow/data/README.en.md +303 -0
  6. codex_flow/data/README.md +305 -0
  7. codex_flow/data/VERSION +1 -0
  8. codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
  9. codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
  10. codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
  11. codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
  12. codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
  13. codex_flow/data/apps/macos-overlay/README.en.md +121 -0
  14. codex_flow/data/apps/macos-overlay/README.md +123 -0
  15. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
  16. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
  17. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
  18. codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
  19. codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
  20. codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
  21. codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
  22. codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
  23. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
  24. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
  25. codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
  26. codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
  27. codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
  28. codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
  29. codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
  30. codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
  31. codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
  32. codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
  33. codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
  34. codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
  35. codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
  36. codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
  37. codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
  38. codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
  39. codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
  40. codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
  41. codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
  42. codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
  43. codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
  44. codex_flow/data/apps/macos-overlay/build.sh +75 -0
  45. codex_flow/data/benchmark/corpus.json +103 -0
  46. codex_flow/data/benchmark/manifest.example.json +41 -0
  47. codex_flow/data/benchmark/manifest.schema.json +137 -0
  48. codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
  49. codex_flow/data/benchmark/profiles.json +90 -0
  50. codex_flow/data/benchmark/schema.json +77 -0
  51. codex_flow/data/benchmark/tasks.json +50 -0
  52. codex_flow/data/completions/codex-flow.bash +34 -0
  53. codex_flow/data/completions/codex-flow.zsh +52 -0
  54. codex_flow/data/glama.json +6 -0
  55. codex_flow/data/install-release.ps1 +126 -0
  56. codex_flow/data/install-release.sh +155 -0
  57. codex_flow/data/install.ps1 +349 -0
  58. codex_flow/data/install.sh +362 -0
  59. codex_flow/data/policy/benchmark.toml +49 -0
  60. codex_flow/data/policy/defaults.toml +70 -0
  61. codex_flow/data/scripts/analyze-benchmark.py +510 -0
  62. codex_flow/data/scripts/benchmark-local.py +171 -0
  63. codex_flow/data/scripts/check-recommendation.py +277 -0
  64. codex_flow/data/scripts/doctor.py +449 -0
  65. codex_flow/data/scripts/generate-release-manifest.py +74 -0
  66. codex_flow/data/scripts/localization.py +192 -0
  67. codex_flow/data/scripts/manage-hooks.py +448 -0
  68. codex_flow/data/scripts/manage-instructions.py +389 -0
  69. codex_flow/data/scripts/manage-shell.py +151 -0
  70. codex_flow/data/scripts/materialize-corpus.py +193 -0
  71. codex_flow/data/scripts/menu.py +646 -0
  72. codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
  73. codex_flow/data/scripts/package-release.py +132 -0
  74. codex_flow/data/scripts/render-benchmark-report.py +292 -0
  75. codex_flow/data/scripts/run-benchmark.py +829 -0
  76. codex_flow/data/scripts/strategies/__init__.py +28 -0
  77. codex_flow/data/scripts/strategies/balanced.py +115 -0
  78. codex_flow/data/scripts/strategies/base.py +363 -0
  79. codex_flow/data/scripts/strategies/efficient.py +158 -0
  80. codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
  81. codex_flow/data/scripts/strategies/quality.py +209 -0
  82. codex_flow/data/scripts/strategies/speed.py +108 -0
  83. codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
  84. codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
  85. codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
  86. codex_flow/data/scripts/strategy_runtime.py +1091 -0
  87. codex_flow/data/scripts/telemetry.py +400 -0
  88. codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
  89. codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
  90. codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
  91. codex_flow/data/scripts/telemetry_core/common.py +421 -0
  92. codex_flow/data/scripts/telemetry_core/latency.py +593 -0
  93. codex_flow/data/scripts/telemetry_core/query.py +427 -0
  94. codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
  95. codex_flow/data/scripts/telemetry_core/render.py +460 -0
  96. codex_flow/data/scripts/telemetry_core/repair.py +223 -0
  97. codex_flow/data/scripts/ui.py +266 -0
  98. codex_flow/data/scripts/update-homebrew-formula.py +146 -0
  99. codex_flow/data/scripts/update_runtime_config.py +134 -0
  100. codex_flow/data/scripts/updater.py +1718 -0
  101. codex_flow/data/smithery.yaml +18 -0
  102. codex_flow/data/templates/agents/worker-explorer.toml +24 -0
  103. codex_flow/data/templates/agents/worker-implementer.toml +49 -0
  104. codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
  105. codex_flow/data/templates/flow-pilot-instructions.md +35 -0
  106. codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
  107. codex_flow/mcp.py +35 -0
  108. codex_flow-2.1.13.dist-info/METADATA +342 -0
  109. codex_flow-2.1.13.dist-info/RECORD +113 -0
  110. codex_flow-2.1.13.dist-info/WHEEL +5 -0
  111. codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
  112. codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
  113. 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())