codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .locks import file_lock
|
|
13
|
+
from .time_utils import now_iso
|
|
14
|
+
|
|
15
|
+
PMA_QUEUE_DIR = ".codex-autorunner/pma/queue"
|
|
16
|
+
QUEUE_FILE_SUFFIX = ".jsonl"
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QueueItemState(str, Enum):
|
|
22
|
+
PENDING = "pending"
|
|
23
|
+
RUNNING = "running"
|
|
24
|
+
COMPLETED = "completed"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
CANCELLED = "cancelled"
|
|
27
|
+
DEDUPED = "deduped"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PmaQueueItem:
|
|
32
|
+
item_id: str
|
|
33
|
+
lane_id: str
|
|
34
|
+
enqueued_at: str
|
|
35
|
+
idempotency_key: str
|
|
36
|
+
payload: dict[str, Any]
|
|
37
|
+
state: QueueItemState = QueueItemState.PENDING
|
|
38
|
+
started_at: Optional[str] = None
|
|
39
|
+
finished_at: Optional[str] = None
|
|
40
|
+
error: Optional[str] = None
|
|
41
|
+
dedupe_reason: Optional[str] = None
|
|
42
|
+
result: Optional[dict[str, Any]] = None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def create(
|
|
46
|
+
cls,
|
|
47
|
+
lane_id: str,
|
|
48
|
+
idempotency_key: str,
|
|
49
|
+
payload: dict[str, Any],
|
|
50
|
+
) -> "PmaQueueItem":
|
|
51
|
+
return cls(
|
|
52
|
+
item_id=str(uuid.uuid4()),
|
|
53
|
+
lane_id=lane_id,
|
|
54
|
+
enqueued_at=now_iso(),
|
|
55
|
+
idempotency_key=idempotency_key,
|
|
56
|
+
payload=payload,
|
|
57
|
+
state=QueueItemState.PENDING,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
data = asdict(self)
|
|
62
|
+
data["state"] = self.state.value
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict[str, Any]) -> "PmaQueueItem":
|
|
67
|
+
data = dict(data)
|
|
68
|
+
if isinstance(data.get("state"), str):
|
|
69
|
+
try:
|
|
70
|
+
data["state"] = QueueItemState(data["state"])
|
|
71
|
+
except ValueError:
|
|
72
|
+
data["state"] = QueueItemState.PENDING
|
|
73
|
+
return cls(**data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PmaQueue:
|
|
77
|
+
"""PMA queue backed by JSONL state; pending items are replayed into memory."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, hub_root: Path) -> None:
|
|
80
|
+
self._hub_root = hub_root
|
|
81
|
+
self._queue_dir = hub_root / PMA_QUEUE_DIR
|
|
82
|
+
self._queue_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self._lane_queues: dict[str, asyncio.Queue[PmaQueueItem]] = {}
|
|
84
|
+
self._lane_locks: dict[str, asyncio.Lock] = {}
|
|
85
|
+
self._lane_events: dict[str, asyncio.Event] = {}
|
|
86
|
+
self._replayed_lanes: set[str] = set()
|
|
87
|
+
self._lock = asyncio.Lock()
|
|
88
|
+
|
|
89
|
+
def _lane_queue_path(self, lane_id: str) -> Path:
|
|
90
|
+
safe_lane_id = lane_id.replace(":", "__COLON__").replace("/", "__SLASH__")
|
|
91
|
+
return self._queue_dir / f"{safe_lane_id}{QUEUE_FILE_SUFFIX}"
|
|
92
|
+
|
|
93
|
+
def _lane_queue_lock_path(self, lane_id: str) -> Path:
|
|
94
|
+
path = self._lane_queue_path(lane_id)
|
|
95
|
+
return path.with_suffix(path.suffix + ".lock")
|
|
96
|
+
|
|
97
|
+
def _ensure_lane_lock(self, lane_id: str) -> asyncio.Lock:
|
|
98
|
+
lock = self._lane_locks.get(lane_id)
|
|
99
|
+
if lock is None:
|
|
100
|
+
lock = asyncio.Lock()
|
|
101
|
+
self._lane_locks[lane_id] = lock
|
|
102
|
+
return lock
|
|
103
|
+
|
|
104
|
+
def _ensure_lane_event(self, lane_id: str) -> asyncio.Event:
|
|
105
|
+
event = self._lane_events.get(lane_id)
|
|
106
|
+
if event is None:
|
|
107
|
+
event = asyncio.Event()
|
|
108
|
+
self._lane_events[lane_id] = event
|
|
109
|
+
return event
|
|
110
|
+
|
|
111
|
+
def _ensure_lane_queue(self, lane_id: str) -> asyncio.Queue[PmaQueueItem]:
|
|
112
|
+
queue = self._lane_queues.get(lane_id)
|
|
113
|
+
if queue is None:
|
|
114
|
+
queue = asyncio.Queue()
|
|
115
|
+
self._lane_queues[lane_id] = queue
|
|
116
|
+
return queue
|
|
117
|
+
|
|
118
|
+
async def enqueue(
|
|
119
|
+
self,
|
|
120
|
+
lane_id: str,
|
|
121
|
+
idempotency_key: str,
|
|
122
|
+
payload: dict[str, Any],
|
|
123
|
+
) -> tuple[PmaQueueItem, Optional[str]]:
|
|
124
|
+
async with self._lock:
|
|
125
|
+
existing = await self._find_by_idempotency_key(lane_id, idempotency_key)
|
|
126
|
+
if existing:
|
|
127
|
+
if existing.state in (QueueItemState.PENDING, QueueItemState.RUNNING):
|
|
128
|
+
dedupe_item = PmaQueueItem.create(
|
|
129
|
+
lane_id=lane_id,
|
|
130
|
+
idempotency_key=idempotency_key,
|
|
131
|
+
payload=payload,
|
|
132
|
+
)
|
|
133
|
+
dedupe_item.state = QueueItemState.DEDUPED
|
|
134
|
+
dedupe_item.dedupe_reason = f"duplicate_of_{existing.item_id}"
|
|
135
|
+
await self._append_to_file(dedupe_item)
|
|
136
|
+
return dedupe_item, f"duplicate of {existing.item_id}"
|
|
137
|
+
|
|
138
|
+
item = PmaQueueItem.create(lane_id, idempotency_key, payload)
|
|
139
|
+
await self._append_to_file(item)
|
|
140
|
+
queue = self._ensure_lane_queue(lane_id)
|
|
141
|
+
await queue.put(item)
|
|
142
|
+
self._ensure_lane_event(lane_id).set()
|
|
143
|
+
return item, None
|
|
144
|
+
|
|
145
|
+
async def dequeue(self, lane_id: str) -> Optional[PmaQueueItem]:
|
|
146
|
+
queue = self._lane_queues.get(lane_id)
|
|
147
|
+
if queue is None or queue.empty():
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
item = queue.get_nowait()
|
|
151
|
+
item.state = QueueItemState.RUNNING
|
|
152
|
+
item.started_at = now_iso()
|
|
153
|
+
await self._update_in_file(item)
|
|
154
|
+
return item
|
|
155
|
+
except asyncio.QueueEmpty:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
async def complete_item(
|
|
159
|
+
self, item: PmaQueueItem, result: Optional[dict[str, Any]] = None
|
|
160
|
+
) -> None:
|
|
161
|
+
item.state = QueueItemState.COMPLETED
|
|
162
|
+
item.finished_at = now_iso()
|
|
163
|
+
if result is not None:
|
|
164
|
+
item.result = result
|
|
165
|
+
await self._update_in_file(item)
|
|
166
|
+
|
|
167
|
+
async def fail_item(self, item: PmaQueueItem, error: str) -> None:
|
|
168
|
+
item.state = QueueItemState.FAILED
|
|
169
|
+
item.finished_at = now_iso()
|
|
170
|
+
item.error = error
|
|
171
|
+
await self._update_in_file(item)
|
|
172
|
+
|
|
173
|
+
async def cancel_lane(self, lane_id: str) -> int:
|
|
174
|
+
cancelled = 0
|
|
175
|
+
cancelled_ids: set[str] = set()
|
|
176
|
+
items = await self.list_items(lane_id)
|
|
177
|
+
for item in items:
|
|
178
|
+
if item.state == QueueItemState.PENDING:
|
|
179
|
+
item.state = QueueItemState.CANCELLED
|
|
180
|
+
item.finished_at = now_iso()
|
|
181
|
+
await self._update_in_file(item)
|
|
182
|
+
cancelled += 1
|
|
183
|
+
cancelled_ids.add(item.item_id)
|
|
184
|
+
|
|
185
|
+
queue = self._lane_queues.get(lane_id)
|
|
186
|
+
if queue is not None:
|
|
187
|
+
while not queue.empty():
|
|
188
|
+
try:
|
|
189
|
+
queued_item = queue.get_nowait()
|
|
190
|
+
except asyncio.QueueEmpty:
|
|
191
|
+
break
|
|
192
|
+
if queued_item.item_id in cancelled_ids:
|
|
193
|
+
continue
|
|
194
|
+
if queued_item.state != QueueItemState.PENDING:
|
|
195
|
+
continue
|
|
196
|
+
queued_item.state = QueueItemState.CANCELLED
|
|
197
|
+
queued_item.finished_at = now_iso()
|
|
198
|
+
await self._update_in_file(queued_item)
|
|
199
|
+
cancelled += 1
|
|
200
|
+
cancelled_ids.add(queued_item.item_id)
|
|
201
|
+
|
|
202
|
+
event = self._lane_events.get(lane_id)
|
|
203
|
+
if event is not None:
|
|
204
|
+
event.set()
|
|
205
|
+
|
|
206
|
+
return cancelled
|
|
207
|
+
|
|
208
|
+
async def replay_pending(self, lane_id: str) -> int:
|
|
209
|
+
if lane_id in self._replayed_lanes:
|
|
210
|
+
return 0
|
|
211
|
+
self._replayed_lanes.add(lane_id)
|
|
212
|
+
|
|
213
|
+
items = await self.list_items(lane_id)
|
|
214
|
+
pending = [item for item in items if item.state == QueueItemState.PENDING]
|
|
215
|
+
if not pending:
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
queue = self._ensure_lane_queue(lane_id)
|
|
219
|
+
for item in pending:
|
|
220
|
+
await queue.put(item)
|
|
221
|
+
self._ensure_lane_event(lane_id).set()
|
|
222
|
+
return len(pending)
|
|
223
|
+
|
|
224
|
+
async def wait_for_lane_item(
|
|
225
|
+
self, lane_id: str, cancel_event: Optional[asyncio.Event] = None
|
|
226
|
+
) -> bool:
|
|
227
|
+
event = self._ensure_lane_event(lane_id)
|
|
228
|
+
if event.is_set():
|
|
229
|
+
event.clear()
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
wait_tasks = [asyncio.create_task(event.wait())]
|
|
233
|
+
if cancel_event is not None:
|
|
234
|
+
wait_tasks.append(asyncio.create_task(cancel_event.wait()))
|
|
235
|
+
|
|
236
|
+
done, pending = await asyncio.wait(
|
|
237
|
+
wait_tasks, return_when=asyncio.FIRST_COMPLETED
|
|
238
|
+
)
|
|
239
|
+
for task in pending:
|
|
240
|
+
task.cancel()
|
|
241
|
+
|
|
242
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
if event.is_set():
|
|
246
|
+
event.clear()
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
async def list_items(self, lane_id: str) -> list[PmaQueueItem]:
|
|
250
|
+
path = self._lane_queue_path(lane_id)
|
|
251
|
+
if not path.exists():
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
items: list[PmaQueueItem] = []
|
|
255
|
+
async with self._ensure_lane_lock(lane_id):
|
|
256
|
+
try:
|
|
257
|
+
content = path.read_text(encoding="utf-8")
|
|
258
|
+
except OSError:
|
|
259
|
+
return []
|
|
260
|
+
|
|
261
|
+
for line in content.strip().splitlines():
|
|
262
|
+
line = line.strip()
|
|
263
|
+
if not line:
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
data = json.loads(line)
|
|
267
|
+
items.append(PmaQueueItem.from_dict(data))
|
|
268
|
+
except (json.JSONDecodeError, ValueError):
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
return items
|
|
272
|
+
|
|
273
|
+
async def _find_by_idempotency_key(
|
|
274
|
+
self, lane_id: str, idempotency_key: str
|
|
275
|
+
) -> Optional[PmaQueueItem]:
|
|
276
|
+
items = await self.list_items(lane_id)
|
|
277
|
+
for item in items:
|
|
278
|
+
if item.idempotency_key == idempotency_key and item.state in (
|
|
279
|
+
QueueItemState.PENDING,
|
|
280
|
+
QueueItemState.RUNNING,
|
|
281
|
+
):
|
|
282
|
+
return item
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
async def _append_to_file(self, item: PmaQueueItem) -> None:
|
|
286
|
+
path = self._lane_queue_path(item.lane_id)
|
|
287
|
+
async with self._ensure_lane_lock(item.lane_id):
|
|
288
|
+
with file_lock(self._lane_queue_lock_path(item.lane_id)):
|
|
289
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
line = json.dumps(item.to_dict(), separators=(",", ":")) + "\n"
|
|
291
|
+
with path.open("a", encoding="utf-8") as f:
|
|
292
|
+
f.write(line)
|
|
293
|
+
|
|
294
|
+
async def _update_in_file(self, item: PmaQueueItem) -> None:
|
|
295
|
+
path = self._lane_queue_path(item.lane_id)
|
|
296
|
+
async with self._ensure_lane_lock(item.lane_id):
|
|
297
|
+
with file_lock(self._lane_queue_lock_path(item.lane_id)):
|
|
298
|
+
if not path.exists():
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
content = path.read_text(encoding="utf-8")
|
|
303
|
+
except OSError:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
lines: list[str] = []
|
|
307
|
+
updated = False
|
|
308
|
+
for line in content.strip().splitlines():
|
|
309
|
+
line = line.strip()
|
|
310
|
+
if not line:
|
|
311
|
+
continue
|
|
312
|
+
try:
|
|
313
|
+
data = json.loads(line)
|
|
314
|
+
if data.get("item_id") == item.item_id:
|
|
315
|
+
lines.append(
|
|
316
|
+
json.dumps(item.to_dict(), separators=(",", ":"))
|
|
317
|
+
)
|
|
318
|
+
updated = True
|
|
319
|
+
else:
|
|
320
|
+
lines.append(line)
|
|
321
|
+
except (json.JSONDecodeError, ValueError):
|
|
322
|
+
lines.append(line)
|
|
323
|
+
|
|
324
|
+
if updated:
|
|
325
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
326
|
+
|
|
327
|
+
async def get_lane_stats(self, lane_id: str) -> dict[str, Any]:
|
|
328
|
+
items = await self.list_items(lane_id)
|
|
329
|
+
by_state: dict[str, int] = {}
|
|
330
|
+
for item in items:
|
|
331
|
+
state = item.state.value
|
|
332
|
+
by_state[state] = by_state.get(state, 0) + 1
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
"lane_id": lane_id,
|
|
336
|
+
"total_items": len(items),
|
|
337
|
+
"by_state": by_state,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async def get_all_lanes(self) -> list[str]:
|
|
341
|
+
lanes: set[str] = set()
|
|
342
|
+
if not self._queue_dir.exists():
|
|
343
|
+
return []
|
|
344
|
+
|
|
345
|
+
for path in self._queue_dir.iterdir():
|
|
346
|
+
if path.is_file() and path.suffix == QUEUE_FILE_SUFFIX:
|
|
347
|
+
lane_name = path.stem.replace("__SLASH__", "/").replace(
|
|
348
|
+
"__COLON__", ":"
|
|
349
|
+
)
|
|
350
|
+
lanes.add(lane_name)
|
|
351
|
+
|
|
352
|
+
return sorted(lanes)
|
|
353
|
+
|
|
354
|
+
async def get_queue_summary(self) -> dict[str, Any]:
|
|
355
|
+
lanes = await self.get_all_lanes()
|
|
356
|
+
summary: dict[str, Any] = {"lanes": {}}
|
|
357
|
+
for lane in lanes:
|
|
358
|
+
summary["lanes"][lane] = await self.get_lane_stats(lane)
|
|
359
|
+
summary["total_lanes"] = len(lanes)
|
|
360
|
+
return summary
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = [
|
|
364
|
+
"QueueItemState",
|
|
365
|
+
"PmaQueueItem",
|
|
366
|
+
"PmaQueue",
|
|
367
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from .pma_audit import PmaActionType, PmaAuditEntry, PmaAuditLog
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PmaSafetyConfig:
|
|
17
|
+
dedup_window_seconds: int = 300
|
|
18
|
+
max_duplicate_actions: int = 3
|
|
19
|
+
rate_limit_window_seconds: int = 60
|
|
20
|
+
max_actions_per_window: int = 20
|
|
21
|
+
circuit_breaker_threshold: int = 5
|
|
22
|
+
circuit_breaker_cooldown_seconds: int = 600
|
|
23
|
+
enable_dedup: bool = True
|
|
24
|
+
enable_rate_limit: bool = True
|
|
25
|
+
enable_circuit_breaker: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SafetyCheckResult:
|
|
30
|
+
allowed: bool
|
|
31
|
+
reason: Optional[str] = None
|
|
32
|
+
details: Optional[dict[str, Any]] = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self):
|
|
35
|
+
if self.details is None:
|
|
36
|
+
object.__setattr__(self, "details", {})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PmaSafetyChecker:
|
|
40
|
+
def __init__(
|
|
41
|
+
self, hub_root: Path, *, config: Optional[PmaSafetyConfig] = None
|
|
42
|
+
) -> None:
|
|
43
|
+
self._hub_root = hub_root
|
|
44
|
+
self._config = config or PmaSafetyConfig()
|
|
45
|
+
self._audit_log = PmaAuditLog(hub_root)
|
|
46
|
+
self._action_timestamps: defaultdict[str, list[float]] = defaultdict(list)
|
|
47
|
+
self._failure_counts: defaultdict[str, int] = defaultdict(int)
|
|
48
|
+
self._circuit_breaker_until: Optional[float] = None
|
|
49
|
+
|
|
50
|
+
def _is_circuit_breaker_active(self) -> bool:
|
|
51
|
+
if not self._config.enable_circuit_breaker:
|
|
52
|
+
return False
|
|
53
|
+
if self._circuit_breaker_until is None:
|
|
54
|
+
return False
|
|
55
|
+
now = datetime.now(timezone.utc).timestamp()
|
|
56
|
+
if now >= self._circuit_breaker_until:
|
|
57
|
+
self._reset_circuit_breaker()
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def _activate_circuit_breaker(self) -> None:
|
|
62
|
+
self._circuit_breaker_until = (
|
|
63
|
+
datetime.now(timezone.utc).timestamp()
|
|
64
|
+
+ self._config.circuit_breaker_cooldown_seconds
|
|
65
|
+
)
|
|
66
|
+
logger.warning(
|
|
67
|
+
"PMA circuit breaker activated (cooldown: %d seconds)",
|
|
68
|
+
self._config.circuit_breaker_cooldown_seconds,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _reset_circuit_breaker(self) -> None:
|
|
72
|
+
if self._circuit_breaker_until:
|
|
73
|
+
self._circuit_breaker_until = None
|
|
74
|
+
self._failure_counts.clear()
|
|
75
|
+
logger.info("PMA circuit breaker reset")
|
|
76
|
+
|
|
77
|
+
def check_chat_start(
|
|
78
|
+
self,
|
|
79
|
+
agent: str,
|
|
80
|
+
message: str,
|
|
81
|
+
client_turn_id: Optional[str] = None,
|
|
82
|
+
) -> SafetyCheckResult:
|
|
83
|
+
if self._is_circuit_breaker_active():
|
|
84
|
+
return SafetyCheckResult(
|
|
85
|
+
allowed=False,
|
|
86
|
+
reason="circuit_breaker_active",
|
|
87
|
+
details={
|
|
88
|
+
"cooldown_remaining_seconds": (
|
|
89
|
+
int(
|
|
90
|
+
self._circuit_breaker_until
|
|
91
|
+
- datetime.now(timezone.utc).timestamp()
|
|
92
|
+
)
|
|
93
|
+
if self._circuit_breaker_until
|
|
94
|
+
else 0
|
|
95
|
+
)
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if self._config.enable_dedup:
|
|
100
|
+
fingerprint = self._compute_chat_fingerprint(agent, message)
|
|
101
|
+
recent_count = self._audit_log.count_fingerprint(
|
|
102
|
+
fingerprint, within_seconds=self._config.dedup_window_seconds
|
|
103
|
+
)
|
|
104
|
+
if recent_count >= self._config.max_duplicate_actions:
|
|
105
|
+
logger.warning(
|
|
106
|
+
"PMA duplicate action blocked (fingerprint: %s, count: %d)",
|
|
107
|
+
fingerprint,
|
|
108
|
+
recent_count,
|
|
109
|
+
)
|
|
110
|
+
return SafetyCheckResult(
|
|
111
|
+
allowed=False,
|
|
112
|
+
reason="duplicate_action",
|
|
113
|
+
details={
|
|
114
|
+
"fingerprint": fingerprint,
|
|
115
|
+
"count": recent_count,
|
|
116
|
+
"max_allowed": self._config.max_duplicate_actions,
|
|
117
|
+
"window_seconds": self._config.dedup_window_seconds,
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if self._config.enable_rate_limit:
|
|
122
|
+
now = datetime.now(timezone.utc).timestamp()
|
|
123
|
+
key = f"chat:{agent}"
|
|
124
|
+
self._action_timestamps[key] = [
|
|
125
|
+
ts
|
|
126
|
+
for ts in self._action_timestamps[key]
|
|
127
|
+
if now - ts < self._config.rate_limit_window_seconds
|
|
128
|
+
]
|
|
129
|
+
if len(self._action_timestamps[key]) >= self._config.max_actions_per_window:
|
|
130
|
+
return SafetyCheckResult(
|
|
131
|
+
allowed=False,
|
|
132
|
+
reason="rate_limit_exceeded",
|
|
133
|
+
details={
|
|
134
|
+
"agent": agent,
|
|
135
|
+
"count": len(self._action_timestamps[key]),
|
|
136
|
+
"max_allowed": self._config.max_actions_per_window,
|
|
137
|
+
"window_seconds": self._config.rate_limit_window_seconds,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
self._action_timestamps[key].append(now)
|
|
141
|
+
|
|
142
|
+
return SafetyCheckResult(allowed=True)
|
|
143
|
+
|
|
144
|
+
def record_chat_result(
|
|
145
|
+
self,
|
|
146
|
+
agent: str,
|
|
147
|
+
status: str,
|
|
148
|
+
error: Optional[str] = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
if (
|
|
151
|
+
status in ("error", "failed", "interrupted")
|
|
152
|
+
and self._config.enable_circuit_breaker
|
|
153
|
+
):
|
|
154
|
+
key = f"chat:{agent}"
|
|
155
|
+
self._failure_counts[key] += 1
|
|
156
|
+
if self._failure_counts[key] >= self._config.circuit_breaker_threshold:
|
|
157
|
+
self._activate_circuit_breaker()
|
|
158
|
+
else:
|
|
159
|
+
key = f"chat:{agent}"
|
|
160
|
+
self._failure_counts[key] = 0
|
|
161
|
+
|
|
162
|
+
def record_action(
|
|
163
|
+
self,
|
|
164
|
+
action_type: PmaActionType,
|
|
165
|
+
agent: Optional[str] = None,
|
|
166
|
+
details: Optional[dict[str, Any]] = None,
|
|
167
|
+
status: str = "ok",
|
|
168
|
+
error: Optional[str] = None,
|
|
169
|
+
thread_id: Optional[str] = None,
|
|
170
|
+
turn_id: Optional[str] = None,
|
|
171
|
+
client_turn_id: Optional[str] = None,
|
|
172
|
+
) -> str:
|
|
173
|
+
entry = PmaAuditEntry(
|
|
174
|
+
action_type=action_type,
|
|
175
|
+
agent=agent,
|
|
176
|
+
thread_id=thread_id,
|
|
177
|
+
turn_id=turn_id,
|
|
178
|
+
client_turn_id=client_turn_id,
|
|
179
|
+
details=details or {},
|
|
180
|
+
status=status,
|
|
181
|
+
error=error,
|
|
182
|
+
)
|
|
183
|
+
entry_id = self._audit_log.append(entry)
|
|
184
|
+
return entry_id
|
|
185
|
+
|
|
186
|
+
def _compute_chat_fingerprint(self, agent: str, message: str) -> str:
|
|
187
|
+
from .pma_audit import PmaAuditEntry
|
|
188
|
+
|
|
189
|
+
temp_entry = PmaAuditEntry(
|
|
190
|
+
action_type=PmaActionType.CHAT_STARTED,
|
|
191
|
+
agent=agent,
|
|
192
|
+
details={"message_truncated": message[:200]},
|
|
193
|
+
)
|
|
194
|
+
return temp_entry.fingerprint
|
|
195
|
+
|
|
196
|
+
def get_stats(self) -> dict[str, Any]:
|
|
197
|
+
recent = self._audit_log.list_recent(limit=100)
|
|
198
|
+
by_type: dict[str, int] = {}
|
|
199
|
+
for entry in recent:
|
|
200
|
+
atype = entry.action_type.value
|
|
201
|
+
by_type[atype] = by_type.get(atype, 0) + 1
|
|
202
|
+
return {
|
|
203
|
+
"circuit_breaker_active": self._is_circuit_breaker_active(),
|
|
204
|
+
"circuit_breaker_cooldown_remaining": (
|
|
205
|
+
int(
|
|
206
|
+
self._circuit_breaker_until - datetime.now(timezone.utc).timestamp()
|
|
207
|
+
)
|
|
208
|
+
if self._circuit_breaker_until
|
|
209
|
+
else 0
|
|
210
|
+
),
|
|
211
|
+
"recent_actions_count": len(recent),
|
|
212
|
+
"recent_actions_by_type": by_type,
|
|
213
|
+
"failure_counts": dict(self._failure_counts),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__ = [
|
|
218
|
+
"PmaSafetyConfig",
|
|
219
|
+
"SafetyCheckResult",
|
|
220
|
+
"PmaSafetyChecker",
|
|
221
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .locks import file_lock
|
|
10
|
+
from .time_utils import now_iso
|
|
11
|
+
from .utils import atomic_write
|
|
12
|
+
|
|
13
|
+
PMA_STATE_FILENAME = "state.json"
|
|
14
|
+
PMA_STATE_CORRUPT_SUFFIX = ".corrupt"
|
|
15
|
+
PMA_STATE_NOTICE_SUFFIX = ".corrupt.json"
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_pma_state() -> dict[str, Any]:
|
|
21
|
+
return {
|
|
22
|
+
"version": 1,
|
|
23
|
+
"active": False,
|
|
24
|
+
"current": {},
|
|
25
|
+
"last_result": {},
|
|
26
|
+
"updated_at": now_iso(),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_pma_state_path(hub_root: Path) -> Path:
|
|
31
|
+
return hub_root / ".codex-autorunner" / "pma" / PMA_STATE_FILENAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PmaStateStore:
|
|
35
|
+
def __init__(self, hub_root: Path) -> None:
|
|
36
|
+
self._path = default_pma_state_path(hub_root)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def path(self) -> Path:
|
|
40
|
+
return self._path
|
|
41
|
+
|
|
42
|
+
def _lock_path(self) -> Path:
|
|
43
|
+
return self._path.with_suffix(self._path.suffix + ".lock")
|
|
44
|
+
|
|
45
|
+
def _stamp(self) -> str:
|
|
46
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
47
|
+
|
|
48
|
+
def _notice_path(self) -> Path:
|
|
49
|
+
return self._path.with_name(f"{self._path.name}{PMA_STATE_NOTICE_SUFFIX}")
|
|
50
|
+
|
|
51
|
+
def load(self, *, ensure_exists: bool = True) -> dict[str, Any]:
|
|
52
|
+
with file_lock(self._lock_path()):
|
|
53
|
+
state = self._load_unlocked()
|
|
54
|
+
if state is not None:
|
|
55
|
+
return state
|
|
56
|
+
state = default_pma_state()
|
|
57
|
+
if ensure_exists:
|
|
58
|
+
self._save_unlocked(state)
|
|
59
|
+
return state
|
|
60
|
+
|
|
61
|
+
def save(self, state: dict[str, Any]) -> None:
|
|
62
|
+
with file_lock(self._lock_path()):
|
|
63
|
+
self._save_unlocked(state)
|
|
64
|
+
|
|
65
|
+
def _load_unlocked(self) -> Optional[dict[str, Any]]:
|
|
66
|
+
if not self._path.exists():
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
raw = self._path.read_text(encoding="utf-8")
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
logger.warning("Failed to read PMA state at %s: %s", self._path, exc)
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(raw)
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
self._handle_corrupt_state(str(exc))
|
|
77
|
+
return default_pma_state()
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
self._handle_corrupt_state(
|
|
80
|
+
f"Expected JSON object, got {type(data).__name__}"
|
|
81
|
+
)
|
|
82
|
+
return default_pma_state()
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
def _save_unlocked(self, state: dict[str, Any]) -> None:
|
|
86
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
atomic_write(self._path, json.dumps(state, indent=2) + "\n")
|
|
88
|
+
|
|
89
|
+
def _handle_corrupt_state(self, detail: str) -> None:
|
|
90
|
+
stamp = self._stamp()
|
|
91
|
+
backup_path = self._path.with_name(
|
|
92
|
+
f"{self._path.name}{PMA_STATE_CORRUPT_SUFFIX}.{stamp}"
|
|
93
|
+
)
|
|
94
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
try:
|
|
96
|
+
self._path.replace(backup_path)
|
|
97
|
+
backup_value = str(backup_path)
|
|
98
|
+
except OSError:
|
|
99
|
+
backup_value = ""
|
|
100
|
+
notice = {
|
|
101
|
+
"status": "corrupt",
|
|
102
|
+
"message": "PMA state reset due to corrupted state.json.",
|
|
103
|
+
"detail": detail,
|
|
104
|
+
"detected_at": stamp,
|
|
105
|
+
"backup_path": backup_value,
|
|
106
|
+
}
|
|
107
|
+
notice_path = self._notice_path()
|
|
108
|
+
try:
|
|
109
|
+
atomic_write(notice_path, json.dumps(notice, indent=2) + "\n")
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.warning("Failed to write PMA corruption notice at %s", notice_path)
|
|
112
|
+
try:
|
|
113
|
+
self._save_unlocked(default_pma_state())
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.warning("Failed to reset PMA state at %s", self._path)
|