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
codex_autorunner/core/hub.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import dataclasses
|
|
2
3
|
import enum
|
|
3
4
|
import logging
|
|
4
5
|
import re
|
|
5
6
|
import shutil
|
|
7
|
+
import threading
|
|
6
8
|
import time
|
|
7
9
|
from pathlib import Path
|
|
8
|
-
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
9
11
|
|
|
10
12
|
from ..bootstrap import seed_repo_files
|
|
11
13
|
from ..discovery import DiscoveryRecord, discover_and_init
|
|
@@ -16,23 +18,35 @@ from ..manifest import (
|
|
|
16
18
|
sanitize_repo_id,
|
|
17
19
|
save_manifest,
|
|
18
20
|
)
|
|
21
|
+
from .archive import archive_worktree_snapshot, build_snapshot_id
|
|
19
22
|
from .config import HubConfig, RepoConfig, derive_repo_config, load_hub_config
|
|
20
|
-
from .engine import Engine
|
|
21
23
|
from .git_utils import (
|
|
22
24
|
GitError,
|
|
23
25
|
git_available,
|
|
26
|
+
git_branch,
|
|
24
27
|
git_default_branch,
|
|
28
|
+
git_head_sha,
|
|
25
29
|
git_is_clean,
|
|
26
30
|
git_upstream_status,
|
|
27
31
|
run_git,
|
|
28
32
|
)
|
|
33
|
+
from .lifecycle_events import LifecycleEvent, LifecycleEventEmitter, LifecycleEventStore
|
|
29
34
|
from .locks import DEFAULT_RUNNER_CMD_HINTS, assess_lock, process_alive
|
|
35
|
+
from .ports.backend_orchestrator import (
|
|
36
|
+
BackendOrchestrator as BackendOrchestratorProtocol,
|
|
37
|
+
)
|
|
30
38
|
from .runner_controller import ProcessRunnerController, SpawnRunnerFn
|
|
39
|
+
from .runtime import RuntimeContext
|
|
31
40
|
from .state import RunnerState, load_state, now_iso
|
|
41
|
+
from .types import AppServerSupervisorFactory, BackendFactory
|
|
32
42
|
from .utils import atomic_write
|
|
33
43
|
|
|
34
44
|
logger = logging.getLogger("codex_autorunner.hub")
|
|
35
45
|
|
|
46
|
+
BackendFactoryBuilder = Callable[[Path, RepoConfig], BackendFactory]
|
|
47
|
+
AppServerSupervisorFactoryBuilder = Callable[[RepoConfig], AppServerSupervisorFactory]
|
|
48
|
+
BackendOrchestratorBuilder = Callable[[Path, RepoConfig], BackendOrchestratorProtocol]
|
|
49
|
+
|
|
36
50
|
|
|
37
51
|
def _git_failure_detail(proc) -> str:
|
|
38
52
|
return (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
|
|
@@ -195,10 +209,29 @@ class RepoRunner:
|
|
|
195
209
|
*,
|
|
196
210
|
repo_config: RepoConfig,
|
|
197
211
|
spawn_fn: Optional[SpawnRunnerFn] = None,
|
|
212
|
+
backend_factory_builder: Optional[BackendFactoryBuilder] = None,
|
|
213
|
+
app_server_supervisor_factory_builder: Optional[
|
|
214
|
+
AppServerSupervisorFactoryBuilder
|
|
215
|
+
] = None,
|
|
216
|
+
backend_orchestrator_builder: Optional[BackendOrchestratorBuilder] = None,
|
|
217
|
+
agent_id_validator: Optional[Callable[[str], str]] = None,
|
|
198
218
|
):
|
|
199
219
|
self.repo_id = repo_id
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
backend_orchestrator = (
|
|
221
|
+
backend_orchestrator_builder(repo_root, repo_config)
|
|
222
|
+
if backend_orchestrator_builder is not None
|
|
223
|
+
else None
|
|
224
|
+
)
|
|
225
|
+
if backend_orchestrator is None:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
"backend_orchestrator_builder is required for HubSupervisor"
|
|
228
|
+
)
|
|
229
|
+
self._ctx = RuntimeContext(
|
|
230
|
+
repo_root=repo_root,
|
|
231
|
+
config=repo_config,
|
|
232
|
+
backend_orchestrator=backend_orchestrator,
|
|
233
|
+
)
|
|
234
|
+
self._controller = ProcessRunnerController(self._ctx, spawn_fn=spawn_fn)
|
|
202
235
|
|
|
203
236
|
@property
|
|
204
237
|
def running(self) -> bool:
|
|
@@ -219,21 +252,60 @@ class RepoRunner:
|
|
|
219
252
|
|
|
220
253
|
class HubSupervisor:
|
|
221
254
|
def __init__(
|
|
222
|
-
self,
|
|
255
|
+
self,
|
|
256
|
+
hub_config: HubConfig,
|
|
257
|
+
*,
|
|
258
|
+
spawn_fn: Optional[SpawnRunnerFn] = None,
|
|
259
|
+
backend_factory_builder: Optional[BackendFactoryBuilder] = None,
|
|
260
|
+
app_server_supervisor_factory_builder: Optional[
|
|
261
|
+
AppServerSupervisorFactoryBuilder
|
|
262
|
+
] = None,
|
|
263
|
+
backend_orchestrator_builder: Optional[BackendOrchestratorBuilder] = None,
|
|
264
|
+
agent_id_validator: Optional[Callable[[str], str]] = None,
|
|
223
265
|
):
|
|
224
266
|
self.hub_config = hub_config
|
|
225
267
|
self.state_path = hub_config.root / ".codex-autorunner" / "hub_state.json"
|
|
226
268
|
self._runners: Dict[str, RepoRunner] = {}
|
|
227
269
|
self._spawn_fn = spawn_fn
|
|
270
|
+
self._backend_factory_builder = backend_factory_builder
|
|
271
|
+
self._app_server_supervisor_factory_builder = (
|
|
272
|
+
app_server_supervisor_factory_builder
|
|
273
|
+
)
|
|
274
|
+
self._backend_orchestrator_builder = backend_orchestrator_builder
|
|
275
|
+
self._agent_id_validator = agent_id_validator
|
|
228
276
|
self.state = load_hub_state(self.state_path, self.hub_config.root)
|
|
229
277
|
self._list_cache_at: Optional[float] = None
|
|
230
278
|
self._list_cache: Optional[List[RepoSnapshot]] = None
|
|
279
|
+
self._list_lock = threading.Lock()
|
|
280
|
+
self._lifecycle_emitter = LifecycleEventEmitter(hub_config.root)
|
|
281
|
+
self._lifecycle_task_lock = threading.Lock()
|
|
282
|
+
self._lifecycle_stop_event = threading.Event()
|
|
283
|
+
self._lifecycle_thread: Optional[threading.Thread] = None
|
|
284
|
+
self._dispatch_interceptor_task: Optional[asyncio.Task] = None
|
|
285
|
+
self._dispatch_interceptor_stop_event: Optional[threading.Event] = None
|
|
286
|
+
self._dispatch_interceptor_thread: Optional[threading.Thread] = None
|
|
231
287
|
self._reconcile_startup()
|
|
288
|
+
self._start_lifecycle_event_processor()
|
|
289
|
+
self._start_dispatch_interceptor()
|
|
232
290
|
|
|
233
291
|
@classmethod
|
|
234
|
-
def from_path(
|
|
292
|
+
def from_path(
|
|
293
|
+
cls,
|
|
294
|
+
path: Path,
|
|
295
|
+
*,
|
|
296
|
+
backend_factory_builder: Optional[BackendFactoryBuilder] = None,
|
|
297
|
+
app_server_supervisor_factory_builder: Optional[
|
|
298
|
+
AppServerSupervisorFactoryBuilder
|
|
299
|
+
] = None,
|
|
300
|
+
backend_orchestrator_builder: Optional[BackendOrchestratorBuilder] = None,
|
|
301
|
+
) -> "HubSupervisor":
|
|
235
302
|
config = load_hub_config(path)
|
|
236
|
-
return cls(
|
|
303
|
+
return cls(
|
|
304
|
+
config,
|
|
305
|
+
backend_factory_builder=backend_factory_builder,
|
|
306
|
+
app_server_supervisor_factory_builder=app_server_supervisor_factory_builder,
|
|
307
|
+
backend_orchestrator_builder=backend_orchestrator_builder,
|
|
308
|
+
)
|
|
237
309
|
|
|
238
310
|
def scan(self) -> List[RepoSnapshot]:
|
|
239
311
|
self._invalidate_list_cache()
|
|
@@ -244,16 +316,17 @@ class HubSupervisor:
|
|
|
244
316
|
return snapshots
|
|
245
317
|
|
|
246
318
|
def list_repos(self, *, use_cache: bool = True) -> List[RepoSnapshot]:
|
|
247
|
-
|
|
248
|
-
if
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
319
|
+
with self._list_lock:
|
|
320
|
+
if use_cache and self._list_cache and self._list_cache_at is not None:
|
|
321
|
+
if time.monotonic() - self._list_cache_at < 2.0:
|
|
322
|
+
return self._list_cache
|
|
323
|
+
manifest, records = self._manifest_records(manifest_only=True)
|
|
324
|
+
snapshots = self._build_snapshots(records)
|
|
325
|
+
self.state = HubState(last_scan_at=self.state.last_scan_at, repos=snapshots)
|
|
326
|
+
save_hub_state(self.state_path, self.state, self.hub_config.root)
|
|
327
|
+
self._list_cache = snapshots
|
|
328
|
+
self._list_cache_at = time.monotonic()
|
|
329
|
+
return snapshots
|
|
257
330
|
|
|
258
331
|
def _reconcile_startup(self) -> None:
|
|
259
332
|
try:
|
|
@@ -268,8 +341,19 @@ class HubSupervisor:
|
|
|
268
341
|
repo_config = derive_repo_config(
|
|
269
342
|
self.hub_config, record.absolute_path, load_env=False
|
|
270
343
|
)
|
|
344
|
+
backend_orchestrator = (
|
|
345
|
+
self._backend_orchestrator_builder(
|
|
346
|
+
record.absolute_path, repo_config
|
|
347
|
+
)
|
|
348
|
+
if self._backend_orchestrator_builder is not None
|
|
349
|
+
else None
|
|
350
|
+
)
|
|
271
351
|
controller = ProcessRunnerController(
|
|
272
|
-
|
|
352
|
+
RuntimeContext(
|
|
353
|
+
repo_root=record.absolute_path,
|
|
354
|
+
config=repo_config,
|
|
355
|
+
backend_orchestrator=backend_orchestrator,
|
|
356
|
+
)
|
|
273
357
|
)
|
|
274
358
|
controller.reconcile()
|
|
275
359
|
except Exception as exc:
|
|
@@ -593,6 +677,9 @@ class HubSupervisor:
|
|
|
593
677
|
worktree_repo_id: str,
|
|
594
678
|
delete_branch: bool = False,
|
|
595
679
|
delete_remote: bool = False,
|
|
680
|
+
archive: bool = True,
|
|
681
|
+
force_archive: bool = False,
|
|
682
|
+
archive_note: Optional[str] = None,
|
|
596
683
|
) -> None:
|
|
597
684
|
self._invalidate_list_cache()
|
|
598
685
|
manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
|
|
@@ -613,6 +700,44 @@ class HubSupervisor:
|
|
|
613
700
|
if runner:
|
|
614
701
|
runner.stop()
|
|
615
702
|
|
|
703
|
+
if archive:
|
|
704
|
+
branch_name = entry.branch or git_branch(worktree_path) or "unknown"
|
|
705
|
+
head_sha = git_head_sha(worktree_path) or "unknown"
|
|
706
|
+
snapshot_id = build_snapshot_id(branch_name, head_sha)
|
|
707
|
+
logger.info(
|
|
708
|
+
"Hub archive worktree start id=%s snapshot_id=%s",
|
|
709
|
+
worktree_repo_id,
|
|
710
|
+
snapshot_id,
|
|
711
|
+
)
|
|
712
|
+
try:
|
|
713
|
+
result = archive_worktree_snapshot(
|
|
714
|
+
base_repo_root=base_path,
|
|
715
|
+
base_repo_id=base.id,
|
|
716
|
+
worktree_repo_root=worktree_path,
|
|
717
|
+
worktree_repo_id=worktree_repo_id,
|
|
718
|
+
branch=branch_name,
|
|
719
|
+
worktree_of=entry.worktree_of,
|
|
720
|
+
note=archive_note,
|
|
721
|
+
snapshot_id=snapshot_id,
|
|
722
|
+
head_sha=head_sha,
|
|
723
|
+
source_path=entry.path,
|
|
724
|
+
)
|
|
725
|
+
except Exception as exc:
|
|
726
|
+
logger.exception(
|
|
727
|
+
"Hub archive worktree failed id=%s snapshot_id=%s",
|
|
728
|
+
worktree_repo_id,
|
|
729
|
+
snapshot_id,
|
|
730
|
+
)
|
|
731
|
+
if not force_archive:
|
|
732
|
+
raise ValueError(f"Worktree archive failed: {exc}") from exc
|
|
733
|
+
else:
|
|
734
|
+
logger.info(
|
|
735
|
+
"Hub archive worktree complete id=%s snapshot_id=%s status=%s",
|
|
736
|
+
worktree_repo_id,
|
|
737
|
+
result.snapshot_id,
|
|
738
|
+
result.status,
|
|
739
|
+
)
|
|
740
|
+
|
|
616
741
|
# Remove worktree from base repo.
|
|
617
742
|
try:
|
|
618
743
|
proc = run_git(
|
|
@@ -777,6 +902,12 @@ class HubSupervisor:
|
|
|
777
902
|
repo_root,
|
|
778
903
|
repo_config=repo_config,
|
|
779
904
|
spawn_fn=self._spawn_fn,
|
|
905
|
+
backend_factory_builder=self._backend_factory_builder,
|
|
906
|
+
app_server_supervisor_factory_builder=(
|
|
907
|
+
self._app_server_supervisor_factory_builder
|
|
908
|
+
),
|
|
909
|
+
backend_orchestrator_builder=self._backend_orchestrator_builder,
|
|
910
|
+
agent_id_validator=self._agent_id_validator,
|
|
780
911
|
)
|
|
781
912
|
self._runners[repo_id] = runner
|
|
782
913
|
return runner
|
|
@@ -819,8 +950,148 @@ class HubSupervisor:
|
|
|
819
950
|
return snapshot
|
|
820
951
|
|
|
821
952
|
def _invalidate_list_cache(self) -> None:
|
|
822
|
-
self.
|
|
823
|
-
|
|
953
|
+
with self._list_lock:
|
|
954
|
+
self._list_cache = None
|
|
955
|
+
self._list_cache_at = None
|
|
956
|
+
|
|
957
|
+
@property
|
|
958
|
+
def lifecycle_emitter(self) -> LifecycleEventEmitter:
|
|
959
|
+
return self._lifecycle_emitter
|
|
960
|
+
|
|
961
|
+
@property
|
|
962
|
+
def lifecycle_store(self) -> LifecycleEventStore:
|
|
963
|
+
return self._lifecycle_emitter._store
|
|
964
|
+
|
|
965
|
+
def trigger_pma_from_lifecycle_event(self, event: LifecycleEvent) -> None:
|
|
966
|
+
if event.processed:
|
|
967
|
+
return
|
|
968
|
+
event_id = event.event_id
|
|
969
|
+
if event_id is None:
|
|
970
|
+
return
|
|
971
|
+
self.lifecycle_store.mark_processed(event_id)
|
|
972
|
+
self.lifecycle_store.prune_processed(keep_last=50)
|
|
973
|
+
logger.info(
|
|
974
|
+
"PMA wakeup triggered by lifecycle event: type=%s repo_id=%s run_id=%s",
|
|
975
|
+
event.event_type.value,
|
|
976
|
+
event.repo_id,
|
|
977
|
+
event.run_id,
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
def process_lifecycle_events(self) -> None:
|
|
981
|
+
events = self.lifecycle_store.get_unprocessed(limit=100)
|
|
982
|
+
if not events:
|
|
983
|
+
return
|
|
984
|
+
for event in events:
|
|
985
|
+
try:
|
|
986
|
+
self.trigger_pma_from_lifecycle_event(event)
|
|
987
|
+
except Exception as exc:
|
|
988
|
+
logger.exception(
|
|
989
|
+
"Failed to process lifecycle event %s: %s", event.event_id, exc
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
def _start_lifecycle_event_processor(self) -> None:
|
|
993
|
+
if self._lifecycle_thread is not None:
|
|
994
|
+
return
|
|
995
|
+
|
|
996
|
+
def _process_loop():
|
|
997
|
+
while not self._lifecycle_stop_event.wait(5.0):
|
|
998
|
+
try:
|
|
999
|
+
self.process_lifecycle_events()
|
|
1000
|
+
except Exception:
|
|
1001
|
+
logger.exception("Error in lifecycle event processor")
|
|
1002
|
+
|
|
1003
|
+
self._lifecycle_thread = threading.Thread(
|
|
1004
|
+
target=_process_loop, daemon=True, name="lifecycle-event-processor"
|
|
1005
|
+
)
|
|
1006
|
+
self._lifecycle_thread.start()
|
|
1007
|
+
|
|
1008
|
+
def _stop_lifecycle_event_processor(self) -> None:
|
|
1009
|
+
if self._lifecycle_thread is None:
|
|
1010
|
+
return
|
|
1011
|
+
self._lifecycle_stop_event.set()
|
|
1012
|
+
self._lifecycle_thread.join(timeout=2.0)
|
|
1013
|
+
self._lifecycle_thread = None
|
|
1014
|
+
|
|
1015
|
+
def shutdown(self) -> None:
|
|
1016
|
+
self._stop_lifecycle_event_processor()
|
|
1017
|
+
self._stop_dispatch_interceptor()
|
|
1018
|
+
|
|
1019
|
+
def _start_dispatch_interceptor(self) -> None:
|
|
1020
|
+
if not self.hub_config.pma.enabled:
|
|
1021
|
+
return
|
|
1022
|
+
if not self.hub_config.pma.dispatch_interception_enabled:
|
|
1023
|
+
return
|
|
1024
|
+
if self._dispatch_interceptor_thread is not None:
|
|
1025
|
+
return
|
|
1026
|
+
|
|
1027
|
+
import asyncio
|
|
1028
|
+
from typing import TYPE_CHECKING
|
|
1029
|
+
|
|
1030
|
+
if TYPE_CHECKING:
|
|
1031
|
+
pass
|
|
1032
|
+
|
|
1033
|
+
def _run_interceptor():
|
|
1034
|
+
loop = asyncio.new_event_loop()
|
|
1035
|
+
asyncio.set_event_loop(loop)
|
|
1036
|
+
|
|
1037
|
+
from .pma_dispatch_interceptor import run_dispatch_interceptor
|
|
1038
|
+
|
|
1039
|
+
stop_event = threading.Event()
|
|
1040
|
+
self._dispatch_interceptor_stop_event = stop_event
|
|
1041
|
+
|
|
1042
|
+
async def run_until_stop():
|
|
1043
|
+
task = None
|
|
1044
|
+
try:
|
|
1045
|
+
task = await run_dispatch_interceptor(
|
|
1046
|
+
hub_root=self.hub_config.root,
|
|
1047
|
+
supervisor=self,
|
|
1048
|
+
interval_seconds=5.0,
|
|
1049
|
+
on_intercept=self._on_dispatch_intercept,
|
|
1050
|
+
)
|
|
1051
|
+
while not stop_event.is_set():
|
|
1052
|
+
await asyncio.sleep(0.1)
|
|
1053
|
+
except asyncio.CancelledError:
|
|
1054
|
+
pass
|
|
1055
|
+
finally:
|
|
1056
|
+
if task is not None and not task.done():
|
|
1057
|
+
task.cancel()
|
|
1058
|
+
if task is not None:
|
|
1059
|
+
try:
|
|
1060
|
+
await task
|
|
1061
|
+
except (asyncio.CancelledError, Exception):
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
loop.run_until_complete(run_until_stop())
|
|
1065
|
+
loop.close()
|
|
1066
|
+
|
|
1067
|
+
self._dispatch_interceptor_thread = threading.Thread(
|
|
1068
|
+
target=_run_interceptor, daemon=True, name="pma-dispatch-interceptor"
|
|
1069
|
+
)
|
|
1070
|
+
self._dispatch_interceptor_thread.start()
|
|
1071
|
+
|
|
1072
|
+
def _stop_dispatch_interceptor(self) -> None:
|
|
1073
|
+
if self._dispatch_interceptor_stop_event is not None:
|
|
1074
|
+
self._dispatch_interceptor_stop_event.set()
|
|
1075
|
+
if self._dispatch_interceptor_thread is not None:
|
|
1076
|
+
self._dispatch_interceptor_thread.join(timeout=2.0)
|
|
1077
|
+
self._dispatch_interceptor_thread = None
|
|
1078
|
+
self._dispatch_interceptor_stop_event = None
|
|
1079
|
+
|
|
1080
|
+
def _on_dispatch_intercept(self, event_id: str, result: Any) -> None:
|
|
1081
|
+
logger.info(
|
|
1082
|
+
"Dispatch intercepted: event_id=%s action=%s reason=%s",
|
|
1083
|
+
event_id,
|
|
1084
|
+
(
|
|
1085
|
+
result.get("action")
|
|
1086
|
+
if isinstance(result, dict)
|
|
1087
|
+
else getattr(result, "action", None)
|
|
1088
|
+
),
|
|
1089
|
+
(
|
|
1090
|
+
result.get("reason")
|
|
1091
|
+
if isinstance(result, dict)
|
|
1092
|
+
else getattr(result, "reason", None)
|
|
1093
|
+
),
|
|
1094
|
+
)
|
|
824
1095
|
|
|
825
1096
|
def _snapshot_from_record(self, record: DiscoveryRecord) -> RepoSnapshot:
|
|
826
1097
|
repo_path = record.absolute_path
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
from .locks import file_lock
|
|
12
|
+
from .utils import atomic_write
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
LIFECYCLE_EVENTS_FILENAME = "lifecycle_events.json"
|
|
17
|
+
LIFECYCLE_EVENTS_LOCK_SUFFIX = ".lock"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LifecycleEventType(str, Enum):
|
|
21
|
+
FLOW_PAUSED = "flow_paused"
|
|
22
|
+
FLOW_COMPLETED = "flow_completed"
|
|
23
|
+
FLOW_FAILED = "flow_failed"
|
|
24
|
+
FLOW_STOPPED = "flow_stopped"
|
|
25
|
+
DISPATCH_CREATED = "dispatch_created"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class LifecycleEvent:
|
|
30
|
+
event_type: LifecycleEventType
|
|
31
|
+
repo_id: str
|
|
32
|
+
run_id: str
|
|
33
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
timestamp: str = field(
|
|
35
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
36
|
+
)
|
|
37
|
+
processed: bool = False
|
|
38
|
+
event_id: str = ""
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
41
|
+
if not self.event_id:
|
|
42
|
+
import uuid
|
|
43
|
+
|
|
44
|
+
object.__setattr__(self, "event_id", str(uuid.uuid4()))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_lifecycle_events_path(hub_root: Path) -> Path:
|
|
48
|
+
return hub_root / ".codex-autorunner" / LIFECYCLE_EVENTS_FILENAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LifecycleEventStore:
|
|
52
|
+
def __init__(self, hub_root: Path) -> None:
|
|
53
|
+
self._path = default_lifecycle_events_path(hub_root)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def path(self) -> Path:
|
|
57
|
+
return self._path
|
|
58
|
+
|
|
59
|
+
def _lock_path(self) -> Path:
|
|
60
|
+
return self._path.with_suffix(LIFECYCLE_EVENTS_LOCK_SUFFIX)
|
|
61
|
+
|
|
62
|
+
def load(self, *, ensure_exists: bool = True) -> list[LifecycleEvent]:
|
|
63
|
+
with file_lock(self._lock_path()):
|
|
64
|
+
if not self._path.exists():
|
|
65
|
+
return []
|
|
66
|
+
try:
|
|
67
|
+
raw = self._path.read_text(encoding="utf-8")
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
logger.warning(
|
|
70
|
+
"Failed to read lifecycle events at %s: %s", self._path, exc
|
|
71
|
+
)
|
|
72
|
+
return []
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(raw)
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
logger.warning(
|
|
77
|
+
"Failed to parse lifecycle events at %s: %s", self._path, exc
|
|
78
|
+
)
|
|
79
|
+
return []
|
|
80
|
+
if not isinstance(data, list):
|
|
81
|
+
logger.warning("Lifecycle events data is not a list: %s", self._path)
|
|
82
|
+
return []
|
|
83
|
+
events: list[LifecycleEvent] = []
|
|
84
|
+
for entry in data:
|
|
85
|
+
try:
|
|
86
|
+
if not isinstance(entry, dict):
|
|
87
|
+
continue
|
|
88
|
+
event_type_str = entry.get("event_type")
|
|
89
|
+
if not isinstance(event_type_str, str):
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
event_type = LifecycleEventType(event_type_str)
|
|
93
|
+
except ValueError:
|
|
94
|
+
continue
|
|
95
|
+
event_id_raw = entry.get("event_id")
|
|
96
|
+
event_id = (
|
|
97
|
+
str(event_id_raw) if isinstance(event_id_raw, str) else ""
|
|
98
|
+
)
|
|
99
|
+
if not event_id:
|
|
100
|
+
import uuid
|
|
101
|
+
|
|
102
|
+
event_id = str(uuid.uuid4())
|
|
103
|
+
event = LifecycleEvent(
|
|
104
|
+
event_type=event_type,
|
|
105
|
+
repo_id=str(entry.get("repo_id", "")),
|
|
106
|
+
run_id=str(entry.get("run_id", "")),
|
|
107
|
+
data=dict(entry.get("data", {})),
|
|
108
|
+
timestamp=str(entry.get("timestamp", "")),
|
|
109
|
+
processed=bool(entry.get("processed", False)),
|
|
110
|
+
event_id=event_id,
|
|
111
|
+
)
|
|
112
|
+
events.append(event)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
logger.debug("Failed to parse lifecycle event entry: %s", exc)
|
|
115
|
+
continue
|
|
116
|
+
return events
|
|
117
|
+
|
|
118
|
+
def save(self, events: list[LifecycleEvent]) -> None:
|
|
119
|
+
with file_lock(self._lock_path()):
|
|
120
|
+
self._save_unlocked(events)
|
|
121
|
+
|
|
122
|
+
def _save_unlocked(self, events: list[LifecycleEvent]) -> None:
|
|
123
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
data = [
|
|
125
|
+
{
|
|
126
|
+
"event_id": event.event_id,
|
|
127
|
+
"event_type": event.event_type.value,
|
|
128
|
+
"repo_id": event.repo_id,
|
|
129
|
+
"run_id": event.run_id,
|
|
130
|
+
"data": event.data,
|
|
131
|
+
"timestamp": event.timestamp,
|
|
132
|
+
"processed": event.processed,
|
|
133
|
+
}
|
|
134
|
+
for event in events
|
|
135
|
+
]
|
|
136
|
+
atomic_write(self._path, json.dumps(data, indent=2) + "\n")
|
|
137
|
+
|
|
138
|
+
def append(self, event: LifecycleEvent) -> None:
|
|
139
|
+
events = self.load(ensure_exists=False)
|
|
140
|
+
events.append(event)
|
|
141
|
+
self.save(events)
|
|
142
|
+
|
|
143
|
+
def mark_processed(self, event_id: str) -> Optional[LifecycleEvent]:
|
|
144
|
+
if not event_id:
|
|
145
|
+
return None
|
|
146
|
+
events = self.load(ensure_exists=False)
|
|
147
|
+
updated = None
|
|
148
|
+
for event in events:
|
|
149
|
+
if event.event_id == event_id:
|
|
150
|
+
event.processed = True
|
|
151
|
+
updated = event
|
|
152
|
+
break
|
|
153
|
+
if updated:
|
|
154
|
+
self.save(events)
|
|
155
|
+
return updated
|
|
156
|
+
|
|
157
|
+
def get_unprocessed(self, *, limit: int = 100) -> list[LifecycleEvent]:
|
|
158
|
+
events = self.load(ensure_exists=False)
|
|
159
|
+
unprocessed = [e for e in events if not e.processed]
|
|
160
|
+
return unprocessed[:limit]
|
|
161
|
+
|
|
162
|
+
def prune_processed(self, *, keep_last: int = 100) -> None:
|
|
163
|
+
events = self.load(ensure_exists=False)
|
|
164
|
+
unprocessed = [e for e in events if not e.processed]
|
|
165
|
+
processed = [e for e in events if e.processed]
|
|
166
|
+
if len(processed) > keep_last:
|
|
167
|
+
processed = processed[-keep_last:]
|
|
168
|
+
self.save(unprocessed + processed)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class LifecycleEventEmitter:
|
|
172
|
+
def __init__(self, hub_root: Path) -> None:
|
|
173
|
+
self._store = LifecycleEventStore(hub_root)
|
|
174
|
+
self._listeners: list[Callable[[LifecycleEvent], None]] = []
|
|
175
|
+
|
|
176
|
+
def emit(self, event: LifecycleEvent) -> str:
|
|
177
|
+
self._store.append(event)
|
|
178
|
+
for listener in self._listeners:
|
|
179
|
+
try:
|
|
180
|
+
listener(event)
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
logger.exception("Error in lifecycle event listener: %s", exc)
|
|
183
|
+
return event.event_id
|
|
184
|
+
|
|
185
|
+
def emit_flow_paused(
|
|
186
|
+
self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
|
|
187
|
+
) -> str:
|
|
188
|
+
event = LifecycleEvent(
|
|
189
|
+
event_type=LifecycleEventType.FLOW_PAUSED,
|
|
190
|
+
repo_id=repo_id,
|
|
191
|
+
run_id=run_id,
|
|
192
|
+
data=data or {},
|
|
193
|
+
)
|
|
194
|
+
return self.emit(event)
|
|
195
|
+
|
|
196
|
+
def emit_flow_completed(
|
|
197
|
+
self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
|
|
198
|
+
) -> str:
|
|
199
|
+
event = LifecycleEvent(
|
|
200
|
+
event_type=LifecycleEventType.FLOW_COMPLETED,
|
|
201
|
+
repo_id=repo_id,
|
|
202
|
+
run_id=run_id,
|
|
203
|
+
data=data or {},
|
|
204
|
+
)
|
|
205
|
+
return self.emit(event)
|
|
206
|
+
|
|
207
|
+
def emit_flow_failed(
|
|
208
|
+
self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
|
|
209
|
+
) -> str:
|
|
210
|
+
event = LifecycleEvent(
|
|
211
|
+
event_type=LifecycleEventType.FLOW_FAILED,
|
|
212
|
+
repo_id=repo_id,
|
|
213
|
+
run_id=run_id,
|
|
214
|
+
data=data or {},
|
|
215
|
+
)
|
|
216
|
+
return self.emit(event)
|
|
217
|
+
|
|
218
|
+
def emit_flow_stopped(
|
|
219
|
+
self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
|
|
220
|
+
) -> str:
|
|
221
|
+
event = LifecycleEvent(
|
|
222
|
+
event_type=LifecycleEventType.FLOW_STOPPED,
|
|
223
|
+
repo_id=repo_id,
|
|
224
|
+
run_id=run_id,
|
|
225
|
+
data=data or {},
|
|
226
|
+
)
|
|
227
|
+
return self.emit(event)
|
|
228
|
+
|
|
229
|
+
def emit_dispatch_created(
|
|
230
|
+
self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
|
|
231
|
+
) -> str:
|
|
232
|
+
event = LifecycleEvent(
|
|
233
|
+
event_type=LifecycleEventType.DISPATCH_CREATED,
|
|
234
|
+
repo_id=repo_id,
|
|
235
|
+
run_id=run_id,
|
|
236
|
+
data=data or {},
|
|
237
|
+
)
|
|
238
|
+
return self.emit(event)
|
|
239
|
+
|
|
240
|
+
def add_listener(self, listener: Callable[[LifecycleEvent], None]) -> None:
|
|
241
|
+
self._listeners.append(listener)
|
|
242
|
+
|
|
243
|
+
def remove_listener(self, listener: Callable[[LifecycleEvent], None]) -> None:
|
|
244
|
+
self._listeners = [lst for lst in self._listeners if lst != listener]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = [
|
|
248
|
+
"LifecycleEventType",
|
|
249
|
+
"LifecycleEvent",
|
|
250
|
+
"LifecycleEventStore",
|
|
251
|
+
"LifecycleEventEmitter",
|
|
252
|
+
"default_lifecycle_events_path",
|
|
253
|
+
]
|
|
@@ -23,6 +23,18 @@ class NotificationManager:
|
|
|
23
23
|
self._warned_missing: set[str] = set()
|
|
24
24
|
self._enabled_mode = self._parse_enabled(self._cfg.get("enabled"))
|
|
25
25
|
self._events = self._normalize_events(self._cfg.get("events"))
|
|
26
|
+
timeout_raw = self._cfg.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
|
27
|
+
try:
|
|
28
|
+
timeout_seconds = (
|
|
29
|
+
float(timeout_raw)
|
|
30
|
+
if timeout_raw is not None
|
|
31
|
+
else DEFAULT_TIMEOUT_SECONDS
|
|
32
|
+
)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
timeout_seconds = DEFAULT_TIMEOUT_SECONDS
|
|
35
|
+
if timeout_seconds <= 0:
|
|
36
|
+
timeout_seconds = DEFAULT_TIMEOUT_SECONDS
|
|
37
|
+
self._timeout_seconds = timeout_seconds
|
|
26
38
|
self._warn_unknown_events(self._events)
|
|
27
39
|
discord_cfg = self._cfg.get("discord")
|
|
28
40
|
self._discord: Dict[str, Any] = (
|
|
@@ -202,7 +214,7 @@ class NotificationManager:
|
|
|
202
214
|
if not targets:
|
|
203
215
|
return
|
|
204
216
|
try:
|
|
205
|
-
with httpx.Client(timeout=
|
|
217
|
+
with httpx.Client(timeout=self._timeout_seconds) as client:
|
|
206
218
|
self._send_sync(client, targets, message)
|
|
207
219
|
except Exception as exc:
|
|
208
220
|
self._log_warning("Notification delivery failed", exc)
|
|
@@ -216,7 +228,7 @@ class NotificationManager:
|
|
|
216
228
|
if not targets:
|
|
217
229
|
return
|
|
218
230
|
try:
|
|
219
|
-
async with httpx.AsyncClient(timeout=
|
|
231
|
+
async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
|
|
220
232
|
await self._send_async(client, targets, message)
|
|
221
233
|
except Exception as exc:
|
|
222
234
|
self._log_warning("Notification delivery failed", exc)
|