codex-autorunner 0.1.2__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +118 -30
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +136 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +16 -35
- codex_autorunner/cli.py +157 -139
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -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 +15 -26
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +390 -100
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +278 -262
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +15 -9
- codex_autorunner/core/locks.py +4 -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/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/utils.py +29 -2
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +576 -92
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +141 -167
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +175 -0
- codex_autorunner/integrations/telegram/constants.py +16 -1
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -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 +227 -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 +133 -475
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
- codex_autorunner/integrations/telegram/helpers.py +88 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +214 -40
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +36 -3
- 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 +23 -14
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +81 -109
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/system.py +6 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +1 -0
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +25 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -196
- 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 +41 -118
- codex_autorunner/static/index.html +787 -858
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -211
- codex_autorunner/static/styles.css +7567 -3865
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -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 +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/app.py +269 -91
- codex_autorunner/web/middleware.py +3 -4
- codex_autorunner/web/schemas.py +89 -109
- codex_autorunner/web/static_assets.py +1 -44
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.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/RECORD +0 -222
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,823 @@
|
|
|
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 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
|
+
)
|
|
81
|
+
|
|
82
|
+
ticket_dir = self._workspace_root / self._config.ticket_dir
|
|
83
|
+
runs_dir = self._config.runs_dir
|
|
84
|
+
|
|
85
|
+
# Ensure outbox dirs exist.
|
|
86
|
+
outbox_paths = resolve_outbox_paths(
|
|
87
|
+
workspace_root=self._workspace_root,
|
|
88
|
+
runs_dir=runs_dir,
|
|
89
|
+
run_id=self._run_id,
|
|
90
|
+
)
|
|
91
|
+
ensure_outbox_dirs(outbox_paths)
|
|
92
|
+
|
|
93
|
+
# Ensure reply inbox dirs exist (human -> agent messages).
|
|
94
|
+
reply_paths = resolve_reply_paths(
|
|
95
|
+
workspace_root=self._workspace_root,
|
|
96
|
+
runs_dir=runs_dir,
|
|
97
|
+
run_id=self._run_id,
|
|
98
|
+
)
|
|
99
|
+
ensure_reply_dirs(reply_paths)
|
|
100
|
+
|
|
101
|
+
ticket_paths = list_ticket_paths(ticket_dir)
|
|
102
|
+
if not ticket_paths:
|
|
103
|
+
return self._pause(
|
|
104
|
+
state,
|
|
105
|
+
reason=(
|
|
106
|
+
"No tickets found. Create tickets under "
|
|
107
|
+
f"{safe_relpath(ticket_dir, self._workspace_root)} and resume."
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
current_ticket = state.get("current_ticket")
|
|
112
|
+
current_path: Optional[Path] = (
|
|
113
|
+
(self._workspace_root / current_ticket)
|
|
114
|
+
if isinstance(current_ticket, str) and current_ticket
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# If current ticket is done, clear it unless we're in the middle of a
|
|
119
|
+
# bounded "commit required" follow-up loop.
|
|
120
|
+
if current_path and ticket_is_done(current_path) and not commit_pending:
|
|
121
|
+
current_path = None
|
|
122
|
+
state.pop("current_ticket", None)
|
|
123
|
+
state.pop("ticket_turns", None)
|
|
124
|
+
state.pop("last_agent_output", None)
|
|
125
|
+
state.pop("lint", None)
|
|
126
|
+
state.pop("commit", None)
|
|
127
|
+
|
|
128
|
+
if current_path is None:
|
|
129
|
+
next_path = self._find_next_ticket(ticket_paths)
|
|
130
|
+
if next_path is None:
|
|
131
|
+
state["status"] = "completed"
|
|
132
|
+
return TicketResult(
|
|
133
|
+
status="completed", state=state, reason="All tickets done."
|
|
134
|
+
)
|
|
135
|
+
current_path = next_path
|
|
136
|
+
state["current_ticket"] = safe_relpath(current_path, self._workspace_root)
|
|
137
|
+
# New ticket resets per-ticket state.
|
|
138
|
+
state["ticket_turns"] = 0
|
|
139
|
+
state.pop("last_agent_output", None)
|
|
140
|
+
state.pop("lint", None)
|
|
141
|
+
state.pop("commit", None)
|
|
142
|
+
|
|
143
|
+
# Determine lint-retry mode early. When lint state is present, we allow the
|
|
144
|
+
# agent to fix the ticket frontmatter even if the ticket is currently
|
|
145
|
+
# unparsable by the strict lint rules.
|
|
146
|
+
if state.get("status") == "paused":
|
|
147
|
+
# Clear stale pause markers so upgraded logic can proceed without manual DB edits.
|
|
148
|
+
state["status"] = "running"
|
|
149
|
+
state.pop("reason", None)
|
|
150
|
+
state.pop("reason_details", None)
|
|
151
|
+
|
|
152
|
+
_lint_raw = state.get("lint")
|
|
153
|
+
lint_state: dict[str, Any] = _lint_raw if isinstance(_lint_raw, dict) else {}
|
|
154
|
+
_lint_errors_raw = lint_state.get("errors")
|
|
155
|
+
lint_errors: list[str] = (
|
|
156
|
+
_lint_errors_raw if isinstance(_lint_errors_raw, list) else []
|
|
157
|
+
)
|
|
158
|
+
lint_retries = int(lint_state.get("retries") or 0)
|
|
159
|
+
_conv_id_raw = lint_state.get("conversation_id")
|
|
160
|
+
reuse_conversation_id: Optional[str] = (
|
|
161
|
+
_conv_id_raw if isinstance(_conv_id_raw, str) else None
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Read ticket (may lint-fail). In lint-retry mode, fall back to a relaxed
|
|
165
|
+
# frontmatter parse so we can still execute an agent turn to repair the file.
|
|
166
|
+
ticket_doc = None
|
|
167
|
+
ticket_errors: list[str] = []
|
|
168
|
+
if lint_errors:
|
|
169
|
+
try:
|
|
170
|
+
raw = current_path.read_text(encoding="utf-8")
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
return self._pause(
|
|
173
|
+
state,
|
|
174
|
+
reason=(
|
|
175
|
+
"Ticket unreadable during lint retry for "
|
|
176
|
+
f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
|
|
177
|
+
),
|
|
178
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
data, _ = parse_markdown_frontmatter(raw)
|
|
182
|
+
agent = data.get("agent")
|
|
183
|
+
agent_id = agent.strip() if isinstance(agent, str) else None
|
|
184
|
+
if not agent_id:
|
|
185
|
+
return self._pause(
|
|
186
|
+
state,
|
|
187
|
+
reason=(
|
|
188
|
+
"Cannot determine ticket agent during lint retry (missing frontmatter.agent). "
|
|
189
|
+
"Fix the ticket frontmatter manually and resume."
|
|
190
|
+
),
|
|
191
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Validate agent id unless it is the special user sentinel.
|
|
195
|
+
if agent_id != "user":
|
|
196
|
+
try:
|
|
197
|
+
from ..agents.registry import validate_agent_id
|
|
198
|
+
|
|
199
|
+
agent_id = validate_agent_id(agent_id)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
return self._pause(
|
|
202
|
+
state,
|
|
203
|
+
reason=(
|
|
204
|
+
"Cannot determine valid agent during lint retry for "
|
|
205
|
+
f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
|
|
206
|
+
),
|
|
207
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
ticket_doc = type(
|
|
211
|
+
"_TicketDocForLintRetry",
|
|
212
|
+
(),
|
|
213
|
+
{
|
|
214
|
+
"frontmatter": TicketFrontmatter(
|
|
215
|
+
agent=agent_id,
|
|
216
|
+
done=False,
|
|
217
|
+
)
|
|
218
|
+
},
|
|
219
|
+
)()
|
|
220
|
+
else:
|
|
221
|
+
ticket_doc, ticket_errors = read_ticket(current_path)
|
|
222
|
+
if ticket_errors or ticket_doc is None:
|
|
223
|
+
return self._pause(
|
|
224
|
+
state,
|
|
225
|
+
reason=f"Ticket frontmatter invalid: {safe_relpath(current_path, self._workspace_root)}",
|
|
226
|
+
reason_details="Errors:\n- " + "\n- ".join(ticket_errors),
|
|
227
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Built-in manual user ticket.
|
|
231
|
+
if ticket_doc.frontmatter.agent == "user":
|
|
232
|
+
if ticket_doc.frontmatter.done:
|
|
233
|
+
# Nothing to do, will advance next step.
|
|
234
|
+
return TicketResult(status="continue", state=state)
|
|
235
|
+
return self._pause(
|
|
236
|
+
state,
|
|
237
|
+
reason=(
|
|
238
|
+
"Paused for user input. Mark ticket as done when ready: "
|
|
239
|
+
f"{safe_relpath(current_path, self._workspace_root)}"
|
|
240
|
+
),
|
|
241
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
ticket_turns = int(state.get("ticket_turns") or 0)
|
|
245
|
+
reply_seq = int(state.get("reply_seq") or 0)
|
|
246
|
+
reply_context, reply_max_seq = self._build_reply_context(
|
|
247
|
+
reply_paths=reply_paths, last_seq=reply_seq
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
previous_ticket_content: Optional[str] = None
|
|
251
|
+
try:
|
|
252
|
+
if current_path in ticket_paths:
|
|
253
|
+
curr_idx = ticket_paths.index(current_path)
|
|
254
|
+
if curr_idx > 0:
|
|
255
|
+
prev_path = ticket_paths[curr_idx - 1]
|
|
256
|
+
previous_ticket_content = prev_path.read_text(encoding="utf-8")
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
prompt = self._build_prompt(
|
|
261
|
+
ticket_path=current_path,
|
|
262
|
+
ticket_doc=ticket_doc,
|
|
263
|
+
last_agent_output=(
|
|
264
|
+
state.get("last_agent_output")
|
|
265
|
+
if isinstance(state.get("last_agent_output"), str)
|
|
266
|
+
else None
|
|
267
|
+
),
|
|
268
|
+
last_checkpoint_error=(
|
|
269
|
+
state.get("last_checkpoint_error")
|
|
270
|
+
if isinstance(state.get("last_checkpoint_error"), str)
|
|
271
|
+
else None
|
|
272
|
+
),
|
|
273
|
+
commit_required=commit_pending,
|
|
274
|
+
commit_attempt=commit_retries + 1 if commit_pending else 0,
|
|
275
|
+
commit_max_attempts=self._config.max_commit_retries,
|
|
276
|
+
outbox_paths=outbox_paths,
|
|
277
|
+
lint_errors=lint_errors if lint_errors else None,
|
|
278
|
+
reply_context=reply_context,
|
|
279
|
+
previous_ticket_content=previous_ticket_content,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Execute turn.
|
|
283
|
+
# Build options dict with model/reasoning from ticket frontmatter if set.
|
|
284
|
+
turn_options: dict[str, Any] = {}
|
|
285
|
+
if ticket_doc.frontmatter.model:
|
|
286
|
+
turn_options["model"] = ticket_doc.frontmatter.model
|
|
287
|
+
if ticket_doc.frontmatter.reasoning:
|
|
288
|
+
turn_options["reasoning"] = ticket_doc.frontmatter.reasoning
|
|
289
|
+
req = AgentTurnRequest(
|
|
290
|
+
agent_id=ticket_doc.frontmatter.agent,
|
|
291
|
+
prompt=prompt,
|
|
292
|
+
workspace_root=self._workspace_root,
|
|
293
|
+
conversation_id=reuse_conversation_id,
|
|
294
|
+
emit_event=emit_event,
|
|
295
|
+
options=turn_options if turn_options else None,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
total_turns += 1
|
|
299
|
+
ticket_turns += 1
|
|
300
|
+
state["total_turns"] = total_turns
|
|
301
|
+
state["ticket_turns"] = ticket_turns
|
|
302
|
+
|
|
303
|
+
head_before_turn: Optional[str] = None
|
|
304
|
+
try:
|
|
305
|
+
head_proc = run_git(
|
|
306
|
+
["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
|
|
307
|
+
)
|
|
308
|
+
head_before_turn = (head_proc.stdout or "").strip() or None
|
|
309
|
+
except Exception:
|
|
310
|
+
head_before_turn = None
|
|
311
|
+
|
|
312
|
+
result = await self._agent_pool.run_turn(req)
|
|
313
|
+
if result.error:
|
|
314
|
+
state["last_agent_output"] = result.text
|
|
315
|
+
state["last_agent_id"] = result.agent_id
|
|
316
|
+
state["last_agent_conversation_id"] = result.conversation_id
|
|
317
|
+
state["last_agent_turn_id"] = result.turn_id
|
|
318
|
+
return self._pause(
|
|
319
|
+
state,
|
|
320
|
+
reason="Agent turn failed. Fix the issue and resume.",
|
|
321
|
+
reason_details=f"Error: {result.error}",
|
|
322
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Mark replies as consumed only after a successful agent turn.
|
|
326
|
+
if reply_max_seq > reply_seq:
|
|
327
|
+
state["reply_seq"] = reply_max_seq
|
|
328
|
+
state["last_agent_output"] = result.text
|
|
329
|
+
state["last_agent_id"] = result.agent_id
|
|
330
|
+
state["last_agent_conversation_id"] = result.conversation_id
|
|
331
|
+
state["last_agent_turn_id"] = result.turn_id
|
|
332
|
+
|
|
333
|
+
# Best-effort: check whether the agent created a commit and whether the
|
|
334
|
+
# working tree is clean, before any runner-driven checkpoint commit.
|
|
335
|
+
head_after_agent: Optional[str] = None
|
|
336
|
+
clean_after_agent: Optional[bool] = None
|
|
337
|
+
status_after_agent: Optional[str] = None
|
|
338
|
+
agent_committed_this_turn: Optional[bool] = None
|
|
339
|
+
try:
|
|
340
|
+
head_proc = run_git(
|
|
341
|
+
["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
|
|
342
|
+
)
|
|
343
|
+
head_after_agent = (head_proc.stdout or "").strip() or None
|
|
344
|
+
status_proc = run_git(
|
|
345
|
+
["status", "--porcelain"], cwd=self._workspace_root, check=True
|
|
346
|
+
)
|
|
347
|
+
status_after_agent = (status_proc.stdout or "").strip()
|
|
348
|
+
clean_after_agent = not bool(status_after_agent)
|
|
349
|
+
if head_before_turn and head_after_agent:
|
|
350
|
+
agent_committed_this_turn = head_after_agent != head_before_turn
|
|
351
|
+
except Exception:
|
|
352
|
+
head_after_agent = None
|
|
353
|
+
clean_after_agent = None
|
|
354
|
+
status_after_agent = None
|
|
355
|
+
agent_committed_this_turn = None
|
|
356
|
+
|
|
357
|
+
# Post-turn: archive outbox if DISPATCH.md exists.
|
|
358
|
+
dispatch_seq = int(state.get("dispatch_seq") or 0)
|
|
359
|
+
current_ticket_id = safe_relpath(current_path, self._workspace_root)
|
|
360
|
+
dispatch, dispatch_errors = archive_dispatch(
|
|
361
|
+
outbox_paths, next_seq=dispatch_seq + 1, ticket_id=current_ticket_id
|
|
362
|
+
)
|
|
363
|
+
if dispatch_errors:
|
|
364
|
+
# Treat as pause: user should fix DISPATCH.md frontmatter. Keep outbox
|
|
365
|
+
# lint separate from ticket frontmatter lint to avoid mixing behaviors.
|
|
366
|
+
state["outbox_lint"] = dispatch_errors
|
|
367
|
+
return self._pause(
|
|
368
|
+
state,
|
|
369
|
+
reason="Invalid DISPATCH.md frontmatter.",
|
|
370
|
+
reason_details="Errors:\n- " + "\n- ".join(dispatch_errors),
|
|
371
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if dispatch is not None:
|
|
375
|
+
state["dispatch_seq"] = dispatch.seq
|
|
376
|
+
state.pop("outbox_lint", None)
|
|
377
|
+
|
|
378
|
+
# Create turn summary record for the agent's final output.
|
|
379
|
+
# This appears in dispatch history as a distinct "turn summary" entry.
|
|
380
|
+
turn_summary_seq = int(state.get("dispatch_seq") or 0) + 1
|
|
381
|
+
turn_summary, turn_summary_errors = create_turn_summary(
|
|
382
|
+
outbox_paths,
|
|
383
|
+
next_seq=turn_summary_seq,
|
|
384
|
+
agent_output=result.text or "",
|
|
385
|
+
ticket_id=current_ticket_id,
|
|
386
|
+
agent_id=result.agent_id,
|
|
387
|
+
turn_number=total_turns,
|
|
388
|
+
)
|
|
389
|
+
if turn_summary is not None:
|
|
390
|
+
state["dispatch_seq"] = turn_summary.seq
|
|
391
|
+
|
|
392
|
+
# Post-turn: ticket frontmatter must remain valid.
|
|
393
|
+
updated_fm, fm_errors = self._recheck_ticket_frontmatter(current_path)
|
|
394
|
+
if fm_errors:
|
|
395
|
+
lint_retries += 1
|
|
396
|
+
if lint_retries > self._config.max_lint_retries:
|
|
397
|
+
return self._pause(
|
|
398
|
+
state,
|
|
399
|
+
reason="Ticket frontmatter invalid. Manual fix required.",
|
|
400
|
+
reason_details=(
|
|
401
|
+
"Exceeded lint retry limit. Fix the ticket frontmatter manually and resume.\n\n"
|
|
402
|
+
"Errors:\n- " + "\n- ".join(fm_errors)
|
|
403
|
+
),
|
|
404
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
state["lint"] = {
|
|
408
|
+
"errors": fm_errors,
|
|
409
|
+
"retries": lint_retries,
|
|
410
|
+
"conversation_id": result.conversation_id,
|
|
411
|
+
}
|
|
412
|
+
return TicketResult(
|
|
413
|
+
status="continue",
|
|
414
|
+
state=state,
|
|
415
|
+
reason="Ticket frontmatter invalid; requesting agent fix.",
|
|
416
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
417
|
+
agent_output=result.text,
|
|
418
|
+
agent_id=result.agent_id,
|
|
419
|
+
agent_conversation_id=result.conversation_id,
|
|
420
|
+
agent_turn_id=result.turn_id,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Clear lint state if previously set.
|
|
424
|
+
if state.get("lint"):
|
|
425
|
+
state.pop("lint", None)
|
|
426
|
+
|
|
427
|
+
# Optional: auto-commit checkpoint (best-effort).
|
|
428
|
+
checkpoint_error = None
|
|
429
|
+
commit_required_now = bool(
|
|
430
|
+
updated_fm and updated_fm.done and clean_after_agent is False
|
|
431
|
+
)
|
|
432
|
+
if self._config.auto_commit and not commit_pending and not commit_required_now:
|
|
433
|
+
checkpoint_error = self._checkpoint_git(
|
|
434
|
+
turn=total_turns, agent=result.agent_id
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# If we dispatched a pause message, pause regardless of ticket completion.
|
|
438
|
+
if dispatch is not None and dispatch.dispatch.mode == "pause":
|
|
439
|
+
reason = dispatch.dispatch.title or "Paused for user input."
|
|
440
|
+
if checkpoint_error:
|
|
441
|
+
reason += f"\n\nNote: checkpoint commit failed: {checkpoint_error}"
|
|
442
|
+
state["reason"] = reason
|
|
443
|
+
return TicketResult(
|
|
444
|
+
status="paused",
|
|
445
|
+
state=state,
|
|
446
|
+
reason=reason,
|
|
447
|
+
dispatch=dispatch,
|
|
448
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
449
|
+
agent_output=result.text,
|
|
450
|
+
agent_id=result.agent_id,
|
|
451
|
+
agent_conversation_id=result.conversation_id,
|
|
452
|
+
agent_turn_id=result.turn_id,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# If ticket is marked done, require a clean working tree (i.e., changes
|
|
456
|
+
# committed) before advancing. This is bounded by max_commit_retries.
|
|
457
|
+
if updated_fm and updated_fm.done:
|
|
458
|
+
if clean_after_agent is False:
|
|
459
|
+
# Enter or continue bounded commit loop.
|
|
460
|
+
if commit_pending:
|
|
461
|
+
# A "commit required" turn just ran and did not succeed.
|
|
462
|
+
next_failed_attempts = commit_retries + 1
|
|
463
|
+
else:
|
|
464
|
+
# Ticket just transitioned to done, but repo is still dirty.
|
|
465
|
+
next_failed_attempts = 0
|
|
466
|
+
|
|
467
|
+
state["commit"] = {
|
|
468
|
+
"pending": True,
|
|
469
|
+
"retries": next_failed_attempts,
|
|
470
|
+
"head_before": head_before_turn,
|
|
471
|
+
"head_after": head_after_agent,
|
|
472
|
+
"agent_committed_this_turn": agent_committed_this_turn,
|
|
473
|
+
"status_porcelain": status_after_agent,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
commit_pending
|
|
478
|
+
and next_failed_attempts >= self._config.max_commit_retries
|
|
479
|
+
):
|
|
480
|
+
detail = (status_after_agent or "").strip()
|
|
481
|
+
detail_lines = detail.splitlines()[:20]
|
|
482
|
+
details_parts = [
|
|
483
|
+
"Please commit manually (ensuring pre-commit hooks pass) and resume."
|
|
484
|
+
]
|
|
485
|
+
if detail_lines:
|
|
486
|
+
details_parts.append(
|
|
487
|
+
"\n\nWorking tree status (git status --porcelain):\n- "
|
|
488
|
+
+ "\n- ".join(detail_lines)
|
|
489
|
+
)
|
|
490
|
+
return self._pause(
|
|
491
|
+
state,
|
|
492
|
+
reason=(
|
|
493
|
+
f"Commit failed after {self._config.max_commit_retries} attempts. "
|
|
494
|
+
"Manual commit required."
|
|
495
|
+
),
|
|
496
|
+
reason_details="".join(details_parts),
|
|
497
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return TicketResult(
|
|
501
|
+
status="continue",
|
|
502
|
+
state=state,
|
|
503
|
+
reason="Ticket done but commit required; requesting agent commit.",
|
|
504
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
505
|
+
agent_output=result.text,
|
|
506
|
+
agent_id=result.agent_id,
|
|
507
|
+
agent_conversation_id=result.conversation_id,
|
|
508
|
+
agent_turn_id=result.turn_id,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Clean (or unknown) → commit satisfied (or no changes / cannot check).
|
|
512
|
+
state.pop("commit", None)
|
|
513
|
+
state.pop("current_ticket", None)
|
|
514
|
+
state.pop("ticket_turns", None)
|
|
515
|
+
state.pop("last_agent_output", None)
|
|
516
|
+
state.pop("lint", None)
|
|
517
|
+
else:
|
|
518
|
+
# If the ticket is no longer done, clear any pending commit gating.
|
|
519
|
+
state.pop("commit", None)
|
|
520
|
+
|
|
521
|
+
if checkpoint_error:
|
|
522
|
+
# Non-fatal, but surface in state for UI.
|
|
523
|
+
state["last_checkpoint_error"] = checkpoint_error
|
|
524
|
+
else:
|
|
525
|
+
state.pop("last_checkpoint_error", None)
|
|
526
|
+
|
|
527
|
+
return TicketResult(
|
|
528
|
+
status="continue",
|
|
529
|
+
state=state,
|
|
530
|
+
reason="Turn complete.",
|
|
531
|
+
dispatch=dispatch,
|
|
532
|
+
current_ticket=safe_relpath(current_path, self._workspace_root),
|
|
533
|
+
agent_output=result.text,
|
|
534
|
+
agent_id=result.agent_id,
|
|
535
|
+
agent_conversation_id=result.conversation_id,
|
|
536
|
+
agent_turn_id=result.turn_id,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def _find_next_ticket(self, ticket_paths: list[Path]) -> Optional[Path]:
|
|
540
|
+
for path in ticket_paths:
|
|
541
|
+
if ticket_is_done(path):
|
|
542
|
+
continue
|
|
543
|
+
return path
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
def _recheck_ticket_frontmatter(self, ticket_path: Path):
|
|
547
|
+
try:
|
|
548
|
+
raw = ticket_path.read_text(encoding="utf-8")
|
|
549
|
+
except OSError as exc:
|
|
550
|
+
return None, [f"Failed to read ticket after turn: {exc}"]
|
|
551
|
+
from .frontmatter import parse_markdown_frontmatter
|
|
552
|
+
|
|
553
|
+
data, _ = parse_markdown_frontmatter(raw)
|
|
554
|
+
fm, errors = lint_ticket_frontmatter(data)
|
|
555
|
+
return fm, errors
|
|
556
|
+
|
|
557
|
+
def _checkpoint_git(self, *, turn: int, agent: str) -> Optional[str]:
|
|
558
|
+
"""Create a best-effort git commit checkpoint.
|
|
559
|
+
|
|
560
|
+
Returns an error string if the checkpoint failed, else None.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
status_proc = run_git(
|
|
565
|
+
["status", "--porcelain"], cwd=self._workspace_root, check=True
|
|
566
|
+
)
|
|
567
|
+
if not (status_proc.stdout or "").strip():
|
|
568
|
+
return None
|
|
569
|
+
run_git(["add", "-A"], cwd=self._workspace_root, check=True)
|
|
570
|
+
msg = self._config.checkpoint_message_template.format(
|
|
571
|
+
run_id=self._run_id,
|
|
572
|
+
turn=turn,
|
|
573
|
+
agent=agent,
|
|
574
|
+
)
|
|
575
|
+
run_git(["commit", "-m", msg], cwd=self._workspace_root, check=True)
|
|
576
|
+
return None
|
|
577
|
+
except Exception as exc:
|
|
578
|
+
_logger.exception("Checkpoint commit failed")
|
|
579
|
+
return str(exc)
|
|
580
|
+
|
|
581
|
+
def _pause(
|
|
582
|
+
self,
|
|
583
|
+
state: dict[str, Any],
|
|
584
|
+
*,
|
|
585
|
+
reason: str,
|
|
586
|
+
reason_details: Optional[str] = None,
|
|
587
|
+
current_ticket: Optional[str] = None,
|
|
588
|
+
) -> TicketResult:
|
|
589
|
+
state = dict(state)
|
|
590
|
+
state["status"] = "paused"
|
|
591
|
+
state["reason"] = reason
|
|
592
|
+
if reason_details:
|
|
593
|
+
state["reason_details"] = reason_details
|
|
594
|
+
else:
|
|
595
|
+
state.pop("reason_details", None)
|
|
596
|
+
return TicketResult(
|
|
597
|
+
status="paused",
|
|
598
|
+
state=state,
|
|
599
|
+
reason=reason,
|
|
600
|
+
reason_details=reason_details,
|
|
601
|
+
current_ticket=current_ticket
|
|
602
|
+
or (
|
|
603
|
+
state.get("current_ticket")
|
|
604
|
+
if isinstance(state.get("current_ticket"), str)
|
|
605
|
+
else None
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
def _build_reply_context(self, *, reply_paths, last_seq: int) -> tuple[str, int]:
|
|
610
|
+
"""Render new human replies (reply_history) into a prompt block.
|
|
611
|
+
|
|
612
|
+
Returns (rendered_text, max_seq_seen).
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
history_dir = getattr(reply_paths, "reply_history_dir", None)
|
|
616
|
+
if history_dir is None:
|
|
617
|
+
return "", last_seq
|
|
618
|
+
if not history_dir.exists() or not history_dir.is_dir():
|
|
619
|
+
return "", last_seq
|
|
620
|
+
|
|
621
|
+
entries: list[tuple[int, Path]] = []
|
|
622
|
+
try:
|
|
623
|
+
for child in history_dir.iterdir():
|
|
624
|
+
try:
|
|
625
|
+
if not child.is_dir():
|
|
626
|
+
continue
|
|
627
|
+
name = child.name
|
|
628
|
+
if not (len(name) == 4 and name.isdigit()):
|
|
629
|
+
continue
|
|
630
|
+
seq = int(name)
|
|
631
|
+
if seq <= last_seq:
|
|
632
|
+
continue
|
|
633
|
+
entries.append((seq, child))
|
|
634
|
+
except OSError:
|
|
635
|
+
continue
|
|
636
|
+
except OSError:
|
|
637
|
+
return "", last_seq
|
|
638
|
+
|
|
639
|
+
if not entries:
|
|
640
|
+
return "", last_seq
|
|
641
|
+
|
|
642
|
+
entries.sort(key=lambda x: x[0])
|
|
643
|
+
max_seq = max(seq for seq, _ in entries)
|
|
644
|
+
|
|
645
|
+
blocks: list[str] = []
|
|
646
|
+
for seq, entry_dir in entries:
|
|
647
|
+
reply_path = entry_dir / "USER_REPLY.md"
|
|
648
|
+
reply, errors = (
|
|
649
|
+
parse_user_reply(reply_path)
|
|
650
|
+
if reply_path.exists()
|
|
651
|
+
else (None, ["USER_REPLY.md missing"])
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
block_lines: list[str] = [f"[USER_REPLY {seq:04d}]"]
|
|
655
|
+
if errors:
|
|
656
|
+
block_lines.append("Errors:\n- " + "\n- ".join(errors))
|
|
657
|
+
if reply is not None:
|
|
658
|
+
if reply.title:
|
|
659
|
+
block_lines.append(f"Title: {reply.title}")
|
|
660
|
+
if reply.body:
|
|
661
|
+
block_lines.append(reply.body)
|
|
662
|
+
|
|
663
|
+
attachments: list[str] = []
|
|
664
|
+
try:
|
|
665
|
+
for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
|
|
666
|
+
try:
|
|
667
|
+
if child.name.startswith("."):
|
|
668
|
+
continue
|
|
669
|
+
if child.name == "USER_REPLY.md":
|
|
670
|
+
continue
|
|
671
|
+
if child.is_dir():
|
|
672
|
+
continue
|
|
673
|
+
attachments.append(safe_relpath(child, self._workspace_root))
|
|
674
|
+
except OSError:
|
|
675
|
+
continue
|
|
676
|
+
except OSError:
|
|
677
|
+
attachments = []
|
|
678
|
+
|
|
679
|
+
if attachments:
|
|
680
|
+
block_lines.append("Attachments:\n- " + "\n- ".join(attachments))
|
|
681
|
+
|
|
682
|
+
blocks.append("\n".join(block_lines).strip())
|
|
683
|
+
|
|
684
|
+
rendered = "\n\n".join(blocks).strip()
|
|
685
|
+
return rendered, max_seq
|
|
686
|
+
|
|
687
|
+
def _build_prompt(
|
|
688
|
+
self,
|
|
689
|
+
*,
|
|
690
|
+
ticket_path: Path,
|
|
691
|
+
ticket_doc,
|
|
692
|
+
last_agent_output: Optional[str],
|
|
693
|
+
last_checkpoint_error: Optional[str] = None,
|
|
694
|
+
commit_required: bool = False,
|
|
695
|
+
commit_attempt: int = 0,
|
|
696
|
+
commit_max_attempts: int = 2,
|
|
697
|
+
outbox_paths,
|
|
698
|
+
lint_errors: Optional[list[str]],
|
|
699
|
+
reply_context: Optional[str] = None,
|
|
700
|
+
previous_ticket_content: Optional[str] = None,
|
|
701
|
+
) -> str:
|
|
702
|
+
rel_ticket = safe_relpath(ticket_path, self._workspace_root)
|
|
703
|
+
rel_dispatch_dir = safe_relpath(outbox_paths.dispatch_dir, self._workspace_root)
|
|
704
|
+
rel_dispatch_path = safe_relpath(
|
|
705
|
+
outbox_paths.dispatch_path, self._workspace_root
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
header = (
|
|
709
|
+
"You are running inside Codex AutoRunner (CAR) in a ticket-based workflow.\n"
|
|
710
|
+
"Complete the current ticket by making changes in the repo and updating the ticket file.\n\n"
|
|
711
|
+
"Key rules:\n"
|
|
712
|
+
f"- Current ticket file: {rel_ticket}\n"
|
|
713
|
+
"- Ticket completion is controlled by YAML frontmatter: set 'done: true' when finished.\n"
|
|
714
|
+
"- To message the user, optionally write attachments first to the dispatch directory, then write DISPATCH.md last.\n"
|
|
715
|
+
f" - Dispatch directory: {rel_dispatch_dir}\n"
|
|
716
|
+
f" - DISPATCH.md path: {rel_dispatch_path}\n"
|
|
717
|
+
" DISPATCH.md frontmatter supports: mode: notify|pause (pause will wait for a user response; notify will continue without waiting for user input).\n"
|
|
718
|
+
"- Keep tickets minimal and avoid scope creep. You may create new tickets only if blocking the current SPEC.\n"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
checkpoint_block = ""
|
|
722
|
+
if last_checkpoint_error:
|
|
723
|
+
checkpoint_block = (
|
|
724
|
+
"\n\n---\n\n"
|
|
725
|
+
"WARNING: The previous checkpoint git commit failed (often due to pre-commit hooks).\n"
|
|
726
|
+
"Resolve this before proceeding, or future turns may fail to checkpoint.\n\n"
|
|
727
|
+
"Checkpoint error:\n"
|
|
728
|
+
f"{last_checkpoint_error}\n"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
commit_block = ""
|
|
732
|
+
if commit_required:
|
|
733
|
+
attempts_remaining = max(commit_max_attempts - commit_attempt + 1, 0)
|
|
734
|
+
commit_block = (
|
|
735
|
+
"\n\n---\n\n"
|
|
736
|
+
"ACTION REQUIRED: Commit your changes, ensuring any pre-commit hooks pass.\n"
|
|
737
|
+
"- Use a meaningful commit message that matches what you implemented.\n"
|
|
738
|
+
"- If hooks fail, fix the underlying issues and retry the commit.\n"
|
|
739
|
+
f"- Attempts remaining before user intervention: {attempts_remaining}\n"
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if lint_errors:
|
|
743
|
+
lint_block = (
|
|
744
|
+
"\n\nTicket frontmatter lint failed. Fix ONLY the ticket frontmatter to satisfy:\n- "
|
|
745
|
+
+ "\n- ".join(lint_errors)
|
|
746
|
+
+ "\n"
|
|
747
|
+
)
|
|
748
|
+
else:
|
|
749
|
+
lint_block = ""
|
|
750
|
+
|
|
751
|
+
reply_block = ""
|
|
752
|
+
if reply_context:
|
|
753
|
+
reply_block = (
|
|
754
|
+
"\n\n---\n\nHUMAN REPLIES (from reply_history; newest since last turn):\n"
|
|
755
|
+
+ reply_context
|
|
756
|
+
+ "\n"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
workspace_block = ""
|
|
760
|
+
workspace_docs: list[tuple[str, str, str]] = []
|
|
761
|
+
for key, label in (
|
|
762
|
+
("active_context", "Active context"),
|
|
763
|
+
("decisions", "Decisions"),
|
|
764
|
+
("spec", "Spec"),
|
|
765
|
+
):
|
|
766
|
+
path = workspace_doc_path(self._workspace_root, key)
|
|
767
|
+
try:
|
|
768
|
+
if not path.exists():
|
|
769
|
+
continue
|
|
770
|
+
content = path.read_text(encoding="utf-8")
|
|
771
|
+
except OSError as exc:
|
|
772
|
+
_logger.debug("workspace doc read failed for %s: %s", path, exc)
|
|
773
|
+
continue
|
|
774
|
+
snippet = (content or "").strip()
|
|
775
|
+
if not snippet:
|
|
776
|
+
continue
|
|
777
|
+
workspace_docs.append(
|
|
778
|
+
(
|
|
779
|
+
label,
|
|
780
|
+
safe_relpath(path, self._workspace_root),
|
|
781
|
+
snippet[:WORKSPACE_DOC_MAX_CHARS],
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
if workspace_docs:
|
|
786
|
+
blocks = ["Workspace docs (truncated; skip if not relevant):"]
|
|
787
|
+
for label, rel, body in workspace_docs:
|
|
788
|
+
blocks.append(f"{label} [{rel}]:\n{body}")
|
|
789
|
+
workspace_block = "\n\n---\n\n" + "\n\n".join(blocks) + "\n"
|
|
790
|
+
|
|
791
|
+
prev_ticket_block = ""
|
|
792
|
+
if previous_ticket_content:
|
|
793
|
+
prev_ticket_block = (
|
|
794
|
+
"\n\n---\n\n"
|
|
795
|
+
"PREVIOUS TICKET CONTEXT (for reference only; do not edit):\n"
|
|
796
|
+
+ previous_ticket_content
|
|
797
|
+
+ "\n"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
ticket_block = (
|
|
801
|
+
"\n\n---\n\n"
|
|
802
|
+
"TICKET CONTENT (edit this file to track progress; update frontmatter.done when complete):\n"
|
|
803
|
+
f"PATH: {rel_ticket}\n"
|
|
804
|
+
"\n" + ticket_path.read_text(encoding="utf-8")
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
prev_block = ""
|
|
808
|
+
if last_agent_output:
|
|
809
|
+
prev_block = (
|
|
810
|
+
"\n\n---\n\nPREVIOUS AGENT OUTPUT (same ticket):\n" + last_agent_output
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
header
|
|
815
|
+
+ checkpoint_block
|
|
816
|
+
+ commit_block
|
|
817
|
+
+ lint_block
|
|
818
|
+
+ workspace_block
|
|
819
|
+
+ reply_block
|
|
820
|
+
+ prev_ticket_block
|
|
821
|
+
+ ticket_block
|
|
822
|
+
+ prev_block
|
|
823
|
+
)
|