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
|
@@ -5,24 +5,27 @@ import collections
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
|
-
import shlex
|
|
9
8
|
import socket
|
|
10
9
|
import time
|
|
10
|
+
from datetime import datetime
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import TYPE_CHECKING, Any, Coroutine, Optional, Sequence
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Sequence
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from .progress_stream import TurnProgressTracker
|
|
16
16
|
from .state import TelegramTopicRecord
|
|
17
17
|
|
|
18
18
|
from ...agents.opencode.supervisor import OpenCodeSupervisor
|
|
19
|
+
from ...core.flows.models import FlowRunRecord
|
|
19
20
|
from ...core.locks import process_alive
|
|
20
21
|
from ...core.logging_utils import log_event
|
|
21
22
|
from ...core.request_context import reset_conversation_id, set_conversation_id
|
|
22
23
|
from ...core.state import now_iso
|
|
23
|
-
from ...core.
|
|
24
|
+
from ...core.text_delta_coalescer import TextDeltaCoalescer
|
|
25
|
+
from ...core.utils import build_opencode_supervisor
|
|
24
26
|
from ...housekeeping import HousekeepingConfig, run_housekeeping_for_roots
|
|
25
27
|
from ...manifest import load_manifest
|
|
28
|
+
from ...tickets.replies import dispatch_reply, ensure_reply_dirs, resolve_reply_paths
|
|
26
29
|
from ...voice import VoiceConfig, VoiceService
|
|
27
30
|
from ..app_server.supervisor import WorkspaceAppServerSupervisor
|
|
28
31
|
from .adapter import (
|
|
@@ -36,25 +39,16 @@ from .adapter import (
|
|
|
36
39
|
)
|
|
37
40
|
from .commands_registry import build_command_payloads, diff_command_lists
|
|
38
41
|
from .config import (
|
|
42
|
+
AppServerUnavailableError,
|
|
39
43
|
TelegramBotConfig,
|
|
40
44
|
TelegramBotConfigError,
|
|
41
45
|
TelegramBotLockError,
|
|
42
46
|
TelegramMediaCandidate,
|
|
43
47
|
)
|
|
44
48
|
from .constants import (
|
|
45
|
-
CACHE_CLEANUP_INTERVAL_SECONDS,
|
|
46
|
-
COALESCE_BUFFER_TTL_SECONDS,
|
|
47
49
|
DEFAULT_INTERRUPT_TIMEOUT_SECONDS,
|
|
48
50
|
DEFAULT_WORKSPACE_STATE_ROOT,
|
|
49
|
-
|
|
50
|
-
MODEL_PENDING_TTL_SECONDS,
|
|
51
|
-
OVERSIZE_WARNING_TTL_SECONDS,
|
|
52
|
-
PENDING_APPROVAL_TTL_SECONDS,
|
|
53
|
-
PROGRESS_STREAM_TTL_SECONDS,
|
|
54
|
-
REASONING_BUFFER_TTL_SECONDS,
|
|
55
|
-
SELECTION_STATE_TTL_SECONDS,
|
|
56
|
-
TURN_PREVIEW_TTL_SECONDS,
|
|
57
|
-
UPDATE_ID_PERSIST_INTERVAL_SECONDS,
|
|
51
|
+
QUEUED_PLACEHOLDER_TEXT,
|
|
58
52
|
TurnKey,
|
|
59
53
|
)
|
|
60
54
|
from .dispatch import dispatch_update
|
|
@@ -64,6 +58,7 @@ from .handlers.approvals import TelegramApprovalHandlers
|
|
|
64
58
|
from .handlers.commands import build_command_specs
|
|
65
59
|
from .handlers.commands_runtime import TelegramCommandHandlers
|
|
66
60
|
from .handlers.messages import _CoalescedBuffer
|
|
61
|
+
from .handlers.questions import TelegramQuestionHandlers
|
|
67
62
|
from .handlers.selections import TelegramSelectionHandlers
|
|
68
63
|
from .helpers import (
|
|
69
64
|
ModelOption,
|
|
@@ -82,39 +77,20 @@ from .state import (
|
|
|
82
77
|
parse_topic_key,
|
|
83
78
|
topic_key,
|
|
84
79
|
)
|
|
80
|
+
from .ticket_flow_bridge import TelegramTicketFlowBridge
|
|
85
81
|
from .transport import TelegramMessageTransport
|
|
86
82
|
from .types import (
|
|
87
83
|
CompactState,
|
|
88
84
|
ModelPickerState,
|
|
89
85
|
PendingApproval,
|
|
86
|
+
PendingQuestion,
|
|
90
87
|
ReviewCommitSelectionState,
|
|
91
88
|
SelectionState,
|
|
92
89
|
TurnContext,
|
|
93
90
|
)
|
|
94
91
|
from .voice import TelegramVoiceManager
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
def _parse_command(raw: Optional[str]) -> list[str]:
|
|
98
|
-
if not raw:
|
|
99
|
-
return []
|
|
100
|
-
try:
|
|
101
|
-
return [part for part in shlex.split(raw) if part]
|
|
102
|
-
except ValueError:
|
|
103
|
-
return []
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _command_available(command: list[str], *, workspace_root: Path) -> bool:
|
|
107
|
-
if not command:
|
|
108
|
-
return False
|
|
109
|
-
entry = str(command[0]).strip()
|
|
110
|
-
if not entry:
|
|
111
|
-
return False
|
|
112
|
-
if os.path.sep in entry or (os.path.altsep and os.path.altsep in entry):
|
|
113
|
-
path = Path(entry)
|
|
114
|
-
if not path.is_absolute():
|
|
115
|
-
path = workspace_root / path
|
|
116
|
-
return path.is_file() and os.access(path, os.X_OK)
|
|
117
|
-
return resolve_executable(entry) is not None
|
|
93
|
+
TICKET_FLOW_WATCH_INTERVAL_SECONDS = 20
|
|
118
94
|
|
|
119
95
|
|
|
120
96
|
def _build_opencode_supervisor(
|
|
@@ -122,38 +98,22 @@ def _build_opencode_supervisor(
|
|
|
122
98
|
*,
|
|
123
99
|
logger: logging.Logger,
|
|
124
100
|
) -> Optional[OpenCodeSupervisor]:
|
|
125
|
-
|
|
126
|
-
command = _parse_command(raw_command)
|
|
101
|
+
opencode_command = config.opencode_command or None
|
|
127
102
|
opencode_binary = config.agent_binaries.get("opencode")
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
resolved_binary = resolve_opencode_binary(resolved_source)
|
|
143
|
-
if command:
|
|
144
|
-
if resolved_binary:
|
|
145
|
-
command[0] = resolved_binary
|
|
146
|
-
else:
|
|
147
|
-
if resolved_binary:
|
|
148
|
-
command = [
|
|
149
|
-
resolved_binary,
|
|
150
|
-
"serve",
|
|
151
|
-
"--hostname",
|
|
152
|
-
"127.0.0.1",
|
|
153
|
-
"--port",
|
|
154
|
-
"0",
|
|
155
|
-
]
|
|
156
|
-
if not command or not _command_available(command, workspace_root=config.root):
|
|
103
|
+
|
|
104
|
+
supervisor = build_opencode_supervisor(
|
|
105
|
+
opencode_command=opencode_command,
|
|
106
|
+
opencode_binary=opencode_binary,
|
|
107
|
+
workspace_root=config.root,
|
|
108
|
+
logger=logger,
|
|
109
|
+
request_timeout=None,
|
|
110
|
+
max_handles=config.app_server_max_handles,
|
|
111
|
+
idle_ttl_seconds=config.app_server_idle_ttl_seconds,
|
|
112
|
+
base_env=None,
|
|
113
|
+
subagent_models=None,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if supervisor is None:
|
|
157
117
|
log_event(
|
|
158
118
|
logger,
|
|
159
119
|
logging.INFO,
|
|
@@ -161,16 +121,28 @@ def _build_opencode_supervisor(
|
|
|
161
121
|
reason="command_missing",
|
|
162
122
|
)
|
|
163
123
|
return None
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
124
|
+
|
|
125
|
+
return supervisor
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _next_reply_seq_sync(reply_history_dir: Any) -> int:
|
|
129
|
+
from pathlib import Path
|
|
130
|
+
|
|
131
|
+
path = Path(reply_history_dir)
|
|
132
|
+
if not path.exists() or not path.is_dir():
|
|
133
|
+
return 1
|
|
134
|
+
existing: list[int] = []
|
|
135
|
+
_SEQ_RE = __import__("re").compile(r"^[0-9]{4}$")
|
|
136
|
+
for child in path.iterdir():
|
|
137
|
+
try:
|
|
138
|
+
if not child.is_dir():
|
|
139
|
+
continue
|
|
140
|
+
if not _SEQ_RE.fullmatch(child.name):
|
|
141
|
+
continue
|
|
142
|
+
existing.append(int(child.name))
|
|
143
|
+
except OSError:
|
|
144
|
+
continue
|
|
145
|
+
return (max(existing) + 1) if existing else 1
|
|
174
146
|
|
|
175
147
|
|
|
176
148
|
class TelegramBotService(
|
|
@@ -178,6 +150,7 @@ class TelegramBotService(
|
|
|
178
150
|
TelegramMessageTransport,
|
|
179
151
|
TelegramNotificationHandlers,
|
|
180
152
|
TelegramApprovalHandlers,
|
|
153
|
+
TelegramQuestionHandlers,
|
|
181
154
|
TelegramSelectionHandlers,
|
|
182
155
|
TelegramCommandHandlers,
|
|
183
156
|
):
|
|
@@ -193,6 +166,8 @@ class TelegramBotService(
|
|
|
193
166
|
housekeeping_config: Optional[HousekeepingConfig] = None,
|
|
194
167
|
update_repo_url: Optional[str] = None,
|
|
195
168
|
update_repo_ref: Optional[str] = None,
|
|
169
|
+
update_skip_checks: bool = False,
|
|
170
|
+
app_server_auto_restart: Optional[bool] = None,
|
|
196
171
|
) -> None:
|
|
197
172
|
self._config = config
|
|
198
173
|
self._logger = logger or logging.getLogger(__name__)
|
|
@@ -200,6 +175,8 @@ class TelegramBotService(
|
|
|
200
175
|
self._manifest_path = manifest_path
|
|
201
176
|
self._update_repo_url = update_repo_url
|
|
202
177
|
self._update_repo_ref = update_repo_ref
|
|
178
|
+
self._update_skip_checks = update_skip_checks
|
|
179
|
+
self._app_server_auto_restart = app_server_auto_restart
|
|
203
180
|
self._allowlist = config.allowlist()
|
|
204
181
|
self._store = TelegramStateStore(
|
|
205
182
|
config.state_file, default_approval_mode=config.defaults.approval_mode
|
|
@@ -213,6 +190,7 @@ class TelegramBotService(
|
|
|
213
190
|
approval_handler=self._handle_approval_request,
|
|
214
191
|
notification_handler=self._handle_app_server_notification,
|
|
215
192
|
logger=self._logger,
|
|
193
|
+
auto_restart=self._app_server_auto_restart,
|
|
216
194
|
max_handles=config.app_server_max_handles,
|
|
217
195
|
idle_ttl_seconds=config.app_server_idle_ttl_seconds,
|
|
218
196
|
)
|
|
@@ -220,7 +198,16 @@ class TelegramBotService(
|
|
|
220
198
|
config,
|
|
221
199
|
logger=self._logger,
|
|
222
200
|
)
|
|
223
|
-
|
|
201
|
+
poll_timeout = float(config.poll_timeout_seconds)
|
|
202
|
+
request_timeout = config.poll_request_timeout_seconds
|
|
203
|
+
if request_timeout is None:
|
|
204
|
+
# Keep HTTP timeout above long-poll timeout to avoid ReadTimeout churn.
|
|
205
|
+
request_timeout = max(poll_timeout + 5.0, 10.0)
|
|
206
|
+
self._bot = TelegramBotClient(
|
|
207
|
+
config.bot_token or "",
|
|
208
|
+
logger=self._logger,
|
|
209
|
+
timeout_seconds=float(request_timeout),
|
|
210
|
+
)
|
|
224
211
|
self._poller = TelegramUpdatePoller(
|
|
225
212
|
self._bot, allowed_updates=config.poll_allowed_updates
|
|
226
213
|
)
|
|
@@ -242,15 +229,25 @@ class TelegramBotService(
|
|
|
242
229
|
)
|
|
243
230
|
self._turn_semaphore: Optional[asyncio.Semaphore] = None
|
|
244
231
|
self._turn_contexts: dict[TurnKey, TurnContext] = {}
|
|
245
|
-
self._reasoning_buffers: dict[str,
|
|
232
|
+
self._reasoning_buffers: dict[str, TextDeltaCoalescer] = {}
|
|
246
233
|
self._turn_preview_text: dict[TurnKey, str] = {}
|
|
247
234
|
self._turn_preview_updated_at: dict[TurnKey, float] = {}
|
|
248
235
|
self._turn_progress_trackers: dict[TurnKey, "TurnProgressTracker"] = {}
|
|
249
236
|
self._turn_progress_rendered: dict[TurnKey, str] = {}
|
|
250
237
|
self._turn_progress_updated_at: dict[TurnKey, float] = {}
|
|
251
238
|
self._turn_progress_tasks: dict[TurnKey, asyncio.Task[None]] = {}
|
|
239
|
+
self._turn_progress_heartbeat_tasks: dict[TurnKey, asyncio.Task[None]] = {}
|
|
240
|
+
self._turn_progress_locks: dict[TurnKey, asyncio.Lock] = {}
|
|
252
241
|
self._oversize_warnings: set[TurnKey] = set()
|
|
253
242
|
self._pending_approvals: dict[str, PendingApproval] = {}
|
|
243
|
+
self._pending_questions: dict[str, PendingQuestion] = {}
|
|
244
|
+
self._ticket_flow_pause_targets: dict[str, str] = {}
|
|
245
|
+
self._ticket_flow_bridge = TelegramTicketFlowBridge(
|
|
246
|
+
logger=self._logger,
|
|
247
|
+
store=self._store,
|
|
248
|
+
pause_targets=self._ticket_flow_pause_targets,
|
|
249
|
+
send_message_with_outbox=self._send_message_with_outbox,
|
|
250
|
+
)
|
|
254
251
|
self._resume_options: dict[str, SelectionState] = {}
|
|
255
252
|
self._bind_options: dict[str, SelectionState] = {}
|
|
256
253
|
self._update_options: dict[str, SelectionState] = {}
|
|
@@ -265,6 +262,8 @@ class TelegramBotService(
|
|
|
265
262
|
self._media_batch_locks: dict[str, asyncio.Lock] = {}
|
|
266
263
|
self._outbox_inflight: set[str] = set()
|
|
267
264
|
self._outbox_lock: Optional[asyncio.Lock] = None
|
|
265
|
+
self._queued_placeholder_map: dict[tuple[int, int], int] = {}
|
|
266
|
+
self._queued_placeholder_timestamps: dict[tuple[int, int], float] = {}
|
|
268
267
|
self._bot_username: Optional[str] = None
|
|
269
268
|
self._token_usage_by_thread: "collections.OrderedDict[str, dict[str, Any]]" = (
|
|
270
269
|
collections.OrderedDict()
|
|
@@ -274,6 +273,7 @@ class TelegramBotService(
|
|
|
274
273
|
)
|
|
275
274
|
self._outbox_task: Optional[asyncio.Task[None]] = None
|
|
276
275
|
self._cache_cleanup_task: Optional[asyncio.Task[None]] = None
|
|
276
|
+
self._ticket_flow_watch_task: Optional[asyncio.Task[None]] = None
|
|
277
277
|
self._cache_timestamps: dict[str, dict[object, float]] = {}
|
|
278
278
|
self._last_update_ids: dict[str, int] = {}
|
|
279
279
|
self._last_update_persisted_at: dict[str, float] = {}
|
|
@@ -302,10 +302,10 @@ class TelegramBotService(
|
|
|
302
302
|
self._command_specs = build_command_specs(self)
|
|
303
303
|
self._instance_lock_path: Optional[Path] = None
|
|
304
304
|
|
|
305
|
-
def _housekeeping_roots(self) -> list[Path]:
|
|
305
|
+
async def _housekeeping_roots(self) -> list[Path]:
|
|
306
306
|
roots: set[Path] = set()
|
|
307
307
|
try:
|
|
308
|
-
state = self._store.load()
|
|
308
|
+
state = await self._store.load()
|
|
309
309
|
for record in state.topics.values():
|
|
310
310
|
if isinstance(record.workspace_path, str) and record.workspace_path:
|
|
311
311
|
roots.add(Path(record.workspace_path).expanduser().resolve())
|
|
@@ -332,6 +332,81 @@ class TelegramBotService(
|
|
|
332
332
|
roots.add(self._config.root.resolve())
|
|
333
333
|
return sorted(roots)
|
|
334
334
|
|
|
335
|
+
async def _gather_workspace_roots(self) -> list[Path]:
|
|
336
|
+
roots: set[Path] = set()
|
|
337
|
+
try:
|
|
338
|
+
state = await self._store.load()
|
|
339
|
+
for record in state.topics.values():
|
|
340
|
+
if isinstance(record.workspace_path, str) and record.workspace_path:
|
|
341
|
+
roots.add(Path(record.workspace_path).expanduser().resolve())
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
log_event(
|
|
344
|
+
self._logger,
|
|
345
|
+
logging.WARNING,
|
|
346
|
+
"telegram.prewarm.state_failed",
|
|
347
|
+
exc=exc,
|
|
348
|
+
)
|
|
349
|
+
return sorted(roots)
|
|
350
|
+
|
|
351
|
+
async def _prewarm_workspace_clients(self) -> None:
|
|
352
|
+
workspace_roots = await self._gather_workspace_roots()
|
|
353
|
+
if not workspace_roots:
|
|
354
|
+
log_event(
|
|
355
|
+
self._logger,
|
|
356
|
+
logging.INFO,
|
|
357
|
+
"telegram.prewarm.skipped",
|
|
358
|
+
reason="no_workspaces",
|
|
359
|
+
)
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
log_event(
|
|
363
|
+
self._logger,
|
|
364
|
+
logging.INFO,
|
|
365
|
+
"telegram.prewarm.started",
|
|
366
|
+
workspace_count=len(workspace_roots),
|
|
367
|
+
workspaces=[str(p) for p in workspace_roots],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
sem = asyncio.Semaphore(3)
|
|
371
|
+
prewarmed_count = 0
|
|
372
|
+
failed_count = 0
|
|
373
|
+
|
|
374
|
+
async def prewarm_one(workspace_root: Path) -> None:
|
|
375
|
+
nonlocal prewarmed_count, failed_count
|
|
376
|
+
async with sem:
|
|
377
|
+
try:
|
|
378
|
+
await self._app_server_supervisor.get_client(workspace_root)
|
|
379
|
+
prewarmed_count += 1
|
|
380
|
+
log_event(
|
|
381
|
+
self._logger,
|
|
382
|
+
logging.INFO,
|
|
383
|
+
"telegram.prewarm.client_ready",
|
|
384
|
+
workspace_root=str(workspace_root),
|
|
385
|
+
)
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
failed_count += 1
|
|
388
|
+
log_event(
|
|
389
|
+
self._logger,
|
|
390
|
+
logging.WARNING,
|
|
391
|
+
"telegram.prewarm.client_failed",
|
|
392
|
+
workspace_root=str(workspace_root),
|
|
393
|
+
exc=exc,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
await asyncio.gather(
|
|
397
|
+
*[prewarm_one(root) for root in workspace_roots],
|
|
398
|
+
return_exceptions=True,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
log_event(
|
|
402
|
+
self._logger,
|
|
403
|
+
logging.INFO,
|
|
404
|
+
"telegram.prewarm.completed",
|
|
405
|
+
workspace_count=len(workspace_roots),
|
|
406
|
+
prewarmed_count=prewarmed_count,
|
|
407
|
+
failed_count=failed_count,
|
|
408
|
+
)
|
|
409
|
+
|
|
335
410
|
async def _housekeeping_loop(self) -> None:
|
|
336
411
|
config = self._housekeeping_config
|
|
337
412
|
if config is None or not config.enabled:
|
|
@@ -339,7 +414,7 @@ class TelegramBotService(
|
|
|
339
414
|
interval = max(config.interval_seconds, 1)
|
|
340
415
|
while True:
|
|
341
416
|
try:
|
|
342
|
-
roots = self._housekeeping_roots()
|
|
417
|
+
roots = await self._housekeeping_roots()
|
|
343
418
|
if roots:
|
|
344
419
|
await asyncio.to_thread(
|
|
345
420
|
run_housekeeping_for_roots,
|
|
@@ -485,11 +560,17 @@ class TelegramBotService(
|
|
|
485
560
|
await self._restore_pending_approvals()
|
|
486
561
|
await self._outbox_manager.restore()
|
|
487
562
|
await self._voice_manager.restore()
|
|
488
|
-
self._prime_poller_offset()
|
|
563
|
+
await self._prime_poller_offset()
|
|
489
564
|
self._outbox_task = asyncio.create_task(self._outbox_manager.run_loop())
|
|
490
565
|
self._voice_task = asyncio.create_task(self._voice_manager.run_loop())
|
|
491
566
|
self._housekeeping_task = asyncio.create_task(self._housekeeping_loop())
|
|
492
567
|
self._cache_cleanup_task = asyncio.create_task(self._cache_cleanup_loop())
|
|
568
|
+
self._ticket_flow_watch_task = asyncio.create_task(
|
|
569
|
+
self._ticket_flow_bridge.watch_ticket_flow_pauses(
|
|
570
|
+
TICKET_FLOW_WATCH_INTERVAL_SECONDS
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
self._spawn_task(self._prewarm_workspace_clients())
|
|
493
574
|
log_event(
|
|
494
575
|
self._logger,
|
|
495
576
|
logging.INFO,
|
|
@@ -505,6 +586,10 @@ class TelegramBotService(
|
|
|
505
586
|
media_enabled=self._config.media.enabled,
|
|
506
587
|
media_images=self._config.media.images,
|
|
507
588
|
media_voice=self._config.media.voice,
|
|
589
|
+
app_server_turn_timeout_seconds=self._config.app_server_turn_timeout_seconds,
|
|
590
|
+
agent_turn_timeout_seconds=dict(
|
|
591
|
+
self._config.agent_turn_timeout_seconds
|
|
592
|
+
),
|
|
508
593
|
poller_offset=self._poller.offset,
|
|
509
594
|
)
|
|
510
595
|
try:
|
|
@@ -532,7 +617,7 @@ class TelegramBotService(
|
|
|
532
617
|
timeout=self._config.poll_timeout_seconds
|
|
533
618
|
)
|
|
534
619
|
if self._poller.offset is not None:
|
|
535
|
-
self._record_poll_offset(updates)
|
|
620
|
+
await self._record_poll_offset(updates)
|
|
536
621
|
except Exception as exc:
|
|
537
622
|
log_event(
|
|
538
623
|
self._logger,
|
|
@@ -570,6 +655,12 @@ class TelegramBotService(
|
|
|
570
655
|
await self._cache_cleanup_task
|
|
571
656
|
except asyncio.CancelledError:
|
|
572
657
|
pass
|
|
658
|
+
if self._ticket_flow_watch_task is not None:
|
|
659
|
+
self._ticket_flow_watch_task.cancel()
|
|
660
|
+
try:
|
|
661
|
+
await self._ticket_flow_watch_task
|
|
662
|
+
except asyncio.CancelledError:
|
|
663
|
+
pass
|
|
573
664
|
if self._spawned_tasks:
|
|
574
665
|
for task in list(self._spawned_tasks):
|
|
575
666
|
task.cancel()
|
|
@@ -593,6 +684,15 @@ class TelegramBotService(
|
|
|
593
684
|
"telegram.app_server.close_failed",
|
|
594
685
|
exc=exc,
|
|
595
686
|
)
|
|
687
|
+
try:
|
|
688
|
+
await self._store.close()
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
log_event(
|
|
691
|
+
self._logger,
|
|
692
|
+
logging.WARNING,
|
|
693
|
+
"telegram.state.close_failed",
|
|
694
|
+
exc=exc,
|
|
695
|
+
)
|
|
596
696
|
self._release_instance_lock()
|
|
597
697
|
|
|
598
698
|
async def _prime_bot_identity(self) -> None:
|
|
@@ -694,8 +794,8 @@ class TelegramBotService(
|
|
|
694
794
|
order_changed=diff.order_changed,
|
|
695
795
|
)
|
|
696
796
|
|
|
697
|
-
def _prime_poller_offset(self) -> None:
|
|
698
|
-
last_update_id = self._store.get_last_update_id_global()
|
|
797
|
+
async def _prime_poller_offset(self) -> None:
|
|
798
|
+
last_update_id = await self._store.get_last_update_id_global()
|
|
699
799
|
if not isinstance(last_update_id, int) or isinstance(last_update_id, bool):
|
|
700
800
|
return
|
|
701
801
|
offset = last_update_id + 1
|
|
@@ -708,16 +808,15 @@ class TelegramBotService(
|
|
|
708
808
|
poller_offset=offset,
|
|
709
809
|
)
|
|
710
810
|
|
|
711
|
-
def _record_poll_offset(self, updates: Sequence[TelegramUpdate]) -> None:
|
|
811
|
+
async def _record_poll_offset(self, updates: Sequence[TelegramUpdate]) -> None:
|
|
712
812
|
offset = self._poller.offset
|
|
713
813
|
if offset is None:
|
|
714
814
|
return
|
|
715
815
|
last_update_id = offset - 1
|
|
716
816
|
if last_update_id < 0:
|
|
717
817
|
return
|
|
718
|
-
stored = self._store.update_last_update_id_global(last_update_id)
|
|
719
|
-
|
|
720
|
-
max_update_id = max(update.update_id for update in updates)
|
|
818
|
+
stored = await self._store.update_last_update_id_global(last_update_id)
|
|
819
|
+
max_update_id = max((update.update_id for update in updates), default=None)
|
|
721
820
|
log_event(
|
|
722
821
|
self._logger,
|
|
723
822
|
logging.INFO,
|
|
@@ -772,6 +871,9 @@ class TelegramBotService(
|
|
|
772
871
|
task = self._turn_progress_tasks.pop(key, None)
|
|
773
872
|
if task and not task.done():
|
|
774
873
|
task.cancel()
|
|
874
|
+
heartbeat_task = self._turn_progress_heartbeat_tasks.pop(key, None)
|
|
875
|
+
if heartbeat_task and not heartbeat_task.done():
|
|
876
|
+
heartbeat_task.cancel()
|
|
775
877
|
elif cache_name == "oversize_warnings":
|
|
776
878
|
self._oversize_warnings.discard(key)
|
|
777
879
|
elif cache_name == "coalesced_buffers":
|
|
@@ -804,63 +906,209 @@ class TelegramBotService(
|
|
|
804
906
|
self._model_pending.pop(key, None)
|
|
805
907
|
elif cache_name == "pending_approvals":
|
|
806
908
|
self._pending_approvals.pop(key, None)
|
|
909
|
+
elif cache_name == "pending_questions":
|
|
910
|
+
self._pending_questions.pop(key, None)
|
|
807
911
|
|
|
808
912
|
async def _cache_cleanup_loop(self) -> None:
|
|
809
|
-
interval = max(
|
|
913
|
+
interval = max(self._config.cache.cleanup_interval_seconds, 1.0)
|
|
810
914
|
while True:
|
|
811
915
|
await asyncio.sleep(interval)
|
|
812
916
|
self._evict_expired_cache_entries(
|
|
813
|
-
"reasoning_buffers",
|
|
917
|
+
"reasoning_buffers", self._config.cache.reasoning_buffer_ttl_seconds
|
|
918
|
+
)
|
|
919
|
+
self._evict_expired_cache_entries(
|
|
920
|
+
"turn_preview", self._config.cache.turn_preview_ttl_seconds
|
|
814
921
|
)
|
|
815
|
-
self._evict_expired_cache_entries("turn_preview", TURN_PREVIEW_TTL_SECONDS)
|
|
816
922
|
self._evict_expired_cache_entries(
|
|
817
|
-
"progress_trackers",
|
|
923
|
+
"progress_trackers", self._config.cache.progress_stream_ttl_seconds
|
|
818
924
|
)
|
|
819
925
|
self._evict_expired_cache_entries(
|
|
820
|
-
"oversize_warnings",
|
|
926
|
+
"oversize_warnings", self._config.cache.oversize_warning_ttl_seconds
|
|
821
927
|
)
|
|
822
928
|
self._evict_expired_cache_entries(
|
|
823
|
-
"coalesced_buffers",
|
|
929
|
+
"coalesced_buffers", self._config.cache.coalesce_buffer_ttl_seconds
|
|
824
930
|
)
|
|
825
931
|
self._evict_expired_cache_entries(
|
|
826
|
-
"media_batch_buffers",
|
|
932
|
+
"media_batch_buffers",
|
|
933
|
+
self._config.cache.media_batch_buffer_ttl_seconds,
|
|
827
934
|
)
|
|
828
935
|
self._evict_expired_cache_entries(
|
|
829
|
-
"resume_options",
|
|
936
|
+
"resume_options", self._config.cache.selection_state_ttl_seconds
|
|
830
937
|
)
|
|
831
938
|
self._evict_expired_cache_entries(
|
|
832
|
-
"bind_options",
|
|
939
|
+
"bind_options", self._config.cache.selection_state_ttl_seconds
|
|
833
940
|
)
|
|
834
941
|
self._evict_expired_cache_entries(
|
|
835
|
-
"agent_options",
|
|
942
|
+
"agent_options", self._config.cache.selection_state_ttl_seconds
|
|
836
943
|
)
|
|
837
944
|
self._evict_expired_cache_entries(
|
|
838
|
-
"update_options",
|
|
945
|
+
"update_options", self._config.cache.selection_state_ttl_seconds
|
|
839
946
|
)
|
|
840
947
|
self._evict_expired_cache_entries(
|
|
841
|
-
"update_confirm_options",
|
|
948
|
+
"update_confirm_options",
|
|
949
|
+
self._config.cache.selection_state_ttl_seconds,
|
|
842
950
|
)
|
|
843
951
|
self._evict_expired_cache_entries(
|
|
844
|
-
"review_commit_options",
|
|
952
|
+
"review_commit_options",
|
|
953
|
+
self._config.cache.selection_state_ttl_seconds,
|
|
845
954
|
)
|
|
846
955
|
self._evict_expired_cache_entries(
|
|
847
|
-
"review_commit_subjects",
|
|
956
|
+
"review_commit_subjects",
|
|
957
|
+
self._config.cache.selection_state_ttl_seconds,
|
|
848
958
|
)
|
|
849
959
|
self._evict_expired_cache_entries(
|
|
850
|
-
"pending_review_custom",
|
|
960
|
+
"pending_review_custom",
|
|
961
|
+
self._config.cache.selection_state_ttl_seconds,
|
|
851
962
|
)
|
|
852
963
|
self._evict_expired_cache_entries(
|
|
853
|
-
"compact_pending",
|
|
964
|
+
"compact_pending", self._config.cache.selection_state_ttl_seconds
|
|
854
965
|
)
|
|
855
966
|
self._evict_expired_cache_entries(
|
|
856
|
-
"model_options",
|
|
967
|
+
"model_options", self._config.cache.selection_state_ttl_seconds
|
|
857
968
|
)
|
|
858
969
|
self._evict_expired_cache_entries(
|
|
859
|
-
"model_pending",
|
|
970
|
+
"model_pending", self._config.cache.model_pending_ttl_seconds
|
|
860
971
|
)
|
|
861
972
|
self._evict_expired_cache_entries(
|
|
862
|
-
"pending_approvals",
|
|
973
|
+
"pending_approvals", self._config.cache.pending_approval_ttl_seconds
|
|
974
|
+
)
|
|
975
|
+
self._evict_expired_cache_entries(
|
|
976
|
+
"pending_questions", self._config.cache.pending_question_ttl_seconds
|
|
977
|
+
)
|
|
978
|
+
now = time.monotonic()
|
|
979
|
+
expired_placeholders = []
|
|
980
|
+
for key, timestamp in self._queued_placeholder_timestamps.items():
|
|
981
|
+
if (now - timestamp) > self._config.cache.pending_approval_ttl_seconds:
|
|
982
|
+
expired_placeholders.append(key)
|
|
983
|
+
for key in expired_placeholders:
|
|
984
|
+
self._queued_placeholder_map.pop(key, None)
|
|
985
|
+
self._queued_placeholder_timestamps.pop(key, None)
|
|
986
|
+
|
|
987
|
+
@staticmethod
|
|
988
|
+
def _parse_last_active(record: "TelegramTopicRecord") -> float:
|
|
989
|
+
raw = getattr(record, "last_active_at", None)
|
|
990
|
+
if isinstance(raw, str):
|
|
991
|
+
try:
|
|
992
|
+
return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
|
|
993
|
+
except ValueError:
|
|
994
|
+
return float("-inf")
|
|
995
|
+
return float("-inf")
|
|
996
|
+
|
|
997
|
+
def _select_ticket_flow_topic(
|
|
998
|
+
self, entries: list[tuple[str, "TelegramTopicRecord"]]
|
|
999
|
+
) -> Optional[tuple[str, "TelegramTopicRecord"]]:
|
|
1000
|
+
return self._ticket_flow_bridge._select_ticket_flow_topic(entries)
|
|
1001
|
+
|
|
1002
|
+
@staticmethod
|
|
1003
|
+
def _set_ticket_dispatch_marker(
|
|
1004
|
+
value: Optional[str],
|
|
1005
|
+
) -> "Callable[[TelegramTopicRecord], None]":
|
|
1006
|
+
def apply(topic: "TelegramTopicRecord") -> None:
|
|
1007
|
+
topic.last_ticket_dispatch_seq = value
|
|
1008
|
+
|
|
1009
|
+
return apply
|
|
1010
|
+
|
|
1011
|
+
async def _ticket_flow_watch_loop(self) -> None:
|
|
1012
|
+
await self._ticket_flow_bridge.watch_ticket_flow_pauses(
|
|
1013
|
+
TICKET_FLOW_WATCH_INTERVAL_SECONDS
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
async def _watch_ticket_flow_pauses(self) -> None:
|
|
1017
|
+
await self._ticket_flow_bridge._scan_and_notify_pauses()
|
|
1018
|
+
|
|
1019
|
+
async def _notify_ticket_flow_pause(
|
|
1020
|
+
self,
|
|
1021
|
+
workspace_root: Path,
|
|
1022
|
+
entries: list[tuple[str, "TelegramTopicRecord"]],
|
|
1023
|
+
) -> None:
|
|
1024
|
+
await self._ticket_flow_bridge._notify_ticket_flow_pause(
|
|
1025
|
+
workspace_root, entries
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
def _load_ticket_flow_pause(
|
|
1029
|
+
self, workspace_root: Path
|
|
1030
|
+
) -> Optional[tuple[str, str, str]]:
|
|
1031
|
+
return self._ticket_flow_bridge._load_ticket_flow_pause(workspace_root)
|
|
1032
|
+
|
|
1033
|
+
def _latest_dispatch_seq(self, history_dir: Path) -> Optional[str]:
|
|
1034
|
+
return self._ticket_flow_bridge._latest_dispatch_seq(history_dir)
|
|
1035
|
+
|
|
1036
|
+
def _format_ticket_flow_pause_reason(self, record: "FlowRunRecord") -> str:
|
|
1037
|
+
return self._ticket_flow_bridge._format_ticket_flow_pause_reason(record)
|
|
1038
|
+
|
|
1039
|
+
def _format_ticket_flow_pause_message(
|
|
1040
|
+
self, run_id: str, seq: str, content: str
|
|
1041
|
+
) -> str:
|
|
1042
|
+
return self._ticket_flow_bridge._format_ticket_flow_pause_message(
|
|
1043
|
+
run_id, seq, content
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
def _get_paused_ticket_flow(
|
|
1047
|
+
self, workspace_root: Path, preferred_run_id: Optional[str] = None
|
|
1048
|
+
) -> Optional[tuple[str, FlowRunRecord]]:
|
|
1049
|
+
return self._ticket_flow_bridge.get_paused_ticket_flow(
|
|
1050
|
+
workspace_root, preferred_run_id=preferred_run_id
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
async def _write_user_reply_from_telegram(
|
|
1054
|
+
self,
|
|
1055
|
+
workspace_root: Path,
|
|
1056
|
+
run_id: str,
|
|
1057
|
+
run_record: FlowRunRecord,
|
|
1058
|
+
message: TelegramMessage,
|
|
1059
|
+
text: str,
|
|
1060
|
+
files: Optional[list[tuple[str, bytes]]] = None,
|
|
1061
|
+
) -> tuple[bool, str]:
|
|
1062
|
+
try:
|
|
1063
|
+
input_data = dict(run_record.input_data or {})
|
|
1064
|
+
runs_dir_raw = input_data.get("runs_dir")
|
|
1065
|
+
runs_dir = (
|
|
1066
|
+
Path(runs_dir_raw)
|
|
1067
|
+
if isinstance(runs_dir_raw, str) and runs_dir_raw
|
|
1068
|
+
else Path(".codex-autorunner/runs")
|
|
863
1069
|
)
|
|
1070
|
+
reply_paths = resolve_reply_paths(
|
|
1071
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
|
|
1072
|
+
)
|
|
1073
|
+
ensure_reply_dirs(reply_paths)
|
|
1074
|
+
|
|
1075
|
+
cleaned_text = text.strip()
|
|
1076
|
+
raw = cleaned_text
|
|
1077
|
+
if raw and not raw.endswith("\n"):
|
|
1078
|
+
raw += "\n"
|
|
1079
|
+
|
|
1080
|
+
await asyncio.to_thread(
|
|
1081
|
+
reply_paths.user_reply_path.write_text, raw, encoding="utf-8"
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
if files:
|
|
1085
|
+
for filename, data in files:
|
|
1086
|
+
dest = reply_paths.reply_dir / filename
|
|
1087
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1088
|
+
await asyncio.to_thread(dest.write_bytes, data)
|
|
1089
|
+
|
|
1090
|
+
seq = await asyncio.to_thread(
|
|
1091
|
+
lambda: _next_reply_seq_sync(reply_paths.reply_history_dir)
|
|
1092
|
+
)
|
|
1093
|
+
dispatch, errors = await asyncio.to_thread(
|
|
1094
|
+
dispatch_reply, reply_paths, next_seq=seq
|
|
1095
|
+
)
|
|
1096
|
+
if errors:
|
|
1097
|
+
return False, "\n".join(errors)
|
|
1098
|
+
if dispatch is None:
|
|
1099
|
+
return False, "Failed to archive reply"
|
|
1100
|
+
return (
|
|
1101
|
+
True,
|
|
1102
|
+
f"Reply archived (seq {dispatch.seq}). Use /flow resume to continue.",
|
|
1103
|
+
)
|
|
1104
|
+
except Exception as exc:
|
|
1105
|
+
self._logger.warning(
|
|
1106
|
+
"Failed to write USER_REPLY.md from Telegram",
|
|
1107
|
+
exc=exc,
|
|
1108
|
+
workspace_root=str(workspace_root),
|
|
1109
|
+
run_id=run_id,
|
|
1110
|
+
)
|
|
1111
|
+
return False, f"Failed to write reply: {exc}"
|
|
864
1112
|
|
|
865
1113
|
async def _interrupt_timeout_check(
|
|
866
1114
|
self, key: str, turn_id: str, message_id: int
|
|
@@ -890,8 +1138,8 @@ class TelegramBotService(
|
|
|
890
1138
|
chat_id: int,
|
|
891
1139
|
thread_id: Optional[int],
|
|
892
1140
|
) -> None:
|
|
893
|
-
key = self._resolve_topic_key(chat_id, thread_id)
|
|
894
|
-
record = self._router.get_topic(key)
|
|
1141
|
+
key = await self._resolve_topic_key(chat_id, thread_id)
|
|
1142
|
+
record = await self._router.get_topic(key)
|
|
895
1143
|
if record and record.agent == "opencode":
|
|
896
1144
|
session_id = record.active_thread_id
|
|
897
1145
|
workspace_path = record.workspace_path
|
|
@@ -943,9 +1191,21 @@ class TelegramBotService(
|
|
|
943
1191
|
runtime.interrupt_turn_id = None
|
|
944
1192
|
runtime.interrupt_requested = False
|
|
945
1193
|
return
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1194
|
+
try:
|
|
1195
|
+
client = await self._client_for_workspace(
|
|
1196
|
+
record.workspace_path if record else None
|
|
1197
|
+
)
|
|
1198
|
+
except AppServerUnavailableError:
|
|
1199
|
+
runtime.interrupt_requested = False
|
|
1200
|
+
if runtime.interrupt_message_id is not None:
|
|
1201
|
+
await self._edit_message_text(
|
|
1202
|
+
chat_id,
|
|
1203
|
+
runtime.interrupt_message_id,
|
|
1204
|
+
"Interrupt failed (app-server unavailable).",
|
|
1205
|
+
)
|
|
1206
|
+
runtime.interrupt_message_id = None
|
|
1207
|
+
runtime.interrupt_turn_id = None
|
|
1208
|
+
return
|
|
949
1209
|
if client is None:
|
|
950
1210
|
runtime.interrupt_requested = False
|
|
951
1211
|
if runtime.interrupt_message_id is not None:
|
|
@@ -992,23 +1252,36 @@ class TelegramBotService(
|
|
|
992
1252
|
await message_handlers.handle_edited_message(self, message)
|
|
993
1253
|
|
|
994
1254
|
async def _handle_message_inner(
|
|
995
|
-
self,
|
|
1255
|
+
self,
|
|
1256
|
+
message: TelegramMessage,
|
|
1257
|
+
*,
|
|
1258
|
+
topic_key: Optional[str] = None,
|
|
1259
|
+
placeholder_id: Optional[int] = None,
|
|
996
1260
|
) -> None:
|
|
997
|
-
await message_handlers.handle_message_inner(
|
|
1261
|
+
await message_handlers.handle_message_inner(
|
|
1262
|
+
self, message, topic_key=topic_key, placeholder_id=placeholder_id
|
|
1263
|
+
)
|
|
998
1264
|
|
|
999
1265
|
def _coalesce_key_for_topic(self, key: str, user_id: Optional[int]) -> str:
|
|
1000
1266
|
return message_handlers.coalesce_key_for_topic(self, key, user_id)
|
|
1001
1267
|
|
|
1002
|
-
def _coalesce_key(self, message: TelegramMessage) -> str:
|
|
1003
|
-
return message_handlers.coalesce_key(self, message)
|
|
1268
|
+
async def _coalesce_key(self, message: TelegramMessage) -> str:
|
|
1269
|
+
return await message_handlers.coalesce_key(self, message)
|
|
1004
1270
|
|
|
1005
1271
|
async def _buffer_coalesced_message(
|
|
1006
|
-
self,
|
|
1272
|
+
self,
|
|
1273
|
+
message: TelegramMessage,
|
|
1274
|
+
text: str,
|
|
1275
|
+
*,
|
|
1276
|
+
placeholder_id: Optional[int] = None,
|
|
1007
1277
|
) -> None:
|
|
1008
|
-
await message_handlers.buffer_coalesced_message(
|
|
1278
|
+
await message_handlers.buffer_coalesced_message(
|
|
1279
|
+
self, message, text, placeholder_id=placeholder_id
|
|
1280
|
+
)
|
|
1009
1281
|
|
|
1010
1282
|
async def _coalesce_flush_after(self, key: str) -> None:
|
|
1011
|
-
|
|
1283
|
+
window_seconds = self._config.coalesce_window_seconds
|
|
1284
|
+
await message_handlers.coalesce_flush_after(self, key, window_seconds)
|
|
1012
1285
|
|
|
1013
1286
|
async def _flush_coalesced_message(self, message: TelegramMessage) -> None:
|
|
1014
1287
|
await message_handlers.flush_coalesced_message(self, message)
|
|
@@ -1041,10 +1314,19 @@ class TelegramBotService(
|
|
|
1041
1314
|
return message_handlers.select_voice_candidate(message)
|
|
1042
1315
|
|
|
1043
1316
|
async def _handle_media_message(
|
|
1044
|
-
self,
|
|
1317
|
+
self,
|
|
1318
|
+
message: TelegramMessage,
|
|
1319
|
+
runtime: Any,
|
|
1320
|
+
caption_text: str,
|
|
1321
|
+
*,
|
|
1322
|
+
placeholder_id: Optional[int] = None,
|
|
1045
1323
|
) -> None:
|
|
1046
1324
|
await message_handlers.handle_media_message(
|
|
1047
|
-
self,
|
|
1325
|
+
self,
|
|
1326
|
+
message,
|
|
1327
|
+
runtime,
|
|
1328
|
+
caption_text,
|
|
1329
|
+
placeholder_id=placeholder_id,
|
|
1048
1330
|
)
|
|
1049
1331
|
|
|
1050
1332
|
def _with_conversation_id(
|
|
@@ -1052,14 +1334,25 @@ class TelegramBotService(
|
|
|
1052
1334
|
) -> str:
|
|
1053
1335
|
return _with_conversation_id(message, chat_id=chat_id, thread_id=thread_id)
|
|
1054
1336
|
|
|
1055
|
-
def _should_process_update(self, key: str, update_id: int) -> bool:
|
|
1337
|
+
async def _should_process_update(self, key: str, update_id: int) -> bool:
|
|
1056
1338
|
if not isinstance(update_id, int):
|
|
1057
1339
|
return True
|
|
1058
1340
|
if isinstance(update_id, bool):
|
|
1059
1341
|
return True
|
|
1060
1342
|
last_id = self._last_update_ids.get(key)
|
|
1061
1343
|
if last_id is None:
|
|
1062
|
-
record =
|
|
1344
|
+
record = None
|
|
1345
|
+
try:
|
|
1346
|
+
record = await self._store.get_topic(key)
|
|
1347
|
+
except Exception as exc:
|
|
1348
|
+
log_event(
|
|
1349
|
+
self._logger,
|
|
1350
|
+
logging.WARNING,
|
|
1351
|
+
"telegram.update_id.load.failed",
|
|
1352
|
+
exc=exc,
|
|
1353
|
+
topic_key=key,
|
|
1354
|
+
update_id=update_id,
|
|
1355
|
+
)
|
|
1063
1356
|
last_id = record.last_update_id if record else None
|
|
1064
1357
|
if isinstance(last_id, int) and not isinstance(last_id, bool):
|
|
1065
1358
|
self._last_update_ids[key] = last_id
|
|
@@ -1068,19 +1361,31 @@ class TelegramBotService(
|
|
|
1068
1361
|
if isinstance(last_id, int) and update_id <= last_id:
|
|
1069
1362
|
return False
|
|
1070
1363
|
self._last_update_ids[key] = update_id
|
|
1071
|
-
|
|
1364
|
+
try:
|
|
1365
|
+
await self._maybe_persist_update_id(key, update_id)
|
|
1366
|
+
except Exception as exc:
|
|
1367
|
+
log_event(
|
|
1368
|
+
self._logger,
|
|
1369
|
+
logging.WARNING,
|
|
1370
|
+
"telegram.update_id.persist.failed",
|
|
1371
|
+
exc=exc,
|
|
1372
|
+
topic_key=key,
|
|
1373
|
+
update_id=update_id,
|
|
1374
|
+
)
|
|
1072
1375
|
return True
|
|
1073
1376
|
|
|
1074
|
-
def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
|
|
1377
|
+
async def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
|
|
1075
1378
|
now = time.monotonic()
|
|
1076
1379
|
last_persisted = self._last_update_persisted_at.get(key, 0.0)
|
|
1077
|
-
if (
|
|
1380
|
+
if (
|
|
1381
|
+
now - last_persisted
|
|
1382
|
+
) < self._config.cache.update_id_persist_interval_seconds:
|
|
1078
1383
|
return
|
|
1079
1384
|
|
|
1080
1385
|
def apply(record: "TelegramTopicRecord") -> None:
|
|
1081
1386
|
record.last_update_id = update_id
|
|
1082
1387
|
|
|
1083
|
-
self._store.update_topic(key, apply)
|
|
1388
|
+
await self._store.update_topic(key, apply)
|
|
1084
1389
|
self._last_update_persisted_at[key] = now
|
|
1085
1390
|
|
|
1086
1391
|
async def _handle_callback(self, callback: TelegramCallbackQuery) -> None:
|
|
@@ -1096,6 +1401,72 @@ class TelegramBotService(
|
|
|
1096
1401
|
else:
|
|
1097
1402
|
self._spawn_task(wrapped())
|
|
1098
1403
|
|
|
1404
|
+
async def _maybe_send_queued_placeholder(
|
|
1405
|
+
self, message: TelegramMessage, *, topic_key: str
|
|
1406
|
+
) -> Optional[int]:
|
|
1407
|
+
runtime = self._router.runtime_for(topic_key)
|
|
1408
|
+
is_busy = runtime.current_turn_id is not None or runtime.queue.pending() > 0
|
|
1409
|
+
if not is_busy:
|
|
1410
|
+
return None
|
|
1411
|
+
placeholder_id = await self._send_placeholder(
|
|
1412
|
+
message.chat_id,
|
|
1413
|
+
thread_id=message.thread_id,
|
|
1414
|
+
reply_to=message.message_id,
|
|
1415
|
+
text=QUEUED_PLACEHOLDER_TEXT,
|
|
1416
|
+
)
|
|
1417
|
+
if placeholder_id is None:
|
|
1418
|
+
return None
|
|
1419
|
+
self._set_queued_placeholder(
|
|
1420
|
+
message.chat_id, message.message_id, placeholder_id
|
|
1421
|
+
)
|
|
1422
|
+
log_event(
|
|
1423
|
+
self._logger,
|
|
1424
|
+
logging.INFO,
|
|
1425
|
+
"telegram.placeholder.queued",
|
|
1426
|
+
topic_key=topic_key,
|
|
1427
|
+
chat_id=message.chat_id,
|
|
1428
|
+
thread_id=message.thread_id,
|
|
1429
|
+
message_id=message.message_id,
|
|
1430
|
+
placeholder_id=placeholder_id,
|
|
1431
|
+
)
|
|
1432
|
+
return placeholder_id
|
|
1433
|
+
|
|
1434
|
+
def _wrap_placeholder_work(
|
|
1435
|
+
self,
|
|
1436
|
+
*,
|
|
1437
|
+
chat_id: int,
|
|
1438
|
+
placeholder_id: Optional[int],
|
|
1439
|
+
work: Any,
|
|
1440
|
+
) -> Any:
|
|
1441
|
+
if placeholder_id is None:
|
|
1442
|
+
return work
|
|
1443
|
+
|
|
1444
|
+
async def wrapped() -> Any:
|
|
1445
|
+
try:
|
|
1446
|
+
return await work()
|
|
1447
|
+
finally:
|
|
1448
|
+
await self._delete_message(chat_id, placeholder_id)
|
|
1449
|
+
|
|
1450
|
+
return wrapped
|
|
1451
|
+
|
|
1452
|
+
def _claim_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
|
|
1453
|
+
placeholder_id = self._queued_placeholder_map.pop((chat_id, message_id), None)
|
|
1454
|
+
self._queued_placeholder_timestamps.pop((chat_id, message_id), None)
|
|
1455
|
+
return placeholder_id
|
|
1456
|
+
|
|
1457
|
+
def _get_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
|
|
1458
|
+
return self._queued_placeholder_map.get((chat_id, message_id))
|
|
1459
|
+
|
|
1460
|
+
def _set_queued_placeholder(
|
|
1461
|
+
self, chat_id: int, message_id: int, placeholder_id: int
|
|
1462
|
+
) -> None:
|
|
1463
|
+
self._queued_placeholder_map[(chat_id, message_id)] = placeholder_id
|
|
1464
|
+
self._queued_placeholder_timestamps[(chat_id, message_id)] = time.monotonic()
|
|
1465
|
+
|
|
1466
|
+
def _clear_queued_placeholder(self, chat_id: int, message_id: int) -> None:
|
|
1467
|
+
self._queued_placeholder_map.pop((chat_id, message_id), None)
|
|
1468
|
+
self._queued_placeholder_timestamps.pop((chat_id, message_id), None)
|
|
1469
|
+
|
|
1099
1470
|
def _wrap_topic_work(self, key: str, work: Any) -> Any:
|
|
1100
1471
|
conversation_id = None
|
|
1101
1472
|
try:
|