codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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/__main__.py +4 -0
- codex_autorunner/agents/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- 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 +162 -150
- 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 +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -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 +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +21 -7
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- 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 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- 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 -274
- 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 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ...core.flows import FlowStore
|
|
10
|
+
from ...core.flows.controller import FlowController
|
|
11
|
+
from ...core.flows.models import FlowRunRecord, FlowRunStatus
|
|
12
|
+
from ...core.flows.worker_process import spawn_flow_worker
|
|
13
|
+
from ...core.utils import canonicalize_path
|
|
14
|
+
from ...flows.ticket_flow import build_ticket_flow_definition
|
|
15
|
+
from ...tickets import AgentPool
|
|
16
|
+
from .state import parse_topic_key
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TelegramTicketFlowBridge:
|
|
20
|
+
"""Encapsulate ticket_flow pause/resume plumbing for Telegram service."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
logger: logging.Logger,
|
|
26
|
+
store,
|
|
27
|
+
pause_targets: dict[str, str],
|
|
28
|
+
send_message_with_outbox,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._logger = logger
|
|
31
|
+
self._store = store
|
|
32
|
+
self._pause_targets = pause_targets
|
|
33
|
+
self._send_message_with_outbox = send_message_with_outbox
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _select_ticket_flow_topic(
|
|
37
|
+
entries: list[tuple[str, object]],
|
|
38
|
+
) -> Optional[tuple[str, object]]:
|
|
39
|
+
if not entries:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def score(entry: tuple[str, object]) -> tuple[int, float, str]:
|
|
43
|
+
key, record = entry
|
|
44
|
+
thread_id = None
|
|
45
|
+
try:
|
|
46
|
+
_chat_id, thread_id, _scope = parse_topic_key(key)
|
|
47
|
+
except Exception:
|
|
48
|
+
thread_id = None
|
|
49
|
+
active_raw = getattr(record, "active_thread_id", None)
|
|
50
|
+
try:
|
|
51
|
+
active_thread = int(active_raw) if active_raw is not None else None
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
active_thread = None
|
|
54
|
+
active_match = (
|
|
55
|
+
int(thread_id) == active_thread if thread_id is not None else False
|
|
56
|
+
)
|
|
57
|
+
last_active_at = getattr(record, "last_active_at", None)
|
|
58
|
+
last_active = TelegramTicketFlowBridge._parse_last_active(last_active_at)
|
|
59
|
+
return (1 if active_match else 0, last_active, key)
|
|
60
|
+
|
|
61
|
+
return max(entries, key=score)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _parse_last_active(raw: Optional[str]) -> float:
|
|
65
|
+
if not isinstance(raw, str):
|
|
66
|
+
return float("-inf")
|
|
67
|
+
try:
|
|
68
|
+
return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
|
|
69
|
+
except ValueError:
|
|
70
|
+
return float("-inf")
|
|
71
|
+
|
|
72
|
+
async def watch_ticket_flow_pauses(self, interval_seconds: float) -> None:
|
|
73
|
+
interval = max(interval_seconds, 1.0)
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
await self._scan_and_notify_pauses()
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
self._logger.warning("telegram.ticket_flow.watch_failed", exc_info=exc)
|
|
79
|
+
await asyncio.sleep(interval)
|
|
80
|
+
|
|
81
|
+
async def _scan_and_notify_pauses(self) -> None:
|
|
82
|
+
topics = await self._store.list_topics()
|
|
83
|
+
if not topics:
|
|
84
|
+
return
|
|
85
|
+
workspace_topics: dict[Path, list[tuple[str, object]]] = {}
|
|
86
|
+
for key, record in topics.items():
|
|
87
|
+
if not isinstance(record.workspace_path, str) or not record.workspace_path:
|
|
88
|
+
continue
|
|
89
|
+
workspace_root = canonicalize_path(Path(record.workspace_path))
|
|
90
|
+
workspace_topics.setdefault(workspace_root, []).append((key, record))
|
|
91
|
+
|
|
92
|
+
tasks = [
|
|
93
|
+
asyncio.create_task(self._notify_ticket_flow_pause(workspace_root, entries))
|
|
94
|
+
for workspace_root, entries in workspace_topics.items()
|
|
95
|
+
]
|
|
96
|
+
if tasks:
|
|
97
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
98
|
+
|
|
99
|
+
async def _notify_ticket_flow_pause(
|
|
100
|
+
self,
|
|
101
|
+
workspace_root: Path,
|
|
102
|
+
entries: list[tuple[str, object]],
|
|
103
|
+
) -> None:
|
|
104
|
+
try:
|
|
105
|
+
pause = await asyncio.to_thread(
|
|
106
|
+
self._load_ticket_flow_pause, workspace_root
|
|
107
|
+
)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
self._logger.warning(
|
|
110
|
+
"telegram.ticket_flow.scan_failed",
|
|
111
|
+
exc_info=exc,
|
|
112
|
+
workspace_root=str(workspace_root),
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
if pause is None:
|
|
116
|
+
return
|
|
117
|
+
run_id, seq, content = pause
|
|
118
|
+
marker = f"{run_id}:{seq}"
|
|
119
|
+
pending = [
|
|
120
|
+
(key, record)
|
|
121
|
+
for key, record in entries
|
|
122
|
+
if getattr(record, "last_ticket_dispatch_seq", None) != marker
|
|
123
|
+
]
|
|
124
|
+
if not pending:
|
|
125
|
+
return
|
|
126
|
+
primary = self._select_ticket_flow_topic(pending)
|
|
127
|
+
if not primary:
|
|
128
|
+
return
|
|
129
|
+
message_text = self._format_ticket_flow_pause_message(run_id, seq, content)
|
|
130
|
+
updates: list[tuple[str, Optional[str]]] = [
|
|
131
|
+
(key, getattr(record, "last_ticket_dispatch_seq", None))
|
|
132
|
+
for key, record in pending
|
|
133
|
+
]
|
|
134
|
+
for key, _previous in updates:
|
|
135
|
+
await self._store.update_topic(
|
|
136
|
+
key, self._set_ticket_dispatch_marker(marker)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
primary_key, _primary_record = primary
|
|
140
|
+
try:
|
|
141
|
+
chat_id, thread_id, _scope = parse_topic_key(primary_key)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
self._logger.debug("Failed to parse topic key: %s", exc)
|
|
144
|
+
for key, previous in updates:
|
|
145
|
+
await self._store.update_topic(
|
|
146
|
+
key, self._set_ticket_dispatch_marker(previous)
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
await self._send_message_with_outbox(
|
|
152
|
+
chat_id,
|
|
153
|
+
message_text,
|
|
154
|
+
thread_id=thread_id,
|
|
155
|
+
reply_to=None,
|
|
156
|
+
)
|
|
157
|
+
self._pause_targets[str(workspace_root)] = run_id
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
self._logger.warning(
|
|
160
|
+
"telegram.ticket_flow.notify_failed",
|
|
161
|
+
exc_info=exc,
|
|
162
|
+
topic_key=primary_key,
|
|
163
|
+
run_id=run_id,
|
|
164
|
+
seq=seq,
|
|
165
|
+
)
|
|
166
|
+
for key, previous in updates:
|
|
167
|
+
await self._store.update_topic(
|
|
168
|
+
key, self._set_ticket_dispatch_marker(previous)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _set_ticket_dispatch_marker(
|
|
173
|
+
value: Optional[str],
|
|
174
|
+
):
|
|
175
|
+
def apply(topic) -> None:
|
|
176
|
+
topic.last_ticket_dispatch_seq = value
|
|
177
|
+
|
|
178
|
+
return apply
|
|
179
|
+
|
|
180
|
+
def _load_ticket_flow_pause(
|
|
181
|
+
self, workspace_root: Path
|
|
182
|
+
) -> Optional[tuple[str, str, str]]:
|
|
183
|
+
db_path = workspace_root / ".codex-autorunner" / "flows.db"
|
|
184
|
+
if not db_path.exists():
|
|
185
|
+
return None
|
|
186
|
+
store = FlowStore(db_path)
|
|
187
|
+
try:
|
|
188
|
+
store.initialize()
|
|
189
|
+
runs = store.list_flow_runs(
|
|
190
|
+
flow_type="ticket_flow", status=FlowRunStatus.PAUSED
|
|
191
|
+
)
|
|
192
|
+
if not runs:
|
|
193
|
+
return None
|
|
194
|
+
latest = runs[0]
|
|
195
|
+
runs_dir_raw = latest.input_data.get("runs_dir")
|
|
196
|
+
runs_dir = (
|
|
197
|
+
Path(runs_dir_raw)
|
|
198
|
+
if isinstance(runs_dir_raw, str) and runs_dir_raw
|
|
199
|
+
else Path(".codex-autorunner/runs")
|
|
200
|
+
)
|
|
201
|
+
from ...tickets.outbox import resolve_outbox_paths
|
|
202
|
+
|
|
203
|
+
paths = resolve_outbox_paths(
|
|
204
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=latest.id
|
|
205
|
+
)
|
|
206
|
+
history_dir = paths.dispatch_history_dir
|
|
207
|
+
seq = self._latest_dispatch_seq(history_dir)
|
|
208
|
+
if not seq:
|
|
209
|
+
reason = self._format_ticket_flow_pause_reason(latest)
|
|
210
|
+
return latest.id, "paused", reason
|
|
211
|
+
message_path = history_dir / seq / "DISPATCH.md"
|
|
212
|
+
try:
|
|
213
|
+
content = message_path.read_text(encoding="utf-8")
|
|
214
|
+
except OSError:
|
|
215
|
+
return None
|
|
216
|
+
return latest.id, seq, content
|
|
217
|
+
finally:
|
|
218
|
+
store.close()
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _latest_dispatch_seq(history_dir: Path) -> Optional[str]:
|
|
222
|
+
if not history_dir.exists() or not history_dir.is_dir():
|
|
223
|
+
return None
|
|
224
|
+
seqs = [
|
|
225
|
+
child.name
|
|
226
|
+
for child in history_dir.iterdir()
|
|
227
|
+
if child.is_dir()
|
|
228
|
+
and not child.name.startswith(".")
|
|
229
|
+
and child.name.isdigit()
|
|
230
|
+
]
|
|
231
|
+
if not seqs:
|
|
232
|
+
return None
|
|
233
|
+
return max(seqs)
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _format_ticket_flow_pause_reason(record: FlowRunRecord) -> str:
|
|
237
|
+
state = record.state or {}
|
|
238
|
+
engine = state.get("ticket_engine") or {}
|
|
239
|
+
reason = (
|
|
240
|
+
engine.get("reason") or record.error_message or "Paused without details."
|
|
241
|
+
)
|
|
242
|
+
return f"Reason: {reason}"
|
|
243
|
+
|
|
244
|
+
def _format_ticket_flow_pause_message(
|
|
245
|
+
self, run_id: str, seq: str, content: str
|
|
246
|
+
) -> str:
|
|
247
|
+
from .helpers import _truncate_text
|
|
248
|
+
|
|
249
|
+
trimmed = _truncate_text(content.strip() or "(no dispatch message)", 3000)
|
|
250
|
+
return (
|
|
251
|
+
f"Ticket flow paused (run {run_id}). Latest dispatch #{seq}:\n\n"
|
|
252
|
+
f"{trimmed}\n\nUse /flow resume to continue."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def get_paused_ticket_flow(
|
|
256
|
+
self, workspace_root: Path, preferred_run_id: Optional[str] = None
|
|
257
|
+
) -> Optional[tuple[str, FlowRunRecord]]:
|
|
258
|
+
db_path = workspace_root / ".codex-autorunner" / "flows.db"
|
|
259
|
+
if not db_path.exists():
|
|
260
|
+
return None
|
|
261
|
+
store = FlowStore(db_path)
|
|
262
|
+
try:
|
|
263
|
+
store.initialize()
|
|
264
|
+
if preferred_run_id:
|
|
265
|
+
preferred = store.get_flow_run(preferred_run_id)
|
|
266
|
+
if preferred and preferred.status == FlowRunStatus.PAUSED:
|
|
267
|
+
return preferred.id, preferred
|
|
268
|
+
runs = store.list_flow_runs(
|
|
269
|
+
flow_type="ticket_flow", status=FlowRunStatus.PAUSED
|
|
270
|
+
)
|
|
271
|
+
if not runs:
|
|
272
|
+
return None
|
|
273
|
+
latest = runs[0]
|
|
274
|
+
return latest.id, latest
|
|
275
|
+
finally:
|
|
276
|
+
store.close()
|
|
277
|
+
|
|
278
|
+
async def auto_resume_run(self, workspace_root: Path, run_id: str) -> None:
|
|
279
|
+
"""Best-effort resume + worker spawn; failures are logged only."""
|
|
280
|
+
try:
|
|
281
|
+
controller = _ticket_controller_for(workspace_root)
|
|
282
|
+
updated = await controller.resume_flow(run_id)
|
|
283
|
+
if updated:
|
|
284
|
+
_spawn_ticket_worker(workspace_root, updated.id, self._logger)
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
self._logger.warning(
|
|
287
|
+
"telegram.ticket_flow.auto_resume_failed",
|
|
288
|
+
exc=exc,
|
|
289
|
+
run_id=run_id,
|
|
290
|
+
workspace_root=str(workspace_root),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _ticket_controller_for(repo_root: Path) -> FlowController:
|
|
295
|
+
repo_root = repo_root.resolve()
|
|
296
|
+
db_path = repo_root / ".codex-autorunner" / "flows.db"
|
|
297
|
+
artifacts_root = repo_root / ".codex-autorunner" / "flows"
|
|
298
|
+
from ...core.engine import Engine
|
|
299
|
+
|
|
300
|
+
engine = Engine(repo_root)
|
|
301
|
+
agent_pool = AgentPool(engine.config)
|
|
302
|
+
definition = build_ticket_flow_definition(agent_pool=agent_pool)
|
|
303
|
+
definition.validate()
|
|
304
|
+
controller = FlowController(
|
|
305
|
+
definition=definition, db_path=db_path, artifacts_root=artifacts_root
|
|
306
|
+
)
|
|
307
|
+
controller.initialize()
|
|
308
|
+
return controller
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _spawn_ticket_worker(repo_root: Path, run_id: str, logger: logging.Logger) -> None:
|
|
312
|
+
try:
|
|
313
|
+
proc, out, err = spawn_flow_worker(repo_root, run_id)
|
|
314
|
+
out.close()
|
|
315
|
+
err.close()
|
|
316
|
+
logger.info("Started ticket_flow worker for %s (pid=%s)", run_id, proc.pid)
|
|
317
|
+
except Exception as exc:
|
|
318
|
+
logger.warning(
|
|
319
|
+
"ticket_flow.worker.spawn_failed",
|
|
320
|
+
exc_info=exc,
|
|
321
|
+
extra={"run_id": run_id},
|
|
322
|
+
)
|
|
@@ -41,14 +41,33 @@ class TelegramMessageTransport:
|
|
|
41
41
|
message_id: int,
|
|
42
42
|
text: str,
|
|
43
43
|
*,
|
|
44
|
+
message_thread_id: Optional[int] = None,
|
|
44
45
|
reply_markup: Optional[dict[str, Any]] = None,
|
|
45
46
|
) -> bool:
|
|
46
47
|
try:
|
|
47
48
|
payload_text, parse_mode = self._prepare_message(text)
|
|
49
|
+
if len(payload_text) > TELEGRAM_MAX_MESSAGE_LENGTH:
|
|
50
|
+
trimmed = trim_markdown_message(
|
|
51
|
+
payload_text,
|
|
52
|
+
max_len=TELEGRAM_MAX_MESSAGE_LENGTH,
|
|
53
|
+
render=(
|
|
54
|
+
_format_telegram_html
|
|
55
|
+
if parse_mode == "HTML"
|
|
56
|
+
else (
|
|
57
|
+
lambda v: (
|
|
58
|
+
_format_telegram_markdown(v, parse_mode)
|
|
59
|
+
if parse_mode in ("Markdown", "MarkdownV2")
|
|
60
|
+
else v
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
payload_text = trimmed
|
|
48
66
|
await self._bot.edit_message_text(
|
|
49
67
|
chat_id,
|
|
50
68
|
message_id,
|
|
51
69
|
payload_text,
|
|
70
|
+
message_thread_id=message_thread_id,
|
|
52
71
|
reply_markup=reply_markup,
|
|
53
72
|
parse_mode=parse_mode,
|
|
54
73
|
)
|
|
@@ -56,11 +75,17 @@ class TelegramMessageTransport:
|
|
|
56
75
|
return False
|
|
57
76
|
return True
|
|
58
77
|
|
|
59
|
-
async def _delete_message(
|
|
78
|
+
async def _delete_message(
|
|
79
|
+
self, chat_id: int, message_id: Optional[int], thread_id: Optional[int] = None
|
|
80
|
+
) -> bool:
|
|
60
81
|
if message_id is None:
|
|
61
82
|
return False
|
|
62
83
|
try:
|
|
63
|
-
return bool(
|
|
84
|
+
return bool(
|
|
85
|
+
await self._bot.delete_message(
|
|
86
|
+
chat_id, message_id, message_thread_id=thread_id
|
|
87
|
+
)
|
|
88
|
+
)
|
|
64
89
|
except Exception:
|
|
65
90
|
return False
|
|
66
91
|
|
|
@@ -77,6 +102,7 @@ class TelegramMessageTransport:
|
|
|
77
102
|
callback.chat_id,
|
|
78
103
|
callback.message_id,
|
|
79
104
|
text,
|
|
105
|
+
message_thread_id=callback.thread_id,
|
|
80
106
|
reply_markup=reply_markup,
|
|
81
107
|
)
|
|
82
108
|
|
|
@@ -283,7 +309,7 @@ class TelegramMessageTransport:
|
|
|
283
309
|
message_thread_id=thread_id,
|
|
284
310
|
reply_to_message_id=reply_to if idx == 0 else None,
|
|
285
311
|
reply_markup=reply_markup if idx == 0 else None,
|
|
286
|
-
parse_mode=
|
|
312
|
+
parse_mode=used_mode,
|
|
287
313
|
)
|
|
288
314
|
return
|
|
289
315
|
if overflow_mode == "trim":
|
|
@@ -385,7 +411,13 @@ class TelegramMessageTransport:
|
|
|
385
411
|
if callback is None:
|
|
386
412
|
return
|
|
387
413
|
try:
|
|
388
|
-
await self._bot.answer_callback_query(
|
|
414
|
+
await self._bot.answer_callback_query(
|
|
415
|
+
callback.callback_id,
|
|
416
|
+
chat_id=callback.chat_id,
|
|
417
|
+
thread_id=callback.thread_id,
|
|
418
|
+
message_id=callback.message_id,
|
|
419
|
+
text=text,
|
|
420
|
+
)
|
|
389
421
|
except Exception as exc:
|
|
390
422
|
log_event(
|
|
391
423
|
self._logger,
|
|
@@ -393,6 +425,7 @@ class TelegramMessageTransport:
|
|
|
393
425
|
"telegram.answer_callback.failed",
|
|
394
426
|
chat_id=callback.chat_id,
|
|
395
427
|
thread_id=callback.thread_id,
|
|
428
|
+
message_id=callback.message_id,
|
|
396
429
|
callback_id=callback.callback_id,
|
|
397
430
|
exc=exc,
|
|
398
431
|
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal, Optional
|
|
4
|
+
|
|
5
|
+
from .adapter import TelegramMessage
|
|
6
|
+
|
|
7
|
+
TriggerMode = Literal["all", "mentions"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def should_trigger_run(
|
|
11
|
+
message: TelegramMessage,
|
|
12
|
+
*,
|
|
13
|
+
text: str,
|
|
14
|
+
bot_username: Optional[str],
|
|
15
|
+
) -> bool:
|
|
16
|
+
"""Return True if this message should start a run in mentions-only mode.
|
|
17
|
+
|
|
18
|
+
This mirrors Takopi's "mentions" trigger mode semantics (subset):
|
|
19
|
+
|
|
20
|
+
- Always trigger in private chats.
|
|
21
|
+
- Trigger when the bot is explicitly mentioned: "@<bot_username>" anywhere in the text.
|
|
22
|
+
- Trigger when replying to a bot message (but ignore the common forum-topic
|
|
23
|
+
"implicit root reply" case where clients set reply_to_message_id == thread_id).
|
|
24
|
+
- Otherwise, do not trigger (commands and other explicit affordances are handled elsewhere).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
if message.chat_type == "private":
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
lowered = (text or "").lower()
|
|
31
|
+
if bot_username:
|
|
32
|
+
needle = f"@{bot_username}".lower()
|
|
33
|
+
if needle in lowered:
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
implicit_topic_reply = (
|
|
37
|
+
message.thread_id is not None
|
|
38
|
+
and message.reply_to_message_id is not None
|
|
39
|
+
and message.reply_to_message_id == message.thread_id
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if message.reply_to_is_bot and not implicit_topic_reply:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
bot_username
|
|
47
|
+
and message.reply_to_username
|
|
48
|
+
and message.reply_to_username.lower() == bot_username.lower()
|
|
49
|
+
and not implicit_topic_reply
|
|
50
|
+
):
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
return False
|
|
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import dataclasses
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import Optional
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional, Union
|
|
7
7
|
|
|
8
8
|
from ..app_server.client import ApprovalDecision
|
|
9
9
|
from .helpers import ModelOption
|
|
@@ -22,6 +22,26 @@ class PendingApproval:
|
|
|
22
22
|
future: asyncio.Future[ApprovalDecision]
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
@dataclass
|
|
26
|
+
class PendingQuestion:
|
|
27
|
+
request_id: str
|
|
28
|
+
turn_id: str
|
|
29
|
+
codex_thread_id: Optional[str]
|
|
30
|
+
chat_id: int
|
|
31
|
+
thread_id: Optional[int]
|
|
32
|
+
topic_key: Optional[str]
|
|
33
|
+
message_id: Optional[int]
|
|
34
|
+
created_at: str
|
|
35
|
+
question_index: int
|
|
36
|
+
prompt: str
|
|
37
|
+
options: list[str]
|
|
38
|
+
future: asyncio.Future[Union[list[int], str, None]]
|
|
39
|
+
multiple: bool = False
|
|
40
|
+
custom: bool = True
|
|
41
|
+
selected_indices: set[int] = field(default_factory=set)
|
|
42
|
+
awaiting_custom_input: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
25
45
|
@dataclass
|
|
26
46
|
class TurnContext:
|
|
27
47
|
topic_key: str
|
|
@@ -65,7 +65,7 @@ class TelegramVoiceManager:
|
|
|
65
65
|
self._lock = asyncio.Lock()
|
|
66
66
|
|
|
67
67
|
async def restore(self) -> None:
|
|
68
|
-
records = self._store.list_pending_voice()
|
|
68
|
+
records = await self._store.list_pending_voice()
|
|
69
69
|
if not records:
|
|
70
70
|
return
|
|
71
71
|
log_event(
|
|
@@ -80,7 +80,7 @@ class TelegramVoiceManager:
|
|
|
80
80
|
while True:
|
|
81
81
|
await asyncio.sleep(VOICE_RETRY_INTERVAL_SECONDS)
|
|
82
82
|
try:
|
|
83
|
-
records = self._store.list_pending_voice()
|
|
83
|
+
records = await self._store.list_pending_voice()
|
|
84
84
|
if records:
|
|
85
85
|
await self._flush(records)
|
|
86
86
|
except Exception as exc:
|
|
@@ -92,7 +92,7 @@ class TelegramVoiceManager:
|
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
async def attempt(self, record_id: str) -> bool:
|
|
95
|
-
record = self._store.get_pending_voice(record_id)
|
|
95
|
+
record = await self._store.get_pending_voice(record_id)
|
|
96
96
|
if record is None:
|
|
97
97
|
return False
|
|
98
98
|
if not self._ready_for_attempt(record):
|
|
@@ -101,7 +101,7 @@ class TelegramVoiceManager:
|
|
|
101
101
|
return False
|
|
102
102
|
inflight_id = record.record_id
|
|
103
103
|
try:
|
|
104
|
-
current_record = self._store.get_pending_voice(record.record_id)
|
|
104
|
+
current_record = await self._store.get_pending_voice(record.record_id)
|
|
105
105
|
if current_record is None:
|
|
106
106
|
return False
|
|
107
107
|
if not self._ready_for_attempt(current_record):
|
|
@@ -114,7 +114,7 @@ class TelegramVoiceManager:
|
|
|
114
114
|
finally:
|
|
115
115
|
await self._clear_inflight(inflight_id)
|
|
116
116
|
if done:
|
|
117
|
-
self._store.delete_pending_voice(record.record_id)
|
|
117
|
+
await self._store.delete_pending_voice(record.record_id)
|
|
118
118
|
return done
|
|
119
119
|
|
|
120
120
|
async def _flush(self, records: list[PendingVoiceRecord]) -> None:
|
|
@@ -155,7 +155,7 @@ class TelegramVoiceManager:
|
|
|
155
155
|
await self._deliver_transcript(record, record.transcript_text)
|
|
156
156
|
self._remove_voice_file(record)
|
|
157
157
|
return True
|
|
158
|
-
path = self._resolve_voice_download_path(record)
|
|
158
|
+
path = await self._resolve_voice_download_path(record)
|
|
159
159
|
if path is None:
|
|
160
160
|
data, file_path, file_size = await self._download_file(record.file_id)
|
|
161
161
|
if file_size and file_size > max_bytes:
|
|
@@ -180,11 +180,10 @@ class TelegramVoiceManager:
|
|
|
180
180
|
record.file_size = file_size
|
|
181
181
|
else:
|
|
182
182
|
record.file_size = len(data)
|
|
183
|
-
self._store.update_pending_voice(record)
|
|
183
|
+
await self._store.update_pending_voice(record)
|
|
184
184
|
data = path.read_bytes()
|
|
185
185
|
try:
|
|
186
|
-
result = await
|
|
187
|
-
self._voice_service.transcribe,
|
|
186
|
+
result = await self._voice_service.transcribe_async(
|
|
188
187
|
data,
|
|
189
188
|
client="telegram",
|
|
190
189
|
filename=record.file_name or path.name,
|
|
@@ -236,7 +235,7 @@ class TelegramVoiceManager:
|
|
|
236
235
|
text_len=len(transcript),
|
|
237
236
|
)
|
|
238
237
|
record.transcript_text = combined
|
|
239
|
-
self._store.update_pending_voice(record)
|
|
238
|
+
await self._store.update_pending_voice(record)
|
|
240
239
|
await self._deliver_transcript(record, combined)
|
|
241
240
|
self._remove_voice_file(record)
|
|
242
241
|
return True
|
|
@@ -253,7 +252,7 @@ class TelegramVoiceManager:
|
|
|
253
252
|
record.last_attempt_at = now_iso()
|
|
254
253
|
delay = self._retry_delay(record.attempts, retry_after=retry_after)
|
|
255
254
|
record.next_attempt_at = _format_future_time(delay)
|
|
256
|
-
self._store.update_pending_voice(record)
|
|
255
|
+
await self._store.update_pending_voice(record)
|
|
257
256
|
log_event(
|
|
258
257
|
self._logger,
|
|
259
258
|
logging.WARNING,
|
|
@@ -273,7 +272,7 @@ class TelegramVoiceManager:
|
|
|
273
272
|
)
|
|
274
273
|
if progress_id is not None:
|
|
275
274
|
record.progress_message_id = progress_id
|
|
276
|
-
self._store.update_pending_voice(record)
|
|
275
|
+
await self._store.update_pending_voice(record)
|
|
277
276
|
if record.attempts >= VOICE_MAX_ATTEMPTS:
|
|
278
277
|
await self._give_up(
|
|
279
278
|
record,
|
|
@@ -295,7 +294,7 @@ class TelegramVoiceManager:
|
|
|
295
294
|
reply_to=record.message_id,
|
|
296
295
|
)
|
|
297
296
|
self._remove_voice_file(record)
|
|
298
|
-
self._store.delete_pending_voice(record.record_id)
|
|
297
|
+
await self._store.delete_pending_voice(record.record_id)
|
|
299
298
|
log_event(
|
|
300
299
|
self._logger,
|
|
301
300
|
logging.WARNING,
|
|
@@ -337,7 +336,7 @@ class TelegramVoiceManager:
|
|
|
337
336
|
delay += random.uniform(0, jitter)
|
|
338
337
|
return delay
|
|
339
338
|
|
|
340
|
-
def _resolve_voice_download_path(
|
|
339
|
+
async def _resolve_voice_download_path(
|
|
341
340
|
self, record: PendingVoiceRecord
|
|
342
341
|
) -> Optional[Path]:
|
|
343
342
|
if not record.download_path:
|
|
@@ -346,7 +345,7 @@ class TelegramVoiceManager:
|
|
|
346
345
|
if path.exists():
|
|
347
346
|
return path
|
|
348
347
|
record.download_path = None
|
|
349
|
-
self._store.update_pending_voice(record)
|
|
348
|
+
await self._store.update_pending_voice(record)
|
|
350
349
|
return None
|
|
351
350
|
|
|
352
351
|
def _persist_voice_payload(
|
codex_autorunner/manifest.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, cast
|
|
|
7
7
|
import yaml
|
|
8
8
|
|
|
9
9
|
MANIFEST_VERSION = 2
|
|
10
|
+
MANIFEST_HEADER = "# GENERATED by CAR - DO NOT EDIT\n"
|
|
10
11
|
_SAFE_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
11
12
|
_SANITIZE_REPO_ID_PATTERN = re.compile(r"[^A-Za-z0-9._-]+")
|
|
12
13
|
|
|
@@ -194,4 +195,5 @@ def save_manifest(manifest_path: Path, manifest: Manifest, hub_root: Path) -> No
|
|
|
194
195
|
"repos": [repo.to_dict(hub_root) for repo in manifest.repos],
|
|
195
196
|
}
|
|
196
197
|
with manifest_path.open("w", encoding="utf-8") as f:
|
|
198
|
+
f.write(MANIFEST_HEADER)
|
|
197
199
|
yaml.safe_dump(payload, f, sort_keys=False)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Codex Autorunner plugin API metadata.
|
|
4
|
+
|
|
5
|
+
This module is intentionally small and stable. External plugins SHOULD depend
|
|
6
|
+
only on the public API in `codex_autorunner.api` + this version constant.
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- Backwards-incompatible changes to the plugin API MUST bump
|
|
10
|
+
`CAR_PLUGIN_API_VERSION`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
CAR_PLUGIN_API_VERSION = 1
|
|
14
|
+
|
|
15
|
+
# Entry point groups (Python packaging entry points).
|
|
16
|
+
#
|
|
17
|
+
# Plugins can publish new agent backends by defining an entry point:
|
|
18
|
+
#
|
|
19
|
+
# [project.entry-points."codex_autorunner.agent_backends"]
|
|
20
|
+
# myagent = "my_package.my_module:AGENT_BACKEND"
|
|
21
|
+
#
|
|
22
|
+
CAR_AGENT_ENTRYPOINT_GROUP = "codex_autorunner.agent_backends"
|