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,881 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from ..core.flows.models import FlowEventType
|
|
8
|
+
from ..core.git_utils import git_diff_stats, run_git
|
|
9
|
+
from ..workspace.paths import workspace_doc_path
|
|
10
|
+
from .agent_pool import AgentPool, AgentTurnRequest
|
|
11
|
+
from .files import list_ticket_paths, read_ticket, safe_relpath, ticket_is_done
|
|
12
|
+
from .frontmatter import parse_markdown_frontmatter
|
|
13
|
+
from .lint import lint_ticket_frontmatter
|
|
14
|
+
from .models import TicketFrontmatter, TicketResult, TicketRunConfig
|
|
15
|
+
from .outbox import (
|
|
16
|
+
archive_dispatch,
|
|
17
|
+
create_turn_summary,
|
|
18
|
+
ensure_outbox_dirs,
|
|
19
|
+
resolve_outbox_paths,
|
|
20
|
+
)
|
|
21
|
+
from .replies import ensure_reply_dirs, parse_user_reply, resolve_reply_paths
|
|
22
|
+
|
|
23
|
+
_logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
WORKSPACE_DOC_MAX_CHARS = 4000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TicketRunner:
|
|
29
|
+
"""Execute a ticket directory one agent turn at a time.
|
|
30
|
+
|
|
31
|
+
This runner is intentionally small and file-backed:
|
|
32
|
+
- Tickets are markdown files under `config.ticket_dir`.
|
|
33
|
+
- User messages + optional attachments are written to an outbox under `config.runs_dir`.
|
|
34
|
+
- The orchestrator is stateless aside from the `state` dict passed into step().
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
workspace_root: Path,
|
|
41
|
+
run_id: str,
|
|
42
|
+
config: TicketRunConfig,
|
|
43
|
+
agent_pool: AgentPool,
|
|
44
|
+
):
|
|
45
|
+
self._workspace_root = workspace_root
|
|
46
|
+
self._run_id = run_id
|
|
47
|
+
self._config = config
|
|
48
|
+
self._agent_pool = agent_pool
|
|
49
|
+
|
|
50
|
+
async def step(
|
|
51
|
+
self,
|
|
52
|
+
state: dict[str, Any],
|
|
53
|
+
*,
|
|
54
|
+
emit_event: Optional[Callable[[FlowEventType, dict[str, Any]], None]] = None,
|
|
55
|
+
) -> TicketResult:
|
|
56
|
+
"""Execute exactly one orchestration step.
|
|
57
|
+
|
|
58
|
+
A step is either:
|
|
59
|
+
- run one agent turn for the current ticket, or
|
|
60
|
+
- pause because prerequisites are missing, or
|
|
61
|
+
- mark the whole run completed (no remaining tickets).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
state = dict(state or {})
|
|
65
|
+
# Clear transient reason from previous pause/resume cycles.
|
|
66
|
+
state.pop("reason", None)
|
|
67
|
+
|
|
68
|
+
_commit_raw = state.get("commit")
|
|
69
|
+
commit_state: dict[str, Any] = (
|
|
70
|
+
_commit_raw if isinstance(_commit_raw, dict) else {}
|
|
71
|
+
)
|
|
72
|
+
commit_pending = bool(commit_state.get("pending"))
|
|
73
|
+
commit_retries = int(commit_state.get("retries") or 0)
|
|
74
|
+
# Global counters.
|
|
75
|
+
total_turns = int(state.get("total_turns") or 0)
|
|
76
|
+
if total_turns >= self._config.max_total_turns:
|
|
77
|
+
return self._pause(
|
|
78
|
+
state,
|
|
79
|
+
reason=f"Max turns reached ({self._config.max_total_turns}). Review tickets and resume.",
|
|
80
|
+
reason_code="needs_user_fix",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
ticket_dir = self._workspace_root / self._config.ticket_dir
|
|
84
|
+
runs_dir = self._config.runs_dir
|
|
85
|
+
|
|
86
|
+
# Ensure outbox dirs exist.
|
|
87
|
+
outbox_paths = resolve_outbox_paths(
|
|
88
|
+
workspace_root=self._workspace_root,
|
|
89
|
+
runs_dir=runs_dir,
|
|
90
|
+
run_id=self._run_id,
|
|
91
|
+
)
|
|
92
|
+
ensure_outbox_dirs(outbox_paths)
|
|
93
|
+
|
|
94
|
+
# Ensure reply inbox dirs exist (human -> agent messages).
|
|
95
|
+
reply_paths = resolve_reply_paths(
|
|
96
|
+
workspace_root=self._workspace_root,
|
|
97
|
+
runs_dir=runs_dir,
|
|
98
|
+
run_id=self._run_id,
|
|
99
|
+
)
|
|
100
|
+
ensure_reply_dirs(reply_paths)
|
|
101
|
+
|
|
102
|
+
ticket_paths = list_ticket_paths(ticket_dir)
|
|
103
|
+
if not ticket_paths:
|
|
104
|
+
return self._pause(
|
|
105
|
+
state,
|
|
106
|
+
reason=(
|
|
107
|
+
"No tickets found. Create tickets under "
|
|
108
|
+
f"{safe_relpath(ticket_dir, self._workspace_root)} and resume."
|
|
109
|
+
),
|
|
110
|
+
reason_code="no_tickets",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
current_ticket = state.get("current_ticket")
|
|
114
|
+
current_path: Optional[Path] = (
|
|
115
|
+
(self._workspace_root / current_ticket)
|
|
116
|
+
if isinstance(current_ticket, str) and current_ticket
|
|
117
|
+
else None
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# If current ticket is done, clear it unless we're in the middle of a
|
|
121
|
+
# bounded "commit required" follow-up loop.
|
|
122
|
+
if current_path and ticket_is_done(current_path) and not commit_pending:
|
|
123
|
+
current_path = None
|
|
124
|
+
state.pop("current_ticket", None)
|
|
125
|
+
state.pop("ticket_turns", None)
|
|
126
|
+
state.pop("last_agent_output", None)
|
|
127
|
+
state.pop("lint", None)
|
|
128
|
+
state.pop("commit", None)
|
|
129
|
+
|
|
130
|
+
if current_path is None:
|
|
131
|
+
next_path = self._find_next_ticket(ticket_paths)
|
|
132
|
+
if next_path is None:
|
|
133
|
+
state["status"] = "completed"
|
|
134
|
+
return TicketResult(
|
|
135
|
+
status="completed", state=state, reason="All tickets done."
|
|
136
|
+
)
|
|
137
|
+
current_path = next_path
|
|
138
|
+
state["current_ticket"] = safe_relpath(current_path, self._workspace_root)
|
|
139
|
+
# Inform listeners immediately which ticket is about to run so the UI
|
|
140
|
+
# can show the active indicator before the first turn completes.
|
|
141
|
+
if emit_event is not None:
|
|
142
|
+
emit_event(
|
|
143
|
+
FlowEventType.STEP_PROGRESS,
|
|
144
|
+
{
|
|
145
|
+
"message": "Selected ticket",
|
|
146
|
+
"current_ticket": state["current_ticket"],
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
# New ticket resets per-ticket state.
|
|
150
|
+
state["ticket_turns"] = 0
|
|
151
|
+
state.pop("last_agent_output", None)
|
|
152
|
+
state.pop("lint", None)
|
|
153
|
+
state.pop("commit", None)
|
|
154
|
+
|
|
155
|
+
# Determine lint-retry mode early. When lint state is present, we allow the
|
|
156
|
+
# agent to fix the ticket frontmatter even if the ticket is currently
|
|
157
|
+
# unparsable by the strict lint rules.
|
|
158
|
+
if state.get("status") == "paused":
|
|
159
|
+
# Clear stale pause markers so upgraded logic can proceed without manual DB edits.
|
|
160
|
+
state["status"] = "running"
|
|
161
|
+
state.pop("reason", None)
|
|
162
|
+
state.pop("reason_details", None)
|
|
163
|
+
state.pop("reason_code", None)
|
|
164
|
+
|
|
165
|
+
_lint_raw = state.get("lint")
|
|
166
|
+
lint_state: dict[str, Any] = _lint_raw if isinstance(_lint_raw, dict) else {}
|
|
167
|
+
_lint_errors_raw = lint_state.get("errors")
|
|
168
|
+
lint_errors: list[str] = (
|
|
169
|
+
_lint_errors_raw if isinstance(_lint_errors_raw, list) else []
|
|
170
|
+
)
|
|
171
|
+
lint_retries = int(lint_state.get("retries") or 0)
|
|
172
|
+
_conv_id_raw = lint_state.get("conversation_id")
|
|
173
|
+
reuse_conversation_id: Optional[str] = (
|
|
174
|
+
_conv_id_raw if isinstance(_conv_id_raw, str) else None
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Read ticket (may lint-fail). In lint-retry mode, fall back to a relaxed
|
|
178
|
+
# frontmatter parse so we can still execute an agent turn to repair the file.
|
|
179
|
+
ticket_doc = None
|
|
180
|
+
ticket_errors: list[str] = []
|
|
181
|
+
if lint_errors:
|
|
182
|
+
try:
|
|
183
|
+
raw = current_path.read_text(encoding="utf-8")
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
return self._pause(
|
|
186
|
+
state,
|
|
187
|
+
reason=(
|
|
188
|
+
"Ticket unreadable during lint retry for "
|
|
189
|
+
f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
|
|
190
|
+
),
|
|
191
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
192
|
+
reason_code="infra_error",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
data, _ = parse_markdown_frontmatter(raw)
|
|
196
|
+
agent = data.get("agent")
|
|
197
|
+
agent_id = agent.strip() if isinstance(agent, str) else None
|
|
198
|
+
if not agent_id:
|
|
199
|
+
return self._pause(
|
|
200
|
+
state,
|
|
201
|
+
reason=(
|
|
202
|
+
"Cannot determine ticket agent during lint retry (missing frontmatter.agent). "
|
|
203
|
+
"Fix the ticket frontmatter manually and resume."
|
|
204
|
+
),
|
|
205
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
206
|
+
reason_code="needs_user_fix",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Validate agent id unless it is the special user sentinel.
|
|
210
|
+
if agent_id != "user":
|
|
211
|
+
try:
|
|
212
|
+
from ..agents.registry import validate_agent_id
|
|
213
|
+
|
|
214
|
+
agent_id = validate_agent_id(agent_id)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
return self._pause(
|
|
217
|
+
state,
|
|
218
|
+
reason=(
|
|
219
|
+
"Cannot determine valid agent during lint retry for "
|
|
220
|
+
f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
|
|
221
|
+
),
|
|
222
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
223
|
+
reason_code="needs_user_fix",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
ticket_doc = type(
|
|
227
|
+
"_TicketDocForLintRetry",
|
|
228
|
+
(),
|
|
229
|
+
{
|
|
230
|
+
"frontmatter": TicketFrontmatter(
|
|
231
|
+
agent=agent_id,
|
|
232
|
+
done=False,
|
|
233
|
+
)
|
|
234
|
+
},
|
|
235
|
+
)()
|
|
236
|
+
else:
|
|
237
|
+
ticket_doc, ticket_errors = read_ticket(current_path)
|
|
238
|
+
if ticket_errors or ticket_doc is None:
|
|
239
|
+
return self._pause(
|
|
240
|
+
state,
|
|
241
|
+
reason=f"Ticket frontmatter invalid: {safe_relpath(current_path, self._workspace_root)}",
|
|
242
|
+
reason_details="Errors:\n- " + "\n- ".join(ticket_errors),
|
|
243
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
244
|
+
reason_code="needs_user_fix",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Built-in manual user ticket.
|
|
248
|
+
if ticket_doc.frontmatter.agent == "user":
|
|
249
|
+
if ticket_doc.frontmatter.done:
|
|
250
|
+
# Nothing to do, will advance next step.
|
|
251
|
+
return TicketResult(status="continue", state=state)
|
|
252
|
+
return self._pause(
|
|
253
|
+
state,
|
|
254
|
+
reason=(
|
|
255
|
+
"Paused for user input. Mark ticket as done when ready: "
|
|
256
|
+
f"{safe_relpath(current_path, self._workspace_root)}"
|
|
257
|
+
),
|
|
258
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
259
|
+
reason_code="user_pause",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
ticket_turns = int(state.get("ticket_turns") or 0)
|
|
263
|
+
reply_seq = int(state.get("reply_seq") or 0)
|
|
264
|
+
reply_context, reply_max_seq = self._build_reply_context(
|
|
265
|
+
reply_paths=reply_paths, last_seq=reply_seq
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
previous_ticket_content: Optional[str] = None
|
|
269
|
+
try:
|
|
270
|
+
if current_path in ticket_paths:
|
|
271
|
+
curr_idx = ticket_paths.index(current_path)
|
|
272
|
+
if curr_idx > 0:
|
|
273
|
+
prev_path = ticket_paths[curr_idx - 1]
|
|
274
|
+
previous_ticket_content = prev_path.read_text(encoding="utf-8")
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
prompt = self._build_prompt(
|
|
279
|
+
ticket_path=current_path,
|
|
280
|
+
ticket_doc=ticket_doc,
|
|
281
|
+
last_agent_output=(
|
|
282
|
+
state.get("last_agent_output")
|
|
283
|
+
if isinstance(state.get("last_agent_output"), str)
|
|
284
|
+
else None
|
|
285
|
+
),
|
|
286
|
+
last_checkpoint_error=(
|
|
287
|
+
state.get("last_checkpoint_error")
|
|
288
|
+
if isinstance(state.get("last_checkpoint_error"), str)
|
|
289
|
+
else None
|
|
290
|
+
),
|
|
291
|
+
commit_required=commit_pending,
|
|
292
|
+
commit_attempt=commit_retries + 1 if commit_pending else 0,
|
|
293
|
+
commit_max_attempts=self._config.max_commit_retries,
|
|
294
|
+
outbox_paths=outbox_paths,
|
|
295
|
+
lint_errors=lint_errors if lint_errors else None,
|
|
296
|
+
reply_context=reply_context,
|
|
297
|
+
previous_ticket_content=previous_ticket_content,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Execute turn.
|
|
301
|
+
# Build options dict with model/reasoning from ticket frontmatter if set.
|
|
302
|
+
turn_options: dict[str, Any] = {}
|
|
303
|
+
if ticket_doc.frontmatter.model:
|
|
304
|
+
turn_options["model"] = ticket_doc.frontmatter.model
|
|
305
|
+
if ticket_doc.frontmatter.reasoning:
|
|
306
|
+
turn_options["reasoning"] = ticket_doc.frontmatter.reasoning
|
|
307
|
+
req = AgentTurnRequest(
|
|
308
|
+
agent_id=ticket_doc.frontmatter.agent,
|
|
309
|
+
prompt=prompt,
|
|
310
|
+
workspace_root=self._workspace_root,
|
|
311
|
+
conversation_id=reuse_conversation_id,
|
|
312
|
+
emit_event=emit_event,
|
|
313
|
+
options=turn_options if turn_options else None,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
total_turns += 1
|
|
317
|
+
ticket_turns += 1
|
|
318
|
+
state["total_turns"] = total_turns
|
|
319
|
+
state["ticket_turns"] = ticket_turns
|
|
320
|
+
|
|
321
|
+
head_before_turn: Optional[str] = None
|
|
322
|
+
try:
|
|
323
|
+
head_proc = run_git(
|
|
324
|
+
["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
|
|
325
|
+
)
|
|
326
|
+
head_before_turn = (head_proc.stdout or "").strip() or None
|
|
327
|
+
except Exception:
|
|
328
|
+
head_before_turn = None
|
|
329
|
+
|
|
330
|
+
result = await self._agent_pool.run_turn(req)
|
|
331
|
+
if result.error:
|
|
332
|
+
state["last_agent_output"] = result.text
|
|
333
|
+
state["last_agent_id"] = result.agent_id
|
|
334
|
+
state["last_agent_conversation_id"] = result.conversation_id
|
|
335
|
+
state["last_agent_turn_id"] = result.turn_id
|
|
336
|
+
return self._pause(
|
|
337
|
+
state,
|
|
338
|
+
reason="Agent turn failed. Fix the issue and resume.",
|
|
339
|
+
reason_details=f"Error: {result.error}",
|
|
340
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
341
|
+
reason_code="infra_error",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Mark replies as consumed only after a successful agent turn.
|
|
345
|
+
if reply_max_seq > reply_seq:
|
|
346
|
+
state["reply_seq"] = reply_max_seq
|
|
347
|
+
state["last_agent_output"] = result.text
|
|
348
|
+
state["last_agent_id"] = result.agent_id
|
|
349
|
+
state["last_agent_conversation_id"] = result.conversation_id
|
|
350
|
+
state["last_agent_turn_id"] = result.turn_id
|
|
351
|
+
|
|
352
|
+
# Best-effort: check whether the agent created a commit and whether the
|
|
353
|
+
# working tree is clean, before any runner-driven checkpoint commit.
|
|
354
|
+
head_after_agent: Optional[str] = None
|
|
355
|
+
clean_after_agent: Optional[bool] = None
|
|
356
|
+
status_after_agent: Optional[str] = None
|
|
357
|
+
agent_committed_this_turn: Optional[bool] = None
|
|
358
|
+
try:
|
|
359
|
+
head_proc = run_git(
|
|
360
|
+
["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
|
|
361
|
+
)
|
|
362
|
+
head_after_agent = (head_proc.stdout or "").strip() or None
|
|
363
|
+
status_proc = run_git(
|
|
364
|
+
["status", "--porcelain"], cwd=self._workspace_root, check=True
|
|
365
|
+
)
|
|
366
|
+
status_after_agent = (status_proc.stdout or "").strip()
|
|
367
|
+
clean_after_agent = not bool(status_after_agent)
|
|
368
|
+
if head_before_turn and head_after_agent:
|
|
369
|
+
agent_committed_this_turn = head_after_agent != head_before_turn
|
|
370
|
+
except Exception:
|
|
371
|
+
head_after_agent = None
|
|
372
|
+
clean_after_agent = None
|
|
373
|
+
status_after_agent = None
|
|
374
|
+
agent_committed_this_turn = None
|
|
375
|
+
|
|
376
|
+
# Post-turn: archive outbox if DISPATCH.md exists.
|
|
377
|
+
dispatch_seq = int(state.get("dispatch_seq") or 0)
|
|
378
|
+
current_ticket_id = safe_relpath(current_path, self._workspace_root)
|
|
379
|
+
dispatch, dispatch_errors = archive_dispatch(
|
|
380
|
+
outbox_paths, next_seq=dispatch_seq + 1, ticket_id=current_ticket_id
|
|
381
|
+
)
|
|
382
|
+
if dispatch_errors:
|
|
383
|
+
# Treat as pause: user should fix DISPATCH.md frontmatter. Keep outbox
|
|
384
|
+
# lint separate from ticket frontmatter lint to avoid mixing behaviors.
|
|
385
|
+
state["outbox_lint"] = dispatch_errors
|
|
386
|
+
return self._pause(
|
|
387
|
+
state,
|
|
388
|
+
reason="Invalid DISPATCH.md frontmatter.",
|
|
389
|
+
reason_details="Errors:\n- " + "\n- ".join(dispatch_errors),
|
|
390
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
391
|
+
reason_code="needs_user_fix",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if dispatch is not None:
|
|
395
|
+
state["dispatch_seq"] = dispatch.seq
|
|
396
|
+
state.pop("outbox_lint", None)
|
|
397
|
+
|
|
398
|
+
# Create turn summary record for the agent's final output.
|
|
399
|
+
# This appears in dispatch history as a distinct "turn summary" entry.
|
|
400
|
+
turn_summary_seq = int(state.get("dispatch_seq") or 0) + 1
|
|
401
|
+
|
|
402
|
+
# Compute diff stats for this turn (changes since head_before_turn).
|
|
403
|
+
# This captures both committed and uncommitted changes made by the agent.
|
|
404
|
+
turn_diff_stats = None
|
|
405
|
+
try:
|
|
406
|
+
if head_before_turn:
|
|
407
|
+
# Compare current state (HEAD + working tree) against pre-turn commit
|
|
408
|
+
turn_diff_stats = git_diff_stats(
|
|
409
|
+
self._workspace_root, from_ref=head_before_turn
|
|
410
|
+
)
|
|
411
|
+
else:
|
|
412
|
+
# No reference commit; show all uncommitted changes
|
|
413
|
+
turn_diff_stats = git_diff_stats(
|
|
414
|
+
self._workspace_root, from_ref=None, include_staged=True
|
|
415
|
+
)
|
|
416
|
+
except Exception:
|
|
417
|
+
# Best-effort; don't block on stats computation errors
|
|
418
|
+
turn_diff_stats = None
|
|
419
|
+
|
|
420
|
+
turn_summary, turn_summary_errors = create_turn_summary(
|
|
421
|
+
outbox_paths,
|
|
422
|
+
next_seq=turn_summary_seq,
|
|
423
|
+
agent_output=result.text or "",
|
|
424
|
+
ticket_id=current_ticket_id,
|
|
425
|
+
agent_id=result.agent_id,
|
|
426
|
+
turn_number=total_turns,
|
|
427
|
+
diff_stats=turn_diff_stats,
|
|
428
|
+
)
|
|
429
|
+
if turn_summary is not None:
|
|
430
|
+
state["dispatch_seq"] = turn_summary.seq
|
|
431
|
+
|
|
432
|
+
# Post-turn: ticket frontmatter must remain valid.
|
|
433
|
+
updated_fm, fm_errors = self._recheck_ticket_frontmatter(current_path)
|
|
434
|
+
if fm_errors:
|
|
435
|
+
lint_retries += 1
|
|
436
|
+
if lint_retries > self._config.max_lint_retries:
|
|
437
|
+
return self._pause(
|
|
438
|
+
state,
|
|
439
|
+
reason="Ticket frontmatter invalid. Manual fix required.",
|
|
440
|
+
reason_details=(
|
|
441
|
+
"Exceeded lint retry limit. Fix the ticket frontmatter manually and resume.\n\n"
|
|
442
|
+
"Errors:\n- " + "\n- ".join(fm_errors)
|
|
443
|
+
),
|
|
444
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
445
|
+
reason_code="needs_user_fix",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
state["lint"] = {
|
|
449
|
+
"errors": fm_errors,
|
|
450
|
+
"retries": lint_retries,
|
|
451
|
+
"conversation_id": result.conversation_id,
|
|
452
|
+
}
|
|
453
|
+
return TicketResult(
|
|
454
|
+
status="continue",
|
|
455
|
+
state=state,
|
|
456
|
+
reason="Ticket frontmatter invalid; requesting agent fix.",
|
|
457
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
458
|
+
agent_output=result.text,
|
|
459
|
+
agent_id=result.agent_id,
|
|
460
|
+
agent_conversation_id=result.conversation_id,
|
|
461
|
+
agent_turn_id=result.turn_id,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Clear lint state if previously set.
|
|
465
|
+
if state.get("lint"):
|
|
466
|
+
state.pop("lint", None)
|
|
467
|
+
|
|
468
|
+
# Optional: auto-commit checkpoint (best-effort).
|
|
469
|
+
checkpoint_error = None
|
|
470
|
+
commit_required_now = bool(
|
|
471
|
+
updated_fm and updated_fm.done and clean_after_agent is False
|
|
472
|
+
)
|
|
473
|
+
if self._config.auto_commit and not commit_pending and not commit_required_now:
|
|
474
|
+
checkpoint_error = self._checkpoint_git(
|
|
475
|
+
turn=total_turns, agent=result.agent_id
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# If we dispatched a pause message, pause regardless of ticket completion.
|
|
479
|
+
if dispatch is not None and dispatch.dispatch.mode == "pause":
|
|
480
|
+
reason = dispatch.dispatch.title or "Paused for user input."
|
|
481
|
+
if checkpoint_error:
|
|
482
|
+
reason += f"\n\nNote: checkpoint commit failed: {checkpoint_error}"
|
|
483
|
+
state["status"] = "paused"
|
|
484
|
+
state["reason"] = reason
|
|
485
|
+
state["reason_code"] = "user_pause"
|
|
486
|
+
return TicketResult(
|
|
487
|
+
status="paused",
|
|
488
|
+
state=state,
|
|
489
|
+
reason=reason,
|
|
490
|
+
dispatch=dispatch,
|
|
491
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
492
|
+
agent_output=result.text,
|
|
493
|
+
agent_id=result.agent_id,
|
|
494
|
+
agent_conversation_id=result.conversation_id,
|
|
495
|
+
agent_turn_id=result.turn_id,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# If ticket is marked done, require a clean working tree (i.e., changes
|
|
499
|
+
# committed) before advancing. This is bounded by max_commit_retries.
|
|
500
|
+
if updated_fm and updated_fm.done:
|
|
501
|
+
if clean_after_agent is False:
|
|
502
|
+
# Enter or continue bounded commit loop.
|
|
503
|
+
if commit_pending:
|
|
504
|
+
# A "commit required" turn just ran and did not succeed.
|
|
505
|
+
next_failed_attempts = commit_retries + 1
|
|
506
|
+
else:
|
|
507
|
+
# Ticket just transitioned to done, but repo is still dirty.
|
|
508
|
+
next_failed_attempts = 0
|
|
509
|
+
|
|
510
|
+
state["commit"] = {
|
|
511
|
+
"pending": True,
|
|
512
|
+
"retries": next_failed_attempts,
|
|
513
|
+
"head_before": head_before_turn,
|
|
514
|
+
"head_after": head_after_agent,
|
|
515
|
+
"agent_committed_this_turn": agent_committed_this_turn,
|
|
516
|
+
"status_porcelain": status_after_agent,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (
|
|
520
|
+
commit_pending
|
|
521
|
+
and next_failed_attempts >= self._config.max_commit_retries
|
|
522
|
+
):
|
|
523
|
+
detail = (status_after_agent or "").strip()
|
|
524
|
+
detail_lines = detail.splitlines()[:20]
|
|
525
|
+
details_parts = [
|
|
526
|
+
"Please commit manually (ensuring pre-commit hooks pass) and resume."
|
|
527
|
+
]
|
|
528
|
+
if detail_lines:
|
|
529
|
+
details_parts.append(
|
|
530
|
+
"\n\nWorking tree status (git status --porcelain):\n- "
|
|
531
|
+
+ "\n- ".join(detail_lines)
|
|
532
|
+
)
|
|
533
|
+
return self._pause(
|
|
534
|
+
state,
|
|
535
|
+
reason=(
|
|
536
|
+
f"Commit failed after {self._config.max_commit_retries} attempts. "
|
|
537
|
+
"Manual commit required."
|
|
538
|
+
),
|
|
539
|
+
reason_details="".join(details_parts),
|
|
540
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
541
|
+
reason_code="needs_user_fix",
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
return TicketResult(
|
|
545
|
+
status="continue",
|
|
546
|
+
state=state,
|
|
547
|
+
reason="Ticket done but commit required; requesting agent commit.",
|
|
548
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
549
|
+
agent_output=result.text,
|
|
550
|
+
agent_id=result.agent_id,
|
|
551
|
+
agent_conversation_id=result.conversation_id,
|
|
552
|
+
agent_turn_id=result.turn_id,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Clean (or unknown) → commit satisfied (or no changes / cannot check).
|
|
556
|
+
state.pop("commit", None)
|
|
557
|
+
state.pop("current_ticket", None)
|
|
558
|
+
state.pop("ticket_turns", None)
|
|
559
|
+
state.pop("last_agent_output", None)
|
|
560
|
+
state.pop("lint", None)
|
|
561
|
+
else:
|
|
562
|
+
# If the ticket is no longer done, clear any pending commit gating.
|
|
563
|
+
state.pop("commit", None)
|
|
564
|
+
|
|
565
|
+
if checkpoint_error:
|
|
566
|
+
# Non-fatal, but surface in state for UI.
|
|
567
|
+
state["last_checkpoint_error"] = checkpoint_error
|
|
568
|
+
else:
|
|
569
|
+
state.pop("last_checkpoint_error", None)
|
|
570
|
+
|
|
571
|
+
return TicketResult(
|
|
572
|
+
status="continue",
|
|
573
|
+
state=state,
|
|
574
|
+
reason="Turn complete.",
|
|
575
|
+
dispatch=dispatch,
|
|
576
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
577
|
+
agent_output=result.text,
|
|
578
|
+
agent_id=result.agent_id,
|
|
579
|
+
agent_conversation_id=result.conversation_id,
|
|
580
|
+
agent_turn_id=result.turn_id,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
def _find_next_ticket(self, ticket_paths: list[Path]) -> Optional[Path]:
|
|
584
|
+
for path in ticket_paths:
|
|
585
|
+
if ticket_is_done(path):
|
|
586
|
+
continue
|
|
587
|
+
return path
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
def _recheck_ticket_frontmatter(self, ticket_path: Path):
|
|
591
|
+
try:
|
|
592
|
+
raw = ticket_path.read_text(encoding="utf-8")
|
|
593
|
+
except OSError as exc:
|
|
594
|
+
return None, [f"Failed to read ticket after turn: {exc}"]
|
|
595
|
+
from .frontmatter import parse_markdown_frontmatter
|
|
596
|
+
|
|
597
|
+
data, _ = parse_markdown_frontmatter(raw)
|
|
598
|
+
fm, errors = lint_ticket_frontmatter(data)
|
|
599
|
+
return fm, errors
|
|
600
|
+
|
|
601
|
+
def _checkpoint_git(self, *, turn: int, agent: str) -> Optional[str]:
|
|
602
|
+
"""Create a best-effort git commit checkpoint.
|
|
603
|
+
|
|
604
|
+
Returns an error string if the checkpoint failed, else None.
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
status_proc = run_git(
|
|
609
|
+
["status", "--porcelain"], cwd=self._workspace_root, check=True
|
|
610
|
+
)
|
|
611
|
+
if not (status_proc.stdout or "").strip():
|
|
612
|
+
return None
|
|
613
|
+
run_git(["add", "-A"], cwd=self._workspace_root, check=True)
|
|
614
|
+
msg = self._config.checkpoint_message_template.format(
|
|
615
|
+
run_id=self._run_id,
|
|
616
|
+
turn=turn,
|
|
617
|
+
agent=agent,
|
|
618
|
+
)
|
|
619
|
+
run_git(["commit", "-m", msg], cwd=self._workspace_root, check=True)
|
|
620
|
+
return None
|
|
621
|
+
except Exception as exc:
|
|
622
|
+
_logger.exception("Checkpoint commit failed")
|
|
623
|
+
return str(exc)
|
|
624
|
+
|
|
625
|
+
def _pause(
|
|
626
|
+
self,
|
|
627
|
+
state: dict[str, Any],
|
|
628
|
+
*,
|
|
629
|
+
reason: str,
|
|
630
|
+
reason_code: str = "needs_user_fix",
|
|
631
|
+
reason_details: Optional[str] = None,
|
|
632
|
+
current_ticket: Optional[str] = None,
|
|
633
|
+
) -> TicketResult:
|
|
634
|
+
state = dict(state)
|
|
635
|
+
state["status"] = "paused"
|
|
636
|
+
state["reason"] = reason
|
|
637
|
+
state["reason_code"] = reason_code
|
|
638
|
+
if reason_details:
|
|
639
|
+
state["reason_details"] = reason_details
|
|
640
|
+
else:
|
|
641
|
+
state.pop("reason_details", None)
|
|
642
|
+
return TicketResult(
|
|
643
|
+
status="paused",
|
|
644
|
+
state=state,
|
|
645
|
+
reason=reason,
|
|
646
|
+
reason_details=reason_details,
|
|
647
|
+
current_ticket=current_ticket
|
|
648
|
+
or (
|
|
649
|
+
state.get("current_ticket")
|
|
650
|
+
if isinstance(state.get("current_ticket"), str)
|
|
651
|
+
else None
|
|
652
|
+
),
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def _build_reply_context(self, *, reply_paths, last_seq: int) -> tuple[str, int]:
|
|
656
|
+
"""Render new human replies (reply_history) into a prompt block.
|
|
657
|
+
|
|
658
|
+
Returns (rendered_text, max_seq_seen).
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
history_dir = getattr(reply_paths, "reply_history_dir", None)
|
|
662
|
+
if history_dir is None:
|
|
663
|
+
return "", last_seq
|
|
664
|
+
if not history_dir.exists() or not history_dir.is_dir():
|
|
665
|
+
return "", last_seq
|
|
666
|
+
|
|
667
|
+
entries: list[tuple[int, Path]] = []
|
|
668
|
+
try:
|
|
669
|
+
for child in history_dir.iterdir():
|
|
670
|
+
try:
|
|
671
|
+
if not child.is_dir():
|
|
672
|
+
continue
|
|
673
|
+
name = child.name
|
|
674
|
+
if not (len(name) == 4 and name.isdigit()):
|
|
675
|
+
continue
|
|
676
|
+
seq = int(name)
|
|
677
|
+
if seq <= last_seq:
|
|
678
|
+
continue
|
|
679
|
+
entries.append((seq, child))
|
|
680
|
+
except OSError:
|
|
681
|
+
continue
|
|
682
|
+
except OSError:
|
|
683
|
+
return "", last_seq
|
|
684
|
+
|
|
685
|
+
if not entries:
|
|
686
|
+
return "", last_seq
|
|
687
|
+
|
|
688
|
+
entries.sort(key=lambda x: x[0])
|
|
689
|
+
max_seq = max(seq for seq, _ in entries)
|
|
690
|
+
|
|
691
|
+
blocks: list[str] = []
|
|
692
|
+
for seq, entry_dir in entries:
|
|
693
|
+
reply_path = entry_dir / "USER_REPLY.md"
|
|
694
|
+
reply, errors = (
|
|
695
|
+
parse_user_reply(reply_path)
|
|
696
|
+
if reply_path.exists()
|
|
697
|
+
else (None, ["USER_REPLY.md missing"])
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
block_lines: list[str] = [f"[USER_REPLY {seq:04d}]"]
|
|
701
|
+
if errors:
|
|
702
|
+
block_lines.append("Errors:\n- " + "\n- ".join(errors))
|
|
703
|
+
if reply is not None:
|
|
704
|
+
if reply.title:
|
|
705
|
+
block_lines.append(f"Title: {reply.title}")
|
|
706
|
+
if reply.body:
|
|
707
|
+
block_lines.append(reply.body)
|
|
708
|
+
|
|
709
|
+
attachments: list[str] = []
|
|
710
|
+
try:
|
|
711
|
+
for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
|
|
712
|
+
try:
|
|
713
|
+
if child.name.startswith("."):
|
|
714
|
+
continue
|
|
715
|
+
if child.name == "USER_REPLY.md":
|
|
716
|
+
continue
|
|
717
|
+
if child.is_dir():
|
|
718
|
+
continue
|
|
719
|
+
attachments.append(safe_relpath(child, self._workspace_root))
|
|
720
|
+
except OSError:
|
|
721
|
+
continue
|
|
722
|
+
except OSError:
|
|
723
|
+
attachments = []
|
|
724
|
+
|
|
725
|
+
if attachments:
|
|
726
|
+
block_lines.append("Attachments:\n- " + "\n- ".join(attachments))
|
|
727
|
+
|
|
728
|
+
blocks.append("\n".join(block_lines).strip())
|
|
729
|
+
|
|
730
|
+
rendered = "\n\n".join(blocks).strip()
|
|
731
|
+
return rendered, max_seq
|
|
732
|
+
|
|
733
|
+
def _build_prompt(
|
|
734
|
+
self,
|
|
735
|
+
*,
|
|
736
|
+
ticket_path: Path,
|
|
737
|
+
ticket_doc,
|
|
738
|
+
last_agent_output: Optional[str],
|
|
739
|
+
last_checkpoint_error: Optional[str] = None,
|
|
740
|
+
commit_required: bool = False,
|
|
741
|
+
commit_attempt: int = 0,
|
|
742
|
+
commit_max_attempts: int = 2,
|
|
743
|
+
outbox_paths,
|
|
744
|
+
lint_errors: Optional[list[str]],
|
|
745
|
+
reply_context: Optional[str] = None,
|
|
746
|
+
previous_ticket_content: Optional[str] = None,
|
|
747
|
+
) -> str:
|
|
748
|
+
rel_ticket = safe_relpath(ticket_path, self._workspace_root)
|
|
749
|
+
rel_dispatch_dir = safe_relpath(outbox_paths.dispatch_dir, self._workspace_root)
|
|
750
|
+
rel_dispatch_path = safe_relpath(
|
|
751
|
+
outbox_paths.dispatch_path, self._workspace_root
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
header = (
|
|
755
|
+
"You are running inside Codex AutoRunner (CAR) in a ticket-based workflow.\n"
|
|
756
|
+
"Complete the current ticket by making changes in the repo.\n\n"
|
|
757
|
+
"How to operate within CAR:\n"
|
|
758
|
+
f"- Current ticket file: {rel_ticket}\n"
|
|
759
|
+
"- Ticket completion is controlled by YAML frontmatter: set 'done: true' when finished.\n"
|
|
760
|
+
"- To message the user, optionally write attachments first to the dispatch directory, then write DISPATCH.md last.\n"
|
|
761
|
+
f" - Dispatch directory: {rel_dispatch_dir}\n"
|
|
762
|
+
f" - DISPATCH.md path: {rel_dispatch_path}\n"
|
|
763
|
+
" DISPATCH.md frontmatter supports: mode: notify|pause (pause will wait for a user response; notify will continue without waiting for user input).\n"
|
|
764
|
+
" Example: `---\\nmode: pause\\n---\\nNeed clarification on X before proceeding.`\n"
|
|
765
|
+
"- No need to dispatch a final notification to the user; your final turn summary is dispatched automatically. Only dispatch if you want something important to stand out to the user, or if you need their input (pause).\n"
|
|
766
|
+
"- If you are completely blocked (missing info, unclear requirements, external dependency), dispatch with mode: pause immediately rather than guessing.\n"
|
|
767
|
+
"- You may create new tickets only if blocking the current SPEC or if the current ticket is too ambiguous and you want to scope it out further. Keep tickets minimal and avoid scope creep.\n"
|
|
768
|
+
"- Avoid stubs, TODOs, or placeholder logic. Either implement fully, create a follow-up ticket, or pause for user input.\n"
|
|
769
|
+
"- Only set 'done: true' when the ticket is truly complete. If partially done, update the ticket body with progress so the next agent can continue.\n"
|
|
770
|
+
"- Each ticket is handled by a new series of agents in a loop, where each new agent gets the context of the previous agent. No context is shared across tickets EXCEPT via the workspace files.\n"
|
|
771
|
+
"- You may update or add new workspace docs and add files under `.codex-autorunner/workspace/` to leave context for future agents.\n"
|
|
772
|
+
"- active_context and spec are ALWAYS passed to each agent and should be considered the most precious context.\n"
|
|
773
|
+
"- decisions.md: can contain conditional decision context that many only be relevant to some tickets.\n"
|
|
774
|
+
"- If you create new documents that future agents should reference, modify their tickets and leave a pointer to your new files.\n"
|
|
775
|
+
"- All files and folders under `.codex-autorunner/workspace/` are viewable and editable by the user. If you need the user's input on something, make sure it's in the workspace including copies of any artifacts they should review.\n"
|
|
776
|
+
"- Do NOT add any files under `.codex-autorunner/` to git unless they are already tracked and not gitignored."
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
checkpoint_block = ""
|
|
780
|
+
if last_checkpoint_error:
|
|
781
|
+
checkpoint_block = (
|
|
782
|
+
"\n\n---\n\n"
|
|
783
|
+
"WARNING: The previous checkpoint git commit failed (often due to pre-commit hooks).\n"
|
|
784
|
+
"Resolve this before proceeding, or future turns may fail to checkpoint.\n\n"
|
|
785
|
+
"Checkpoint error:\n"
|
|
786
|
+
f"{last_checkpoint_error}\n"
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
commit_block = ""
|
|
790
|
+
if commit_required:
|
|
791
|
+
attempts_remaining = max(commit_max_attempts - commit_attempt + 1, 0)
|
|
792
|
+
commit_block = (
|
|
793
|
+
"\n\n---\n\n"
|
|
794
|
+
"ACTION REQUIRED: Commit your changes, ensuring any pre-commit hooks pass.\n"
|
|
795
|
+
"- Use a meaningful commit message that matches what you implemented.\n"
|
|
796
|
+
"- If hooks fail, fix the underlying issues and retry the commit.\n"
|
|
797
|
+
f"- Attempts remaining before user intervention: {attempts_remaining}\n"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
if lint_errors:
|
|
801
|
+
lint_block = (
|
|
802
|
+
"\n\nTicket frontmatter lint failed. Fix ONLY the ticket frontmatter to satisfy:\n- "
|
|
803
|
+
+ "\n- ".join(lint_errors)
|
|
804
|
+
+ "\n"
|
|
805
|
+
)
|
|
806
|
+
else:
|
|
807
|
+
lint_block = ""
|
|
808
|
+
|
|
809
|
+
reply_block = ""
|
|
810
|
+
if reply_context:
|
|
811
|
+
reply_block = (
|
|
812
|
+
"\n\n---\n\nHUMAN REPLIES (from reply_history; newest since last turn):\n"
|
|
813
|
+
+ reply_context
|
|
814
|
+
+ "\n"
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
workspace_block = ""
|
|
818
|
+
workspace_docs: list[tuple[str, str, str]] = []
|
|
819
|
+
for key, label in (
|
|
820
|
+
("active_context", "Active context"),
|
|
821
|
+
("decisions", "Decisions"),
|
|
822
|
+
("spec", "Spec"),
|
|
823
|
+
):
|
|
824
|
+
path = workspace_doc_path(self._workspace_root, key)
|
|
825
|
+
try:
|
|
826
|
+
if not path.exists():
|
|
827
|
+
continue
|
|
828
|
+
content = path.read_text(encoding="utf-8")
|
|
829
|
+
except OSError as exc:
|
|
830
|
+
_logger.debug("workspace doc read failed for %s: %s", path, exc)
|
|
831
|
+
continue
|
|
832
|
+
snippet = (content or "").strip()
|
|
833
|
+
if not snippet:
|
|
834
|
+
continue
|
|
835
|
+
workspace_docs.append(
|
|
836
|
+
(
|
|
837
|
+
label,
|
|
838
|
+
safe_relpath(path, self._workspace_root),
|
|
839
|
+
snippet[:WORKSPACE_DOC_MAX_CHARS],
|
|
840
|
+
)
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
if workspace_docs:
|
|
844
|
+
blocks = ["Workspace docs (truncated; skip if not relevant):"]
|
|
845
|
+
for label, rel, body in workspace_docs:
|
|
846
|
+
blocks.append(f"{label} [{rel}]:\n{body}")
|
|
847
|
+
workspace_block = "\n\n---\n\n" + "\n\n".join(blocks) + "\n"
|
|
848
|
+
|
|
849
|
+
prev_ticket_block = ""
|
|
850
|
+
if previous_ticket_content:
|
|
851
|
+
prev_ticket_block = (
|
|
852
|
+
"\n\n---\n\n"
|
|
853
|
+
"PREVIOUS TICKET CONTEXT (for reference only; do not edit):\n"
|
|
854
|
+
+ previous_ticket_content
|
|
855
|
+
+ "\n"
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
ticket_block = (
|
|
859
|
+
"\n\n---\n\n"
|
|
860
|
+
"TICKET CONTENT (edit this file to track progress; update frontmatter.done when complete):\n"
|
|
861
|
+
f"PATH: {rel_ticket}\n"
|
|
862
|
+
"\n" + ticket_path.read_text(encoding="utf-8")
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
prev_block = ""
|
|
866
|
+
if last_agent_output:
|
|
867
|
+
prev_block = (
|
|
868
|
+
"\n\n---\n\nPREVIOUS AGENT OUTPUT (same ticket):\n" + last_agent_output
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
return (
|
|
872
|
+
header
|
|
873
|
+
+ checkpoint_block
|
|
874
|
+
+ commit_block
|
|
875
|
+
+ lint_block
|
|
876
|
+
+ workspace_block
|
|
877
|
+
+ reply_block
|
|
878
|
+
+ prev_ticket_block
|
|
879
|
+
+ ticket_block
|
|
880
|
+
+ prev_block
|
|
881
|
+
)
|