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
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import dataclasses
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import re
|
|
7
8
|
from typing import Any, AsyncIterator, Iterable, Optional
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
@@ -48,7 +49,18 @@ def _normalize_sse_event(event: SSEEvent) -> SSEEvent:
|
|
|
48
49
|
payload_obj = None
|
|
49
50
|
|
|
50
51
|
if isinstance(payload_obj, dict) and isinstance(payload_obj.get("payload"), dict):
|
|
51
|
-
|
|
52
|
+
outer = payload_obj
|
|
53
|
+
inner = dict(outer.get("payload") or {})
|
|
54
|
+
if "type" not in inner and isinstance(outer.get("type"), str):
|
|
55
|
+
inner["type"] = outer["type"]
|
|
56
|
+
for key in ("sessionID", "sessionId", "session_id"):
|
|
57
|
+
if key in outer and key not in inner:
|
|
58
|
+
inner[key] = outer[key]
|
|
59
|
+
if "session" in outer and "session" not in inner:
|
|
60
|
+
inner["session"] = outer["session"]
|
|
61
|
+
if "properties" in outer and "properties" not in inner:
|
|
62
|
+
inner["properties"] = outer["properties"]
|
|
63
|
+
payload_obj = inner
|
|
52
64
|
|
|
53
65
|
if isinstance(payload_obj, dict):
|
|
54
66
|
payload_type = payload_obj.get("type")
|
|
@@ -505,49 +517,70 @@ class OpenCodeClient:
|
|
|
505
517
|
|
|
506
518
|
async def fetch_openapi_spec(self) -> dict[str, Any]:
|
|
507
519
|
"""Fetch OpenAPI spec from /doc endpoint for capability negotiation."""
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
520
|
+
response = await self._client.get("/doc")
|
|
521
|
+
response.raise_for_status()
|
|
522
|
+
content = response.content
|
|
523
|
+
try:
|
|
524
|
+
spec = json.loads(content) if content else {}
|
|
525
|
+
log_event(
|
|
526
|
+
self._logger,
|
|
527
|
+
logging.INFO,
|
|
528
|
+
"opencode.openapi.fetched",
|
|
529
|
+
paths=len(spec.get("paths", {})) if isinstance(spec, dict) else 0,
|
|
530
|
+
has_components=(
|
|
531
|
+
"components" in spec if isinstance(spec, dict) else False
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
return spec
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
log_event(
|
|
537
|
+
self._logger,
|
|
538
|
+
logging.WARNING,
|
|
539
|
+
"opencode.openapi.parse_failed",
|
|
540
|
+
exc=exc,
|
|
541
|
+
)
|
|
542
|
+
raise OpenCodeProtocolError(
|
|
543
|
+
f"Failed to parse OpenAPI spec: {exc}",
|
|
544
|
+
status_code=response.status_code,
|
|
545
|
+
content_type=(
|
|
546
|
+
response.headers.get("content-type") if response else None
|
|
547
|
+
),
|
|
548
|
+
) from exc
|
|
537
549
|
|
|
538
550
|
def has_endpoint(
|
|
539
551
|
self, openapi_spec: dict[str, Any], method: str, path: str
|
|
540
552
|
) -> bool:
|
|
541
|
-
"""Check if endpoint is available in OpenAPI spec.
|
|
553
|
+
"""Check if endpoint is available in OpenAPI spec.
|
|
554
|
+
|
|
555
|
+
The OpenAPI spec sometimes uses different template parameter names (e.g.,
|
|
556
|
+
`{sessionID}` vs `{session_id}`). We normalize templates before matching so
|
|
557
|
+
capability detection does not depend on placeholder spelling.
|
|
558
|
+
"""
|
|
542
559
|
if not isinstance(openapi_spec, dict):
|
|
543
560
|
return False
|
|
544
561
|
paths = openapi_spec.get("paths", {})
|
|
545
562
|
if not isinstance(paths, dict):
|
|
546
563
|
return False
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
564
|
+
|
|
565
|
+
target = _normalize_template_path(path)
|
|
566
|
+
method = method.lower()
|
|
567
|
+
|
|
568
|
+
for candidate_path, info in paths.items():
|
|
569
|
+
if not isinstance(info, dict):
|
|
570
|
+
continue
|
|
571
|
+
if _normalize_template_path(candidate_path) != target:
|
|
572
|
+
continue
|
|
573
|
+
if method in info:
|
|
574
|
+
return True
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _normalize_template_path(path: str) -> str:
|
|
579
|
+
"""Collapse template placeholders to a canonical form.
|
|
580
|
+
|
|
581
|
+
Example: `/session/{sessionID}/prompt_async` -> `/session/{}/prompt_async`
|
|
582
|
+
"""
|
|
583
|
+
return re.sub(r"{[^/]+}", "{}", path)
|
|
551
584
|
|
|
552
585
|
|
|
553
586
|
__all__ = ["OpenCodeClient", "OpenCodeProtocolError", "OpenCodeApiProfile"]
|
|
@@ -66,11 +66,6 @@ class OpenCodeEventFormatter:
|
|
|
66
66
|
for line in complete_lines:
|
|
67
67
|
if line.strip():
|
|
68
68
|
lines.append(f"**{line.strip()}**")
|
|
69
|
-
|
|
70
|
-
remaining = coalescer.get_buffer()
|
|
71
|
-
if remaining and remaining.strip():
|
|
72
|
-
lines.append(f"**{remaining.strip()}**")
|
|
73
|
-
coalescer.clear()
|
|
74
69
|
return lines
|
|
75
70
|
|
|
76
71
|
def _format_tool_part(self, part: dict[str, Any]) -> list[str]:
|
|
@@ -120,6 +115,27 @@ class OpenCodeEventFormatter:
|
|
|
120
115
|
lines.append("exec")
|
|
121
116
|
lines.append(f"tool: {tool_name}")
|
|
122
117
|
|
|
118
|
+
input_preview: Optional[str] = None
|
|
119
|
+
for key in ("input", "command", "cmd", "script"):
|
|
120
|
+
value = part.get(key)
|
|
121
|
+
if isinstance(value, str) and value.strip():
|
|
122
|
+
input_preview = value.strip()
|
|
123
|
+
break
|
|
124
|
+
if input_preview is None:
|
|
125
|
+
args = part.get("args") or part.get("arguments") or part.get("params")
|
|
126
|
+
if isinstance(args, dict):
|
|
127
|
+
for key in ("command", "cmd", "script", "input"):
|
|
128
|
+
value = args.get(key)
|
|
129
|
+
if isinstance(value, str) and value.strip():
|
|
130
|
+
input_preview = value.strip()
|
|
131
|
+
break
|
|
132
|
+
elif isinstance(args, str) and args.strip():
|
|
133
|
+
input_preview = args.strip()
|
|
134
|
+
if input_preview:
|
|
135
|
+
if len(input_preview) > 240:
|
|
136
|
+
input_preview = input_preview[:240] + "…"
|
|
137
|
+
lines.append(f"input: {input_preview}")
|
|
138
|
+
|
|
123
139
|
return lines
|
|
124
140
|
|
|
125
141
|
def _format_patch_part(self, part: dict[str, Any]) -> list[str]:
|
|
@@ -15,6 +15,7 @@ from typing import (
|
|
|
15
15
|
Callable,
|
|
16
16
|
MutableMapping,
|
|
17
17
|
Optional,
|
|
18
|
+
cast,
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
import httpx
|
|
@@ -127,17 +128,19 @@ def extract_session_id(
|
|
|
127
128
|
return value
|
|
128
129
|
properties = payload.get("properties")
|
|
129
130
|
if isinstance(properties, dict):
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return value
|
|
133
|
-
part = properties.get("part")
|
|
134
|
-
if isinstance(part, dict):
|
|
135
|
-
value = part.get("sessionID")
|
|
131
|
+
for key in ("sessionID", "sessionId", "session_id"):
|
|
132
|
+
value = properties.get(key)
|
|
136
133
|
if isinstance(value, str) and value:
|
|
137
134
|
return value
|
|
135
|
+
part = properties.get("part")
|
|
136
|
+
if isinstance(part, dict):
|
|
137
|
+
for key in ("sessionID", "sessionId", "session_id"):
|
|
138
|
+
value = part.get(key)
|
|
139
|
+
if isinstance(value, str) and value:
|
|
140
|
+
return value
|
|
138
141
|
session = payload.get("session")
|
|
139
142
|
if isinstance(session, dict):
|
|
140
|
-
return extract_session_id(session, allow_fallback_id=
|
|
143
|
+
return extract_session_id(session, allow_fallback_id=True)
|
|
141
144
|
return None
|
|
142
145
|
|
|
143
146
|
|
|
@@ -676,7 +679,7 @@ async def opencode_missing_env(
|
|
|
676
679
|
providers = [entry for entry in payload if isinstance(entry, dict)]
|
|
677
680
|
for provider in providers:
|
|
678
681
|
pid = provider.get("id") or provider.get("providerID")
|
|
679
|
-
if pid != provider_id:
|
|
682
|
+
if not pid or pid != provider_id:
|
|
680
683
|
continue
|
|
681
684
|
if _provider_has_auth(pid, workspace_root):
|
|
682
685
|
return []
|
|
@@ -732,6 +735,7 @@ async def collect_opencode_output_from_events(
|
|
|
732
735
|
events: Optional[AsyncIterator[SSEEvent]] = None,
|
|
733
736
|
*,
|
|
734
737
|
session_id: str,
|
|
738
|
+
model_payload: Optional[dict[str, str]] = None,
|
|
735
739
|
progress_session_ids: Optional[set[str]] = None,
|
|
736
740
|
permission_policy: str = PERMISSION_ALLOW,
|
|
737
741
|
permission_handler: Optional[PermissionHandler] = None,
|
|
@@ -759,13 +763,16 @@ async def collect_opencode_output_from_events(
|
|
|
759
763
|
last_usage_total: Optional[int] = None
|
|
760
764
|
last_context_window: Optional[int] = None
|
|
761
765
|
part_types: dict[str, str] = {}
|
|
762
|
-
seen_question_request_ids: set[tuple[str, str]] = set()
|
|
766
|
+
seen_question_request_ids: set[tuple[Optional[str], str]] = set()
|
|
763
767
|
logged_permission_errors: set[str] = set()
|
|
764
768
|
normalized_question_policy = _normalize_question_policy(question_policy)
|
|
765
769
|
logger = logging.getLogger(__name__)
|
|
766
770
|
providers_cache: Optional[list[dict[str, Any]]] = None
|
|
767
771
|
context_window_cache: dict[str, Optional[int]] = {}
|
|
768
772
|
session_model_ids: Optional[tuple[Optional[str], Optional[str]]] = None
|
|
773
|
+
default_model_ids = (
|
|
774
|
+
_extract_model_ids(model_payload) if isinstance(model_payload, dict) else None
|
|
775
|
+
)
|
|
769
776
|
|
|
770
777
|
def _message_id_from_info(info: Any) -> Optional[str]:
|
|
771
778
|
if not isinstance(info, dict):
|
|
@@ -843,15 +850,19 @@ async def collect_opencode_output_from_events(
|
|
|
843
850
|
nonlocal session_model_ids
|
|
844
851
|
if session_model_ids is not None:
|
|
845
852
|
return session_model_ids
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
853
|
+
resolved_ids: Optional[tuple[Optional[str], Optional[str]]] = None
|
|
854
|
+
if session_fetcher is not None:
|
|
855
|
+
try:
|
|
856
|
+
payload = await session_fetcher()
|
|
857
|
+
resolved_ids = _extract_model_ids(payload)
|
|
858
|
+
except Exception:
|
|
859
|
+
resolved_ids = None
|
|
860
|
+
# If we failed to resolve model ids from the session (including the empty
|
|
861
|
+
# tuple case), fall back to the caller-provided model payload so we can
|
|
862
|
+
# still backfill usage metadata.
|
|
863
|
+
if not resolved_ids or all(value is None for value in resolved_ids):
|
|
864
|
+
resolved_ids = default_model_ids
|
|
865
|
+
session_model_ids = resolved_ids or (None, None)
|
|
855
866
|
return session_model_ids
|
|
856
867
|
|
|
857
868
|
async def _resolve_context_window_from_providers(
|
|
@@ -946,7 +957,7 @@ async def collect_opencode_output_from_events(
|
|
|
946
957
|
await aclose()
|
|
947
958
|
|
|
948
959
|
stream_iter = _new_stream().__aiter__()
|
|
949
|
-
|
|
960
|
+
last_relevant_event_at = time.monotonic()
|
|
950
961
|
last_primary_completion_at: Optional[float] = None
|
|
951
962
|
reconnect_attempts = 0
|
|
952
963
|
can_reconnect = (
|
|
@@ -981,6 +992,7 @@ async def collect_opencode_output_from_events(
|
|
|
981
992
|
session_id=session_id,
|
|
982
993
|
exc=exc,
|
|
983
994
|
)
|
|
995
|
+
idle_seconds = now - last_relevant_event_at
|
|
984
996
|
if _status_is_idle(status_type):
|
|
985
997
|
log_event(
|
|
986
998
|
logger,
|
|
@@ -988,7 +1000,7 @@ async def collect_opencode_output_from_events(
|
|
|
988
1000
|
"opencode.stream.stalled.session_idle",
|
|
989
1001
|
session_id=session_id,
|
|
990
1002
|
status_type=status_type,
|
|
991
|
-
idle_seconds=
|
|
1003
|
+
idle_seconds=idle_seconds,
|
|
992
1004
|
)
|
|
993
1005
|
if not text_parts and pending_text:
|
|
994
1006
|
_flush_all_pending_text()
|
|
@@ -1000,7 +1012,7 @@ async def collect_opencode_output_from_events(
|
|
|
1000
1012
|
"opencode.stream.stalled.after_completion",
|
|
1001
1013
|
session_id=session_id,
|
|
1002
1014
|
status_type=status_type,
|
|
1003
|
-
idle_seconds=
|
|
1015
|
+
idle_seconds=idle_seconds,
|
|
1004
1016
|
)
|
|
1005
1017
|
if not can_reconnect:
|
|
1006
1018
|
break
|
|
@@ -1015,7 +1027,7 @@ async def collect_opencode_output_from_events(
|
|
|
1015
1027
|
logging.WARNING,
|
|
1016
1028
|
"opencode.stream.stalled.reconnecting",
|
|
1017
1029
|
session_id=session_id,
|
|
1018
|
-
idle_seconds=
|
|
1030
|
+
idle_seconds=idle_seconds,
|
|
1019
1031
|
backoff_seconds=backoff,
|
|
1020
1032
|
status_type=status_type,
|
|
1021
1033
|
attempts=reconnect_attempts,
|
|
@@ -1023,21 +1035,86 @@ async def collect_opencode_output_from_events(
|
|
|
1023
1035
|
await _close_stream(stream_iter)
|
|
1024
1036
|
await asyncio.sleep(backoff)
|
|
1025
1037
|
stream_iter = _new_stream().__aiter__()
|
|
1038
|
+
last_relevant_event_at = now
|
|
1026
1039
|
continue
|
|
1027
|
-
|
|
1040
|
+
now = time.monotonic()
|
|
1028
1041
|
raw = event.data or ""
|
|
1029
1042
|
try:
|
|
1030
1043
|
payload = json.loads(raw) if raw else {}
|
|
1031
1044
|
except json.JSONDecodeError:
|
|
1032
1045
|
payload = {}
|
|
1033
1046
|
event_session_id = extract_session_id(payload)
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1047
|
+
is_relevant = False
|
|
1048
|
+
if event_session_id:
|
|
1049
|
+
if progress_session_ids is None:
|
|
1050
|
+
is_relevant = event_session_id == session_id
|
|
1051
|
+
else:
|
|
1052
|
+
is_relevant = event_session_id in progress_session_ids
|
|
1053
|
+
if not is_relevant:
|
|
1054
|
+
if (
|
|
1055
|
+
stall_timeout_seconds is not None
|
|
1056
|
+
and now - last_relevant_event_at > stall_timeout_seconds
|
|
1057
|
+
):
|
|
1058
|
+
idle_seconds = now - last_relevant_event_at
|
|
1059
|
+
last_relevant_event_at = now
|
|
1060
|
+
status_type = None
|
|
1061
|
+
if session_fetcher is not None:
|
|
1062
|
+
try:
|
|
1063
|
+
payload = await session_fetcher()
|
|
1064
|
+
status_type = _extract_status_type(payload)
|
|
1065
|
+
except Exception as exc:
|
|
1066
|
+
log_event(
|
|
1067
|
+
logger,
|
|
1068
|
+
logging.WARNING,
|
|
1069
|
+
"opencode.session.poll_failed",
|
|
1070
|
+
session_id=session_id,
|
|
1071
|
+
exc=exc,
|
|
1072
|
+
)
|
|
1073
|
+
if _status_is_idle(status_type):
|
|
1074
|
+
log_event(
|
|
1075
|
+
logger,
|
|
1076
|
+
logging.INFO,
|
|
1077
|
+
"opencode.stream.stalled.session_idle",
|
|
1078
|
+
session_id=session_id,
|
|
1079
|
+
status_type=status_type,
|
|
1080
|
+
idle_seconds=idle_seconds,
|
|
1081
|
+
)
|
|
1082
|
+
if not text_parts and pending_text:
|
|
1083
|
+
_flush_all_pending_text()
|
|
1084
|
+
break
|
|
1085
|
+
if last_primary_completion_at is not None:
|
|
1086
|
+
log_event(
|
|
1087
|
+
logger,
|
|
1088
|
+
logging.INFO,
|
|
1089
|
+
"opencode.stream.stalled.after_completion",
|
|
1090
|
+
session_id=session_id,
|
|
1091
|
+
status_type=status_type,
|
|
1092
|
+
idle_seconds=idle_seconds,
|
|
1093
|
+
)
|
|
1094
|
+
if not can_reconnect:
|
|
1095
|
+
break
|
|
1096
|
+
backoff_index = min(
|
|
1097
|
+
reconnect_attempts,
|
|
1098
|
+
len(_OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS) - 1,
|
|
1099
|
+
)
|
|
1100
|
+
backoff = _OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS[backoff_index]
|
|
1101
|
+
reconnect_attempts += 1
|
|
1102
|
+
log_event(
|
|
1103
|
+
logger,
|
|
1104
|
+
logging.WARNING,
|
|
1105
|
+
"opencode.stream.stalled.reconnecting",
|
|
1106
|
+
session_id=session_id,
|
|
1107
|
+
idle_seconds=idle_seconds,
|
|
1108
|
+
backoff_seconds=backoff,
|
|
1109
|
+
status_type=status_type,
|
|
1110
|
+
attempts=reconnect_attempts,
|
|
1111
|
+
)
|
|
1112
|
+
await _close_stream(stream_iter)
|
|
1113
|
+
await asyncio.sleep(backoff)
|
|
1114
|
+
stream_iter = _new_stream().__aiter__()
|
|
1040
1115
|
continue
|
|
1116
|
+
last_relevant_event_at = now
|
|
1117
|
+
reconnect_attempts = 0
|
|
1041
1118
|
is_primary_session = event_session_id == session_id
|
|
1042
1119
|
if event.event == "question.asked":
|
|
1043
1120
|
request_id, props = _extract_question_request(payload)
|
|
@@ -1419,6 +1496,7 @@ async def collect_opencode_output(
|
|
|
1419
1496
|
*,
|
|
1420
1497
|
session_id: str,
|
|
1421
1498
|
workspace_path: str,
|
|
1499
|
+
model_payload: Optional[dict[str, str]] = None,
|
|
1422
1500
|
progress_session_ids: Optional[set[str]] = None,
|
|
1423
1501
|
permission_policy: str = PERMISSION_ALLOW,
|
|
1424
1502
|
permission_handler: Optional[PermissionHandler] = None,
|
|
@@ -1427,6 +1505,7 @@ async def collect_opencode_output(
|
|
|
1427
1505
|
should_stop: Optional[Callable[[], bool]] = None,
|
|
1428
1506
|
ready_event: Optional[Any] = None,
|
|
1429
1507
|
part_handler: Optional[PartHandler] = None,
|
|
1508
|
+
stall_timeout_seconds: Optional[float] = _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS,
|
|
1430
1509
|
) -> OpenCodeTurnOutput:
|
|
1431
1510
|
async def _respond(request_id: str, reply: str) -> None:
|
|
1432
1511
|
await client.respond_permission(request_id=request_id, reply=reply)
|
|
@@ -1438,14 +1517,21 @@ async def collect_opencode_output(
|
|
|
1438
1517
|
await client.reject_question(request_id)
|
|
1439
1518
|
|
|
1440
1519
|
def _stream_factory() -> AsyncIterator[SSEEvent]:
|
|
1441
|
-
return
|
|
1520
|
+
return cast(
|
|
1521
|
+
AsyncIterator[SSEEvent],
|
|
1522
|
+
client.stream_events(directory=workspace_path, ready_event=ready_event),
|
|
1523
|
+
)
|
|
1442
1524
|
|
|
1443
1525
|
async def _fetch_session() -> Any:
|
|
1444
1526
|
statuses = await client.session_status(directory=workspace_path)
|
|
1445
1527
|
if isinstance(statuses, dict):
|
|
1446
1528
|
session_status = statuses.get(session_id)
|
|
1529
|
+
if session_status is None:
|
|
1530
|
+
return {"status": {"type": "idle"}}
|
|
1447
1531
|
if isinstance(session_status, dict):
|
|
1448
1532
|
return {"status": session_status}
|
|
1533
|
+
if isinstance(session_status, str):
|
|
1534
|
+
return {"status": session_status}
|
|
1449
1535
|
return {"status": {}}
|
|
1450
1536
|
|
|
1451
1537
|
async def _fetch_providers() -> Any:
|
|
@@ -1465,8 +1551,10 @@ async def collect_opencode_output(
|
|
|
1465
1551
|
reject_question=_reject_question,
|
|
1466
1552
|
part_handler=part_handler,
|
|
1467
1553
|
event_stream_factory=_stream_factory,
|
|
1554
|
+
model_payload=model_payload,
|
|
1468
1555
|
session_fetcher=_fetch_session,
|
|
1469
1556
|
provider_fetcher=_fetch_providers,
|
|
1557
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
1470
1558
|
)
|
|
1471
1559
|
|
|
1472
1560
|
|
|
@@ -11,6 +11,7 @@ from typing import Any, Mapping, Optional, Sequence
|
|
|
11
11
|
import httpx
|
|
12
12
|
|
|
13
13
|
from ...core.logging_utils import log_event
|
|
14
|
+
from ...core.supervisor_utils import evict_lru_handle_locked, pop_idle_handles_locked
|
|
14
15
|
from ...core.utils import infer_home_from_workspace, subprocess_env
|
|
15
16
|
from ...workspace import canonical_workspace_root, workspace_id_for_path
|
|
16
17
|
from .client import OpenCodeClient
|
|
@@ -53,20 +54,28 @@ class OpenCodeSupervisor:
|
|
|
53
54
|
base_env: Optional[Mapping[str, str]] = None,
|
|
54
55
|
base_url: Optional[str] = None,
|
|
55
56
|
subagent_models: Optional[Mapping[str, str]] = None,
|
|
57
|
+
session_stall_timeout_seconds: Optional[float] = None,
|
|
56
58
|
) -> None:
|
|
57
59
|
self._command = [str(arg) for arg in command]
|
|
58
60
|
self._logger = logger or logging.getLogger(__name__)
|
|
59
61
|
self._request_timeout = request_timeout
|
|
60
62
|
self._max_handles = max_handles
|
|
61
63
|
self._idle_ttl_seconds = idle_ttl_seconds
|
|
64
|
+
self._session_stall_timeout_seconds = session_stall_timeout_seconds
|
|
62
65
|
if password and not username:
|
|
63
66
|
username = "opencode"
|
|
64
|
-
self._auth
|
|
67
|
+
self._auth: Optional[tuple[str, str]] = (
|
|
68
|
+
(username, password) if password and username else None
|
|
69
|
+
)
|
|
65
70
|
self._base_env = base_env
|
|
66
71
|
self._base_url = base_url
|
|
67
72
|
self._subagent_models = subagent_models or {}
|
|
68
73
|
self._handles: dict[str, OpenCodeHandle] = {}
|
|
69
|
-
self._lock
|
|
74
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def session_stall_timeout_seconds(self) -> Optional[float]:
|
|
78
|
+
return self._session_stall_timeout_seconds
|
|
70
79
|
|
|
71
80
|
async def get_client(self, workspace_root: Path) -> OpenCodeClient:
|
|
72
81
|
canonical_root = canonical_workspace_root(workspace_root)
|
|
@@ -79,7 +88,7 @@ class OpenCodeSupervisor:
|
|
|
79
88
|
return handle.client
|
|
80
89
|
|
|
81
90
|
async def close_all(self) -> None:
|
|
82
|
-
async with self.
|
|
91
|
+
async with self._get_lock():
|
|
83
92
|
handles = list(self._handles.values())
|
|
84
93
|
self._handles = {}
|
|
85
94
|
for handle in handles:
|
|
@@ -98,7 +107,7 @@ class OpenCodeSupervisor:
|
|
|
98
107
|
async def mark_turn_started(self, workspace_root: Path) -> None:
|
|
99
108
|
canonical_root = canonical_workspace_root(workspace_root)
|
|
100
109
|
workspace_id = workspace_id_for_path(canonical_root)
|
|
101
|
-
async with self.
|
|
110
|
+
async with self._get_lock():
|
|
102
111
|
handle = self._handles.get(workspace_id)
|
|
103
112
|
if handle is None:
|
|
104
113
|
return
|
|
@@ -108,7 +117,7 @@ class OpenCodeSupervisor:
|
|
|
108
117
|
async def mark_turn_finished(self, workspace_root: Path) -> None:
|
|
109
118
|
canonical_root = canonical_workspace_root(workspace_root)
|
|
110
119
|
workspace_id = workspace_id_for_path(canonical_root)
|
|
111
|
-
async with self.
|
|
120
|
+
async with self._get_lock():
|
|
112
121
|
handle = self._handles.get(workspace_id)
|
|
113
122
|
if handle is None:
|
|
114
123
|
return
|
|
@@ -187,7 +196,7 @@ class OpenCodeSupervisor:
|
|
|
187
196
|
) -> OpenCodeHandle:
|
|
188
197
|
handles_to_close: list[OpenCodeHandle] = []
|
|
189
198
|
evicted_id: Optional[str] = None
|
|
190
|
-
async with self.
|
|
199
|
+
async with self._get_lock():
|
|
191
200
|
existing = self._handles.get(workspace_id)
|
|
192
201
|
if existing is not None:
|
|
193
202
|
existing.last_used_at = time.monotonic()
|
|
@@ -287,7 +296,7 @@ class OpenCodeSupervisor:
|
|
|
287
296
|
logging.WARNING,
|
|
288
297
|
"opencode.openapi.fetch_failed",
|
|
289
298
|
base_url=base_url,
|
|
290
|
-
exc=
|
|
299
|
+
exc=exc,
|
|
291
300
|
)
|
|
292
301
|
handle.openapi_spec = {}
|
|
293
302
|
handle.started = True
|
|
@@ -356,7 +365,7 @@ class OpenCodeSupervisor:
|
|
|
356
365
|
logging.WARNING,
|
|
357
366
|
"opencode.openapi.fetch_failed",
|
|
358
367
|
base_url=base_url,
|
|
359
|
-
exc=
|
|
368
|
+
exc=exc,
|
|
360
369
|
)
|
|
361
370
|
handle.openapi_spec = {}
|
|
362
371
|
self._start_stdout_drain(handle)
|
|
@@ -468,53 +477,32 @@ class OpenCodeSupervisor:
|
|
|
468
477
|
return match.group(1)
|
|
469
478
|
|
|
470
479
|
async def _pop_idle_handles(self) -> list[OpenCodeHandle]:
|
|
471
|
-
async with self.
|
|
480
|
+
async with self._get_lock():
|
|
472
481
|
return self._pop_idle_handles_locked()
|
|
473
482
|
|
|
483
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
484
|
+
if self._lock is None:
|
|
485
|
+
self._lock = asyncio.Lock()
|
|
486
|
+
return self._lock
|
|
487
|
+
|
|
474
488
|
def _pop_idle_handles_locked(self) -> list[OpenCodeHandle]:
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
logging.INFO,
|
|
484
|
-
"opencode.handle.prune.skipped",
|
|
485
|
-
reason="active_turns",
|
|
486
|
-
workspace_id=handle.workspace_id,
|
|
487
|
-
workspace_root=str(handle.workspace_root),
|
|
488
|
-
active_turns=handle.active_turns,
|
|
489
|
-
)
|
|
490
|
-
continue
|
|
491
|
-
if handle.last_used_at and handle.last_used_at < cutoff:
|
|
492
|
-
self._handles.pop(handle.workspace_id, None)
|
|
493
|
-
stale.append(handle)
|
|
494
|
-
return stale
|
|
489
|
+
return pop_idle_handles_locked(
|
|
490
|
+
self._handles,
|
|
491
|
+
self._idle_ttl_seconds,
|
|
492
|
+
self._logger,
|
|
493
|
+
"opencode",
|
|
494
|
+
last_used_at_getter=lambda h: h.last_used_at,
|
|
495
|
+
should_skip_prune=lambda h: h.active_turns > 0,
|
|
496
|
+
)
|
|
495
497
|
|
|
496
498
|
def _evict_lru_handle_locked(self) -> Optional[OpenCodeHandle]:
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
return None
|
|
501
|
-
lru_handle = min(
|
|
502
|
-
self._handles.values(),
|
|
503
|
-
key=lambda handle: handle.last_used_at or 0.0,
|
|
504
|
-
)
|
|
505
|
-
log_event(
|
|
499
|
+
return evict_lru_handle_locked(
|
|
500
|
+
self._handles,
|
|
501
|
+
self._max_handles,
|
|
506
502
|
self._logger,
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
reason="max_handles",
|
|
510
|
-
workspace_id=lru_handle.workspace_id,
|
|
511
|
-
workspace_root=str(lru_handle.workspace_root),
|
|
512
|
-
max_handles=self._max_handles,
|
|
513
|
-
handle_count=len(self._handles),
|
|
514
|
-
last_used_at=lru_handle.last_used_at,
|
|
503
|
+
"opencode",
|
|
504
|
+
last_used_at_getter=lambda h: h.last_used_at or 0.0,
|
|
515
505
|
)
|
|
516
|
-
self._handles.pop(lru_handle.workspace_id, None)
|
|
517
|
-
return lru_handle
|
|
518
506
|
|
|
519
507
|
|
|
520
508
|
__all__ = ["OpenCodeHandle", "OpenCodeSupervisor", "OpenCodeSupervisorError"]
|