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
codex_autorunner/core/engine.py
CHANGED
|
@@ -12,11 +12,10 @@ from collections import Counter
|
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
from logging.handlers import RotatingFileHandler
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import IO, Any,
|
|
15
|
+
from typing import IO, Any, Iterator, Optional
|
|
16
16
|
|
|
17
17
|
import yaml
|
|
18
18
|
|
|
19
|
-
from ..agents.factory import create_orchestrator
|
|
20
19
|
from ..agents.opencode.logging import OpenCodeEventFormatter
|
|
21
20
|
from ..agents.opencode.runtime import (
|
|
22
21
|
OpenCodeTurnOutput,
|
|
@@ -39,8 +38,9 @@ from ..integrations.app_server.client import (
|
|
|
39
38
|
from ..integrations.app_server.env import build_app_server_env
|
|
40
39
|
from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
|
|
41
40
|
from ..manifest import MANIFEST_VERSION
|
|
42
|
-
from ..
|
|
41
|
+
from ..tickets.files import list_ticket_paths, ticket_is_done
|
|
43
42
|
from .about_car import ensure_about_car_file
|
|
43
|
+
from .adapter_utils import handle_agent_output
|
|
44
44
|
from .app_server_events import AppServerEventBuffer
|
|
45
45
|
from .app_server_logging import AppServerEventFormatter
|
|
46
46
|
from .app_server_prompts import build_autorunner_prompt
|
|
@@ -67,9 +67,11 @@ from .locks import (
|
|
|
67
67
|
from .notifications import NotificationManager
|
|
68
68
|
from .optional_dependencies import missing_optional_dependencies
|
|
69
69
|
from .prompt import build_final_summary_prompt
|
|
70
|
+
from .redaction import redact_text
|
|
70
71
|
from .review_context import build_spec_progress_review_context
|
|
71
72
|
from .run_index import RunIndexStore
|
|
72
73
|
from .state import RunnerState, load_state, now_iso, save_state, state_lock
|
|
74
|
+
from .static_assets import missing_static_assets, resolve_static_dir
|
|
73
75
|
from .utils import (
|
|
74
76
|
RepoNotFoundError,
|
|
75
77
|
atomic_write,
|
|
@@ -134,6 +136,7 @@ class Engine:
|
|
|
134
136
|
self._run_index_store = RunIndexStore(self.state_path)
|
|
135
137
|
self.lock_path = self.repo_root / ".codex-autorunner" / "lock"
|
|
136
138
|
self.stop_path = self.repo_root / ".codex-autorunner" / "stop"
|
|
139
|
+
self._hub_path = hub_path
|
|
137
140
|
self._active_global_handler: Optional[RotatingFileHandler] = None
|
|
138
141
|
self._active_run_log: Optional[IO[str]] = None
|
|
139
142
|
self._app_server_threads = AppServerThreadRegistry(
|
|
@@ -142,7 +145,10 @@ class Engine:
|
|
|
142
145
|
self._app_server_threads_lock = threading.Lock()
|
|
143
146
|
self._app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None
|
|
144
147
|
self._app_server_logger = logging.getLogger("codex_autorunner.app_server")
|
|
145
|
-
|
|
148
|
+
redact_enabled = self.config.security.get("redact_run_logs", True)
|
|
149
|
+
self._app_server_event_formatter = AppServerEventFormatter(
|
|
150
|
+
redact_enabled=redact_enabled
|
|
151
|
+
)
|
|
146
152
|
self._app_server_events = AppServerEventBuffer()
|
|
147
153
|
self._opencode_event_formatter = OpenCodeEventFormatter()
|
|
148
154
|
self._opencode_supervisor: Optional[OpenCodeSupervisor] = None
|
|
@@ -262,41 +268,21 @@ class Engine:
|
|
|
262
268
|
return None
|
|
263
269
|
|
|
264
270
|
def todos_done(self) -> bool:
|
|
265
|
-
|
|
271
|
+
# Ticket-first mode: completion is determined by ticket files, not TODO.md.
|
|
272
|
+
ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
|
|
273
|
+
ticket_paths = list_ticket_paths(ticket_dir)
|
|
274
|
+
if not ticket_paths:
|
|
275
|
+
return False
|
|
276
|
+
return all(ticket_is_done(path) for path in ticket_paths)
|
|
266
277
|
|
|
267
278
|
def summary_finalized(self) -> bool:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
text = self.docs.read_doc("summary")
|
|
271
|
-
except (FileNotFoundError, OSError) as exc:
|
|
272
|
-
self._app_server_logger.debug("Failed to read SUMMARY.md: %s", exc)
|
|
273
|
-
return False
|
|
274
|
-
return SUMMARY_FINALIZED_MARKER in (text or "")
|
|
279
|
+
# Legacy docs finalization no longer applies (no SUMMARY doc).
|
|
280
|
+
return True
|
|
275
281
|
|
|
276
282
|
def _stamp_summary_finalized(self, run_id: int) -> None:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
"""
|
|
281
|
-
path = self.config.doc_path("summary")
|
|
282
|
-
try:
|
|
283
|
-
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
284
|
-
except (FileNotFoundError, OSError) as exc:
|
|
285
|
-
self._app_server_logger.debug(
|
|
286
|
-
"Failed to read SUMMARY.md for stamping: %s", exc
|
|
287
|
-
)
|
|
288
|
-
existing = ""
|
|
289
|
-
if SUMMARY_FINALIZED_MARKER in existing:
|
|
290
|
-
return
|
|
291
|
-
stamp = f"{SUMMARY_FINALIZED_MARKER_PREFIX} run_id={int(run_id)} -->\n"
|
|
292
|
-
new_text = existing
|
|
293
|
-
if new_text and not new_text.endswith("\n"):
|
|
294
|
-
new_text += "\n"
|
|
295
|
-
# Keep a blank line before the marker for readability.
|
|
296
|
-
if new_text and not new_text.endswith("\n\n"):
|
|
297
|
-
new_text += "\n"
|
|
298
|
-
new_text += stamp
|
|
299
|
-
atomic_write(path, new_text)
|
|
283
|
+
# No-op: summary file no longer exists.
|
|
284
|
+
_ = run_id
|
|
285
|
+
return
|
|
300
286
|
|
|
301
287
|
async def _execute_run_step(
|
|
302
288
|
self,
|
|
@@ -317,7 +303,9 @@ class Engine:
|
|
|
317
303
|
try:
|
|
318
304
|
todo_before = self.docs.read_doc("todo")
|
|
319
305
|
except (FileNotFoundError, OSError) as exc:
|
|
320
|
-
self._app_server_logger.debug(
|
|
306
|
+
self._app_server_logger.debug(
|
|
307
|
+
"Failed to read TODO.md before run %s: %s", run_id, exc
|
|
308
|
+
)
|
|
321
309
|
todo_before = ""
|
|
322
310
|
state = load_state(self.state_path)
|
|
323
311
|
selected_agent = (state.autorunner_agent_override or "codex").strip().lower()
|
|
@@ -332,8 +320,23 @@ class Engine:
|
|
|
332
320
|
self._update_state("running", run_id, None, started=True)
|
|
333
321
|
self._last_run_interrupted = False
|
|
334
322
|
self._start_run_telemetry(run_id)
|
|
323
|
+
|
|
324
|
+
actor: dict[str, Any] = {
|
|
325
|
+
"backend": "codex_app_server",
|
|
326
|
+
"agent_id": validated_agent,
|
|
327
|
+
"surface": "hub" if self._hub_path else "cli",
|
|
328
|
+
}
|
|
329
|
+
mode: dict[str, Any] = {
|
|
330
|
+
"approval_policy": state.autorunner_approval_policy or "never",
|
|
331
|
+
"sandbox": state.autorunner_sandbox_mode or "dangerFullAccess",
|
|
332
|
+
}
|
|
333
|
+
runner_cfg = self.config.raw.get("runner") or {}
|
|
334
|
+
review_cfg = runner_cfg.get("review")
|
|
335
|
+
if isinstance(review_cfg, dict):
|
|
336
|
+
mode["review_enabled"] = bool(review_cfg.get("enabled"))
|
|
337
|
+
|
|
335
338
|
with self._run_log_context(run_id):
|
|
336
|
-
self._write_run_marker(run_id, "start")
|
|
339
|
+
self._write_run_marker(run_id, "start", actor=actor, mode=mode)
|
|
337
340
|
if validated_agent == "opencode":
|
|
338
341
|
exit_code = await self._run_opencode_app_server_async(
|
|
339
342
|
prompt,
|
|
@@ -353,7 +356,9 @@ class Engine:
|
|
|
353
356
|
try:
|
|
354
357
|
todo_after = self.docs.read_doc("todo")
|
|
355
358
|
except (FileNotFoundError, OSError) as exc:
|
|
356
|
-
self._app_server_logger.debug(
|
|
359
|
+
self._app_server_logger.debug(
|
|
360
|
+
"Failed to read TODO.md after run %s: %s", run_id, exc
|
|
361
|
+
)
|
|
357
362
|
todo_after = ""
|
|
358
363
|
todo_delta = self._compute_todo_attribution(todo_before, todo_after)
|
|
359
364
|
todo_snapshot = self._build_todo_snapshot(todo_before, todo_after)
|
|
@@ -380,6 +385,7 @@ class Engine:
|
|
|
380
385
|
"thread_total_after": telemetry.token_total,
|
|
381
386
|
}
|
|
382
387
|
artifacts: dict[str, str] = {}
|
|
388
|
+
redact_enabled = self.config.security.get("redact_run_logs", True)
|
|
383
389
|
if telemetry and telemetry.plan is not None:
|
|
384
390
|
try:
|
|
385
391
|
plan_content = (
|
|
@@ -391,25 +397,39 @@ class Engine:
|
|
|
391
397
|
)
|
|
392
398
|
except (TypeError, ValueError) as exc:
|
|
393
399
|
self._app_server_logger.debug(
|
|
394
|
-
"Failed to serialize plan to JSON: %s", exc
|
|
400
|
+
"Failed to serialize plan to JSON for run %s: %s", run_id, exc
|
|
395
401
|
)
|
|
396
402
|
plan_content = json.dumps(
|
|
397
403
|
{"plan": str(telemetry.plan)}, ensure_ascii=True, indent=2
|
|
398
404
|
)
|
|
405
|
+
if redact_enabled:
|
|
406
|
+
plan_content = redact_text(plan_content)
|
|
399
407
|
plan_path = self._write_run_artifact(run_id, "plan.json", plan_content)
|
|
400
408
|
artifacts["plan_path"] = str(plan_path)
|
|
401
409
|
if telemetry and telemetry.diff is not None:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
410
|
+
normalized_diff = self._normalize_diff_payload(telemetry.diff)
|
|
411
|
+
if normalized_diff is not None:
|
|
412
|
+
diff_content = (
|
|
413
|
+
normalized_diff
|
|
414
|
+
if isinstance(normalized_diff, str)
|
|
415
|
+
else json.dumps(
|
|
416
|
+
normalized_diff, ensure_ascii=True, indent=2, default=str
|
|
417
|
+
)
|
|
407
418
|
)
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
419
|
+
if redact_enabled:
|
|
420
|
+
diff_content = redact_text(diff_content)
|
|
421
|
+
diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
|
|
422
|
+
artifacts["diff_path"] = str(diff_path)
|
|
411
423
|
if artifacts:
|
|
412
424
|
run_updates["artifacts"] = artifacts
|
|
425
|
+
if redact_enabled:
|
|
426
|
+
from .redaction import get_redaction_patterns
|
|
427
|
+
|
|
428
|
+
run_updates["security"] = {
|
|
429
|
+
"redaction_enabled": True,
|
|
430
|
+
"redaction_version": "1.0",
|
|
431
|
+
"redaction_patterns": get_redaction_patterns(),
|
|
432
|
+
}
|
|
413
433
|
if run_updates:
|
|
414
434
|
self._merge_run_index_entry(run_id, run_updates)
|
|
415
435
|
self._clear_run_telemetry(run_id)
|
|
@@ -457,7 +477,7 @@ class Engine:
|
|
|
457
477
|
text = run_log.read_text(encoding="utf-8")
|
|
458
478
|
except (FileNotFoundError, OSError) as exc:
|
|
459
479
|
self._app_server_logger.debug(
|
|
460
|
-
"Failed to read previous run log: %s", exc
|
|
480
|
+
"Failed to read previous run log for run %s: %s", run_id, exc
|
|
461
481
|
)
|
|
462
482
|
text = ""
|
|
463
483
|
if text:
|
|
@@ -508,10 +528,12 @@ class Engine:
|
|
|
508
528
|
try:
|
|
509
529
|
return run_log.read_text(encoding="utf-8")
|
|
510
530
|
except (FileNotFoundError, OSError) as exc:
|
|
511
|
-
self._app_server_logger.debug(
|
|
531
|
+
self._app_server_logger.debug(
|
|
532
|
+
"Failed to read run log block for run %s: %s", run_id, exc
|
|
533
|
+
)
|
|
512
534
|
return None
|
|
513
535
|
if index_entry:
|
|
514
|
-
block = self._read_log_range(index_entry)
|
|
536
|
+
block = self._read_log_range(run_id, index_entry)
|
|
515
537
|
if block is not None:
|
|
516
538
|
return block
|
|
517
539
|
if not self.log_path.exists():
|
|
@@ -555,7 +577,7 @@ class Engine:
|
|
|
555
577
|
return "\n".join(buf) if buf else None
|
|
556
578
|
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
557
579
|
self._app_server_logger.debug(
|
|
558
|
-
"Failed to read full log for run block: %s", exc
|
|
580
|
+
"Failed to read full log for run %s block: %s", run_id, exc
|
|
559
581
|
)
|
|
560
582
|
return None
|
|
561
583
|
return None
|
|
@@ -582,7 +604,7 @@ class Engine:
|
|
|
582
604
|
self._active_run_log.flush()
|
|
583
605
|
except (OSError, IOError) as exc:
|
|
584
606
|
self._app_server_logger.warning(
|
|
585
|
-
"Failed to write to active run log: %s", exc
|
|
607
|
+
"Failed to write to active run log for run %s: %s", run_id, exc
|
|
586
608
|
)
|
|
587
609
|
else:
|
|
588
610
|
run_log = self._run_log_path(run_id)
|
|
@@ -607,7 +629,7 @@ class Engine:
|
|
|
607
629
|
f.write(_json.dumps(event_data) + "\n")
|
|
608
630
|
except (OSError, IOError) as exc:
|
|
609
631
|
self._app_server_logger.warning(
|
|
610
|
-
"Failed to write event to events log: %s", exc
|
|
632
|
+
"Failed to write event to events log for run %s: %s", run_id, exc
|
|
611
633
|
)
|
|
612
634
|
|
|
613
635
|
def _ensure_log_path(self) -> None:
|
|
@@ -623,14 +645,25 @@ class Engine:
|
|
|
623
645
|
(self.log_path.parent / "runs").mkdir(parents=True, exist_ok=True)
|
|
624
646
|
|
|
625
647
|
def _write_run_marker(
|
|
626
|
-
self,
|
|
648
|
+
self,
|
|
649
|
+
run_id: int,
|
|
650
|
+
marker: str,
|
|
651
|
+
exit_code: Optional[int] = None,
|
|
652
|
+
*,
|
|
653
|
+
actor: Optional[dict[str, Any]] = None,
|
|
654
|
+
mode: Optional[dict[str, Any]] = None,
|
|
627
655
|
) -> None:
|
|
628
656
|
suffix = ""
|
|
629
657
|
if marker == "end":
|
|
630
658
|
suffix = f" (code {exit_code})"
|
|
631
659
|
self._emit_event(run_id, "run.finished", exit_code=exit_code)
|
|
632
660
|
elif marker == "start":
|
|
633
|
-
|
|
661
|
+
payload: dict[str, Any] = {}
|
|
662
|
+
if actor is not None:
|
|
663
|
+
payload["actor"] = actor
|
|
664
|
+
if mode is not None:
|
|
665
|
+
payload["mode"] = mode
|
|
666
|
+
self._emit_event(run_id, "run.started", **payload)
|
|
634
667
|
text = f"=== run {run_id} {marker}{suffix} ==="
|
|
635
668
|
offset = self._emit_global_line(text)
|
|
636
669
|
if self._active_run_log is not None:
|
|
@@ -639,14 +672,18 @@ class Engine:
|
|
|
639
672
|
self._active_run_log.flush()
|
|
640
673
|
except (OSError, IOError) as exc:
|
|
641
674
|
self._app_server_logger.warning(
|
|
642
|
-
"Failed to write marker to active run log: %s",
|
|
675
|
+
"Failed to write marker to active run log for run %s: %s",
|
|
676
|
+
run_id,
|
|
677
|
+
exc,
|
|
643
678
|
)
|
|
644
679
|
else:
|
|
645
680
|
self._ensure_run_log_dir()
|
|
646
681
|
run_log = self._run_log_path(run_id)
|
|
647
682
|
with run_log.open("a", encoding="utf-8") as f:
|
|
648
683
|
f.write(f"{text}\n")
|
|
649
|
-
self._update_run_index(
|
|
684
|
+
self._update_run_index(
|
|
685
|
+
run_id, marker, offset, exit_code, actor=actor, mode=mode
|
|
686
|
+
)
|
|
650
687
|
|
|
651
688
|
def _emit_global_line(self, text: str) -> Optional[tuple[int, int]]:
|
|
652
689
|
if self._active_global_handler is None:
|
|
@@ -715,7 +752,7 @@ class Engine:
|
|
|
715
752
|
handler.close()
|
|
716
753
|
except (OSError, IOError) as exc:
|
|
717
754
|
self._app_server_logger.debug(
|
|
718
|
-
"Failed to close run log handler: %s", exc
|
|
755
|
+
"Failed to close run log handler for run %s: %s", run_id, exc
|
|
719
756
|
)
|
|
720
757
|
|
|
721
758
|
def _start_run_telemetry(self, run_id: int) -> None:
|
|
@@ -747,6 +784,29 @@ class Engine:
|
|
|
747
784
|
return
|
|
748
785
|
self._run_telemetry = None
|
|
749
786
|
|
|
787
|
+
@staticmethod
|
|
788
|
+
def _normalize_diff_payload(diff: Any) -> Optional[Any]:
|
|
789
|
+
if diff is None:
|
|
790
|
+
return None
|
|
791
|
+
if isinstance(diff, str):
|
|
792
|
+
return diff if diff.strip() else None
|
|
793
|
+
if isinstance(diff, dict):
|
|
794
|
+
# Prefer meaningful fields if present.
|
|
795
|
+
for key in ("diff", "patch", "content", "value"):
|
|
796
|
+
if key in diff:
|
|
797
|
+
val = diff.get(key)
|
|
798
|
+
if isinstance(val, str) and val.strip():
|
|
799
|
+
return val
|
|
800
|
+
if val not in (None, "", [], {}, ()):
|
|
801
|
+
return diff
|
|
802
|
+
for val in diff.values():
|
|
803
|
+
if isinstance(val, str) and val.strip():
|
|
804
|
+
return diff
|
|
805
|
+
if val not in (None, "", [], {}, ()):
|
|
806
|
+
return diff
|
|
807
|
+
return None
|
|
808
|
+
return diff
|
|
809
|
+
|
|
750
810
|
def _maybe_update_run_index_telemetry(
|
|
751
811
|
self, run_id: int, min_interval_seconds: float = 3.0
|
|
752
812
|
) -> None:
|
|
@@ -821,13 +881,12 @@ class Engine:
|
|
|
821
881
|
if method == "turn/plan/updated":
|
|
822
882
|
telemetry.plan = params.get("plan") if "plan" in params else params
|
|
823
883
|
if method == "turn/diff/updated":
|
|
824
|
-
diff =
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
telemetry.diff = diff if diff is not None else params
|
|
884
|
+
diff: Any = None
|
|
885
|
+
for key in ("diff", "patch", "content", "value"):
|
|
886
|
+
if key in params:
|
|
887
|
+
diff = params.get(key)
|
|
888
|
+
break
|
|
889
|
+
telemetry.diff = diff if diff is not None else params or None
|
|
831
890
|
if run_id is None:
|
|
832
891
|
return
|
|
833
892
|
for line in self._app_server_event_formatter.format_event(message):
|
|
@@ -847,7 +906,10 @@ class Engine:
|
|
|
847
906
|
"""
|
|
848
907
|
try:
|
|
849
908
|
state = load_state(self.state_path)
|
|
850
|
-
except Exception:
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
self._app_server_logger.warning(
|
|
911
|
+
"Failed to load state during run index reconciliation: %s", exc
|
|
912
|
+
)
|
|
851
913
|
return
|
|
852
914
|
|
|
853
915
|
active_pid: Optional[int] = None
|
|
@@ -870,7 +932,10 @@ class Engine:
|
|
|
870
932
|
now = now_iso()
|
|
871
933
|
try:
|
|
872
934
|
index = self._run_index_store.load_all()
|
|
873
|
-
except Exception:
|
|
935
|
+
except Exception as exc:
|
|
936
|
+
self._app_server_logger.warning(
|
|
937
|
+
"Failed to load run index during reconciliation: %s", exc
|
|
938
|
+
)
|
|
874
939
|
return
|
|
875
940
|
|
|
876
941
|
for key, entry in index.items():
|
|
@@ -917,7 +982,10 @@ class Engine:
|
|
|
917
982
|
),
|
|
918
983
|
},
|
|
919
984
|
)
|
|
920
|
-
except Exception:
|
|
985
|
+
except Exception as exc:
|
|
986
|
+
self._app_server_logger.warning(
|
|
987
|
+
"Failed to reconcile run index entry for run %d: %s", run_id, exc
|
|
988
|
+
)
|
|
921
989
|
continue
|
|
922
990
|
|
|
923
991
|
def _merge_run_index_entry(self, run_id: int, updates: dict[str, Any]) -> None:
|
|
@@ -929,6 +997,9 @@ class Engine:
|
|
|
929
997
|
marker: str,
|
|
930
998
|
offset: Optional[tuple[int, int]],
|
|
931
999
|
exit_code: Optional[int],
|
|
1000
|
+
*,
|
|
1001
|
+
actor: Optional[dict[str, Any]] = None,
|
|
1002
|
+
mode: Optional[dict[str, Any]] = None,
|
|
932
1003
|
) -> None:
|
|
933
1004
|
self._run_index_store.update_marker(
|
|
934
1005
|
run_id,
|
|
@@ -937,6 +1008,8 @@ class Engine:
|
|
|
937
1008
|
exit_code,
|
|
938
1009
|
log_path=str(self.log_path),
|
|
939
1010
|
run_log_path=str(self._run_log_path(run_id)),
|
|
1011
|
+
actor=actor,
|
|
1012
|
+
mode=mode,
|
|
940
1013
|
)
|
|
941
1014
|
|
|
942
1015
|
def _list_from_counts(self, source: list[str], counts: Counter[str]) -> list[str]:
|
|
@@ -1021,7 +1094,10 @@ class Engine:
|
|
|
1021
1094
|
entry_id = int(key)
|
|
1022
1095
|
except (TypeError, ValueError) as exc:
|
|
1023
1096
|
self._app_server_logger.debug(
|
|
1024
|
-
"Failed to parse run index key '%s': %s",
|
|
1097
|
+
"Failed to parse run index key '%s' while resolving run %s: %s",
|
|
1098
|
+
key,
|
|
1099
|
+
run_id,
|
|
1100
|
+
exc,
|
|
1025
1101
|
)
|
|
1026
1102
|
continue
|
|
1027
1103
|
if entry_id >= run_id:
|
|
@@ -1106,7 +1182,7 @@ class Engine:
|
|
|
1106
1182
|
atomic_write(path, content)
|
|
1107
1183
|
return path
|
|
1108
1184
|
|
|
1109
|
-
def _read_log_range(self, entry: dict) -> Optional[str]:
|
|
1185
|
+
def _read_log_range(self, run_id: int, entry: dict) -> Optional[str]:
|
|
1110
1186
|
start = entry.get("start_offset")
|
|
1111
1187
|
end = entry.get("end_offset")
|
|
1112
1188
|
if start is None or end is None:
|
|
@@ -1115,7 +1191,9 @@ class Engine:
|
|
|
1115
1191
|
start_offset = int(start)
|
|
1116
1192
|
end_offset = int(end)
|
|
1117
1193
|
except (TypeError, ValueError) as exc:
|
|
1118
|
-
self._app_server_logger.debug(
|
|
1194
|
+
self._app_server_logger.debug(
|
|
1195
|
+
"Failed to parse log range offsets for run %s: %s", run_id, exc
|
|
1196
|
+
)
|
|
1119
1197
|
return None
|
|
1120
1198
|
if end_offset < start_offset:
|
|
1121
1199
|
return None
|
|
@@ -1131,7 +1209,9 @@ class Engine:
|
|
|
1131
1209
|
data = f.read(end_offset - start_offset)
|
|
1132
1210
|
return data.decode("utf-8", errors="replace")
|
|
1133
1211
|
except (FileNotFoundError, OSError) as exc:
|
|
1134
|
-
self._app_server_logger.debug(
|
|
1212
|
+
self._app_server_logger.debug(
|
|
1213
|
+
"Failed to read log range for run %s: %s", run_id, exc
|
|
1214
|
+
)
|
|
1135
1215
|
return None
|
|
1136
1216
|
|
|
1137
1217
|
def _build_app_server_prompt(self, prev_output: Optional[str]) -> str:
|
|
@@ -1166,107 +1246,6 @@ class Engine:
|
|
|
1166
1246
|
return 1
|
|
1167
1247
|
raise
|
|
1168
1248
|
|
|
1169
|
-
async def _run_agent_turn_async(
|
|
1170
|
-
self,
|
|
1171
|
-
agent_id: str,
|
|
1172
|
-
prompt: str,
|
|
1173
|
-
run_id: int,
|
|
1174
|
-
*,
|
|
1175
|
-
external_stop_flag: Optional[threading.Event] = None,
|
|
1176
|
-
) -> int:
|
|
1177
|
-
orchestrator = self._get_orchestrator(agent_id)
|
|
1178
|
-
if orchestrator is None:
|
|
1179
|
-
self.log_line(
|
|
1180
|
-
run_id,
|
|
1181
|
-
f"error: agent '{agent_id}' backend is not configured",
|
|
1182
|
-
)
|
|
1183
|
-
return 1
|
|
1184
|
-
|
|
1185
|
-
thread_key = f"autorunner.{agent_id}"
|
|
1186
|
-
with state_lock(self.state_path):
|
|
1187
|
-
state = load_state(self.state_path)
|
|
1188
|
-
effective_model = state.autorunner_model_override or self.config.codex_model
|
|
1189
|
-
effective_effort = (
|
|
1190
|
-
state.autorunner_effort_override or self.config.codex_reasoning
|
|
1191
|
-
)
|
|
1192
|
-
|
|
1193
|
-
with self._app_server_threads_lock:
|
|
1194
|
-
conversation_id = self._app_server_threads.get_thread_id(thread_key)
|
|
1195
|
-
if not conversation_id:
|
|
1196
|
-
try:
|
|
1197
|
-
conversation_info = (
|
|
1198
|
-
await orchestrator.create_or_resume_conversation(
|
|
1199
|
-
self.repo_root, agent_id
|
|
1200
|
-
)
|
|
1201
|
-
)
|
|
1202
|
-
conversation_id = conversation_info.id
|
|
1203
|
-
self._app_server_threads.set_thread_id(thread_key, conversation_id)
|
|
1204
|
-
except Exception as exc:
|
|
1205
|
-
self.log_line(
|
|
1206
|
-
run_id, f"error: failed to create conversation: {exc}"
|
|
1207
|
-
)
|
|
1208
|
-
return 1
|
|
1209
|
-
|
|
1210
|
-
if conversation_id:
|
|
1211
|
-
self._update_run_telemetry(run_id, thread_id=conversation_id)
|
|
1212
|
-
|
|
1213
|
-
approval_policy = state.autorunner_approval_policy or "never"
|
|
1214
|
-
sandbox_mode = state.autorunner_sandbox_mode or "dangerFullAccess"
|
|
1215
|
-
if sandbox_mode == "workspaceWrite":
|
|
1216
|
-
sandbox_policy: Union[Dict[str, Any], str] = {
|
|
1217
|
-
"type": "workspaceWrite",
|
|
1218
|
-
"writableRoots": [str(self.repo_root)],
|
|
1219
|
-
"networkAccess": bool(state.autorunner_workspace_write_network),
|
|
1220
|
-
}
|
|
1221
|
-
else:
|
|
1222
|
-
sandbox_policy = sandbox_mode
|
|
1223
|
-
|
|
1224
|
-
stop_event = asyncio.Event()
|
|
1225
|
-
stop_task: Optional[asyncio.Task] = None
|
|
1226
|
-
|
|
1227
|
-
if external_stop_flag:
|
|
1228
|
-
stop_task = asyncio.create_task(
|
|
1229
|
-
self._wait_for_stop(external_stop_flag, stop_event)
|
|
1230
|
-
)
|
|
1231
|
-
|
|
1232
|
-
try:
|
|
1233
|
-
result = await orchestrator.run_turn(
|
|
1234
|
-
self.repo_root,
|
|
1235
|
-
conversation_id,
|
|
1236
|
-
prompt,
|
|
1237
|
-
model=effective_model,
|
|
1238
|
-
reasoning=effective_effort,
|
|
1239
|
-
approval_mode=approval_policy,
|
|
1240
|
-
sandbox_policy=sandbox_policy,
|
|
1241
|
-
should_stop=stop_event.is_set,
|
|
1242
|
-
)
|
|
1243
|
-
if result.get("status") != "completed":
|
|
1244
|
-
self.log_line(
|
|
1245
|
-
run_id, f"error: turn failed with status {result.get('status')}"
|
|
1246
|
-
)
|
|
1247
|
-
return 1
|
|
1248
|
-
output = result.get("output", "")
|
|
1249
|
-
if output:
|
|
1250
|
-
self._log_app_server_output(run_id, output.splitlines())
|
|
1251
|
-
output_path = self._write_run_artifact(run_id, "output.txt", output)
|
|
1252
|
-
self._merge_run_index_entry(
|
|
1253
|
-
run_id, {"artifacts": {"output_path": str(output_path)}}
|
|
1254
|
-
)
|
|
1255
|
-
return 0
|
|
1256
|
-
except Exception as exc:
|
|
1257
|
-
self.log_line(run_id, f"error: {exc}")
|
|
1258
|
-
return 1
|
|
1259
|
-
finally:
|
|
1260
|
-
if stop_task is not None:
|
|
1261
|
-
stop_task.cancel()
|
|
1262
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
1263
|
-
await stop_task
|
|
1264
|
-
if stop_event.is_set():
|
|
1265
|
-
await orchestrator.interrupt_turn(
|
|
1266
|
-
self.repo_root, conversation_id, grace_seconds=30.0
|
|
1267
|
-
)
|
|
1268
|
-
self._last_run_interrupted = True
|
|
1269
|
-
|
|
1270
1249
|
async def _run_codex_app_server_async(
|
|
1271
1250
|
self,
|
|
1272
1251
|
prompt: str,
|
|
@@ -1379,15 +1358,13 @@ class Engine:
|
|
|
1379
1358
|
supervisor=supervisor,
|
|
1380
1359
|
)
|
|
1381
1360
|
self._last_run_interrupted = interrupted
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
run_id, {"artifacts": {"output_path": str(output_path)}}
|
|
1390
|
-
)
|
|
1361
|
+
handle_agent_output(
|
|
1362
|
+
self._log_app_server_output,
|
|
1363
|
+
self._write_run_artifact,
|
|
1364
|
+
self._merge_run_index_entry,
|
|
1365
|
+
run_id,
|
|
1366
|
+
turn_result.agent_messages,
|
|
1367
|
+
)
|
|
1391
1368
|
if turn_result.errors:
|
|
1392
1369
|
for error in turn_result.errors:
|
|
1393
1370
|
self.log_line(run_id, f"error: {error}")
|
|
@@ -1419,13 +1396,12 @@ class Engine:
|
|
|
1419
1396
|
msg = self.config.git_commit_message_template.replace(
|
|
1420
1397
|
"{run_id}", str(run_id)
|
|
1421
1398
|
).replace("#{run_id}", str(run_id))
|
|
1422
|
-
paths = [
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
]
|
|
1399
|
+
paths = []
|
|
1400
|
+
for key in ("active_context", "decisions", "spec"):
|
|
1401
|
+
try:
|
|
1402
|
+
paths.append(self.config.doc_path(key))
|
|
1403
|
+
except KeyError:
|
|
1404
|
+
pass
|
|
1429
1405
|
add_paths = [str(p.relative_to(self.repo_root)) for p in paths if p.exists()]
|
|
1430
1406
|
if not add_paths:
|
|
1431
1407
|
return
|
|
@@ -1465,9 +1441,19 @@ class Engine:
|
|
|
1465
1441
|
env_builder=env_builder,
|
|
1466
1442
|
logger=self._app_server_logger,
|
|
1467
1443
|
notification_handler=self._handle_app_server_notification,
|
|
1444
|
+
auto_restart=config.auto_restart,
|
|
1468
1445
|
max_handles=config.max_handles,
|
|
1469
1446
|
idle_ttl_seconds=config.idle_ttl_seconds,
|
|
1470
1447
|
request_timeout=config.request_timeout,
|
|
1448
|
+
turn_stall_timeout_seconds=config.turn_stall_timeout_seconds,
|
|
1449
|
+
turn_stall_poll_interval_seconds=config.turn_stall_poll_interval_seconds,
|
|
1450
|
+
turn_stall_recovery_min_interval_seconds=config.turn_stall_recovery_min_interval_seconds,
|
|
1451
|
+
max_message_bytes=config.client.max_message_bytes,
|
|
1452
|
+
oversize_preview_bytes=config.client.oversize_preview_bytes,
|
|
1453
|
+
max_oversize_drain_bytes=config.client.max_oversize_drain_bytes,
|
|
1454
|
+
restart_backoff_initial_seconds=config.client.restart_backoff_initial_seconds,
|
|
1455
|
+
restart_backoff_max_seconds=config.client.restart_backoff_max_seconds,
|
|
1456
|
+
restart_backoff_jitter_ratio=config.client.restart_backoff_jitter_ratio,
|
|
1471
1457
|
)
|
|
1472
1458
|
|
|
1473
1459
|
def _ensure_app_server_supervisor(
|
|
@@ -1509,6 +1495,7 @@ class Engine:
|
|
|
1509
1495
|
request_timeout=config.request_timeout,
|
|
1510
1496
|
max_handles=config.max_handles,
|
|
1511
1497
|
idle_ttl_seconds=config.idle_ttl_seconds,
|
|
1498
|
+
session_stall_timeout_seconds=self.config.opencode.session_stall_timeout_seconds,
|
|
1512
1499
|
base_env=None,
|
|
1513
1500
|
subagent_models=subagent_models,
|
|
1514
1501
|
)
|
|
@@ -1536,22 +1523,6 @@ class Engine:
|
|
|
1536
1523
|
except Exception as exc:
|
|
1537
1524
|
self._app_server_logger.warning("opencode supervisor close failed: %s", exc)
|
|
1538
1525
|
|
|
1539
|
-
def _get_orchestrator(self, agent_id: str):
|
|
1540
|
-
if agent_id == "opencode":
|
|
1541
|
-
opencode_sup = self._ensure_opencode_supervisor()
|
|
1542
|
-
if opencode_sup is None:
|
|
1543
|
-
return None
|
|
1544
|
-
return create_orchestrator(agent_id, opencode_supervisor=opencode_sup)
|
|
1545
|
-
else:
|
|
1546
|
-
app_server_sup = self._ensure_app_server_supervisor(
|
|
1547
|
-
lambda workspace_root, workspace_id, state_dir: {}
|
|
1548
|
-
)
|
|
1549
|
-
return create_orchestrator(
|
|
1550
|
-
agent_id,
|
|
1551
|
-
codex_supervisor=app_server_sup,
|
|
1552
|
-
codex_events=self._app_server_events,
|
|
1553
|
-
)
|
|
1554
|
-
|
|
1555
1526
|
async def _wait_for_stop(
|
|
1556
1527
|
self,
|
|
1557
1528
|
external_stop_flag: Optional[threading.Event],
|
|
@@ -1669,8 +1640,9 @@ class Engine:
|
|
|
1669
1640
|
await client.get_session(thread_id)
|
|
1670
1641
|
except Exception as exc:
|
|
1671
1642
|
self._app_server_logger.debug(
|
|
1672
|
-
"Failed to get existing opencode session '%s': %s",
|
|
1643
|
+
"Failed to get existing opencode session '%s' for run %s: %s",
|
|
1673
1644
|
thread_id,
|
|
1645
|
+
run_id,
|
|
1674
1646
|
exc,
|
|
1675
1647
|
)
|
|
1676
1648
|
self._app_server_threads.reset_thread(key)
|
|
@@ -1742,11 +1714,13 @@ class Engine:
|
|
|
1742
1714
|
client,
|
|
1743
1715
|
session_id=thread_id,
|
|
1744
1716
|
workspace_path=str(self.repo_root),
|
|
1717
|
+
model_payload=model_payload,
|
|
1745
1718
|
permission_policy=permission_policy,
|
|
1746
1719
|
question_policy="auto_first_option",
|
|
1747
1720
|
should_stop=active.interrupt_event.is_set,
|
|
1748
1721
|
part_handler=_opencode_part_handler,
|
|
1749
1722
|
ready_event=ready_event,
|
|
1723
|
+
stall_timeout_seconds=self.config.opencode.session_stall_timeout_seconds,
|
|
1750
1724
|
)
|
|
1751
1725
|
)
|
|
1752
1726
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
@@ -1843,7 +1817,12 @@ class Engine:
|
|
|
1843
1817
|
output_result = OpenCodeTurnOutput(
|
|
1844
1818
|
text=fallback.text, error=fallback.error
|
|
1845
1819
|
)
|
|
1820
|
+
self.log_line(run_id, "info: opencode fallback message used")
|
|
1846
1821
|
finally:
|
|
1822
|
+
# Flush buffered reasoning deltas before cleanup, so partial reasoning is still logged
|
|
1823
|
+
# even when the turn is aborted, times out, or is interrupted.
|
|
1824
|
+
for line in self._opencode_event_formatter.flush_all_reasoning():
|
|
1825
|
+
self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
|
|
1847
1826
|
stop_task.cancel()
|
|
1848
1827
|
with contextlib.suppress(asyncio.CancelledError):
|
|
1849
1828
|
await stop_task
|
|
@@ -1854,17 +1833,20 @@ class Engine:
|
|
|
1854
1833
|
if opencode_turn_started:
|
|
1855
1834
|
await supervisor.mark_turn_finished(self.repo_root)
|
|
1856
1835
|
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
self.
|
|
1866
|
-
|
|
1867
|
-
|
|
1836
|
+
if not output_result.text:
|
|
1837
|
+
self.log_line(
|
|
1838
|
+
run_id,
|
|
1839
|
+
"info: opencode returned empty output (error=%s)"
|
|
1840
|
+
% (output_result.error or "none"),
|
|
1841
|
+
)
|
|
1842
|
+
if output_result.text:
|
|
1843
|
+
handle_agent_output(
|
|
1844
|
+
self._log_app_server_output,
|
|
1845
|
+
self._write_run_artifact,
|
|
1846
|
+
self._merge_run_index_entry,
|
|
1847
|
+
run_id,
|
|
1848
|
+
output_result.text,
|
|
1849
|
+
)
|
|
1868
1850
|
if output_result.error:
|
|
1869
1851
|
self.log_line(
|
|
1870
1852
|
run_id, f"error: opencode session error: {output_result.error}"
|
|
@@ -1894,8 +1876,10 @@ class Engine:
|
|
|
1894
1876
|
)
|
|
1895
1877
|
)
|
|
1896
1878
|
no_progress_count = 0
|
|
1897
|
-
|
|
1898
|
-
|
|
1879
|
+
ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
|
|
1880
|
+
initial_tickets = list_ticket_paths(ticket_dir)
|
|
1881
|
+
last_done_count = sum(1 for path in initial_tickets if ticket_is_done(path))
|
|
1882
|
+
last_outstanding_count = len(initial_tickets) - last_done_count
|
|
1899
1883
|
exit_reason: Optional[str] = None
|
|
1900
1884
|
|
|
1901
1885
|
try:
|
|
@@ -1949,9 +1933,11 @@ class Engine:
|
|
|
1949
1933
|
break
|
|
1950
1934
|
|
|
1951
1935
|
# Check for no progress across runs
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1936
|
+
current_tickets = list_ticket_paths(ticket_dir)
|
|
1937
|
+
current_done_count = sum(
|
|
1938
|
+
1 for path in current_tickets if ticket_is_done(path)
|
|
1939
|
+
)
|
|
1940
|
+
current_outstanding_count = len(current_tickets) - current_done_count
|
|
1955
1941
|
|
|
1956
1942
|
# Check if there was any meaningful progress
|
|
1957
1943
|
has_progress = (
|
|
@@ -1959,25 +1945,55 @@ class Engine:
|
|
|
1959
1945
|
or current_done_count != last_done_count
|
|
1960
1946
|
)
|
|
1961
1947
|
|
|
1962
|
-
# Check if there was any meaningful output (diff,
|
|
1948
|
+
# Check if there was any meaningful output (diff, plan, etc.)
|
|
1963
1949
|
has_output = False
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1950
|
+
run_entry = self._run_index_store.get_entry(run_id)
|
|
1951
|
+
if run_entry:
|
|
1952
|
+
artifacts = run_entry.get("artifacts", {})
|
|
1953
|
+
if isinstance(artifacts, dict):
|
|
1954
|
+
diff_path = artifacts.get("diff_path")
|
|
1955
|
+
if diff_path:
|
|
1956
|
+
try:
|
|
1957
|
+
diff_content = (
|
|
1958
|
+
Path(diff_path).read_text(encoding="utf-8").strip()
|
|
1959
|
+
)
|
|
1960
|
+
has_output = len(diff_content) > 0
|
|
1961
|
+
except (OSError, IOError):
|
|
1962
|
+
pass
|
|
1963
|
+
if not has_output:
|
|
1964
|
+
plan_path = artifacts.get("plan_path")
|
|
1965
|
+
if plan_path:
|
|
1966
|
+
try:
|
|
1967
|
+
plan_content = (
|
|
1968
|
+
Path(plan_path)
|
|
1969
|
+
.read_text(encoding="utf-8")
|
|
1970
|
+
.strip()
|
|
1971
|
+
)
|
|
1972
|
+
has_output = len(plan_content) > 0
|
|
1973
|
+
except (OSError, IOError):
|
|
1974
|
+
pass
|
|
1978
1975
|
|
|
1979
1976
|
if not has_progress and not has_output:
|
|
1980
1977
|
no_progress_count += 1
|
|
1978
|
+
|
|
1979
|
+
evidence = {
|
|
1980
|
+
"outstanding_count": current_outstanding_count,
|
|
1981
|
+
"done_count": current_done_count,
|
|
1982
|
+
"has_diff": bool(
|
|
1983
|
+
run_entry
|
|
1984
|
+
and isinstance(run_entry.get("artifacts"), dict)
|
|
1985
|
+
and run_entry["artifacts"].get("diff_path")
|
|
1986
|
+
),
|
|
1987
|
+
"has_plan": bool(
|
|
1988
|
+
run_entry
|
|
1989
|
+
and isinstance(run_entry.get("artifacts"), dict)
|
|
1990
|
+
and run_entry["artifacts"].get("plan_path")
|
|
1991
|
+
),
|
|
1992
|
+
"run_id": run_id,
|
|
1993
|
+
}
|
|
1994
|
+
self._emit_event(
|
|
1995
|
+
run_id, "run.no_progress", count=no_progress_count, **evidence
|
|
1996
|
+
)
|
|
1981
1997
|
self.log_line(
|
|
1982
1998
|
run_id,
|
|
1983
1999
|
f"info: no progress detected ({no_progress_count}/{self.config.runner_no_progress_threshold} runs without progress)",
|
|
@@ -2030,12 +2046,16 @@ class Engine:
|
|
|
2030
2046
|
for line in tb.splitlines():
|
|
2031
2047
|
self.log_line(run_id, f"traceback: {line}")
|
|
2032
2048
|
except (OSError, IOError) as exc:
|
|
2033
|
-
self._app_server_logger.error(
|
|
2049
|
+
self._app_server_logger.error(
|
|
2050
|
+
"Failed to log run_loop crash for run %s: %s", run_id, exc
|
|
2051
|
+
)
|
|
2034
2052
|
try:
|
|
2035
2053
|
self._update_state("error", run_id, 1, finished=True)
|
|
2036
2054
|
except (OSError, IOError) as exc:
|
|
2037
2055
|
self._app_server_logger.error(
|
|
2038
|
-
"Failed to update state after run_loop crash: %s",
|
|
2056
|
+
"Failed to update state after run_loop crash for run %s: %s",
|
|
2057
|
+
run_id,
|
|
2058
|
+
exc,
|
|
2039
2059
|
)
|
|
2040
2060
|
finally:
|
|
2041
2061
|
try:
|
|
@@ -2044,7 +2064,9 @@ class Engine:
|
|
|
2044
2064
|
last_exit_code=last_exit_code,
|
|
2045
2065
|
)
|
|
2046
2066
|
except Exception as exc:
|
|
2047
|
-
self._app_server_logger.warning(
|
|
2067
|
+
self._app_server_logger.warning(
|
|
2068
|
+
"End-of-run review failed for run %s: %s", run_id, exc
|
|
2069
|
+
)
|
|
2048
2070
|
await self._close_app_server_supervisor()
|
|
2049
2071
|
await self._close_opencode_supervisor()
|
|
2050
2072
|
# IMPORTANT: lock ownership is managed by the caller (CLI/Hub/Server runner).
|
|
@@ -2208,8 +2230,12 @@ class Engine:
|
|
|
2208
2230
|
started: bool = False,
|
|
2209
2231
|
finished: bool = False,
|
|
2210
2232
|
) -> None:
|
|
2233
|
+
prev_status: Optional[str] = None
|
|
2234
|
+
last_run_started_at: Optional[str] = None
|
|
2235
|
+
last_run_finished_at: Optional[str] = None
|
|
2211
2236
|
with state_lock(self.state_path):
|
|
2212
2237
|
current = load_state(self.state_path)
|
|
2238
|
+
prev_status = current.status
|
|
2213
2239
|
last_run_started_at = current.last_run_started_at
|
|
2214
2240
|
last_run_finished_at = current.last_run_finished_at
|
|
2215
2241
|
runner_pid = current.runner_pid
|
|
@@ -2237,6 +2263,18 @@ class Engine:
|
|
|
2237
2263
|
repo_to_session=current.repo_to_session,
|
|
2238
2264
|
)
|
|
2239
2265
|
save_state(self.state_path, new_state)
|
|
2266
|
+
if run_id > 0 and prev_status != status:
|
|
2267
|
+
payload: dict[str, Any] = {
|
|
2268
|
+
"from_status": prev_status,
|
|
2269
|
+
"to_status": status,
|
|
2270
|
+
}
|
|
2271
|
+
if exit_code is not None:
|
|
2272
|
+
payload["exit_code"] = exit_code
|
|
2273
|
+
if started and last_run_started_at:
|
|
2274
|
+
payload["started_at"] = last_run_started_at
|
|
2275
|
+
if finished and last_run_finished_at:
|
|
2276
|
+
payload["finished_at"] = last_run_finished_at
|
|
2277
|
+
self._emit_event(run_id, "run.state_changed", **payload)
|
|
2240
2278
|
|
|
2241
2279
|
|
|
2242
2280
|
def clear_stale_lock(lock_path: Path) -> bool:
|
|
@@ -2612,26 +2650,4 @@ def doctor(start_path: Path) -> DoctorReport:
|
|
|
2612
2650
|
"Install git or disable worktrees.",
|
|
2613
2651
|
)
|
|
2614
2652
|
|
|
2615
|
-
telegram_cfg = None
|
|
2616
|
-
if isinstance(config.raw, dict):
|
|
2617
|
-
telegram_cfg = config.raw.get("telegram_bot")
|
|
2618
|
-
if isinstance(telegram_cfg, dict) and telegram_cfg.get("enabled") is True:
|
|
2619
|
-
missing_telegram = missing_optional_dependencies((("httpx", "httpx"),))
|
|
2620
|
-
if missing_telegram:
|
|
2621
|
-
deps_list = ", ".join(missing_telegram)
|
|
2622
|
-
_append_check(
|
|
2623
|
-
checks,
|
|
2624
|
-
"telegram.dependencies",
|
|
2625
|
-
"error",
|
|
2626
|
-
f"Telegram is enabled but missing optional deps: {deps_list}",
|
|
2627
|
-
"Install with `pip install codex-autorunner[telegram]`.",
|
|
2628
|
-
)
|
|
2629
|
-
else:
|
|
2630
|
-
_append_check(
|
|
2631
|
-
checks,
|
|
2632
|
-
"telegram.dependencies",
|
|
2633
|
-
"ok",
|
|
2634
|
-
"Telegram dependencies are installed.",
|
|
2635
|
-
)
|
|
2636
|
-
|
|
2637
2653
|
return DoctorReport(checks=checks)
|