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
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Optional, cast
|
|
7
|
+
|
|
8
|
+
from ..agents.opencode.constants import DEFAULT_TICKET_MODEL
|
|
9
|
+
from ..agents.opencode.runtime import collect_opencode_output, split_model_id
|
|
10
|
+
from ..agents.opencode.supervisor import OpenCodeSupervisor
|
|
11
|
+
from ..core.config import RepoConfig
|
|
12
|
+
from ..core.flows.models import FlowEventType
|
|
13
|
+
from ..core.utils import build_opencode_supervisor
|
|
14
|
+
from ..integrations.app_server.client import CodexAppServerClient
|
|
15
|
+
from ..integrations.app_server.env import build_app_server_env
|
|
16
|
+
from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
EmitEventFn = Callable[[FlowEventType, dict[str, Any]], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AgentTurnRequest:
|
|
25
|
+
agent_id: str # "codex" | "opencode"
|
|
26
|
+
prompt: str
|
|
27
|
+
workspace_root: Path
|
|
28
|
+
conversation_id: Optional[str] = None
|
|
29
|
+
# Optional, agent-specific extras.
|
|
30
|
+
options: Optional[dict[str, Any]] = None
|
|
31
|
+
# Optional flow event emitter (for live streaming).
|
|
32
|
+
emit_event: Optional[EmitEventFn] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class AgentTurnResult:
|
|
37
|
+
agent_id: str
|
|
38
|
+
conversation_id: str
|
|
39
|
+
turn_id: str
|
|
40
|
+
text: str
|
|
41
|
+
error: Optional[str] = None
|
|
42
|
+
raw: Optional[dict[str, Any]] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AgentPool:
|
|
46
|
+
"""Minimal agent execution facade.
|
|
47
|
+
|
|
48
|
+
The pool is intentionally small: it can run either the Codex app-server or
|
|
49
|
+
OpenCode server for a single prompt.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, config: RepoConfig):
|
|
53
|
+
self._config = config
|
|
54
|
+
self._app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None
|
|
55
|
+
self._opencode_supervisor: Optional[OpenCodeSupervisor] = None
|
|
56
|
+
self._active_emitters: dict[str, EmitEventFn] = {}
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _extract_turn_id(params: Any) -> Optional[str]:
|
|
60
|
+
if not isinstance(params, dict):
|
|
61
|
+
return None
|
|
62
|
+
for key in ("turnId", "turn_id", "id"):
|
|
63
|
+
value = params.get(key)
|
|
64
|
+
if isinstance(value, str) and value:
|
|
65
|
+
return value
|
|
66
|
+
turn = params.get("turn")
|
|
67
|
+
if isinstance(turn, dict):
|
|
68
|
+
for key in ("turnId", "turn_id", "id"):
|
|
69
|
+
value = turn.get(key)
|
|
70
|
+
if isinstance(value, str) and value:
|
|
71
|
+
return value
|
|
72
|
+
item = params.get("item")
|
|
73
|
+
if isinstance(item, dict):
|
|
74
|
+
for key in ("turnId", "turn_id", "id"):
|
|
75
|
+
value = item.get(key)
|
|
76
|
+
if isinstance(value, str) and value:
|
|
77
|
+
return value
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
async def _handle_app_server_notification(self, message: dict[str, Any]) -> None:
|
|
81
|
+
method = message.get("method")
|
|
82
|
+
params = message.get("params")
|
|
83
|
+
turn_id = self._extract_turn_id(params)
|
|
84
|
+
if not turn_id:
|
|
85
|
+
return
|
|
86
|
+
emitter = self._active_emitters.get(turn_id)
|
|
87
|
+
if emitter is None:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Forward the raw app-server event for richer UI rendering (tools, files, commands, etc.)
|
|
91
|
+
try:
|
|
92
|
+
emitter(
|
|
93
|
+
FlowEventType.APP_SERVER_EVENT,
|
|
94
|
+
{"message": message, "turn_id": turn_id},
|
|
95
|
+
)
|
|
96
|
+
except Exception:
|
|
97
|
+
_logger.exception("Failed emitting app-server event for turn %s", turn_id)
|
|
98
|
+
|
|
99
|
+
if method in ("item/agentMessage/delta", "turn/streamDelta"):
|
|
100
|
+
delta = None
|
|
101
|
+
if isinstance(params, dict):
|
|
102
|
+
raw = params.get("delta") or params.get("text")
|
|
103
|
+
if isinstance(raw, str):
|
|
104
|
+
delta = raw
|
|
105
|
+
if delta:
|
|
106
|
+
emitter(
|
|
107
|
+
FlowEventType.AGENT_STREAM_DELTA,
|
|
108
|
+
{"delta": delta, "turn_id": turn_id, "method": method},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _ensure_app_server_supervisor(self) -> WorkspaceAppServerSupervisor:
|
|
112
|
+
if self._app_server_supervisor is not None:
|
|
113
|
+
return self._app_server_supervisor
|
|
114
|
+
|
|
115
|
+
app_server_cfg = self._config.app_server
|
|
116
|
+
ticket_flow_cfg = cast(dict[str, Any], getattr(self._config, "ticket_flow", {}))
|
|
117
|
+
default_approval_decision = ticket_flow_cfg.get(
|
|
118
|
+
"default_approval_decision", "accept"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _env_builder(
|
|
122
|
+
workspace_root: Path, workspace_id: str, state_dir: Path
|
|
123
|
+
) -> dict[str, str]:
|
|
124
|
+
# env is deterministic and purely derived from workspace/state dirs.
|
|
125
|
+
return build_app_server_env(
|
|
126
|
+
command=app_server_cfg.command,
|
|
127
|
+
workspace_root=workspace_root,
|
|
128
|
+
state_dir=state_dir,
|
|
129
|
+
logger=logging.getLogger("codex_autorunner.app_server"),
|
|
130
|
+
event_prefix=f"tickets.{workspace_id}",
|
|
131
|
+
base_env=None,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Default approval decision is "accept" to keep the loop KISS.
|
|
135
|
+
self._app_server_supervisor = WorkspaceAppServerSupervisor(
|
|
136
|
+
app_server_cfg.command,
|
|
137
|
+
state_root=app_server_cfg.state_root,
|
|
138
|
+
env_builder=_env_builder,
|
|
139
|
+
logger=logging.getLogger("codex_autorunner.app_server"),
|
|
140
|
+
notification_handler=self._handle_app_server_notification,
|
|
141
|
+
auto_restart=app_server_cfg.auto_restart,
|
|
142
|
+
max_handles=app_server_cfg.max_handles,
|
|
143
|
+
idle_ttl_seconds=app_server_cfg.idle_ttl_seconds,
|
|
144
|
+
request_timeout=app_server_cfg.request_timeout,
|
|
145
|
+
turn_stall_timeout_seconds=app_server_cfg.turn_stall_timeout_seconds,
|
|
146
|
+
turn_stall_poll_interval_seconds=app_server_cfg.turn_stall_poll_interval_seconds,
|
|
147
|
+
turn_stall_recovery_min_interval_seconds=app_server_cfg.turn_stall_recovery_min_interval_seconds,
|
|
148
|
+
max_message_bytes=app_server_cfg.client.max_message_bytes,
|
|
149
|
+
oversize_preview_bytes=app_server_cfg.client.oversize_preview_bytes,
|
|
150
|
+
max_oversize_drain_bytes=app_server_cfg.client.max_oversize_drain_bytes,
|
|
151
|
+
restart_backoff_initial_seconds=app_server_cfg.client.restart_backoff_initial_seconds,
|
|
152
|
+
restart_backoff_max_seconds=app_server_cfg.client.restart_backoff_max_seconds,
|
|
153
|
+
restart_backoff_jitter_ratio=app_server_cfg.client.restart_backoff_jitter_ratio,
|
|
154
|
+
default_approval_decision=default_approval_decision,
|
|
155
|
+
)
|
|
156
|
+
return self._app_server_supervisor
|
|
157
|
+
|
|
158
|
+
def _ensure_opencode_supervisor(self) -> OpenCodeSupervisor:
|
|
159
|
+
if self._opencode_supervisor is not None:
|
|
160
|
+
return self._opencode_supervisor
|
|
161
|
+
|
|
162
|
+
app_server_cfg = self._config.app_server
|
|
163
|
+
opencode_command = self._config.agent_serve_command("opencode")
|
|
164
|
+
opencode_binary = None
|
|
165
|
+
try:
|
|
166
|
+
opencode_binary = self._config.agent_binary("opencode")
|
|
167
|
+
except Exception:
|
|
168
|
+
opencode_binary = None
|
|
169
|
+
|
|
170
|
+
agent_cfg = self._config.agents.get("opencode")
|
|
171
|
+
subagent_models = agent_cfg.subagent_models if agent_cfg else None
|
|
172
|
+
|
|
173
|
+
supervisor = build_opencode_supervisor(
|
|
174
|
+
opencode_command=opencode_command,
|
|
175
|
+
opencode_binary=opencode_binary,
|
|
176
|
+
workspace_root=self._config.root,
|
|
177
|
+
logger=logging.getLogger("codex_autorunner.opencode"),
|
|
178
|
+
request_timeout=app_server_cfg.request_timeout,
|
|
179
|
+
max_handles=app_server_cfg.max_handles,
|
|
180
|
+
idle_ttl_seconds=app_server_cfg.idle_ttl_seconds,
|
|
181
|
+
session_stall_timeout_seconds=self._config.opencode.session_stall_timeout_seconds,
|
|
182
|
+
base_env=None,
|
|
183
|
+
subagent_models=subagent_models,
|
|
184
|
+
)
|
|
185
|
+
if supervisor is None:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
"OpenCode supervisor unavailable (missing opencode command/binary)."
|
|
188
|
+
)
|
|
189
|
+
self._opencode_supervisor = cast(OpenCodeSupervisor, supervisor)
|
|
190
|
+
return self._opencode_supervisor
|
|
191
|
+
|
|
192
|
+
async def close(self) -> None:
|
|
193
|
+
if self._app_server_supervisor is not None:
|
|
194
|
+
try:
|
|
195
|
+
await self._app_server_supervisor.close_all()
|
|
196
|
+
except Exception:
|
|
197
|
+
_logger.exception("Failed closing app-server supervisor")
|
|
198
|
+
self._app_server_supervisor = None
|
|
199
|
+
if self._opencode_supervisor is not None:
|
|
200
|
+
try:
|
|
201
|
+
await self._opencode_supervisor.close_all()
|
|
202
|
+
except Exception:
|
|
203
|
+
_logger.exception("Failed closing opencode supervisor")
|
|
204
|
+
self._opencode_supervisor = None
|
|
205
|
+
|
|
206
|
+
async def run_turn(self, req: AgentTurnRequest) -> AgentTurnResult:
|
|
207
|
+
if req.agent_id == "codex":
|
|
208
|
+
return await self._run_codex_turn(req)
|
|
209
|
+
if req.agent_id == "opencode":
|
|
210
|
+
return await self._run_opencode_turn(req)
|
|
211
|
+
raise ValueError(f"Unsupported agent_id: {req.agent_id}")
|
|
212
|
+
|
|
213
|
+
async def _run_codex_turn(self, req: AgentTurnRequest) -> AgentTurnResult:
|
|
214
|
+
supervisor = self._ensure_app_server_supervisor()
|
|
215
|
+
handle = await supervisor.get_client(req.workspace_root)
|
|
216
|
+
client: CodexAppServerClient = handle
|
|
217
|
+
|
|
218
|
+
approval_mode = (
|
|
219
|
+
cast(dict[str, Any], getattr(self._config, "ticket_flow", {})).get(
|
|
220
|
+
"approval_mode", "yolo"
|
|
221
|
+
)
|
|
222
|
+
or "yolo"
|
|
223
|
+
).strip()
|
|
224
|
+
approval_policy = "never" if approval_mode == "yolo" else "on-request"
|
|
225
|
+
sandbox = "danger-full-access" if approval_mode == "yolo" else "workspace-write"
|
|
226
|
+
|
|
227
|
+
thread_id = req.conversation_id
|
|
228
|
+
if thread_id:
|
|
229
|
+
await client.thread_resume(thread_id)
|
|
230
|
+
else:
|
|
231
|
+
thread = await client.thread_start(
|
|
232
|
+
cwd=str(req.workspace_root),
|
|
233
|
+
approvalPolicy=approval_policy,
|
|
234
|
+
sandbox=sandbox,
|
|
235
|
+
)
|
|
236
|
+
thread_id = thread.get("id") or thread.get("thread", {}).get("id")
|
|
237
|
+
if not thread_id:
|
|
238
|
+
raise RuntimeError("Codex thread_start returned no thread id")
|
|
239
|
+
|
|
240
|
+
_logger.info(
|
|
241
|
+
"Starting turn for thread %s with prompt length %d",
|
|
242
|
+
thread_id,
|
|
243
|
+
len(req.prompt),
|
|
244
|
+
)
|
|
245
|
+
# Extract model/reasoning from options if provided.
|
|
246
|
+
turn_kwargs: dict[str, Any] = {}
|
|
247
|
+
if req.options:
|
|
248
|
+
if req.options.get("model"):
|
|
249
|
+
turn_kwargs["model"] = req.options["model"]
|
|
250
|
+
if req.options.get("reasoning"):
|
|
251
|
+
turn_kwargs["effort"] = req.options["reasoning"]
|
|
252
|
+
turn_handle = await client.turn_start(thread_id, req.prompt, **turn_kwargs)
|
|
253
|
+
if req.emit_event is not None:
|
|
254
|
+
self._active_emitters[turn_handle.turn_id] = req.emit_event
|
|
255
|
+
try:
|
|
256
|
+
result = await turn_handle.wait()
|
|
257
|
+
finally:
|
|
258
|
+
if req.emit_event is not None:
|
|
259
|
+
self._active_emitters.pop(turn_handle.turn_id, None)
|
|
260
|
+
text = "\n\n".join(result.agent_messages or []).strip()
|
|
261
|
+
return AgentTurnResult(
|
|
262
|
+
agent_id=req.agent_id,
|
|
263
|
+
conversation_id=thread_id,
|
|
264
|
+
turn_id=result.turn_id,
|
|
265
|
+
text=text,
|
|
266
|
+
error=result.errors[0] if result.errors else None,
|
|
267
|
+
raw={
|
|
268
|
+
"status": result.status,
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
async def _run_opencode_turn(self, req: AgentTurnRequest) -> AgentTurnResult:
|
|
273
|
+
supervisor = self._ensure_opencode_supervisor()
|
|
274
|
+
handle = await supervisor.get_client(req.workspace_root)
|
|
275
|
+
client = handle
|
|
276
|
+
directory = str(req.workspace_root)
|
|
277
|
+
|
|
278
|
+
options = req.options if isinstance(req.options, dict) else {}
|
|
279
|
+
model_raw = options.get("model")
|
|
280
|
+
model_payload = None
|
|
281
|
+
if isinstance(model_raw, dict):
|
|
282
|
+
provider_id = model_raw.get("providerID") or model_raw.get("providerId")
|
|
283
|
+
model_id = model_raw.get("modelID") or model_raw.get("modelId")
|
|
284
|
+
if provider_id and model_id:
|
|
285
|
+
model_payload = {"providerID": provider_id, "modelID": model_id}
|
|
286
|
+
elif isinstance(model_raw, str) and model_raw.strip():
|
|
287
|
+
model_payload = split_model_id(model_raw.strip())
|
|
288
|
+
if model_payload is None:
|
|
289
|
+
model_payload = split_model_id(DEFAULT_TICKET_MODEL)
|
|
290
|
+
|
|
291
|
+
variant = None
|
|
292
|
+
reasoning_raw = options.get("reasoning")
|
|
293
|
+
if isinstance(reasoning_raw, str) and reasoning_raw.strip():
|
|
294
|
+
variant = reasoning_raw.strip()
|
|
295
|
+
|
|
296
|
+
session_id = req.conversation_id
|
|
297
|
+
if not session_id:
|
|
298
|
+
created = await client.create_session(title="ticket", directory=directory)
|
|
299
|
+
session_id = created.get("id") or created.get("session", {}).get("id")
|
|
300
|
+
if not session_id:
|
|
301
|
+
raise RuntimeError("OpenCode create_session returned no session id")
|
|
302
|
+
|
|
303
|
+
prompt_response = await client.prompt_async(
|
|
304
|
+
session_id, message=req.prompt, model=model_payload, variant=variant
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
import uuid
|
|
308
|
+
|
|
309
|
+
turn_id = str(
|
|
310
|
+
prompt_response.get("id") if isinstance(prompt_response, dict) else ""
|
|
311
|
+
)
|
|
312
|
+
if not turn_id:
|
|
313
|
+
turn_id = str(uuid.uuid4())
|
|
314
|
+
text_item_id = f"text-{turn_id}"
|
|
315
|
+
|
|
316
|
+
async def _part_handler(
|
|
317
|
+
part_type: str, part: dict[str, Any], delta: Optional[str]
|
|
318
|
+
) -> None:
|
|
319
|
+
if req.emit_event is None:
|
|
320
|
+
return
|
|
321
|
+
if part_type == "text" and isinstance(delta, str) and delta:
|
|
322
|
+
req.emit_event(
|
|
323
|
+
FlowEventType.AGENT_STREAM_DELTA,
|
|
324
|
+
{"delta": delta, "turn_id": turn_id, "part_type": part_type},
|
|
325
|
+
)
|
|
326
|
+
# Also emit app-server event for summary view
|
|
327
|
+
message = {
|
|
328
|
+
"method": "outputDelta",
|
|
329
|
+
"params": {
|
|
330
|
+
"delta": delta,
|
|
331
|
+
"turnId": turn_id,
|
|
332
|
+
"itemId": text_item_id,
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
req.emit_event(
|
|
336
|
+
FlowEventType.APP_SERVER_EVENT,
|
|
337
|
+
{"message": message, "turn_id": turn_id},
|
|
338
|
+
)
|
|
339
|
+
elif part_type == "reasoning" and isinstance(delta, str) and delta:
|
|
340
|
+
# Emit reasoning as app-server event for summary view
|
|
341
|
+
# Use item/reasoning/summaryTextDelta for merging behavior
|
|
342
|
+
message = {
|
|
343
|
+
"method": "item/reasoning/summaryTextDelta",
|
|
344
|
+
"params": {
|
|
345
|
+
"delta": delta,
|
|
346
|
+
"turnId": turn_id,
|
|
347
|
+
"itemId": f"reasoning-{turn_id}",
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
req.emit_event(
|
|
351
|
+
FlowEventType.APP_SERVER_EVENT,
|
|
352
|
+
{"message": message, "turn_id": turn_id},
|
|
353
|
+
)
|
|
354
|
+
elif part_type == "usage":
|
|
355
|
+
req.emit_event(
|
|
356
|
+
FlowEventType.TOKEN_USAGE,
|
|
357
|
+
{"usage": part, "turn_id": turn_id},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
output = await collect_opencode_output(
|
|
361
|
+
client,
|
|
362
|
+
session_id=session_id,
|
|
363
|
+
workspace_path=directory,
|
|
364
|
+
model_payload=model_payload,
|
|
365
|
+
part_handler=_part_handler if req.emit_event is not None else None,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if req.emit_event is not None and output.text:
|
|
369
|
+
# Emit item/completed for the full text to ensure final state is correct
|
|
370
|
+
message = {
|
|
371
|
+
"method": "item/completed",
|
|
372
|
+
"params": {
|
|
373
|
+
"item": {
|
|
374
|
+
"type": "agentMessage",
|
|
375
|
+
"text": output.text,
|
|
376
|
+
"id": text_item_id,
|
|
377
|
+
},
|
|
378
|
+
"turnId": turn_id,
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
req.emit_event(
|
|
382
|
+
FlowEventType.APP_SERVER_EVENT,
|
|
383
|
+
{"message": message, "turn_id": turn_id},
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if output.error:
|
|
387
|
+
return AgentTurnResult(
|
|
388
|
+
agent_id=req.agent_id,
|
|
389
|
+
conversation_id=session_id,
|
|
390
|
+
turn_id=turn_id,
|
|
391
|
+
text=output.text,
|
|
392
|
+
error=output.error,
|
|
393
|
+
)
|
|
394
|
+
return AgentTurnResult(
|
|
395
|
+
agent_id=req.agent_id,
|
|
396
|
+
conversation_id=session_id,
|
|
397
|
+
turn_id=turn_id,
|
|
398
|
+
text=output.text,
|
|
399
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .frontmatter import parse_markdown_frontmatter
|
|
8
|
+
from .lint import lint_ticket_frontmatter
|
|
9
|
+
from .models import TicketDoc, TicketFrontmatter
|
|
10
|
+
|
|
11
|
+
# Accept TICKET-###.md or TICKET-###<suffix>.md (suffix optional), case-insensitive.
|
|
12
|
+
_TICKET_NAME_RE = re.compile(r"^TICKET-(\d{3,})(?:[^/]*)\.md$", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_ticket_index(name: str) -> Optional[int]:
|
|
16
|
+
match = _TICKET_NAME_RE.match(name)
|
|
17
|
+
if not match:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return int(match.group(1))
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_ticket_paths(ticket_dir: Path) -> list[Path]:
|
|
26
|
+
if not ticket_dir.exists() or not ticket_dir.is_dir():
|
|
27
|
+
return []
|
|
28
|
+
tickets: list[tuple[int, Path]] = []
|
|
29
|
+
for path in ticket_dir.iterdir():
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
continue
|
|
32
|
+
idx = parse_ticket_index(path.name)
|
|
33
|
+
if idx is None:
|
|
34
|
+
continue
|
|
35
|
+
tickets.append((idx, path))
|
|
36
|
+
tickets.sort(key=lambda pair: pair[0])
|
|
37
|
+
return [p for _, p in tickets]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_ticket(path: Path) -> tuple[Optional[TicketDoc], list[str]]:
|
|
41
|
+
"""Read and validate a ticket file.
|
|
42
|
+
|
|
43
|
+
Returns (ticket_doc, lint_errors). When lint errors are present, ticket_doc will
|
|
44
|
+
be None.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
raw = path.read_text(encoding="utf-8")
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
return None, [f"Failed to read ticket: {exc}"]
|
|
51
|
+
|
|
52
|
+
data, body = parse_markdown_frontmatter(raw)
|
|
53
|
+
idx = parse_ticket_index(path.name)
|
|
54
|
+
if idx is None:
|
|
55
|
+
return None, [
|
|
56
|
+
"Invalid ticket filename; expected TICKET-<number>[suffix].md (e.g. TICKET-001-foo.md)"
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
frontmatter, errors = lint_ticket_frontmatter(data)
|
|
60
|
+
if errors:
|
|
61
|
+
return None, errors
|
|
62
|
+
assert frontmatter is not None
|
|
63
|
+
return TicketDoc(path=path, index=idx, frontmatter=frontmatter, body=body), []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_ticket_frontmatter(
|
|
67
|
+
path: Path,
|
|
68
|
+
) -> tuple[Optional[TicketFrontmatter], list[str]]:
|
|
69
|
+
try:
|
|
70
|
+
raw = path.read_text(encoding="utf-8")
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
return None, [f"Failed to read ticket: {exc}"]
|
|
73
|
+
data, _ = parse_markdown_frontmatter(raw)
|
|
74
|
+
frontmatter, errors = lint_ticket_frontmatter(data)
|
|
75
|
+
return frontmatter, errors
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ticket_is_done(path: Path) -> bool:
|
|
79
|
+
frontmatter, errors = read_ticket_frontmatter(path)
|
|
80
|
+
if errors or not frontmatter:
|
|
81
|
+
return False
|
|
82
|
+
return bool(frontmatter.done)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def safe_relpath(path: Path, root: Path) -> str:
|
|
86
|
+
try:
|
|
87
|
+
return str(path.relative_to(root))
|
|
88
|
+
except ValueError:
|
|
89
|
+
return str(path)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
_FRONTMATTER_START = re.compile(r"^---\s*$")
|
|
9
|
+
_FRONTMATTER_END = re.compile(r"^(---|\.\.\.)\s*$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def split_markdown_frontmatter(text: str) -> Tuple[Optional[str], str]:
|
|
13
|
+
"""Split YAML frontmatter from a markdown document.
|
|
14
|
+
|
|
15
|
+
Returns (frontmatter_yaml, body). If no frontmatter is present, frontmatter_yaml is None.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
if not text:
|
|
19
|
+
return None, ""
|
|
20
|
+
lines = text.splitlines()
|
|
21
|
+
if not lines:
|
|
22
|
+
return None, ""
|
|
23
|
+
if not _FRONTMATTER_START.match(lines[0]):
|
|
24
|
+
return None, text
|
|
25
|
+
|
|
26
|
+
end_idx: Optional[int] = None
|
|
27
|
+
for i in range(1, len(lines)):
|
|
28
|
+
if _FRONTMATTER_END.match(lines[i]):
|
|
29
|
+
end_idx = i
|
|
30
|
+
break
|
|
31
|
+
if end_idx is None:
|
|
32
|
+
# Malformed frontmatter; treat as absent so callers can surface a lint error.
|
|
33
|
+
return None, text
|
|
34
|
+
|
|
35
|
+
fm_yaml = "\n".join(lines[1:end_idx])
|
|
36
|
+
body = "\n".join(lines[end_idx + 1 :])
|
|
37
|
+
if body and not body.startswith("\n"):
|
|
38
|
+
body = "\n" + body
|
|
39
|
+
return fm_yaml, body
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_yaml_frontmatter(fm_yaml: Optional[str]) -> dict[str, Any]:
|
|
43
|
+
if fm_yaml is None:
|
|
44
|
+
return {}
|
|
45
|
+
try:
|
|
46
|
+
loaded = yaml.safe_load(fm_yaml)
|
|
47
|
+
except yaml.YAMLError:
|
|
48
|
+
return {}
|
|
49
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_markdown_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
53
|
+
fm_yaml, body = split_markdown_frontmatter(text)
|
|
54
|
+
data = parse_yaml_frontmatter(fm_yaml)
|
|
55
|
+
return data, body
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from ..agents.registry import validate_agent_id
|
|
6
|
+
from .models import TicketFrontmatter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _as_optional_str(value: Any) -> Optional[str]:
|
|
10
|
+
if isinstance(value, str):
|
|
11
|
+
cleaned = value.strip()
|
|
12
|
+
return cleaned or None
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def lint_ticket_frontmatter(
|
|
17
|
+
data: dict[str, Any],
|
|
18
|
+
) -> Tuple[Optional[TicketFrontmatter], list[str]]:
|
|
19
|
+
"""Validate and normalize ticket frontmatter.
|
|
20
|
+
|
|
21
|
+
Required keys:
|
|
22
|
+
- agent: string (or the special value "user")
|
|
23
|
+
- done: bool
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
errors: list[str] = []
|
|
27
|
+
if not isinstance(data, dict) or not data:
|
|
28
|
+
return None, ["Missing or invalid YAML frontmatter (expected a mapping)."]
|
|
29
|
+
|
|
30
|
+
extra = {k: v for k, v in data.items()}
|
|
31
|
+
|
|
32
|
+
agent_raw = data.get("agent")
|
|
33
|
+
agent = _as_optional_str(agent_raw)
|
|
34
|
+
if not agent:
|
|
35
|
+
errors.append("frontmatter.agent is required (e.g. 'codex' or 'opencode').")
|
|
36
|
+
else:
|
|
37
|
+
# Special built-in ticket handler.
|
|
38
|
+
if agent != "user":
|
|
39
|
+
try:
|
|
40
|
+
validate_agent_id(agent)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
errors.append(f"frontmatter.agent is invalid: {exc}")
|
|
43
|
+
|
|
44
|
+
done_raw = data.get("done")
|
|
45
|
+
done: Optional[bool]
|
|
46
|
+
if isinstance(done_raw, bool):
|
|
47
|
+
done = done_raw
|
|
48
|
+
else:
|
|
49
|
+
done = None
|
|
50
|
+
errors.append("frontmatter.done is required and must be a boolean.")
|
|
51
|
+
|
|
52
|
+
title = _as_optional_str(data.get("title"))
|
|
53
|
+
goal = _as_optional_str(data.get("goal"))
|
|
54
|
+
|
|
55
|
+
# Optional model/reasoning overrides.
|
|
56
|
+
model = _as_optional_str(data.get("model"))
|
|
57
|
+
reasoning = _as_optional_str(data.get("reasoning"))
|
|
58
|
+
|
|
59
|
+
# Remove normalized keys from extra.
|
|
60
|
+
for key in ("agent", "done", "title", "goal", "model", "reasoning"):
|
|
61
|
+
extra.pop(key, None)
|
|
62
|
+
|
|
63
|
+
if errors:
|
|
64
|
+
return None, errors
|
|
65
|
+
|
|
66
|
+
assert agent is not None
|
|
67
|
+
assert done is not None
|
|
68
|
+
return (
|
|
69
|
+
TicketFrontmatter(
|
|
70
|
+
agent=agent,
|
|
71
|
+
done=done,
|
|
72
|
+
title=title,
|
|
73
|
+
goal=goal,
|
|
74
|
+
model=model,
|
|
75
|
+
reasoning=reasoning,
|
|
76
|
+
extra=extra,
|
|
77
|
+
),
|
|
78
|
+
[],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lint_dispatch_frontmatter(
|
|
83
|
+
data: dict[str, Any],
|
|
84
|
+
) -> Tuple[dict[str, Any], list[str]]:
|
|
85
|
+
"""Validate DISPATCH.md frontmatter.
|
|
86
|
+
|
|
87
|
+
Keys:
|
|
88
|
+
- mode: "notify" | "pause" | "turn_summary" (defaults to notify)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
errors: list[str] = []
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
return {}, ["Invalid YAML frontmatter (expected a mapping)."]
|
|
94
|
+
|
|
95
|
+
mode_raw = data.get("mode")
|
|
96
|
+
mode = mode_raw.strip().lower() if isinstance(mode_raw, str) else "notify"
|
|
97
|
+
if mode not in ("notify", "pause", "turn_summary"):
|
|
98
|
+
errors.append("frontmatter.mode must be 'notify', 'pause', or 'turn_summary'.")
|
|
99
|
+
|
|
100
|
+
normalized = dict(data)
|
|
101
|
+
normalized["mode"] = mode
|
|
102
|
+
return normalized, errors
|