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,1364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
from .....agents.registry import validate_agent_id
|
|
12
|
+
from .....core.config import load_repo_config
|
|
13
|
+
from .....core.engine import Engine
|
|
14
|
+
from .....core.flows import FlowController, FlowStore
|
|
15
|
+
from .....core.flows.models import FlowRunStatus
|
|
16
|
+
from .....core.flows.reconciler import reconcile_flow_run
|
|
17
|
+
from .....core.flows.ux_helpers import (
|
|
18
|
+
bootstrap_check,
|
|
19
|
+
build_flow_status_snapshot,
|
|
20
|
+
ensure_worker,
|
|
21
|
+
issue_md_has_content,
|
|
22
|
+
issue_md_path,
|
|
23
|
+
seed_issue_from_github,
|
|
24
|
+
seed_issue_from_text,
|
|
25
|
+
)
|
|
26
|
+
from .....core.flows.worker_process import (
|
|
27
|
+
FlowWorkerHealth,
|
|
28
|
+
check_worker_health,
|
|
29
|
+
clear_worker_metadata,
|
|
30
|
+
)
|
|
31
|
+
from .....core.state import now_iso
|
|
32
|
+
from .....core.utils import atomic_write, canonicalize_path
|
|
33
|
+
from .....flows.ticket_flow import build_ticket_flow_definition
|
|
34
|
+
from .....integrations.agents.wiring import (
|
|
35
|
+
build_agent_backend_factory,
|
|
36
|
+
build_app_server_supervisor_factory,
|
|
37
|
+
)
|
|
38
|
+
from .....tickets import AgentPool
|
|
39
|
+
from .....tickets.files import list_ticket_paths
|
|
40
|
+
from .....tickets.outbox import resolve_outbox_paths
|
|
41
|
+
from ....github.service import GitHubError, GitHubService
|
|
42
|
+
from ...adapter import (
|
|
43
|
+
FlowCallback,
|
|
44
|
+
InlineButton,
|
|
45
|
+
TelegramCallbackQuery,
|
|
46
|
+
TelegramMessage,
|
|
47
|
+
build_inline_keyboard,
|
|
48
|
+
encode_flow_callback,
|
|
49
|
+
encode_question_cancel_callback,
|
|
50
|
+
)
|
|
51
|
+
from ...config import DEFAULT_APPROVAL_TIMEOUT_SECONDS
|
|
52
|
+
from ...helpers import _truncate_text
|
|
53
|
+
from ...types import PendingQuestion, SelectionState
|
|
54
|
+
from .shared import SharedHelpers
|
|
55
|
+
|
|
56
|
+
_logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _flow_paths(repo_root: Path) -> tuple[Path, Path]:
|
|
60
|
+
repo_root = repo_root.resolve()
|
|
61
|
+
db_path = repo_root / ".codex-autorunner" / "flows.db"
|
|
62
|
+
artifacts_root = repo_root / ".codex-autorunner" / "flows"
|
|
63
|
+
return db_path, artifacts_root
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ticket_dir(repo_root: Path) -> Path:
|
|
67
|
+
return repo_root.resolve() / ".codex-autorunner" / "tickets"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_run_id(value: str) -> Optional[str]:
|
|
71
|
+
try:
|
|
72
|
+
return str(uuid.UUID(str(value)))
|
|
73
|
+
except ValueError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _split_flow_action(args: str) -> tuple[str, str]:
|
|
78
|
+
trimmed = (args or "").strip()
|
|
79
|
+
if not trimmed:
|
|
80
|
+
return "", ""
|
|
81
|
+
parts = trimmed.split(None, 1)
|
|
82
|
+
if len(parts) == 1:
|
|
83
|
+
return parts[0], ""
|
|
84
|
+
return parts[0], parts[1]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _normalize_flow_action(action: str) -> str:
|
|
88
|
+
normalized = (action or "").strip().lower()
|
|
89
|
+
if not normalized:
|
|
90
|
+
return "help"
|
|
91
|
+
if normalized == "start":
|
|
92
|
+
return "bootstrap"
|
|
93
|
+
return normalized
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _flow_help_lines() -> list[str]:
|
|
97
|
+
return [
|
|
98
|
+
"Flow commands:",
|
|
99
|
+
"/flow status [run_id]",
|
|
100
|
+
"/flow runs [N]",
|
|
101
|
+
"/flow bootstrap [--force-new]",
|
|
102
|
+
"/flow issue <issue#|url>",
|
|
103
|
+
"/flow plan <text>",
|
|
104
|
+
"/flow resume [run_id]",
|
|
105
|
+
"/flow stop [run_id]",
|
|
106
|
+
"/flow recover [run_id]",
|
|
107
|
+
"/flow restart",
|
|
108
|
+
"/flow archive [run_id] [--force]",
|
|
109
|
+
"/flow reply <message>",
|
|
110
|
+
"Aliases: /flow start, /flow_status",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_ticket_controller(repo_root: Path) -> FlowController:
|
|
115
|
+
db_path, artifacts_root = _flow_paths(repo_root)
|
|
116
|
+
config = load_repo_config(repo_root)
|
|
117
|
+
engine = Engine(
|
|
118
|
+
repo_root,
|
|
119
|
+
config=config,
|
|
120
|
+
backend_factory=build_agent_backend_factory(repo_root, config),
|
|
121
|
+
app_server_supervisor_factory=build_app_server_supervisor_factory(config),
|
|
122
|
+
agent_id_validator=validate_agent_id,
|
|
123
|
+
)
|
|
124
|
+
agent_pool = AgentPool(engine.config)
|
|
125
|
+
definition = build_ticket_flow_definition(agent_pool=agent_pool)
|
|
126
|
+
definition.validate()
|
|
127
|
+
controller = FlowController(
|
|
128
|
+
definition=definition, db_path=db_path, artifacts_root=artifacts_root
|
|
129
|
+
)
|
|
130
|
+
controller.initialize()
|
|
131
|
+
return controller
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _spawn_flow_worker(repo_root: Path, run_id: str) -> None:
|
|
135
|
+
result = ensure_worker(repo_root, run_id)
|
|
136
|
+
if result["status"] == "reused":
|
|
137
|
+
health = result["health"]
|
|
138
|
+
_logger.info("Worker already active for run %s (pid=%s)", run_id, health.pid)
|
|
139
|
+
return
|
|
140
|
+
proc = result["proc"]
|
|
141
|
+
out = result["stdout"]
|
|
142
|
+
err = result["stderr"]
|
|
143
|
+
try:
|
|
144
|
+
# We don't track handles in Telegram commands, close in parent after spawn.
|
|
145
|
+
out.close()
|
|
146
|
+
err.close()
|
|
147
|
+
finally:
|
|
148
|
+
if proc.poll() is not None:
|
|
149
|
+
_logger.warning("Flow worker for %s exited immediately", run_id)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _select_latest_run(
|
|
153
|
+
store: FlowStore, predicate: Callable[[object], bool]
|
|
154
|
+
) -> Optional[object]:
|
|
155
|
+
for record in store.list_flow_runs(flow_type="ticket_flow"):
|
|
156
|
+
if predicate(record):
|
|
157
|
+
return record
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class FlowCommands(SharedHelpers):
|
|
162
|
+
def _github_bootstrap_status(self, repo_root: Path) -> tuple[bool, Optional[str]]:
|
|
163
|
+
result = bootstrap_check(repo_root, github_service_factory=GitHubService)
|
|
164
|
+
return bool(result.github_available), result.repo_slug
|
|
165
|
+
|
|
166
|
+
async def _prompt_flow_text_input(
|
|
167
|
+
self,
|
|
168
|
+
message: TelegramMessage,
|
|
169
|
+
prompt_text: str,
|
|
170
|
+
) -> Optional[str]:
|
|
171
|
+
request_id = str(uuid.uuid4())
|
|
172
|
+
topic_key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
173
|
+
payload_text, parse_mode = self._prepare_outgoing_text(
|
|
174
|
+
prompt_text,
|
|
175
|
+
chat_id=message.chat_id,
|
|
176
|
+
thread_id=message.thread_id,
|
|
177
|
+
reply_to=message.message_id,
|
|
178
|
+
topic_key=topic_key,
|
|
179
|
+
)
|
|
180
|
+
keyboard = build_inline_keyboard(
|
|
181
|
+
[[InlineButton("Cancel", encode_question_cancel_callback(request_id))]]
|
|
182
|
+
)
|
|
183
|
+
response = await self._bot.send_message(
|
|
184
|
+
message.chat_id,
|
|
185
|
+
payload_text,
|
|
186
|
+
message_thread_id=message.thread_id,
|
|
187
|
+
reply_to_message_id=message.message_id,
|
|
188
|
+
reply_markup=keyboard,
|
|
189
|
+
parse_mode=parse_mode,
|
|
190
|
+
)
|
|
191
|
+
message_id = response.get("message_id") if isinstance(response, dict) else None
|
|
192
|
+
loop = asyncio.get_running_loop()
|
|
193
|
+
future: asyncio.Future[Optional[str]] = loop.create_future()
|
|
194
|
+
pending = PendingQuestion(
|
|
195
|
+
request_id=request_id,
|
|
196
|
+
turn_id=f"flow-bootstrap:{request_id}",
|
|
197
|
+
codex_thread_id=None,
|
|
198
|
+
chat_id=message.chat_id,
|
|
199
|
+
thread_id=message.thread_id,
|
|
200
|
+
topic_key=topic_key,
|
|
201
|
+
message_id=message_id if isinstance(message_id, int) else None,
|
|
202
|
+
created_at=now_iso(),
|
|
203
|
+
question_index=0,
|
|
204
|
+
prompt=prompt_text,
|
|
205
|
+
options=[],
|
|
206
|
+
future=future,
|
|
207
|
+
multiple=False,
|
|
208
|
+
custom=True,
|
|
209
|
+
selected_indices=set(),
|
|
210
|
+
awaiting_custom_input=True,
|
|
211
|
+
)
|
|
212
|
+
self._pending_questions[request_id] = pending
|
|
213
|
+
self._touch_cache_timestamp("pending_questions", request_id)
|
|
214
|
+
try:
|
|
215
|
+
result = await asyncio.wait_for(
|
|
216
|
+
future, timeout=DEFAULT_APPROVAL_TIMEOUT_SECONDS
|
|
217
|
+
)
|
|
218
|
+
except asyncio.TimeoutError:
|
|
219
|
+
self._pending_questions.pop(request_id, None)
|
|
220
|
+
if pending.message_id is not None:
|
|
221
|
+
await self._edit_message_text(
|
|
222
|
+
pending.chat_id,
|
|
223
|
+
pending.message_id,
|
|
224
|
+
"Question timed out.",
|
|
225
|
+
reply_markup={"inline_keyboard": []},
|
|
226
|
+
)
|
|
227
|
+
return None
|
|
228
|
+
if not result:
|
|
229
|
+
return None
|
|
230
|
+
return result.strip() or None
|
|
231
|
+
|
|
232
|
+
async def _seed_issue_from_ref(
|
|
233
|
+
self, repo_root: Path, issue_ref: str
|
|
234
|
+
) -> tuple[int, str]:
|
|
235
|
+
seed = seed_issue_from_github(
|
|
236
|
+
repo_root, issue_ref, github_service_factory=GitHubService
|
|
237
|
+
)
|
|
238
|
+
atomic_write(issue_md_path(repo_root), seed.content)
|
|
239
|
+
return seed.issue_number, seed.repo_slug
|
|
240
|
+
|
|
241
|
+
def _seed_issue_from_plan(self, repo_root: Path, plan_text: str) -> None:
|
|
242
|
+
content = seed_issue_from_text(plan_text)
|
|
243
|
+
atomic_write(issue_md_path(repo_root), content)
|
|
244
|
+
|
|
245
|
+
async def _handle_flow_status(self, message: TelegramMessage, args: str) -> None:
|
|
246
|
+
text = args.strip()
|
|
247
|
+
if text:
|
|
248
|
+
await self._handle_flow(message, f"status {text}")
|
|
249
|
+
else:
|
|
250
|
+
await self._handle_flow(message, "status")
|
|
251
|
+
|
|
252
|
+
async def _handle_flow(self, message: TelegramMessage, args: str) -> None:
|
|
253
|
+
argv = self._parse_command_args(args)
|
|
254
|
+
action_raw = argv[0] if argv else ""
|
|
255
|
+
action = _normalize_flow_action(action_raw)
|
|
256
|
+
_, remainder = _split_flow_action(args)
|
|
257
|
+
rest_argv = argv[1:]
|
|
258
|
+
|
|
259
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
260
|
+
record = await self._store.get_topic(key)
|
|
261
|
+
|
|
262
|
+
if action == "help":
|
|
263
|
+
await self._send_flow_overview(message, record)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
if not record or not record.workspace_path:
|
|
267
|
+
await self._send_message(
|
|
268
|
+
message.chat_id,
|
|
269
|
+
"No workspace bound. Use /bind to bind this topic to a repo first.",
|
|
270
|
+
thread_id=message.thread_id,
|
|
271
|
+
reply_to=message.message_id,
|
|
272
|
+
)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
repo_root = canonicalize_path(Path(record.workspace_path))
|
|
276
|
+
|
|
277
|
+
if action == "status":
|
|
278
|
+
await self._handle_flow_status_action(message, repo_root, rest_argv)
|
|
279
|
+
return
|
|
280
|
+
if action == "runs":
|
|
281
|
+
await self._handle_flow_runs(message, repo_root, rest_argv)
|
|
282
|
+
return
|
|
283
|
+
if action == "bootstrap":
|
|
284
|
+
await self._handle_flow_bootstrap(message, repo_root, rest_argv)
|
|
285
|
+
return
|
|
286
|
+
if action == "issue":
|
|
287
|
+
await self._handle_flow_issue(message, repo_root, remainder)
|
|
288
|
+
return
|
|
289
|
+
if action == "plan":
|
|
290
|
+
await self._handle_flow_plan(message, repo_root, remainder)
|
|
291
|
+
return
|
|
292
|
+
if action == "resume":
|
|
293
|
+
await self._handle_flow_resume(message, repo_root, rest_argv)
|
|
294
|
+
return
|
|
295
|
+
if action == "stop":
|
|
296
|
+
await self._handle_flow_stop(message, repo_root, rest_argv)
|
|
297
|
+
return
|
|
298
|
+
if action == "recover":
|
|
299
|
+
await self._handle_flow_recover(message, repo_root, rest_argv)
|
|
300
|
+
return
|
|
301
|
+
if action == "restart":
|
|
302
|
+
await self._handle_flow_restart(message, repo_root, rest_argv)
|
|
303
|
+
return
|
|
304
|
+
if action == "archive":
|
|
305
|
+
await self._handle_flow_archive(message, repo_root, rest_argv)
|
|
306
|
+
return
|
|
307
|
+
if action == "reply":
|
|
308
|
+
await self._handle_reply(message, remainder)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
await self._send_message(
|
|
312
|
+
message.chat_id,
|
|
313
|
+
f"Unknown /flow command: {action_raw or action}. Use /flow help.",
|
|
314
|
+
thread_id=message.thread_id,
|
|
315
|
+
reply_to=message.message_id,
|
|
316
|
+
)
|
|
317
|
+
await self._send_flow_help_block(message)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
async def _render_flow_status_callback(
|
|
321
|
+
self,
|
|
322
|
+
callback: TelegramCallbackQuery,
|
|
323
|
+
repo_root: Path,
|
|
324
|
+
run_id_raw: Optional[str],
|
|
325
|
+
) -> None:
|
|
326
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
327
|
+
try:
|
|
328
|
+
store.initialize()
|
|
329
|
+
record, error = self._resolve_status_record(store, run_id_raw)
|
|
330
|
+
if error:
|
|
331
|
+
await self._edit_callback_message(
|
|
332
|
+
callback, error, reply_markup={"inline_keyboard": []}
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
text, keyboard = self._build_flow_status_card(repo_root, record, store)
|
|
336
|
+
finally:
|
|
337
|
+
store.close()
|
|
338
|
+
await self._edit_callback_message(callback, text, reply_markup=keyboard)
|
|
339
|
+
|
|
340
|
+
async def _handle_flow_callback(
|
|
341
|
+
self, callback: TelegramCallbackQuery, parsed: FlowCallback
|
|
342
|
+
) -> None:
|
|
343
|
+
if callback.chat_id is None:
|
|
344
|
+
return
|
|
345
|
+
key = await self._resolve_topic_key(callback.chat_id, callback.thread_id)
|
|
346
|
+
record = await self._store.get_topic(key)
|
|
347
|
+
if not record or not record.workspace_path:
|
|
348
|
+
await self._answer_callback(callback, "No workspace bound")
|
|
349
|
+
await self._edit_callback_message(
|
|
350
|
+
callback,
|
|
351
|
+
"No workspace bound. Use /bind to bind this topic to a repo first.",
|
|
352
|
+
reply_markup={"inline_keyboard": []},
|
|
353
|
+
)
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
repo_root = canonicalize_path(Path(record.workspace_path))
|
|
357
|
+
action = (parsed.action or "").strip().lower()
|
|
358
|
+
run_id_raw = parsed.run_id
|
|
359
|
+
|
|
360
|
+
if action in {"refresh", "status"}:
|
|
361
|
+
await self._answer_callback(callback, "Refreshing...")
|
|
362
|
+
await self._render_flow_status_callback(callback, repo_root, run_id_raw)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
error = None
|
|
366
|
+
notice = None
|
|
367
|
+
if action == "resume":
|
|
368
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
369
|
+
try:
|
|
370
|
+
store.initialize()
|
|
371
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
372
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
373
|
+
if run_id_raw and error:
|
|
374
|
+
record = None
|
|
375
|
+
if error is None and record is None:
|
|
376
|
+
record = _select_latest_run(
|
|
377
|
+
store, lambda run: run.status == FlowRunStatus.PAUSED
|
|
378
|
+
)
|
|
379
|
+
if error is None and record is None:
|
|
380
|
+
error = "No paused ticket flow run found."
|
|
381
|
+
if error is None and record.status != FlowRunStatus.PAUSED:
|
|
382
|
+
error = f"Run {record.id} is {record.status.value}, not paused."
|
|
383
|
+
finally:
|
|
384
|
+
store.close()
|
|
385
|
+
if error is None:
|
|
386
|
+
controller = _get_ticket_controller(repo_root)
|
|
387
|
+
updated = await controller.resume_flow(record.id)
|
|
388
|
+
_spawn_flow_worker(repo_root, updated.id)
|
|
389
|
+
notice = "Resumed."
|
|
390
|
+
elif action == "stop":
|
|
391
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
392
|
+
try:
|
|
393
|
+
store.initialize()
|
|
394
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
395
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
396
|
+
if run_id_raw and error:
|
|
397
|
+
record = None
|
|
398
|
+
if error is None and record is None:
|
|
399
|
+
record = _select_latest_run(
|
|
400
|
+
store, lambda run: run.status.is_active()
|
|
401
|
+
)
|
|
402
|
+
if error is None and record is None:
|
|
403
|
+
error = "No active ticket flow run found."
|
|
404
|
+
if error is None and record.status.is_terminal():
|
|
405
|
+
error = f"Run {record.id} is already {record.status.value}."
|
|
406
|
+
finally:
|
|
407
|
+
store.close()
|
|
408
|
+
if error is None:
|
|
409
|
+
controller = _get_ticket_controller(repo_root)
|
|
410
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
411
|
+
await controller.stop_flow(record.id)
|
|
412
|
+
notice = "Stopped."
|
|
413
|
+
elif action == "recover":
|
|
414
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
415
|
+
try:
|
|
416
|
+
store.initialize()
|
|
417
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
418
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
419
|
+
if run_id_raw and error:
|
|
420
|
+
record = None
|
|
421
|
+
if error is None and record is None:
|
|
422
|
+
record = _select_latest_run(
|
|
423
|
+
store, lambda run: run.status.is_active()
|
|
424
|
+
)
|
|
425
|
+
if error is None and record is None:
|
|
426
|
+
error = "No active ticket flow run found."
|
|
427
|
+
if error is None:
|
|
428
|
+
record, updated, locked = reconcile_flow_run(
|
|
429
|
+
repo_root, record, store
|
|
430
|
+
)
|
|
431
|
+
if locked:
|
|
432
|
+
error = f"Run {record.id} is locked for reconcile; try again."
|
|
433
|
+
else:
|
|
434
|
+
notice = "Recovered." if updated else "No changes needed."
|
|
435
|
+
finally:
|
|
436
|
+
store.close()
|
|
437
|
+
elif action == "archive":
|
|
438
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
439
|
+
record = None
|
|
440
|
+
try:
|
|
441
|
+
store.initialize()
|
|
442
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
443
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
444
|
+
if run_id_raw and error:
|
|
445
|
+
record = None
|
|
446
|
+
if error is None and record is None:
|
|
447
|
+
record = _select_latest_run(
|
|
448
|
+
store,
|
|
449
|
+
lambda run: run.status.is_terminal()
|
|
450
|
+
or run.status == FlowRunStatus.PAUSED,
|
|
451
|
+
)
|
|
452
|
+
if error is None and record is None:
|
|
453
|
+
error = "No paused or terminal ticket flow run found."
|
|
454
|
+
if error is None and not record.status.is_terminal():
|
|
455
|
+
if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
|
|
456
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
457
|
+
else:
|
|
458
|
+
error = "Can only archive completed/stopped/failed runs (use --force for stuck flows)."
|
|
459
|
+
finally:
|
|
460
|
+
store.close()
|
|
461
|
+
|
|
462
|
+
if error is None:
|
|
463
|
+
_, artifacts_root = _flow_paths(repo_root)
|
|
464
|
+
archive_dir = artifacts_root / record.id / "archived_tickets"
|
|
465
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
466
|
+
ticket_dir = _ticket_dir(repo_root)
|
|
467
|
+
for ticket_path in list_ticket_paths(ticket_dir):
|
|
468
|
+
dest = archive_dir / ticket_path.name
|
|
469
|
+
shutil.move(str(ticket_path), str(dest))
|
|
470
|
+
|
|
471
|
+
runs_dir = Path(
|
|
472
|
+
record.input_data.get("runs_dir") or ".codex-autorunner/runs"
|
|
473
|
+
)
|
|
474
|
+
outbox_paths = resolve_outbox_paths(
|
|
475
|
+
workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
|
|
476
|
+
)
|
|
477
|
+
run_dir = outbox_paths.run_dir
|
|
478
|
+
if run_dir.exists() and run_dir.is_dir():
|
|
479
|
+
archived_runs_dir = artifacts_root / record.id / "archived_runs"
|
|
480
|
+
shutil.move(str(run_dir), str(archived_runs_dir))
|
|
481
|
+
|
|
482
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
483
|
+
try:
|
|
484
|
+
store.initialize()
|
|
485
|
+
store.delete_flow_run(record.id)
|
|
486
|
+
finally:
|
|
487
|
+
store.close()
|
|
488
|
+
notice = "Archived."
|
|
489
|
+
elif action == "restart":
|
|
490
|
+
message = TelegramMessage(
|
|
491
|
+
update_id=callback.update_id,
|
|
492
|
+
message_id=callback.message_id or 0,
|
|
493
|
+
chat_id=callback.chat_id,
|
|
494
|
+
thread_id=callback.thread_id,
|
|
495
|
+
from_user_id=callback.from_user_id,
|
|
496
|
+
text=None,
|
|
497
|
+
date=None,
|
|
498
|
+
is_topic_message=callback.thread_id is not None,
|
|
499
|
+
)
|
|
500
|
+
argv = [run_id_raw] if run_id_raw else []
|
|
501
|
+
await self._handle_flow_restart(message, repo_root, argv)
|
|
502
|
+
notice = "Restarted."
|
|
503
|
+
else:
|
|
504
|
+
await self._answer_callback(callback, "Unknown action")
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
if error:
|
|
508
|
+
await self._answer_callback(callback, error)
|
|
509
|
+
elif notice:
|
|
510
|
+
await self._answer_callback(callback, notice)
|
|
511
|
+
await self._render_flow_status_callback(callback, repo_root, run_id_raw)
|
|
512
|
+
|
|
513
|
+
def _resolve_run_id_input(
|
|
514
|
+
self, store: FlowStore, raw_run_id: Optional[str]
|
|
515
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
516
|
+
if not raw_run_id:
|
|
517
|
+
return None, None
|
|
518
|
+
normalized = _normalize_run_id(raw_run_id)
|
|
519
|
+
if normalized:
|
|
520
|
+
return normalized, None
|
|
521
|
+
matches = [
|
|
522
|
+
record.id
|
|
523
|
+
for record in store.list_flow_runs(flow_type="ticket_flow")
|
|
524
|
+
if record.id.startswith(raw_run_id)
|
|
525
|
+
]
|
|
526
|
+
if len(matches) == 1:
|
|
527
|
+
return matches[0], None
|
|
528
|
+
if len(matches) > 1:
|
|
529
|
+
return None, "Run ID prefix is ambiguous. Use the full run_id."
|
|
530
|
+
return None, "Invalid run_id."
|
|
531
|
+
|
|
532
|
+
def _first_non_flag(self, argv: list[str]) -> Optional[str]:
|
|
533
|
+
for part in argv:
|
|
534
|
+
if not part.startswith("--"):
|
|
535
|
+
return part
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
def _has_flag(self, argv: list[str], name: str) -> bool:
|
|
539
|
+
prefix = f"{name}="
|
|
540
|
+
return any(part == name or part.startswith(prefix) for part in argv)
|
|
541
|
+
|
|
542
|
+
def _resolve_status_record(
|
|
543
|
+
self, store: FlowStore, run_id_raw: Optional[str]
|
|
544
|
+
) -> tuple[Optional[object], Optional[str]]:
|
|
545
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
546
|
+
if run_id_raw and error:
|
|
547
|
+
return None, error
|
|
548
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
549
|
+
if record is None:
|
|
550
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
551
|
+
record = runs[0] if runs else None
|
|
552
|
+
if record is None:
|
|
553
|
+
return None, "No ticket flow run found. Use /flow bootstrap to start."
|
|
554
|
+
return record, None
|
|
555
|
+
|
|
556
|
+
def _format_flow_status_lines(
|
|
557
|
+
self,
|
|
558
|
+
repo_root: Path,
|
|
559
|
+
record: Optional[object],
|
|
560
|
+
store: Optional[FlowStore],
|
|
561
|
+
*,
|
|
562
|
+
health: Optional[FlowWorkerHealth] = None,
|
|
563
|
+
snapshot: Optional[dict] = None,
|
|
564
|
+
) -> list[str]:
|
|
565
|
+
if record is None:
|
|
566
|
+
return ["Run: none"]
|
|
567
|
+
if snapshot is None:
|
|
568
|
+
snapshot = build_flow_status_snapshot(repo_root, record, store)
|
|
569
|
+
run = record
|
|
570
|
+
status = getattr(run, "status", None)
|
|
571
|
+
status_value = status.value if status else "unknown"
|
|
572
|
+
lines = [f"Run: {run.id}", f"Status: {status_value}"]
|
|
573
|
+
flow_type = getattr(run, "flow_type", None)
|
|
574
|
+
if flow_type:
|
|
575
|
+
lines.append(f"Flow: {flow_type}")
|
|
576
|
+
created_at = getattr(run, "created_at", None)
|
|
577
|
+
if created_at:
|
|
578
|
+
lines.append(f"Created: {created_at}")
|
|
579
|
+
started_at = getattr(run, "started_at", None)
|
|
580
|
+
if started_at:
|
|
581
|
+
lines.append(f"Started: {started_at}")
|
|
582
|
+
finished_at = getattr(run, "finished_at", None)
|
|
583
|
+
if finished_at:
|
|
584
|
+
lines.append(f"Finished: {finished_at}")
|
|
585
|
+
current_step = getattr(run, "current_step", None)
|
|
586
|
+
if current_step:
|
|
587
|
+
lines.append(f"Step: {current_step}")
|
|
588
|
+
state = run.state or {}
|
|
589
|
+
engine = state.get("ticket_engine") if isinstance(state, dict) else None
|
|
590
|
+
engine = engine if isinstance(engine, dict) else {}
|
|
591
|
+
current = snapshot.get("effective_current_ticket") if snapshot else None
|
|
592
|
+
if isinstance(current, str) and current.strip():
|
|
593
|
+
lines.append(f"Current: {current.strip()}")
|
|
594
|
+
reason_summary = None
|
|
595
|
+
if isinstance(state, dict):
|
|
596
|
+
value = state.get("reason_summary")
|
|
597
|
+
if isinstance(value, str) and value.strip():
|
|
598
|
+
reason_summary = value.strip()
|
|
599
|
+
if reason_summary:
|
|
600
|
+
lines.append(f"Summary: {_truncate_text(reason_summary, 300)}")
|
|
601
|
+
reason = engine.get("reason") if isinstance(engine, dict) else None
|
|
602
|
+
if isinstance(reason, str) and reason.strip():
|
|
603
|
+
if reason_summary and reason.strip() == reason_summary:
|
|
604
|
+
pass
|
|
605
|
+
else:
|
|
606
|
+
lines.append(f"Reason: {_truncate_text(reason.strip(), 300)}")
|
|
607
|
+
error_message = getattr(run, "error_message", None)
|
|
608
|
+
if isinstance(error_message, str) and error_message.strip():
|
|
609
|
+
lines.append(f"Error: {_truncate_text(error_message.strip(), 300)}")
|
|
610
|
+
if snapshot:
|
|
611
|
+
last_seq = snapshot.get("last_event_seq")
|
|
612
|
+
last_at = snapshot.get("last_event_at")
|
|
613
|
+
if last_seq or last_at:
|
|
614
|
+
seq_label = str(last_seq) if last_seq is not None else "?"
|
|
615
|
+
at_label = last_at or "unknown time"
|
|
616
|
+
lines.append(f"Last event: {seq_label} @ {at_label}")
|
|
617
|
+
if health is None:
|
|
618
|
+
health = snapshot.get("worker_health") if snapshot else None
|
|
619
|
+
if health is None:
|
|
620
|
+
return lines
|
|
621
|
+
worker_line = f"Worker: {health.status}"
|
|
622
|
+
if health.pid:
|
|
623
|
+
worker_line += f" (pid {health.pid})"
|
|
624
|
+
if health.message and health.status not in {"alive"}:
|
|
625
|
+
worker_line += f" - {health.message}"
|
|
626
|
+
lines.append(worker_line)
|
|
627
|
+
if status == FlowRunStatus.PAUSED:
|
|
628
|
+
lines.append("Paused: use /flow reply <message>, then /flow resume.")
|
|
629
|
+
return lines
|
|
630
|
+
|
|
631
|
+
def _build_flow_status_keyboard(
|
|
632
|
+
self, record: Optional[object], *, health: Optional[FlowWorkerHealth]
|
|
633
|
+
) -> Optional[dict[str, object]]:
|
|
634
|
+
if record is None or health is None:
|
|
635
|
+
return None
|
|
636
|
+
status = getattr(record, "status", None)
|
|
637
|
+
if status is None:
|
|
638
|
+
return None
|
|
639
|
+
run_id = record.id
|
|
640
|
+
rows: list[list[InlineButton]] = []
|
|
641
|
+
if status == FlowRunStatus.PAUSED:
|
|
642
|
+
rows.append(
|
|
643
|
+
[
|
|
644
|
+
InlineButton("Resume", encode_flow_callback("resume", run_id)),
|
|
645
|
+
InlineButton("Restart", encode_flow_callback("restart", run_id)),
|
|
646
|
+
]
|
|
647
|
+
)
|
|
648
|
+
rows.append(
|
|
649
|
+
[InlineButton("Archive", encode_flow_callback("archive", run_id))]
|
|
650
|
+
)
|
|
651
|
+
elif status.is_terminal():
|
|
652
|
+
rows.append(
|
|
653
|
+
[
|
|
654
|
+
InlineButton("Restart", encode_flow_callback("restart", run_id)),
|
|
655
|
+
InlineButton("Archive", encode_flow_callback("archive", run_id)),
|
|
656
|
+
]
|
|
657
|
+
)
|
|
658
|
+
rows.append(
|
|
659
|
+
[InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
|
|
660
|
+
)
|
|
661
|
+
else:
|
|
662
|
+
if health.status in {"dead", "mismatch", "invalid", "absent"}:
|
|
663
|
+
rows.append(
|
|
664
|
+
[
|
|
665
|
+
InlineButton(
|
|
666
|
+
"Recover", encode_flow_callback("recover", run_id)
|
|
667
|
+
),
|
|
668
|
+
InlineButton(
|
|
669
|
+
"Refresh", encode_flow_callback("refresh", run_id)
|
|
670
|
+
),
|
|
671
|
+
]
|
|
672
|
+
)
|
|
673
|
+
elif status == FlowRunStatus.RUNNING:
|
|
674
|
+
rows.append(
|
|
675
|
+
[
|
|
676
|
+
InlineButton("Stop", encode_flow_callback("stop", run_id)),
|
|
677
|
+
InlineButton(
|
|
678
|
+
"Refresh", encode_flow_callback("refresh", run_id)
|
|
679
|
+
),
|
|
680
|
+
]
|
|
681
|
+
)
|
|
682
|
+
else:
|
|
683
|
+
rows.append(
|
|
684
|
+
[InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
|
|
685
|
+
)
|
|
686
|
+
return build_inline_keyboard(rows) if rows else None
|
|
687
|
+
|
|
688
|
+
def _build_flow_status_card(
|
|
689
|
+
self, repo_root: Path, record: Optional[object], store: Optional[FlowStore]
|
|
690
|
+
) -> tuple[str, Optional[dict[str, object]]]:
|
|
691
|
+
if record is None:
|
|
692
|
+
return (
|
|
693
|
+
"\n".join(self._format_flow_status_lines(repo_root, record, store)),
|
|
694
|
+
None,
|
|
695
|
+
)
|
|
696
|
+
snapshot = build_flow_status_snapshot(repo_root, record, store)
|
|
697
|
+
health = snapshot.get("worker_health")
|
|
698
|
+
lines = self._format_flow_status_lines(
|
|
699
|
+
repo_root, record, store, health=health, snapshot=snapshot
|
|
700
|
+
)
|
|
701
|
+
keyboard = self._build_flow_status_keyboard(record, health=health)
|
|
702
|
+
return "\n".join(lines), keyboard
|
|
703
|
+
|
|
704
|
+
async def _send_flow_help_block(self, message: TelegramMessage) -> None:
|
|
705
|
+
await self._send_message(
|
|
706
|
+
message.chat_id,
|
|
707
|
+
"\n".join(_flow_help_lines()),
|
|
708
|
+
thread_id=message.thread_id,
|
|
709
|
+
reply_to=message.message_id,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
async def _send_flow_overview(
|
|
713
|
+
self, message: TelegramMessage, record: Optional[object]
|
|
714
|
+
) -> None:
|
|
715
|
+
repo_root = (
|
|
716
|
+
canonicalize_path(Path(record.workspace_path))
|
|
717
|
+
if record and record.workspace_path
|
|
718
|
+
else None
|
|
719
|
+
)
|
|
720
|
+
lines = [
|
|
721
|
+
f"Workspace: {repo_root}" if repo_root else "Workspace: unbound",
|
|
722
|
+
]
|
|
723
|
+
if repo_root:
|
|
724
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
725
|
+
try:
|
|
726
|
+
store.initialize()
|
|
727
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
728
|
+
latest = runs[0] if runs else None
|
|
729
|
+
lines.extend(self._format_flow_status_lines(repo_root, latest, store))
|
|
730
|
+
finally:
|
|
731
|
+
store.close()
|
|
732
|
+
else:
|
|
733
|
+
lines.append("Run: none")
|
|
734
|
+
lines.append("Use /bind <repo_id> or /bind <path>.")
|
|
735
|
+
lines.append("")
|
|
736
|
+
lines.extend(_flow_help_lines())
|
|
737
|
+
await self._send_message(
|
|
738
|
+
message.chat_id,
|
|
739
|
+
"\n".join(lines),
|
|
740
|
+
thread_id=message.thread_id,
|
|
741
|
+
reply_to=message.message_id,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
async def _handle_flow_status_action(
|
|
745
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
746
|
+
) -> None:
|
|
747
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
748
|
+
try:
|
|
749
|
+
store.initialize()
|
|
750
|
+
run_id_raw = self._first_non_flag(argv)
|
|
751
|
+
record, error = self._resolve_status_record(store, run_id_raw)
|
|
752
|
+
if error:
|
|
753
|
+
await self._send_message(
|
|
754
|
+
message.chat_id,
|
|
755
|
+
error,
|
|
756
|
+
thread_id=message.thread_id,
|
|
757
|
+
reply_to=message.message_id,
|
|
758
|
+
)
|
|
759
|
+
return
|
|
760
|
+
text, keyboard = self._build_flow_status_card(repo_root, record, store)
|
|
761
|
+
finally:
|
|
762
|
+
store.close()
|
|
763
|
+
await self._send_message(
|
|
764
|
+
message.chat_id,
|
|
765
|
+
text,
|
|
766
|
+
thread_id=message.thread_id,
|
|
767
|
+
reply_to=message.message_id,
|
|
768
|
+
reply_markup=keyboard,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
async def _handle_flow_runs(
|
|
772
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
773
|
+
) -> None:
|
|
774
|
+
limit = 5
|
|
775
|
+
limit_raw = self._first_non_flag(argv)
|
|
776
|
+
if limit_raw:
|
|
777
|
+
limit_value = self._coerce_int(limit_raw)
|
|
778
|
+
if limit_value is None or limit_value <= 0:
|
|
779
|
+
await self._send_message(
|
|
780
|
+
message.chat_id,
|
|
781
|
+
"Provide a positive integer for /flow runs [N].",
|
|
782
|
+
thread_id=message.thread_id,
|
|
783
|
+
reply_to=message.message_id,
|
|
784
|
+
)
|
|
785
|
+
return
|
|
786
|
+
limit = min(limit_value, 50)
|
|
787
|
+
|
|
788
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
789
|
+
try:
|
|
790
|
+
store.initialize()
|
|
791
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
792
|
+
finally:
|
|
793
|
+
store.close()
|
|
794
|
+
|
|
795
|
+
if not runs:
|
|
796
|
+
await self._send_message(
|
|
797
|
+
message.chat_id,
|
|
798
|
+
"No ticket flow runs found. Use /flow bootstrap to start.",
|
|
799
|
+
thread_id=message.thread_id,
|
|
800
|
+
reply_to=message.message_id,
|
|
801
|
+
)
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
items: list[tuple[str, str]] = []
|
|
805
|
+
button_labels: dict[str, str] = {}
|
|
806
|
+
for run in runs[:limit]:
|
|
807
|
+
created_at = getattr(run, "created_at", None) or "unknown"
|
|
808
|
+
status = getattr(run, "status", None)
|
|
809
|
+
status_label = status.value if status is not None else "unknown"
|
|
810
|
+
items.append((run.id, f"{status_label} • {created_at}"))
|
|
811
|
+
short_id = run.id.split("-")[0]
|
|
812
|
+
button_label = f"{short_id} {status_label}"
|
|
813
|
+
button_labels[run.id] = _truncate_text(button_label, 32)
|
|
814
|
+
|
|
815
|
+
state = SelectionState(items=items, button_labels=button_labels)
|
|
816
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
817
|
+
self._flow_run_options[key] = state
|
|
818
|
+
self._touch_cache_timestamp("flow_run_options", key)
|
|
819
|
+
prompt = self._flow_runs_prompt(state)
|
|
820
|
+
keyboard = self._build_flow_runs_keyboard(state)
|
|
821
|
+
await self._send_message(
|
|
822
|
+
message.chat_id,
|
|
823
|
+
prompt,
|
|
824
|
+
thread_id=message.thread_id,
|
|
825
|
+
reply_to=message.message_id,
|
|
826
|
+
reply_markup=keyboard,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
async def _handle_flow_bootstrap(
|
|
830
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
831
|
+
) -> None:
|
|
832
|
+
force_new = self._has_flag(argv, "--force-new") or self._has_flag(
|
|
833
|
+
argv, "--force"
|
|
834
|
+
)
|
|
835
|
+
ticket_dir = _ticket_dir(repo_root)
|
|
836
|
+
ticket_dir.mkdir(parents=True, exist_ok=True)
|
|
837
|
+
existing_tickets = list_ticket_paths(ticket_dir)
|
|
838
|
+
tickets_exist = bool(existing_tickets)
|
|
839
|
+
issue_exists = issue_md_has_content(repo_root)
|
|
840
|
+
|
|
841
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
842
|
+
active_run = None
|
|
843
|
+
try:
|
|
844
|
+
store.initialize()
|
|
845
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
846
|
+
for record in runs:
|
|
847
|
+
if record.status in (FlowRunStatus.RUNNING, FlowRunStatus.PAUSED):
|
|
848
|
+
active_run = record
|
|
849
|
+
break
|
|
850
|
+
finally:
|
|
851
|
+
store.close()
|
|
852
|
+
|
|
853
|
+
if not force_new and active_run:
|
|
854
|
+
_spawn_flow_worker(repo_root, active_run.id)
|
|
855
|
+
await self._send_message(
|
|
856
|
+
message.chat_id,
|
|
857
|
+
f"Reusing ticket flow run {active_run.id} ({active_run.status.value}).",
|
|
858
|
+
thread_id=message.thread_id,
|
|
859
|
+
reply_to=message.message_id,
|
|
860
|
+
)
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
if not tickets_exist and not issue_exists:
|
|
864
|
+
gh_available, repo_slug = self._github_bootstrap_status(repo_root)
|
|
865
|
+
if gh_available:
|
|
866
|
+
repo_label = f" for {repo_slug}" if repo_slug else ""
|
|
867
|
+
prompt = (
|
|
868
|
+
"Enter GitHub issue number or URL" f"{repo_label} to seed ISSUE.md:"
|
|
869
|
+
)
|
|
870
|
+
issue_ref = await self._prompt_flow_text_input(message, prompt)
|
|
871
|
+
if not issue_ref:
|
|
872
|
+
await self._send_message(
|
|
873
|
+
message.chat_id,
|
|
874
|
+
"Bootstrap cancelled (no issue provided).",
|
|
875
|
+
thread_id=message.thread_id,
|
|
876
|
+
reply_to=message.message_id,
|
|
877
|
+
)
|
|
878
|
+
return
|
|
879
|
+
try:
|
|
880
|
+
number, _repo = await self._seed_issue_from_ref(
|
|
881
|
+
repo_root, issue_ref
|
|
882
|
+
)
|
|
883
|
+
except GitHubError as exc:
|
|
884
|
+
await self._send_message(
|
|
885
|
+
message.chat_id,
|
|
886
|
+
f"GitHub error: {exc}",
|
|
887
|
+
thread_id=message.thread_id,
|
|
888
|
+
reply_to=message.message_id,
|
|
889
|
+
)
|
|
890
|
+
return
|
|
891
|
+
except Exception as exc:
|
|
892
|
+
await self._send_message(
|
|
893
|
+
message.chat_id,
|
|
894
|
+
f"Failed to fetch issue: {exc}",
|
|
895
|
+
thread_id=message.thread_id,
|
|
896
|
+
reply_to=message.message_id,
|
|
897
|
+
)
|
|
898
|
+
return
|
|
899
|
+
await self._send_message(
|
|
900
|
+
message.chat_id,
|
|
901
|
+
f"Seeded ISSUE.md from GitHub issue {number}.",
|
|
902
|
+
thread_id=message.thread_id,
|
|
903
|
+
reply_to=message.message_id,
|
|
904
|
+
)
|
|
905
|
+
issue_exists = True
|
|
906
|
+
else:
|
|
907
|
+
prompt = "Describe the work to seed ISSUE.md:"
|
|
908
|
+
plan_text = await self._prompt_flow_text_input(message, prompt)
|
|
909
|
+
if not plan_text:
|
|
910
|
+
await self._send_message(
|
|
911
|
+
message.chat_id,
|
|
912
|
+
"Bootstrap cancelled (no description provided).",
|
|
913
|
+
thread_id=message.thread_id,
|
|
914
|
+
reply_to=message.message_id,
|
|
915
|
+
)
|
|
916
|
+
return
|
|
917
|
+
self._seed_issue_from_plan(repo_root, plan_text)
|
|
918
|
+
await self._send_message(
|
|
919
|
+
message.chat_id,
|
|
920
|
+
"Seeded ISSUE.md from your plan.",
|
|
921
|
+
thread_id=message.thread_id,
|
|
922
|
+
reply_to=message.message_id,
|
|
923
|
+
)
|
|
924
|
+
issue_exists = True
|
|
925
|
+
|
|
926
|
+
seeded = False
|
|
927
|
+
if not tickets_exist:
|
|
928
|
+
first_ticket = ticket_dir / "TICKET-001.md"
|
|
929
|
+
if not first_ticket.exists():
|
|
930
|
+
template = """---
|
|
931
|
+
agent: codex
|
|
932
|
+
done: false
|
|
933
|
+
title: Bootstrap ticket plan
|
|
934
|
+
goal: Capture scope and seed follow-up tickets
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
You are the first ticket in a new ticket_flow run.
|
|
938
|
+
|
|
939
|
+
- Read `.codex-autorunner/ISSUE.md`. If it is missing:
|
|
940
|
+
- If GitHub is available, ask the user for the issue/PR URL or number and create `.codex-autorunner/ISSUE.md` from it.
|
|
941
|
+
- If GitHub is not available, write `DISPATCH.md` with `mode: pause` asking the user to describe the work (or share a doc). After the reply, create `.codex-autorunner/ISSUE.md` with their input.
|
|
942
|
+
- If helpful, create or update workspace docs under `.codex-autorunner/workspace/`:
|
|
943
|
+
- `active_context.md` for current context and links
|
|
944
|
+
- `decisions.md` for decisions/rationale
|
|
945
|
+
- `spec.md` for requirements and constraints
|
|
946
|
+
- Break the work into additional `TICKET-00X.md` files with clear owners/goals; keep this ticket open until they exist.
|
|
947
|
+
- Place any supporting artifacts in `.codex-autorunner/runs/<run_id>/dispatch/` if needed.
|
|
948
|
+
- Write `DISPATCH.md` to dispatch a message to the user:
|
|
949
|
+
- Use `mode: pause` (handoff) to wait for user response. This pauses execution.
|
|
950
|
+
- Use `mode: notify` (informational) to message the user but keep running.
|
|
951
|
+
"""
|
|
952
|
+
first_ticket.write_text(template, encoding="utf-8")
|
|
953
|
+
seeded = True
|
|
954
|
+
|
|
955
|
+
controller = _get_ticket_controller(repo_root)
|
|
956
|
+
flow_record = await controller.start_flow(
|
|
957
|
+
input_data={},
|
|
958
|
+
metadata={"seeded_ticket": seeded, "origin": "telegram"},
|
|
959
|
+
)
|
|
960
|
+
_spawn_flow_worker(repo_root, flow_record.id)
|
|
961
|
+
|
|
962
|
+
if not issue_exists and not tickets_exist:
|
|
963
|
+
await self._send_flow_issue_hint(message, repo_root)
|
|
964
|
+
|
|
965
|
+
await self._send_message(
|
|
966
|
+
message.chat_id,
|
|
967
|
+
f"Started ticket flow run {flow_record.id}.",
|
|
968
|
+
thread_id=message.thread_id,
|
|
969
|
+
reply_to=message.message_id,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
async def _send_flow_issue_hint(
|
|
973
|
+
self, message: TelegramMessage, repo_root: Path
|
|
974
|
+
) -> None:
|
|
975
|
+
gh_status = (
|
|
976
|
+
"No ISSUE.md found. Use /flow plan <text> to seed it from a short plan."
|
|
977
|
+
)
|
|
978
|
+
gh_available, repo_slug = self._github_bootstrap_status(repo_root)
|
|
979
|
+
if gh_available:
|
|
980
|
+
repo_label = repo_slug or "your repo"
|
|
981
|
+
gh_status = (
|
|
982
|
+
f"No ISSUE.md found. Use /flow issue <issue#|url> for {repo_label}, "
|
|
983
|
+
"or /flow plan <text>."
|
|
984
|
+
)
|
|
985
|
+
await self._send_message(
|
|
986
|
+
message.chat_id,
|
|
987
|
+
gh_status,
|
|
988
|
+
thread_id=message.thread_id,
|
|
989
|
+
reply_to=message.message_id,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
async def _handle_flow_issue(
|
|
993
|
+
self, message: TelegramMessage, repo_root: Path, issue_ref: str
|
|
994
|
+
) -> None:
|
|
995
|
+
issue_ref = issue_ref.strip()
|
|
996
|
+
if not issue_ref:
|
|
997
|
+
await self._send_message(
|
|
998
|
+
message.chat_id,
|
|
999
|
+
"Provide an issue reference: /flow issue <issue#|url>",
|
|
1000
|
+
thread_id=message.thread_id,
|
|
1001
|
+
reply_to=message.message_id,
|
|
1002
|
+
)
|
|
1003
|
+
return
|
|
1004
|
+
try:
|
|
1005
|
+
number, _repo = await self._seed_issue_from_ref(repo_root, issue_ref)
|
|
1006
|
+
except GitHubError as exc:
|
|
1007
|
+
await self._send_message(
|
|
1008
|
+
message.chat_id,
|
|
1009
|
+
f"GitHub error: {exc}",
|
|
1010
|
+
thread_id=message.thread_id,
|
|
1011
|
+
reply_to=message.message_id,
|
|
1012
|
+
)
|
|
1013
|
+
return
|
|
1014
|
+
except RuntimeError as exc:
|
|
1015
|
+
await self._send_message(
|
|
1016
|
+
message.chat_id,
|
|
1017
|
+
str(exc),
|
|
1018
|
+
thread_id=message.thread_id,
|
|
1019
|
+
reply_to=message.message_id,
|
|
1020
|
+
)
|
|
1021
|
+
return
|
|
1022
|
+
except Exception as exc:
|
|
1023
|
+
await self._send_message(
|
|
1024
|
+
message.chat_id,
|
|
1025
|
+
f"Failed to fetch issue: {exc}",
|
|
1026
|
+
thread_id=message.thread_id,
|
|
1027
|
+
reply_to=message.message_id,
|
|
1028
|
+
)
|
|
1029
|
+
return
|
|
1030
|
+
await self._send_message(
|
|
1031
|
+
message.chat_id,
|
|
1032
|
+
f"Seeded ISSUE.md from GitHub issue {number}.",
|
|
1033
|
+
thread_id=message.thread_id,
|
|
1034
|
+
reply_to=message.message_id,
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
async def _handle_flow_plan(
|
|
1038
|
+
self, message: TelegramMessage, repo_root: Path, plan_text: str
|
|
1039
|
+
) -> None:
|
|
1040
|
+
plan_text = plan_text.strip()
|
|
1041
|
+
if not plan_text:
|
|
1042
|
+
await self._send_message(
|
|
1043
|
+
message.chat_id,
|
|
1044
|
+
"Provide a plan: /flow plan <text>",
|
|
1045
|
+
thread_id=message.thread_id,
|
|
1046
|
+
reply_to=message.message_id,
|
|
1047
|
+
)
|
|
1048
|
+
return
|
|
1049
|
+
self._seed_issue_from_plan(repo_root, plan_text)
|
|
1050
|
+
await self._send_message(
|
|
1051
|
+
message.chat_id,
|
|
1052
|
+
"Seeded ISSUE.md from your plan.",
|
|
1053
|
+
thread_id=message.thread_id,
|
|
1054
|
+
reply_to=message.message_id,
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
async def _handle_flow_resume(
|
|
1058
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1059
|
+
) -> None:
|
|
1060
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
1061
|
+
try:
|
|
1062
|
+
store.initialize()
|
|
1063
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1064
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1065
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1066
|
+
if run_id_raw and error:
|
|
1067
|
+
await self._send_message(
|
|
1068
|
+
message.chat_id,
|
|
1069
|
+
error,
|
|
1070
|
+
thread_id=message.thread_id,
|
|
1071
|
+
reply_to=message.message_id,
|
|
1072
|
+
)
|
|
1073
|
+
return
|
|
1074
|
+
if record is None:
|
|
1075
|
+
record = _select_latest_run(
|
|
1076
|
+
store, lambda run: run.status == FlowRunStatus.PAUSED
|
|
1077
|
+
)
|
|
1078
|
+
if record is None:
|
|
1079
|
+
await self._send_message(
|
|
1080
|
+
message.chat_id,
|
|
1081
|
+
"No paused ticket flow run found.",
|
|
1082
|
+
thread_id=message.thread_id,
|
|
1083
|
+
reply_to=message.message_id,
|
|
1084
|
+
)
|
|
1085
|
+
return
|
|
1086
|
+
if record.status != FlowRunStatus.PAUSED:
|
|
1087
|
+
await self._send_message(
|
|
1088
|
+
message.chat_id,
|
|
1089
|
+
f"Run {record.id} is {record.status.value}, not paused.",
|
|
1090
|
+
thread_id=message.thread_id,
|
|
1091
|
+
reply_to=message.message_id,
|
|
1092
|
+
)
|
|
1093
|
+
return
|
|
1094
|
+
finally:
|
|
1095
|
+
store.close()
|
|
1096
|
+
|
|
1097
|
+
controller = _get_ticket_controller(repo_root)
|
|
1098
|
+
updated = await controller.resume_flow(record.id)
|
|
1099
|
+
_spawn_flow_worker(repo_root, updated.id)
|
|
1100
|
+
await self._send_message(
|
|
1101
|
+
message.chat_id,
|
|
1102
|
+
f"Resumed run {updated.id}.",
|
|
1103
|
+
thread_id=message.thread_id,
|
|
1104
|
+
reply_to=message.message_id,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
def _stop_flow_worker(self, repo_root: Path, run_id: str) -> None:
|
|
1108
|
+
health = check_worker_health(repo_root, run_id)
|
|
1109
|
+
if health.is_alive and health.pid:
|
|
1110
|
+
try:
|
|
1111
|
+
subprocess.run(["kill", str(health.pid)], check=False)
|
|
1112
|
+
except Exception as exc:
|
|
1113
|
+
_logger.warning("Failed to stop worker %s: %s", run_id, exc)
|
|
1114
|
+
if health.status in {"dead", "mismatch", "invalid"}:
|
|
1115
|
+
clear_worker_metadata(health.artifact_path.parent)
|
|
1116
|
+
|
|
1117
|
+
async def _handle_flow_stop(
|
|
1118
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1119
|
+
) -> None:
|
|
1120
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
1121
|
+
try:
|
|
1122
|
+
store.initialize()
|
|
1123
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1124
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1125
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1126
|
+
if run_id_raw and error:
|
|
1127
|
+
await self._send_message(
|
|
1128
|
+
message.chat_id,
|
|
1129
|
+
error,
|
|
1130
|
+
thread_id=message.thread_id,
|
|
1131
|
+
reply_to=message.message_id,
|
|
1132
|
+
)
|
|
1133
|
+
return
|
|
1134
|
+
if record is None:
|
|
1135
|
+
record = _select_latest_run(store, lambda run: run.status.is_active())
|
|
1136
|
+
if record is None:
|
|
1137
|
+
await self._send_message(
|
|
1138
|
+
message.chat_id,
|
|
1139
|
+
"No active ticket flow run found.",
|
|
1140
|
+
thread_id=message.thread_id,
|
|
1141
|
+
reply_to=message.message_id,
|
|
1142
|
+
)
|
|
1143
|
+
return
|
|
1144
|
+
if record.status.is_terminal():
|
|
1145
|
+
await self._send_message(
|
|
1146
|
+
message.chat_id,
|
|
1147
|
+
f"Run {record.id} is already {record.status.value}.",
|
|
1148
|
+
thread_id=message.thread_id,
|
|
1149
|
+
reply_to=message.message_id,
|
|
1150
|
+
)
|
|
1151
|
+
return
|
|
1152
|
+
finally:
|
|
1153
|
+
store.close()
|
|
1154
|
+
|
|
1155
|
+
controller = _get_ticket_controller(repo_root)
|
|
1156
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
1157
|
+
updated = await controller.stop_flow(record.id)
|
|
1158
|
+
await self._send_message(
|
|
1159
|
+
message.chat_id,
|
|
1160
|
+
f"Stopped run {updated.id} ({updated.status.value}).",
|
|
1161
|
+
thread_id=message.thread_id,
|
|
1162
|
+
reply_to=message.message_id,
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
async def _handle_flow_recover(
|
|
1166
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1167
|
+
) -> None:
|
|
1168
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
1169
|
+
try:
|
|
1170
|
+
store.initialize()
|
|
1171
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1172
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1173
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1174
|
+
if run_id_raw and error:
|
|
1175
|
+
await self._send_message(
|
|
1176
|
+
message.chat_id,
|
|
1177
|
+
error,
|
|
1178
|
+
thread_id=message.thread_id,
|
|
1179
|
+
reply_to=message.message_id,
|
|
1180
|
+
)
|
|
1181
|
+
return
|
|
1182
|
+
if record is None:
|
|
1183
|
+
record = _select_latest_run(store, lambda run: run.status.is_active())
|
|
1184
|
+
if record is None:
|
|
1185
|
+
await self._send_message(
|
|
1186
|
+
message.chat_id,
|
|
1187
|
+
"No active ticket flow run found.",
|
|
1188
|
+
thread_id=message.thread_id,
|
|
1189
|
+
reply_to=message.message_id,
|
|
1190
|
+
)
|
|
1191
|
+
return
|
|
1192
|
+
record, updated, locked = reconcile_flow_run(repo_root, record, store)
|
|
1193
|
+
if locked:
|
|
1194
|
+
await self._send_message(
|
|
1195
|
+
message.chat_id,
|
|
1196
|
+
f"Run {record.id} is locked for reconcile; try again.",
|
|
1197
|
+
thread_id=message.thread_id,
|
|
1198
|
+
reply_to=message.message_id,
|
|
1199
|
+
)
|
|
1200
|
+
return
|
|
1201
|
+
hint = "Recovered" if updated else "No changes needed"
|
|
1202
|
+
lines = [f"{hint} for run {record.id}."]
|
|
1203
|
+
lines.extend(self._format_flow_status_lines(repo_root, record, store))
|
|
1204
|
+
finally:
|
|
1205
|
+
store.close()
|
|
1206
|
+
|
|
1207
|
+
await self._send_message(
|
|
1208
|
+
message.chat_id,
|
|
1209
|
+
"\n".join(lines),
|
|
1210
|
+
thread_id=message.thread_id,
|
|
1211
|
+
reply_to=message.message_id,
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
async def _handle_flow_restart(
|
|
1215
|
+
self,
|
|
1216
|
+
message: TelegramMessage,
|
|
1217
|
+
repo_root: Path,
|
|
1218
|
+
argv: Optional[list[str]] = None,
|
|
1219
|
+
) -> None:
|
|
1220
|
+
argv = argv or []
|
|
1221
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
1222
|
+
record = None
|
|
1223
|
+
try:
|
|
1224
|
+
store.initialize()
|
|
1225
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1226
|
+
if run_id_raw:
|
|
1227
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1228
|
+
if error is None and run_id:
|
|
1229
|
+
record = store.get_flow_run(run_id)
|
|
1230
|
+
else:
|
|
1231
|
+
record = _select_latest_run(store, lambda run: run.status.is_active())
|
|
1232
|
+
finally:
|
|
1233
|
+
store.close()
|
|
1234
|
+
if record and not record.status.is_terminal():
|
|
1235
|
+
controller = _get_ticket_controller(repo_root)
|
|
1236
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
1237
|
+
await controller.stop_flow(record.id)
|
|
1238
|
+
await self._handle_flow_bootstrap(message, repo_root, argv=["--force-new"])
|
|
1239
|
+
|
|
1240
|
+
async def _handle_flow_archive(
|
|
1241
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1242
|
+
) -> None:
|
|
1243
|
+
force = self._has_flag(argv, "--force")
|
|
1244
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
1245
|
+
record = None
|
|
1246
|
+
try:
|
|
1247
|
+
store.initialize()
|
|
1248
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1249
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1250
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1251
|
+
if run_id_raw and error:
|
|
1252
|
+
await self._send_message(
|
|
1253
|
+
message.chat_id,
|
|
1254
|
+
error,
|
|
1255
|
+
thread_id=message.thread_id,
|
|
1256
|
+
reply_to=message.message_id,
|
|
1257
|
+
)
|
|
1258
|
+
return
|
|
1259
|
+
if record is None:
|
|
1260
|
+
record = _select_latest_run(
|
|
1261
|
+
store,
|
|
1262
|
+
lambda run: run.status.is_terminal()
|
|
1263
|
+
or run.status == FlowRunStatus.PAUSED
|
|
1264
|
+
or (force and run.status == FlowRunStatus.STOPPING),
|
|
1265
|
+
)
|
|
1266
|
+
if record is None:
|
|
1267
|
+
await self._send_message(
|
|
1268
|
+
message.chat_id,
|
|
1269
|
+
"No paused or terminal ticket flow run found.",
|
|
1270
|
+
thread_id=message.thread_id,
|
|
1271
|
+
reply_to=message.message_id,
|
|
1272
|
+
)
|
|
1273
|
+
return
|
|
1274
|
+
if not record.status.is_terminal():
|
|
1275
|
+
if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
|
|
1276
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
1277
|
+
else:
|
|
1278
|
+
await self._send_message(
|
|
1279
|
+
message.chat_id,
|
|
1280
|
+
"Can only archive completed/stopped/failed runs (use --force for stuck flows).",
|
|
1281
|
+
thread_id=message.thread_id,
|
|
1282
|
+
reply_to=message.message_id,
|
|
1283
|
+
)
|
|
1284
|
+
return
|
|
1285
|
+
finally:
|
|
1286
|
+
store.close()
|
|
1287
|
+
|
|
1288
|
+
_, artifacts_root = _flow_paths(repo_root)
|
|
1289
|
+
archive_dir = artifacts_root / record.id / "archived_tickets"
|
|
1290
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
1291
|
+
ticket_dir = _ticket_dir(repo_root)
|
|
1292
|
+
archived_count = 0
|
|
1293
|
+
for ticket_path in list_ticket_paths(ticket_dir):
|
|
1294
|
+
dest = archive_dir / ticket_path.name
|
|
1295
|
+
shutil.move(str(ticket_path), str(dest))
|
|
1296
|
+
archived_count += 1
|
|
1297
|
+
|
|
1298
|
+
runs_dir = Path(record.input_data.get("runs_dir") or ".codex-autorunner/runs")
|
|
1299
|
+
outbox_paths = resolve_outbox_paths(
|
|
1300
|
+
workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
|
|
1301
|
+
)
|
|
1302
|
+
run_dir = outbox_paths.run_dir
|
|
1303
|
+
if run_dir.exists() and run_dir.is_dir():
|
|
1304
|
+
archived_runs_dir = artifacts_root / record.id / "archived_runs"
|
|
1305
|
+
shutil.move(str(run_dir), str(archived_runs_dir))
|
|
1306
|
+
|
|
1307
|
+
store = FlowStore(_flow_paths(repo_root)[0])
|
|
1308
|
+
try:
|
|
1309
|
+
store.initialize()
|
|
1310
|
+
store.delete_flow_run(record.id)
|
|
1311
|
+
finally:
|
|
1312
|
+
store.close()
|
|
1313
|
+
|
|
1314
|
+
await self._send_message(
|
|
1315
|
+
message.chat_id,
|
|
1316
|
+
f"Archived run {record.id} ({archived_count} tickets).",
|
|
1317
|
+
thread_id=message.thread_id,
|
|
1318
|
+
reply_to=message.message_id,
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
async def _handle_reply(self, message: TelegramMessage, args: str) -> None:
|
|
1322
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
1323
|
+
record = await self._store.get_topic(key)
|
|
1324
|
+
if not record or not record.workspace_path:
|
|
1325
|
+
await self._send_message(
|
|
1326
|
+
message.chat_id,
|
|
1327
|
+
"No workspace bound. Use /bind to bind this topic to a repo first.",
|
|
1328
|
+
thread_id=message.thread_id,
|
|
1329
|
+
reply_to=message.message_id,
|
|
1330
|
+
)
|
|
1331
|
+
return
|
|
1332
|
+
|
|
1333
|
+
repo_root = canonicalize_path(Path(record.workspace_path))
|
|
1334
|
+
text = args.strip()
|
|
1335
|
+
if not text:
|
|
1336
|
+
await self._send_message(
|
|
1337
|
+
message.chat_id,
|
|
1338
|
+
"Provide a reply: /flow reply <message> (or /reply <message>).",
|
|
1339
|
+
thread_id=message.thread_id,
|
|
1340
|
+
reply_to=message.message_id,
|
|
1341
|
+
)
|
|
1342
|
+
return
|
|
1343
|
+
|
|
1344
|
+
target_run_id = self._ticket_flow_pause_targets.get(str(repo_root))
|
|
1345
|
+
paused = self._get_paused_ticket_flow(repo_root, preferred_run_id=target_run_id)
|
|
1346
|
+
if not paused:
|
|
1347
|
+
await self._send_message(
|
|
1348
|
+
message.chat_id,
|
|
1349
|
+
"No paused ticket flow run found for this workspace.",
|
|
1350
|
+
thread_id=message.thread_id,
|
|
1351
|
+
reply_to=message.message_id,
|
|
1352
|
+
)
|
|
1353
|
+
return
|
|
1354
|
+
|
|
1355
|
+
run_id, run_record = paused
|
|
1356
|
+
success, result = await self._write_user_reply_from_telegram(
|
|
1357
|
+
repo_root, run_id, run_record, message, text
|
|
1358
|
+
)
|
|
1359
|
+
await self._send_message(
|
|
1360
|
+
message.chat_id,
|
|
1361
|
+
result,
|
|
1362
|
+
thread_id=message.thread_id,
|
|
1363
|
+
reply_to=message.message_id,
|
|
1364
|
+
)
|