codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- 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/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- 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 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -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 +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -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 +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -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 +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -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 +469 -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 +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,20 +1,60 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import uuid
|
|
4
8
|
from pathlib import Path
|
|
9
|
+
from typing import Callable, Optional
|
|
5
10
|
|
|
6
|
-
from .....
|
|
11
|
+
from .....agents.registry import validate_agent_id
|
|
12
|
+
from .....core.config import load_hub_config, load_repo_config
|
|
7
13
|
from .....core.flows import FlowController, FlowStore
|
|
8
14
|
from .....core.flows.models import FlowRunStatus
|
|
15
|
+
from .....core.flows.reconciler import reconcile_flow_run
|
|
16
|
+
from .....core.flows.ux_helpers import (
|
|
17
|
+
bootstrap_check,
|
|
18
|
+
build_flow_status_snapshot,
|
|
19
|
+
ensure_worker,
|
|
20
|
+
issue_md_has_content,
|
|
21
|
+
issue_md_path,
|
|
22
|
+
seed_issue_from_github,
|
|
23
|
+
seed_issue_from_text,
|
|
24
|
+
ticket_progress,
|
|
25
|
+
)
|
|
9
26
|
from .....core.flows.worker_process import (
|
|
27
|
+
FlowWorkerHealth,
|
|
10
28
|
check_worker_health,
|
|
11
|
-
|
|
29
|
+
clear_worker_metadata,
|
|
12
30
|
)
|
|
13
|
-
from .....core.
|
|
31
|
+
from .....core.logging_utils import log_event
|
|
32
|
+
from .....core.runtime import RuntimeContext
|
|
33
|
+
from .....core.state import now_iso
|
|
34
|
+
from .....core.utils import atomic_write, canonicalize_path
|
|
14
35
|
from .....flows.ticket_flow import build_ticket_flow_definition
|
|
36
|
+
from .....integrations.agents import build_backend_orchestrator
|
|
37
|
+
from .....integrations.agents.wiring import (
|
|
38
|
+
build_agent_backend_factory,
|
|
39
|
+
build_app_server_supervisor_factory,
|
|
40
|
+
)
|
|
41
|
+
from .....manifest import load_manifest
|
|
15
42
|
from .....tickets import AgentPool
|
|
16
|
-
from
|
|
43
|
+
from .....tickets.files import list_ticket_paths
|
|
44
|
+
from .....tickets.outbox import resolve_outbox_paths
|
|
45
|
+
from ....github.service import GitHubError, GitHubService
|
|
46
|
+
from ...adapter import (
|
|
47
|
+
FlowCallback,
|
|
48
|
+
InlineButton,
|
|
49
|
+
TelegramCallbackQuery,
|
|
50
|
+
TelegramMessage,
|
|
51
|
+
build_inline_keyboard,
|
|
52
|
+
encode_flow_callback,
|
|
53
|
+
encode_question_cancel_callback,
|
|
54
|
+
)
|
|
55
|
+
from ...config import DEFAULT_APPROVAL_TIMEOUT_SECONDS
|
|
17
56
|
from ...helpers import _truncate_text
|
|
57
|
+
from ...types import PendingQuestion, SelectionState
|
|
18
58
|
from .shared import SharedHelpers
|
|
19
59
|
|
|
20
60
|
_logger = logging.getLogger(__name__)
|
|
@@ -27,9 +67,123 @@ def _flow_paths(repo_root: Path) -> tuple[Path, Path]:
|
|
|
27
67
|
return db_path, artifacts_root
|
|
28
68
|
|
|
29
69
|
|
|
70
|
+
def _ticket_dir(repo_root: Path) -> Path:
|
|
71
|
+
return repo_root.resolve() / ".codex-autorunner" / "tickets"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_flow_store(repo_root: Path, hub_root: Optional[Path] = None) -> FlowStore:
|
|
75
|
+
config = load_repo_config(repo_root, hub_root)
|
|
76
|
+
return FlowStore(_flow_paths(repo_root)[0], durable=config.durable_writes)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _normalize_run_id(value: str) -> Optional[str]:
|
|
80
|
+
try:
|
|
81
|
+
return str(uuid.UUID(str(value)))
|
|
82
|
+
except ValueError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _split_flow_action(args: str) -> tuple[str, str]:
|
|
87
|
+
trimmed = (args or "").strip()
|
|
88
|
+
if not trimmed:
|
|
89
|
+
return "", ""
|
|
90
|
+
parts = trimmed.split(None, 1)
|
|
91
|
+
if len(parts) == 1:
|
|
92
|
+
return parts[0], ""
|
|
93
|
+
return parts[0], parts[1]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _normalize_flow_action(action: str) -> str:
|
|
97
|
+
normalized = (action or "").strip().lower()
|
|
98
|
+
if not normalized:
|
|
99
|
+
return "help"
|
|
100
|
+
if normalized == "start":
|
|
101
|
+
return "bootstrap"
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _flow_help_lines() -> list[str]:
|
|
106
|
+
return [
|
|
107
|
+
"Flow commands:",
|
|
108
|
+
"/flow status [run_id]",
|
|
109
|
+
"/flow runs [N]",
|
|
110
|
+
"/flow bootstrap [--force-new]",
|
|
111
|
+
"/flow issue <issue#|url>",
|
|
112
|
+
"/flow plan <text>",
|
|
113
|
+
"/flow resume [run_id]",
|
|
114
|
+
"/flow stop [run_id]",
|
|
115
|
+
"/flow recover [run_id]",
|
|
116
|
+
"/flow restart",
|
|
117
|
+
"/flow archive [run_id] [--force]",
|
|
118
|
+
"/flow reply <message>",
|
|
119
|
+
"Alias: /flow start",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _discover_unregistered_worktrees(
|
|
124
|
+
manifest, hub_root: Optional[Path]
|
|
125
|
+
) -> list[dict[str, object]]:
|
|
126
|
+
if not hub_root:
|
|
127
|
+
return []
|
|
128
|
+
try:
|
|
129
|
+
hub_config = load_hub_config(hub_root)
|
|
130
|
+
except Exception:
|
|
131
|
+
return []
|
|
132
|
+
worktrees_root = hub_config.worktrees_root
|
|
133
|
+
if not worktrees_root.exists() or not worktrees_root.is_dir():
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
known_paths = {(hub_root / repo.path).resolve() for repo in manifest.repos}
|
|
137
|
+
known_ids = {repo.id for repo in manifest.repos}
|
|
138
|
+
extras: list[dict[str, object]] = []
|
|
139
|
+
for child in sorted(worktrees_root.iterdir()):
|
|
140
|
+
if not child.is_dir():
|
|
141
|
+
continue
|
|
142
|
+
if not (child / ".git").exists():
|
|
143
|
+
continue
|
|
144
|
+
resolved = child.resolve()
|
|
145
|
+
if resolved in known_paths:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
flows_root = child / ".codex-autorunner" / "flows"
|
|
149
|
+
flows_db = child / ".codex-autorunner" / "flows.db"
|
|
150
|
+
if not flows_root.exists() and not flows_db.exists():
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
repo_id = child.name
|
|
154
|
+
label = repo_id
|
|
155
|
+
indent = ""
|
|
156
|
+
if "--" in repo_id:
|
|
157
|
+
_, suffix = repo_id.split("--", 1)
|
|
158
|
+
label = suffix or repo_id
|
|
159
|
+
indent = " - "
|
|
160
|
+
label = f"{label} (unregistered)"
|
|
161
|
+
if repo_id in known_ids:
|
|
162
|
+
label = f"{label} (duplicate id)"
|
|
163
|
+
extras.append(
|
|
164
|
+
{
|
|
165
|
+
"repo_id": repo_id,
|
|
166
|
+
"repo_root": resolved,
|
|
167
|
+
"label": label,
|
|
168
|
+
"indent": indent,
|
|
169
|
+
"unregistered": True,
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
return extras
|
|
173
|
+
|
|
174
|
+
|
|
30
175
|
def _get_ticket_controller(repo_root: Path) -> FlowController:
|
|
31
176
|
db_path, artifacts_root = _flow_paths(repo_root)
|
|
32
|
-
|
|
177
|
+
config = load_repo_config(repo_root)
|
|
178
|
+
backend_orchestrator = build_backend_orchestrator(repo_root, config)
|
|
179
|
+
engine = RuntimeContext(
|
|
180
|
+
repo_root,
|
|
181
|
+
config=config,
|
|
182
|
+
backend_orchestrator=backend_orchestrator,
|
|
183
|
+
backend_factory=build_agent_backend_factory(repo_root, config),
|
|
184
|
+
app_server_supervisor_factory=build_app_server_supervisor_factory(config),
|
|
185
|
+
agent_id_validator=validate_agent_id,
|
|
186
|
+
)
|
|
33
187
|
agent_pool = AgentPool(engine.config)
|
|
34
188
|
definition = build_ticket_flow_definition(agent_pool=agent_pool)
|
|
35
189
|
definition.validate()
|
|
@@ -41,12 +195,14 @@ def _get_ticket_controller(repo_root: Path) -> FlowController:
|
|
|
41
195
|
|
|
42
196
|
|
|
43
197
|
def _spawn_flow_worker(repo_root: Path, run_id: str) -> None:
|
|
44
|
-
|
|
45
|
-
if
|
|
198
|
+
result = ensure_worker(repo_root, run_id)
|
|
199
|
+
if result["status"] == "reused":
|
|
200
|
+
health = result["health"]
|
|
46
201
|
_logger.info("Worker already active for run %s (pid=%s)", run_id, health.pid)
|
|
47
202
|
return
|
|
48
|
-
|
|
49
|
-
|
|
203
|
+
proc = result["proc"]
|
|
204
|
+
out = result["stdout"]
|
|
205
|
+
err = result["stderr"]
|
|
50
206
|
try:
|
|
51
207
|
# We don't track handles in Telegram commands, close in parent after spawn.
|
|
52
208
|
out.close()
|
|
@@ -56,127 +212,1396 @@ def _spawn_flow_worker(repo_root: Path, run_id: str) -> None:
|
|
|
56
212
|
_logger.warning("Flow worker for %s exited immediately", run_id)
|
|
57
213
|
|
|
58
214
|
|
|
215
|
+
def _select_latest_run(
|
|
216
|
+
store: FlowStore, predicate: Callable[[object], bool]
|
|
217
|
+
) -> Optional[object]:
|
|
218
|
+
for record in store.list_flow_runs(flow_type="ticket_flow"):
|
|
219
|
+
if predicate(record):
|
|
220
|
+
return record
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
59
224
|
class FlowCommands(SharedHelpers):
|
|
225
|
+
def _github_bootstrap_status(self, repo_root: Path) -> tuple[bool, Optional[str]]:
|
|
226
|
+
result = bootstrap_check(repo_root, github_service_factory=GitHubService)
|
|
227
|
+
return bool(result.github_available), result.repo_slug
|
|
228
|
+
|
|
229
|
+
async def _prompt_flow_text_input(
|
|
230
|
+
self,
|
|
231
|
+
message: TelegramMessage,
|
|
232
|
+
prompt_text: str,
|
|
233
|
+
) -> Optional[str]:
|
|
234
|
+
request_id = str(uuid.uuid4())
|
|
235
|
+
topic_key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
236
|
+
payload_text, parse_mode = self._prepare_outgoing_text(
|
|
237
|
+
prompt_text,
|
|
238
|
+
chat_id=message.chat_id,
|
|
239
|
+
thread_id=message.thread_id,
|
|
240
|
+
reply_to=message.message_id,
|
|
241
|
+
topic_key=topic_key,
|
|
242
|
+
)
|
|
243
|
+
keyboard = build_inline_keyboard(
|
|
244
|
+
[[InlineButton("Cancel", encode_question_cancel_callback(request_id))]]
|
|
245
|
+
)
|
|
246
|
+
response = await self._bot.send_message(
|
|
247
|
+
message.chat_id,
|
|
248
|
+
payload_text,
|
|
249
|
+
message_thread_id=message.thread_id,
|
|
250
|
+
reply_to_message_id=message.message_id,
|
|
251
|
+
reply_markup=keyboard,
|
|
252
|
+
parse_mode=parse_mode,
|
|
253
|
+
)
|
|
254
|
+
message_id = response.get("message_id") if isinstance(response, dict) else None
|
|
255
|
+
loop = asyncio.get_running_loop()
|
|
256
|
+
future: asyncio.Future[Optional[str]] = loop.create_future()
|
|
257
|
+
pending = PendingQuestion(
|
|
258
|
+
request_id=request_id,
|
|
259
|
+
turn_id=f"flow-bootstrap:{request_id}",
|
|
260
|
+
codex_thread_id=None,
|
|
261
|
+
chat_id=message.chat_id,
|
|
262
|
+
thread_id=message.thread_id,
|
|
263
|
+
topic_key=topic_key,
|
|
264
|
+
message_id=message_id if isinstance(message_id, int) else None,
|
|
265
|
+
created_at=now_iso(),
|
|
266
|
+
question_index=0,
|
|
267
|
+
prompt=prompt_text,
|
|
268
|
+
options=[],
|
|
269
|
+
future=future,
|
|
270
|
+
multiple=False,
|
|
271
|
+
custom=True,
|
|
272
|
+
selected_indices=set(),
|
|
273
|
+
awaiting_custom_input=True,
|
|
274
|
+
)
|
|
275
|
+
self._pending_questions[request_id] = pending
|
|
276
|
+
self._touch_cache_timestamp("pending_questions", request_id)
|
|
277
|
+
try:
|
|
278
|
+
result = await asyncio.wait_for(
|
|
279
|
+
future, timeout=DEFAULT_APPROVAL_TIMEOUT_SECONDS
|
|
280
|
+
)
|
|
281
|
+
except asyncio.TimeoutError:
|
|
282
|
+
self._pending_questions.pop(request_id, None)
|
|
283
|
+
if pending.message_id is not None:
|
|
284
|
+
await self._edit_message_text(
|
|
285
|
+
pending.chat_id,
|
|
286
|
+
pending.message_id,
|
|
287
|
+
"Question timed out.",
|
|
288
|
+
reply_markup={"inline_keyboard": []},
|
|
289
|
+
)
|
|
290
|
+
return None
|
|
291
|
+
if not result:
|
|
292
|
+
return None
|
|
293
|
+
return result.strip() or None
|
|
294
|
+
|
|
295
|
+
async def _seed_issue_from_ref(
|
|
296
|
+
self, repo_root: Path, issue_ref: str
|
|
297
|
+
) -> tuple[int, str]:
|
|
298
|
+
seed = seed_issue_from_github(
|
|
299
|
+
repo_root, issue_ref, github_service_factory=GitHubService
|
|
300
|
+
)
|
|
301
|
+
atomic_write(issue_md_path(repo_root), seed.content)
|
|
302
|
+
return seed.issue_number, seed.repo_slug
|
|
303
|
+
|
|
304
|
+
def _seed_issue_from_plan(self, repo_root: Path, plan_text: str) -> None:
|
|
305
|
+
content = seed_issue_from_text(plan_text)
|
|
306
|
+
atomic_write(issue_md_path(repo_root), content)
|
|
307
|
+
|
|
308
|
+
async def _handle_flow_status(self, message: TelegramMessage, args: str) -> None:
|
|
309
|
+
text = args.strip()
|
|
310
|
+
if text:
|
|
311
|
+
await self._handle_flow(message, f"status {text}")
|
|
312
|
+
else:
|
|
313
|
+
await self._handle_flow(message, "status")
|
|
314
|
+
|
|
60
315
|
async def _handle_flow(self, message: TelegramMessage, args: str) -> None:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
316
|
+
argv = self._parse_command_args(args)
|
|
317
|
+
|
|
318
|
+
target_repo_root = None
|
|
319
|
+
effective_args = args
|
|
320
|
+
|
|
321
|
+
if argv:
|
|
322
|
+
resolved = self._resolve_workspace(argv[0])
|
|
323
|
+
if resolved:
|
|
324
|
+
target_repo_root = Path(resolved[0])
|
|
325
|
+
argv = argv[1:]
|
|
326
|
+
# Reconstruct args for remainder logic (imperfect but sufficient for text commands)
|
|
327
|
+
effective_args = " ".join(argv)
|
|
328
|
+
|
|
329
|
+
action_raw = argv[0] if argv else ""
|
|
330
|
+
if target_repo_root and not action_raw:
|
|
331
|
+
action_raw = "status"
|
|
332
|
+
argv = ["status"]
|
|
333
|
+
effective_args = "status"
|
|
334
|
+
action = _normalize_flow_action(action_raw)
|
|
335
|
+
_, remainder = _split_flow_action(effective_args)
|
|
336
|
+
rest_argv = argv[1:]
|
|
337
|
+
|
|
66
338
|
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
67
339
|
record = await self._store.get_topic(key)
|
|
68
|
-
|
|
340
|
+
is_pma = bool(record and getattr(record, "pma_enabled", False))
|
|
341
|
+
is_unbound = bool(not record or not getattr(record, "workspace_path", None))
|
|
342
|
+
|
|
343
|
+
if not target_repo_root and not action_raw:
|
|
344
|
+
# Check if we should show Hub Overview
|
|
345
|
+
if is_pma or is_unbound:
|
|
346
|
+
await self._send_flow_hub_overview(message)
|
|
347
|
+
return
|
|
348
|
+
action = "status"
|
|
349
|
+
rest_argv = []
|
|
350
|
+
|
|
351
|
+
if action == "help":
|
|
352
|
+
await self._send_flow_overview(message, record)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
if target_repo_root:
|
|
356
|
+
repo_root = canonicalize_path(target_repo_root)
|
|
357
|
+
elif record and record.workspace_path:
|
|
358
|
+
repo_root = canonicalize_path(Path(record.workspace_path))
|
|
359
|
+
else:
|
|
360
|
+
if action == "status" and (is_pma or is_unbound):
|
|
361
|
+
await self._send_flow_hub_overview(message)
|
|
362
|
+
return
|
|
69
363
|
await self._send_message(
|
|
70
364
|
message.chat_id,
|
|
71
|
-
"No workspace bound. Use /
|
|
365
|
+
"No workspace bound. Use /flow <repo-id> status to inspect a repo without binding, or /bind <repo-id> to attach this topic.",
|
|
72
366
|
thread_id=message.thread_id,
|
|
73
367
|
reply_to=message.message_id,
|
|
74
368
|
)
|
|
75
369
|
return
|
|
76
370
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
371
|
+
try:
|
|
372
|
+
if action == "status":
|
|
373
|
+
await self._handle_flow_status_action(message, repo_root, rest_argv)
|
|
374
|
+
return
|
|
375
|
+
if action == "runs":
|
|
376
|
+
await self._handle_flow_runs(message, repo_root, rest_argv)
|
|
377
|
+
return
|
|
378
|
+
if action == "bootstrap":
|
|
379
|
+
await self._handle_flow_bootstrap(message, repo_root, rest_argv)
|
|
380
|
+
return
|
|
381
|
+
if action == "issue":
|
|
382
|
+
await self._handle_flow_issue(message, repo_root, remainder)
|
|
383
|
+
return
|
|
384
|
+
if action == "plan":
|
|
385
|
+
await self._handle_flow_plan(message, repo_root, remainder)
|
|
386
|
+
return
|
|
387
|
+
if action == "resume":
|
|
388
|
+
await self._handle_flow_resume(message, repo_root, rest_argv)
|
|
389
|
+
return
|
|
390
|
+
if action == "stop":
|
|
391
|
+
await self._handle_flow_stop(message, repo_root, rest_argv)
|
|
392
|
+
return
|
|
393
|
+
if action == "recover":
|
|
394
|
+
await self._handle_flow_recover(message, repo_root, rest_argv)
|
|
395
|
+
return
|
|
396
|
+
if action == "restart":
|
|
397
|
+
await self._handle_flow_restart(message, repo_root, rest_argv)
|
|
398
|
+
return
|
|
399
|
+
if action == "archive":
|
|
400
|
+
await self._handle_flow_archive(message, repo_root, rest_argv)
|
|
401
|
+
return
|
|
402
|
+
if action == "reply":
|
|
403
|
+
await self._handle_reply(message, remainder)
|
|
404
|
+
return
|
|
405
|
+
except (asyncio.CancelledError, KeyboardInterrupt):
|
|
406
|
+
# Let cancellations propagate so shutdowns/timeouts are not masked.
|
|
407
|
+
raise
|
|
408
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
409
|
+
log_event(
|
|
410
|
+
_logger,
|
|
411
|
+
logging.WARNING,
|
|
412
|
+
"telegram.flow.command_failed",
|
|
413
|
+
chat_id=message.chat_id,
|
|
414
|
+
thread_id=message.thread_id,
|
|
415
|
+
action=action or "unknown",
|
|
416
|
+
exc=exc,
|
|
417
|
+
)
|
|
418
|
+
format_msg = getattr(self, "_with_conversation_id", None)
|
|
419
|
+
error_text = (
|
|
420
|
+
format_msg(
|
|
421
|
+
"Flow command failed; check logs for details.",
|
|
422
|
+
chat_id=message.chat_id,
|
|
423
|
+
thread_id=message.thread_id,
|
|
424
|
+
)
|
|
425
|
+
if callable(format_msg)
|
|
426
|
+
else "Flow command failed; check logs for details."
|
|
427
|
+
)
|
|
428
|
+
await self._send_message(
|
|
429
|
+
message.chat_id,
|
|
430
|
+
error_text,
|
|
431
|
+
thread_id=message.thread_id,
|
|
432
|
+
reply_to=message.message_id,
|
|
433
|
+
)
|
|
434
|
+
return
|
|
80
435
|
|
|
81
|
-
|
|
436
|
+
await self._send_message(
|
|
437
|
+
message.chat_id,
|
|
438
|
+
f"Unknown /flow command: {action_raw or action}. Use /flow help.",
|
|
439
|
+
thread_id=message.thread_id,
|
|
440
|
+
reply_to=message.message_id,
|
|
441
|
+
)
|
|
442
|
+
await self._send_flow_help_block(message)
|
|
443
|
+
return
|
|
82
444
|
|
|
83
|
-
|
|
445
|
+
async def _render_flow_status_callback(
|
|
446
|
+
self,
|
|
447
|
+
callback: TelegramCallbackQuery,
|
|
448
|
+
repo_root: Path,
|
|
449
|
+
run_id_raw: Optional[str],
|
|
450
|
+
) -> None:
|
|
451
|
+
store = _load_flow_store(repo_root)
|
|
84
452
|
try:
|
|
85
453
|
store.initialize()
|
|
86
|
-
|
|
87
|
-
|
|
454
|
+
record, error = self._resolve_status_record(store, run_id_raw)
|
|
455
|
+
if error:
|
|
456
|
+
await self._edit_callback_message(
|
|
457
|
+
callback, error, reply_markup={"inline_keyboard": []}
|
|
458
|
+
)
|
|
459
|
+
return
|
|
460
|
+
text, keyboard = self._build_flow_status_card(repo_root, record, store)
|
|
88
461
|
finally:
|
|
89
462
|
store.close()
|
|
463
|
+
await self._edit_callback_message(callback, text, reply_markup=keyboard)
|
|
90
464
|
|
|
91
|
-
|
|
92
|
-
|
|
465
|
+
async def _handle_flow_callback(
|
|
466
|
+
self, callback: TelegramCallbackQuery, parsed: FlowCallback
|
|
467
|
+
) -> None:
|
|
468
|
+
if callback.chat_id is None:
|
|
469
|
+
return
|
|
470
|
+
key = await self._resolve_topic_key(callback.chat_id, callback.thread_id)
|
|
471
|
+
record = await self._store.get_topic(key)
|
|
472
|
+
if not record or not record.workspace_path:
|
|
473
|
+
await self._answer_callback(callback, "No workspace bound")
|
|
474
|
+
await self._edit_callback_message(
|
|
475
|
+
callback,
|
|
476
|
+
"No workspace bound. Use /bind to bind this topic to a repo first.",
|
|
477
|
+
reply_markup={"inline_keyboard": []},
|
|
478
|
+
)
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
repo_root = canonicalize_path(Path(record.workspace_path))
|
|
482
|
+
action = (parsed.action or "").strip().lower()
|
|
483
|
+
run_id_raw = parsed.run_id
|
|
484
|
+
|
|
485
|
+
if action in {"refresh", "status"}:
|
|
486
|
+
await self._answer_callback(callback, "Refreshing...")
|
|
487
|
+
await self._render_flow_status_callback(callback, repo_root, run_id_raw)
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
error = None
|
|
491
|
+
notice = None
|
|
492
|
+
if action == "resume":
|
|
493
|
+
store = _load_flow_store(repo_root)
|
|
494
|
+
try:
|
|
495
|
+
store.initialize()
|
|
496
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
497
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
498
|
+
if run_id_raw and error:
|
|
499
|
+
record = None
|
|
500
|
+
if error is None and record is None:
|
|
501
|
+
record = _select_latest_run(
|
|
502
|
+
store, lambda run: run.status == FlowRunStatus.PAUSED
|
|
503
|
+
)
|
|
504
|
+
if error is None and record is None:
|
|
505
|
+
error = "No paused ticket flow run found."
|
|
506
|
+
if error is None and record.status != FlowRunStatus.PAUSED:
|
|
507
|
+
error = f"Run {record.id} is {record.status.value}, not paused."
|
|
508
|
+
finally:
|
|
509
|
+
store.close()
|
|
510
|
+
if error is None:
|
|
511
|
+
controller = _get_ticket_controller(repo_root)
|
|
512
|
+
updated = await controller.resume_flow(record.id)
|
|
513
|
+
_spawn_flow_worker(repo_root, updated.id)
|
|
514
|
+
notice = "Resumed."
|
|
515
|
+
elif action == "stop":
|
|
516
|
+
store = _load_flow_store(repo_root)
|
|
517
|
+
try:
|
|
518
|
+
store.initialize()
|
|
519
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
520
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
521
|
+
if run_id_raw and error:
|
|
522
|
+
record = None
|
|
523
|
+
if error is None and record is None:
|
|
524
|
+
record = _select_latest_run(
|
|
525
|
+
store, lambda run: run.status.is_active()
|
|
526
|
+
)
|
|
527
|
+
if error is None and record is None:
|
|
528
|
+
error = "No active ticket flow run found."
|
|
529
|
+
if error is None and record.status.is_terminal():
|
|
530
|
+
error = f"Run {record.id} is already {record.status.value}."
|
|
531
|
+
finally:
|
|
532
|
+
store.close()
|
|
533
|
+
if error is None:
|
|
534
|
+
controller = _get_ticket_controller(repo_root)
|
|
535
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
536
|
+
await controller.stop_flow(record.id)
|
|
537
|
+
notice = "Stopped."
|
|
538
|
+
elif action == "recover":
|
|
539
|
+
store = _load_flow_store(repo_root)
|
|
540
|
+
try:
|
|
541
|
+
store.initialize()
|
|
542
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
543
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
544
|
+
if run_id_raw and error:
|
|
545
|
+
record = None
|
|
546
|
+
if error is None and record is None:
|
|
547
|
+
record = _select_latest_run(
|
|
548
|
+
store, lambda run: run.status.is_active()
|
|
549
|
+
)
|
|
550
|
+
if error is None and record is None:
|
|
551
|
+
error = "No active ticket flow run found."
|
|
552
|
+
if error is None:
|
|
553
|
+
record, updated, locked = reconcile_flow_run(
|
|
554
|
+
repo_root, record, store
|
|
555
|
+
)
|
|
556
|
+
if locked:
|
|
557
|
+
error = f"Run {record.id} is locked for reconcile; try again."
|
|
558
|
+
else:
|
|
559
|
+
notice = "Recovered." if updated else "No changes needed."
|
|
560
|
+
finally:
|
|
561
|
+
store.close()
|
|
562
|
+
elif action == "archive":
|
|
563
|
+
store = _load_flow_store(repo_root)
|
|
564
|
+
record = None
|
|
565
|
+
try:
|
|
566
|
+
store.initialize()
|
|
567
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
568
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
569
|
+
if run_id_raw and error:
|
|
570
|
+
record = None
|
|
571
|
+
if error is None and record is None:
|
|
572
|
+
record = _select_latest_run(
|
|
573
|
+
store,
|
|
574
|
+
lambda run: run.status.is_terminal()
|
|
575
|
+
or run.status == FlowRunStatus.PAUSED,
|
|
576
|
+
)
|
|
577
|
+
if error is None and record is None:
|
|
578
|
+
error = "No paused or terminal ticket flow run found."
|
|
579
|
+
if error is None and not record.status.is_terminal():
|
|
580
|
+
if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
|
|
581
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
582
|
+
else:
|
|
583
|
+
error = "Can only archive completed/stopped/failed runs (use --force for stuck flows)."
|
|
584
|
+
finally:
|
|
585
|
+
store.close()
|
|
586
|
+
|
|
587
|
+
if error is None:
|
|
588
|
+
_, artifacts_root = _flow_paths(repo_root)
|
|
589
|
+
archive_dir = artifacts_root / record.id / "archived_tickets"
|
|
590
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
591
|
+
ticket_dir = _ticket_dir(repo_root)
|
|
592
|
+
for ticket_path in list_ticket_paths(ticket_dir):
|
|
593
|
+
dest = archive_dir / ticket_path.name
|
|
594
|
+
shutil.move(str(ticket_path), str(dest))
|
|
595
|
+
|
|
596
|
+
runs_dir = Path(
|
|
597
|
+
record.input_data.get("runs_dir") or ".codex-autorunner/runs"
|
|
598
|
+
)
|
|
599
|
+
outbox_paths = resolve_outbox_paths(
|
|
600
|
+
workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
|
|
601
|
+
)
|
|
602
|
+
run_dir = outbox_paths.run_dir
|
|
603
|
+
if run_dir.exists() and run_dir.is_dir():
|
|
604
|
+
archived_runs_dir = artifacts_root / record.id / "archived_runs"
|
|
605
|
+
shutil.move(str(run_dir), str(archived_runs_dir))
|
|
606
|
+
|
|
607
|
+
store = _load_flow_store(repo_root)
|
|
608
|
+
try:
|
|
609
|
+
store.initialize()
|
|
610
|
+
store.delete_flow_run(record.id)
|
|
611
|
+
finally:
|
|
612
|
+
store.close()
|
|
613
|
+
notice = "Archived."
|
|
614
|
+
elif action == "restart":
|
|
615
|
+
message = TelegramMessage(
|
|
616
|
+
update_id=callback.update_id,
|
|
617
|
+
message_id=callback.message_id or 0,
|
|
618
|
+
chat_id=callback.chat_id,
|
|
619
|
+
thread_id=callback.thread_id,
|
|
620
|
+
from_user_id=callback.from_user_id,
|
|
621
|
+
text=None,
|
|
622
|
+
date=None,
|
|
623
|
+
is_topic_message=callback.thread_id is not None,
|
|
624
|
+
)
|
|
625
|
+
argv = [run_id_raw] if run_id_raw else []
|
|
626
|
+
await self._handle_flow_restart(message, repo_root, argv)
|
|
627
|
+
notice = "Restarted."
|
|
628
|
+
else:
|
|
629
|
+
await self._answer_callback(callback, "Unknown action")
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
if error:
|
|
633
|
+
await self._answer_callback(callback, error)
|
|
634
|
+
elif notice:
|
|
635
|
+
await self._answer_callback(callback, notice)
|
|
636
|
+
await self._render_flow_status_callback(callback, repo_root, run_id_raw)
|
|
637
|
+
|
|
638
|
+
def _resolve_run_id_input(
|
|
639
|
+
self, store: FlowStore, raw_run_id: Optional[str]
|
|
640
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
641
|
+
if not raw_run_id:
|
|
642
|
+
return None, None
|
|
643
|
+
normalized = _normalize_run_id(raw_run_id)
|
|
644
|
+
if normalized:
|
|
645
|
+
return normalized, None
|
|
646
|
+
matches = [
|
|
647
|
+
record.id
|
|
648
|
+
for record in store.list_flow_runs(flow_type="ticket_flow")
|
|
649
|
+
if record.id.startswith(raw_run_id)
|
|
650
|
+
]
|
|
651
|
+
if len(matches) == 1:
|
|
652
|
+
return matches[0], None
|
|
653
|
+
if len(matches) > 1:
|
|
654
|
+
return None, "Run ID prefix is ambiguous. Use the full run_id."
|
|
655
|
+
return None, "Invalid run_id."
|
|
656
|
+
|
|
657
|
+
def _first_non_flag(self, argv: list[str]) -> Optional[str]:
|
|
658
|
+
for part in argv:
|
|
659
|
+
if not part.startswith("--"):
|
|
660
|
+
return part
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
def _has_flag(self, argv: list[str], name: str) -> bool:
|
|
664
|
+
prefix = f"{name}="
|
|
665
|
+
return any(part == name or part.startswith(prefix) for part in argv)
|
|
666
|
+
|
|
667
|
+
def _resolve_status_record(
|
|
668
|
+
self, store: FlowStore, run_id_raw: Optional[str]
|
|
669
|
+
) -> tuple[Optional[object], Optional[str]]:
|
|
670
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
671
|
+
if run_id_raw and error:
|
|
672
|
+
return None, error
|
|
673
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
674
|
+
if record is None:
|
|
675
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
676
|
+
record = runs[0] if runs else None
|
|
677
|
+
if record is None:
|
|
678
|
+
return None, "No ticket flow run found. Use /flow bootstrap to start."
|
|
679
|
+
return record, None
|
|
680
|
+
|
|
681
|
+
def _format_flow_status_lines(
|
|
682
|
+
self,
|
|
683
|
+
repo_root: Path,
|
|
684
|
+
record: Optional[object],
|
|
685
|
+
store: Optional[FlowStore],
|
|
686
|
+
*,
|
|
687
|
+
health: Optional[FlowWorkerHealth] = None,
|
|
688
|
+
snapshot: Optional[dict] = None,
|
|
689
|
+
) -> list[str]:
|
|
690
|
+
if record is None:
|
|
691
|
+
return ["Run: none"]
|
|
692
|
+
if snapshot is None:
|
|
693
|
+
snapshot = build_flow_status_snapshot(repo_root, record, store)
|
|
694
|
+
run = record
|
|
695
|
+
status = getattr(run, "status", None)
|
|
696
|
+
status_value = status.value if status else "unknown"
|
|
697
|
+
progress = snapshot.get("ticket_progress") if snapshot else None
|
|
698
|
+
progress_label = None
|
|
699
|
+
if isinstance(progress, dict):
|
|
700
|
+
done = progress.get("done")
|
|
701
|
+
total = progress.get("total")
|
|
702
|
+
if isinstance(done, int) and isinstance(total, int) and total >= 0:
|
|
703
|
+
progress_label = f"{done}/{total}"
|
|
704
|
+
lines = [f"Run: {run.id}", f"Status: {status_value}"]
|
|
705
|
+
if progress_label:
|
|
706
|
+
lines.append(f"Tickets: {progress_label}")
|
|
707
|
+
flow_type = getattr(run, "flow_type", None)
|
|
708
|
+
if flow_type:
|
|
709
|
+
lines.append(f"Flow: {flow_type}")
|
|
710
|
+
created_at = getattr(run, "created_at", None)
|
|
711
|
+
if created_at:
|
|
712
|
+
lines.append(f"Created: {created_at}")
|
|
713
|
+
started_at = getattr(run, "started_at", None)
|
|
714
|
+
if started_at:
|
|
715
|
+
lines.append(f"Started: {started_at}")
|
|
716
|
+
finished_at = getattr(run, "finished_at", None)
|
|
717
|
+
if finished_at:
|
|
718
|
+
lines.append(f"Finished: {finished_at}")
|
|
719
|
+
current_step = getattr(run, "current_step", None)
|
|
720
|
+
if current_step:
|
|
721
|
+
lines.append(f"Step: {current_step}")
|
|
722
|
+
state = run.state or {}
|
|
723
|
+
engine = state.get("ticket_engine") if isinstance(state, dict) else None
|
|
724
|
+
engine = engine if isinstance(engine, dict) else {}
|
|
725
|
+
current = snapshot.get("effective_current_ticket") if snapshot else None
|
|
726
|
+
if isinstance(current, str) and current.strip():
|
|
727
|
+
lines.append(f"Current: {current.strip()}")
|
|
728
|
+
reason_summary = None
|
|
729
|
+
if isinstance(state, dict):
|
|
730
|
+
value = state.get("reason_summary")
|
|
731
|
+
if isinstance(value, str) and value.strip():
|
|
732
|
+
reason_summary = value.strip()
|
|
733
|
+
if reason_summary:
|
|
734
|
+
lines.append(f"Summary: {_truncate_text(reason_summary, 300)}")
|
|
735
|
+
reason = engine.get("reason") if isinstance(engine, dict) else None
|
|
736
|
+
if isinstance(reason, str) and reason.strip():
|
|
737
|
+
if reason_summary and reason.strip() == reason_summary:
|
|
738
|
+
pass
|
|
739
|
+
else:
|
|
740
|
+
lines.append(f"Reason: {_truncate_text(reason.strip(), 300)}")
|
|
741
|
+
error_message = getattr(run, "error_message", None)
|
|
742
|
+
if isinstance(error_message, str) and error_message.strip():
|
|
743
|
+
lines.append(f"Error: {_truncate_text(error_message.strip(), 300)}")
|
|
744
|
+
if snapshot:
|
|
745
|
+
last_seq = snapshot.get("last_event_seq")
|
|
746
|
+
last_at = snapshot.get("last_event_at")
|
|
747
|
+
if last_seq or last_at:
|
|
748
|
+
seq_label = str(last_seq) if last_seq is not None else "?"
|
|
749
|
+
at_label = last_at or "unknown time"
|
|
750
|
+
lines.append(f"Last event: {seq_label} @ {at_label}")
|
|
751
|
+
if health is None:
|
|
752
|
+
health = snapshot.get("worker_health") if snapshot else None
|
|
753
|
+
if health is None:
|
|
754
|
+
return lines
|
|
755
|
+
worker_line = f"Worker: {health.status}"
|
|
756
|
+
if health.pid:
|
|
757
|
+
worker_line += f" (pid {health.pid})"
|
|
758
|
+
if health.message and health.status not in {"alive"}:
|
|
759
|
+
worker_line += f" - {health.message}"
|
|
760
|
+
lines.append(worker_line)
|
|
761
|
+
if status == FlowRunStatus.PAUSED:
|
|
762
|
+
lines.append("Paused: use /flow reply <message>, then /flow resume.")
|
|
763
|
+
return lines
|
|
764
|
+
|
|
765
|
+
def _build_flow_status_keyboard(
|
|
766
|
+
self, record: Optional[object], *, health: Optional[FlowWorkerHealth]
|
|
767
|
+
) -> Optional[dict[str, object]]:
|
|
768
|
+
if record is None or health is None:
|
|
769
|
+
return None
|
|
770
|
+
status = getattr(record, "status", None)
|
|
771
|
+
if status is None:
|
|
772
|
+
return None
|
|
773
|
+
run_id = record.id
|
|
774
|
+
rows: list[list[InlineButton]] = []
|
|
775
|
+
if status == FlowRunStatus.PAUSED:
|
|
776
|
+
rows.append(
|
|
777
|
+
[
|
|
778
|
+
InlineButton("Resume", encode_flow_callback("resume", run_id)),
|
|
779
|
+
InlineButton("Restart", encode_flow_callback("restart", run_id)),
|
|
780
|
+
]
|
|
781
|
+
)
|
|
782
|
+
rows.append(
|
|
783
|
+
[InlineButton("Archive", encode_flow_callback("archive", run_id))]
|
|
784
|
+
)
|
|
785
|
+
elif status.is_terminal():
|
|
786
|
+
rows.append(
|
|
787
|
+
[
|
|
788
|
+
InlineButton("Restart", encode_flow_callback("restart", run_id)),
|
|
789
|
+
InlineButton("Archive", encode_flow_callback("archive", run_id)),
|
|
790
|
+
]
|
|
791
|
+
)
|
|
792
|
+
rows.append(
|
|
793
|
+
[InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
|
|
794
|
+
)
|
|
795
|
+
else:
|
|
796
|
+
if health.status in {"dead", "mismatch", "invalid", "absent"}:
|
|
797
|
+
rows.append(
|
|
798
|
+
[
|
|
799
|
+
InlineButton(
|
|
800
|
+
"Recover", encode_flow_callback("recover", run_id)
|
|
801
|
+
),
|
|
802
|
+
InlineButton(
|
|
803
|
+
"Refresh", encode_flow_callback("refresh", run_id)
|
|
804
|
+
),
|
|
805
|
+
]
|
|
806
|
+
)
|
|
807
|
+
elif status == FlowRunStatus.RUNNING:
|
|
808
|
+
rows.append(
|
|
809
|
+
[
|
|
810
|
+
InlineButton("Stop", encode_flow_callback("stop", run_id)),
|
|
811
|
+
InlineButton(
|
|
812
|
+
"Refresh", encode_flow_callback("refresh", run_id)
|
|
813
|
+
),
|
|
814
|
+
]
|
|
815
|
+
)
|
|
816
|
+
else:
|
|
817
|
+
rows.append(
|
|
818
|
+
[InlineButton("Refresh", encode_flow_callback("refresh", run_id))]
|
|
819
|
+
)
|
|
820
|
+
return build_inline_keyboard(rows) if rows else None
|
|
821
|
+
|
|
822
|
+
def _build_flow_status_card(
|
|
823
|
+
self, repo_root: Path, record: Optional[object], store: Optional[FlowStore]
|
|
824
|
+
) -> tuple[str, Optional[dict[str, object]]]:
|
|
825
|
+
if record is None:
|
|
826
|
+
return (
|
|
827
|
+
"\n".join(self._format_flow_status_lines(repo_root, record, store)),
|
|
828
|
+
None,
|
|
829
|
+
)
|
|
830
|
+
snapshot = build_flow_status_snapshot(repo_root, record, store)
|
|
831
|
+
health = snapshot.get("worker_health")
|
|
832
|
+
lines = self._format_flow_status_lines(
|
|
833
|
+
repo_root, record, store, health=health, snapshot=snapshot
|
|
834
|
+
)
|
|
835
|
+
keyboard = self._build_flow_status_keyboard(record, health=health)
|
|
836
|
+
return "\n".join(lines), keyboard
|
|
837
|
+
|
|
838
|
+
async def _send_flow_help_block(self, message: TelegramMessage) -> None:
|
|
839
|
+
await self._send_message(
|
|
840
|
+
message.chat_id,
|
|
841
|
+
"\n".join(_flow_help_lines()),
|
|
842
|
+
thread_id=message.thread_id,
|
|
843
|
+
reply_to=message.message_id,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
async def _send_flow_overview(
|
|
847
|
+
self, message: TelegramMessage, record: Optional[object]
|
|
848
|
+
) -> None:
|
|
849
|
+
repo_root = (
|
|
850
|
+
canonicalize_path(Path(record.workspace_path))
|
|
851
|
+
if record and record.workspace_path
|
|
852
|
+
else None
|
|
853
|
+
)
|
|
854
|
+
lines = [
|
|
855
|
+
f"Workspace: {repo_root}" if repo_root else "Workspace: unbound",
|
|
856
|
+
]
|
|
857
|
+
if repo_root:
|
|
858
|
+
store = _load_flow_store(repo_root)
|
|
859
|
+
try:
|
|
860
|
+
store.initialize()
|
|
861
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
862
|
+
latest = runs[0] if runs else None
|
|
863
|
+
lines.extend(self._format_flow_status_lines(repo_root, latest, store))
|
|
864
|
+
finally:
|
|
865
|
+
store.close()
|
|
866
|
+
else:
|
|
867
|
+
lines.append("Run: none")
|
|
868
|
+
lines.append("Use /bind <repo_id> or /bind <path>.")
|
|
869
|
+
lines.append("")
|
|
870
|
+
lines.extend(_flow_help_lines())
|
|
871
|
+
await self._send_message(
|
|
872
|
+
message.chat_id,
|
|
873
|
+
"\n".join(lines),
|
|
874
|
+
thread_id=message.thread_id,
|
|
875
|
+
reply_to=message.message_id,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
async def _send_flow_hub_overview(self, message: TelegramMessage) -> None:
|
|
879
|
+
if not self._manifest_path or not self._hub_root:
|
|
880
|
+
await self._send_message(
|
|
881
|
+
message.chat_id,
|
|
882
|
+
"Hub manifest not configured.",
|
|
883
|
+
thread_id=message.thread_id,
|
|
884
|
+
reply_to=message.message_id,
|
|
885
|
+
)
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
try:
|
|
889
|
+
manifest = load_manifest(self._manifest_path, self._hub_root)
|
|
890
|
+
except Exception:
|
|
891
|
+
await self._send_message(
|
|
892
|
+
message.chat_id,
|
|
893
|
+
"Failed to load manifest.",
|
|
894
|
+
thread_id=message.thread_id,
|
|
895
|
+
reply_to=message.message_id,
|
|
896
|
+
)
|
|
897
|
+
return
|
|
898
|
+
|
|
899
|
+
def _group_key(repo_id: str) -> tuple[str, Optional[str]]:
|
|
900
|
+
parts = repo_id.split("--", 1)
|
|
901
|
+
if len(parts) == 1:
|
|
902
|
+
return repo_id, None
|
|
903
|
+
return parts[0], parts[1]
|
|
904
|
+
|
|
905
|
+
def _format_status_line(
|
|
906
|
+
label: str,
|
|
907
|
+
*,
|
|
908
|
+
status_icon: str,
|
|
909
|
+
status_value: str,
|
|
910
|
+
progress_label: str,
|
|
911
|
+
run_id: Optional[str],
|
|
912
|
+
indent: str = "",
|
|
913
|
+
) -> str:
|
|
914
|
+
run_suffix = f" run {run_id}" if run_id else ""
|
|
915
|
+
return f"{indent}{status_icon} {label}: {status_value} {progress_label}{run_suffix}"
|
|
916
|
+
|
|
917
|
+
lines = ["Hub Flow Overview:"]
|
|
918
|
+
groups: dict[str, list[tuple[str, str]]] = {}
|
|
919
|
+
group_order: list[str] = []
|
|
920
|
+
|
|
921
|
+
entries: list[dict[str, object]] = []
|
|
922
|
+
for repo in manifest.repos:
|
|
923
|
+
if not repo.enabled:
|
|
924
|
+
continue
|
|
925
|
+
repo_root = (self._hub_root / repo.path).resolve()
|
|
926
|
+
group, suffix = _group_key(repo.id)
|
|
927
|
+
label = suffix or repo.id
|
|
928
|
+
indent = " - " if suffix else ""
|
|
929
|
+
entries.append(
|
|
930
|
+
{
|
|
931
|
+
"repo_id": repo.id,
|
|
932
|
+
"repo_root": repo_root,
|
|
933
|
+
"label": label,
|
|
934
|
+
"indent": indent,
|
|
935
|
+
"group": group,
|
|
936
|
+
"unregistered": False,
|
|
937
|
+
}
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
extras = _discover_unregistered_worktrees(manifest, self._hub_root)
|
|
941
|
+
for extra in extras:
|
|
942
|
+
repo_id = str(extra["repo_id"])
|
|
943
|
+
group, _ = _group_key(repo_id)
|
|
944
|
+
extra["group"] = group
|
|
945
|
+
entries.append(extra)
|
|
946
|
+
|
|
947
|
+
for entry in entries:
|
|
948
|
+
repo_id = str(entry["repo_id"])
|
|
949
|
+
repo_root = Path(entry["repo_root"])
|
|
950
|
+
label = str(entry["label"])
|
|
951
|
+
indent = str(entry.get("indent", ""))
|
|
952
|
+
group = str(entry.get("group", repo_id))
|
|
953
|
+
if group not in groups:
|
|
954
|
+
groups[group] = []
|
|
955
|
+
group_order.append(group)
|
|
956
|
+
|
|
957
|
+
store = _load_flow_store(repo_root)
|
|
958
|
+
try:
|
|
959
|
+
store.initialize()
|
|
960
|
+
progress = ticket_progress(repo_root)
|
|
961
|
+
done = progress.get("done", 0)
|
|
962
|
+
total = progress.get("total", 0)
|
|
963
|
+
progress_label = f"{done}/{total}"
|
|
964
|
+
active = _select_latest_run(store, lambda run: run.status.is_active())
|
|
965
|
+
if active:
|
|
966
|
+
status_icon = (
|
|
967
|
+
"🟢" if active.status == FlowRunStatus.RUNNING else "🟡"
|
|
968
|
+
)
|
|
969
|
+
status_line = _format_status_line(
|
|
970
|
+
label,
|
|
971
|
+
status_icon=status_icon,
|
|
972
|
+
status_value=active.status.value,
|
|
973
|
+
progress_label=progress_label,
|
|
974
|
+
run_id=active.id,
|
|
975
|
+
indent=indent,
|
|
976
|
+
)
|
|
977
|
+
else:
|
|
978
|
+
paused = _select_latest_run(
|
|
979
|
+
store, lambda run: run.status == FlowRunStatus.PAUSED
|
|
980
|
+
)
|
|
981
|
+
if paused:
|
|
982
|
+
status_line = _format_status_line(
|
|
983
|
+
label,
|
|
984
|
+
status_icon="🔴",
|
|
985
|
+
status_value="PAUSED",
|
|
986
|
+
progress_label=progress_label,
|
|
987
|
+
run_id=paused.id,
|
|
988
|
+
indent=indent,
|
|
989
|
+
)
|
|
990
|
+
else:
|
|
991
|
+
status_line = _format_status_line(
|
|
992
|
+
label,
|
|
993
|
+
status_icon="⚪",
|
|
994
|
+
status_value="Idle",
|
|
995
|
+
progress_label=progress_label,
|
|
996
|
+
run_id=None,
|
|
997
|
+
indent=indent,
|
|
998
|
+
)
|
|
999
|
+
except Exception:
|
|
1000
|
+
status_line = f"{indent}❓ {label}: Error reading state"
|
|
1001
|
+
finally:
|
|
1002
|
+
store.close()
|
|
1003
|
+
|
|
1004
|
+
groups[group].append((label, status_line))
|
|
1005
|
+
|
|
1006
|
+
for group in group_order:
|
|
1007
|
+
entries = groups.get(group, [])
|
|
1008
|
+
if not entries:
|
|
1009
|
+
continue
|
|
1010
|
+
entries.sort(key=lambda pair: (0 if pair[0] == group else 1, pair[0]))
|
|
1011
|
+
lines.extend([line for _label, line in entries])
|
|
1012
|
+
lines.append("")
|
|
1013
|
+
|
|
1014
|
+
if lines and lines[-1] == "":
|
|
1015
|
+
lines.pop()
|
|
1016
|
+
if extras:
|
|
1017
|
+
lines.append("")
|
|
1018
|
+
lines.append(
|
|
1019
|
+
"Note: Unregistered worktrees detected. Run 'car hub scan' to register them."
|
|
1020
|
+
)
|
|
1021
|
+
lines.append("")
|
|
1022
|
+
lines.append("Tip: use /flow <repo-id> status for repo details.")
|
|
1023
|
+
|
|
1024
|
+
await self._send_message(
|
|
1025
|
+
message.chat_id,
|
|
1026
|
+
"\n".join(lines),
|
|
1027
|
+
thread_id=message.thread_id,
|
|
1028
|
+
reply_to=message.message_id,
|
|
1029
|
+
parse_mode=None,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
async def _handle_flow_status_action(
|
|
1033
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1034
|
+
) -> None:
|
|
1035
|
+
store = _load_flow_store(repo_root)
|
|
1036
|
+
try:
|
|
1037
|
+
store.initialize()
|
|
1038
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1039
|
+
record, error = self._resolve_status_record(store, run_id_raw)
|
|
1040
|
+
if error:
|
|
93
1041
|
await self._send_message(
|
|
94
1042
|
message.chat_id,
|
|
95
|
-
|
|
1043
|
+
error,
|
|
96
1044
|
thread_id=message.thread_id,
|
|
97
1045
|
reply_to=message.message_id,
|
|
98
1046
|
)
|
|
99
1047
|
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
title: Bootstrap ticket flow
|
|
111
|
-
goal: Create SPEC.md and additional tickets, then pause for review
|
|
112
|
-
---
|
|
1048
|
+
text, keyboard = self._build_flow_status_card(repo_root, record, store)
|
|
1049
|
+
finally:
|
|
1050
|
+
store.close()
|
|
1051
|
+
await self._send_message(
|
|
1052
|
+
message.chat_id,
|
|
1053
|
+
text,
|
|
1054
|
+
thread_id=message.thread_id,
|
|
1055
|
+
reply_to=message.message_id,
|
|
1056
|
+
reply_markup=keyboard,
|
|
1057
|
+
)
|
|
113
1058
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
1059
|
+
async def _handle_flow_runs(
|
|
1060
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1061
|
+
) -> None:
|
|
1062
|
+
limit = 5
|
|
1063
|
+
limit_raw = self._first_non_flag(argv)
|
|
1064
|
+
if limit_raw:
|
|
1065
|
+
limit_value = self._coerce_int(limit_raw)
|
|
1066
|
+
if limit_value is None or limit_value <= 0:
|
|
1067
|
+
await self._send_message(
|
|
1068
|
+
message.chat_id,
|
|
1069
|
+
"Provide a positive integer for /flow runs [N].",
|
|
1070
|
+
thread_id=message.thread_id,
|
|
1071
|
+
reply_to=message.message_id,
|
|
117
1072
|
)
|
|
118
|
-
|
|
1073
|
+
return
|
|
1074
|
+
limit = min(limit_value, 50)
|
|
1075
|
+
|
|
1076
|
+
store = _load_flow_store(repo_root)
|
|
1077
|
+
try:
|
|
1078
|
+
store.initialize()
|
|
1079
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
1080
|
+
finally:
|
|
1081
|
+
store.close()
|
|
119
1082
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
1083
|
+
if not runs:
|
|
1084
|
+
await self._send_message(
|
|
1085
|
+
message.chat_id,
|
|
1086
|
+
"No ticket flow runs found. Use /flow bootstrap to start.",
|
|
1087
|
+
thread_id=message.thread_id,
|
|
1088
|
+
reply_to=message.message_id,
|
|
123
1089
|
)
|
|
124
|
-
|
|
1090
|
+
return
|
|
1091
|
+
|
|
1092
|
+
items: list[tuple[str, str]] = []
|
|
1093
|
+
button_labels: dict[str, str] = {}
|
|
1094
|
+
for run in runs[:limit]:
|
|
1095
|
+
created_at = getattr(run, "created_at", None) or "unknown"
|
|
1096
|
+
status = getattr(run, "status", None)
|
|
1097
|
+
status_label = status.value if status is not None else "unknown"
|
|
1098
|
+
items.append((run.id, f"{status_label} • {created_at}"))
|
|
1099
|
+
short_id = run.id.split("-")[0]
|
|
1100
|
+
button_label = f"{short_id} {status_label}"
|
|
1101
|
+
button_labels[run.id] = _truncate_text(button_label, 32)
|
|
1102
|
+
|
|
1103
|
+
state = SelectionState(items=items, button_labels=button_labels)
|
|
1104
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
1105
|
+
self._flow_run_options[key] = state
|
|
1106
|
+
self._touch_cache_timestamp("flow_run_options", key)
|
|
1107
|
+
prompt = self._flow_runs_prompt(state)
|
|
1108
|
+
keyboard = self._build_flow_runs_keyboard(state)
|
|
1109
|
+
await self._send_message(
|
|
1110
|
+
message.chat_id,
|
|
1111
|
+
prompt,
|
|
1112
|
+
thread_id=message.thread_id,
|
|
1113
|
+
reply_to=message.message_id,
|
|
1114
|
+
reply_markup=keyboard,
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
async def _handle_flow_bootstrap(
|
|
1118
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1119
|
+
) -> None:
|
|
1120
|
+
force_new = self._has_flag(argv, "--force-new") or self._has_flag(
|
|
1121
|
+
argv, "--force"
|
|
1122
|
+
)
|
|
1123
|
+
ticket_dir = _ticket_dir(repo_root)
|
|
1124
|
+
ticket_dir.mkdir(parents=True, exist_ok=True)
|
|
1125
|
+
existing_tickets = list_ticket_paths(ticket_dir)
|
|
1126
|
+
tickets_exist = bool(existing_tickets)
|
|
1127
|
+
issue_exists = issue_md_has_content(repo_root)
|
|
1128
|
+
|
|
1129
|
+
store = _load_flow_store(repo_root)
|
|
1130
|
+
active_run = None
|
|
1131
|
+
try:
|
|
1132
|
+
store.initialize()
|
|
1133
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
1134
|
+
for record in runs:
|
|
1135
|
+
if record.status in (FlowRunStatus.RUNNING, FlowRunStatus.PAUSED):
|
|
1136
|
+
active_run = record
|
|
1137
|
+
break
|
|
1138
|
+
finally:
|
|
1139
|
+
store.close()
|
|
1140
|
+
|
|
1141
|
+
if not force_new and active_run:
|
|
1142
|
+
_spawn_flow_worker(repo_root, active_run.id)
|
|
125
1143
|
await self._send_message(
|
|
126
1144
|
message.chat_id,
|
|
127
|
-
f"
|
|
1145
|
+
f"Reusing ticket flow run {active_run.id} ({active_run.status.value}).",
|
|
128
1146
|
thread_id=message.thread_id,
|
|
129
1147
|
reply_to=message.message_id,
|
|
130
1148
|
)
|
|
131
1149
|
return
|
|
132
1150
|
|
|
133
|
-
if
|
|
134
|
-
|
|
1151
|
+
if not tickets_exist and not issue_exists:
|
|
1152
|
+
gh_available, repo_slug = self._github_bootstrap_status(repo_root)
|
|
1153
|
+
if gh_available:
|
|
1154
|
+
repo_label = f" for {repo_slug}" if repo_slug else ""
|
|
1155
|
+
prompt = (
|
|
1156
|
+
f"Enter GitHub issue number or URL{repo_label} to seed ISSUE.md:"
|
|
1157
|
+
)
|
|
1158
|
+
issue_ref = await self._prompt_flow_text_input(message, prompt)
|
|
1159
|
+
if not issue_ref:
|
|
1160
|
+
await self._send_message(
|
|
1161
|
+
message.chat_id,
|
|
1162
|
+
"Bootstrap cancelled (no issue provided).",
|
|
1163
|
+
thread_id=message.thread_id,
|
|
1164
|
+
reply_to=message.message_id,
|
|
1165
|
+
)
|
|
1166
|
+
return
|
|
1167
|
+
try:
|
|
1168
|
+
number, _repo = await self._seed_issue_from_ref(
|
|
1169
|
+
repo_root, issue_ref
|
|
1170
|
+
)
|
|
1171
|
+
except GitHubError as exc:
|
|
1172
|
+
await self._send_message(
|
|
1173
|
+
message.chat_id,
|
|
1174
|
+
f"GitHub error: {exc}",
|
|
1175
|
+
thread_id=message.thread_id,
|
|
1176
|
+
reply_to=message.message_id,
|
|
1177
|
+
)
|
|
1178
|
+
return
|
|
1179
|
+
except Exception as exc:
|
|
1180
|
+
await self._send_message(
|
|
1181
|
+
message.chat_id,
|
|
1182
|
+
f"Failed to fetch issue: {exc}",
|
|
1183
|
+
thread_id=message.thread_id,
|
|
1184
|
+
reply_to=message.message_id,
|
|
1185
|
+
)
|
|
1186
|
+
return
|
|
135
1187
|
await self._send_message(
|
|
136
1188
|
message.chat_id,
|
|
137
|
-
"
|
|
1189
|
+
f"Seeded ISSUE.md from GitHub issue {number}.",
|
|
138
1190
|
thread_id=message.thread_id,
|
|
139
1191
|
reply_to=message.message_id,
|
|
140
1192
|
)
|
|
141
|
-
|
|
142
|
-
|
|
1193
|
+
issue_exists = True
|
|
1194
|
+
else:
|
|
1195
|
+
prompt = "Describe the work to seed ISSUE.md:"
|
|
1196
|
+
plan_text = await self._prompt_flow_text_input(message, prompt)
|
|
1197
|
+
if not plan_text:
|
|
1198
|
+
await self._send_message(
|
|
1199
|
+
message.chat_id,
|
|
1200
|
+
"Bootstrap cancelled (no description provided).",
|
|
1201
|
+
thread_id=message.thread_id,
|
|
1202
|
+
reply_to=message.message_id,
|
|
1203
|
+
)
|
|
1204
|
+
return
|
|
1205
|
+
self._seed_issue_from_plan(repo_root, plan_text)
|
|
143
1206
|
await self._send_message(
|
|
144
1207
|
message.chat_id,
|
|
145
|
-
|
|
1208
|
+
"Seeded ISSUE.md from your plan.",
|
|
146
1209
|
thread_id=message.thread_id,
|
|
147
1210
|
reply_to=message.message_id,
|
|
148
1211
|
)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
1212
|
+
issue_exists = True
|
|
1213
|
+
|
|
1214
|
+
seeded = False
|
|
1215
|
+
if not tickets_exist:
|
|
1216
|
+
first_ticket = ticket_dir / "TICKET-001.md"
|
|
1217
|
+
if not first_ticket.exists():
|
|
1218
|
+
template = """---
|
|
1219
|
+
agent: codex
|
|
1220
|
+
done: false
|
|
1221
|
+
title: Bootstrap ticket plan
|
|
1222
|
+
goal: Capture scope and seed follow-up tickets
|
|
1223
|
+
---
|
|
1224
|
+
|
|
1225
|
+
You are the first ticket in a new ticket_flow run.
|
|
1226
|
+
|
|
1227
|
+
- Read `.codex-autorunner/ISSUE.md`. If it is missing:
|
|
1228
|
+
- If GitHub is available, ask the user for the issue/PR URL or number and create `.codex-autorunner/ISSUE.md` from it.
|
|
1229
|
+
- 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.
|
|
1230
|
+
- If helpful, create or update workspace docs under `.codex-autorunner/workspace/`:
|
|
1231
|
+
- `active_context.md` for current context and links
|
|
1232
|
+
- `decisions.md` for decisions/rationale
|
|
1233
|
+
- `spec.md` for requirements and constraints
|
|
1234
|
+
- Break the work into additional `TICKET-00X.md` files with clear owners/goals; keep this ticket open until they exist.
|
|
1235
|
+
- Place any supporting artifacts in `.codex-autorunner/runs/<run_id>/dispatch/` if needed.
|
|
1236
|
+
- Write `DISPATCH.md` to dispatch a message to the user:
|
|
1237
|
+
- Use `mode: pause` (handoff) to wait for user response. This pauses execution.
|
|
1238
|
+
- Use `mode: notify` (informational) to message the user but keep running.
|
|
1239
|
+
"""
|
|
1240
|
+
first_ticket.write_text(template, encoding="utf-8")
|
|
1241
|
+
seeded = True
|
|
1242
|
+
|
|
1243
|
+
controller = _get_ticket_controller(repo_root)
|
|
1244
|
+
flow_record = await controller.start_flow(
|
|
1245
|
+
input_data={},
|
|
1246
|
+
metadata={"seeded_ticket": seeded, "origin": "telegram"},
|
|
1247
|
+
)
|
|
1248
|
+
_spawn_flow_worker(repo_root, flow_record.id)
|
|
1249
|
+
|
|
1250
|
+
if not issue_exists and not tickets_exist:
|
|
1251
|
+
await self._send_flow_issue_hint(message, repo_root)
|
|
1252
|
+
|
|
1253
|
+
await self._send_message(
|
|
1254
|
+
message.chat_id,
|
|
1255
|
+
f"Started ticket flow run {flow_record.id}.",
|
|
1256
|
+
thread_id=message.thread_id,
|
|
1257
|
+
reply_to=message.message_id,
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
async def _send_flow_issue_hint(
|
|
1261
|
+
self, message: TelegramMessage, repo_root: Path
|
|
1262
|
+
) -> None:
|
|
1263
|
+
gh_status = (
|
|
1264
|
+
"No ISSUE.md found. Use /flow plan <text> to seed it from a short plan."
|
|
1265
|
+
)
|
|
1266
|
+
gh_available, repo_slug = self._github_bootstrap_status(repo_root)
|
|
1267
|
+
if gh_available:
|
|
1268
|
+
repo_label = repo_slug or "your repo"
|
|
1269
|
+
gh_status = (
|
|
1270
|
+
f"No ISSUE.md found. Use /flow issue <issue#|url> for {repo_label}, "
|
|
1271
|
+
"or /flow plan <text>."
|
|
1272
|
+
)
|
|
1273
|
+
await self._send_message(
|
|
1274
|
+
message.chat_id,
|
|
1275
|
+
gh_status,
|
|
1276
|
+
thread_id=message.thread_id,
|
|
1277
|
+
reply_to=message.message_id,
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
async def _handle_flow_issue(
|
|
1281
|
+
self, message: TelegramMessage, repo_root: Path, issue_ref: str
|
|
1282
|
+
) -> None:
|
|
1283
|
+
issue_ref = issue_ref.strip()
|
|
1284
|
+
if not issue_ref:
|
|
152
1285
|
await self._send_message(
|
|
153
1286
|
message.chat_id,
|
|
154
|
-
|
|
1287
|
+
"Provide an issue reference: /flow issue <issue#|url>",
|
|
155
1288
|
thread_id=message.thread_id,
|
|
156
1289
|
reply_to=message.message_id,
|
|
157
1290
|
)
|
|
158
1291
|
return
|
|
1292
|
+
try:
|
|
1293
|
+
number, _repo = await self._seed_issue_from_ref(repo_root, issue_ref)
|
|
1294
|
+
except GitHubError as exc:
|
|
1295
|
+
await self._send_message(
|
|
1296
|
+
message.chat_id,
|
|
1297
|
+
f"GitHub error: {exc}",
|
|
1298
|
+
thread_id=message.thread_id,
|
|
1299
|
+
reply_to=message.message_id,
|
|
1300
|
+
)
|
|
1301
|
+
return
|
|
1302
|
+
except RuntimeError as exc:
|
|
1303
|
+
await self._send_message(
|
|
1304
|
+
message.chat_id,
|
|
1305
|
+
str(exc),
|
|
1306
|
+
thread_id=message.thread_id,
|
|
1307
|
+
reply_to=message.message_id,
|
|
1308
|
+
)
|
|
1309
|
+
return
|
|
1310
|
+
except Exception as exc:
|
|
1311
|
+
await self._send_message(
|
|
1312
|
+
message.chat_id,
|
|
1313
|
+
f"Failed to fetch issue: {exc}",
|
|
1314
|
+
thread_id=message.thread_id,
|
|
1315
|
+
reply_to=message.message_id,
|
|
1316
|
+
)
|
|
1317
|
+
return
|
|
1318
|
+
await self._send_message(
|
|
1319
|
+
message.chat_id,
|
|
1320
|
+
f"Seeded ISSUE.md from GitHub issue {number}.",
|
|
1321
|
+
thread_id=message.thread_id,
|
|
1322
|
+
reply_to=message.message_id,
|
|
1323
|
+
)
|
|
159
1324
|
|
|
160
|
-
|
|
161
|
-
|
|
1325
|
+
async def _handle_flow_plan(
|
|
1326
|
+
self, message: TelegramMessage, repo_root: Path, plan_text: str
|
|
1327
|
+
) -> None:
|
|
1328
|
+
plan_text = plan_text.strip()
|
|
1329
|
+
if not plan_text:
|
|
162
1330
|
await self._send_message(
|
|
163
1331
|
message.chat_id,
|
|
164
|
-
"
|
|
1332
|
+
"Provide a plan: /flow plan <text>",
|
|
165
1333
|
thread_id=message.thread_id,
|
|
166
1334
|
reply_to=message.message_id,
|
|
167
1335
|
)
|
|
168
1336
|
return
|
|
169
|
-
|
|
170
|
-
engine = state.get("ticket_engine") or {}
|
|
171
|
-
current = engine.get("current_ticket") or "–"
|
|
172
|
-
reason = engine.get("reason") or latest.error_message or ""
|
|
173
|
-
text = f"Run {latest.id}\nStatus: {latest.status.value}\nCurrent: {current}"
|
|
174
|
-
if reason:
|
|
175
|
-
text += f"\nReason: {_truncate_text(str(reason), 400)}"
|
|
176
|
-
text += "\n\nUse /flow resume to resume a paused run."
|
|
1337
|
+
self._seed_issue_from_plan(repo_root, plan_text)
|
|
177
1338
|
await self._send_message(
|
|
178
1339
|
message.chat_id,
|
|
179
|
-
|
|
1340
|
+
"Seeded ISSUE.md from your plan.",
|
|
1341
|
+
thread_id=message.thread_id,
|
|
1342
|
+
reply_to=message.message_id,
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
async def _handle_flow_resume(
|
|
1346
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1347
|
+
) -> None:
|
|
1348
|
+
store = _load_flow_store(repo_root)
|
|
1349
|
+
try:
|
|
1350
|
+
store.initialize()
|
|
1351
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1352
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1353
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1354
|
+
if run_id_raw and error:
|
|
1355
|
+
await self._send_message(
|
|
1356
|
+
message.chat_id,
|
|
1357
|
+
error,
|
|
1358
|
+
thread_id=message.thread_id,
|
|
1359
|
+
reply_to=message.message_id,
|
|
1360
|
+
)
|
|
1361
|
+
return
|
|
1362
|
+
if record is None:
|
|
1363
|
+
record = _select_latest_run(
|
|
1364
|
+
store, lambda run: run.status == FlowRunStatus.PAUSED
|
|
1365
|
+
)
|
|
1366
|
+
if record is None:
|
|
1367
|
+
await self._send_message(
|
|
1368
|
+
message.chat_id,
|
|
1369
|
+
"No paused ticket flow run found.",
|
|
1370
|
+
thread_id=message.thread_id,
|
|
1371
|
+
reply_to=message.message_id,
|
|
1372
|
+
)
|
|
1373
|
+
return
|
|
1374
|
+
if record.status != FlowRunStatus.PAUSED:
|
|
1375
|
+
await self._send_message(
|
|
1376
|
+
message.chat_id,
|
|
1377
|
+
f"Run {record.id} is {record.status.value}, not paused.",
|
|
1378
|
+
thread_id=message.thread_id,
|
|
1379
|
+
reply_to=message.message_id,
|
|
1380
|
+
)
|
|
1381
|
+
return
|
|
1382
|
+
finally:
|
|
1383
|
+
store.close()
|
|
1384
|
+
|
|
1385
|
+
controller = _get_ticket_controller(repo_root)
|
|
1386
|
+
updated = await controller.resume_flow(record.id)
|
|
1387
|
+
_spawn_flow_worker(repo_root, updated.id)
|
|
1388
|
+
await self._send_message(
|
|
1389
|
+
message.chat_id,
|
|
1390
|
+
f"Resumed run {updated.id}.",
|
|
1391
|
+
thread_id=message.thread_id,
|
|
1392
|
+
reply_to=message.message_id,
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
def _stop_flow_worker(self, repo_root: Path, run_id: str) -> None:
|
|
1396
|
+
health = check_worker_health(repo_root, run_id)
|
|
1397
|
+
if health.is_alive and health.pid:
|
|
1398
|
+
try:
|
|
1399
|
+
subprocess.run(["kill", str(health.pid)], check=False)
|
|
1400
|
+
except Exception as exc:
|
|
1401
|
+
_logger.warning("Failed to stop worker %s: %s", run_id, exc)
|
|
1402
|
+
if health.status in {"dead", "mismatch", "invalid"}:
|
|
1403
|
+
clear_worker_metadata(health.artifact_path.parent)
|
|
1404
|
+
|
|
1405
|
+
async def _handle_flow_stop(
|
|
1406
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1407
|
+
) -> None:
|
|
1408
|
+
store = _load_flow_store(repo_root)
|
|
1409
|
+
try:
|
|
1410
|
+
store.initialize()
|
|
1411
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1412
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1413
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1414
|
+
if run_id_raw and error:
|
|
1415
|
+
await self._send_message(
|
|
1416
|
+
message.chat_id,
|
|
1417
|
+
error,
|
|
1418
|
+
thread_id=message.thread_id,
|
|
1419
|
+
reply_to=message.message_id,
|
|
1420
|
+
)
|
|
1421
|
+
return
|
|
1422
|
+
if record is None:
|
|
1423
|
+
record = _select_latest_run(store, lambda run: run.status.is_active())
|
|
1424
|
+
if record is None:
|
|
1425
|
+
await self._send_message(
|
|
1426
|
+
message.chat_id,
|
|
1427
|
+
"No active ticket flow run found.",
|
|
1428
|
+
thread_id=message.thread_id,
|
|
1429
|
+
reply_to=message.message_id,
|
|
1430
|
+
)
|
|
1431
|
+
return
|
|
1432
|
+
if record.status.is_terminal():
|
|
1433
|
+
await self._send_message(
|
|
1434
|
+
message.chat_id,
|
|
1435
|
+
f"Run {record.id} is already {record.status.value}.",
|
|
1436
|
+
thread_id=message.thread_id,
|
|
1437
|
+
reply_to=message.message_id,
|
|
1438
|
+
)
|
|
1439
|
+
return
|
|
1440
|
+
finally:
|
|
1441
|
+
store.close()
|
|
1442
|
+
|
|
1443
|
+
controller = _get_ticket_controller(repo_root)
|
|
1444
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
1445
|
+
updated = await controller.stop_flow(record.id)
|
|
1446
|
+
await self._send_message(
|
|
1447
|
+
message.chat_id,
|
|
1448
|
+
f"Stopped run {updated.id} ({updated.status.value}).",
|
|
1449
|
+
thread_id=message.thread_id,
|
|
1450
|
+
reply_to=message.message_id,
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
async def _handle_flow_recover(
|
|
1454
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1455
|
+
) -> None:
|
|
1456
|
+
store = _load_flow_store(repo_root)
|
|
1457
|
+
try:
|
|
1458
|
+
store.initialize()
|
|
1459
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1460
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1461
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1462
|
+
if run_id_raw and error:
|
|
1463
|
+
await self._send_message(
|
|
1464
|
+
message.chat_id,
|
|
1465
|
+
error,
|
|
1466
|
+
thread_id=message.thread_id,
|
|
1467
|
+
reply_to=message.message_id,
|
|
1468
|
+
)
|
|
1469
|
+
return
|
|
1470
|
+
if record is None:
|
|
1471
|
+
record = _select_latest_run(store, lambda run: run.status.is_active())
|
|
1472
|
+
if record is None:
|
|
1473
|
+
await self._send_message(
|
|
1474
|
+
message.chat_id,
|
|
1475
|
+
"No active ticket flow run found.",
|
|
1476
|
+
thread_id=message.thread_id,
|
|
1477
|
+
reply_to=message.message_id,
|
|
1478
|
+
)
|
|
1479
|
+
return
|
|
1480
|
+
record, updated, locked = reconcile_flow_run(repo_root, record, store)
|
|
1481
|
+
if locked:
|
|
1482
|
+
await self._send_message(
|
|
1483
|
+
message.chat_id,
|
|
1484
|
+
f"Run {record.id} is locked for reconcile; try again.",
|
|
1485
|
+
thread_id=message.thread_id,
|
|
1486
|
+
reply_to=message.message_id,
|
|
1487
|
+
)
|
|
1488
|
+
return
|
|
1489
|
+
hint = "Recovered" if updated else "No changes needed"
|
|
1490
|
+
lines = [f"{hint} for run {record.id}."]
|
|
1491
|
+
lines.extend(self._format_flow_status_lines(repo_root, record, store))
|
|
1492
|
+
finally:
|
|
1493
|
+
store.close()
|
|
1494
|
+
|
|
1495
|
+
await self._send_message(
|
|
1496
|
+
message.chat_id,
|
|
1497
|
+
"\n".join(lines),
|
|
1498
|
+
thread_id=message.thread_id,
|
|
1499
|
+
reply_to=message.message_id,
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
async def _handle_flow_restart(
|
|
1503
|
+
self,
|
|
1504
|
+
message: TelegramMessage,
|
|
1505
|
+
repo_root: Path,
|
|
1506
|
+
argv: Optional[list[str]] = None,
|
|
1507
|
+
) -> None:
|
|
1508
|
+
argv = argv or []
|
|
1509
|
+
store = _load_flow_store(repo_root)
|
|
1510
|
+
record = None
|
|
1511
|
+
try:
|
|
1512
|
+
store.initialize()
|
|
1513
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1514
|
+
if run_id_raw:
|
|
1515
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1516
|
+
if error is None and run_id:
|
|
1517
|
+
record = store.get_flow_run(run_id)
|
|
1518
|
+
else:
|
|
1519
|
+
record = _select_latest_run(store, lambda run: run.status.is_active())
|
|
1520
|
+
finally:
|
|
1521
|
+
store.close()
|
|
1522
|
+
if record and not record.status.is_terminal():
|
|
1523
|
+
controller = _get_ticket_controller(repo_root)
|
|
1524
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
1525
|
+
await controller.stop_flow(record.id)
|
|
1526
|
+
await self._handle_flow_bootstrap(message, repo_root, argv=["--force-new"])
|
|
1527
|
+
|
|
1528
|
+
async def _handle_flow_archive(
|
|
1529
|
+
self, message: TelegramMessage, repo_root: Path, argv: list[str]
|
|
1530
|
+
) -> None:
|
|
1531
|
+
force = self._has_flag(argv, "--force")
|
|
1532
|
+
store = _load_flow_store(repo_root)
|
|
1533
|
+
record = None
|
|
1534
|
+
try:
|
|
1535
|
+
store.initialize()
|
|
1536
|
+
run_id_raw = self._first_non_flag(argv)
|
|
1537
|
+
run_id, error = self._resolve_run_id_input(store, run_id_raw)
|
|
1538
|
+
record = store.get_flow_run(run_id) if run_id else None
|
|
1539
|
+
if run_id_raw and error:
|
|
1540
|
+
await self._send_message(
|
|
1541
|
+
message.chat_id,
|
|
1542
|
+
error,
|
|
1543
|
+
thread_id=message.thread_id,
|
|
1544
|
+
reply_to=message.message_id,
|
|
1545
|
+
)
|
|
1546
|
+
return
|
|
1547
|
+
if record is None:
|
|
1548
|
+
record = _select_latest_run(
|
|
1549
|
+
store,
|
|
1550
|
+
lambda run: run.status.is_terminal()
|
|
1551
|
+
or run.status == FlowRunStatus.PAUSED
|
|
1552
|
+
or (force and run.status == FlowRunStatus.STOPPING),
|
|
1553
|
+
)
|
|
1554
|
+
if record is None:
|
|
1555
|
+
await self._send_message(
|
|
1556
|
+
message.chat_id,
|
|
1557
|
+
"No paused or terminal ticket flow run found.",
|
|
1558
|
+
thread_id=message.thread_id,
|
|
1559
|
+
reply_to=message.message_id,
|
|
1560
|
+
)
|
|
1561
|
+
return
|
|
1562
|
+
if not record.status.is_terminal():
|
|
1563
|
+
if record.status in (FlowRunStatus.STOPPING, FlowRunStatus.PAUSED):
|
|
1564
|
+
self._stop_flow_worker(repo_root, record.id)
|
|
1565
|
+
else:
|
|
1566
|
+
await self._send_message(
|
|
1567
|
+
message.chat_id,
|
|
1568
|
+
"Can only archive completed/stopped/failed runs (use --force for stuck flows).",
|
|
1569
|
+
thread_id=message.thread_id,
|
|
1570
|
+
reply_to=message.message_id,
|
|
1571
|
+
)
|
|
1572
|
+
return
|
|
1573
|
+
finally:
|
|
1574
|
+
store.close()
|
|
1575
|
+
|
|
1576
|
+
_, artifacts_root = _flow_paths(repo_root)
|
|
1577
|
+
archive_dir = artifacts_root / record.id / "archived_tickets"
|
|
1578
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
1579
|
+
ticket_dir = _ticket_dir(repo_root)
|
|
1580
|
+
archived_count = 0
|
|
1581
|
+
for ticket_path in list_ticket_paths(ticket_dir):
|
|
1582
|
+
dest = archive_dir / ticket_path.name
|
|
1583
|
+
shutil.move(str(ticket_path), str(dest))
|
|
1584
|
+
archived_count += 1
|
|
1585
|
+
|
|
1586
|
+
runs_dir = Path(record.input_data.get("runs_dir") or ".codex-autorunner/runs")
|
|
1587
|
+
outbox_paths = resolve_outbox_paths(
|
|
1588
|
+
workspace_root=repo_root, runs_dir=runs_dir, run_id=record.id
|
|
1589
|
+
)
|
|
1590
|
+
run_dir = outbox_paths.run_dir
|
|
1591
|
+
if run_dir.exists() and run_dir.is_dir():
|
|
1592
|
+
archived_runs_dir = artifacts_root / record.id / "archived_runs"
|
|
1593
|
+
shutil.move(str(run_dir), str(archived_runs_dir))
|
|
1594
|
+
|
|
1595
|
+
store = _load_flow_store(repo_root)
|
|
1596
|
+
try:
|
|
1597
|
+
store.initialize()
|
|
1598
|
+
store.delete_flow_run(record.id)
|
|
1599
|
+
finally:
|
|
1600
|
+
store.close()
|
|
1601
|
+
|
|
1602
|
+
await self._send_message(
|
|
1603
|
+
message.chat_id,
|
|
1604
|
+
f"Archived run {record.id} ({archived_count} tickets).",
|
|
180
1605
|
thread_id=message.thread_id,
|
|
181
1606
|
reply_to=message.message_id,
|
|
182
1607
|
)
|
|
@@ -198,7 +1623,7 @@ Create SPEC.md and additional tickets under .codex-autorunner/tickets/. Then wri
|
|
|
198
1623
|
if not text:
|
|
199
1624
|
await self._send_message(
|
|
200
1625
|
message.chat_id,
|
|
201
|
-
"Provide a reply:
|
|
1626
|
+
"Provide a reply: /flow reply <message> (or /reply <message>).",
|
|
202
1627
|
thread_id=message.thread_id,
|
|
203
1628
|
reply_to=message.message_id,
|
|
204
1629
|
)
|