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,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
DEFAULT_MAX_TOTAL_TURNS = 50
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class TicketFrontmatter:
|
|
12
|
+
"""Parsed, validated ticket frontmatter.
|
|
13
|
+
|
|
14
|
+
Only a minimal set of keys are required for orchestration. Additional
|
|
15
|
+
keys are preserved in `extra` for forward compatibility.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
agent: str
|
|
19
|
+
done: bool
|
|
20
|
+
title: Optional[str] = None
|
|
21
|
+
goal: Optional[str] = None
|
|
22
|
+
# Optional model/reasoning overrides for this ticket.
|
|
23
|
+
model: Optional[str] = None
|
|
24
|
+
reasoning: Optional[str] = None
|
|
25
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class TicketDoc:
|
|
30
|
+
path: Path
|
|
31
|
+
index: int
|
|
32
|
+
frontmatter: TicketFrontmatter
|
|
33
|
+
body: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Dispatch:
|
|
38
|
+
"""Agent-to-human communication dispatched via the outbox.
|
|
39
|
+
|
|
40
|
+
A Dispatch is the canonical unit of agent→human communication. The mode
|
|
41
|
+
determines whether it's informational or requires human action:
|
|
42
|
+
- "notify": FYI, agent continues working
|
|
43
|
+
- "pause": Handoff, agent yields and awaits human reply
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
mode: str # "notify" | "pause"
|
|
47
|
+
body: str
|
|
48
|
+
title: Optional[str] = None
|
|
49
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_handoff(self) -> bool:
|
|
53
|
+
"""True if this dispatch requires human action (mode='pause')."""
|
|
54
|
+
return self.mode == "pause"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class DispatchRecord:
|
|
59
|
+
"""Archived dispatch with sequence number and file references.
|
|
60
|
+
|
|
61
|
+
This is the envelope/record created when a Dispatch is archived to the
|
|
62
|
+
dispatch history directory.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
seq: int
|
|
66
|
+
dispatch: Dispatch
|
|
67
|
+
archived_dir: Path
|
|
68
|
+
archived_files: tuple[Path, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class TicketRunConfig:
|
|
73
|
+
ticket_dir: Path
|
|
74
|
+
runs_dir: Path
|
|
75
|
+
max_total_turns: int = DEFAULT_MAX_TOTAL_TURNS
|
|
76
|
+
max_lint_retries: int = 3
|
|
77
|
+
max_commit_retries: int = 2
|
|
78
|
+
auto_commit: bool = True
|
|
79
|
+
checkpoint_message_template: str = (
|
|
80
|
+
"CAR checkpoint: run={run_id} turn={turn} agent={agent}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class TicketResult:
|
|
86
|
+
"""Return value of a single TicketRunner.step() call."""
|
|
87
|
+
|
|
88
|
+
status: str # "continue" | "paused" | "completed" | "failed"
|
|
89
|
+
state: dict[str, Any]
|
|
90
|
+
reason: Optional[str] = None
|
|
91
|
+
reason_details: Optional[str] = None # Technical details (git status, etc.)
|
|
92
|
+
dispatch: Optional[DispatchRecord] = None
|
|
93
|
+
current_ticket: Optional[str] = None
|
|
94
|
+
agent_output: Optional[str] = None
|
|
95
|
+
agent_id: Optional[str] = None
|
|
96
|
+
agent_conversation_id: Optional[str] = None
|
|
97
|
+
agent_turn_id: Optional[str] = None
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .frontmatter import parse_markdown_frontmatter
|
|
9
|
+
from .lint import lint_dispatch_frontmatter
|
|
10
|
+
from .models import Dispatch, DispatchRecord
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class OutboxPaths:
|
|
15
|
+
"""Filesystem paths for the dispatch outbox."""
|
|
16
|
+
|
|
17
|
+
run_dir: Path
|
|
18
|
+
dispatch_dir: Path
|
|
19
|
+
dispatch_history_dir: Path
|
|
20
|
+
dispatch_path: Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_outbox_paths(
|
|
24
|
+
*, workspace_root: Path, runs_dir: Path, run_id: str
|
|
25
|
+
) -> OutboxPaths:
|
|
26
|
+
run_dir = workspace_root / runs_dir / run_id
|
|
27
|
+
dispatch_dir = run_dir / "dispatch"
|
|
28
|
+
dispatch_history_dir = run_dir / "dispatch_history"
|
|
29
|
+
dispatch_path = run_dir / "DISPATCH.md"
|
|
30
|
+
return OutboxPaths(
|
|
31
|
+
run_dir=run_dir,
|
|
32
|
+
dispatch_dir=dispatch_dir,
|
|
33
|
+
dispatch_history_dir=dispatch_history_dir,
|
|
34
|
+
dispatch_path=dispatch_path,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_outbox_dirs(paths: OutboxPaths) -> None:
|
|
39
|
+
paths.dispatch_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
paths.dispatch_history_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _copy_item(src: Path, dst: Path) -> None:
|
|
44
|
+
if src.is_dir():
|
|
45
|
+
shutil.copytree(src, dst)
|
|
46
|
+
else:
|
|
47
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
shutil.copy2(src, dst)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _list_dispatch_items(dispatch_dir: Path) -> list[Path]:
|
|
52
|
+
if not dispatch_dir.exists() or not dispatch_dir.is_dir():
|
|
53
|
+
return []
|
|
54
|
+
items: list[Path] = []
|
|
55
|
+
for child in sorted(dispatch_dir.iterdir(), key=lambda p: p.name):
|
|
56
|
+
if child.name.startswith("."):
|
|
57
|
+
continue
|
|
58
|
+
items.append(child)
|
|
59
|
+
return items
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _delete_dispatch_items(items: list[Path]) -> None:
|
|
63
|
+
for item in items:
|
|
64
|
+
try:
|
|
65
|
+
if item.is_dir():
|
|
66
|
+
shutil.rmtree(item)
|
|
67
|
+
else:
|
|
68
|
+
item.unlink()
|
|
69
|
+
except OSError:
|
|
70
|
+
# Best-effort cleanup.
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_dispatch(path: Path) -> tuple[Optional[Dispatch], list[str]]:
|
|
75
|
+
"""Parse a dispatch file (DISPATCH.md) into a Dispatch object."""
|
|
76
|
+
try:
|
|
77
|
+
raw = path.read_text(encoding="utf-8")
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
return None, [f"Failed to read dispatch file: {exc}"]
|
|
80
|
+
|
|
81
|
+
data, body = parse_markdown_frontmatter(raw)
|
|
82
|
+
normalized, errors = lint_dispatch_frontmatter(data)
|
|
83
|
+
if errors:
|
|
84
|
+
return None, errors
|
|
85
|
+
|
|
86
|
+
mode = normalized.get("mode", "notify")
|
|
87
|
+
title = normalized.get("title")
|
|
88
|
+
title_str = title.strip() if isinstance(title, str) and title.strip() else None
|
|
89
|
+
extra = dict(normalized)
|
|
90
|
+
extra.pop("mode", None)
|
|
91
|
+
extra.pop("title", None)
|
|
92
|
+
return (
|
|
93
|
+
Dispatch(mode=mode, body=body.lstrip("\n"), title=title_str, extra=extra),
|
|
94
|
+
[],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_turn_summary(
|
|
99
|
+
paths: OutboxPaths,
|
|
100
|
+
*,
|
|
101
|
+
next_seq: int,
|
|
102
|
+
agent_output: str,
|
|
103
|
+
ticket_id: Optional[str] = None,
|
|
104
|
+
agent_id: Optional[str] = None,
|
|
105
|
+
turn_number: Optional[int] = None,
|
|
106
|
+
diff_stats: Optional[dict] = None,
|
|
107
|
+
) -> tuple[Optional[DispatchRecord], list[str]]:
|
|
108
|
+
"""Create a turn summary dispatch record for the agent's final output.
|
|
109
|
+
|
|
110
|
+
This creates a synthetic dispatch with mode="turn_summary" to show
|
|
111
|
+
the agent's final turn output in the dispatch history panel.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
paths: Outbox paths for the run
|
|
115
|
+
next_seq: Sequence number for this dispatch
|
|
116
|
+
agent_output: The agent's output text
|
|
117
|
+
ticket_id: Optional ticket ID for context
|
|
118
|
+
agent_id: Optional agent ID (e.g., "codex", "opencode")
|
|
119
|
+
turn_number: Optional turn number
|
|
120
|
+
diff_stats: Optional dict with insertions/deletions/files_changed
|
|
121
|
+
|
|
122
|
+
Returns (DispatchRecord, []) on success.
|
|
123
|
+
Returns (None, errors) on failure.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
if not agent_output or not agent_output.strip():
|
|
127
|
+
return None, []
|
|
128
|
+
|
|
129
|
+
extra: dict = {}
|
|
130
|
+
if ticket_id:
|
|
131
|
+
extra["ticket_id"] = ticket_id
|
|
132
|
+
if agent_id:
|
|
133
|
+
extra["agent_id"] = agent_id
|
|
134
|
+
if turn_number is not None:
|
|
135
|
+
extra["turn_number"] = turn_number
|
|
136
|
+
if diff_stats:
|
|
137
|
+
extra["diff_stats"] = diff_stats
|
|
138
|
+
extra["is_turn_summary"] = True
|
|
139
|
+
|
|
140
|
+
dispatch = Dispatch(
|
|
141
|
+
mode="turn_summary",
|
|
142
|
+
body=agent_output.strip(),
|
|
143
|
+
title=None,
|
|
144
|
+
extra=extra,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
dest = paths.dispatch_history_dir / f"{next_seq:04d}"
|
|
148
|
+
try:
|
|
149
|
+
dest.mkdir(parents=True, exist_ok=False)
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
return None, [f"Failed to create turn summary dir: {exc}"]
|
|
152
|
+
|
|
153
|
+
# Write a synthetic DISPATCH.md for consistency
|
|
154
|
+
msg_dest = dest / "DISPATCH.md"
|
|
155
|
+
try:
|
|
156
|
+
# Write minimal frontmatter + body
|
|
157
|
+
content = f"---\nmode: turn_summary\n---\n\n{agent_output.strip()}\n"
|
|
158
|
+
msg_dest.write_text(content, encoding="utf-8")
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
return None, [f"Failed to write turn summary: {exc}"]
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
DispatchRecord(
|
|
164
|
+
seq=next_seq,
|
|
165
|
+
dispatch=dispatch,
|
|
166
|
+
archived_dir=dest,
|
|
167
|
+
archived_files=(msg_dest,),
|
|
168
|
+
),
|
|
169
|
+
[],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def archive_dispatch(
|
|
174
|
+
paths: OutboxPaths,
|
|
175
|
+
*,
|
|
176
|
+
next_seq: int,
|
|
177
|
+
ticket_id: Optional[str] = None,
|
|
178
|
+
) -> tuple[Optional[DispatchRecord], list[str]]:
|
|
179
|
+
"""Archive the current dispatch and attachments to the dispatch history.
|
|
180
|
+
|
|
181
|
+
Moves DISPATCH.md + attachments into dispatch_history/<seq>/.
|
|
182
|
+
|
|
183
|
+
Returns (DispatchRecord, []) on success.
|
|
184
|
+
Returns (None, []) when no dispatch file exists.
|
|
185
|
+
Returns (None, errors) on failure.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
if not paths.dispatch_path.exists():
|
|
189
|
+
return None, []
|
|
190
|
+
|
|
191
|
+
dispatch, errors = parse_dispatch(paths.dispatch_path)
|
|
192
|
+
if errors or dispatch is None:
|
|
193
|
+
return None, errors
|
|
194
|
+
|
|
195
|
+
# Add ticket_id to extra if provided
|
|
196
|
+
if ticket_id and dispatch is not None:
|
|
197
|
+
extra = dict(dispatch.extra)
|
|
198
|
+
extra["ticket_id"] = ticket_id
|
|
199
|
+
dispatch = Dispatch(
|
|
200
|
+
mode=dispatch.mode,
|
|
201
|
+
body=dispatch.body,
|
|
202
|
+
title=dispatch.title,
|
|
203
|
+
extra=extra,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
items = _list_dispatch_items(paths.dispatch_dir)
|
|
207
|
+
dest = paths.dispatch_history_dir / f"{next_seq:04d}"
|
|
208
|
+
try:
|
|
209
|
+
dest.mkdir(parents=True, exist_ok=False)
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
return None, [f"Failed to create dispatch history dir: {exc}"]
|
|
212
|
+
|
|
213
|
+
archived: list[Path] = []
|
|
214
|
+
try:
|
|
215
|
+
# Archive the dispatch file.
|
|
216
|
+
msg_dest = dest / "DISPATCH.md"
|
|
217
|
+
_copy_item(paths.dispatch_path, msg_dest)
|
|
218
|
+
archived.append(msg_dest)
|
|
219
|
+
|
|
220
|
+
# Archive all attachments.
|
|
221
|
+
for item in items:
|
|
222
|
+
item_dest = dest / item.name
|
|
223
|
+
_copy_item(item, item_dest)
|
|
224
|
+
archived.append(item_dest)
|
|
225
|
+
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
return None, [f"Failed to archive dispatch: {exc}"]
|
|
228
|
+
|
|
229
|
+
# Cleanup (best-effort).
|
|
230
|
+
try:
|
|
231
|
+
paths.dispatch_path.unlink()
|
|
232
|
+
except OSError:
|
|
233
|
+
pass
|
|
234
|
+
_delete_dispatch_items(items)
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
DispatchRecord(
|
|
238
|
+
seq=next_seq,
|
|
239
|
+
dispatch=dispatch,
|
|
240
|
+
archived_dir=dest,
|
|
241
|
+
archived_files=tuple(archived),
|
|
242
|
+
),
|
|
243
|
+
[],
|
|
244
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .frontmatter import parse_markdown_frontmatter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ReplyPaths:
|
|
14
|
+
run_dir: Path
|
|
15
|
+
reply_dir: Path
|
|
16
|
+
reply_history_dir: Path
|
|
17
|
+
user_reply_path: Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class UserReply:
|
|
22
|
+
body: str
|
|
23
|
+
title: Optional[str] = None
|
|
24
|
+
extra: dict = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ReplyDispatch:
|
|
29
|
+
seq: int
|
|
30
|
+
reply: UserReply
|
|
31
|
+
archived_dir: Path
|
|
32
|
+
archived_files: tuple[Path, ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_reply_paths(
|
|
36
|
+
*, workspace_root: Path, runs_dir: Path, run_id: str
|
|
37
|
+
) -> ReplyPaths:
|
|
38
|
+
run_dir = workspace_root / runs_dir / run_id
|
|
39
|
+
reply_dir = run_dir / "reply"
|
|
40
|
+
reply_history_dir = run_dir / "reply_history"
|
|
41
|
+
user_reply_path = run_dir / "USER_REPLY.md"
|
|
42
|
+
return ReplyPaths(
|
|
43
|
+
run_dir=run_dir,
|
|
44
|
+
reply_dir=reply_dir,
|
|
45
|
+
reply_history_dir=reply_history_dir,
|
|
46
|
+
user_reply_path=user_reply_path,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ensure_reply_dirs(paths: ReplyPaths) -> None:
|
|
51
|
+
paths.reply_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
paths.reply_history_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_user_reply(path: Path) -> tuple[Optional[UserReply], list[str]]:
|
|
56
|
+
"""Parse a USER_REPLY.md file.
|
|
57
|
+
|
|
58
|
+
USER_REPLY.md is intentionally permissive:
|
|
59
|
+
- frontmatter is optional
|
|
60
|
+
- we accept any YAML keys (stored in `extra`)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
raw = path.read_text(encoding="utf-8")
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
return None, [f"Failed to read USER_REPLY.md: {exc}"]
|
|
67
|
+
|
|
68
|
+
data, body = parse_markdown_frontmatter(raw)
|
|
69
|
+
title = data.get("title")
|
|
70
|
+
title_str = title.strip() if isinstance(title, str) and title.strip() else None
|
|
71
|
+
extra = dict(data)
|
|
72
|
+
extra.pop("title", None)
|
|
73
|
+
|
|
74
|
+
# Keep the body as-is, but normalize leading whitespace so it mirrors DISPATCH.md.
|
|
75
|
+
return UserReply(body=body.lstrip("\n"), title=title_str, extra=extra), []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _copy_item(src: Path, dst: Path) -> None:
|
|
79
|
+
if src.is_dir():
|
|
80
|
+
shutil.copytree(src, dst)
|
|
81
|
+
else:
|
|
82
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
shutil.copy2(src, dst)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _list_reply_items(reply_dir: Path) -> list[Path]:
|
|
87
|
+
if not reply_dir.exists() or not reply_dir.is_dir():
|
|
88
|
+
return []
|
|
89
|
+
items: list[Path] = []
|
|
90
|
+
for child in sorted(reply_dir.iterdir(), key=lambda p: p.name):
|
|
91
|
+
if child.name.startswith("."):
|
|
92
|
+
continue
|
|
93
|
+
items.append(child)
|
|
94
|
+
return items
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _delete_items(items: list[Path]) -> None:
|
|
98
|
+
for item in items:
|
|
99
|
+
try:
|
|
100
|
+
if item.is_dir():
|
|
101
|
+
shutil.rmtree(item)
|
|
102
|
+
else:
|
|
103
|
+
item.unlink()
|
|
104
|
+
except OSError:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_SEQ_RE = re.compile(r"^[0-9]{4}$")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def next_reply_seq(reply_history_dir: Path) -> int:
|
|
112
|
+
"""Return the next sequence number for reply_history."""
|
|
113
|
+
|
|
114
|
+
if not reply_history_dir.exists() or not reply_history_dir.is_dir():
|
|
115
|
+
return 1
|
|
116
|
+
existing: list[int] = []
|
|
117
|
+
for child in reply_history_dir.iterdir():
|
|
118
|
+
try:
|
|
119
|
+
if not child.is_dir():
|
|
120
|
+
continue
|
|
121
|
+
if not _SEQ_RE.fullmatch(child.name):
|
|
122
|
+
continue
|
|
123
|
+
existing.append(int(child.name))
|
|
124
|
+
except OSError:
|
|
125
|
+
continue
|
|
126
|
+
return (max(existing) + 1) if existing else 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def dispatch_reply(
|
|
130
|
+
paths: ReplyPaths, *, next_seq: int
|
|
131
|
+
) -> tuple[Optional[ReplyDispatch], list[str]]:
|
|
132
|
+
"""Archive USER_REPLY.md + reply/* into reply_history/<seq>/.
|
|
133
|
+
|
|
134
|
+
Returns (dispatch, errors). When USER_REPLY.md does not exist, returns (None, []).
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
if not paths.user_reply_path.exists():
|
|
138
|
+
return None, []
|
|
139
|
+
|
|
140
|
+
reply, errors = parse_user_reply(paths.user_reply_path)
|
|
141
|
+
if errors or reply is None:
|
|
142
|
+
return None, errors
|
|
143
|
+
|
|
144
|
+
items = _list_reply_items(paths.reply_dir)
|
|
145
|
+
dest = paths.reply_history_dir / f"{next_seq:04d}"
|
|
146
|
+
try:
|
|
147
|
+
dest.mkdir(parents=True, exist_ok=False)
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
return None, [f"Failed to create reply history dir: {exc}"]
|
|
150
|
+
|
|
151
|
+
archived: list[Path] = []
|
|
152
|
+
try:
|
|
153
|
+
msg_dest = dest / "USER_REPLY.md"
|
|
154
|
+
_copy_item(paths.user_reply_path, msg_dest)
|
|
155
|
+
archived.append(msg_dest)
|
|
156
|
+
|
|
157
|
+
for item in items:
|
|
158
|
+
item_dest = dest / item.name
|
|
159
|
+
_copy_item(item, item_dest)
|
|
160
|
+
archived.append(item_dest)
|
|
161
|
+
except OSError as exc:
|
|
162
|
+
return None, [f"Failed to archive reply: {exc}"]
|
|
163
|
+
|
|
164
|
+
# Cleanup (best-effort).
|
|
165
|
+
try:
|
|
166
|
+
paths.user_reply_path.unlink()
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
_delete_items(items)
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
ReplyDispatch(
|
|
173
|
+
seq=next_seq,
|
|
174
|
+
reply=reply,
|
|
175
|
+
archived_dir=dest,
|
|
176
|
+
archived_files=tuple(archived),
|
|
177
|
+
),
|
|
178
|
+
[],
|
|
179
|
+
)
|