codex-autorunner 0.1.1__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/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- 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 +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- 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 +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- 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/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -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 -150
- 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 +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -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 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -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 +21 -7
- 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/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- 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 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- 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 -274
- 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 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import IO, Literal, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
_WORKER_METADATA_FILENAME = "worker.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FlowWorkerHealth:
|
|
17
|
+
status: Literal["absent", "alive", "dead", "invalid", "mismatch"]
|
|
18
|
+
pid: Optional[int]
|
|
19
|
+
cmdline: list[str]
|
|
20
|
+
artifact_path: Path
|
|
21
|
+
message: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_alive(self) -> bool:
|
|
25
|
+
return self.status == "alive"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalized_run_id(run_id: str) -> str:
|
|
29
|
+
return str(uuid.UUID(str(run_id)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _worker_artifacts_dir(
|
|
33
|
+
repo_root: Path, run_id: str, artifacts_root: Optional[Path] = None
|
|
34
|
+
) -> Path:
|
|
35
|
+
repo_root = repo_root.resolve()
|
|
36
|
+
base_artifacts = (
|
|
37
|
+
artifacts_root
|
|
38
|
+
if artifacts_root is not None
|
|
39
|
+
else repo_root / ".codex-autorunner" / "flows"
|
|
40
|
+
)
|
|
41
|
+
artifacts_dir = base_artifacts / _normalized_run_id(run_id)
|
|
42
|
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return artifacts_dir
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _worker_metadata_path(artifacts_dir: Path) -> Path:
|
|
47
|
+
return artifacts_dir / _WORKER_METADATA_FILENAME
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_worker_cmd(entrypoint: str, run_id: str) -> list[str]:
|
|
51
|
+
normalized_run_id = _normalized_run_id(run_id)
|
|
52
|
+
return [
|
|
53
|
+
sys.executable,
|
|
54
|
+
"-m",
|
|
55
|
+
entrypoint,
|
|
56
|
+
"flow",
|
|
57
|
+
"worker",
|
|
58
|
+
"--run-id",
|
|
59
|
+
normalized_run_id,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _pid_is_running(pid: int) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
os.kill(pid, 0)
|
|
66
|
+
except ProcessLookupError:
|
|
67
|
+
return False
|
|
68
|
+
except PermissionError:
|
|
69
|
+
# Process exists but we may not own it.
|
|
70
|
+
return True
|
|
71
|
+
except OSError:
|
|
72
|
+
return False
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_process_cmdline(pid: int) -> list[str] | None:
|
|
77
|
+
proc_path = Path(f"/proc/{pid}/cmdline")
|
|
78
|
+
if proc_path.exists():
|
|
79
|
+
try:
|
|
80
|
+
raw = proc_path.read_bytes()
|
|
81
|
+
return [part for part in raw.decode().split("\0") if part]
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
out = subprocess.check_output(
|
|
87
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
88
|
+
stderr=subprocess.DEVNULL,
|
|
89
|
+
)
|
|
90
|
+
cmd = out.decode().strip()
|
|
91
|
+
if cmd:
|
|
92
|
+
return cmd.split()
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmdline_matches(expected: list[str], actual: list[str]) -> bool:
|
|
99
|
+
if not expected or not actual:
|
|
100
|
+
return False
|
|
101
|
+
if len(actual) >= len(expected) and actual[-len(expected) :] == expected:
|
|
102
|
+
return True
|
|
103
|
+
expected_str = " ".join(expected)
|
|
104
|
+
actual_str = " ".join(actual)
|
|
105
|
+
return expected_str in actual_str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write_worker_metadata(path: Path, pid: int, cmd: list[str]) -> None:
|
|
109
|
+
data = {
|
|
110
|
+
"pid": pid,
|
|
111
|
+
"cmd": cmd,
|
|
112
|
+
"cwd": os.getcwd(),
|
|
113
|
+
}
|
|
114
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
115
|
+
# Also emit a plain PID file for quick inspection.
|
|
116
|
+
pid_path = path.with_suffix(".pid")
|
|
117
|
+
pid_path.write_text(str(pid), encoding="utf-8")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clear_worker_metadata(artifacts_dir: Path) -> None:
|
|
121
|
+
for name in (
|
|
122
|
+
_WORKER_METADATA_FILENAME,
|
|
123
|
+
f"{Path(_WORKER_METADATA_FILENAME).stem}.pid",
|
|
124
|
+
):
|
|
125
|
+
try:
|
|
126
|
+
(artifacts_dir / name).unlink()
|
|
127
|
+
except FileNotFoundError:
|
|
128
|
+
pass
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_worker_health(
|
|
134
|
+
repo_root: Path,
|
|
135
|
+
run_id: str,
|
|
136
|
+
*,
|
|
137
|
+
artifacts_root: Optional[Path] = None,
|
|
138
|
+
entrypoint: str = "codex_autorunner",
|
|
139
|
+
) -> FlowWorkerHealth:
|
|
140
|
+
artifacts_dir = _worker_artifacts_dir(repo_root, run_id, artifacts_root)
|
|
141
|
+
metadata_path = _worker_metadata_path(artifacts_dir)
|
|
142
|
+
|
|
143
|
+
if not metadata_path.exists():
|
|
144
|
+
return FlowWorkerHealth(
|
|
145
|
+
status="absent",
|
|
146
|
+
pid=None,
|
|
147
|
+
cmdline=[],
|
|
148
|
+
artifact_path=metadata_path,
|
|
149
|
+
message="worker metadata missing",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
154
|
+
pid = int(data.get("pid")) if data.get("pid") is not None else None
|
|
155
|
+
cmd = data.get("cmd") or []
|
|
156
|
+
except Exception:
|
|
157
|
+
return FlowWorkerHealth(
|
|
158
|
+
status="invalid",
|
|
159
|
+
pid=None,
|
|
160
|
+
cmdline=[],
|
|
161
|
+
artifact_path=metadata_path,
|
|
162
|
+
message="worker metadata unreadable",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if not pid or pid <= 0:
|
|
166
|
+
return FlowWorkerHealth(
|
|
167
|
+
status="invalid",
|
|
168
|
+
pid=pid,
|
|
169
|
+
cmdline=cmd if isinstance(cmd, list) else [],
|
|
170
|
+
artifact_path=metadata_path,
|
|
171
|
+
message="missing or invalid PID",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if not _pid_is_running(pid):
|
|
175
|
+
return FlowWorkerHealth(
|
|
176
|
+
status="dead",
|
|
177
|
+
pid=pid,
|
|
178
|
+
cmdline=cmd if isinstance(cmd, list) else [],
|
|
179
|
+
artifact_path=metadata_path,
|
|
180
|
+
message="worker PID not running",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
expected_cmd = _build_worker_cmd(entrypoint, run_id)
|
|
184
|
+
actual_cmd = _read_process_cmdline(pid)
|
|
185
|
+
if actual_cmd is None:
|
|
186
|
+
# Can't inspect cmdline; trust the PID check.
|
|
187
|
+
return FlowWorkerHealth(
|
|
188
|
+
status="alive",
|
|
189
|
+
pid=pid,
|
|
190
|
+
cmdline=cmd if isinstance(cmd, list) else [],
|
|
191
|
+
artifact_path=metadata_path,
|
|
192
|
+
message="worker running (cmdline unknown)",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if not _cmdline_matches(expected_cmd, actual_cmd):
|
|
196
|
+
return FlowWorkerHealth(
|
|
197
|
+
status="mismatch",
|
|
198
|
+
pid=pid,
|
|
199
|
+
cmdline=actual_cmd,
|
|
200
|
+
artifact_path=metadata_path,
|
|
201
|
+
message="worker PID command does not match expected",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return FlowWorkerHealth(
|
|
205
|
+
status="alive",
|
|
206
|
+
pid=pid,
|
|
207
|
+
cmdline=actual_cmd,
|
|
208
|
+
artifact_path=metadata_path,
|
|
209
|
+
message="worker running",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def spawn_flow_worker(
|
|
214
|
+
repo_root: Path,
|
|
215
|
+
run_id: str,
|
|
216
|
+
*,
|
|
217
|
+
artifacts_root: Optional[Path] = None,
|
|
218
|
+
entrypoint: str = "codex_autorunner",
|
|
219
|
+
) -> Tuple[subprocess.Popen, IO[bytes], IO[bytes]]:
|
|
220
|
+
"""Spawn a detached flow worker with consistent artifacts/log layout."""
|
|
221
|
+
|
|
222
|
+
normalized_run_id = _normalized_run_id(run_id)
|
|
223
|
+
repo_root = repo_root.resolve()
|
|
224
|
+
artifacts_dir = _worker_artifacts_dir(repo_root, normalized_run_id, artifacts_root)
|
|
225
|
+
|
|
226
|
+
stdout_path = artifacts_dir / "worker.out.log"
|
|
227
|
+
stderr_path = artifacts_dir / "worker.err.log"
|
|
228
|
+
|
|
229
|
+
stdout_handle = stdout_path.open("ab")
|
|
230
|
+
stderr_handle = stderr_path.open("ab")
|
|
231
|
+
|
|
232
|
+
cmd = _build_worker_cmd(entrypoint, normalized_run_id)
|
|
233
|
+
|
|
234
|
+
proc = subprocess.Popen(
|
|
235
|
+
cmd,
|
|
236
|
+
cwd=repo_root,
|
|
237
|
+
stdout=stdout_handle,
|
|
238
|
+
stderr=stderr_handle,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
_write_worker_metadata(_worker_metadata_path(artifacts_dir), proc.pid, cmd)
|
|
242
|
+
return proc, stdout_handle, stderr_handle
|
codex_autorunner/core/hub.py
CHANGED
|
@@ -26,7 +26,7 @@ from .git_utils import (
|
|
|
26
26
|
git_upstream_status,
|
|
27
27
|
run_git,
|
|
28
28
|
)
|
|
29
|
-
from .locks import
|
|
29
|
+
from .locks import DEFAULT_RUNNER_CMD_HINTS, assess_lock, process_alive
|
|
30
30
|
from .runner_controller import ProcessRunnerController, SpawnRunnerFn
|
|
31
31
|
from .state import RunnerState, load_state, now_iso
|
|
32
32
|
from .utils import atomic_write
|
|
@@ -120,9 +120,11 @@ class HubState:
|
|
|
120
120
|
def read_lock_status(lock_path: Path) -> LockStatus:
|
|
121
121
|
if not lock_path.exists():
|
|
122
122
|
return LockStatus.UNLOCKED
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
assessment = assess_lock(
|
|
124
|
+
lock_path,
|
|
125
|
+
expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS,
|
|
126
|
+
)
|
|
127
|
+
if not assessment.freeable and assessment.pid and process_alive(assessment.pid):
|
|
126
128
|
return LockStatus.LOCKED_ALIVE
|
|
127
129
|
return LockStatus.LOCKED_STALE
|
|
128
130
|
|
|
@@ -135,7 +137,8 @@ def load_hub_state(state_path: Path, hub_root: Path) -> HubState:
|
|
|
135
137
|
import json
|
|
136
138
|
|
|
137
139
|
payload = json.loads(data)
|
|
138
|
-
except Exception:
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.warning("Failed to parse hub state from %s: %s", state_path, exc)
|
|
139
142
|
return HubState(last_scan_at=None, repos=[])
|
|
140
143
|
last_scan_at = payload.get("last_scan_at")
|
|
141
144
|
repos_payload = payload.get("repos") or []
|
|
@@ -166,7 +169,13 @@ def load_hub_state(state_path: Path, hub_root: Path) -> HubState:
|
|
|
166
169
|
runner_pid=entry.get("runner_pid"),
|
|
167
170
|
)
|
|
168
171
|
repos.append(repo)
|
|
169
|
-
except Exception:
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
repo_id = entry.get("id", "unknown")
|
|
174
|
+
logger.warning(
|
|
175
|
+
"Failed to load repo snapshot for id=%s from hub state: %s",
|
|
176
|
+
repo_id,
|
|
177
|
+
exc,
|
|
178
|
+
)
|
|
170
179
|
continue
|
|
171
180
|
return HubState(last_scan_at=last_scan_at, repos=repos)
|
|
172
181
|
|
|
@@ -757,10 +766,10 @@ class HubSupervisor:
|
|
|
757
766
|
if not repo:
|
|
758
767
|
raise ValueError(f"Repo {repo_id} not found in manifest")
|
|
759
768
|
repo_root = (self.hub_config.root / repo.path).resolve()
|
|
760
|
-
|
|
761
|
-
if not allow_uninitialized and not
|
|
769
|
+
tickets_dir = repo_root / ".codex-autorunner" / "tickets"
|
|
770
|
+
if not allow_uninitialized and not tickets_dir.exists():
|
|
762
771
|
raise ValueError(f"Repo {repo_id} is not initialized")
|
|
763
|
-
if not
|
|
772
|
+
if not tickets_dir.exists():
|
|
764
773
|
return None
|
|
765
774
|
repo_config = derive_repo_config(self.hub_config, repo_root, load_env=False)
|
|
766
775
|
runner = RepoRunner(
|
|
@@ -779,7 +788,7 @@ class HubSupervisor:
|
|
|
779
788
|
records: List[DiscoveryRecord] = []
|
|
780
789
|
for entry in manifest.repos:
|
|
781
790
|
repo_path = (self.hub_config.root / entry.path).resolve()
|
|
782
|
-
initialized = (repo_path / ".codex-autorunner" / "
|
|
791
|
+
initialized = (repo_path / ".codex-autorunner" / "tickets").exists()
|
|
783
792
|
records.append(
|
|
784
793
|
DiscoveryRecord(
|
|
785
794
|
repo=entry,
|
|
@@ -819,9 +828,8 @@ class HubSupervisor:
|
|
|
819
828
|
lock_status = read_lock_status(lock_path)
|
|
820
829
|
|
|
821
830
|
runner_state: Optional[RunnerState] = None
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
runner_state = load_state(state_path)
|
|
831
|
+
if record.initialized:
|
|
832
|
+
runner_state = load_state(repo_path / ".codex-autorunner" / "state.sqlite3")
|
|
825
833
|
|
|
826
834
|
is_clean: Optional[bool] = None
|
|
827
835
|
if record.exists_on_disk and git_available(repo_path):
|
codex_autorunner/core/locks.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import errno
|
|
2
2
|
import json
|
|
3
|
+
import logging
|
|
3
4
|
import os
|
|
4
5
|
import socket
|
|
6
|
+
import subprocess
|
|
5
7
|
from contextlib import contextmanager
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from pathlib import Path
|
|
8
|
-
from typing import IO, Iterator, Optional
|
|
10
|
+
from typing import IO, Iterable, Iterator, Optional
|
|
9
11
|
|
|
10
12
|
from .utils import atomic_write
|
|
11
13
|
|
|
@@ -17,6 +19,18 @@ class LockInfo:
|
|
|
17
19
|
host: Optional[str]
|
|
18
20
|
|
|
19
21
|
|
|
22
|
+
@dataclass
|
|
23
|
+
class LockAssessment:
|
|
24
|
+
freeable: bool
|
|
25
|
+
reason: Optional[str]
|
|
26
|
+
pid: Optional[int]
|
|
27
|
+
host: Optional[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_RUNNER_CMD_HINTS = ("codex_autorunner.cli", "codex-autorunner", "car ")
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
20
34
|
def process_alive(pid: int) -> bool:
|
|
21
35
|
try:
|
|
22
36
|
os.kill(pid, 0)
|
|
@@ -25,6 +39,109 @@ def process_alive(pid: int) -> bool:
|
|
|
25
39
|
return True
|
|
26
40
|
|
|
27
41
|
|
|
42
|
+
def process_is_zombie(pid: int) -> bool:
|
|
43
|
+
if os.name == "nt":
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["ps", "-o", "stat=", "-p", str(pid)],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
check=False,
|
|
51
|
+
)
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.debug("Failed to check process status for pid %s", pid, exc_info=True)
|
|
54
|
+
return False
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
return False
|
|
57
|
+
return "Z" in result.stdout.strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def process_command(pid: int) -> Optional[str]:
|
|
61
|
+
if os.name == "nt":
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["ps", "-o", "command=", "-p", str(pid)],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
check=False,
|
|
69
|
+
)
|
|
70
|
+
except Exception:
|
|
71
|
+
logger.debug("Failed to inspect process command for pid %s", pid, exc_info=True)
|
|
72
|
+
return None
|
|
73
|
+
if result.returncode != 0:
|
|
74
|
+
return None
|
|
75
|
+
command = result.stdout.strip()
|
|
76
|
+
return command or None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def process_is_active(pid: int) -> bool:
|
|
80
|
+
return process_alive(pid) and not process_is_zombie(pid)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assess_lock(
|
|
84
|
+
lock_path: Path,
|
|
85
|
+
*,
|
|
86
|
+
expected_cmd_substrings: Optional[Iterable[str]] = None,
|
|
87
|
+
require_host_match: bool = True,
|
|
88
|
+
) -> LockAssessment:
|
|
89
|
+
if not lock_path.exists():
|
|
90
|
+
return LockAssessment(freeable=False, reason=None, pid=None, host=None)
|
|
91
|
+
info = read_lock_info(lock_path)
|
|
92
|
+
pid = info.pid
|
|
93
|
+
if not pid:
|
|
94
|
+
return LockAssessment(
|
|
95
|
+
freeable=True,
|
|
96
|
+
reason="Lock has no pid; safe to clear.",
|
|
97
|
+
pid=None,
|
|
98
|
+
host=info.host,
|
|
99
|
+
)
|
|
100
|
+
if not process_alive(pid):
|
|
101
|
+
return LockAssessment(
|
|
102
|
+
freeable=True,
|
|
103
|
+
reason="Lock pid is not running; safe to clear.",
|
|
104
|
+
pid=pid,
|
|
105
|
+
host=info.host,
|
|
106
|
+
)
|
|
107
|
+
if process_is_zombie(pid):
|
|
108
|
+
return LockAssessment(
|
|
109
|
+
freeable=True,
|
|
110
|
+
reason="Lock pid is a zombie process; safe to clear.",
|
|
111
|
+
pid=pid,
|
|
112
|
+
host=info.host,
|
|
113
|
+
)
|
|
114
|
+
if require_host_match and info.host and info.host != socket.gethostname():
|
|
115
|
+
return LockAssessment(
|
|
116
|
+
freeable=False,
|
|
117
|
+
reason="Lock belongs to another host.",
|
|
118
|
+
pid=pid,
|
|
119
|
+
host=info.host,
|
|
120
|
+
)
|
|
121
|
+
if expected_cmd_substrings:
|
|
122
|
+
command = process_command(pid)
|
|
123
|
+
if command is None:
|
|
124
|
+
return LockAssessment(
|
|
125
|
+
freeable=False,
|
|
126
|
+
reason="Unable to inspect lock pid command.",
|
|
127
|
+
pid=pid,
|
|
128
|
+
host=info.host,
|
|
129
|
+
)
|
|
130
|
+
if not any(fragment in command for fragment in expected_cmd_substrings):
|
|
131
|
+
return LockAssessment(
|
|
132
|
+
freeable=True,
|
|
133
|
+
reason="Lock pid command does not match autorunner; safe to clear.",
|
|
134
|
+
pid=pid,
|
|
135
|
+
host=info.host,
|
|
136
|
+
)
|
|
137
|
+
return LockAssessment(
|
|
138
|
+
freeable=False,
|
|
139
|
+
reason=None,
|
|
140
|
+
pid=pid,
|
|
141
|
+
host=info.host,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
28
145
|
try:
|
|
29
146
|
import fcntl
|
|
30
147
|
except ImportError: # pragma: no cover - Windows fallback
|
|
@@ -8,6 +8,8 @@ from typing import Any, Mapping, Optional, OrderedDict
|
|
|
8
8
|
from .config import LogConfig
|
|
9
9
|
from .request_context import get_conversation_id, get_request_id
|
|
10
10
|
|
|
11
|
+
logger = logging.getLogger("codex_autorunner.core.logging_utils")
|
|
12
|
+
|
|
11
13
|
_MAX_CACHED_LOGGERS = 64
|
|
12
14
|
_LOGGER_CACHE: "OrderedDict[str, logging.Logger]" = collections.OrderedDict()
|
|
13
15
|
_REDACTED_VALUE = "<redacted>"
|
|
@@ -60,10 +62,10 @@ def setup_rotating_logger(name: str, log_config: LogConfig) -> logging.Logger:
|
|
|
60
62
|
for h in list(evicted.handlers):
|
|
61
63
|
try:
|
|
62
64
|
h.close()
|
|
63
|
-
except
|
|
65
|
+
except (OSError, ValueError):
|
|
64
66
|
pass
|
|
65
67
|
evicted.handlers.clear()
|
|
66
|
-
except
|
|
68
|
+
except (OSError, ValueError, RuntimeError):
|
|
67
69
|
pass
|
|
68
70
|
return logger
|
|
69
71
|
|
|
@@ -74,18 +76,19 @@ def safe_log(
|
|
|
74
76
|
message: str,
|
|
75
77
|
*args,
|
|
76
78
|
exc: Optional[Exception] = None,
|
|
79
|
+
exc_info: bool = False,
|
|
77
80
|
) -> None:
|
|
78
81
|
try:
|
|
79
82
|
formatted = message
|
|
80
83
|
if args:
|
|
81
84
|
try:
|
|
82
85
|
formatted = message % args
|
|
83
|
-
except
|
|
86
|
+
except (TypeError, ValueError):
|
|
84
87
|
formatted = f"{message} {' '.join(str(arg) for arg in args)}"
|
|
85
88
|
if exc is not None:
|
|
86
89
|
formatted = f"{formatted}: {exc}"
|
|
87
|
-
logger.log(level, formatted)
|
|
88
|
-
except
|
|
90
|
+
logger.log(level, formatted, exc_info=exc_info)
|
|
91
|
+
except (OSError, TypeError, ValueError, RuntimeError):
|
|
89
92
|
pass
|
|
90
93
|
|
|
91
94
|
|
|
@@ -114,7 +117,7 @@ def log_event(
|
|
|
114
117
|
try:
|
|
115
118
|
message = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
|
|
116
119
|
logger.log(level, message)
|
|
117
|
-
except
|
|
120
|
+
except (TypeError, ValueError, OverflowError, RuntimeError):
|
|
118
121
|
pass
|
|
119
122
|
|
|
120
123
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
|
|
4
|
+
PathLike = Union[str, Path]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConfigPathError(Exception):
|
|
8
|
+
"""Raised when a config path is invalid."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
message: str,
|
|
13
|
+
*,
|
|
14
|
+
path: Optional[str] = None,
|
|
15
|
+
resolved: Optional[Path] = None,
|
|
16
|
+
scope: Optional[str] = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.path = path
|
|
20
|
+
self.resolved = resolved
|
|
21
|
+
self.scope = scope
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
msg = super().__str__()
|
|
25
|
+
if self.scope:
|
|
26
|
+
msg = f"{self.scope}: {msg}"
|
|
27
|
+
if self.path:
|
|
28
|
+
msg = f"{msg} (path: {self.path})"
|
|
29
|
+
if self.resolved:
|
|
30
|
+
msg = f"{msg} (resolved: {self.resolved})"
|
|
31
|
+
return msg
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_config_path(
|
|
35
|
+
value: PathLike,
|
|
36
|
+
repo_root: Path,
|
|
37
|
+
*,
|
|
38
|
+
allow_absolute: bool = False,
|
|
39
|
+
allow_home: bool = False,
|
|
40
|
+
allow_dotdot: bool = False,
|
|
41
|
+
scope: Optional[str] = None,
|
|
42
|
+
) -> Path:
|
|
43
|
+
"""
|
|
44
|
+
Resolve a config path according to standard rules.
|
|
45
|
+
|
|
46
|
+
Rules:
|
|
47
|
+
1. If value starts with '/' and allow_absolute=True, use as-is
|
|
48
|
+
2. If value starts with '~', expand to home directory
|
|
49
|
+
3. Otherwise, resolve relative to repo_root
|
|
50
|
+
4. Reject '..' segments unless allow_dotdot=True
|
|
51
|
+
5. Reject paths escaping repo_root (except home expansion)
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
value: Path string or Path object
|
|
55
|
+
repo_root: Repository root directory
|
|
56
|
+
allow_absolute: Allow absolute paths (default False)
|
|
57
|
+
allow_home: Allow home directory expansion with ~ (default False)
|
|
58
|
+
allow_dotdot: Allow '..' segments (default False, for security)
|
|
59
|
+
scope: Config section name for error messages (e.g., 'docs.todo')
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Resolved Path object
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ConfigPathError: If path is invalid
|
|
66
|
+
"""
|
|
67
|
+
value_str = str(value)
|
|
68
|
+
|
|
69
|
+
if not value_str:
|
|
70
|
+
raise ConfigPathError("Path cannot be empty", path=value_str, scope=scope)
|
|
71
|
+
|
|
72
|
+
if value_str.strip() == "":
|
|
73
|
+
raise ConfigPathError(
|
|
74
|
+
"Path cannot be whitespace only", path=value_str, scope=scope
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
value_str = value_str.strip()
|
|
78
|
+
|
|
79
|
+
path = Path(value_str)
|
|
80
|
+
|
|
81
|
+
if path.is_absolute():
|
|
82
|
+
if allow_absolute:
|
|
83
|
+
return path.resolve()
|
|
84
|
+
raise ConfigPathError(
|
|
85
|
+
"Absolute paths are not allowed",
|
|
86
|
+
path=value_str,
|
|
87
|
+
scope=scope,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if str(path).startswith("~"):
|
|
91
|
+
if not allow_home:
|
|
92
|
+
raise ConfigPathError(
|
|
93
|
+
"Home directory expansion (~) is not allowed",
|
|
94
|
+
path=value_str,
|
|
95
|
+
scope=scope,
|
|
96
|
+
)
|
|
97
|
+
if not allow_dotdot and ".." in path.parts:
|
|
98
|
+
raise ConfigPathError(
|
|
99
|
+
"Path contains '..' segments",
|
|
100
|
+
path=value_str,
|
|
101
|
+
scope=scope,
|
|
102
|
+
)
|
|
103
|
+
resolved = path.expanduser().resolve()
|
|
104
|
+
return resolved
|
|
105
|
+
|
|
106
|
+
if not allow_dotdot and ".." in path.parts:
|
|
107
|
+
raise ConfigPathError(
|
|
108
|
+
"Path contains '..' segments",
|
|
109
|
+
path=value_str,
|
|
110
|
+
scope=scope,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
resolved = (repo_root / path).resolve()
|
|
114
|
+
|
|
115
|
+
if not allow_home and not allow_dotdot and not resolved.is_relative_to(repo_root):
|
|
116
|
+
raise ConfigPathError(
|
|
117
|
+
"Path resolves outside repo root",
|
|
118
|
+
path=value_str,
|
|
119
|
+
resolved=resolved,
|
|
120
|
+
scope=scope,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return resolved
|
codex_autorunner/core/prompt.py
CHANGED
|
@@ -15,12 +15,20 @@ def _display_path(root: Path, path: Path) -> str:
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def build_doc_paths(config: Config) -> Mapping[str, str]:
|
|
18
|
+
def _safe_path(*keys: str) -> str:
|
|
19
|
+
for key in keys:
|
|
20
|
+
try:
|
|
21
|
+
return _display_path(config.root, config.doc_path(key))
|
|
22
|
+
except KeyError:
|
|
23
|
+
continue
|
|
24
|
+
return ""
|
|
25
|
+
|
|
18
26
|
return {
|
|
19
|
-
"todo":
|
|
20
|
-
"progress":
|
|
21
|
-
"opinions":
|
|
22
|
-
"spec":
|
|
23
|
-
"summary":
|
|
27
|
+
"todo": _safe_path("todo", "active_context"),
|
|
28
|
+
"progress": _safe_path("progress", "decisions"),
|
|
29
|
+
"opinions": _safe_path("opinions"),
|
|
30
|
+
"spec": _safe_path("spec"),
|
|
31
|
+
"summary": _safe_path("summary"),
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
|
|
@@ -64,8 +72,8 @@ def build_final_summary_prompt(
|
|
|
64
72
|
|
|
65
73
|
doc_paths = build_doc_paths(config)
|
|
66
74
|
doc_contents = {
|
|
67
|
-
"todo": docs.read_doc("todo"),
|
|
68
|
-
"progress": docs.read_doc("progress"),
|
|
75
|
+
"todo": docs.read_doc("todo") or docs.read_doc("active_context"),
|
|
76
|
+
"progress": docs.read_doc("progress") or docs.read_doc("decisions"),
|
|
69
77
|
"opinions": docs.read_doc("opinions"),
|
|
70
78
|
"spec": docs.read_doc("spec"),
|
|
71
79
|
"summary": docs.read_doc("summary"),
|