codex-autorunner 0.1.2__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +176 -47
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +155 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +22 -37
- codex_autorunner/cli.py +5 -1156
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +4 -0
- codex_autorunner/core/about_car.py +49 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_logging.py +7 -3
- codex_autorunner/core/app_server_prompts.py +27 -260
- codex_autorunner/core/app_server_threads.py +26 -28
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +12 -2
- codex_autorunner/core/config.py +587 -103
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +136 -0
- codex_autorunner/core/engine.py +1531 -866
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +202 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +88 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +131 -0
- codex_autorunner/core/flows/runtime.py +382 -0
- codex_autorunner/core/flows/store.py +568 -0
- codex_autorunner/core/flows/transition.py +138 -0
- codex_autorunner/core/flows/ux_helpers.py +257 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +136 -16
- codex_autorunner/core/locks.py +4 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/core/ports/agent_backend.py +150 -0
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/core/ports/run_event.py +91 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/review_context.py +5 -8
- codex_autorunner/core/run_index.py +6 -0
- codex_autorunner/core/runner_process.py +5 -2
- codex_autorunner/core/state.py +0 -88
- codex_autorunner/core/state_roots.py +57 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +201 -0
- codex_autorunner/core/ticket_manager_cli.py +432 -0
- codex_autorunner/core/update.py +24 -16
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +120 -11
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +98 -0
- codex_autorunner/integrations/agents/__init__.py +17 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +448 -0
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +598 -0
- codex_autorunner/integrations/agents/runner.py +91 -0
- codex_autorunner/integrations/agents/wiring.py +271 -0
- codex_autorunner/integrations/app_server/client.py +583 -152
- 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/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +204 -165
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +221 -0
- codex_autorunner/integrations/telegram/constants.py +17 -2
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
- codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +111 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +221 -42
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
- codex_autorunner/integrations/telegram/transport.py +39 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +37 -67
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +3 -0
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -624
- codex_autorunner/routes/file_chat.py +7 -0
- codex_autorunner/routes/flows.py +7 -0
- codex_autorunner/routes/messages.py +7 -0
- 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 -188
- codex_autorunner/routes/usage.py +3 -0
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +3 -0
- codex_autorunner/server.py +3 -2
- codex_autorunner/static/agentControls.js +41 -11
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +35 -24
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +36 -8
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +344 -325
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +126 -185
- codex_autorunner/static/index.html +839 -863
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +873 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +149 -217
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/styles.css +8850 -3876
- codex_autorunner/static/tabs.js +175 -11
- codex_autorunner/static/terminal.js +32 -0
- codex_autorunner/static/terminalManager.js +34 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +844 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1988 -0
- codex_autorunner/static/utils.js +43 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +765 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +1224 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2019 -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 +78 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +277 -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 +836 -0
- codex_autorunner/surfaces/web/routes/flows.py +1164 -0
- codex_autorunner/surfaces/web/routes/messages.py +459 -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 +280 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -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 +417 -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 +27 -0
- codex_autorunner/tickets/agent_pool.py +399 -0
- codex_autorunner/tickets/files.py +89 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +97 -0
- codex_autorunner/tickets/outbox.py +244 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +881 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1771
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -587
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -396
- codex_autorunner/web/static_assets.py +4 -484
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +335 -0
- codex_autorunner-1.1.0.dist-info/METADATA +154 -0
- codex_autorunner-1.1.0.dist-info/RECORD +308 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
- codex_autorunner/agents/execution/policy.py +0 -292
- codex_autorunner/agents/factory.py +0 -52
- codex_autorunner/agents/orchestrator.py +0 -358
- codex_autorunner/core/doc_chat.py +0 -1446
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -250
- codex_autorunner/spec_ingest.py +0 -812
- codex_autorunner/static/docChatActions.js +0 -287
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -285
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -504
- codex_autorunner/static/logs.js +0 -678
- codex_autorunner/static/review.js +0 -157
- codex_autorunner/static/runs.js +0 -418
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -94
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.2.dist-info/METADATA +0 -249
- codex_autorunner-0.1.2.dist-info/RECORD +0 -222
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
codex_autorunner/core/engine.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import contextlib
|
|
3
3
|
import dataclasses
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
4
7
|
import json
|
|
5
8
|
import logging
|
|
6
9
|
import os
|
|
@@ -8,52 +11,45 @@ import signal
|
|
|
8
11
|
import threading
|
|
9
12
|
import time
|
|
10
13
|
import traceback
|
|
14
|
+
import uuid
|
|
11
15
|
from collections import Counter
|
|
12
16
|
from datetime import datetime, timezone
|
|
13
17
|
from logging.handlers import RotatingFileHandler
|
|
14
18
|
from pathlib import Path
|
|
15
|
-
from typing import IO, Any,
|
|
19
|
+
from typing import IO, Any, Awaitable, Callable, Iterator, Optional
|
|
16
20
|
|
|
17
21
|
import yaml
|
|
18
22
|
|
|
19
|
-
from ..agents.factory import create_orchestrator
|
|
20
|
-
from ..agents.opencode.logging import OpenCodeEventFormatter
|
|
21
|
-
from ..agents.opencode.runtime import (
|
|
22
|
-
OpenCodeTurnOutput,
|
|
23
|
-
build_turn_id,
|
|
24
|
-
collect_opencode_output,
|
|
25
|
-
extract_session_id,
|
|
26
|
-
map_approval_policy_to_permission,
|
|
27
|
-
opencode_missing_env,
|
|
28
|
-
parse_message_response,
|
|
29
|
-
split_model_id,
|
|
30
|
-
)
|
|
31
|
-
from ..agents.opencode.supervisor import OpenCodeSupervisor, OpenCodeSupervisorError
|
|
32
23
|
from ..agents.registry import validate_agent_id
|
|
33
|
-
from ..integrations.app_server.client import (
|
|
34
|
-
CodexAppServerError,
|
|
35
|
-
_extract_thread_id,
|
|
36
|
-
_extract_thread_id_for_turn,
|
|
37
|
-
_extract_turn_id,
|
|
38
|
-
)
|
|
39
|
-
from ..integrations.app_server.env import build_app_server_env
|
|
40
|
-
from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
|
|
41
24
|
from ..manifest import MANIFEST_VERSION
|
|
42
|
-
from ..
|
|
25
|
+
from ..tickets.files import list_ticket_paths, ticket_is_done
|
|
43
26
|
from .about_car import ensure_about_car_file
|
|
44
|
-
from .
|
|
27
|
+
from .adapter_utils import handle_agent_output
|
|
28
|
+
from .app_server_ids import (
|
|
29
|
+
extract_thread_id,
|
|
30
|
+
extract_thread_id_for_turn,
|
|
31
|
+
extract_turn_id,
|
|
32
|
+
)
|
|
45
33
|
from .app_server_logging import AppServerEventFormatter
|
|
46
34
|
from .app_server_prompts import build_autorunner_prompt
|
|
47
35
|
from .app_server_threads import AppServerThreadRegistry, default_app_server_threads_path
|
|
48
36
|
from .config import (
|
|
37
|
+
CONFIG_FILENAME,
|
|
38
|
+
CONFIG_VERSION,
|
|
39
|
+
DEFAULT_REPO_CONFIG,
|
|
49
40
|
ConfigError,
|
|
50
41
|
RepoConfig,
|
|
42
|
+
_build_repo_config,
|
|
51
43
|
_is_loopback_host,
|
|
44
|
+
_load_yaml_dict,
|
|
45
|
+
_merge_defaults,
|
|
46
|
+
_validate_repo_config,
|
|
52
47
|
derive_repo_config,
|
|
53
48
|
load_hub_config,
|
|
54
49
|
load_repo_config,
|
|
55
50
|
)
|
|
56
51
|
from .docs import DocsManager, parse_todos
|
|
52
|
+
from .flows.models import FlowEventType
|
|
57
53
|
from .git_utils import GitError, run_git
|
|
58
54
|
from .locks import (
|
|
59
55
|
DEFAULT_RUNNER_CMD_HINTS,
|
|
@@ -66,14 +62,29 @@ from .locks import (
|
|
|
66
62
|
)
|
|
67
63
|
from .notifications import NotificationManager
|
|
68
64
|
from .optional_dependencies import missing_optional_dependencies
|
|
65
|
+
from .ports.agent_backend import AgentBackend
|
|
66
|
+
from .ports.run_event import (
|
|
67
|
+
ApprovalRequested,
|
|
68
|
+
Completed,
|
|
69
|
+
Failed,
|
|
70
|
+
OutputDelta,
|
|
71
|
+
RunEvent,
|
|
72
|
+
RunNotice,
|
|
73
|
+
Started,
|
|
74
|
+
TokenUsage,
|
|
75
|
+
ToolCall,
|
|
76
|
+
)
|
|
69
77
|
from .prompt import build_final_summary_prompt
|
|
78
|
+
from .redaction import redact_text
|
|
70
79
|
from .review_context import build_spec_progress_review_context
|
|
71
80
|
from .run_index import RunIndexStore
|
|
72
81
|
from .state import RunnerState, load_state, now_iso, save_state, state_lock
|
|
82
|
+
from .state_roots import resolve_global_state_root, resolve_repo_state_root
|
|
83
|
+
from .ticket_linter_cli import ensure_ticket_linter
|
|
84
|
+
from .ticket_manager_cli import ensure_ticket_manager
|
|
73
85
|
from .utils import (
|
|
74
86
|
RepoNotFoundError,
|
|
75
87
|
atomic_write,
|
|
76
|
-
build_opencode_supervisor,
|
|
77
88
|
ensure_executable,
|
|
78
89
|
find_repo_root,
|
|
79
90
|
)
|
|
@@ -106,13 +117,11 @@ class RunTelemetry:
|
|
|
106
117
|
diff: Optional[Any] = None
|
|
107
118
|
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
interrupted: bool
|
|
115
|
-
interrupt_event: asyncio.Event
|
|
120
|
+
NotificationHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
|
121
|
+
BackendFactory = Callable[
|
|
122
|
+
[str, RunnerState, Optional[NotificationHandler]], AgentBackend
|
|
123
|
+
]
|
|
124
|
+
AppServerSupervisorFactory = Callable[[str, Optional[NotificationHandler]], Any]
|
|
116
125
|
|
|
117
126
|
|
|
118
127
|
class Engine:
|
|
@@ -122,6 +131,10 @@ class Engine:
|
|
|
122
131
|
*,
|
|
123
132
|
config: Optional[RepoConfig] = None,
|
|
124
133
|
hub_path: Optional[Path] = None,
|
|
134
|
+
backend_factory: Optional[BackendFactory] = None,
|
|
135
|
+
app_server_supervisor_factory: Optional[AppServerSupervisorFactory] = None,
|
|
136
|
+
backend_orchestrator: Optional[Any] = None,
|
|
137
|
+
agent_id_validator: Optional[Callable[[str], str]] = None,
|
|
125
138
|
):
|
|
126
139
|
if config is None:
|
|
127
140
|
config = load_repo_config(repo_root, hub_path=hub_path)
|
|
@@ -134,21 +147,42 @@ class Engine:
|
|
|
134
147
|
self._run_index_store = RunIndexStore(self.state_path)
|
|
135
148
|
self.lock_path = self.repo_root / ".codex-autorunner" / "lock"
|
|
136
149
|
self.stop_path = self.repo_root / ".codex-autorunner" / "stop"
|
|
150
|
+
self._hub_path = hub_path
|
|
137
151
|
self._active_global_handler: Optional[RotatingFileHandler] = None
|
|
138
152
|
self._active_run_log: Optional[IO[str]] = None
|
|
139
153
|
self._app_server_threads = AppServerThreadRegistry(
|
|
140
154
|
default_app_server_threads_path(self.repo_root)
|
|
141
155
|
)
|
|
142
156
|
self._app_server_threads_lock = threading.Lock()
|
|
143
|
-
self.
|
|
157
|
+
self._backend_factory = backend_factory
|
|
158
|
+
self._app_server_supervisor_factory = app_server_supervisor_factory
|
|
159
|
+
self._app_server_supervisor: Optional[Any] = None
|
|
160
|
+
self._backend_orchestrator: Optional[Any] = None
|
|
144
161
|
self._app_server_logger = logging.getLogger("codex_autorunner.app_server")
|
|
145
|
-
self.
|
|
146
|
-
|
|
147
|
-
self.
|
|
148
|
-
|
|
162
|
+
self._agent_id_validator = agent_id_validator or validate_agent_id
|
|
163
|
+
redact_enabled = self.config.security.get("redact_run_logs", True)
|
|
164
|
+
self._app_server_event_formatter = AppServerEventFormatter(
|
|
165
|
+
redact_enabled=redact_enabled
|
|
166
|
+
)
|
|
167
|
+
self._opencode_supervisor: Optional[Any] = None
|
|
168
|
+
|
|
169
|
+
# Backend orchestrator for protocol-agnostic backend management
|
|
170
|
+
# Use provided orchestrator if available (for testing), otherwise create it
|
|
171
|
+
self._backend_orchestrator = None
|
|
172
|
+
if backend_orchestrator is not None:
|
|
173
|
+
self._backend_orchestrator = backend_orchestrator
|
|
174
|
+
elif backend_factory is None and app_server_supervisor_factory is None:
|
|
175
|
+
self._backend_orchestrator = self._build_backend_orchestrator()
|
|
176
|
+
else:
|
|
177
|
+
self._app_server_logger.debug(
|
|
178
|
+
"Skipping BackendOrchestrator creation because backend_factory or app_server_supervisor_factory is set",
|
|
179
|
+
)
|
|
180
|
+
self._backend_orchestrator = None
|
|
149
181
|
self._run_telemetry_lock = threading.Lock()
|
|
150
182
|
self._run_telemetry: Optional[RunTelemetry] = None
|
|
151
183
|
self._last_telemetry_update_time: float = 0.0
|
|
184
|
+
self._canonical_event_lock = threading.Lock()
|
|
185
|
+
self._canonical_event_seq: dict[int, int] = {}
|
|
152
186
|
self._last_run_interrupted = False
|
|
153
187
|
self._lock_handle: Optional[FileLock] = None
|
|
154
188
|
# Ensure the interactive TUI briefing doc exists (for web Terminal "New").
|
|
@@ -159,6 +193,44 @@ class Engine:
|
|
|
159
193
|
self._app_server_logger.debug(
|
|
160
194
|
"Best-effort ABOUT_CAR.md creation failed: %s", exc
|
|
161
195
|
)
|
|
196
|
+
try:
|
|
197
|
+
ensure_ticket_linter(self.config.root)
|
|
198
|
+
except (OSError, IOError) as exc:
|
|
199
|
+
self._app_server_logger.debug(
|
|
200
|
+
"Best-effort lint_tickets.py creation failed: %s", exc
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
ensure_ticket_manager(self.config.root)
|
|
204
|
+
except (OSError, IOError) as exc:
|
|
205
|
+
self._app_server_logger.debug(
|
|
206
|
+
"Best-effort ticket_tool.py creation failed: %s", exc
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _build_backend_orchestrator(self) -> Optional[Any]:
|
|
210
|
+
"""
|
|
211
|
+
Dynamically construct BackendOrchestrator without introducing a core -> integrations
|
|
212
|
+
import-time dependency. Keeps import-boundary checks satisfied.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
module = importlib.import_module(
|
|
216
|
+
"codex_autorunner.integrations.agents.backend_orchestrator"
|
|
217
|
+
)
|
|
218
|
+
orchestrator_cls = getattr(module, "BackendOrchestrator", None)
|
|
219
|
+
if orchestrator_cls is None:
|
|
220
|
+
raise AttributeError("BackendOrchestrator not found in module")
|
|
221
|
+
return orchestrator_cls(
|
|
222
|
+
repo_root=self.repo_root,
|
|
223
|
+
config=self.config,
|
|
224
|
+
notification_handler=self._handle_app_server_notification,
|
|
225
|
+
logger=self._app_server_logger,
|
|
226
|
+
)
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
self._app_server_logger.warning(
|
|
229
|
+
"Failed to create BackendOrchestrator: %s\n%s",
|
|
230
|
+
exc,
|
|
231
|
+
traceback.format_exc(),
|
|
232
|
+
)
|
|
233
|
+
return None
|
|
162
234
|
|
|
163
235
|
@staticmethod
|
|
164
236
|
def from_cwd(repo: Optional[Path] = None) -> "Engine":
|
|
@@ -262,41 +334,21 @@ class Engine:
|
|
|
262
334
|
return None
|
|
263
335
|
|
|
264
336
|
def todos_done(self) -> bool:
|
|
265
|
-
|
|
337
|
+
# Ticket-first mode: completion is determined by ticket files, not TODO.md.
|
|
338
|
+
ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
|
|
339
|
+
ticket_paths = list_ticket_paths(ticket_dir)
|
|
340
|
+
if not ticket_paths:
|
|
341
|
+
return False
|
|
342
|
+
return all(ticket_is_done(path) for path in ticket_paths)
|
|
266
343
|
|
|
267
344
|
def summary_finalized(self) -> bool:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
text = self.docs.read_doc("summary")
|
|
271
|
-
except (FileNotFoundError, OSError) as exc:
|
|
272
|
-
self._app_server_logger.debug("Failed to read SUMMARY.md: %s", exc)
|
|
273
|
-
return False
|
|
274
|
-
return SUMMARY_FINALIZED_MARKER in (text or "")
|
|
345
|
+
# Legacy docs finalization no longer applies (no SUMMARY doc).
|
|
346
|
+
return True
|
|
275
347
|
|
|
276
348
|
def _stamp_summary_finalized(self, run_id: int) -> None:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
"""
|
|
281
|
-
path = self.config.doc_path("summary")
|
|
282
|
-
try:
|
|
283
|
-
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
284
|
-
except (FileNotFoundError, OSError) as exc:
|
|
285
|
-
self._app_server_logger.debug(
|
|
286
|
-
"Failed to read SUMMARY.md for stamping: %s", exc
|
|
287
|
-
)
|
|
288
|
-
existing = ""
|
|
289
|
-
if SUMMARY_FINALIZED_MARKER in existing:
|
|
290
|
-
return
|
|
291
|
-
stamp = f"{SUMMARY_FINALIZED_MARKER_PREFIX} run_id={int(run_id)} -->\n"
|
|
292
|
-
new_text = existing
|
|
293
|
-
if new_text and not new_text.endswith("\n"):
|
|
294
|
-
new_text += "\n"
|
|
295
|
-
# Keep a blank line before the marker for readability.
|
|
296
|
-
if new_text and not new_text.endswith("\n\n"):
|
|
297
|
-
new_text += "\n"
|
|
298
|
-
new_text += stamp
|
|
299
|
-
atomic_write(path, new_text)
|
|
349
|
+
# No-op: summary file no longer exists.
|
|
350
|
+
_ = run_id
|
|
351
|
+
return
|
|
300
352
|
|
|
301
353
|
async def _execute_run_step(
|
|
302
354
|
self,
|
|
@@ -317,43 +369,56 @@ class Engine:
|
|
|
317
369
|
try:
|
|
318
370
|
todo_before = self.docs.read_doc("todo")
|
|
319
371
|
except (FileNotFoundError, OSError) as exc:
|
|
320
|
-
self._app_server_logger.debug(
|
|
372
|
+
self._app_server_logger.debug(
|
|
373
|
+
"Failed to read TODO.md before run %s: %s", run_id, exc
|
|
374
|
+
)
|
|
321
375
|
todo_before = ""
|
|
322
376
|
state = load_state(self.state_path)
|
|
323
|
-
selected_agent = (state.autorunner_agent_override or "codex").strip().lower()
|
|
324
377
|
try:
|
|
325
|
-
validated_agent =
|
|
378
|
+
validated_agent = self._agent_id_validator(
|
|
379
|
+
state.autorunner_agent_override or "codex"
|
|
380
|
+
)
|
|
326
381
|
except ValueError:
|
|
327
382
|
validated_agent = "codex"
|
|
328
383
|
self.log_line(
|
|
329
384
|
run_id,
|
|
330
|
-
f"info: unknown agent '{
|
|
385
|
+
f"info: unknown agent '{state.autorunner_agent_override}', defaulting to codex",
|
|
331
386
|
)
|
|
332
387
|
self._update_state("running", run_id, None, started=True)
|
|
333
388
|
self._last_run_interrupted = False
|
|
334
389
|
self._start_run_telemetry(run_id)
|
|
390
|
+
|
|
391
|
+
actor: dict[str, Any] = {
|
|
392
|
+
"backend": "codex_app_server",
|
|
393
|
+
"agent_id": validated_agent,
|
|
394
|
+
"surface": "hub" if self._hub_path else "cli",
|
|
395
|
+
}
|
|
396
|
+
mode: dict[str, Any] = {
|
|
397
|
+
"approval_policy": state.autorunner_approval_policy or "never",
|
|
398
|
+
"sandbox": state.autorunner_sandbox_mode or "dangerFullAccess",
|
|
399
|
+
}
|
|
400
|
+
runner_cfg = self.config.raw.get("runner") or {}
|
|
401
|
+
review_cfg = runner_cfg.get("review")
|
|
402
|
+
if isinstance(review_cfg, dict):
|
|
403
|
+
mode["review_enabled"] = bool(review_cfg.get("enabled"))
|
|
404
|
+
|
|
335
405
|
with self._run_log_context(run_id):
|
|
336
|
-
self._write_run_marker(run_id, "start")
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
345
|
-
else:
|
|
346
|
-
exit_code = await self._run_codex_app_server_async(
|
|
347
|
-
prompt,
|
|
348
|
-
run_id,
|
|
349
|
-
external_stop_flag=external_stop_flag,
|
|
350
|
-
)
|
|
406
|
+
self._write_run_marker(run_id, "start", actor=actor, mode=mode)
|
|
407
|
+
exit_code = await self._run_agent_async(
|
|
408
|
+
agent_id=validated_agent,
|
|
409
|
+
prompt=prompt,
|
|
410
|
+
run_id=run_id,
|
|
411
|
+
state=state,
|
|
412
|
+
external_stop_flag=external_stop_flag,
|
|
413
|
+
)
|
|
351
414
|
self._write_run_marker(run_id, "end", exit_code=exit_code)
|
|
352
415
|
|
|
353
416
|
try:
|
|
354
417
|
todo_after = self.docs.read_doc("todo")
|
|
355
418
|
except (FileNotFoundError, OSError) as exc:
|
|
356
|
-
self._app_server_logger.debug(
|
|
419
|
+
self._app_server_logger.debug(
|
|
420
|
+
"Failed to read TODO.md after run %s: %s", run_id, exc
|
|
421
|
+
)
|
|
357
422
|
todo_after = ""
|
|
358
423
|
todo_delta = self._compute_todo_attribution(todo_before, todo_after)
|
|
359
424
|
todo_snapshot = self._build_todo_snapshot(todo_before, todo_after)
|
|
@@ -362,6 +427,7 @@ class Engine:
|
|
|
362
427
|
"todo_snapshot": todo_snapshot,
|
|
363
428
|
}
|
|
364
429
|
telemetry = self._snapshot_run_telemetry(run_id)
|
|
430
|
+
usage_payload: Optional[dict[str, Any]] = None
|
|
365
431
|
if (
|
|
366
432
|
telemetry
|
|
367
433
|
and telemetry.thread_id
|
|
@@ -374,42 +440,51 @@ class Engine:
|
|
|
374
440
|
thread_id=telemetry.thread_id, run_id=run_id
|
|
375
441
|
)
|
|
376
442
|
delta = self._compute_token_delta(baseline, telemetry.token_total)
|
|
377
|
-
|
|
443
|
+
token_usage_payload = {
|
|
378
444
|
"delta": delta,
|
|
379
445
|
"thread_total_before": baseline,
|
|
380
446
|
"thread_total_after": telemetry.token_total,
|
|
381
447
|
}
|
|
448
|
+
run_updates["token_usage"] = token_usage_payload
|
|
449
|
+
usage_payload = {
|
|
450
|
+
"run_id": run_id,
|
|
451
|
+
"captured_at": timestamp(),
|
|
452
|
+
"agent": validated_agent,
|
|
453
|
+
"thread_id": telemetry.thread_id,
|
|
454
|
+
"turn_id": telemetry.turn_id,
|
|
455
|
+
"token_usage": token_usage_payload,
|
|
456
|
+
# Use getattr() for optional config attributes that may not exist in all config versions
|
|
457
|
+
"cache_scope": getattr(self.config.usage, "cache_scope", "global"),
|
|
458
|
+
}
|
|
382
459
|
artifacts: dict[str, str] = {}
|
|
460
|
+
if usage_payload is not None:
|
|
461
|
+
usage_path = self._write_run_usage_artifact(run_id, usage_payload)
|
|
462
|
+
if usage_path is not None:
|
|
463
|
+
artifacts["usage_path"] = str(usage_path)
|
|
464
|
+
redact_enabled = self.config.security.get("redact_run_logs", True)
|
|
383
465
|
if telemetry and telemetry.plan is not None:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if isinstance(telemetry.plan, str)
|
|
388
|
-
else json.dumps(
|
|
389
|
-
telemetry.plan, ensure_ascii=True, indent=2, default=str
|
|
390
|
-
)
|
|
391
|
-
)
|
|
392
|
-
except (TypeError, ValueError) as exc:
|
|
393
|
-
self._app_server_logger.debug(
|
|
394
|
-
"Failed to serialize plan to JSON: %s", exc
|
|
395
|
-
)
|
|
396
|
-
plan_content = json.dumps(
|
|
397
|
-
{"plan": str(telemetry.plan)}, ensure_ascii=True, indent=2
|
|
398
|
-
)
|
|
466
|
+
plan_content = self._serialize_plan_content(
|
|
467
|
+
telemetry.plan, redact_enabled=redact_enabled, run_id=run_id
|
|
468
|
+
)
|
|
399
469
|
plan_path = self._write_run_artifact(run_id, "plan.json", plan_content)
|
|
400
470
|
artifacts["plan_path"] = str(plan_path)
|
|
401
471
|
if telemetry and telemetry.diff is not None:
|
|
402
|
-
diff_content = (
|
|
403
|
-
telemetry.diff
|
|
404
|
-
if isinstance(telemetry.diff, str)
|
|
405
|
-
else json.dumps(
|
|
406
|
-
telemetry.diff, ensure_ascii=True, indent=2, default=str
|
|
407
|
-
)
|
|
472
|
+
diff_content = self._serialize_diff_content(
|
|
473
|
+
telemetry.diff, redact_enabled=redact_enabled
|
|
408
474
|
)
|
|
409
|
-
|
|
410
|
-
|
|
475
|
+
if diff_content is not None:
|
|
476
|
+
diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
|
|
477
|
+
artifacts["diff_path"] = str(diff_path)
|
|
411
478
|
if artifacts:
|
|
412
479
|
run_updates["artifacts"] = artifacts
|
|
480
|
+
if redact_enabled:
|
|
481
|
+
from .redaction import get_redaction_patterns
|
|
482
|
+
|
|
483
|
+
run_updates["security"] = {
|
|
484
|
+
"redaction_enabled": True,
|
|
485
|
+
"redaction_version": "1.0",
|
|
486
|
+
"redaction_patterns": get_redaction_patterns(),
|
|
487
|
+
}
|
|
413
488
|
if run_updates:
|
|
414
489
|
self._merge_run_index_entry(run_id, run_updates)
|
|
415
490
|
self._clear_run_telemetry(run_id)
|
|
@@ -457,7 +532,7 @@ class Engine:
|
|
|
457
532
|
text = run_log.read_text(encoding="utf-8")
|
|
458
533
|
except (FileNotFoundError, OSError) as exc:
|
|
459
534
|
self._app_server_logger.debug(
|
|
460
|
-
"Failed to read previous run log: %s", exc
|
|
535
|
+
"Failed to read previous run log for run %s: %s", run_id, exc
|
|
461
536
|
)
|
|
462
537
|
text = ""
|
|
463
538
|
if text:
|
|
@@ -508,10 +583,12 @@ class Engine:
|
|
|
508
583
|
try:
|
|
509
584
|
return run_log.read_text(encoding="utf-8")
|
|
510
585
|
except (FileNotFoundError, OSError) as exc:
|
|
511
|
-
self._app_server_logger.debug(
|
|
586
|
+
self._app_server_logger.debug(
|
|
587
|
+
"Failed to read run log block for run %s: %s", run_id, exc
|
|
588
|
+
)
|
|
512
589
|
return None
|
|
513
590
|
if index_entry:
|
|
514
|
-
block = self._read_log_range(index_entry)
|
|
591
|
+
block = self._read_log_range(run_id, index_entry)
|
|
515
592
|
if block is not None:
|
|
516
593
|
return block
|
|
517
594
|
if not self.log_path.exists():
|
|
@@ -555,7 +632,7 @@ class Engine:
|
|
|
555
632
|
return "\n".join(buf) if buf else None
|
|
556
633
|
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
557
634
|
self._app_server_logger.debug(
|
|
558
|
-
"Failed to read full log for run block: %s", exc
|
|
635
|
+
"Failed to read full log for run %s block: %s", run_id, exc
|
|
559
636
|
)
|
|
560
637
|
return None
|
|
561
638
|
return None
|
|
@@ -582,7 +659,7 @@ class Engine:
|
|
|
582
659
|
self._active_run_log.flush()
|
|
583
660
|
except (OSError, IOError) as exc:
|
|
584
661
|
self._app_server_logger.warning(
|
|
585
|
-
"Failed to write to active run log: %s", exc
|
|
662
|
+
"Failed to write to active run log for run %s: %s", run_id, exc
|
|
586
663
|
)
|
|
587
664
|
else:
|
|
588
665
|
run_log = self._run_log_path(run_id)
|
|
@@ -607,7 +684,69 @@ class Engine:
|
|
|
607
684
|
f.write(_json.dumps(event_data) + "\n")
|
|
608
685
|
except (OSError, IOError) as exc:
|
|
609
686
|
self._app_server_logger.warning(
|
|
610
|
-
"Failed to write event to events log: %s", exc
|
|
687
|
+
"Failed to write event to events log for run %s: %s", run_id, exc
|
|
688
|
+
)
|
|
689
|
+
event_type = {
|
|
690
|
+
"run.started": FlowEventType.RUN_STARTED,
|
|
691
|
+
"run.finished": FlowEventType.RUN_FINISHED,
|
|
692
|
+
"run.state_changed": FlowEventType.RUN_STATE_CHANGED,
|
|
693
|
+
"run.no_progress": FlowEventType.RUN_NO_PROGRESS,
|
|
694
|
+
"token.updated": FlowEventType.TOKEN_USAGE,
|
|
695
|
+
"plan.updated": FlowEventType.PLAN_UPDATED,
|
|
696
|
+
"diff.updated": FlowEventType.DIFF_UPDATED,
|
|
697
|
+
}.get(event)
|
|
698
|
+
if event_type is not None:
|
|
699
|
+
self._emit_canonical_event(run_id, event_type, payload)
|
|
700
|
+
|
|
701
|
+
def _emit_canonical_event(
|
|
702
|
+
self,
|
|
703
|
+
run_id: int,
|
|
704
|
+
event_type: FlowEventType,
|
|
705
|
+
data: Optional[dict[str, Any]] = None,
|
|
706
|
+
*,
|
|
707
|
+
step_id: Optional[str] = None,
|
|
708
|
+
timestamp_override: Optional[str] = None,
|
|
709
|
+
) -> None:
|
|
710
|
+
event_payload: dict[str, Any] = {
|
|
711
|
+
"id": uuid.uuid4().hex,
|
|
712
|
+
"run_id": str(run_id),
|
|
713
|
+
"event_type": event_type.value,
|
|
714
|
+
"timestamp": timestamp_override or now_iso(),
|
|
715
|
+
"data": data or {},
|
|
716
|
+
}
|
|
717
|
+
if step_id is not None:
|
|
718
|
+
event_payload["step_id"] = step_id
|
|
719
|
+
self._ensure_run_log_dir()
|
|
720
|
+
with self._canonical_event_lock:
|
|
721
|
+
seq = self._canonical_event_seq.get(run_id, 0) + 1
|
|
722
|
+
self._canonical_event_seq[run_id] = seq
|
|
723
|
+
event_payload["seq"] = seq
|
|
724
|
+
events_path = self._canonical_events_log_path(run_id)
|
|
725
|
+
try:
|
|
726
|
+
with events_path.open("a", encoding="utf-8") as f:
|
|
727
|
+
f.write(json.dumps(event_payload, ensure_ascii=True) + "\n")
|
|
728
|
+
except (OSError, IOError) as exc:
|
|
729
|
+
self._app_server_logger.warning(
|
|
730
|
+
"Failed to write canonical event for run %s: %s", run_id, exc
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
async def _cancel_task_with_notice(
|
|
734
|
+
self,
|
|
735
|
+
run_id: int,
|
|
736
|
+
task: asyncio.Task[Any],
|
|
737
|
+
*,
|
|
738
|
+
name: str,
|
|
739
|
+
) -> None:
|
|
740
|
+
if task.done():
|
|
741
|
+
return
|
|
742
|
+
task.cancel()
|
|
743
|
+
try:
|
|
744
|
+
await task
|
|
745
|
+
except asyncio.CancelledError:
|
|
746
|
+
self._emit_canonical_event(
|
|
747
|
+
run_id,
|
|
748
|
+
FlowEventType.RUN_CANCELLED,
|
|
749
|
+
{"task": name},
|
|
611
750
|
)
|
|
612
751
|
|
|
613
752
|
def _ensure_log_path(self) -> None:
|
|
@@ -619,18 +758,32 @@ class Engine:
|
|
|
619
758
|
def _events_log_path(self, run_id: int) -> Path:
|
|
620
759
|
return self.log_path.parent / "runs" / f"run-{run_id}.events.jsonl"
|
|
621
760
|
|
|
761
|
+
def _canonical_events_log_path(self, run_id: int) -> Path:
|
|
762
|
+
return self.log_path.parent / "runs" / f"run-{run_id}.events.canonical.jsonl"
|
|
763
|
+
|
|
622
764
|
def _ensure_run_log_dir(self) -> None:
|
|
623
765
|
(self.log_path.parent / "runs").mkdir(parents=True, exist_ok=True)
|
|
624
766
|
|
|
625
767
|
def _write_run_marker(
|
|
626
|
-
self,
|
|
768
|
+
self,
|
|
769
|
+
run_id: int,
|
|
770
|
+
marker: str,
|
|
771
|
+
exit_code: Optional[int] = None,
|
|
772
|
+
*,
|
|
773
|
+
actor: Optional[dict[str, Any]] = None,
|
|
774
|
+
mode: Optional[dict[str, Any]] = None,
|
|
627
775
|
) -> None:
|
|
628
776
|
suffix = ""
|
|
629
777
|
if marker == "end":
|
|
630
778
|
suffix = f" (code {exit_code})"
|
|
631
779
|
self._emit_event(run_id, "run.finished", exit_code=exit_code)
|
|
632
780
|
elif marker == "start":
|
|
633
|
-
|
|
781
|
+
payload: dict[str, Any] = {}
|
|
782
|
+
if actor is not None:
|
|
783
|
+
payload["actor"] = actor
|
|
784
|
+
if mode is not None:
|
|
785
|
+
payload["mode"] = mode
|
|
786
|
+
self._emit_event(run_id, "run.started", **payload)
|
|
634
787
|
text = f"=== run {run_id} {marker}{suffix} ==="
|
|
635
788
|
offset = self._emit_global_line(text)
|
|
636
789
|
if self._active_run_log is not None:
|
|
@@ -639,14 +792,18 @@ class Engine:
|
|
|
639
792
|
self._active_run_log.flush()
|
|
640
793
|
except (OSError, IOError) as exc:
|
|
641
794
|
self._app_server_logger.warning(
|
|
642
|
-
"Failed to write marker to active run log: %s",
|
|
795
|
+
"Failed to write marker to active run log for run %s: %s",
|
|
796
|
+
run_id,
|
|
797
|
+
exc,
|
|
643
798
|
)
|
|
644
799
|
else:
|
|
645
800
|
self._ensure_run_log_dir()
|
|
646
801
|
run_log = self._run_log_path(run_id)
|
|
647
802
|
with run_log.open("a", encoding="utf-8") as f:
|
|
648
803
|
f.write(f"{text}\n")
|
|
649
|
-
self._update_run_index(
|
|
804
|
+
self._update_run_index(
|
|
805
|
+
run_id, marker, offset, exit_code, actor=actor, mode=mode
|
|
806
|
+
)
|
|
650
807
|
|
|
651
808
|
def _emit_global_line(self, text: str) -> Optional[tuple[int, int]]:
|
|
652
809
|
if self._active_global_handler is None:
|
|
@@ -693,6 +850,7 @@ class Engine:
|
|
|
693
850
|
def _run_log_context(self, run_id: int) -> Iterator[None]:
|
|
694
851
|
self._ensure_log_path()
|
|
695
852
|
self._ensure_run_log_dir()
|
|
853
|
+
# Use getattr() for optional config attributes that may not exist in all config versions
|
|
696
854
|
max_bytes = getattr(self.config.log, "max_bytes", None) or 0
|
|
697
855
|
backup_count = getattr(self.config.log, "backup_count", 0) or 0
|
|
698
856
|
handler = RotatingFileHandler(
|
|
@@ -715,14 +873,13 @@ class Engine:
|
|
|
715
873
|
handler.close()
|
|
716
874
|
except (OSError, IOError) as exc:
|
|
717
875
|
self._app_server_logger.debug(
|
|
718
|
-
"Failed to close run log handler: %s", exc
|
|
876
|
+
"Failed to close run log handler for run %s: %s", run_id, exc
|
|
719
877
|
)
|
|
720
878
|
|
|
721
879
|
def _start_run_telemetry(self, run_id: int) -> None:
|
|
722
880
|
with self._run_telemetry_lock:
|
|
723
881
|
self._run_telemetry = RunTelemetry(run_id=run_id)
|
|
724
882
|
self._app_server_event_formatter.reset()
|
|
725
|
-
self._opencode_event_formatter.reset()
|
|
726
883
|
|
|
727
884
|
def _update_run_telemetry(self, run_id: int, **updates: Any) -> None:
|
|
728
885
|
with self._run_telemetry_lock:
|
|
@@ -747,6 +904,75 @@ class Engine:
|
|
|
747
904
|
return
|
|
748
905
|
self._run_telemetry = None
|
|
749
906
|
|
|
907
|
+
@staticmethod
|
|
908
|
+
def _normalize_diff_payload(diff: Any) -> Optional[Any]:
|
|
909
|
+
if diff is None:
|
|
910
|
+
return None
|
|
911
|
+
if isinstance(diff, str):
|
|
912
|
+
return diff if diff.strip() else None
|
|
913
|
+
if isinstance(diff, dict):
|
|
914
|
+
# Prefer meaningful fields if present.
|
|
915
|
+
for key in ("diff", "patch", "content", "value"):
|
|
916
|
+
if key in diff:
|
|
917
|
+
val = diff.get(key)
|
|
918
|
+
if isinstance(val, str) and val.strip():
|
|
919
|
+
return val
|
|
920
|
+
if val not in (None, "", [], {}, ()):
|
|
921
|
+
return diff
|
|
922
|
+
for val in diff.values():
|
|
923
|
+
if isinstance(val, str) and val.strip():
|
|
924
|
+
return diff
|
|
925
|
+
if val not in (None, "", [], {}, ()):
|
|
926
|
+
return diff
|
|
927
|
+
return None
|
|
928
|
+
return diff
|
|
929
|
+
|
|
930
|
+
@staticmethod
|
|
931
|
+
def _hash_content(content: str) -> str:
|
|
932
|
+
return hashlib.sha256((content or "").encode("utf-8")).hexdigest()
|
|
933
|
+
|
|
934
|
+
def _serialize_plan_content(
|
|
935
|
+
self,
|
|
936
|
+
plan: Any,
|
|
937
|
+
*,
|
|
938
|
+
redact_enabled: bool,
|
|
939
|
+
run_id: Optional[int] = None,
|
|
940
|
+
) -> str:
|
|
941
|
+
try:
|
|
942
|
+
content = (
|
|
943
|
+
plan
|
|
944
|
+
if isinstance(plan, str)
|
|
945
|
+
else json.dumps(plan, ensure_ascii=True, indent=2, default=str)
|
|
946
|
+
)
|
|
947
|
+
except (TypeError, ValueError) as exc:
|
|
948
|
+
if run_id is not None:
|
|
949
|
+
self._app_server_logger.debug(
|
|
950
|
+
"Failed to serialize plan to JSON for run %s: %s", run_id, exc
|
|
951
|
+
)
|
|
952
|
+
else:
|
|
953
|
+
self._app_server_logger.debug(
|
|
954
|
+
"Failed to serialize plan to JSON: %s", exc
|
|
955
|
+
)
|
|
956
|
+
content = json.dumps({"plan": str(plan)}, ensure_ascii=True, indent=2)
|
|
957
|
+
if redact_enabled:
|
|
958
|
+
content = redact_text(content)
|
|
959
|
+
return content
|
|
960
|
+
|
|
961
|
+
def _serialize_diff_content(
|
|
962
|
+
self, diff: Any, *, redact_enabled: bool
|
|
963
|
+
) -> Optional[str]:
|
|
964
|
+
normalized = self._normalize_diff_payload(diff)
|
|
965
|
+
if normalized is None:
|
|
966
|
+
return None
|
|
967
|
+
content = (
|
|
968
|
+
normalized
|
|
969
|
+
if isinstance(normalized, str)
|
|
970
|
+
else json.dumps(normalized, ensure_ascii=True, indent=2, default=str)
|
|
971
|
+
)
|
|
972
|
+
if redact_enabled:
|
|
973
|
+
content = redact_text(content)
|
|
974
|
+
return content
|
|
975
|
+
|
|
750
976
|
def _maybe_update_run_index_telemetry(
|
|
751
977
|
self, run_id: int, min_interval_seconds: float = 3.0
|
|
752
978
|
) -> None:
|
|
@@ -789,12 +1015,14 @@ class Engine:
|
|
|
789
1015
|
params_raw = message.get("params")
|
|
790
1016
|
params = params_raw if isinstance(params_raw, dict) else {}
|
|
791
1017
|
thread_id = (
|
|
792
|
-
|
|
793
|
-
or
|
|
794
|
-
or
|
|
1018
|
+
extract_thread_id_for_turn(params)
|
|
1019
|
+
or extract_thread_id(params)
|
|
1020
|
+
or extract_thread_id(message)
|
|
795
1021
|
)
|
|
796
|
-
turn_id =
|
|
1022
|
+
turn_id = extract_turn_id(params) or extract_turn_id(message)
|
|
797
1023
|
run_id: Optional[int] = None
|
|
1024
|
+
plan_update: Any = None
|
|
1025
|
+
diff_update: Any = None
|
|
798
1026
|
with self._run_telemetry_lock:
|
|
799
1027
|
telemetry = self._run_telemetry
|
|
800
1028
|
if telemetry is None:
|
|
@@ -819,17 +1047,60 @@ class Engine:
|
|
|
819
1047
|
self._maybe_update_run_index_telemetry(run_id)
|
|
820
1048
|
self._emit_event(run_id, "token.updated", token_total=total)
|
|
821
1049
|
if method == "turn/plan/updated":
|
|
822
|
-
|
|
1050
|
+
plan_update = params.get("plan") if "plan" in params else params
|
|
1051
|
+
telemetry.plan = plan_update
|
|
823
1052
|
if method == "turn/diff/updated":
|
|
824
|
-
diff =
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
telemetry.diff =
|
|
1053
|
+
diff: Any = None
|
|
1054
|
+
for key in ("diff", "patch", "content", "value"):
|
|
1055
|
+
if key in params:
|
|
1056
|
+
diff = params.get(key)
|
|
1057
|
+
break
|
|
1058
|
+
diff_update = diff if diff is not None else params or None
|
|
1059
|
+
telemetry.diff = diff_update
|
|
831
1060
|
if run_id is None:
|
|
832
1061
|
return
|
|
1062
|
+
redact_enabled = self.config.security.get("redact_run_logs", True)
|
|
1063
|
+
notification_path = self._append_run_notification(
|
|
1064
|
+
run_id, message, redact_enabled
|
|
1065
|
+
)
|
|
1066
|
+
if notification_path is not None:
|
|
1067
|
+
self._merge_run_index_entry(
|
|
1068
|
+
run_id,
|
|
1069
|
+
{
|
|
1070
|
+
"artifacts": {
|
|
1071
|
+
"app_server_notifications_path": str(notification_path)
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
)
|
|
1075
|
+
if plan_update is not None:
|
|
1076
|
+
plan_content = self._serialize_plan_content(
|
|
1077
|
+
plan_update, redact_enabled=redact_enabled, run_id=run_id
|
|
1078
|
+
)
|
|
1079
|
+
plan_path = self._write_run_artifact(run_id, "plan.json", plan_content)
|
|
1080
|
+
self._merge_run_index_entry(
|
|
1081
|
+
run_id, {"artifacts": {"plan_path": str(plan_path)}}
|
|
1082
|
+
)
|
|
1083
|
+
self._emit_event(
|
|
1084
|
+
run_id,
|
|
1085
|
+
"plan.updated",
|
|
1086
|
+
plan_hash=self._hash_content(plan_content),
|
|
1087
|
+
plan_path=str(plan_path),
|
|
1088
|
+
)
|
|
1089
|
+
if diff_update is not None:
|
|
1090
|
+
diff_content = self._serialize_diff_content(
|
|
1091
|
+
diff_update, redact_enabled=redact_enabled
|
|
1092
|
+
)
|
|
1093
|
+
if diff_content is not None:
|
|
1094
|
+
diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
|
|
1095
|
+
self._merge_run_index_entry(
|
|
1096
|
+
run_id, {"artifacts": {"diff_path": str(diff_path)}}
|
|
1097
|
+
)
|
|
1098
|
+
self._emit_event(
|
|
1099
|
+
run_id,
|
|
1100
|
+
"diff.updated",
|
|
1101
|
+
diff_hash=self._hash_content(diff_content),
|
|
1102
|
+
diff_path=str(diff_path),
|
|
1103
|
+
)
|
|
833
1104
|
for line in self._app_server_event_formatter.format_event(message):
|
|
834
1105
|
self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
|
|
835
1106
|
|
|
@@ -847,7 +1118,10 @@ class Engine:
|
|
|
847
1118
|
"""
|
|
848
1119
|
try:
|
|
849
1120
|
state = load_state(self.state_path)
|
|
850
|
-
except Exception:
|
|
1121
|
+
except Exception as exc:
|
|
1122
|
+
self._app_server_logger.warning(
|
|
1123
|
+
"Failed to load state during run index reconciliation: %s", exc
|
|
1124
|
+
)
|
|
851
1125
|
return
|
|
852
1126
|
|
|
853
1127
|
active_pid: Optional[int] = None
|
|
@@ -870,7 +1144,10 @@ class Engine:
|
|
|
870
1144
|
now = now_iso()
|
|
871
1145
|
try:
|
|
872
1146
|
index = self._run_index_store.load_all()
|
|
873
|
-
except Exception:
|
|
1147
|
+
except Exception as exc:
|
|
1148
|
+
self._app_server_logger.warning(
|
|
1149
|
+
"Failed to load run index during reconciliation: %s", exc
|
|
1150
|
+
)
|
|
874
1151
|
return
|
|
875
1152
|
|
|
876
1153
|
for key, entry in index.items():
|
|
@@ -917,7 +1194,10 @@ class Engine:
|
|
|
917
1194
|
),
|
|
918
1195
|
},
|
|
919
1196
|
)
|
|
920
|
-
except Exception:
|
|
1197
|
+
except Exception as exc:
|
|
1198
|
+
self._app_server_logger.warning(
|
|
1199
|
+
"Failed to reconcile run index entry for run %d: %s", run_id, exc
|
|
1200
|
+
)
|
|
921
1201
|
continue
|
|
922
1202
|
|
|
923
1203
|
def _merge_run_index_entry(self, run_id: int, updates: dict[str, Any]) -> None:
|
|
@@ -929,6 +1209,9 @@ class Engine:
|
|
|
929
1209
|
marker: str,
|
|
930
1210
|
offset: Optional[tuple[int, int]],
|
|
931
1211
|
exit_code: Optional[int],
|
|
1212
|
+
*,
|
|
1213
|
+
actor: Optional[dict[str, Any]] = None,
|
|
1214
|
+
mode: Optional[dict[str, Any]] = None,
|
|
932
1215
|
) -> None:
|
|
933
1216
|
self._run_index_store.update_marker(
|
|
934
1217
|
run_id,
|
|
@@ -937,6 +1220,8 @@ class Engine:
|
|
|
937
1220
|
exit_code,
|
|
938
1221
|
log_path=str(self.log_path),
|
|
939
1222
|
run_log_path=str(self._run_log_path(run_id)),
|
|
1223
|
+
actor=actor,
|
|
1224
|
+
mode=mode,
|
|
940
1225
|
)
|
|
941
1226
|
|
|
942
1227
|
def _list_from_counts(self, source: list[str], counts: Counter[str]) -> list[str]:
|
|
@@ -1021,7 +1306,10 @@ class Engine:
|
|
|
1021
1306
|
entry_id = int(key)
|
|
1022
1307
|
except (TypeError, ValueError) as exc:
|
|
1023
1308
|
self._app_server_logger.debug(
|
|
1024
|
-
"Failed to parse run index key '%s': %s",
|
|
1309
|
+
"Failed to parse run index key '%s' while resolving run %s: %s",
|
|
1310
|
+
key,
|
|
1311
|
+
run_id,
|
|
1312
|
+
exc,
|
|
1025
1313
|
)
|
|
1026
1314
|
continue
|
|
1027
1315
|
if entry_id >= run_id:
|
|
@@ -1106,7 +1394,52 @@ class Engine:
|
|
|
1106
1394
|
atomic_write(path, content)
|
|
1107
1395
|
return path
|
|
1108
1396
|
|
|
1109
|
-
def
|
|
1397
|
+
def _write_run_usage_artifact(
|
|
1398
|
+
self, run_id: int, payload: dict[str, Any]
|
|
1399
|
+
) -> Optional[Path]:
|
|
1400
|
+
self._ensure_run_log_dir()
|
|
1401
|
+
run_dir = self.log_path.parent / "runs" / str(run_id)
|
|
1402
|
+
try:
|
|
1403
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
1404
|
+
path = run_dir / "usage.json"
|
|
1405
|
+
atomic_write(
|
|
1406
|
+
path,
|
|
1407
|
+
json.dumps(payload, ensure_ascii=True, indent=2, default=str),
|
|
1408
|
+
)
|
|
1409
|
+
return path
|
|
1410
|
+
except OSError as exc:
|
|
1411
|
+
self._app_server_logger.warning(
|
|
1412
|
+
"Failed to write usage artifact for run %s: %s", run_id, exc
|
|
1413
|
+
)
|
|
1414
|
+
return None
|
|
1415
|
+
|
|
1416
|
+
def _app_server_notifications_path(self, run_id: int) -> Path:
|
|
1417
|
+
return (
|
|
1418
|
+
self.log_path.parent
|
|
1419
|
+
/ "runs"
|
|
1420
|
+
/ f"run-{run_id}.app_server.notifications.jsonl"
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
def _append_run_notification(
|
|
1424
|
+
self, run_id: int, message: dict[str, Any], redact_enabled: bool
|
|
1425
|
+
) -> Optional[Path]:
|
|
1426
|
+
self._ensure_run_log_dir()
|
|
1427
|
+
path = self._app_server_notifications_path(run_id)
|
|
1428
|
+
payload = {"ts": timestamp(), "message": message}
|
|
1429
|
+
try:
|
|
1430
|
+
line = json.dumps(payload, ensure_ascii=True, default=str)
|
|
1431
|
+
if redact_enabled:
|
|
1432
|
+
line = redact_text(line)
|
|
1433
|
+
with path.open("a", encoding="utf-8") as f:
|
|
1434
|
+
f.write(line + "\n")
|
|
1435
|
+
except (OSError, IOError, TypeError, ValueError) as exc:
|
|
1436
|
+
self._app_server_logger.warning(
|
|
1437
|
+
"Failed to write app-server notification for run %s: %s", run_id, exc
|
|
1438
|
+
)
|
|
1439
|
+
return None
|
|
1440
|
+
return path
|
|
1441
|
+
|
|
1442
|
+
def _read_log_range(self, run_id: int, entry: dict) -> Optional[str]:
|
|
1110
1443
|
start = entry.get("start_offset")
|
|
1111
1444
|
end = entry.get("end_offset")
|
|
1112
1445
|
if start is None or end is None:
|
|
@@ -1115,7 +1448,9 @@ class Engine:
|
|
|
1115
1448
|
start_offset = int(start)
|
|
1116
1449
|
end_offset = int(end)
|
|
1117
1450
|
except (TypeError, ValueError) as exc:
|
|
1118
|
-
self._app_server_logger.debug(
|
|
1451
|
+
self._app_server_logger.debug(
|
|
1452
|
+
"Failed to parse log range offsets for run %s: %s", run_id, exc
|
|
1453
|
+
)
|
|
1119
1454
|
return None
|
|
1120
1455
|
if end_offset < start_offset:
|
|
1121
1456
|
return None
|
|
@@ -1131,7 +1466,9 @@ class Engine:
|
|
|
1131
1466
|
data = f.read(end_offset - start_offset)
|
|
1132
1467
|
return data.decode("utf-8", errors="replace")
|
|
1133
1468
|
except (FileNotFoundError, OSError) as exc:
|
|
1134
|
-
self._app_server_logger.debug(
|
|
1469
|
+
self._app_server_logger.debug(
|
|
1470
|
+
"Failed to read log range for run %s: %s", run_id, exc
|
|
1471
|
+
)
|
|
1135
1472
|
return None
|
|
1136
1473
|
|
|
1137
1474
|
def _build_app_server_prompt(self, prev_output: Optional[str]) -> str:
|
|
@@ -1154,7 +1491,6 @@ class Engine:
|
|
|
1154
1491
|
prompt,
|
|
1155
1492
|
run_id,
|
|
1156
1493
|
external_stop_flag=external_stop_flag,
|
|
1157
|
-
reuse_supervisor=False,
|
|
1158
1494
|
)
|
|
1159
1495
|
)
|
|
1160
1496
|
except RuntimeError as exc:
|
|
@@ -1166,114 +1502,312 @@ class Engine:
|
|
|
1166
1502
|
return 1
|
|
1167
1503
|
raise
|
|
1168
1504
|
|
|
1169
|
-
async def
|
|
1505
|
+
async def _run_agent_async(
|
|
1170
1506
|
self,
|
|
1507
|
+
*,
|
|
1171
1508
|
agent_id: str,
|
|
1172
1509
|
prompt: str,
|
|
1173
1510
|
run_id: int,
|
|
1174
|
-
|
|
1175
|
-
external_stop_flag: Optional[threading.Event]
|
|
1511
|
+
state: RunnerState,
|
|
1512
|
+
external_stop_flag: Optional[threading.Event],
|
|
1176
1513
|
) -> int:
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1514
|
+
"""
|
|
1515
|
+
Run an agent turn using the specified backend.
|
|
1516
|
+
|
|
1517
|
+
This method is protocol-agnostic - it determines the appropriate
|
|
1518
|
+
model/reasoning parameters based on the agent_id and delegates to
|
|
1519
|
+
either the BackendOrchestrator or _run_agent_backend_async().
|
|
1520
|
+
"""
|
|
1521
|
+
# Determine model and reasoning parameters based on agent
|
|
1522
|
+
if agent_id == "codex":
|
|
1523
|
+
model = state.autorunner_model_override or self.config.codex_model
|
|
1524
|
+
reasoning = state.autorunner_effort_override or self.config.codex_reasoning
|
|
1525
|
+
elif agent_id == "opencode":
|
|
1526
|
+
model = state.autorunner_model_override
|
|
1527
|
+
reasoning = state.autorunner_effort_override
|
|
1528
|
+
else:
|
|
1529
|
+
# Fallback to codex defaults for unknown agents
|
|
1530
|
+
model = state.autorunner_model_override or self.config.codex_model
|
|
1531
|
+
reasoning = state.autorunner_effort_override or self.config.codex_reasoning
|
|
1532
|
+
|
|
1533
|
+
# Use BackendOrchestrator if available, otherwise fall back to old method
|
|
1534
|
+
if agent_id == "codex":
|
|
1535
|
+
session_key = "autorunner"
|
|
1536
|
+
elif agent_id == "opencode":
|
|
1537
|
+
session_key = "autorunner.opencode"
|
|
1538
|
+
else:
|
|
1539
|
+
session_key = "autorunner"
|
|
1540
|
+
|
|
1541
|
+
if self._backend_orchestrator is not None:
|
|
1542
|
+
return await self._run_agent_via_orchestrator(
|
|
1543
|
+
agent_id=agent_id,
|
|
1544
|
+
prompt=prompt,
|
|
1545
|
+
run_id=run_id,
|
|
1546
|
+
state=state,
|
|
1547
|
+
model=model,
|
|
1548
|
+
reasoning=reasoning,
|
|
1549
|
+
session_key=session_key,
|
|
1550
|
+
external_stop_flag=external_stop_flag,
|
|
1182
1551
|
)
|
|
1183
|
-
return 1
|
|
1184
1552
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
state
|
|
1553
|
+
# Fallback to old method for backward compatibility (testing)
|
|
1554
|
+
return await self._run_agent_backend_async(
|
|
1555
|
+
agent_id=agent_id,
|
|
1556
|
+
prompt=prompt,
|
|
1557
|
+
run_id=run_id,
|
|
1558
|
+
state=state,
|
|
1559
|
+
session_key=session_key,
|
|
1560
|
+
model=model,
|
|
1561
|
+
reasoning=reasoning,
|
|
1562
|
+
external_stop_flag=external_stop_flag,
|
|
1191
1563
|
)
|
|
1192
1564
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1565
|
+
async def _run_agent_via_orchestrator(
|
|
1566
|
+
self,
|
|
1567
|
+
*,
|
|
1568
|
+
agent_id: str,
|
|
1569
|
+
prompt: str,
|
|
1570
|
+
run_id: int,
|
|
1571
|
+
state: RunnerState,
|
|
1572
|
+
model: Optional[str],
|
|
1573
|
+
reasoning: Optional[str],
|
|
1574
|
+
session_key: str,
|
|
1575
|
+
external_stop_flag: Optional[threading.Event],
|
|
1576
|
+
) -> int:
|
|
1577
|
+
"""
|
|
1578
|
+
Run an agent turn using the BackendOrchestrator.
|
|
1579
|
+
|
|
1580
|
+
This method uses the orchestrator's protocol-agnostic interface to run
|
|
1581
|
+
a turn on the backend, handling all events and emitting canonical events.
|
|
1582
|
+
"""
|
|
1583
|
+
orchestrator = self._backend_orchestrator
|
|
1584
|
+
assert (
|
|
1585
|
+
orchestrator is not None
|
|
1586
|
+
), "orchestrator should be set when calling this method"
|
|
1209
1587
|
|
|
1210
|
-
|
|
1211
|
-
self._update_run_telemetry(run_id, thread_id=conversation_id)
|
|
1588
|
+
events: asyncio.Queue[Optional[RunEvent]] = asyncio.Queue()
|
|
1212
1589
|
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1590
|
+
async def _produce_events() -> None:
|
|
1591
|
+
try:
|
|
1592
|
+
async for event in orchestrator.run_turn(
|
|
1593
|
+
agent_id=agent_id,
|
|
1594
|
+
state=state,
|
|
1595
|
+
prompt=prompt,
|
|
1596
|
+
model=model,
|
|
1597
|
+
reasoning=reasoning,
|
|
1598
|
+
session_key=session_key,
|
|
1599
|
+
):
|
|
1600
|
+
await events.put(event)
|
|
1601
|
+
except Exception as exc:
|
|
1602
|
+
await events.put(Failed(timestamp=now_iso(), error_message=str(exc)))
|
|
1603
|
+
finally:
|
|
1604
|
+
await events.put(None)
|
|
1223
1605
|
|
|
1224
|
-
|
|
1225
|
-
stop_task
|
|
1606
|
+
producer_task = asyncio.create_task(_produce_events())
|
|
1607
|
+
stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
|
|
1608
|
+
timeout_seconds = self.config.app_server.turn_timeout_seconds
|
|
1609
|
+
timeout_task: Optional[asyncio.Task] = (
|
|
1610
|
+
asyncio.create_task(asyncio.sleep(timeout_seconds))
|
|
1611
|
+
if timeout_seconds
|
|
1612
|
+
else None
|
|
1613
|
+
)
|
|
1226
1614
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
)
|
|
1615
|
+
assistant_messages: list[str] = []
|
|
1616
|
+
final_message: Optional[str] = None
|
|
1617
|
+
failed_error: Optional[str] = None
|
|
1231
1618
|
|
|
1232
1619
|
try:
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
sandbox_policy=sandbox_policy,
|
|
1241
|
-
should_stop=stop_event.is_set,
|
|
1242
|
-
)
|
|
1243
|
-
if result.get("status") != "completed":
|
|
1244
|
-
self.log_line(
|
|
1245
|
-
run_id, f"error: turn failed with status {result.get('status')}"
|
|
1246
|
-
)
|
|
1247
|
-
return 1
|
|
1248
|
-
output = result.get("output", "")
|
|
1249
|
-
if output:
|
|
1250
|
-
self._log_app_server_output(run_id, output.splitlines())
|
|
1251
|
-
output_path = self._write_run_artifact(run_id, "output.txt", output)
|
|
1252
|
-
self._merge_run_index_entry(
|
|
1253
|
-
run_id, {"artifacts": {"output_path": str(output_path)}}
|
|
1620
|
+
while True:
|
|
1621
|
+
get_task = asyncio.create_task(events.get())
|
|
1622
|
+
tasks = {get_task, stop_task}
|
|
1623
|
+
if timeout_task is not None:
|
|
1624
|
+
tasks.add(timeout_task)
|
|
1625
|
+
done, pending = await asyncio.wait(
|
|
1626
|
+
tasks, return_when=asyncio.FIRST_COMPLETED
|
|
1254
1627
|
)
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1628
|
+
|
|
1629
|
+
if get_task in done:
|
|
1630
|
+
event = get_task.result()
|
|
1631
|
+
if event is None:
|
|
1632
|
+
break
|
|
1633
|
+
if isinstance(event, Started) and event.session_id:
|
|
1634
|
+
self._update_run_telemetry(run_id, thread_id=event.session_id)
|
|
1635
|
+
elif isinstance(event, OutputDelta):
|
|
1636
|
+
self._emit_canonical_event(
|
|
1637
|
+
run_id,
|
|
1638
|
+
FlowEventType.AGENT_STREAM_DELTA,
|
|
1639
|
+
{
|
|
1640
|
+
"delta": event.content,
|
|
1641
|
+
"delta_type": event.delta_type,
|
|
1642
|
+
},
|
|
1643
|
+
timestamp_override=event.timestamp,
|
|
1644
|
+
)
|
|
1645
|
+
if event.delta_type in {
|
|
1646
|
+
"assistant_message",
|
|
1647
|
+
"assistant_stream",
|
|
1648
|
+
}:
|
|
1649
|
+
assistant_messages.append(event.content)
|
|
1650
|
+
elif event.delta_type == "log_line":
|
|
1651
|
+
self.log_line(
|
|
1652
|
+
run_id,
|
|
1653
|
+
(
|
|
1654
|
+
f"stdout: {event.content}"
|
|
1655
|
+
if event.content
|
|
1656
|
+
else "stdout: "
|
|
1657
|
+
),
|
|
1658
|
+
)
|
|
1659
|
+
elif isinstance(event, ToolCall):
|
|
1660
|
+
self._emit_canonical_event(
|
|
1661
|
+
run_id,
|
|
1662
|
+
FlowEventType.TOOL_CALL,
|
|
1663
|
+
{
|
|
1664
|
+
"tool_name": event.tool_name,
|
|
1665
|
+
"tool_input": event.tool_input,
|
|
1666
|
+
},
|
|
1667
|
+
timestamp_override=event.timestamp,
|
|
1668
|
+
)
|
|
1669
|
+
elif isinstance(event, ApprovalRequested):
|
|
1670
|
+
self._emit_canonical_event(
|
|
1671
|
+
run_id,
|
|
1672
|
+
FlowEventType.APPROVAL_REQUESTED,
|
|
1673
|
+
{
|
|
1674
|
+
"request_id": event.request_id,
|
|
1675
|
+
"description": event.description,
|
|
1676
|
+
"context": event.context,
|
|
1677
|
+
},
|
|
1678
|
+
timestamp_override=event.timestamp,
|
|
1679
|
+
)
|
|
1680
|
+
elif isinstance(event, TokenUsage):
|
|
1681
|
+
self._emit_canonical_event(
|
|
1682
|
+
run_id,
|
|
1683
|
+
FlowEventType.TOKEN_USAGE,
|
|
1684
|
+
{"usage": event.usage},
|
|
1685
|
+
timestamp_override=event.timestamp,
|
|
1686
|
+
)
|
|
1687
|
+
elif isinstance(event, RunNotice):
|
|
1688
|
+
notice_type = FlowEventType.RUN_STATE_CHANGED
|
|
1689
|
+
if event.kind.endswith("timeout"):
|
|
1690
|
+
notice_type = FlowEventType.RUN_TIMEOUT
|
|
1691
|
+
elif "cancel" in event.kind:
|
|
1692
|
+
notice_type = FlowEventType.RUN_CANCELLED
|
|
1693
|
+
data: dict[str, Any] = {
|
|
1694
|
+
"kind": event.kind,
|
|
1695
|
+
"message": event.message,
|
|
1696
|
+
}
|
|
1697
|
+
if event.data:
|
|
1698
|
+
data["data"] = event.data
|
|
1699
|
+
self._emit_canonical_event(
|
|
1700
|
+
run_id,
|
|
1701
|
+
notice_type,
|
|
1702
|
+
data,
|
|
1703
|
+
timestamp_override=event.timestamp,
|
|
1704
|
+
)
|
|
1705
|
+
elif isinstance(event, Completed):
|
|
1706
|
+
if event.final_message:
|
|
1707
|
+
self._emit_canonical_event(
|
|
1708
|
+
run_id,
|
|
1709
|
+
FlowEventType.AGENT_MESSAGE_COMPLETE,
|
|
1710
|
+
{"final_message": event.final_message},
|
|
1711
|
+
timestamp_override=event.timestamp,
|
|
1712
|
+
)
|
|
1713
|
+
if event.final_message:
|
|
1714
|
+
final_message = event.final_message
|
|
1715
|
+
elif isinstance(event, Failed):
|
|
1716
|
+
self.log_line(
|
|
1717
|
+
run_id,
|
|
1718
|
+
f"error: backend run failed: {event.error_message}",
|
|
1719
|
+
)
|
|
1720
|
+
failed_error = event.error_message
|
|
1721
|
+
|
|
1722
|
+
if stop_task in done:
|
|
1723
|
+
self._last_run_interrupted = True
|
|
1724
|
+
self.log_line(run_id, "info: stop requested; interrupting backend")
|
|
1725
|
+
if not producer_task.done():
|
|
1726
|
+
producer_task.cancel()
|
|
1727
|
+
try:
|
|
1728
|
+
await producer_task
|
|
1729
|
+
except asyncio.CancelledError:
|
|
1730
|
+
pass
|
|
1731
|
+
if timeout_task and not timeout_task.done():
|
|
1732
|
+
timeout_task.cancel()
|
|
1733
|
+
try:
|
|
1734
|
+
await orchestrator.interrupt(agent_id, state)
|
|
1735
|
+
except Exception as exc:
|
|
1736
|
+
self.log_line(run_id, f"interrupt failed: {exc}")
|
|
1737
|
+
if not get_task.done():
|
|
1738
|
+
get_task.cancel()
|
|
1739
|
+
for task in pending:
|
|
1740
|
+
task.cancel()
|
|
1741
|
+
return 0
|
|
1742
|
+
|
|
1743
|
+
if timeout_task and timeout_task in done:
|
|
1744
|
+
if not producer_task.done():
|
|
1745
|
+
producer_task.cancel()
|
|
1746
|
+
try:
|
|
1747
|
+
await producer_task
|
|
1748
|
+
except asyncio.CancelledError:
|
|
1749
|
+
pass
|
|
1750
|
+
try:
|
|
1751
|
+
await orchestrator.interrupt(agent_id, state)
|
|
1752
|
+
except Exception as exc:
|
|
1753
|
+
self.log_line(run_id, f"interrupt failed: {exc}")
|
|
1754
|
+
if not get_task.done():
|
|
1755
|
+
get_task.cancel()
|
|
1756
|
+
for task in pending:
|
|
1757
|
+
task.cancel()
|
|
1758
|
+
return 1
|
|
1259
1759
|
finally:
|
|
1260
|
-
if
|
|
1760
|
+
if not producer_task.done():
|
|
1761
|
+
producer_task.cancel()
|
|
1762
|
+
try:
|
|
1763
|
+
await producer_task
|
|
1764
|
+
except asyncio.CancelledError:
|
|
1765
|
+
pass
|
|
1766
|
+
if timeout_task and not timeout_task.done():
|
|
1767
|
+
timeout_task.cancel()
|
|
1768
|
+
if stop_task and not stop_task.done():
|
|
1261
1769
|
stop_task.cancel()
|
|
1262
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
1263
|
-
await stop_task
|
|
1264
|
-
if stop_event.is_set():
|
|
1265
|
-
await orchestrator.interrupt_turn(
|
|
1266
|
-
self.repo_root, conversation_id, grace_seconds=30.0
|
|
1267
|
-
)
|
|
1268
|
-
self._last_run_interrupted = True
|
|
1269
1770
|
|
|
1270
|
-
|
|
1271
|
-
|
|
1771
|
+
if failed_error:
|
|
1772
|
+
return 1
|
|
1773
|
+
|
|
1774
|
+
output_messages: list[str] = []
|
|
1775
|
+
if final_message:
|
|
1776
|
+
self.log_line(run_id, final_message)
|
|
1777
|
+
output_messages = [final_message]
|
|
1778
|
+
elif assistant_messages:
|
|
1779
|
+
output_messages = assistant_messages
|
|
1780
|
+
|
|
1781
|
+
if output_messages:
|
|
1782
|
+
handle_agent_output(
|
|
1783
|
+
self._log_app_server_output,
|
|
1784
|
+
self._write_run_artifact,
|
|
1785
|
+
self._merge_run_index_entry,
|
|
1786
|
+
run_id,
|
|
1787
|
+
output_messages,
|
|
1788
|
+
)
|
|
1789
|
+
|
|
1790
|
+
context = orchestrator.get_context()
|
|
1791
|
+
if context:
|
|
1792
|
+
turn_id = context.turn_id or orchestrator.get_last_turn_id()
|
|
1793
|
+
thread_info = context.thread_info or orchestrator.get_last_thread_info()
|
|
1794
|
+
token_total = orchestrator.get_last_token_total()
|
|
1795
|
+
self._update_run_telemetry(
|
|
1796
|
+
run_id,
|
|
1797
|
+
turn_id=turn_id,
|
|
1798
|
+
token_total=token_total,
|
|
1799
|
+
)
|
|
1800
|
+
if thread_info:
|
|
1801
|
+
self._update_run_telemetry(run_id, thread_info=thread_info)
|
|
1802
|
+
|
|
1803
|
+
return 0
|
|
1804
|
+
|
|
1805
|
+
async def _run_codex_app_server_async(
|
|
1806
|
+
self,
|
|
1272
1807
|
prompt: str,
|
|
1273
1808
|
run_id: int,
|
|
1274
1809
|
*,
|
|
1275
1810
|
external_stop_flag: Optional[threading.Event] = None,
|
|
1276
|
-
reuse_supervisor: bool = True,
|
|
1277
1811
|
) -> int:
|
|
1278
1812
|
config = self.config
|
|
1279
1813
|
if not config.app_server.command:
|
|
@@ -1282,129 +1816,306 @@ class Engine:
|
|
|
1282
1816
|
"error: app-server backend requires app_server.command to be configured",
|
|
1283
1817
|
)
|
|
1284
1818
|
return 1
|
|
1285
|
-
|
|
1286
|
-
def _env_builder(
|
|
1287
|
-
workspace_root: Path, _workspace_id: str, state_dir: Path
|
|
1288
|
-
) -> dict[str, str]:
|
|
1289
|
-
state_dir.mkdir(parents=True, exist_ok=True)
|
|
1290
|
-
return build_app_server_env(
|
|
1291
|
-
config.app_server.command,
|
|
1292
|
-
workspace_root,
|
|
1293
|
-
state_dir,
|
|
1294
|
-
logger=self._app_server_logger,
|
|
1295
|
-
event_prefix="autorunner",
|
|
1296
|
-
)
|
|
1297
|
-
|
|
1298
|
-
supervisor = (
|
|
1299
|
-
self._ensure_app_server_supervisor(_env_builder)
|
|
1300
|
-
if reuse_supervisor
|
|
1301
|
-
else self._build_app_server_supervisor(_env_builder)
|
|
1302
|
-
)
|
|
1303
1819
|
with state_lock(self.state_path):
|
|
1304
1820
|
state = load_state(self.state_path)
|
|
1305
1821
|
effective_model = state.autorunner_model_override or config.codex_model
|
|
1306
1822
|
effective_effort = state.autorunner_effort_override or config.codex_reasoning
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1823
|
+
return await self._run_agent_backend_async(
|
|
1824
|
+
agent_id="codex",
|
|
1825
|
+
prompt=prompt,
|
|
1826
|
+
run_id=run_id,
|
|
1827
|
+
state=state,
|
|
1828
|
+
session_key="autorunner",
|
|
1829
|
+
model=effective_model,
|
|
1830
|
+
reasoning=effective_effort,
|
|
1831
|
+
external_stop_flag=external_stop_flag,
|
|
1832
|
+
)
|
|
1833
|
+
|
|
1834
|
+
async def _run_agent_backend_async(
|
|
1835
|
+
self,
|
|
1836
|
+
*,
|
|
1837
|
+
agent_id: str,
|
|
1838
|
+
prompt: str,
|
|
1839
|
+
run_id: int,
|
|
1840
|
+
state: RunnerState,
|
|
1841
|
+
session_key: str,
|
|
1842
|
+
model: Optional[str],
|
|
1843
|
+
reasoning: Optional[str],
|
|
1844
|
+
external_stop_flag: Optional[threading.Event],
|
|
1845
|
+
) -> int:
|
|
1846
|
+
if self._backend_factory is None:
|
|
1847
|
+
self.log_line(
|
|
1848
|
+
run_id,
|
|
1849
|
+
f"error: {agent_id} backend factory is not configured for this engine",
|
|
1850
|
+
)
|
|
1851
|
+
return 1
|
|
1852
|
+
|
|
1317
1853
|
try:
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
thread_id = self._app_server_threads.get_thread_id("autorunner")
|
|
1321
|
-
thread_info: Optional[dict[str, Any]] = None
|
|
1322
|
-
if thread_id:
|
|
1323
|
-
try:
|
|
1324
|
-
resume_result = await client.thread_resume(thread_id)
|
|
1325
|
-
resumed = resume_result.get("id")
|
|
1326
|
-
if isinstance(resumed, str) and resumed:
|
|
1327
|
-
thread_id = resumed
|
|
1328
|
-
self._app_server_threads.set_thread_id(
|
|
1329
|
-
"autorunner", thread_id
|
|
1330
|
-
)
|
|
1331
|
-
if isinstance(resume_result, dict):
|
|
1332
|
-
thread_info = resume_result
|
|
1333
|
-
except CodexAppServerError:
|
|
1334
|
-
self._app_server_threads.reset_thread("autorunner")
|
|
1335
|
-
thread_id = None
|
|
1336
|
-
if not thread_id:
|
|
1337
|
-
thread = await client.thread_start(str(self.repo_root))
|
|
1338
|
-
thread_id = thread.get("id")
|
|
1339
|
-
if not isinstance(thread_id, str) or not thread_id:
|
|
1340
|
-
self.log_line(
|
|
1341
|
-
run_id, "error: app-server did not return a thread id"
|
|
1342
|
-
)
|
|
1343
|
-
return 1
|
|
1344
|
-
self._app_server_threads.set_thread_id("autorunner", thread_id)
|
|
1345
|
-
if isinstance(thread, dict):
|
|
1346
|
-
thread_info = thread
|
|
1347
|
-
if thread_id:
|
|
1348
|
-
self._update_run_telemetry(run_id, thread_id=thread_id)
|
|
1349
|
-
turn_kwargs: dict[str, Any] = {}
|
|
1350
|
-
if effective_model:
|
|
1351
|
-
turn_kwargs["model"] = str(effective_model)
|
|
1352
|
-
if effective_effort:
|
|
1353
|
-
turn_kwargs["effort"] = str(effective_effort)
|
|
1354
|
-
handle = await client.turn_start(
|
|
1355
|
-
thread_id,
|
|
1356
|
-
prompt,
|
|
1357
|
-
approval_policy=approval_policy,
|
|
1358
|
-
sandbox_policy=sandbox_policy,
|
|
1359
|
-
**turn_kwargs,
|
|
1854
|
+
backend = self._backend_factory(
|
|
1855
|
+
agent_id, state, self._handle_app_server_notification
|
|
1360
1856
|
)
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
thread_info=thread_info,
|
|
1365
|
-
model=turn_kwargs.get("model"),
|
|
1366
|
-
reasoning_effort=turn_kwargs.get("effort"),
|
|
1857
|
+
except Exception as exc:
|
|
1858
|
+
self.log_line(
|
|
1859
|
+
run_id, f"error: failed to initialize {agent_id} backend: {exc}"
|
|
1367
1860
|
)
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1861
|
+
return 1
|
|
1862
|
+
|
|
1863
|
+
reuse_session = bool(getattr(self.config, "autorunner_reuse_session", False))
|
|
1864
|
+
session_id: Optional[str] = None
|
|
1865
|
+
if reuse_session and self._backend_orchestrator is not None:
|
|
1866
|
+
session_id = self._backend_orchestrator.get_thread_id(session_key)
|
|
1867
|
+
elif reuse_session:
|
|
1868
|
+
with self._app_server_threads_lock:
|
|
1869
|
+
session_id = self._app_server_threads.get_thread_id(session_key)
|
|
1870
|
+
|
|
1871
|
+
try:
|
|
1872
|
+
session_id = await backend.start_session(
|
|
1873
|
+
target={"workspace": str(self.repo_root)},
|
|
1874
|
+
context={"workspace": str(self.repo_root), "session_id": session_id},
|
|
1371
1875
|
)
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
handle,
|
|
1376
|
-
run_id,
|
|
1377
|
-
timeout=turn_timeout,
|
|
1378
|
-
external_stop_flag=external_stop_flag,
|
|
1379
|
-
supervisor=supervisor,
|
|
1876
|
+
except Exception as exc:
|
|
1877
|
+
self.log_line(
|
|
1878
|
+
run_id, f"error: {agent_id} backend failed to start session: {exc}"
|
|
1380
1879
|
)
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1880
|
+
return 1
|
|
1881
|
+
|
|
1882
|
+
if not session_id:
|
|
1883
|
+
self.log_line(
|
|
1884
|
+
run_id, f"error: {agent_id} backend did not return a session id"
|
|
1885
|
+
)
|
|
1886
|
+
return 1
|
|
1887
|
+
|
|
1888
|
+
if reuse_session and self._backend_orchestrator is not None:
|
|
1889
|
+
self._backend_orchestrator.set_thread_id(session_key, session_id)
|
|
1890
|
+
elif reuse_session:
|
|
1891
|
+
with self._app_server_threads_lock:
|
|
1892
|
+
self._app_server_threads.set_thread_id(session_key, session_id)
|
|
1893
|
+
|
|
1894
|
+
self._update_run_telemetry(run_id, thread_id=session_id)
|
|
1895
|
+
|
|
1896
|
+
events: asyncio.Queue[Optional[RunEvent]] = asyncio.Queue()
|
|
1897
|
+
|
|
1898
|
+
async def _produce_events() -> None:
|
|
1899
|
+
try:
|
|
1900
|
+
async for event in backend.run_turn_events(session_id, prompt):
|
|
1901
|
+
await events.put(event)
|
|
1902
|
+
except Exception as exc:
|
|
1903
|
+
await events.put(Failed(timestamp=now_iso(), error_message=str(exc)))
|
|
1904
|
+
finally:
|
|
1905
|
+
await events.put(None)
|
|
1906
|
+
|
|
1907
|
+
producer_task = asyncio.create_task(_produce_events())
|
|
1908
|
+
stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
|
|
1909
|
+
timeout_seconds = self.config.app_server.turn_timeout_seconds
|
|
1910
|
+
timeout_task: Optional[asyncio.Task] = (
|
|
1911
|
+
asyncio.create_task(asyncio.sleep(timeout_seconds))
|
|
1912
|
+
if timeout_seconds
|
|
1913
|
+
else None
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
assistant_messages: list[str] = []
|
|
1917
|
+
final_message: Optional[str] = None
|
|
1918
|
+
failed_error: Optional[str] = None
|
|
1919
|
+
|
|
1920
|
+
try:
|
|
1921
|
+
while True:
|
|
1922
|
+
get_task = asyncio.create_task(events.get())
|
|
1923
|
+
tasks = {get_task, stop_task}
|
|
1924
|
+
if timeout_task is not None:
|
|
1925
|
+
tasks.add(timeout_task)
|
|
1926
|
+
done, pending = await asyncio.wait(
|
|
1927
|
+
tasks, return_when=asyncio.FIRST_COMPLETED
|
|
1387
1928
|
)
|
|
1388
|
-
|
|
1389
|
-
|
|
1929
|
+
|
|
1930
|
+
if get_task in done:
|
|
1931
|
+
event = get_task.result()
|
|
1932
|
+
if event is None:
|
|
1933
|
+
break
|
|
1934
|
+
if isinstance(event, Started) and event.session_id:
|
|
1935
|
+
self._update_run_telemetry(
|
|
1936
|
+
run_id, thread_id=event.session_id, turn_id=event.turn_id
|
|
1937
|
+
)
|
|
1938
|
+
elif isinstance(event, OutputDelta):
|
|
1939
|
+
self._emit_canonical_event(
|
|
1940
|
+
run_id,
|
|
1941
|
+
FlowEventType.AGENT_STREAM_DELTA,
|
|
1942
|
+
{
|
|
1943
|
+
"delta": event.content,
|
|
1944
|
+
"delta_type": event.delta_type,
|
|
1945
|
+
},
|
|
1946
|
+
timestamp_override=event.timestamp,
|
|
1947
|
+
)
|
|
1948
|
+
if event.delta_type in {
|
|
1949
|
+
"assistant_message",
|
|
1950
|
+
"assistant_stream",
|
|
1951
|
+
}:
|
|
1952
|
+
assistant_messages.append(event.content)
|
|
1953
|
+
elif event.delta_type == "log_line":
|
|
1954
|
+
self.log_line(
|
|
1955
|
+
run_id,
|
|
1956
|
+
(
|
|
1957
|
+
f"stdout: {event.content}"
|
|
1958
|
+
if event.content
|
|
1959
|
+
else "stdout: "
|
|
1960
|
+
),
|
|
1961
|
+
)
|
|
1962
|
+
elif isinstance(event, ToolCall):
|
|
1963
|
+
self._emit_canonical_event(
|
|
1964
|
+
run_id,
|
|
1965
|
+
FlowEventType.TOOL_CALL,
|
|
1966
|
+
{
|
|
1967
|
+
"tool_name": event.tool_name,
|
|
1968
|
+
"tool_input": event.tool_input,
|
|
1969
|
+
},
|
|
1970
|
+
timestamp_override=event.timestamp,
|
|
1971
|
+
)
|
|
1972
|
+
elif isinstance(event, ApprovalRequested):
|
|
1973
|
+
self._emit_canonical_event(
|
|
1974
|
+
run_id,
|
|
1975
|
+
FlowEventType.APPROVAL_REQUESTED,
|
|
1976
|
+
{
|
|
1977
|
+
"request_id": event.request_id,
|
|
1978
|
+
"description": event.description,
|
|
1979
|
+
"context": event.context,
|
|
1980
|
+
},
|
|
1981
|
+
timestamp_override=event.timestamp,
|
|
1982
|
+
)
|
|
1983
|
+
elif isinstance(event, TokenUsage):
|
|
1984
|
+
self._emit_canonical_event(
|
|
1985
|
+
run_id,
|
|
1986
|
+
FlowEventType.TOKEN_USAGE,
|
|
1987
|
+
{"usage": event.usage},
|
|
1988
|
+
timestamp_override=event.timestamp,
|
|
1989
|
+
)
|
|
1990
|
+
elif isinstance(event, RunNotice):
|
|
1991
|
+
notice_type = FlowEventType.RUN_STATE_CHANGED
|
|
1992
|
+
if event.kind.endswith("timeout"):
|
|
1993
|
+
notice_type = FlowEventType.RUN_TIMEOUT
|
|
1994
|
+
elif "cancel" in event.kind:
|
|
1995
|
+
notice_type = FlowEventType.RUN_CANCELLED
|
|
1996
|
+
data: dict[str, Any] = {
|
|
1997
|
+
"kind": event.kind,
|
|
1998
|
+
"message": event.message,
|
|
1999
|
+
}
|
|
2000
|
+
if event.data:
|
|
2001
|
+
data["data"] = event.data
|
|
2002
|
+
self._emit_canonical_event(
|
|
2003
|
+
run_id,
|
|
2004
|
+
notice_type,
|
|
2005
|
+
data,
|
|
2006
|
+
timestamp_override=event.timestamp,
|
|
2007
|
+
)
|
|
2008
|
+
elif isinstance(event, Completed):
|
|
2009
|
+
if event.final_message:
|
|
2010
|
+
self._emit_canonical_event(
|
|
2011
|
+
run_id,
|
|
2012
|
+
FlowEventType.AGENT_MESSAGE_COMPLETE,
|
|
2013
|
+
{"final_message": event.final_message},
|
|
2014
|
+
timestamp_override=event.timestamp,
|
|
2015
|
+
)
|
|
2016
|
+
if event.final_message:
|
|
2017
|
+
final_message = event.final_message
|
|
2018
|
+
elif isinstance(event, Failed):
|
|
2019
|
+
self._emit_canonical_event(
|
|
2020
|
+
run_id,
|
|
2021
|
+
FlowEventType.AGENT_FAILED,
|
|
2022
|
+
{"error_message": event.error_message},
|
|
2023
|
+
timestamp_override=event.timestamp,
|
|
2024
|
+
)
|
|
2025
|
+
failed_error = event.error_message
|
|
2026
|
+
continue
|
|
2027
|
+
|
|
2028
|
+
timed_out = timeout_task is not None and timeout_task in done
|
|
2029
|
+
stopped = stop_task in done
|
|
2030
|
+
if timed_out:
|
|
2031
|
+
self.log_line(
|
|
2032
|
+
run_id,
|
|
2033
|
+
"error: app-server turn timed out; interrupting app-server",
|
|
2034
|
+
)
|
|
2035
|
+
self._emit_canonical_event(
|
|
2036
|
+
run_id,
|
|
2037
|
+
FlowEventType.RUN_TIMEOUT,
|
|
2038
|
+
{
|
|
2039
|
+
"context": "app_server_turn",
|
|
2040
|
+
"timeout_seconds": timeout_seconds,
|
|
2041
|
+
},
|
|
2042
|
+
)
|
|
2043
|
+
if stopped:
|
|
2044
|
+
self._last_run_interrupted = True
|
|
2045
|
+
self.log_line(
|
|
2046
|
+
run_id, "info: stop requested; interrupting app-server"
|
|
2047
|
+
)
|
|
2048
|
+
try:
|
|
2049
|
+
await backend.interrupt(session_id)
|
|
2050
|
+
except Exception as exc:
|
|
2051
|
+
self.log_line(run_id, f"error: app-server interrupt failed: {exc}")
|
|
2052
|
+
|
|
2053
|
+
done_after_interrupt, _pending = await asyncio.wait(
|
|
2054
|
+
{producer_task}, timeout=AUTORUNNER_INTERRUPT_GRACE_SECONDS
|
|
1390
2055
|
)
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
2056
|
+
if not done_after_interrupt:
|
|
2057
|
+
await self._cancel_task_with_notice(
|
|
2058
|
+
run_id, producer_task, name="producer_task"
|
|
2059
|
+
)
|
|
2060
|
+
if stopped:
|
|
2061
|
+
return 0
|
|
2062
|
+
return 1
|
|
2063
|
+
if stopped:
|
|
2064
|
+
return 0
|
|
1394
2065
|
return 1
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
self.log_line(run_id, "error: app-server turn timed out")
|
|
1398
|
-
return 1
|
|
1399
|
-
except CodexAppServerError as exc:
|
|
1400
|
-
self.log_line(run_id, f"error: {exc}")
|
|
1401
|
-
return 1
|
|
1402
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
1403
|
-
self.log_line(run_id, f"error: app-server failed: {exc}")
|
|
1404
|
-
return 1
|
|
2066
|
+
|
|
2067
|
+
await producer_task
|
|
1405
2068
|
finally:
|
|
1406
|
-
|
|
1407
|
-
|
|
2069
|
+
await self._cancel_task_with_notice(run_id, stop_task, name="stop_task")
|
|
2070
|
+
if timeout_task is not None:
|
|
2071
|
+
await self._cancel_task_with_notice(
|
|
2072
|
+
run_id, timeout_task, name="timeout_task"
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
if failed_error:
|
|
2076
|
+
self.log_line(run_id, f"error: {failed_error}")
|
|
2077
|
+
return 1
|
|
2078
|
+
|
|
2079
|
+
output_messages = []
|
|
2080
|
+
if final_message:
|
|
2081
|
+
output_messages = [final_message]
|
|
2082
|
+
elif assistant_messages:
|
|
2083
|
+
output_messages = assistant_messages
|
|
2084
|
+
|
|
2085
|
+
if output_messages:
|
|
2086
|
+
handle_agent_output(
|
|
2087
|
+
self._log_app_server_output,
|
|
2088
|
+
self._write_run_artifact,
|
|
2089
|
+
self._merge_run_index_entry,
|
|
2090
|
+
run_id,
|
|
2091
|
+
output_messages,
|
|
2092
|
+
)
|
|
2093
|
+
|
|
2094
|
+
token_total = getattr(backend, "last_token_total", None)
|
|
2095
|
+
if isinstance(token_total, dict):
|
|
2096
|
+
self._update_run_telemetry(run_id, token_total=token_total)
|
|
2097
|
+
|
|
2098
|
+
telemetry = self._snapshot_run_telemetry(run_id)
|
|
2099
|
+
turn_id = None
|
|
2100
|
+
if telemetry is not None:
|
|
2101
|
+
turn_id = telemetry.turn_id
|
|
2102
|
+
if not turn_id:
|
|
2103
|
+
turn_id = getattr(backend, "last_turn_id", None)
|
|
2104
|
+
thread_info = getattr(backend, "last_thread_info", None)
|
|
2105
|
+
|
|
2106
|
+
if session_id and turn_id:
|
|
2107
|
+
app_server_meta = self._build_app_server_meta(
|
|
2108
|
+
thread_id=session_id,
|
|
2109
|
+
turn_id=turn_id,
|
|
2110
|
+
thread_info=thread_info if isinstance(thread_info, dict) else None,
|
|
2111
|
+
model=model,
|
|
2112
|
+
reasoning_effort=reasoning,
|
|
2113
|
+
)
|
|
2114
|
+
if agent_id != "codex":
|
|
2115
|
+
app_server_meta["agent"] = agent_id
|
|
2116
|
+
self._merge_run_index_entry(run_id, {"app_server": app_server_meta})
|
|
2117
|
+
|
|
2118
|
+
return 0
|
|
1408
2119
|
|
|
1409
2120
|
def _log_app_server_output(self, run_id: int, messages: list[str]) -> None:
|
|
1410
2121
|
if not messages:
|
|
@@ -1419,13 +2130,12 @@ class Engine:
|
|
|
1419
2130
|
msg = self.config.git_commit_message_template.replace(
|
|
1420
2131
|
"{run_id}", str(run_id)
|
|
1421
2132
|
).replace("#{run_id}", str(run_id))
|
|
1422
|
-
paths = [
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
]
|
|
2133
|
+
paths = []
|
|
2134
|
+
for key in ("active_context", "decisions", "spec"):
|
|
2135
|
+
try:
|
|
2136
|
+
paths.append(self.config.doc_path(key))
|
|
2137
|
+
except KeyError:
|
|
2138
|
+
pass
|
|
1429
2139
|
add_paths = [str(p.relative_to(self.repo_root)) for p in paths if p.exists()]
|
|
1430
2140
|
if not add_paths:
|
|
1431
2141
|
return
|
|
@@ -1455,26 +2165,36 @@ class Engine:
|
|
|
1455
2165
|
except GitError as exc:
|
|
1456
2166
|
self.log_line(run_id, f"git commit failed: {exc}")
|
|
1457
2167
|
|
|
1458
|
-
def
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
config = self.config.app_server
|
|
1462
|
-
return WorkspaceAppServerSupervisor(
|
|
1463
|
-
config.command,
|
|
1464
|
-
state_root=config.state_root,
|
|
1465
|
-
env_builder=env_builder,
|
|
1466
|
-
logger=self._app_server_logger,
|
|
1467
|
-
notification_handler=self._handle_app_server_notification,
|
|
1468
|
-
max_handles=config.max_handles,
|
|
1469
|
-
idle_ttl_seconds=config.idle_ttl_seconds,
|
|
1470
|
-
request_timeout=config.request_timeout,
|
|
1471
|
-
)
|
|
2168
|
+
def _ensure_app_server_supervisor(self, event_prefix: str) -> Optional[Any]:
|
|
2169
|
+
"""
|
|
2170
|
+
Ensure app server supervisor exists by delegating to BackendOrchestrator.
|
|
1472
2171
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
2172
|
+
This method is kept for backward compatibility but now delegates to
|
|
2173
|
+
BackendOrchestrator to keep Engine protocol-agnostic.
|
|
2174
|
+
"""
|
|
1476
2175
|
if self._app_server_supervisor is None:
|
|
1477
|
-
|
|
2176
|
+
if (
|
|
2177
|
+
self._backend_orchestrator is None
|
|
2178
|
+
and self._app_server_supervisor_factory is not None
|
|
2179
|
+
):
|
|
2180
|
+
self._app_server_supervisor = self._app_server_supervisor_factory(
|
|
2181
|
+
event_prefix, self._handle_app_server_notification
|
|
2182
|
+
)
|
|
2183
|
+
elif self._backend_orchestrator is not None:
|
|
2184
|
+
try:
|
|
2185
|
+
self._app_server_supervisor = (
|
|
2186
|
+
self._backend_orchestrator.build_app_server_supervisor(
|
|
2187
|
+
event_prefix=event_prefix,
|
|
2188
|
+
notification_handler=self._handle_app_server_notification,
|
|
2189
|
+
)
|
|
2190
|
+
)
|
|
2191
|
+
except Exception:
|
|
2192
|
+
if self._app_server_supervisor_factory is not None:
|
|
2193
|
+
self._app_server_supervisor = (
|
|
2194
|
+
self._app_server_supervisor_factory(
|
|
2195
|
+
event_prefix, self._handle_app_server_notification
|
|
2196
|
+
)
|
|
2197
|
+
)
|
|
1478
2198
|
return self._app_server_supervisor
|
|
1479
2199
|
|
|
1480
2200
|
async def _close_app_server_supervisor(self) -> None:
|
|
@@ -1483,45 +2203,49 @@ class Engine:
|
|
|
1483
2203
|
supervisor = self._app_server_supervisor
|
|
1484
2204
|
self._app_server_supervisor = None
|
|
1485
2205
|
try:
|
|
1486
|
-
|
|
2206
|
+
close_all = getattr(supervisor, "close_all", None)
|
|
2207
|
+
if close_all is None:
|
|
2208
|
+
return
|
|
2209
|
+
result = close_all()
|
|
2210
|
+
if inspect.isawaitable(result):
|
|
2211
|
+
await result
|
|
1487
2212
|
except Exception as exc:
|
|
1488
2213
|
self._app_server_logger.warning(
|
|
1489
2214
|
"app-server supervisor close failed: %s", exc
|
|
1490
2215
|
)
|
|
1491
2216
|
|
|
1492
|
-
def
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
2217
|
+
async def _close_agent_backends(self) -> None:
|
|
2218
|
+
if self._backend_factory is None:
|
|
2219
|
+
return
|
|
2220
|
+
close_all = getattr(self._backend_factory, "close_all", None)
|
|
2221
|
+
if close_all is None:
|
|
2222
|
+
return
|
|
1496
2223
|
try:
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
subagent_models = agent_config.subagent_models if agent_config else None
|
|
2224
|
+
result = close_all()
|
|
2225
|
+
if inspect.isawaitable(result):
|
|
2226
|
+
await result
|
|
2227
|
+
except Exception as exc:
|
|
2228
|
+
self._app_server_logger.warning("agent backend close failed: %s", exc)
|
|
1503
2229
|
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
workspace_root=self.repo_root,
|
|
1508
|
-
logger=self._app_server_logger,
|
|
1509
|
-
request_timeout=config.request_timeout,
|
|
1510
|
-
max_handles=config.max_handles,
|
|
1511
|
-
idle_ttl_seconds=config.idle_ttl_seconds,
|
|
1512
|
-
base_env=None,
|
|
1513
|
-
subagent_models=subagent_models,
|
|
1514
|
-
)
|
|
2230
|
+
def _build_opencode_supervisor(self) -> Optional[Any]:
|
|
2231
|
+
"""
|
|
2232
|
+
Build OpenCode supervisor by delegating to BackendOrchestrator.
|
|
1515
2233
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
2234
|
+
This method is kept for backward compatibility but now delegates to
|
|
2235
|
+
BackendOrchestrator to keep Engine protocol-agnostic.
|
|
2236
|
+
"""
|
|
2237
|
+
if self._backend_orchestrator is None:
|
|
1520
2238
|
return None
|
|
1521
2239
|
|
|
1522
|
-
return
|
|
2240
|
+
return self._backend_orchestrator.ensure_opencode_supervisor()
|
|
2241
|
+
|
|
2242
|
+
def _ensure_opencode_supervisor(self) -> Optional[Any]:
|
|
2243
|
+
"""
|
|
2244
|
+
Ensure OpenCode supervisor exists by delegating to BackendOrchestrator.
|
|
1523
2245
|
|
|
1524
|
-
|
|
2246
|
+
This method is kept for backward compatibility but now delegates to
|
|
2247
|
+
BackendOrchestrator to keep Engine protocol-agnostic.
|
|
2248
|
+
"""
|
|
1525
2249
|
if self._opencode_supervisor is None:
|
|
1526
2250
|
self._opencode_supervisor = self._build_opencode_supervisor()
|
|
1527
2251
|
return self._opencode_supervisor
|
|
@@ -1536,22 +2260,6 @@ class Engine:
|
|
|
1536
2260
|
except Exception as exc:
|
|
1537
2261
|
self._app_server_logger.warning("opencode supervisor close failed: %s", exc)
|
|
1538
2262
|
|
|
1539
|
-
def _get_orchestrator(self, agent_id: str):
|
|
1540
|
-
if agent_id == "opencode":
|
|
1541
|
-
opencode_sup = self._ensure_opencode_supervisor()
|
|
1542
|
-
if opencode_sup is None:
|
|
1543
|
-
return None
|
|
1544
|
-
return create_orchestrator(agent_id, opencode_supervisor=opencode_sup)
|
|
1545
|
-
else:
|
|
1546
|
-
app_server_sup = self._ensure_app_server_supervisor(
|
|
1547
|
-
lambda workspace_root, workspace_id, state_dir: {}
|
|
1548
|
-
)
|
|
1549
|
-
return create_orchestrator(
|
|
1550
|
-
agent_id,
|
|
1551
|
-
codex_supervisor=app_server_sup,
|
|
1552
|
-
codex_events=self._app_server_events,
|
|
1553
|
-
)
|
|
1554
|
-
|
|
1555
2263
|
async def _wait_for_stop(
|
|
1556
2264
|
self,
|
|
1557
2265
|
external_stop_flag: Optional[threading.Event],
|
|
@@ -1570,7 +2278,7 @@ class Engine:
|
|
|
1570
2278
|
*,
|
|
1571
2279
|
timeout: Optional[float],
|
|
1572
2280
|
external_stop_flag: Optional[threading.Event],
|
|
1573
|
-
supervisor: Optional[
|
|
2281
|
+
supervisor: Optional[Any] = None,
|
|
1574
2282
|
) -> tuple[Any, bool]:
|
|
1575
2283
|
stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
|
|
1576
2284
|
turn_task = asyncio.create_task(handle.wait(timeout=None))
|
|
@@ -1594,6 +2302,11 @@ class Engine:
|
|
|
1594
2302
|
self.log_line(
|
|
1595
2303
|
run_id, "error: app-server turn timed out; interrupting app-server"
|
|
1596
2304
|
)
|
|
2305
|
+
self._emit_canonical_event(
|
|
2306
|
+
run_id,
|
|
2307
|
+
FlowEventType.RUN_TIMEOUT,
|
|
2308
|
+
{"context": "app_server_turn", "timeout_seconds": timeout},
|
|
2309
|
+
)
|
|
1597
2310
|
if stopped and not turn_task.done():
|
|
1598
2311
|
interrupted = True
|
|
1599
2312
|
self.log_line(run_id, "info: stop requested; interrupting app-server")
|
|
@@ -1602,7 +2315,7 @@ class Engine:
|
|
|
1602
2315
|
await client.turn_interrupt(
|
|
1603
2316
|
handle.turn_id, thread_id=handle.thread_id
|
|
1604
2317
|
)
|
|
1605
|
-
except
|
|
2318
|
+
except Exception as exc:
|
|
1606
2319
|
self.log_line(run_id, f"error: app-server interrupt failed: {exc}")
|
|
1607
2320
|
if interrupted:
|
|
1608
2321
|
self.kill_running_process()
|
|
@@ -1617,7 +2330,7 @@ class Engine:
|
|
|
1617
2330
|
)
|
|
1618
2331
|
if interrupted:
|
|
1619
2332
|
self.kill_running_process()
|
|
1620
|
-
raise
|
|
2333
|
+
raise RuntimeError("App-server interrupt timed out")
|
|
1621
2334
|
if supervisor is not None:
|
|
1622
2335
|
await supervisor.close_all()
|
|
1623
2336
|
raise asyncio.TimeoutError()
|
|
@@ -1626,254 +2339,11 @@ class Engine:
|
|
|
1626
2339
|
raise asyncio.TimeoutError()
|
|
1627
2340
|
return result, interrupted
|
|
1628
2341
|
finally:
|
|
1629
|
-
|
|
1630
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
1631
|
-
await stop_task
|
|
1632
|
-
if timeout_task is not None:
|
|
1633
|
-
timeout_task.cancel()
|
|
1634
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
1635
|
-
await timeout_task
|
|
1636
|
-
|
|
1637
|
-
async def _abort_opencode(self, client: Any, session_id: str, run_id: int) -> None:
|
|
1638
|
-
try:
|
|
1639
|
-
await client.abort(session_id)
|
|
1640
|
-
except Exception as exc:
|
|
1641
|
-
self.log_line(run_id, f"error: opencode abort failed: {exc}")
|
|
1642
|
-
|
|
1643
|
-
async def _run_opencode_app_server_async(
|
|
1644
|
-
self,
|
|
1645
|
-
prompt: str,
|
|
1646
|
-
run_id: int,
|
|
1647
|
-
*,
|
|
1648
|
-
model: Optional[str],
|
|
1649
|
-
reasoning: Optional[str],
|
|
1650
|
-
external_stop_flag: Optional[threading.Event] = None,
|
|
1651
|
-
) -> int:
|
|
1652
|
-
supervisor = self._ensure_opencode_supervisor()
|
|
1653
|
-
if supervisor is None:
|
|
1654
|
-
self.log_line(
|
|
1655
|
-
run_id, "error: opencode backend is not configured in this repo"
|
|
1656
|
-
)
|
|
1657
|
-
return 1
|
|
1658
|
-
try:
|
|
1659
|
-
client = await supervisor.get_client(self.repo_root)
|
|
1660
|
-
except OpenCodeSupervisorError as exc:
|
|
1661
|
-
self.log_line(run_id, f"error: opencode backend unavailable: {exc}")
|
|
1662
|
-
return 1
|
|
1663
|
-
|
|
1664
|
-
with self._app_server_threads_lock:
|
|
1665
|
-
key = "autorunner.opencode"
|
|
1666
|
-
thread_id = self._app_server_threads.get_thread_id(key)
|
|
1667
|
-
if thread_id:
|
|
1668
|
-
try:
|
|
1669
|
-
await client.get_session(thread_id)
|
|
1670
|
-
except Exception as exc:
|
|
1671
|
-
self._app_server_logger.debug(
|
|
1672
|
-
"Failed to get existing opencode session '%s': %s",
|
|
1673
|
-
thread_id,
|
|
1674
|
-
exc,
|
|
1675
|
-
)
|
|
1676
|
-
self._app_server_threads.reset_thread(key)
|
|
1677
|
-
thread_id = None
|
|
1678
|
-
if not thread_id:
|
|
1679
|
-
session = await client.create_session(directory=str(self.repo_root))
|
|
1680
|
-
thread_id = extract_session_id(session, allow_fallback_id=True)
|
|
1681
|
-
if not isinstance(thread_id, str) or not thread_id:
|
|
1682
|
-
self.log_line(run_id, "error: opencode did not return a session id")
|
|
1683
|
-
return 1
|
|
1684
|
-
self._app_server_threads.set_thread_id(key, thread_id)
|
|
1685
|
-
|
|
1686
|
-
model_payload = split_model_id(model)
|
|
1687
|
-
missing_env = await opencode_missing_env(
|
|
1688
|
-
client, str(self.repo_root), model_payload
|
|
1689
|
-
)
|
|
1690
|
-
if missing_env:
|
|
1691
|
-
provider_id = model_payload.get("providerID") if model_payload else None
|
|
1692
|
-
self.log_line(
|
|
1693
|
-
run_id,
|
|
1694
|
-
"error: opencode provider "
|
|
1695
|
-
f"{provider_id or 'selected'} requires env vars: "
|
|
1696
|
-
f"{', '.join(missing_env)}",
|
|
1697
|
-
)
|
|
1698
|
-
return 1
|
|
1699
|
-
opencode_turn_started = False
|
|
1700
|
-
await supervisor.mark_turn_started(self.repo_root)
|
|
1701
|
-
opencode_turn_started = True
|
|
1702
|
-
turn_id = build_turn_id(thread_id)
|
|
1703
|
-
self._update_run_telemetry(run_id, thread_id=thread_id, turn_id=turn_id)
|
|
1704
|
-
app_server_meta = self._build_app_server_meta(
|
|
1705
|
-
thread_id=thread_id,
|
|
1706
|
-
turn_id=turn_id,
|
|
1707
|
-
thread_info=None,
|
|
1708
|
-
model=model,
|
|
1709
|
-
reasoning_effort=reasoning,
|
|
1710
|
-
)
|
|
1711
|
-
app_server_meta["agent"] = "opencode"
|
|
1712
|
-
self._merge_run_index_entry(run_id, {"app_server": app_server_meta})
|
|
1713
|
-
|
|
1714
|
-
active = ActiveOpencodeRun(
|
|
1715
|
-
session_id=thread_id,
|
|
1716
|
-
turn_id=turn_id,
|
|
1717
|
-
client=client,
|
|
1718
|
-
interrupted=False,
|
|
1719
|
-
interrupt_event=asyncio.Event(),
|
|
1720
|
-
)
|
|
1721
|
-
with state_lock(self.state_path):
|
|
1722
|
-
state = load_state(self.state_path)
|
|
1723
|
-
permission_policy = map_approval_policy_to_permission(
|
|
1724
|
-
state.autorunner_approval_policy, default="allow"
|
|
1725
|
-
)
|
|
1726
|
-
|
|
1727
|
-
async def _opencode_part_handler(
|
|
1728
|
-
part_type: str, part: dict[str, Any], delta_text: Optional[str]
|
|
1729
|
-
) -> None:
|
|
1730
|
-
if part_type == "usage" and isinstance(part, dict):
|
|
1731
|
-
for line in self._opencode_event_formatter.format_usage(part):
|
|
1732
|
-
self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
|
|
1733
|
-
else:
|
|
1734
|
-
for line in self._opencode_event_formatter.format_part(
|
|
1735
|
-
part_type, part, delta_text
|
|
1736
|
-
):
|
|
1737
|
-
self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
|
|
1738
|
-
|
|
1739
|
-
ready_event = asyncio.Event()
|
|
1740
|
-
output_task = asyncio.create_task(
|
|
1741
|
-
collect_opencode_output(
|
|
1742
|
-
client,
|
|
1743
|
-
session_id=thread_id,
|
|
1744
|
-
workspace_path=str(self.repo_root),
|
|
1745
|
-
permission_policy=permission_policy,
|
|
1746
|
-
question_policy="auto_first_option",
|
|
1747
|
-
should_stop=active.interrupt_event.is_set,
|
|
1748
|
-
part_handler=_opencode_part_handler,
|
|
1749
|
-
ready_event=ready_event,
|
|
1750
|
-
)
|
|
1751
|
-
)
|
|
1752
|
-
with contextlib.suppress(asyncio.TimeoutError):
|
|
1753
|
-
await asyncio.wait_for(ready_event.wait(), timeout=2.0)
|
|
1754
|
-
prompt_task = asyncio.create_task(
|
|
1755
|
-
client.prompt_async(
|
|
1756
|
-
thread_id,
|
|
1757
|
-
message=prompt,
|
|
1758
|
-
model=model_payload,
|
|
1759
|
-
variant=reasoning,
|
|
1760
|
-
)
|
|
1761
|
-
)
|
|
1762
|
-
stop_task = asyncio.create_task(self._wait_for_stop(external_stop_flag))
|
|
1763
|
-
timeout_task = None
|
|
1764
|
-
turn_timeout = self.config.app_server.turn_timeout_seconds
|
|
1765
|
-
if turn_timeout:
|
|
1766
|
-
timeout_task = asyncio.create_task(asyncio.sleep(turn_timeout))
|
|
1767
|
-
timed_out = False
|
|
1768
|
-
try:
|
|
1769
|
-
try:
|
|
1770
|
-
prompt_response = await prompt_task
|
|
1771
|
-
prompt_info = (
|
|
1772
|
-
prompt_response.get("info")
|
|
1773
|
-
if isinstance(prompt_response, dict)
|
|
1774
|
-
else {}
|
|
1775
|
-
)
|
|
1776
|
-
tokens = (
|
|
1777
|
-
prompt_info.get("tokens") if isinstance(prompt_info, dict) else {}
|
|
1778
|
-
)
|
|
1779
|
-
if isinstance(tokens, dict):
|
|
1780
|
-
input_tokens = int(tokens.get("input", 0) or 0)
|
|
1781
|
-
cached_read = (
|
|
1782
|
-
int(tokens.get("cache", {}).get("read", 0) or 0)
|
|
1783
|
-
if isinstance(tokens.get("cache"), dict)
|
|
1784
|
-
else 0
|
|
1785
|
-
)
|
|
1786
|
-
output_tokens = int(tokens.get("output", 0) or 0)
|
|
1787
|
-
reasoning_tokens = int(tokens.get("reasoning", 0) or 0)
|
|
1788
|
-
total_tokens = (
|
|
1789
|
-
input_tokens + cached_read + output_tokens + reasoning_tokens
|
|
1790
|
-
)
|
|
1791
|
-
token_total = {
|
|
1792
|
-
"total": total_tokens,
|
|
1793
|
-
"input_tokens": input_tokens,
|
|
1794
|
-
"prompt_tokens": input_tokens,
|
|
1795
|
-
"cached_input_tokens": cached_read,
|
|
1796
|
-
"output_tokens": output_tokens,
|
|
1797
|
-
"completion_tokens": output_tokens,
|
|
1798
|
-
"reasoning_tokens": reasoning_tokens,
|
|
1799
|
-
"reasoning_output_tokens": reasoning_tokens,
|
|
1800
|
-
}
|
|
1801
|
-
self._update_run_telemetry(run_id, token_total=token_total)
|
|
1802
|
-
except Exception as exc:
|
|
1803
|
-
active.interrupt_event.set()
|
|
1804
|
-
output_task.cancel()
|
|
1805
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
1806
|
-
await output_task
|
|
1807
|
-
self.log_line(run_id, f"error: opencode prompt failed: {exc}")
|
|
1808
|
-
return 1
|
|
1809
|
-
tasks = {output_task, stop_task}
|
|
1810
|
-
if timeout_task is not None:
|
|
1811
|
-
tasks.add(timeout_task)
|
|
1812
|
-
done, _pending = await asyncio.wait(
|
|
1813
|
-
tasks, return_when=asyncio.FIRST_COMPLETED
|
|
1814
|
-
)
|
|
1815
|
-
timed_out = timeout_task is not None and timeout_task in done
|
|
1816
|
-
stopped = stop_task in done
|
|
1817
|
-
if timed_out:
|
|
1818
|
-
self.log_line(
|
|
1819
|
-
run_id, "error: opencode turn timed out; aborting session"
|
|
1820
|
-
)
|
|
1821
|
-
active.interrupt_event.set()
|
|
1822
|
-
if stopped:
|
|
1823
|
-
active.interrupted = True
|
|
1824
|
-
active.interrupt_event.set()
|
|
1825
|
-
self.log_line(run_id, "info: stop requested; aborting opencode")
|
|
1826
|
-
if timed_out or stopped:
|
|
1827
|
-
await self._abort_opencode(client, thread_id, run_id)
|
|
1828
|
-
done, _pending = await asyncio.wait(
|
|
1829
|
-
{output_task}, timeout=AUTORUNNER_INTERRUPT_GRACE_SECONDS
|
|
1830
|
-
)
|
|
1831
|
-
if not done:
|
|
1832
|
-
output_task.cancel()
|
|
1833
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
1834
|
-
await output_task
|
|
1835
|
-
if timed_out:
|
|
1836
|
-
return 1
|
|
1837
|
-
self._last_run_interrupted = active.interrupted
|
|
1838
|
-
return 0
|
|
1839
|
-
output_result = await output_task
|
|
1840
|
-
if not output_result.text and not output_result.error:
|
|
1841
|
-
fallback = parse_message_response(prompt_response)
|
|
1842
|
-
if fallback.text:
|
|
1843
|
-
output_result = OpenCodeTurnOutput(
|
|
1844
|
-
text=fallback.text, error=fallback.error
|
|
1845
|
-
)
|
|
1846
|
-
finally:
|
|
1847
|
-
stop_task.cancel()
|
|
1848
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
1849
|
-
await stop_task
|
|
2342
|
+
await self._cancel_task_with_notice(run_id, stop_task, name="stop_task")
|
|
1850
2343
|
if timeout_task is not None:
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
await timeout_task
|
|
1854
|
-
if opencode_turn_started:
|
|
1855
|
-
await supervisor.mark_turn_finished(self.repo_root)
|
|
1856
|
-
|
|
1857
|
-
output = output_result.text
|
|
1858
|
-
if output:
|
|
1859
|
-
self._log_app_server_output(run_id, [output])
|
|
1860
|
-
output_text = output.strip()
|
|
1861
|
-
if output_text:
|
|
1862
|
-
output_path = self._write_run_artifact(
|
|
1863
|
-
run_id, "output.txt", output_text
|
|
2344
|
+
await self._cancel_task_with_notice(
|
|
2345
|
+
run_id, timeout_task, name="timeout_task"
|
|
1864
2346
|
)
|
|
1865
|
-
self._merge_run_index_entry(
|
|
1866
|
-
run_id, {"artifacts": {"output_path": str(output_path)}}
|
|
1867
|
-
)
|
|
1868
|
-
if output_result.error:
|
|
1869
|
-
self.log_line(
|
|
1870
|
-
run_id, f"error: opencode session error: {output_result.error}"
|
|
1871
|
-
)
|
|
1872
|
-
return 1
|
|
1873
|
-
self._last_run_interrupted = active.interrupted
|
|
1874
|
-
if timed_out:
|
|
1875
|
-
return 1
|
|
1876
|
-
return 0
|
|
1877
2347
|
|
|
1878
2348
|
async def _run_loop_async(
|
|
1879
2349
|
self,
|
|
@@ -1894,8 +2364,10 @@ class Engine:
|
|
|
1894
2364
|
)
|
|
1895
2365
|
)
|
|
1896
2366
|
no_progress_count = 0
|
|
1897
|
-
|
|
1898
|
-
|
|
2367
|
+
ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
|
|
2368
|
+
initial_tickets = list_ticket_paths(ticket_dir)
|
|
2369
|
+
last_done_count = sum(1 for path in initial_tickets if ticket_is_done(path))
|
|
2370
|
+
last_outstanding_count = len(initial_tickets) - last_done_count
|
|
1899
2371
|
exit_reason: Optional[str] = None
|
|
1900
2372
|
|
|
1901
2373
|
try:
|
|
@@ -1949,9 +2421,11 @@ class Engine:
|
|
|
1949
2421
|
break
|
|
1950
2422
|
|
|
1951
2423
|
# Check for no progress across runs
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2424
|
+
current_tickets = list_ticket_paths(ticket_dir)
|
|
2425
|
+
current_done_count = sum(
|
|
2426
|
+
1 for path in current_tickets if ticket_is_done(path)
|
|
2427
|
+
)
|
|
2428
|
+
current_outstanding_count = len(current_tickets) - current_done_count
|
|
1955
2429
|
|
|
1956
2430
|
# Check if there was any meaningful progress
|
|
1957
2431
|
has_progress = (
|
|
@@ -1959,25 +2433,55 @@ class Engine:
|
|
|
1959
2433
|
or current_done_count != last_done_count
|
|
1960
2434
|
)
|
|
1961
2435
|
|
|
1962
|
-
# Check if there was any meaningful output (diff,
|
|
2436
|
+
# Check if there was any meaningful output (diff, plan, etc.)
|
|
1963
2437
|
has_output = False
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2438
|
+
run_entry = self._run_index_store.get_entry(run_id)
|
|
2439
|
+
if run_entry:
|
|
2440
|
+
artifacts = run_entry.get("artifacts", {})
|
|
2441
|
+
if isinstance(artifacts, dict):
|
|
2442
|
+
diff_path = artifacts.get("diff_path")
|
|
2443
|
+
if diff_path:
|
|
2444
|
+
try:
|
|
2445
|
+
diff_content = (
|
|
2446
|
+
Path(diff_path).read_text(encoding="utf-8").strip()
|
|
2447
|
+
)
|
|
2448
|
+
has_output = len(diff_content) > 0
|
|
2449
|
+
except (OSError, IOError):
|
|
2450
|
+
pass
|
|
2451
|
+
if not has_output:
|
|
2452
|
+
plan_path = artifacts.get("plan_path")
|
|
2453
|
+
if plan_path:
|
|
2454
|
+
try:
|
|
2455
|
+
plan_content = (
|
|
2456
|
+
Path(plan_path)
|
|
2457
|
+
.read_text(encoding="utf-8")
|
|
2458
|
+
.strip()
|
|
2459
|
+
)
|
|
2460
|
+
has_output = len(plan_content) > 0
|
|
2461
|
+
except (OSError, IOError):
|
|
2462
|
+
pass
|
|
1978
2463
|
|
|
1979
2464
|
if not has_progress and not has_output:
|
|
1980
2465
|
no_progress_count += 1
|
|
2466
|
+
|
|
2467
|
+
evidence = {
|
|
2468
|
+
"outstanding_count": current_outstanding_count,
|
|
2469
|
+
"done_count": current_done_count,
|
|
2470
|
+
"has_diff": bool(
|
|
2471
|
+
run_entry
|
|
2472
|
+
and isinstance(run_entry.get("artifacts"), dict)
|
|
2473
|
+
and run_entry["artifacts"].get("diff_path")
|
|
2474
|
+
),
|
|
2475
|
+
"has_plan": bool(
|
|
2476
|
+
run_entry
|
|
2477
|
+
and isinstance(run_entry.get("artifacts"), dict)
|
|
2478
|
+
and run_entry["artifacts"].get("plan_path")
|
|
2479
|
+
),
|
|
2480
|
+
"run_id": run_id,
|
|
2481
|
+
}
|
|
2482
|
+
self._emit_event(
|
|
2483
|
+
run_id, "run.no_progress", count=no_progress_count, **evidence
|
|
2484
|
+
)
|
|
1981
2485
|
self.log_line(
|
|
1982
2486
|
run_id,
|
|
1983
2487
|
f"info: no progress detected ({no_progress_count}/{self.config.runner_no_progress_threshold} runs without progress)",
|
|
@@ -2030,12 +2534,16 @@ class Engine:
|
|
|
2030
2534
|
for line in tb.splitlines():
|
|
2031
2535
|
self.log_line(run_id, f"traceback: {line}")
|
|
2032
2536
|
except (OSError, IOError) as exc:
|
|
2033
|
-
self._app_server_logger.error(
|
|
2537
|
+
self._app_server_logger.error(
|
|
2538
|
+
"Failed to log run_loop crash for run %s: %s", run_id, exc
|
|
2539
|
+
)
|
|
2034
2540
|
try:
|
|
2035
2541
|
self._update_state("error", run_id, 1, finished=True)
|
|
2036
2542
|
except (OSError, IOError) as exc:
|
|
2037
2543
|
self._app_server_logger.error(
|
|
2038
|
-
"Failed to update state after run_loop crash: %s",
|
|
2544
|
+
"Failed to update state after run_loop crash for run %s: %s",
|
|
2545
|
+
run_id,
|
|
2546
|
+
exc,
|
|
2039
2547
|
)
|
|
2040
2548
|
finally:
|
|
2041
2549
|
try:
|
|
@@ -2044,9 +2552,12 @@ class Engine:
|
|
|
2044
2552
|
last_exit_code=last_exit_code,
|
|
2045
2553
|
)
|
|
2046
2554
|
except Exception as exc:
|
|
2047
|
-
self._app_server_logger.warning(
|
|
2555
|
+
self._app_server_logger.warning(
|
|
2556
|
+
"End-of-run review failed for run %s: %s", run_id, exc
|
|
2557
|
+
)
|
|
2048
2558
|
await self._close_app_server_supervisor()
|
|
2049
2559
|
await self._close_opencode_supervisor()
|
|
2560
|
+
await self._close_agent_backends()
|
|
2050
2561
|
# IMPORTANT: lock ownership is managed by the caller (CLI/Hub/Server runner).
|
|
2051
2562
|
# Engine.run_loop must never unconditionally mutate the lock file.
|
|
2052
2563
|
|
|
@@ -2116,8 +2627,8 @@ class Engine:
|
|
|
2116
2627
|
}
|
|
2117
2628
|
payload = {k: v for k, v in payload.items() if v is not None}
|
|
2118
2629
|
|
|
2119
|
-
opencode_supervisor: Optional[
|
|
2120
|
-
app_server_supervisor: Optional[
|
|
2630
|
+
opencode_supervisor: Optional[Any] = None
|
|
2631
|
+
app_server_supervisor: Optional[Any] = None
|
|
2121
2632
|
|
|
2122
2633
|
if agent == "codex":
|
|
2123
2634
|
if not self.config.app_server.command:
|
|
@@ -2125,20 +2636,12 @@ class Engine:
|
|
|
2125
2636
|
"Skipping end-of-run review: codex backend not configured"
|
|
2126
2637
|
)
|
|
2127
2638
|
return
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
state_dir.mkdir(parents=True, exist_ok=True)
|
|
2133
|
-
return build_app_server_env(
|
|
2134
|
-
self.config.app_server.command,
|
|
2135
|
-
workspace_root,
|
|
2136
|
-
state_dir,
|
|
2137
|
-
logger=self._app_server_logger,
|
|
2138
|
-
event_prefix="review",
|
|
2639
|
+
app_server_supervisor = self._ensure_app_server_supervisor("review")
|
|
2640
|
+
if app_server_supervisor is None:
|
|
2641
|
+
self._app_server_logger.info(
|
|
2642
|
+
"Skipping end-of-run review: codex supervisor factory unavailable"
|
|
2139
2643
|
)
|
|
2140
|
-
|
|
2141
|
-
app_server_supervisor = self._ensure_app_server_supervisor(_env_builder)
|
|
2644
|
+
return
|
|
2142
2645
|
else:
|
|
2143
2646
|
opencode_supervisor = self._ensure_opencode_supervisor()
|
|
2144
2647
|
if opencode_supervisor is None:
|
|
@@ -2147,7 +2650,7 @@ class Engine:
|
|
|
2147
2650
|
)
|
|
2148
2651
|
return
|
|
2149
2652
|
|
|
2150
|
-
from .review import ReviewService
|
|
2653
|
+
from ..flows.review import ReviewService
|
|
2151
2654
|
|
|
2152
2655
|
review_service = ReviewService(
|
|
2153
2656
|
self,
|
|
@@ -2208,8 +2711,12 @@ class Engine:
|
|
|
2208
2711
|
started: bool = False,
|
|
2209
2712
|
finished: bool = False,
|
|
2210
2713
|
) -> None:
|
|
2714
|
+
prev_status: Optional[str] = None
|
|
2715
|
+
last_run_started_at: Optional[str] = None
|
|
2716
|
+
last_run_finished_at: Optional[str] = None
|
|
2211
2717
|
with state_lock(self.state_path):
|
|
2212
2718
|
current = load_state(self.state_path)
|
|
2719
|
+
prev_status = current.status
|
|
2213
2720
|
last_run_started_at = current.last_run_started_at
|
|
2214
2721
|
last_run_finished_at = current.last_run_finished_at
|
|
2215
2722
|
runner_pid = current.runner_pid
|
|
@@ -2237,6 +2744,18 @@ class Engine:
|
|
|
2237
2744
|
repo_to_session=current.repo_to_session,
|
|
2238
2745
|
)
|
|
2239
2746
|
save_state(self.state_path, new_state)
|
|
2747
|
+
if run_id > 0 and prev_status != status:
|
|
2748
|
+
payload: dict[str, Any] = {
|
|
2749
|
+
"from_status": prev_status,
|
|
2750
|
+
"to_status": status,
|
|
2751
|
+
}
|
|
2752
|
+
if exit_code is not None:
|
|
2753
|
+
payload["exit_code"] = exit_code
|
|
2754
|
+
if started and last_run_started_at:
|
|
2755
|
+
payload["started_at"] = last_run_started_at
|
|
2756
|
+
if finished and last_run_finished_at:
|
|
2757
|
+
payload["finished_at"] = last_run_finished_at
|
|
2758
|
+
self._emit_event(run_id, "run.state_changed", **payload)
|
|
2240
2759
|
|
|
2241
2760
|
|
|
2242
2761
|
def clear_stale_lock(lock_path: Path) -> bool:
|
|
@@ -2385,253 +2904,399 @@ def _manifest_has_worktrees(manifest_path: Path) -> bool:
|
|
|
2385
2904
|
return False
|
|
2386
2905
|
|
|
2387
2906
|
|
|
2388
|
-
def
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2907
|
+
def _append_repo_check(
|
|
2908
|
+
checks: list[DoctorCheck],
|
|
2909
|
+
prefix: str,
|
|
2910
|
+
check_id: str,
|
|
2911
|
+
status: str,
|
|
2912
|
+
message: str,
|
|
2913
|
+
fix: Optional[str] = None,
|
|
2914
|
+
) -> None:
|
|
2915
|
+
full_id = f"{prefix}.{check_id}" if prefix else check_id
|
|
2916
|
+
_append_check(checks, full_id, status, message, fix)
|
|
2917
|
+
|
|
2918
|
+
|
|
2919
|
+
def _load_isolated_repo_config(repo_root: Path) -> RepoConfig:
|
|
2920
|
+
config_path = repo_root / CONFIG_FILENAME
|
|
2921
|
+
raw_config = _load_yaml_dict(config_path) if config_path.exists() else {}
|
|
2922
|
+
raw = _merge_defaults(DEFAULT_REPO_CONFIG, raw_config or {})
|
|
2923
|
+
raw["mode"] = "repo"
|
|
2924
|
+
raw["version"] = raw.get("version") or CONFIG_VERSION
|
|
2925
|
+
_validate_repo_config(raw, root=repo_root)
|
|
2926
|
+
return _build_repo_config(config_path, raw)
|
|
2927
|
+
|
|
2928
|
+
|
|
2929
|
+
def _repo_checks(
|
|
2930
|
+
repo_config: RepoConfig,
|
|
2931
|
+
global_state_root: Path,
|
|
2932
|
+
prefix: str = "",
|
|
2933
|
+
) -> list[DoctorCheck]:
|
|
2396
2934
|
checks: list[DoctorCheck] = []
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2935
|
+
repo_state_root = resolve_repo_state_root(repo_config.root)
|
|
2936
|
+
_append_repo_check(
|
|
2937
|
+
checks,
|
|
2938
|
+
prefix,
|
|
2939
|
+
"state.roots",
|
|
2940
|
+
"ok",
|
|
2941
|
+
f"Repo state root: {repo_state_root}; Global state root: {global_state_root}",
|
|
2942
|
+
)
|
|
2943
|
+
|
|
2944
|
+
missing = []
|
|
2945
|
+
configured_docs = repo_config.docs or {}
|
|
2946
|
+
for key in configured_docs:
|
|
2947
|
+
path = repo_config.doc_path(key)
|
|
2948
|
+
if not path.exists():
|
|
2949
|
+
missing.append(path)
|
|
2950
|
+
if missing:
|
|
2951
|
+
names = ", ".join(str(p) for p in missing)
|
|
2952
|
+
_append_repo_check(
|
|
2953
|
+
checks,
|
|
2954
|
+
prefix,
|
|
2955
|
+
"docs.required",
|
|
2956
|
+
"warning",
|
|
2957
|
+
f"Configured doc files are missing: {names}",
|
|
2958
|
+
"Create the missing files (workspace docs are optional but recommended).",
|
|
2959
|
+
)
|
|
2960
|
+
else:
|
|
2961
|
+
_append_repo_check(
|
|
2962
|
+
checks,
|
|
2963
|
+
prefix,
|
|
2964
|
+
"docs.required",
|
|
2965
|
+
"ok",
|
|
2966
|
+
"Configured doc files are present.",
|
|
2967
|
+
)
|
|
2968
|
+
|
|
2969
|
+
if ensure_executable(repo_config.codex_binary):
|
|
2970
|
+
_append_repo_check(
|
|
2971
|
+
checks,
|
|
2972
|
+
prefix,
|
|
2973
|
+
"codex.binary",
|
|
2974
|
+
"ok",
|
|
2975
|
+
f"Codex binary resolved: {repo_config.codex_binary}",
|
|
2976
|
+
)
|
|
2977
|
+
else:
|
|
2978
|
+
_append_repo_check(
|
|
2979
|
+
checks,
|
|
2980
|
+
prefix,
|
|
2981
|
+
"codex.binary",
|
|
2982
|
+
"error",
|
|
2983
|
+
f"Codex binary not found in PATH: {repo_config.codex_binary}",
|
|
2984
|
+
"Install Codex or set codex.binary to a full path.",
|
|
2985
|
+
)
|
|
2986
|
+
|
|
2987
|
+
voice_enabled = bool(repo_config.voice.get("enabled", True))
|
|
2988
|
+
if voice_enabled:
|
|
2989
|
+
missing_voice = missing_optional_dependencies(
|
|
2990
|
+
(
|
|
2991
|
+
("httpx", "httpx"),
|
|
2992
|
+
(("multipart", "python_multipart"), "python-multipart"),
|
|
2993
|
+
)
|
|
2994
|
+
)
|
|
2995
|
+
if missing_voice:
|
|
2996
|
+
deps_list = ", ".join(missing_voice)
|
|
2997
|
+
_append_repo_check(
|
|
2409
2998
|
checks,
|
|
2410
|
-
|
|
2999
|
+
prefix,
|
|
3000
|
+
"voice.dependencies",
|
|
2411
3001
|
"error",
|
|
2412
|
-
f"
|
|
2413
|
-
"
|
|
3002
|
+
f"Voice is enabled but missing optional deps: {deps_list}",
|
|
3003
|
+
"Install with `pip install codex-autorunner[voice]`.",
|
|
2414
3004
|
)
|
|
2415
3005
|
else:
|
|
2416
|
-
|
|
2417
|
-
checks,
|
|
2418
|
-
"docs.required",
|
|
2419
|
-
"ok",
|
|
2420
|
-
"Required doc files are present.",
|
|
2421
|
-
)
|
|
2422
|
-
|
|
2423
|
-
if ensure_executable(repo_config.codex_binary):
|
|
2424
|
-
_append_check(
|
|
3006
|
+
_append_repo_check(
|
|
2425
3007
|
checks,
|
|
2426
|
-
|
|
3008
|
+
prefix,
|
|
3009
|
+
"voice.dependencies",
|
|
2427
3010
|
"ok",
|
|
2428
|
-
|
|
2429
|
-
)
|
|
2430
|
-
else:
|
|
2431
|
-
_append_check(
|
|
2432
|
-
checks,
|
|
2433
|
-
"codex.binary",
|
|
2434
|
-
"error",
|
|
2435
|
-
f"Codex binary not found in PATH: {repo_config.codex_binary}",
|
|
2436
|
-
"Install Codex or set codex.binary to a full path.",
|
|
3011
|
+
"Voice dependencies are installed.",
|
|
2437
3012
|
)
|
|
2438
3013
|
|
|
2439
|
-
voice_enabled = bool(repo_config.voice.get("enabled", True))
|
|
2440
|
-
if voice_enabled:
|
|
2441
|
-
missing_voice = missing_optional_dependencies(
|
|
2442
|
-
(
|
|
2443
|
-
("httpx", "httpx"),
|
|
2444
|
-
(("multipart", "python_multipart"), "python-multipart"),
|
|
2445
|
-
)
|
|
2446
|
-
)
|
|
2447
|
-
if missing_voice:
|
|
2448
|
-
deps_list = ", ".join(missing_voice)
|
|
2449
|
-
_append_check(
|
|
2450
|
-
checks,
|
|
2451
|
-
"voice.dependencies",
|
|
2452
|
-
"error",
|
|
2453
|
-
f"Voice is enabled but missing optional deps: {deps_list}",
|
|
2454
|
-
"Install with `pip install codex-autorunner[voice]`.",
|
|
2455
|
-
)
|
|
2456
|
-
else:
|
|
2457
|
-
_append_check(
|
|
2458
|
-
checks,
|
|
2459
|
-
"voice.dependencies",
|
|
2460
|
-
"ok",
|
|
2461
|
-
"Voice dependencies are installed.",
|
|
2462
|
-
)
|
|
2463
|
-
|
|
2464
3014
|
env_candidates = [
|
|
2465
|
-
root / ".env",
|
|
2466
|
-
root / ".codex-autorunner" / ".env",
|
|
3015
|
+
repo_config.root / ".env",
|
|
3016
|
+
repo_config.root / ".codex-autorunner" / ".env",
|
|
2467
3017
|
]
|
|
2468
3018
|
env_found = [str(path) for path in env_candidates if path.exists()]
|
|
2469
3019
|
if env_found:
|
|
2470
|
-
|
|
3020
|
+
_append_repo_check(
|
|
2471
3021
|
checks,
|
|
3022
|
+
prefix,
|
|
2472
3023
|
"dotenv.locations",
|
|
2473
3024
|
"ok",
|
|
2474
3025
|
f"Found .env files: {', '.join(env_found)}",
|
|
2475
3026
|
)
|
|
2476
3027
|
else:
|
|
2477
|
-
|
|
3028
|
+
_append_repo_check(
|
|
2478
3029
|
checks,
|
|
3030
|
+
prefix,
|
|
2479
3031
|
"dotenv.locations",
|
|
2480
3032
|
"warning",
|
|
2481
3033
|
"No .env files found in repo root or .codex-autorunner/.env.",
|
|
2482
3034
|
"Create one of these files if you rely on env vars.",
|
|
2483
3035
|
)
|
|
2484
3036
|
|
|
2485
|
-
host = str(
|
|
3037
|
+
host = str(repo_config.server_host or "")
|
|
2486
3038
|
if not _is_loopback_host(host):
|
|
2487
|
-
if not
|
|
2488
|
-
|
|
3039
|
+
if not repo_config.server_auth_token_env:
|
|
3040
|
+
_append_repo_check(
|
|
2489
3041
|
checks,
|
|
3042
|
+
prefix,
|
|
2490
3043
|
"server.auth",
|
|
2491
3044
|
"error",
|
|
2492
3045
|
f"Non-loopback host {host} requires server.auth_token_env.",
|
|
2493
3046
|
"Set server.auth_token_env or bind to 127.0.0.1.",
|
|
2494
3047
|
)
|
|
2495
3048
|
else:
|
|
2496
|
-
token_val = os.environ.get(
|
|
3049
|
+
token_val = os.environ.get(repo_config.server_auth_token_env)
|
|
2497
3050
|
if not token_val:
|
|
2498
|
-
|
|
3051
|
+
_append_repo_check(
|
|
2499
3052
|
checks,
|
|
3053
|
+
prefix,
|
|
2500
3054
|
"server.auth",
|
|
2501
3055
|
"warning",
|
|
2502
|
-
f"Auth token env var {
|
|
3056
|
+
f"Auth token env var {repo_config.server_auth_token_env} is not set.",
|
|
2503
3057
|
"Export the env var or add it to .env.",
|
|
2504
3058
|
)
|
|
2505
3059
|
else:
|
|
2506
|
-
|
|
3060
|
+
_append_repo_check(
|
|
2507
3061
|
checks,
|
|
3062
|
+
prefix,
|
|
2508
3063
|
"server.auth",
|
|
2509
3064
|
"ok",
|
|
2510
3065
|
"Server auth token env var is set for non-loopback host.",
|
|
2511
3066
|
)
|
|
2512
3067
|
|
|
2513
|
-
|
|
3068
|
+
return checks
|
|
3069
|
+
|
|
3070
|
+
|
|
3071
|
+
def _iter_hub_repos(hub_config) -> list[tuple[str, Path]]:
|
|
3072
|
+
repos: list[tuple[str, Path]] = []
|
|
3073
|
+
if hub_config.manifest_path.exists():
|
|
3074
|
+
try:
|
|
3075
|
+
raw = yaml.safe_load(hub_config.manifest_path.read_text(encoding="utf-8"))
|
|
3076
|
+
except (OSError, yaml.YAMLError):
|
|
3077
|
+
raw = None
|
|
3078
|
+
if isinstance(raw, dict):
|
|
3079
|
+
entries = raw.get("repos")
|
|
3080
|
+
if isinstance(entries, list):
|
|
3081
|
+
for entry in entries:
|
|
3082
|
+
if not isinstance(entry, dict):
|
|
3083
|
+
continue
|
|
3084
|
+
if not entry.get("enabled", True):
|
|
3085
|
+
continue
|
|
3086
|
+
path_val = entry.get("path")
|
|
3087
|
+
if not isinstance(path_val, str):
|
|
3088
|
+
continue
|
|
3089
|
+
repo_id = str(entry.get("id") or path_val)
|
|
3090
|
+
repos.append((repo_id, (hub_config.root / path_val).resolve()))
|
|
3091
|
+
if not repos and hub_config.repos_root.exists():
|
|
3092
|
+
for child in hub_config.repos_root.iterdir():
|
|
3093
|
+
if child.is_dir():
|
|
3094
|
+
repos.append((child.name, child.resolve()))
|
|
3095
|
+
return repos
|
|
3096
|
+
|
|
3097
|
+
|
|
3098
|
+
def doctor(start_path: Path) -> DoctorReport:
|
|
3099
|
+
checks: list[DoctorCheck] = []
|
|
3100
|
+
hub_config = None
|
|
2514
3101
|
try:
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
3102
|
+
hub_config = load_hub_config(start_path)
|
|
3103
|
+
except ConfigError:
|
|
3104
|
+
hub_config = None
|
|
3105
|
+
|
|
3106
|
+
repo_root: Optional[Path] = None
|
|
3107
|
+
try:
|
|
3108
|
+
repo_root = find_repo_root(start_path)
|
|
3109
|
+
except RepoNotFoundError:
|
|
3110
|
+
repo_root = None
|
|
3111
|
+
|
|
3112
|
+
repo_config: Optional[RepoConfig] = None
|
|
3113
|
+
if hub_config is not None and repo_root is not None:
|
|
3114
|
+
try:
|
|
3115
|
+
repo_config = derive_repo_config(hub_config, repo_root)
|
|
3116
|
+
except ConfigError:
|
|
3117
|
+
repo_config = None
|
|
3118
|
+
elif hub_config is None and repo_root is not None:
|
|
3119
|
+
try:
|
|
3120
|
+
repo_config = load_repo_config(start_path)
|
|
3121
|
+
except ConfigError:
|
|
3122
|
+
repo_config = _load_isolated_repo_config(repo_root)
|
|
3123
|
+
|
|
3124
|
+
if hub_config is not None:
|
|
3125
|
+
global_state_root = resolve_global_state_root(config=hub_config)
|
|
3126
|
+
_append_check(
|
|
3127
|
+
checks,
|
|
3128
|
+
"state.roots",
|
|
3129
|
+
"ok",
|
|
3130
|
+
f"Hub root: {hub_config.root}; Global state root: {global_state_root}",
|
|
3131
|
+
)
|
|
3132
|
+
elif repo_config is not None:
|
|
3133
|
+
global_state_root = resolve_global_state_root(config=repo_config)
|
|
3134
|
+
_append_check(
|
|
3135
|
+
checks,
|
|
3136
|
+
"state.roots",
|
|
3137
|
+
"ok",
|
|
3138
|
+
f"Repo state root: {resolve_repo_state_root(repo_config.root)}; Global state root: {global_state_root}",
|
|
3139
|
+
)
|
|
3140
|
+
else:
|
|
3141
|
+
raise ConfigError("No hub or repo configuration found for doctor check.")
|
|
3142
|
+
|
|
3143
|
+
if hub_config is not None:
|
|
3144
|
+
if hub_config.manifest_path.exists():
|
|
3145
|
+
version = _parse_manifest_version(hub_config.manifest_path)
|
|
3146
|
+
if version is None:
|
|
3147
|
+
_append_check(
|
|
3148
|
+
checks,
|
|
3149
|
+
"hub.manifest.version",
|
|
3150
|
+
"error",
|
|
3151
|
+
f"Failed to read manifest version from {hub_config.manifest_path}.",
|
|
3152
|
+
"Fix the manifest YAML or regenerate it with `car hub scan`.",
|
|
3153
|
+
)
|
|
3154
|
+
elif version != MANIFEST_VERSION:
|
|
3155
|
+
_append_check(
|
|
3156
|
+
checks,
|
|
3157
|
+
"hub.manifest.version",
|
|
3158
|
+
"error",
|
|
3159
|
+
f"Hub manifest version {version} unsupported (expected {MANIFEST_VERSION}).",
|
|
3160
|
+
"Regenerate the manifest (delete it and run `car hub scan`).",
|
|
3161
|
+
)
|
|
3162
|
+
else:
|
|
3163
|
+
_append_check(
|
|
3164
|
+
checks,
|
|
3165
|
+
"hub.manifest.version",
|
|
3166
|
+
"ok",
|
|
3167
|
+
f"Hub manifest version {version} is supported.",
|
|
3168
|
+
)
|
|
2524
3169
|
else:
|
|
2525
3170
|
_append_check(
|
|
2526
3171
|
checks,
|
|
2527
|
-
"
|
|
2528
|
-
"
|
|
2529
|
-
f"
|
|
3172
|
+
"hub.manifest.exists",
|
|
3173
|
+
"warning",
|
|
3174
|
+
f"Hub manifest missing at {hub_config.manifest_path}.",
|
|
3175
|
+
"Run `car hub scan` or `car hub create` to generate it.",
|
|
2530
3176
|
)
|
|
2531
|
-
finally:
|
|
2532
|
-
if static_context is not None:
|
|
2533
|
-
static_context.close()
|
|
2534
3177
|
|
|
2535
|
-
|
|
2536
|
-
version = _parse_manifest_version(hub_config.manifest_path)
|
|
2537
|
-
if version is None:
|
|
3178
|
+
if not hub_config.repos_root.exists():
|
|
2538
3179
|
_append_check(
|
|
2539
3180
|
checks,
|
|
2540
|
-
"hub.
|
|
3181
|
+
"hub.repos_root",
|
|
2541
3182
|
"error",
|
|
2542
|
-
f"
|
|
2543
|
-
"
|
|
3183
|
+
f"Hub repos_root does not exist: {hub_config.repos_root}",
|
|
3184
|
+
"Create the directory or update hub.repos_root in config.",
|
|
2544
3185
|
)
|
|
2545
|
-
elif
|
|
3186
|
+
elif not hub_config.repos_root.is_dir():
|
|
2546
3187
|
_append_check(
|
|
2547
3188
|
checks,
|
|
2548
|
-
"hub.
|
|
3189
|
+
"hub.repos_root",
|
|
2549
3190
|
"error",
|
|
2550
|
-
f"Hub
|
|
2551
|
-
"
|
|
3191
|
+
f"Hub repos_root is not a directory: {hub_config.repos_root}",
|
|
3192
|
+
"Point hub.repos_root at a directory.",
|
|
2552
3193
|
)
|
|
2553
3194
|
else:
|
|
2554
3195
|
_append_check(
|
|
2555
3196
|
checks,
|
|
2556
|
-
"hub.
|
|
3197
|
+
"hub.repos_root",
|
|
2557
3198
|
"ok",
|
|
2558
|
-
f"Hub
|
|
3199
|
+
f"Hub repos_root exists: {hub_config.repos_root}",
|
|
2559
3200
|
)
|
|
2560
|
-
else:
|
|
2561
|
-
_append_check(
|
|
2562
|
-
checks,
|
|
2563
|
-
"hub.manifest.exists",
|
|
2564
|
-
"warning",
|
|
2565
|
-
f"Hub manifest missing at {hub_config.manifest_path}.",
|
|
2566
|
-
"Run `car hub scan` or `car hub create` to generate it.",
|
|
2567
|
-
)
|
|
2568
3201
|
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
"hub.repos_root",
|
|
2573
|
-
"error",
|
|
2574
|
-
f"Hub repos_root does not exist: {hub_config.repos_root}",
|
|
2575
|
-
"Create the directory or update hub.repos_root in config.",
|
|
2576
|
-
)
|
|
2577
|
-
elif not hub_config.repos_root.is_dir():
|
|
2578
|
-
_append_check(
|
|
2579
|
-
checks,
|
|
2580
|
-
"hub.repos_root",
|
|
2581
|
-
"error",
|
|
2582
|
-
f"Hub repos_root is not a directory: {hub_config.repos_root}",
|
|
2583
|
-
"Point hub.repos_root at a directory.",
|
|
2584
|
-
)
|
|
2585
|
-
else:
|
|
2586
|
-
_append_check(
|
|
2587
|
-
checks,
|
|
2588
|
-
"hub.repos_root",
|
|
2589
|
-
"ok",
|
|
2590
|
-
f"Hub repos_root exists: {hub_config.repos_root}",
|
|
3202
|
+
manifest_has_worktrees = (
|
|
3203
|
+
hub_config.manifest_path.exists()
|
|
3204
|
+
and _manifest_has_worktrees(hub_config.manifest_path)
|
|
2591
3205
|
)
|
|
3206
|
+
worktrees_enabled = hub_config.worktrees_root.exists() or manifest_has_worktrees
|
|
3207
|
+
if worktrees_enabled:
|
|
3208
|
+
if ensure_executable("git"):
|
|
3209
|
+
_append_check(
|
|
3210
|
+
checks,
|
|
3211
|
+
"hub.git",
|
|
3212
|
+
"ok",
|
|
3213
|
+
"git is available for hub worktrees.",
|
|
3214
|
+
)
|
|
3215
|
+
else:
|
|
3216
|
+
_append_check(
|
|
3217
|
+
checks,
|
|
3218
|
+
"hub.git",
|
|
3219
|
+
"error",
|
|
3220
|
+
"git is not available but hub worktrees are enabled.",
|
|
3221
|
+
"Install git or disable worktrees.",
|
|
3222
|
+
)
|
|
2592
3223
|
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
if ensure_executable("git"):
|
|
3224
|
+
env_candidates = [
|
|
3225
|
+
hub_config.root / ".env",
|
|
3226
|
+
hub_config.root / ".codex-autorunner" / ".env",
|
|
3227
|
+
]
|
|
3228
|
+
env_found = [str(path) for path in env_candidates if path.exists()]
|
|
3229
|
+
if env_found:
|
|
2600
3230
|
_append_check(
|
|
2601
3231
|
checks,
|
|
2602
|
-
"
|
|
3232
|
+
"dotenv.locations",
|
|
2603
3233
|
"ok",
|
|
2604
|
-
"
|
|
3234
|
+
f"Found .env files: {', '.join(env_found)}",
|
|
2605
3235
|
)
|
|
2606
3236
|
else:
|
|
2607
3237
|
_append_check(
|
|
2608
3238
|
checks,
|
|
2609
|
-
"
|
|
2610
|
-
"
|
|
2611
|
-
"
|
|
2612
|
-
"
|
|
3239
|
+
"dotenv.locations",
|
|
3240
|
+
"warning",
|
|
3241
|
+
"No .env files found in repo root or .codex-autorunner/.env.",
|
|
3242
|
+
"Create one of these files if you rely on env vars.",
|
|
2613
3243
|
)
|
|
2614
3244
|
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
3245
|
+
host = str(hub_config.server_host or "")
|
|
3246
|
+
if not _is_loopback_host(host):
|
|
3247
|
+
if not hub_config.server_auth_token_env:
|
|
3248
|
+
_append_check(
|
|
3249
|
+
checks,
|
|
3250
|
+
"server.auth",
|
|
3251
|
+
"error",
|
|
3252
|
+
f"Non-loopback host {host} requires server.auth_token_env.",
|
|
3253
|
+
"Set server.auth_token_env or bind to 127.0.0.1.",
|
|
3254
|
+
)
|
|
3255
|
+
else:
|
|
3256
|
+
token_val = os.environ.get(hub_config.server_auth_token_env)
|
|
3257
|
+
if not token_val:
|
|
3258
|
+
_append_check(
|
|
3259
|
+
checks,
|
|
3260
|
+
"server.auth",
|
|
3261
|
+
"warning",
|
|
3262
|
+
f"Auth token env var {hub_config.server_auth_token_env} is not set.",
|
|
3263
|
+
"Export the env var or add it to .env.",
|
|
3264
|
+
)
|
|
3265
|
+
else:
|
|
3266
|
+
_append_check(
|
|
3267
|
+
checks,
|
|
3268
|
+
"server.auth",
|
|
3269
|
+
"ok",
|
|
3270
|
+
"Server auth token env var is set for non-loopback host.",
|
|
3271
|
+
)
|
|
3272
|
+
|
|
3273
|
+
for repo_id, repo_path in _iter_hub_repos(hub_config):
|
|
3274
|
+
prefix = f"repo[{repo_id}]"
|
|
3275
|
+
if not repo_path.exists():
|
|
3276
|
+
_append_repo_check(
|
|
3277
|
+
checks,
|
|
3278
|
+
prefix,
|
|
3279
|
+
"state.roots",
|
|
3280
|
+
"error",
|
|
3281
|
+
f"Repo path not found: {repo_path}",
|
|
3282
|
+
"Clone or initialize the repo, or update the hub manifest.",
|
|
3283
|
+
)
|
|
3284
|
+
continue
|
|
3285
|
+
try:
|
|
3286
|
+
repo_cfg = derive_repo_config(hub_config, repo_path)
|
|
3287
|
+
except ConfigError as exc:
|
|
3288
|
+
_append_repo_check(
|
|
3289
|
+
checks,
|
|
3290
|
+
prefix,
|
|
3291
|
+
"config",
|
|
3292
|
+
"error",
|
|
3293
|
+
f"Failed to derive repo config: {exc}",
|
|
3294
|
+
)
|
|
3295
|
+
continue
|
|
3296
|
+
checks.extend(_repo_checks(repo_cfg, global_state_root, prefix=prefix))
|
|
3297
|
+
|
|
3298
|
+
else:
|
|
3299
|
+
assert repo_config is not None
|
|
3300
|
+
checks.extend(_repo_checks(repo_config, global_state_root))
|
|
2636
3301
|
|
|
2637
3302
|
return DoctorReport(checks=checks)
|