@researai/deepscientist 1.5.15 → 1.5.17
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.
- package/README.md +385 -104
- package/bin/ds.js +1241 -110
- package/docs/en/00_QUICK_START.md +100 -19
- package/docs/en/01_SETTINGS_REFERENCE.md +34 -1
- package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/en/05_TUI_GUIDE.md +6 -0
- package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
- package/docs/en/09_DOCTOR.md +25 -8
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
- package/docs/en/15_CODEX_PROVIDER_SETUP.md +37 -11
- package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
- package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
- package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
- package/docs/en/21_LOCAL_MODEL_BACKENDS_GUIDE.md +283 -0
- package/docs/en/91_DEVELOPMENT.md +237 -0
- package/docs/en/README.md +24 -2
- package/docs/zh/00_QUICK_START.md +89 -19
- package/docs/zh/01_SETTINGS_REFERENCE.md +34 -1
- package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/zh/05_TUI_GUIDE.md +6 -0
- package/docs/zh/09_DOCTOR.md +26 -9
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
- package/docs/zh/15_CODEX_PROVIDER_SETUP.md +37 -11
- package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
- package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
- package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
- package/docs/zh/21_LOCAL_MODEL_BACKENDS_GUIDE.md +281 -0
- package/docs/zh/README.md +24 -2
- package/install.sh +46 -4
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/acp/envelope.py +6 -0
- package/src/deepscientist/artifact/service.py +647 -22
- package/src/deepscientist/bash_exec/service.py +234 -9
- package/src/deepscientist/bridges/connectors.py +8 -2
- package/src/deepscientist/cli.py +115 -19
- package/src/deepscientist/codex_cli_compat.py +367 -22
- package/src/deepscientist/config/models.py +2 -1
- package/src/deepscientist/config/service.py +183 -13
- package/src/deepscientist/daemon/api/handlers.py +255 -31
- package/src/deepscientist/daemon/api/router.py +9 -0
- package/src/deepscientist/daemon/app.py +1146 -105
- package/src/deepscientist/diagnostics/__init__.py +6 -0
- package/src/deepscientist/diagnostics/runner_failures.py +130 -0
- package/src/deepscientist/doctor.py +207 -3
- package/src/deepscientist/gitops/__init__.py +10 -1
- package/src/deepscientist/gitops/diff.py +129 -0
- package/src/deepscientist/gitops/service.py +4 -1
- package/src/deepscientist/mcp/server.py +39 -0
- package/src/deepscientist/prompts/builder.py +275 -34
- package/src/deepscientist/quest/layout.py +15 -2
- package/src/deepscientist/quest/service.py +707 -55
- package/src/deepscientist/quest/stage_views.py +6 -1
- package/src/deepscientist/runners/codex.py +143 -43
- package/src/deepscientist/shared.py +19 -0
- package/src/deepscientist/skills/__init__.py +2 -2
- package/src/deepscientist/skills/installer.py +196 -5
- package/src/deepscientist/skills/registry.py +66 -0
- package/src/prompts/connectors/qq.md +18 -8
- package/src/prompts/connectors/weixin.md +16 -6
- package/src/prompts/contracts/shared_interaction.md +14 -2
- package/src/prompts/system.md +23 -5
- package/src/prompts/system_copilot.md +56 -0
- package/src/skills/analysis-campaign/SKILL.md +1 -0
- package/src/skills/baseline/SKILL.md +8 -0
- package/src/skills/decision/SKILL.md +8 -0
- package/src/skills/experiment/SKILL.md +8 -0
- package/src/skills/figure-polish/SKILL.md +1 -0
- package/src/skills/finalize/SKILL.md +1 -0
- package/src/skills/idea/SKILL.md +1 -0
- package/src/skills/intake-audit/SKILL.md +8 -0
- package/src/skills/mentor/SKILL.md +217 -0
- package/src/skills/mentor/references/correction-rules.md +210 -0
- package/src/skills/mentor/references/knowledge-profile.md +91 -0
- package/src/skills/mentor/references/persona-profile.md +138 -0
- package/src/skills/mentor/references/taste-profile.md +128 -0
- package/src/skills/mentor/references/thought-style-profile.md +138 -0
- package/src/skills/mentor/references/work-profile.md +289 -0
- package/src/skills/mentor/references/workflow-profile.md +240 -0
- package/src/skills/optimize/SKILL.md +1 -0
- package/src/skills/rebuttal/SKILL.md +1 -0
- package/src/skills/review/SKILL.md +1 -0
- package/src/skills/scout/SKILL.md +8 -0
- package/src/skills/write/SKILL.md +1 -0
- package/src/tui/dist/app/AppContainer.js +19 -11
- package/src/tui/dist/index.js +4 -1
- package/src/tui/dist/lib/api.js +33 -3
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/AiManusChatView-Bv-Z8YpU.js +204 -0
- package/src/ui/dist/assets/AnalysisPlugin-BCKAfjba.js +1 -0
- package/src/ui/dist/assets/CliPlugin-BCKcpc35.js +109 -0
- package/src/ui/dist/assets/CodeEditorPlugin-DbOfSJ8K.js +2 -0
- package/src/ui/dist/assets/CodeViewerPlugin-CbaFRrUU.js +270 -0
- package/src/ui/dist/assets/DocViewerPlugin-DAjLVeQD.js +7 -0
- package/src/ui/dist/assets/GitCommitViewerPlugin-CIUqbUDO.js +1 -0
- package/src/ui/dist/assets/GitDiffViewerPlugin-CQACjoAA.js +6 -0
- package/src/ui/dist/assets/GitSnapshotViewer-0r4nLPke.js +30 -0
- package/src/ui/dist/assets/ImageViewerPlugin-nBOmI2v_.js +26 -0
- package/src/ui/dist/assets/LabCopilotPanel-BHxOxF4z.js +14 -0
- package/src/ui/dist/assets/LabPlugin-BKoZGs95.js +22 -0
- package/src/ui/dist/assets/LatexPlugin-ZwtV8pIp.js +25 -0
- package/src/ui/dist/assets/MarkdownViewerPlugin-DKqVfKyW.js +128 -0
- package/src/ui/dist/assets/MarketplacePlugin-BwxStZ9D.js +13 -0
- package/src/ui/dist/assets/NotebookEditor-BEQhaQbt.js +81 -0
- package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
- package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
- package/src/ui/dist/assets/NotebookEditor-DB9N_T9q.js +361 -0
- package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
- package/src/ui/dist/assets/PdfLoader-eWBONbQP.js +16 -0
- package/src/ui/dist/assets/PdfMarkdownPlugin-D22YOZL3.js +1 -0
- package/src/ui/dist/assets/PdfViewerPlugin-c-RK9DLM.js +17 -0
- package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
- package/src/ui/dist/assets/SearchPlugin-CxF9ytAx.js +16 -0
- package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
- package/src/ui/dist/assets/TextViewerPlugin-C5xqeeUH.js +54 -0
- package/src/ui/dist/assets/VNCViewer-BoLGLnHz.js +11 -0
- package/src/ui/dist/assets/bot-DREQOxzP.js +6 -0
- package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
- package/src/ui/dist/assets/chevron-up-C9Qpx4DE.js +6 -0
- package/src/ui/dist/assets/code-WlFHE7z_.js +6 -0
- package/src/ui/dist/assets/file-content-BZMz3RYp.js +1 -0
- package/src/ui/dist/assets/file-diff-panel-CQhw0jS2.js +1 -0
- package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
- package/src/ui/dist/assets/file-socket-CfQPKQKj.js +1 -0
- package/src/ui/dist/assets/git-commit-horizontal-DxZ8DCZh.js +6 -0
- package/src/ui/dist/assets/image-Bgl4VIyx.js +6 -0
- package/src/ui/dist/assets/index-BpV6lusQ.css +33 -0
- package/src/ui/dist/assets/index-CBNVuWcP.js +2496 -0
- package/src/ui/dist/assets/index-CwNu1aH4.js +11 -0
- package/src/ui/dist/assets/index-DrUnlf6K.js +1 -0
- package/src/ui/dist/assets/index-NW-h8VzN.js +1 -0
- package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
- package/src/ui/dist/assets/pdf-effect-queue-J8OnM0jE.js +6 -0
- package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
- package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
- package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
- package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
- package/src/ui/dist/assets/popover-CLc0pPP8.js +1 -0
- package/src/ui/dist/assets/project-sync-C9IdzdZW.js +1 -0
- package/src/ui/dist/assets/select-Cs2PmzwL.js +11 -0
- package/src/ui/dist/assets/sigma-ClKcHAXm.js +6 -0
- package/src/ui/dist/assets/trash-DwpbFr3w.js +11 -0
- package/src/ui/dist/assets/useCliAccess-NQ8m0Let.js +1 -0
- package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
- package/src/ui/dist/assets/wrap-text-BC-Hltpd.js +11 -0
- package/src/ui/dist/assets/zoom-out-E_gaeAxL.js +11 -0
- package/src/ui/dist/index.html +5 -2
- package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
- package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
- package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
- package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
- package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
- package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
- package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
- package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
- package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
- package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
- package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
- package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
- package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
- package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
- package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
- package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
- package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
- package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
- package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
- package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
- package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
- package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
- package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
- package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
- package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
- package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
- package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
- package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
- package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
- package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
- package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
- package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
- package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
- package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
- package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
- package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
- package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
- package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
- package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
- package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
- package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
- package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
- package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
- package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
- package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
- package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
- package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
- package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
- package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
- package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
- package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
- package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
- package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
- package/src/ui/dist/assets/zoom-out-BgVMmOW4.js +0 -34
|
@@ -2,12 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
from collections import deque
|
|
5
|
+
from dataclasses import dataclass
|
|
5
6
|
import faulthandler
|
|
6
7
|
import hashlib
|
|
8
|
+
import hmac
|
|
7
9
|
import json
|
|
8
10
|
import mimetypes
|
|
9
11
|
import os
|
|
10
12
|
import re
|
|
13
|
+
import secrets
|
|
11
14
|
import signal
|
|
12
15
|
import shutil
|
|
13
16
|
import subprocess
|
|
@@ -16,6 +19,7 @@ import threading
|
|
|
16
19
|
import time
|
|
17
20
|
import traceback
|
|
18
21
|
from datetime import UTC, datetime, timedelta
|
|
22
|
+
from http.cookies import SimpleCookie
|
|
19
23
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
24
|
from pathlib import Path
|
|
21
25
|
from typing import Any
|
|
@@ -24,9 +28,11 @@ from urllib.request import Request
|
|
|
24
28
|
|
|
25
29
|
from .. import __version__
|
|
26
30
|
from ..annotations import AnnotationService
|
|
31
|
+
from ..acp import build_session_update
|
|
27
32
|
from ..artifact import ArtifactService
|
|
28
33
|
from ..bash_exec import BashExecService
|
|
29
34
|
from ..bash_exec.models import TerminalClient
|
|
35
|
+
from ..bash_exec.service import DEFAULT_TERMINAL_SESSION_ID
|
|
30
36
|
from ..bridges import register_builtin_connector_bridges
|
|
31
37
|
from ..bridges.connectors import QQConnectorBridge
|
|
32
38
|
from ..channels import QQRelayChannel, get_channel_factory, list_channel_names, register_builtin_channels
|
|
@@ -49,6 +55,7 @@ from ..connector.connector_profiles import (
|
|
|
49
55
|
from ..connector_runtime import conversation_identity_key, format_conversation_id, normalize_conversation_id, parse_conversation_id
|
|
50
56
|
from ..config import ConfigManager
|
|
51
57
|
from ..config.models import SYSTEM_CONNECTOR_NAMES
|
|
58
|
+
from ..diagnostics import FailureDiagnosis, diagnose_runner_failure
|
|
52
59
|
from ..home import repo_root
|
|
53
60
|
from ..memory import MemoryService
|
|
54
61
|
from ..network import urlopen_with_proxy as urlopen
|
|
@@ -69,7 +76,7 @@ from ..connector.lingzhu_support import (
|
|
|
69
76
|
lingzhu_verify_auth_header,
|
|
70
77
|
)
|
|
71
78
|
from ..prompts import PromptBuilder
|
|
72
|
-
from ..prompts.builder import
|
|
79
|
+
from ..prompts.builder import classify_turn_intent, current_standard_skills
|
|
73
80
|
from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
|
|
74
81
|
from ..quest import QuestService
|
|
75
82
|
from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
|
|
@@ -96,7 +103,11 @@ from websockets.sync.server import Server as WebSocketServer
|
|
|
96
103
|
from websockets.sync.server import ServerConnection, serve as websocket_serve
|
|
97
104
|
|
|
98
105
|
TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
|
|
99
|
-
_AUTO_CONTINUE_DELAY_SECONDS = 0
|
|
106
|
+
_AUTO_CONTINUE_DELAY_SECONDS = 240.0
|
|
107
|
+
_AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS = 0.2
|
|
108
|
+
_TERMINAL_PREWARM_DEBOUNCE_SECONDS = 20.0
|
|
109
|
+
_STALLED_RUNNING_TURN_INACTIVITY_SECONDS = 30 * 60
|
|
110
|
+
_STALLED_RUNNING_TURN_INTERRUPT_TIMEOUT_SECONDS = 5.0
|
|
100
111
|
CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
|
|
101
112
|
CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
|
|
102
113
|
CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
|
|
@@ -144,6 +155,20 @@ _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新", "最新的"}
|
|
|
144
155
|
_WEIXIN_STALE_REPLAY_LIMIT_DEFAULT = 5
|
|
145
156
|
_WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT = 2.0
|
|
146
157
|
_LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
|
|
158
|
+
_BROWSER_AUTH_COOKIE_NAME = "ds_local_auth"
|
|
159
|
+
_BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365
|
|
160
|
+
_BROWSER_AUTH_QUERY_PARAM = "token"
|
|
161
|
+
_BROWSER_AUTH_STORAGE_KEY = "ds_local_auth_token"
|
|
162
|
+
_BROWSER_AUTH_PUBLIC_ROUTE_NAMES = {"root", "spa_root", "ui_asset", "asset", "auth_login"}
|
|
163
|
+
_BROWSER_AUTH_EXEMPT_ROUTE_NAMES = {"lingzhu_health", "lingzhu_sse"}
|
|
164
|
+
_BROWSER_AUTH_REALM = "DeepScientist"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class BrowserAuthState:
|
|
169
|
+
authenticated: bool
|
|
170
|
+
token_source: str | None = None
|
|
171
|
+
response_cookie: str | None = None
|
|
147
172
|
|
|
148
173
|
|
|
149
174
|
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|
@@ -155,7 +180,14 @@ def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|
|
155
180
|
class DaemonApp:
|
|
156
181
|
_MAX_INBOUND_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
157
182
|
|
|
158
|
-
def __init__(
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
home: Path,
|
|
186
|
+
*,
|
|
187
|
+
browser_auth_enabled: bool | None = None,
|
|
188
|
+
browser_auth_token: str | None = None,
|
|
189
|
+
prompt_version_selection: str | None = None,
|
|
190
|
+
) -> None:
|
|
159
191
|
self.home = home.resolve()
|
|
160
192
|
self.daemon_id = str(os.environ.get("DS_DAEMON_ID") or "").strip() or generate_id("daemon")
|
|
161
193
|
self.daemon_managed_by = str(os.environ.get("DS_DAEMON_MANAGED_BY") or "manual").strip() or "manual"
|
|
@@ -191,7 +223,11 @@ class DaemonApp:
|
|
|
191
223
|
abandoned_run_id=item.get("abandoned_run_id"),
|
|
192
224
|
status=item.get("status"),
|
|
193
225
|
)
|
|
194
|
-
self.prompt_builder = PromptBuilder(
|
|
226
|
+
self.prompt_builder = PromptBuilder(
|
|
227
|
+
self.repo_root,
|
|
228
|
+
home,
|
|
229
|
+
prompt_version_selection=prompt_version_selection,
|
|
230
|
+
)
|
|
195
231
|
self.codex_runner = CodexRunner(
|
|
196
232
|
home=home,
|
|
197
233
|
repo_root=self.repo_root,
|
|
@@ -211,6 +247,8 @@ class DaemonApp:
|
|
|
211
247
|
self._canonicalize_lingzhu_binding_state()
|
|
212
248
|
self._turn_lock = threading.Lock()
|
|
213
249
|
self._turn_state: dict[str, dict[str, object]] = {}
|
|
250
|
+
self._terminal_prewarm_lock = threading.Lock()
|
|
251
|
+
self._terminal_prewarm_recent: dict[str, float] = {}
|
|
214
252
|
self._server: ThreadingHTTPServer | None = None
|
|
215
253
|
self._terminal_attach_server: WebSocketServer | None = None
|
|
216
254
|
self._terminal_attach_thread: threading.Thread | None = None
|
|
@@ -230,8 +268,190 @@ class DaemonApp:
|
|
|
230
268
|
self._process_hooks_installed = False
|
|
231
269
|
self._faulthandler_stream = None
|
|
232
270
|
self._recovered_quest_ids: set[str] = set()
|
|
271
|
+
ui_config = config.get("ui") if isinstance(config.get("ui"), dict) else {}
|
|
272
|
+
configured_browser_auth_enabled = self._parse_browser_auth_bool(ui_config.get("auth_enabled"))
|
|
273
|
+
env_browser_auth_enabled = self._parse_browser_auth_bool(os.environ.get("DS_UI_AUTH_ENABLED"))
|
|
274
|
+
explicit_browser_auth_enabled = self._parse_browser_auth_bool(browser_auth_enabled)
|
|
275
|
+
if explicit_browser_auth_enabled is not None:
|
|
276
|
+
self.browser_auth_enabled = explicit_browser_auth_enabled
|
|
277
|
+
elif env_browser_auth_enabled is not None:
|
|
278
|
+
self.browser_auth_enabled = env_browser_auth_enabled
|
|
279
|
+
elif configured_browser_auth_enabled is not None:
|
|
280
|
+
self.browser_auth_enabled = configured_browser_auth_enabled
|
|
281
|
+
else:
|
|
282
|
+
self.browser_auth_enabled = False
|
|
283
|
+
explicit_browser_auth_token = self._normalize_browser_auth_token(browser_auth_token)
|
|
284
|
+
env_browser_auth_token = self._normalize_browser_auth_token(os.environ.get("DS_UI_AUTH_TOKEN"))
|
|
285
|
+
if self.browser_auth_enabled:
|
|
286
|
+
self.browser_auth_token = explicit_browser_auth_token or env_browser_auth_token or self.generate_browser_auth_token()
|
|
287
|
+
else:
|
|
288
|
+
self.browser_auth_token = None
|
|
233
289
|
self.handlers = ApiHandlers(self)
|
|
234
290
|
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _parse_browser_auth_bool(value: object) -> bool | None:
|
|
293
|
+
if isinstance(value, bool):
|
|
294
|
+
return value
|
|
295
|
+
normalized = str(value or "").strip().lower()
|
|
296
|
+
if not normalized:
|
|
297
|
+
return None
|
|
298
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
299
|
+
return True
|
|
300
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
301
|
+
return False
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _normalize_browser_auth_token(value: object) -> str | None:
|
|
306
|
+
token = str(value or "").strip()
|
|
307
|
+
return token or None
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def generate_browser_auth_token() -> str:
|
|
311
|
+
return secrets.token_hex(8)
|
|
312
|
+
|
|
313
|
+
def masked_browser_auth_token(self) -> str | None:
|
|
314
|
+
token = self.browser_auth_token
|
|
315
|
+
if not token:
|
|
316
|
+
return None
|
|
317
|
+
if len(token) <= 6:
|
|
318
|
+
return "*" * len(token)
|
|
319
|
+
return f"{token[:3]}{'*' * (len(token) - 6)}{token[-3:]}"
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _header_value(headers: dict[str, str] | None, name: str) -> str:
|
|
323
|
+
if not isinstance(headers, dict):
|
|
324
|
+
return ""
|
|
325
|
+
target = name.strip().lower()
|
|
326
|
+
for key, value in headers.items():
|
|
327
|
+
if str(key).strip().lower() == target:
|
|
328
|
+
return str(value or "")
|
|
329
|
+
return ""
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _parse_bearer_token(header_value: str) -> str | None:
|
|
333
|
+
normalized = str(header_value or "").strip()
|
|
334
|
+
prefix = "bearer "
|
|
335
|
+
if not normalized or normalized[: len(prefix)].lower() != prefix:
|
|
336
|
+
return None
|
|
337
|
+
token = normalized[len(prefix) :].strip()
|
|
338
|
+
return token or None
|
|
339
|
+
|
|
340
|
+
def _request_cookie_token(self, headers: dict[str, str] | None) -> str | None:
|
|
341
|
+
raw_cookie = self._header_value(headers, "Cookie")
|
|
342
|
+
if not raw_cookie:
|
|
343
|
+
return None
|
|
344
|
+
try:
|
|
345
|
+
cookie = SimpleCookie()
|
|
346
|
+
cookie.load(raw_cookie)
|
|
347
|
+
except Exception:
|
|
348
|
+
return None
|
|
349
|
+
morsel = cookie.get(_BROWSER_AUTH_COOKIE_NAME)
|
|
350
|
+
if morsel is None:
|
|
351
|
+
return None
|
|
352
|
+
token = str(getattr(morsel, "value", "") or "").strip()
|
|
353
|
+
return token or None
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def _request_query_token(path: str) -> str | None:
|
|
357
|
+
query = parse_qs(urlparse(path).query, keep_blank_values=True)
|
|
358
|
+
token = str((query.get(_BROWSER_AUTH_QUERY_PARAM) or [""])[0] or "").strip()
|
|
359
|
+
return token or None
|
|
360
|
+
|
|
361
|
+
def _browser_auth_cookie_header(self, token: str | None = None) -> str:
|
|
362
|
+
cookie = SimpleCookie()
|
|
363
|
+
cookie[_BROWSER_AUTH_COOKIE_NAME] = token or (self.browser_auth_token or "")
|
|
364
|
+
morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
|
|
365
|
+
morsel["path"] = "/"
|
|
366
|
+
morsel["httponly"] = True
|
|
367
|
+
morsel["samesite"] = "Strict"
|
|
368
|
+
morsel["max-age"] = str(_BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS)
|
|
369
|
+
return morsel.OutputString()
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _browser_auth_clear_cookie_header() -> str:
|
|
373
|
+
cookie = SimpleCookie()
|
|
374
|
+
cookie[_BROWSER_AUTH_COOKIE_NAME] = ""
|
|
375
|
+
morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
|
|
376
|
+
morsel["path"] = "/"
|
|
377
|
+
morsel["httponly"] = True
|
|
378
|
+
morsel["samesite"] = "Strict"
|
|
379
|
+
morsel["max-age"] = "0"
|
|
380
|
+
morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
|
|
381
|
+
return morsel.OutputString()
|
|
382
|
+
|
|
383
|
+
def browser_auth_matches(self, token: str | None) -> bool:
|
|
384
|
+
expected = self.browser_auth_token
|
|
385
|
+
candidate = self._normalize_browser_auth_token(token)
|
|
386
|
+
return bool(expected and candidate and hmac.compare_digest(candidate, expected))
|
|
387
|
+
|
|
388
|
+
def rotate_browser_auth_token(self) -> str:
|
|
389
|
+
if not self.browser_auth_enabled:
|
|
390
|
+
raise RuntimeError("Browser authentication is disabled.")
|
|
391
|
+
rotated = self.generate_browser_auth_token()
|
|
392
|
+
self.browser_auth_token = rotated
|
|
393
|
+
return rotated
|
|
394
|
+
|
|
395
|
+
def browser_auth_state_for_request(self, path: str, headers: dict[str, str] | None = None) -> BrowserAuthState:
|
|
396
|
+
if not self.browser_auth_enabled:
|
|
397
|
+
return BrowserAuthState(authenticated=True)
|
|
398
|
+
expected = self.browser_auth_token
|
|
399
|
+
if not expected:
|
|
400
|
+
return BrowserAuthState(authenticated=False)
|
|
401
|
+
|
|
402
|
+
candidates = (
|
|
403
|
+
("authorization", self._parse_bearer_token(self._header_value(headers, "Authorization"))),
|
|
404
|
+
("query", self._request_query_token(path)),
|
|
405
|
+
("cookie", self._request_cookie_token(headers)),
|
|
406
|
+
)
|
|
407
|
+
for source, candidate in candidates:
|
|
408
|
+
if candidate and hmac.compare_digest(candidate, expected):
|
|
409
|
+
response_cookie = self._browser_auth_cookie_header(expected) if source in {"authorization", "query"} else None
|
|
410
|
+
return BrowserAuthState(authenticated=True, token_source=source, response_cookie=response_cookie)
|
|
411
|
+
return BrowserAuthState(authenticated=False, response_cookie=self._browser_auth_clear_cookie_header())
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _auth_response_headers(auth_state: BrowserAuthState | None) -> dict[str, str]:
|
|
415
|
+
if auth_state is None or not auth_state.response_cookie:
|
|
416
|
+
return {}
|
|
417
|
+
return {"Set-Cookie": auth_state.response_cookie}
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def _merge_response_headers(
|
|
421
|
+
base: dict[str, str] | None = None,
|
|
422
|
+
extra: dict[str, str] | None = None,
|
|
423
|
+
) -> dict[str, str]:
|
|
424
|
+
merged: dict[str, str] = {}
|
|
425
|
+
if isinstance(extra, dict):
|
|
426
|
+
merged.update(extra)
|
|
427
|
+
if isinstance(base, dict):
|
|
428
|
+
merged.update(base)
|
|
429
|
+
return merged
|
|
430
|
+
|
|
431
|
+
def _route_requires_browser_auth(self, route_name: str | None) -> bool:
|
|
432
|
+
if not self.browser_auth_enabled or not route_name:
|
|
433
|
+
return False
|
|
434
|
+
if route_name in _BROWSER_AUTH_PUBLIC_ROUTE_NAMES:
|
|
435
|
+
return False
|
|
436
|
+
if route_name in _BROWSER_AUTH_EXEMPT_ROUTE_NAMES:
|
|
437
|
+
return False
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
def browser_auth_runtime_payload(self) -> dict[str, object]:
|
|
441
|
+
return {
|
|
442
|
+
"enabled": self.browser_auth_enabled,
|
|
443
|
+
"tokenQueryParam": _BROWSER_AUTH_QUERY_PARAM,
|
|
444
|
+
"storageKey": _BROWSER_AUTH_STORAGE_KEY,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def browser_auth_tokenized_url(self, url: str) -> str:
|
|
448
|
+
if not self.browser_auth_enabled or not self.browser_auth_token:
|
|
449
|
+
return url
|
|
450
|
+
parsed = urlparse(url)
|
|
451
|
+
query = parse_qs(parsed.query, keep_blank_values=True)
|
|
452
|
+
query[_BROWSER_AUTH_QUERY_PARAM] = [self.browser_auth_token]
|
|
453
|
+
return parsed._replace(query=urlencode(query, doseq=True)).geturl()
|
|
454
|
+
|
|
235
455
|
def list_connector_statuses(self) -> list[dict[str, object]]:
|
|
236
456
|
title_by_quest = self._quest_titles_by_id()
|
|
237
457
|
items = [
|
|
@@ -538,7 +758,23 @@ class DaemonApp:
|
|
|
538
758
|
if int(snapshot.get("pending_user_message_count") or 0) > 0
|
|
539
759
|
else "auto_continue"
|
|
540
760
|
)
|
|
541
|
-
|
|
761
|
+
retry_delay_seconds = self._recovery_retry_delay_seconds(snapshot) if reason == "auto_continue" else None
|
|
762
|
+
if retry_delay_seconds is not None and retry_delay_seconds > 0:
|
|
763
|
+
self._schedule_turn_later(
|
|
764
|
+
quest_id,
|
|
765
|
+
reason=reason,
|
|
766
|
+
delay_seconds=retry_delay_seconds,
|
|
767
|
+
)
|
|
768
|
+
scheduled = {
|
|
769
|
+
"scheduled": True,
|
|
770
|
+
"started": False,
|
|
771
|
+
"queued": True,
|
|
772
|
+
"reason": reason,
|
|
773
|
+
"delayed": True,
|
|
774
|
+
"delay_seconds": retry_delay_seconds,
|
|
775
|
+
}
|
|
776
|
+
else:
|
|
777
|
+
scheduled = self.schedule_turn(quest_id, reason=reason)
|
|
542
778
|
event = {
|
|
543
779
|
"event_id": generate_id("evt"),
|
|
544
780
|
"type": "quest.runtime_auto_resumed",
|
|
@@ -550,6 +786,8 @@ class DaemonApp:
|
|
|
550
786
|
"scheduled": bool(scheduled.get("scheduled")),
|
|
551
787
|
"started": bool(scheduled.get("started")),
|
|
552
788
|
"queued": bool(scheduled.get("queued")),
|
|
789
|
+
"delayed": bool(scheduled.get("delayed")),
|
|
790
|
+
"delay_seconds": scheduled.get("delay_seconds"),
|
|
553
791
|
"created_at": utc_now(),
|
|
554
792
|
}
|
|
555
793
|
append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", event)
|
|
@@ -564,6 +802,8 @@ class DaemonApp:
|
|
|
564
802
|
scheduled=bool(scheduled.get("scheduled")),
|
|
565
803
|
started=bool(scheduled.get("started")),
|
|
566
804
|
queued=bool(scheduled.get("queued")),
|
|
805
|
+
delayed=bool(scheduled.get("delayed")),
|
|
806
|
+
delay_seconds=scheduled.get("delay_seconds"),
|
|
567
807
|
)
|
|
568
808
|
self._recovered_quest_ids.add(quest_id)
|
|
569
809
|
resumed.append(
|
|
@@ -617,6 +857,63 @@ class DaemonApp:
|
|
|
617
857
|
count += 1
|
|
618
858
|
return count
|
|
619
859
|
|
|
860
|
+
def _recovery_retry_delay_seconds(self, snapshot: dict[str, Any]) -> float | None:
|
|
861
|
+
retry_state = snapshot.get("retry_state") if isinstance(snapshot.get("retry_state"), dict) else None
|
|
862
|
+
if not retry_state:
|
|
863
|
+
return None
|
|
864
|
+
next_retry_at = str(retry_state.get("next_retry_at") or "").strip()
|
|
865
|
+
if not next_retry_at:
|
|
866
|
+
return None
|
|
867
|
+
parsed = self._parse_event_timestamp(next_retry_at)
|
|
868
|
+
if parsed is None:
|
|
869
|
+
return None
|
|
870
|
+
return max((parsed - datetime.now(UTC)).total_seconds(), 0.0)
|
|
871
|
+
|
|
872
|
+
def _resume_retry_state(
|
|
873
|
+
self,
|
|
874
|
+
snapshot: dict[str, Any],
|
|
875
|
+
*,
|
|
876
|
+
max_attempts: int,
|
|
877
|
+
) -> tuple[int, str | None, dict[str, Any] | None]:
|
|
878
|
+
retry_state = snapshot.get("retry_state") if isinstance(snapshot.get("retry_state"), dict) else None
|
|
879
|
+
resume_source = str(snapshot.get("last_resume_source") or "").strip()
|
|
880
|
+
if not retry_state or not resume_source.startswith("auto:daemon-recovery"):
|
|
881
|
+
return 1, None, None
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
recorded_attempt = int(retry_state.get("attempt_index") or 0)
|
|
885
|
+
except (TypeError, ValueError):
|
|
886
|
+
recorded_attempt = 0
|
|
887
|
+
if recorded_attempt <= 0:
|
|
888
|
+
return 1, None, None
|
|
889
|
+
|
|
890
|
+
next_retry_at = str(retry_state.get("next_retry_at") or "").strip()
|
|
891
|
+
start_attempt = recorded_attempt + 1 if next_retry_at else recorded_attempt
|
|
892
|
+
if start_attempt > max_attempts:
|
|
893
|
+
start_attempt = max_attempts
|
|
894
|
+
if start_attempt <= 1:
|
|
895
|
+
return 1, None, None
|
|
896
|
+
|
|
897
|
+
turn_id = str(retry_state.get("turn_id") or "").strip() or None
|
|
898
|
+
previous_run_id = str(retry_state.get("last_run_id") or "").strip() or None
|
|
899
|
+
failure_summary = str(retry_state.get("last_error") or "").strip() or None
|
|
900
|
+
retry_context = {
|
|
901
|
+
"turn_id": turn_id,
|
|
902
|
+
"attempt_index": recorded_attempt,
|
|
903
|
+
"max_attempts": max_attempts,
|
|
904
|
+
"previous_run_id": previous_run_id,
|
|
905
|
+
"failure_kind": "daemon_recovery",
|
|
906
|
+
"failure_summary": failure_summary or "Recovered retry state after daemon restart.",
|
|
907
|
+
"previous_exit_code": None,
|
|
908
|
+
"previous_output_text": "",
|
|
909
|
+
"stderr_tail": "",
|
|
910
|
+
"recent_messages": [],
|
|
911
|
+
"tool_progress": [],
|
|
912
|
+
"workspace_summary": {},
|
|
913
|
+
"recent_artifacts": [],
|
|
914
|
+
}
|
|
915
|
+
return start_attempt, turn_id, retry_context
|
|
916
|
+
|
|
620
917
|
def _record_auto_resume_suppressed(
|
|
621
918
|
self,
|
|
622
919
|
*,
|
|
@@ -1326,7 +1623,13 @@ class DaemonApp:
|
|
|
1326
1623
|
)
|
|
1327
1624
|
turn_state = self._refresh_turn_worker_state(quest_id)
|
|
1328
1625
|
has_live_turn = bool(turn_state.get("running"))
|
|
1329
|
-
|
|
1626
|
+
stalled_details = self._stalled_running_turn_details(
|
|
1627
|
+
quest_id,
|
|
1628
|
+
snapshot=snapshot,
|
|
1629
|
+
turn_state=turn_state,
|
|
1630
|
+
turn_reason="user_message",
|
|
1631
|
+
)
|
|
1632
|
+
if runtime_status == "running" and has_live_turn and stalled_details is None:
|
|
1330
1633
|
scheduled = {
|
|
1331
1634
|
"scheduled": True,
|
|
1332
1635
|
"started": False,
|
|
@@ -1495,18 +1798,30 @@ class DaemonApp:
|
|
|
1495
1798
|
return snapshot
|
|
1496
1799
|
|
|
1497
1800
|
def schedule_turn(self, quest_id: str, *, reason: str = "user_message") -> dict:
|
|
1801
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
1802
|
+
snapshot = self._reconcile_stale_active_turn(quest_id, snapshot=snapshot)
|
|
1803
|
+
recovery = self._recover_stalled_running_turn(quest_id, snapshot=snapshot, turn_reason=reason)
|
|
1804
|
+
snapshot = dict(recovery.get("snapshot") or snapshot)
|
|
1805
|
+
if recovery.get("blocked"):
|
|
1806
|
+
return {
|
|
1807
|
+
"scheduled": True,
|
|
1808
|
+
"started": False,
|
|
1809
|
+
"queued": True,
|
|
1810
|
+
"reason": "stalled_turn_recovery_pending",
|
|
1811
|
+
}
|
|
1498
1812
|
self._refresh_turn_worker_state(quest_id)
|
|
1499
1813
|
with self._turn_lock:
|
|
1500
1814
|
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1501
1815
|
state["pending"] = True
|
|
1502
1816
|
state["stop_requested"] = False
|
|
1817
|
+
state.pop("recovery_pending", None)
|
|
1503
1818
|
state["reason"] = reason
|
|
1504
1819
|
if state.get("running"):
|
|
1505
1820
|
return {
|
|
1506
1821
|
"scheduled": True,
|
|
1507
1822
|
"started": False,
|
|
1508
1823
|
"queued": True,
|
|
1509
|
-
"reason": reason,
|
|
1824
|
+
"reason": "queued_for_artifact_interact" if reason == "user_message" else reason,
|
|
1510
1825
|
}
|
|
1511
1826
|
state["running"] = True
|
|
1512
1827
|
worker = threading.Thread(
|
|
@@ -1528,6 +1843,57 @@ class DaemonApp:
|
|
|
1528
1843
|
def _turn_worker_is_alive(worker: object) -> bool:
|
|
1529
1844
|
return isinstance(worker, threading.Thread) and worker.is_alive()
|
|
1530
1845
|
|
|
1846
|
+
def schedule_latest_quest_terminal_prewarm(
|
|
1847
|
+
self,
|
|
1848
|
+
quest_id: str,
|
|
1849
|
+
*,
|
|
1850
|
+
source: str = "quest_session_prewarm",
|
|
1851
|
+
) -> None:
|
|
1852
|
+
normalized_quest_id = str(quest_id or "").strip()
|
|
1853
|
+
if not normalized_quest_id or os.name == "nt":
|
|
1854
|
+
return
|
|
1855
|
+
try:
|
|
1856
|
+
quests = self.quest_service.list_quests()
|
|
1857
|
+
except Exception:
|
|
1858
|
+
return
|
|
1859
|
+
latest_quest_id = str((quests[0].get("quest_id") if quests else "") or "").strip()
|
|
1860
|
+
if latest_quest_id != normalized_quest_id:
|
|
1861
|
+
return
|
|
1862
|
+
now = time.monotonic()
|
|
1863
|
+
with self._terminal_prewarm_lock:
|
|
1864
|
+
last_attempt = float(self._terminal_prewarm_recent.get(normalized_quest_id) or 0.0)
|
|
1865
|
+
if now - last_attempt < _TERMINAL_PREWARM_DEBOUNCE_SECONDS:
|
|
1866
|
+
return
|
|
1867
|
+
self._terminal_prewarm_recent[normalized_quest_id] = now
|
|
1868
|
+
threading.Thread(
|
|
1869
|
+
target=self._prewarm_terminal_for_quest,
|
|
1870
|
+
args=(normalized_quest_id, source),
|
|
1871
|
+
daemon=True,
|
|
1872
|
+
name=f"deepscientist-terminal-prewarm-{normalized_quest_id}",
|
|
1873
|
+
).start()
|
|
1874
|
+
|
|
1875
|
+
def _prewarm_terminal_for_quest(self, quest_id: str, source: str) -> None:
|
|
1876
|
+
try:
|
|
1877
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
1878
|
+
workspace_root = self.quest_service.active_workspace_root(quest_root)
|
|
1879
|
+
self.bash_exec_service.ensure_terminal_session(
|
|
1880
|
+
quest_root,
|
|
1881
|
+
quest_id=quest_id,
|
|
1882
|
+
bash_id=DEFAULT_TERMINAL_SESSION_ID,
|
|
1883
|
+
cwd=workspace_root,
|
|
1884
|
+
source=source,
|
|
1885
|
+
)
|
|
1886
|
+
except Exception as exc:
|
|
1887
|
+
with self._terminal_prewarm_lock:
|
|
1888
|
+
self._terminal_prewarm_recent.pop(quest_id, None)
|
|
1889
|
+
self.logger.log(
|
|
1890
|
+
"warning",
|
|
1891
|
+
"terminal.prewarm_failed",
|
|
1892
|
+
quest_id=quest_id,
|
|
1893
|
+
source=source,
|
|
1894
|
+
error=str(exc),
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1531
1897
|
def _refresh_turn_worker_state(self, quest_id: str) -> dict[str, object]:
|
|
1532
1898
|
with self._turn_lock:
|
|
1533
1899
|
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
@@ -1536,6 +1902,110 @@ class DaemonApp:
|
|
|
1536
1902
|
state.pop("worker", None)
|
|
1537
1903
|
return dict(state)
|
|
1538
1904
|
|
|
1905
|
+
def _wait_for_turn_worker_exit(self, quest_id: str, *, timeout_seconds: float) -> dict[str, object]:
|
|
1906
|
+
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
|
|
1907
|
+
state = self._refresh_turn_worker_state(quest_id)
|
|
1908
|
+
while state.get("running") and time.monotonic() < deadline:
|
|
1909
|
+
time.sleep(0.05)
|
|
1910
|
+
state = self._refresh_turn_worker_state(quest_id)
|
|
1911
|
+
return state
|
|
1912
|
+
|
|
1913
|
+
def _ensure_recovery_resume_watch(self, quest_id: str, *, turn_reason: str) -> None:
|
|
1914
|
+
with self._turn_lock:
|
|
1915
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1916
|
+
if state.get("recovery_watch_active"):
|
|
1917
|
+
return
|
|
1918
|
+
state["recovery_watch_active"] = True
|
|
1919
|
+
watcher = threading.Thread(
|
|
1920
|
+
target=self._wait_and_resume_recovered_turn,
|
|
1921
|
+
args=(quest_id, turn_reason),
|
|
1922
|
+
daemon=True,
|
|
1923
|
+
name=f"deepscientist-recovery-watch-{quest_id}",
|
|
1924
|
+
)
|
|
1925
|
+
watcher.start()
|
|
1926
|
+
|
|
1927
|
+
def _wait_and_resume_recovered_turn(self, quest_id: str, turn_reason: str) -> None:
|
|
1928
|
+
try:
|
|
1929
|
+
while True:
|
|
1930
|
+
state = self._refresh_turn_worker_state(quest_id)
|
|
1931
|
+
if not state.get("recovery_pending"):
|
|
1932
|
+
return
|
|
1933
|
+
if not state.get("running"):
|
|
1934
|
+
break
|
|
1935
|
+
time.sleep(0.1)
|
|
1936
|
+
|
|
1937
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
1938
|
+
runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
|
|
1939
|
+
if runtime_status in {"paused", "stopped", "completed", "error"}:
|
|
1940
|
+
with self._turn_lock:
|
|
1941
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1942
|
+
state.pop("recovery_pending", None)
|
|
1943
|
+
state["stop_requested"] = runtime_status in {"paused", "stopped"}
|
|
1944
|
+
return
|
|
1945
|
+
pending_user_count = int(snapshot.get("pending_user_message_count") or 0)
|
|
1946
|
+
if pending_user_count > 0:
|
|
1947
|
+
with self._turn_lock:
|
|
1948
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1949
|
+
state.pop("recovery_pending", None)
|
|
1950
|
+
self.schedule_turn(quest_id, reason=turn_reason)
|
|
1951
|
+
return
|
|
1952
|
+
|
|
1953
|
+
with self._turn_lock:
|
|
1954
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1955
|
+
state.pop("recovery_pending", None)
|
|
1956
|
+
state["stop_requested"] = False
|
|
1957
|
+
except Exception as exc:
|
|
1958
|
+
self.logger.log(
|
|
1959
|
+
"warning",
|
|
1960
|
+
"quest.turn_state_recovery_watch_failed",
|
|
1961
|
+
quest_id=quest_id,
|
|
1962
|
+
reason=turn_reason,
|
|
1963
|
+
error=str(exc),
|
|
1964
|
+
)
|
|
1965
|
+
finally:
|
|
1966
|
+
with self._turn_lock:
|
|
1967
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1968
|
+
state.pop("recovery_watch_active", None)
|
|
1969
|
+
|
|
1970
|
+
def _stalled_running_turn_details(
|
|
1971
|
+
self,
|
|
1972
|
+
quest_id: str,
|
|
1973
|
+
*,
|
|
1974
|
+
snapshot: dict | None = None,
|
|
1975
|
+
turn_state: dict[str, object] | None = None,
|
|
1976
|
+
turn_reason: str,
|
|
1977
|
+
) -> dict[str, int] | None:
|
|
1978
|
+
if str(turn_reason or "").strip() not in {"user_message", "queued_user_messages"}:
|
|
1979
|
+
return None
|
|
1980
|
+
snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
|
|
1981
|
+
runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
|
|
1982
|
+
active_run_id = str(snapshot.get("active_run_id") or "").strip()
|
|
1983
|
+
if runtime_status != "running" or not active_run_id:
|
|
1984
|
+
return None
|
|
1985
|
+
state = dict(turn_state or self._refresh_turn_worker_state(quest_id))
|
|
1986
|
+
if not state.get("running"):
|
|
1987
|
+
return None
|
|
1988
|
+
pending_user_count = int(snapshot.get("pending_user_message_count") or 0)
|
|
1989
|
+
if pending_user_count <= 0:
|
|
1990
|
+
return None
|
|
1991
|
+
counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
|
|
1992
|
+
if int(counts.get("bash_running_count") or 0) > 0:
|
|
1993
|
+
return None
|
|
1994
|
+
silent_seconds = snapshot.get("seconds_since_last_tool_activity")
|
|
1995
|
+
if silent_seconds is None:
|
|
1996
|
+
watchdog = snapshot.get("interaction_watchdog") if isinstance(snapshot.get("interaction_watchdog"), dict) else {}
|
|
1997
|
+
silent_seconds = watchdog.get("seconds_since_last_tool_activity")
|
|
1998
|
+
try:
|
|
1999
|
+
silent_seconds_int = int(silent_seconds or 0)
|
|
2000
|
+
except (TypeError, ValueError):
|
|
2001
|
+
return None
|
|
2002
|
+
if silent_seconds_int < _STALLED_RUNNING_TURN_INACTIVITY_SECONDS:
|
|
2003
|
+
return None
|
|
2004
|
+
return {
|
|
2005
|
+
"pending_user_count": pending_user_count,
|
|
2006
|
+
"silent_seconds": silent_seconds_int,
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1539
2009
|
def _reconcile_stale_active_turn(self, quest_id: str, *, snapshot: dict | None = None) -> dict:
|
|
1540
2010
|
snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
|
|
1541
2011
|
active_run_id = str(snapshot.get("active_run_id") or "").strip()
|
|
@@ -1587,6 +2057,139 @@ class DaemonApp:
|
|
|
1587
2057
|
)
|
|
1588
2058
|
return self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
1589
2059
|
|
|
2060
|
+
def _recover_stalled_running_turn(
|
|
2061
|
+
self,
|
|
2062
|
+
quest_id: str,
|
|
2063
|
+
*,
|
|
2064
|
+
snapshot: dict | None = None,
|
|
2065
|
+
turn_reason: str,
|
|
2066
|
+
) -> dict[str, object]:
|
|
2067
|
+
snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
|
|
2068
|
+
turn_state = self._refresh_turn_worker_state(quest_id)
|
|
2069
|
+
with self._turn_lock:
|
|
2070
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
2071
|
+
if state.get("recovery_pending"):
|
|
2072
|
+
return {
|
|
2073
|
+
"snapshot": snapshot,
|
|
2074
|
+
"blocked": True,
|
|
2075
|
+
}
|
|
2076
|
+
details = self._stalled_running_turn_details(
|
|
2077
|
+
quest_id,
|
|
2078
|
+
snapshot=snapshot,
|
|
2079
|
+
turn_state=turn_state,
|
|
2080
|
+
turn_reason=turn_reason,
|
|
2081
|
+
)
|
|
2082
|
+
if details is None:
|
|
2083
|
+
return {
|
|
2084
|
+
"snapshot": snapshot,
|
|
2085
|
+
"blocked": False,
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
active_run_id = str(snapshot.get("active_run_id") or "").strip()
|
|
2089
|
+
runner_name = self._runner_name_for(snapshot)
|
|
2090
|
+
with self._turn_lock:
|
|
2091
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
2092
|
+
if state.get("recovery_pending"):
|
|
2093
|
+
return {
|
|
2094
|
+
"snapshot": snapshot,
|
|
2095
|
+
"blocked": True,
|
|
2096
|
+
}
|
|
2097
|
+
state["pending"] = False
|
|
2098
|
+
state["stop_requested"] = True
|
|
2099
|
+
state["recovery_pending"] = True
|
|
2100
|
+
interrupted = False
|
|
2101
|
+
try:
|
|
2102
|
+
try:
|
|
2103
|
+
runner = self.get_runner(runner_name)
|
|
2104
|
+
except KeyError:
|
|
2105
|
+
runner = None
|
|
2106
|
+
if runner is not None and hasattr(runner, "interrupt"):
|
|
2107
|
+
interrupted = bool(getattr(runner, "interrupt")(quest_id))
|
|
2108
|
+
stopped_bash_session_ids = self._stop_active_bash_exec_sessions(
|
|
2109
|
+
quest_id,
|
|
2110
|
+
run_id=active_run_id or None,
|
|
2111
|
+
reason="stalled_turn_recovery",
|
|
2112
|
+
user_id="auto:stalled-turn-recovery",
|
|
2113
|
+
)
|
|
2114
|
+
turn_state = self._wait_for_turn_worker_exit(
|
|
2115
|
+
quest_id,
|
|
2116
|
+
timeout_seconds=_STALLED_RUNNING_TURN_INTERRUPT_TIMEOUT_SECONDS,
|
|
2117
|
+
)
|
|
2118
|
+
if turn_state.get("running"):
|
|
2119
|
+
self._ensure_recovery_resume_watch(quest_id, turn_reason="queued_user_messages")
|
|
2120
|
+
self.logger.log(
|
|
2121
|
+
"warning",
|
|
2122
|
+
"quest.turn_state_recovery_pending",
|
|
2123
|
+
quest_id=quest_id,
|
|
2124
|
+
abandoned_run_id=active_run_id or None,
|
|
2125
|
+
reason=turn_reason,
|
|
2126
|
+
silent_seconds=int(details.get("silent_seconds") or 0),
|
|
2127
|
+
pending_user_message_count=int(details.get("pending_user_count") or 0),
|
|
2128
|
+
interrupted=interrupted,
|
|
2129
|
+
)
|
|
2130
|
+
return {
|
|
2131
|
+
"snapshot": snapshot,
|
|
2132
|
+
"blocked": True,
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
previous_status = (
|
|
2136
|
+
str(snapshot.get("runtime_status") or snapshot.get("status") or snapshot.get("display_status") or "running").strip()
|
|
2137
|
+
or "running"
|
|
2138
|
+
)
|
|
2139
|
+
normalized_status = "active" if previous_status == "running" else previous_status
|
|
2140
|
+
summary = (
|
|
2141
|
+
f"Recovered stalled running turn `{active_run_id}` after "
|
|
2142
|
+
f"{int(details.get('silent_seconds') or 0)} seconds without tool activity while "
|
|
2143
|
+
f"{int(details.get('pending_user_count') or 0)} queued user message(s) were waiting."
|
|
2144
|
+
)
|
|
2145
|
+
if interrupted:
|
|
2146
|
+
summary = f"{summary} The active runner process was interrupted."
|
|
2147
|
+
if stopped_bash_session_ids:
|
|
2148
|
+
summary = f"{summary} Stopped {len(stopped_bash_session_ids)} bash_exec session(s)."
|
|
2149
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
2150
|
+
append_jsonl(
|
|
2151
|
+
quest_root / ".ds" / "events.jsonl",
|
|
2152
|
+
{
|
|
2153
|
+
"event_id": generate_id("evt"),
|
|
2154
|
+
"type": "quest.turn_state_reconciled",
|
|
2155
|
+
"quest_id": quest_id,
|
|
2156
|
+
"abandoned_run_id": active_run_id or None,
|
|
2157
|
+
"previous_status": previous_status,
|
|
2158
|
+
"status": normalized_status,
|
|
2159
|
+
"completed_at": None,
|
|
2160
|
+
"exit_code": None,
|
|
2161
|
+
"summary": summary,
|
|
2162
|
+
"recovery_kind": "stalled_live_turn",
|
|
2163
|
+
"interrupted": interrupted,
|
|
2164
|
+
"stopped_bash_session_ids": stopped_bash_session_ids,
|
|
2165
|
+
"created_at": utc_now(),
|
|
2166
|
+
},
|
|
2167
|
+
)
|
|
2168
|
+
self.logger.log(
|
|
2169
|
+
"warning",
|
|
2170
|
+
"quest.turn_state_reconciled",
|
|
2171
|
+
quest_id=quest_id,
|
|
2172
|
+
abandoned_run_id=active_run_id or None,
|
|
2173
|
+
previous_status=previous_status,
|
|
2174
|
+
status=normalized_status,
|
|
2175
|
+
recovery_kind="stalled_live_turn",
|
|
2176
|
+
interrupted=interrupted,
|
|
2177
|
+
stopped_bash_session_count=len(stopped_bash_session_ids),
|
|
2178
|
+
)
|
|
2179
|
+
snapshot = self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
2180
|
+
with self._turn_lock:
|
|
2181
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
2182
|
+
state.pop("recovery_pending", None)
|
|
2183
|
+
return {
|
|
2184
|
+
"snapshot": snapshot,
|
|
2185
|
+
"blocked": False,
|
|
2186
|
+
}
|
|
2187
|
+
except Exception:
|
|
2188
|
+
with self._turn_lock:
|
|
2189
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
2190
|
+
state.pop("recovery_pending", None)
|
|
2191
|
+
raise
|
|
2192
|
+
|
|
1590
2193
|
def control_quest(self, quest_id: str, *, action: str, source: str = "local") -> dict:
|
|
1591
2194
|
normalized_action = str(action or "").strip().lower()
|
|
1592
2195
|
if normalized_action == "pause":
|
|
@@ -1882,52 +2485,50 @@ class DaemonApp:
|
|
|
1882
2485
|
cancelled_pending_user_message_count: int,
|
|
1883
2486
|
previous_snapshot: dict | None = None,
|
|
1884
2487
|
) -> str:
|
|
1885
|
-
branch = str(snapshot.get("branch") or "unknown").strip() or "unknown"
|
|
1886
|
-
workspace_root = str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or "").strip()
|
|
1887
2488
|
if action == "resume":
|
|
1888
2489
|
lines = [
|
|
1889
2490
|
self._polite_copy(
|
|
1890
|
-
zh="
|
|
1891
|
-
en="
|
|
2491
|
+
zh=f"我回来继续干活啦,Quest `{quest_id}` 已恢复。",
|
|
2492
|
+
en=f"I’m back on it. Quest `{quest_id}` has resumed. ✨",
|
|
1892
2493
|
),
|
|
1893
2494
|
self._polite_copy(
|
|
1894
|
-
zh="
|
|
1895
|
-
en="The current
|
|
2495
|
+
zh="刚才的进度都还在,我会直接接着往下推。",
|
|
2496
|
+
en="The current progress is still here, and I’ll pick up right where I left off.",
|
|
1896
2497
|
),
|
|
1897
2498
|
]
|
|
1898
2499
|
if source.startswith("auto:daemon-recovery"):
|
|
1899
2500
|
lines.append(
|
|
1900
2501
|
self._polite_copy(
|
|
1901
|
-
zh="
|
|
1902
|
-
en="The daemon
|
|
2502
|
+
zh="刚才是 daemon 意外断开了,不过现在已经自动接回来了。",
|
|
2503
|
+
en="The daemon dropped unexpectedly, but it has been recovered automatically. 🔧",
|
|
1903
2504
|
)
|
|
1904
2505
|
)
|
|
1905
2506
|
elif action == "pause":
|
|
1906
2507
|
lines = [
|
|
1907
2508
|
self._polite_copy(
|
|
1908
|
-
zh="
|
|
1909
|
-
en="
|
|
2509
|
+
zh=f"我先帮您把 Quest `{quest_id}` 稳稳停在这里啦。",
|
|
2510
|
+
en=f"I’ve paused Quest `{quest_id}` right here for now. ⏸️",
|
|
1910
2511
|
),
|
|
1911
2512
|
self._polite_copy(
|
|
1912
|
-
zh="
|
|
1913
|
-
en="
|
|
2513
|
+
zh="当前进度我都保留好了,您发新消息或者执行 `/resume`,我就会继续。",
|
|
2514
|
+
en="I kept the current progress safe. Send a new message or use `/resume`, and I’ll continue.",
|
|
1914
2515
|
),
|
|
1915
2516
|
]
|
|
1916
2517
|
else:
|
|
1917
2518
|
lines = [
|
|
1918
2519
|
self._polite_copy(
|
|
1919
|
-
zh="
|
|
1920
|
-
en="
|
|
2520
|
+
zh=f"这轮我先收住啦,Quest `{quest_id}` 已停止运行。",
|
|
2521
|
+
en=f"I’m wrapping this round here. Quest `{quest_id}` has stopped. 📌",
|
|
1921
2522
|
),
|
|
1922
2523
|
self._polite_copy(
|
|
1923
|
-
zh="
|
|
1924
|
-
en="
|
|
2524
|
+
zh="不过别担心,当前进度我都保留好了;您发新消息或者执行 `/resume`,我就能接着干。",
|
|
2525
|
+
en="Don’t worry, the current progress is still preserved. Send a new message or use `/resume`, and I’ll keep going.",
|
|
1925
2526
|
),
|
|
1926
2527
|
]
|
|
1927
2528
|
if interrupted:
|
|
1928
2529
|
lines.append(
|
|
1929
2530
|
self._polite_copy(
|
|
1930
|
-
zh="
|
|
2531
|
+
zh="刚才正在跑的任务已经被打断了。",
|
|
1931
2532
|
en="The active runner was interrupted.",
|
|
1932
2533
|
)
|
|
1933
2534
|
)
|
|
@@ -1935,8 +2536,8 @@ class DaemonApp:
|
|
|
1935
2536
|
if cancelled_count > 0:
|
|
1936
2537
|
lines.append(
|
|
1937
2538
|
self._polite_copy(
|
|
1938
|
-
zh=f"
|
|
1939
|
-
en=f"
|
|
2539
|
+
zh=f"另外我还顺手清掉了 {cancelled_count} 条排队消息,避免旧指令继续堆着。",
|
|
2540
|
+
en=f"I also cleared {cancelled_count} queued message(s) so stale instructions do not pile up.",
|
|
1940
2541
|
)
|
|
1941
2542
|
)
|
|
1942
2543
|
previous_status = str(
|
|
@@ -1947,17 +2548,10 @@ class DaemonApp:
|
|
|
1947
2548
|
if previous_status and action == "resume":
|
|
1948
2549
|
lines.append(
|
|
1949
2550
|
self._polite_copy(
|
|
1950
|
-
zh=f"
|
|
2551
|
+
zh=f"恢复前的状态是:`{previous_status}`。",
|
|
1951
2552
|
en=f"Previous status: `{previous_status}`.",
|
|
1952
2553
|
)
|
|
1953
2554
|
)
|
|
1954
|
-
lines.extend(
|
|
1955
|
-
[
|
|
1956
|
-
f"- Quest: `{quest_id}`",
|
|
1957
|
-
f"- Branch: `{branch}`",
|
|
1958
|
-
f"- Workspace: `{workspace_root or snapshot.get('quest_root')}`",
|
|
1959
|
-
]
|
|
1960
|
-
)
|
|
1961
2555
|
return "\n".join(lines)
|
|
1962
2556
|
|
|
1963
2557
|
def _drain_turns(self, quest_id: str) -> None:
|
|
@@ -2062,12 +2656,15 @@ class DaemonApp:
|
|
|
2062
2656
|
)
|
|
2063
2657
|
retry_policy = self._runner_retry_policy(runner_name, runner_cfg if isinstance(runner_cfg, dict) else {})
|
|
2064
2658
|
max_attempts = int(retry_policy.get("max_attempts") or 1)
|
|
2065
|
-
|
|
2066
|
-
|
|
2659
|
+
resumed_start_attempt, resumed_turn_id, retry_context = self._resume_retry_state(
|
|
2660
|
+
snapshot,
|
|
2661
|
+
max_attempts=max_attempts,
|
|
2662
|
+
)
|
|
2663
|
+
turn_id = resumed_turn_id or generate_id("turn")
|
|
2067
2664
|
quest_root = Path(snapshot["quest_root"])
|
|
2068
2665
|
worktree_root = Path(str(snapshot["current_workspace_root"])) if snapshot.get("current_workspace_root") else None
|
|
2069
2666
|
|
|
2070
|
-
for attempt_index in range(
|
|
2667
|
+
for attempt_index in range(resumed_start_attempt, max_attempts + 1):
|
|
2071
2668
|
current_run_id = run_id if attempt_index == 1 else generate_id("run")
|
|
2072
2669
|
if attempt_index > 1:
|
|
2073
2670
|
self._append_retry_event(
|
|
@@ -2136,6 +2733,31 @@ class DaemonApp:
|
|
|
2136
2733
|
previous_output_text="",
|
|
2137
2734
|
stderr_text=str(exc),
|
|
2138
2735
|
)
|
|
2736
|
+
diagnosis = self._non_retryable_failure_diagnosis(
|
|
2737
|
+
runner_name=runner_name,
|
|
2738
|
+
summary=failure_summary,
|
|
2739
|
+
stderr_text=str(exc),
|
|
2740
|
+
output_text="",
|
|
2741
|
+
)
|
|
2742
|
+
if diagnosis is not None:
|
|
2743
|
+
self.quest_service.update_runtime_state(
|
|
2744
|
+
quest_root=quest_root,
|
|
2745
|
+
continuation_policy="wait_for_user_or_resume",
|
|
2746
|
+
continuation_reason="non_retryable_runner_error",
|
|
2747
|
+
continuation_updated_at=utc_now(),
|
|
2748
|
+
)
|
|
2749
|
+
self._record_turn_error(
|
|
2750
|
+
quest_id=quest_id,
|
|
2751
|
+
runner_name=runner_name,
|
|
2752
|
+
run_id=current_run_id,
|
|
2753
|
+
skill_id=skill_id,
|
|
2754
|
+
model=model,
|
|
2755
|
+
summary=f"{diagnosis.problem} {failure_summary}".strip(),
|
|
2756
|
+
retry_state=None,
|
|
2757
|
+
diagnosis_code=diagnosis.code,
|
|
2758
|
+
guidance=list(diagnosis.guidance),
|
|
2759
|
+
)
|
|
2760
|
+
return
|
|
2139
2761
|
if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
|
|
2140
2762
|
delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
|
|
2141
2763
|
next_retry_at = self._retry_next_timestamp(delay_seconds)
|
|
@@ -2284,6 +2906,31 @@ class DaemonApp:
|
|
|
2284
2906
|
previous_output_text=result.output_text,
|
|
2285
2907
|
stderr_text=result.stderr_text,
|
|
2286
2908
|
)
|
|
2909
|
+
diagnosis = self._non_retryable_failure_diagnosis(
|
|
2910
|
+
runner_name=runner_name,
|
|
2911
|
+
summary=failure_summary,
|
|
2912
|
+
stderr_text=result.stderr_text,
|
|
2913
|
+
output_text=result.output_text,
|
|
2914
|
+
)
|
|
2915
|
+
if diagnosis is not None:
|
|
2916
|
+
self.quest_service.update_runtime_state(
|
|
2917
|
+
quest_root=quest_root,
|
|
2918
|
+
continuation_policy="wait_for_user_or_resume",
|
|
2919
|
+
continuation_reason="non_retryable_runner_error",
|
|
2920
|
+
continuation_updated_at=utc_now(),
|
|
2921
|
+
)
|
|
2922
|
+
self._record_turn_error(
|
|
2923
|
+
quest_id=quest_id,
|
|
2924
|
+
runner_name=runner_name,
|
|
2925
|
+
run_id=result.run_id,
|
|
2926
|
+
skill_id=skill_id,
|
|
2927
|
+
model=model,
|
|
2928
|
+
summary=f"{diagnosis.problem} {failure_summary}".strip(),
|
|
2929
|
+
retry_state=None,
|
|
2930
|
+
diagnosis_code=diagnosis.code,
|
|
2931
|
+
guidance=list(diagnosis.guidance),
|
|
2932
|
+
)
|
|
2933
|
+
return
|
|
2287
2934
|
if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
|
|
2288
2935
|
delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
|
|
2289
2936
|
next_retry_at = self._retry_next_timestamp(delay_seconds)
|
|
@@ -2421,11 +3068,41 @@ class DaemonApp:
|
|
|
2421
3068
|
|
|
2422
3069
|
@staticmethod
|
|
2423
3070
|
def _continuation_anchor_for(snapshot: dict) -> str:
|
|
3071
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
2424
3072
|
continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
|
|
2425
|
-
if continuation_anchor in
|
|
3073
|
+
if continuation_anchor in available_stage_skills:
|
|
2426
3074
|
return continuation_anchor
|
|
2427
3075
|
active_anchor = str(snapshot.get("active_anchor") or "").strip()
|
|
2428
|
-
return active_anchor if active_anchor in
|
|
3076
|
+
return active_anchor if active_anchor in available_stage_skills else "decision"
|
|
3077
|
+
|
|
3078
|
+
@staticmethod
|
|
3079
|
+
def _workspace_mode_for(snapshot: dict) -> str:
|
|
3080
|
+
value = str(snapshot.get("workspace_mode") or "").strip().lower()
|
|
3081
|
+
if value in {"copilot", "autonomous"}:
|
|
3082
|
+
return value
|
|
3083
|
+
startup_contract = snapshot.get("startup_contract")
|
|
3084
|
+
if isinstance(startup_contract, dict):
|
|
3085
|
+
value = str(startup_contract.get("workspace_mode") or "").strip().lower()
|
|
3086
|
+
if value in {"copilot", "autonomous"}:
|
|
3087
|
+
return value
|
|
3088
|
+
return "autonomous"
|
|
3089
|
+
|
|
3090
|
+
def _resolve_continuation_policy(self, snapshot: dict, *, current_policy: str) -> tuple[str, str]:
|
|
3091
|
+
normalized = str(current_policy or "auto").strip().lower() or "auto"
|
|
3092
|
+
if normalized != "auto":
|
|
3093
|
+
return normalized, str(snapshot.get("continuation_reason") or "").strip() or "explicit_continuation_policy"
|
|
3094
|
+
if self._workspace_mode_for(snapshot) == "copilot":
|
|
3095
|
+
return "wait_for_user_or_resume", "copilot_mode"
|
|
3096
|
+
if self._has_external_progress(snapshot):
|
|
3097
|
+
return "when_external_progress", "background_external_progress_active"
|
|
3098
|
+
return "auto", "autonomous_prepare_or_launch_long_run"
|
|
3099
|
+
|
|
3100
|
+
@staticmethod
|
|
3101
|
+
def _auto_continue_delay_for_policy(policy: str) -> float:
|
|
3102
|
+
normalized = str(policy or "").strip().lower() or "auto"
|
|
3103
|
+
if normalized == "when_external_progress":
|
|
3104
|
+
return _AUTO_CONTINUE_DELAY_SECONDS
|
|
3105
|
+
return _AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS
|
|
2429
3106
|
|
|
2430
3107
|
@staticmethod
|
|
2431
3108
|
def _turn_skill_stage_gate(snapshot: dict, candidate_skill: str) -> str:
|
|
@@ -2447,6 +3124,18 @@ class DaemonApp:
|
|
|
2447
3124
|
|
|
2448
3125
|
return skill
|
|
2449
3126
|
|
|
3127
|
+
@staticmethod
|
|
3128
|
+
def _direct_user_turn_skill(snapshot: dict) -> str:
|
|
3129
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
3130
|
+
for candidate in (
|
|
3131
|
+
str(snapshot.get("active_anchor") or "").strip(),
|
|
3132
|
+
str(snapshot.get("continuation_anchor") or "").strip(),
|
|
3133
|
+
):
|
|
3134
|
+
if candidate in available_stage_skills and candidate != "decision":
|
|
3135
|
+
return DaemonApp._turn_skill_stage_gate(snapshot, candidate)
|
|
3136
|
+
fallback = "baseline" if "baseline" in available_stage_skills else "scout"
|
|
3137
|
+
return DaemonApp._turn_skill_stage_gate(snapshot, fallback)
|
|
3138
|
+
|
|
2450
3139
|
@staticmethod
|
|
2451
3140
|
def _turn_skill_for(
|
|
2452
3141
|
snapshot: dict,
|
|
@@ -2455,6 +3144,9 @@ class DaemonApp:
|
|
|
2455
3144
|
turn_reason: str = "user_message",
|
|
2456
3145
|
turn_mode: str = "stage_execution",
|
|
2457
3146
|
) -> str:
|
|
3147
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
3148
|
+
workspace_mode = DaemonApp._workspace_mode_for(snapshot)
|
|
3149
|
+
|
|
2458
3150
|
reply_target = str((latest_user_message or {}).get("reply_to_interaction_id") or "").strip()
|
|
2459
3151
|
if reply_target:
|
|
2460
3152
|
for item in (snapshot.get("active_interactions") or []):
|
|
@@ -2481,12 +3173,18 @@ class DaemonApp:
|
|
|
2481
3173
|
):
|
|
2482
3174
|
return "decision"
|
|
2483
3175
|
if str(item.get("reply_mode") or "") == "threaded":
|
|
3176
|
+
if workspace_mode == "copilot" or turn_mode in {"answering", "command_execution"}:
|
|
3177
|
+
return DaemonApp._direct_user_turn_skill(snapshot)
|
|
2484
3178
|
return DaemonApp._turn_skill_stage_gate(
|
|
2485
3179
|
snapshot,
|
|
2486
3180
|
DaemonApp._continuation_anchor_for(snapshot),
|
|
2487
3181
|
)
|
|
2488
|
-
if turn_mode
|
|
3182
|
+
if turn_mode == "recovering":
|
|
2489
3183
|
return "decision"
|
|
3184
|
+
if workspace_mode == "copilot" and latest_user_message is not None:
|
|
3185
|
+
return DaemonApp._direct_user_turn_skill(snapshot)
|
|
3186
|
+
if turn_mode in {"answering", "command_execution"}:
|
|
3187
|
+
return DaemonApp._direct_user_turn_skill(snapshot)
|
|
2490
3188
|
if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
|
|
2491
3189
|
return DaemonApp._turn_skill_stage_gate(
|
|
2492
3190
|
snapshot,
|
|
@@ -2501,7 +3199,7 @@ class DaemonApp:
|
|
|
2501
3199
|
active_anchor = str(snapshot.get("active_anchor") or "").strip()
|
|
2502
3200
|
return DaemonApp._turn_skill_stage_gate(
|
|
2503
3201
|
snapshot,
|
|
2504
|
-
active_anchor if active_anchor in
|
|
3202
|
+
active_anchor if active_anchor in available_stage_skills else "decision",
|
|
2505
3203
|
)
|
|
2506
3204
|
|
|
2507
3205
|
def _latest_user_message(self, quest_id: str) -> dict | None:
|
|
@@ -2800,8 +3498,11 @@ class DaemonApp:
|
|
|
2800
3498
|
summary: str,
|
|
2801
3499
|
display_status: str = "error",
|
|
2802
3500
|
retry_state: dict[str, Any] | None = None,
|
|
3501
|
+
diagnosis_code: str | None = None,
|
|
3502
|
+
guidance: list[str] | None = None,
|
|
2803
3503
|
) -> None:
|
|
2804
3504
|
quest_root = self.home / "quests" / quest_id
|
|
3505
|
+
normalized_guidance = [str(line) for line in (guidance or []) if str(line).strip()]
|
|
2805
3506
|
append_jsonl(
|
|
2806
3507
|
quest_root / ".ds" / "events.jsonl",
|
|
2807
3508
|
{
|
|
@@ -2813,6 +3514,8 @@ class DaemonApp:
|
|
|
2813
3514
|
"skill_id": skill_id,
|
|
2814
3515
|
"model": model,
|
|
2815
3516
|
"summary": summary,
|
|
3517
|
+
"diagnosis_code": str(diagnosis_code or "").strip() or None,
|
|
3518
|
+
"guidance": normalized_guidance,
|
|
2816
3519
|
"created_at": utc_now(),
|
|
2817
3520
|
},
|
|
2818
3521
|
)
|
|
@@ -2823,6 +3526,16 @@ class DaemonApp:
|
|
|
2823
3526
|
active_run_id=None,
|
|
2824
3527
|
retry_state=retry_state,
|
|
2825
3528
|
)
|
|
3529
|
+
notice_message = summary
|
|
3530
|
+
if normalized_guidance:
|
|
3531
|
+
notice_message = "\n".join(
|
|
3532
|
+
[
|
|
3533
|
+
summary,
|
|
3534
|
+
"",
|
|
3535
|
+
"Suggested fix:",
|
|
3536
|
+
*[f"- {line}" for line in normalized_guidance[:3]],
|
|
3537
|
+
]
|
|
3538
|
+
).strip()
|
|
2826
3539
|
self.logger.log(
|
|
2827
3540
|
"error",
|
|
2828
3541
|
"runner.turn_error",
|
|
@@ -2835,7 +3548,7 @@ class DaemonApp:
|
|
|
2835
3548
|
)
|
|
2836
3549
|
self._relay_quest_message_to_bound_connectors(
|
|
2837
3550
|
quest_id,
|
|
2838
|
-
message=
|
|
3551
|
+
message=notice_message,
|
|
2839
3552
|
kind="error",
|
|
2840
3553
|
response_phase="final",
|
|
2841
3554
|
importance="warning",
|
|
@@ -2846,10 +3559,29 @@ class DaemonApp:
|
|
|
2846
3559
|
"skill_id": skill_id,
|
|
2847
3560
|
"runner": runner_name,
|
|
2848
3561
|
"model": model,
|
|
3562
|
+
"diagnosis_code": str(diagnosis_code or "").strip() or None,
|
|
2849
3563
|
}
|
|
2850
3564
|
],
|
|
2851
3565
|
)
|
|
2852
3566
|
|
|
3567
|
+
@staticmethod
|
|
3568
|
+
def _non_retryable_failure_diagnosis(
|
|
3569
|
+
*,
|
|
3570
|
+
runner_name: str,
|
|
3571
|
+
summary: str,
|
|
3572
|
+
stderr_text: str,
|
|
3573
|
+
output_text: str,
|
|
3574
|
+
) -> FailureDiagnosis | None:
|
|
3575
|
+
diagnosis = diagnose_runner_failure(
|
|
3576
|
+
runner_name=runner_name,
|
|
3577
|
+
summary=summary,
|
|
3578
|
+
stderr_text=stderr_text,
|
|
3579
|
+
output_text=output_text,
|
|
3580
|
+
)
|
|
3581
|
+
if diagnosis is None or diagnosis.retriable:
|
|
3582
|
+
return None
|
|
3583
|
+
return diagnosis
|
|
3584
|
+
|
|
2853
3585
|
def _record_turn_postprocess_warning(
|
|
2854
3586
|
self,
|
|
2855
3587
|
*,
|
|
@@ -2981,23 +3713,67 @@ class DaemonApp:
|
|
|
2981
3713
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2982
3714
|
else:
|
|
2983
3715
|
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
3716
|
+
if continuation_policy == "auto":
|
|
3717
|
+
continuation_policy, continuation_reason = self._resolve_continuation_policy(
|
|
3718
|
+
snapshot,
|
|
3719
|
+
current_policy=continuation_policy,
|
|
3720
|
+
)
|
|
3721
|
+
self.quest_service.update_runtime_state(
|
|
3722
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3723
|
+
continuation_policy=continuation_policy,
|
|
3724
|
+
continuation_reason=continuation_reason,
|
|
3725
|
+
continuation_updated_at=utc_now(),
|
|
3726
|
+
)
|
|
3727
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2984
3728
|
if continuation_policy not in {"wait_for_user_or_resume", "none"}:
|
|
2985
|
-
self._schedule_turn_later(
|
|
3729
|
+
self._schedule_turn_later(
|
|
3730
|
+
quest_id,
|
|
3731
|
+
reason="auto_continue",
|
|
3732
|
+
delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
|
|
3733
|
+
)
|
|
2986
3734
|
return
|
|
2987
3735
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2988
3736
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2989
3737
|
return
|
|
2990
3738
|
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
3739
|
+
if continuation_policy == "auto":
|
|
3740
|
+
continuation_policy, continuation_reason = self._resolve_continuation_policy(
|
|
3741
|
+
snapshot,
|
|
3742
|
+
current_policy=continuation_policy,
|
|
3743
|
+
)
|
|
3744
|
+
self.quest_service.update_runtime_state(
|
|
3745
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3746
|
+
continuation_policy=continuation_policy,
|
|
3747
|
+
continuation_reason=continuation_reason,
|
|
3748
|
+
continuation_updated_at=utc_now(),
|
|
3749
|
+
)
|
|
3750
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2991
3751
|
if continuation_policy == "none":
|
|
2992
3752
|
return
|
|
2993
3753
|
if continuation_policy == "wait_for_user_or_resume":
|
|
2994
3754
|
return
|
|
2995
3755
|
if continuation_policy == "when_external_progress":
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
3756
|
+
if not self._has_external_progress(snapshot):
|
|
3757
|
+
next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
|
|
3758
|
+
next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
|
|
3759
|
+
self.quest_service.update_runtime_state(
|
|
3760
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3761
|
+
continuation_policy=next_policy,
|
|
3762
|
+
continuation_reason=next_reason,
|
|
3763
|
+
continuation_updated_at=utc_now(),
|
|
3764
|
+
)
|
|
3765
|
+
if next_policy != "wait_for_user_or_resume":
|
|
3766
|
+
self._schedule_turn_later(
|
|
3767
|
+
quest_id,
|
|
3768
|
+
reason="auto_continue",
|
|
3769
|
+
delay_seconds=self._auto_continue_delay_for_policy(next_policy),
|
|
3770
|
+
)
|
|
2999
3771
|
return
|
|
3000
|
-
self._schedule_turn_later(
|
|
3772
|
+
self._schedule_turn_later(
|
|
3773
|
+
quest_id,
|
|
3774
|
+
reason="auto_continue",
|
|
3775
|
+
delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
|
|
3776
|
+
)
|
|
3001
3777
|
|
|
3002
3778
|
def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
|
|
3003
3779
|
def _delayed() -> None:
|
|
@@ -3009,12 +3785,30 @@ class DaemonApp:
|
|
|
3009
3785
|
if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
|
|
3010
3786
|
return
|
|
3011
3787
|
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
3788
|
+
if continuation_policy == "auto":
|
|
3789
|
+
continuation_policy, continuation_reason = self._resolve_continuation_policy(
|
|
3790
|
+
snapshot,
|
|
3791
|
+
current_policy=continuation_policy,
|
|
3792
|
+
)
|
|
3793
|
+
self.quest_service.update_runtime_state(
|
|
3794
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3795
|
+
continuation_policy=continuation_policy,
|
|
3796
|
+
continuation_reason=continuation_reason,
|
|
3797
|
+
continuation_updated_at=utc_now(),
|
|
3798
|
+
)
|
|
3799
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
3012
3800
|
if continuation_policy in {"none", "wait_for_user_or_resume"}:
|
|
3013
3801
|
return
|
|
3014
3802
|
if continuation_policy == "when_external_progress":
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3803
|
+
if not self._has_external_progress(snapshot):
|
|
3804
|
+
next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
|
|
3805
|
+
next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
|
|
3806
|
+
self.quest_service.update_runtime_state(
|
|
3807
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3808
|
+
continuation_policy=next_policy,
|
|
3809
|
+
continuation_reason=next_reason,
|
|
3810
|
+
continuation_updated_at=utc_now(),
|
|
3811
|
+
)
|
|
3018
3812
|
return
|
|
3019
3813
|
self.schedule_turn(quest_id, reason=reason)
|
|
3020
3814
|
|
|
@@ -3024,6 +3818,23 @@ class DaemonApp:
|
|
|
3024
3818
|
name=f"deepscientist-turn-delay-{quest_id}",
|
|
3025
3819
|
).start()
|
|
3026
3820
|
|
|
3821
|
+
def _has_external_progress(self, snapshot: dict) -> bool:
|
|
3822
|
+
if bool(snapshot.get("active_run_id")):
|
|
3823
|
+
return True
|
|
3824
|
+
quest_id = str(snapshot.get("quest_id") or "").strip()
|
|
3825
|
+
if not quest_id:
|
|
3826
|
+
return False
|
|
3827
|
+
try:
|
|
3828
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
3829
|
+
except FileNotFoundError:
|
|
3830
|
+
return False
|
|
3831
|
+
try:
|
|
3832
|
+
sessions = self.bash_exec_service.list_sessions(quest_root, limit=200)
|
|
3833
|
+
return any(str(item.get("status") or "").strip().lower() == "running" for item in sessions if isinstance(item, dict))
|
|
3834
|
+
except Exception:
|
|
3835
|
+
counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
|
|
3836
|
+
return int(counts.get("bash_running_count") or 0) > 0
|
|
3837
|
+
|
|
3027
3838
|
def _relay_quest_message_to_bound_connectors(
|
|
3028
3839
|
self,
|
|
3029
3840
|
quest_id: str,
|
|
@@ -4016,8 +4827,8 @@ class DaemonApp:
|
|
|
4016
4827
|
"quest_id": target_quest,
|
|
4017
4828
|
"kind": "ack",
|
|
4018
4829
|
"message": self._polite_copy(
|
|
4019
|
-
zh=f"
|
|
4020
|
-
en=f"
|
|
4830
|
+
zh=f"收到啦!这里已经切到 Quest `{target_quest}` 了,接下来我会直接在这个 {connector_label} 里继续同步进展。",
|
|
4831
|
+
en=f"Got it. This {connector_label} is now on Quest `{target_quest}`, and I’ll keep the next updates here. ✨",
|
|
4021
4832
|
),
|
|
4022
4833
|
}
|
|
4023
4834
|
)
|
|
@@ -5108,13 +5919,13 @@ class DaemonApp:
|
|
|
5108
5919
|
channel = self._channel_with_bindings(old_connector)
|
|
5109
5920
|
if mode == "disconnect":
|
|
5110
5921
|
message = self._polite_copy(
|
|
5111
|
-
zh=f"
|
|
5112
|
-
en=f"
|
|
5922
|
+
zh=f"Quest `{quest_id}` 已经从这里解绑啦,后面会只在本地继续推进。",
|
|
5923
|
+
en=f"Quest `{quest_id}` is no longer bound here. It will continue locally only. 📌",
|
|
5113
5924
|
)
|
|
5114
5925
|
else:
|
|
5115
5926
|
message = self._polite_copy(
|
|
5116
|
-
zh=f"
|
|
5117
|
-
en=f"
|
|
5927
|
+
zh=f"Quest `{quest_id}` 已经从这里切走啦,后面的进展请在 {current_label} 查看。",
|
|
5928
|
+
en=f"Quest `{quest_id}` has moved away from this conversation. Continue from {current_label}. 🔁",
|
|
5118
5929
|
)
|
|
5119
5930
|
channel.send(
|
|
5120
5931
|
{
|
|
@@ -5131,13 +5942,13 @@ class DaemonApp:
|
|
|
5131
5942
|
channel = self._channel_with_bindings(new_connector)
|
|
5132
5943
|
if mode == "bind":
|
|
5133
5944
|
message = self._polite_copy(
|
|
5134
|
-
zh=f"
|
|
5135
|
-
en=f"
|
|
5945
|
+
zh=f"收到!Quest `{quest_id}` 已经接上啦,后面的进展我都会直接在这里同步给您。",
|
|
5946
|
+
en=f"Quest `{quest_id}` is now connected here, and I’ll keep the next updates in this conversation. ✨",
|
|
5136
5947
|
)
|
|
5137
5948
|
elif mode == "switch":
|
|
5138
5949
|
message = self._polite_copy(
|
|
5139
|
-
zh=f"
|
|
5140
|
-
en=f"
|
|
5950
|
+
zh=f"收到!Quest `{quest_id}` 已经切到这里啦,后面的进展我都会直接在这里同步给您。",
|
|
5951
|
+
en=f"Quest `{quest_id}` has switched over here, and I’ll keep the next updates in this conversation. 🔄",
|
|
5141
5952
|
)
|
|
5142
5953
|
else:
|
|
5143
5954
|
message = ""
|
|
@@ -5371,6 +6182,20 @@ class DaemonApp:
|
|
|
5371
6182
|
)
|
|
5372
6183
|
return f"{notice}\n\n{base}"
|
|
5373
6184
|
|
|
6185
|
+
def _connector_goal_preview(self, goal: str, *, limit: int = 88) -> str:
|
|
6186
|
+
for raw_line in str(goal or "").replace("\r", "\n").split("\n"):
|
|
6187
|
+
line = re.sub(r"^[#>*\-\d\.)\s]+", "", raw_line).strip()
|
|
6188
|
+
if not line:
|
|
6189
|
+
continue
|
|
6190
|
+
normalized = re.sub(r"\s+", " ", line).strip()
|
|
6191
|
+
if len(normalized) <= limit:
|
|
6192
|
+
return normalized
|
|
6193
|
+
return normalized[: max(0, limit - 3)].rstrip() + "..."
|
|
6194
|
+
return self._polite_copy(
|
|
6195
|
+
zh="我会先把当前任务整理清楚,再继续推进。",
|
|
6196
|
+
en="I will clarify the current task first, then keep moving. ✨",
|
|
6197
|
+
)
|
|
6198
|
+
|
|
5374
6199
|
def _quest_created_connector_message(
|
|
5375
6200
|
self,
|
|
5376
6201
|
connector_name: str,
|
|
@@ -5379,29 +6204,29 @@ class DaemonApp:
|
|
|
5379
6204
|
goal: str,
|
|
5380
6205
|
previous_quest_id: str | None = None,
|
|
5381
6206
|
) -> str:
|
|
5382
|
-
normalized_goal = str(goal or "").strip() or "(未提供具体任务)"
|
|
5383
6207
|
previous = str(previous_quest_id or "").strip()
|
|
6208
|
+
goal_preview = self._connector_goal_preview(goal)
|
|
5384
6209
|
restore_zh = (
|
|
5385
|
-
f"\n
|
|
6210
|
+
f"\n如果想切回原先的 Quest `{previous}`,给我发 `/use {previous}` 就行。"
|
|
5386
6211
|
if previous and previous != quest_id
|
|
5387
6212
|
else ""
|
|
5388
6213
|
)
|
|
5389
6214
|
restore_en = (
|
|
5390
|
-
f"\nIf you
|
|
6215
|
+
f"\nIf you want to switch back to Quest `{previous}`, send `/use {previous}`. 🔁"
|
|
5391
6216
|
if previous and previous != quest_id
|
|
5392
6217
|
else ""
|
|
5393
6218
|
)
|
|
5394
6219
|
return self._polite_copy(
|
|
5395
6220
|
zh=(
|
|
5396
|
-
f"
|
|
5397
|
-
f"
|
|
5398
|
-
f"
|
|
6221
|
+
f"开工啦!新的 Quest `{quest_id}` 已经建好啦。\n"
|
|
6222
|
+
f"这轮我先做这件事:{goal_preview}\n"
|
|
6223
|
+
f"后面的进展我都会直接在这里同步给您。"
|
|
5399
6224
|
)
|
|
5400
6225
|
+ restore_zh,
|
|
5401
6226
|
en=(
|
|
5402
|
-
f"
|
|
5403
|
-
f"
|
|
5404
|
-
f"
|
|
6227
|
+
f"Quest `{quest_id}` is ready, and I’m starting now. 🚀\n"
|
|
6228
|
+
f"Current focus: {goal_preview}\n"
|
|
6229
|
+
f"I’ll keep the next updates right here."
|
|
5405
6230
|
)
|
|
5406
6231
|
+ restore_en,
|
|
5407
6232
|
)
|
|
@@ -6228,6 +7053,33 @@ class DaemonApp:
|
|
|
6228
7053
|
handler.wfile.write(b"\n")
|
|
6229
7054
|
handler.wfile.flush()
|
|
6230
7055
|
|
|
7056
|
+
@staticmethod
|
|
7057
|
+
def _write_handler_response(
|
|
7058
|
+
handler: BaseHTTPRequestHandler,
|
|
7059
|
+
*,
|
|
7060
|
+
code: int,
|
|
7061
|
+
content: bytes,
|
|
7062
|
+
content_type: str | None = None,
|
|
7063
|
+
extra_headers: dict[str, str] | None = None,
|
|
7064
|
+
) -> bool:
|
|
7065
|
+
try:
|
|
7066
|
+
handler.send_response(code)
|
|
7067
|
+
if content_type:
|
|
7068
|
+
handler.send_header("Content-Type", content_type)
|
|
7069
|
+
handler.send_header("Content-Length", str(len(content)))
|
|
7070
|
+
for key, value in (extra_headers or {}).items():
|
|
7071
|
+
handler.send_header(key, value)
|
|
7072
|
+
handler.end_headers()
|
|
7073
|
+
if content:
|
|
7074
|
+
handler.wfile.write(content)
|
|
7075
|
+
return True
|
|
7076
|
+
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
7077
|
+
try:
|
|
7078
|
+
handler.close_connection = True
|
|
7079
|
+
except Exception:
|
|
7080
|
+
pass
|
|
7081
|
+
return False
|
|
7082
|
+
|
|
6231
7083
|
@staticmethod
|
|
6232
7084
|
def _parse_bash_log_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
|
|
6233
7085
|
stripped = raw_line.strip()
|
|
@@ -6241,6 +7093,19 @@ class DaemonApp:
|
|
|
6241
7093
|
return None
|
|
6242
7094
|
return payload
|
|
6243
7095
|
|
|
7096
|
+
@staticmethod
|
|
7097
|
+
def _parse_quest_event_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
|
|
7098
|
+
stripped = raw_line.strip()
|
|
7099
|
+
if not stripped:
|
|
7100
|
+
return None
|
|
7101
|
+
try:
|
|
7102
|
+
payload = json.loads(stripped.decode("utf-8", errors="replace"))
|
|
7103
|
+
except json.JSONDecodeError:
|
|
7104
|
+
return None
|
|
7105
|
+
if not isinstance(payload, dict):
|
|
7106
|
+
return None
|
|
7107
|
+
return payload
|
|
7108
|
+
|
|
6244
7109
|
@classmethod
|
|
6245
7110
|
def _read_bash_log_delta(
|
|
6246
7111
|
cls,
|
|
@@ -6284,6 +7149,42 @@ class DaemonApp:
|
|
|
6284
7149
|
|
|
6285
7150
|
return fresh_entries, next_offset, remainder
|
|
6286
7151
|
|
|
7152
|
+
@classmethod
|
|
7153
|
+
def _read_quest_event_delta(
|
|
7154
|
+
cls,
|
|
7155
|
+
event_path: Path,
|
|
7156
|
+
*,
|
|
7157
|
+
offset: int,
|
|
7158
|
+
pending: bytes,
|
|
7159
|
+
) -> tuple[list[dict[str, Any]], int, bytes]:
|
|
7160
|
+
if not event_path.exists():
|
|
7161
|
+
return [], 0, pending
|
|
7162
|
+
|
|
7163
|
+
current_size = event_path.stat().st_size
|
|
7164
|
+
safe_offset = max(0, min(offset, current_size))
|
|
7165
|
+
with event_path.open("rb") as handle:
|
|
7166
|
+
handle.seek(safe_offset)
|
|
7167
|
+
chunk = handle.read()
|
|
7168
|
+
next_offset = handle.tell()
|
|
7169
|
+
|
|
7170
|
+
if not chunk:
|
|
7171
|
+
return [], next_offset, pending
|
|
7172
|
+
|
|
7173
|
+
payload = pending + chunk
|
|
7174
|
+
lines = payload.split(b"\n")
|
|
7175
|
+
remainder = b""
|
|
7176
|
+
if payload and not payload.endswith(b"\n"):
|
|
7177
|
+
remainder = lines.pop()
|
|
7178
|
+
|
|
7179
|
+
fresh_entries: list[dict[str, Any]] = []
|
|
7180
|
+
for raw_line in lines:
|
|
7181
|
+
entry = cls._parse_quest_event_jsonl_line(raw_line.rstrip(b"\r"))
|
|
7182
|
+
if not entry:
|
|
7183
|
+
continue
|
|
7184
|
+
fresh_entries.append(entry)
|
|
7185
|
+
|
|
7186
|
+
return fresh_entries, next_offset, remainder
|
|
7187
|
+
|
|
6287
7188
|
def stream_quest_events(
|
|
6288
7189
|
self,
|
|
6289
7190
|
handler: BaseHTTPRequestHandler,
|
|
@@ -6291,6 +7192,7 @@ class DaemonApp:
|
|
|
6291
7192
|
quest_id: str,
|
|
6292
7193
|
path: str,
|
|
6293
7194
|
headers: dict[str, str] | None = None,
|
|
7195
|
+
extra_headers: dict[str, str] | None = None,
|
|
6294
7196
|
) -> None:
|
|
6295
7197
|
query = self.handlers.parse_query(path)
|
|
6296
7198
|
after = int((query.get("after") or ["0"])[0] or "0")
|
|
@@ -6300,16 +7202,23 @@ class DaemonApp:
|
|
|
6300
7202
|
last_event_id = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
|
|
6301
7203
|
current_cursor = max(after, int(last_event_id)) if last_event_id.isdigit() else after
|
|
6302
7204
|
heartbeat_at = time.monotonic()
|
|
6303
|
-
idle_sleep_seconds = 0.
|
|
7205
|
+
idle_sleep_seconds = 0.08
|
|
6304
7206
|
force_fetch = True
|
|
6305
7207
|
event_path = self.quest_service._quest_root(quest_id) / ".ds" / "events.jsonl"
|
|
6306
7208
|
previous_event_state = None
|
|
7209
|
+
cached_tail = self.quest_service.jsonl_tail_cache_entry(event_path) or {}
|
|
7210
|
+
cached_tail_state = cached_tail.get("state") if isinstance(cached_tail.get("state"), (list, tuple)) else None
|
|
7211
|
+
cached_tail_total = int(cached_tail.get("total") or 0) if isinstance(cached_tail, dict) else 0
|
|
7212
|
+
event_offset = int(cached_tail_state[2]) if cached_tail_state and cached_tail_total == current_cursor else 0
|
|
7213
|
+
pending_bytes = b""
|
|
6307
7214
|
|
|
6308
7215
|
handler.send_response(200)
|
|
6309
7216
|
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
6310
7217
|
handler.send_header("Cache-Control", "no-cache, no-transform")
|
|
6311
7218
|
handler.send_header("Connection", "keep-alive")
|
|
6312
7219
|
handler.send_header("X-Accel-Buffering", "no")
|
|
7220
|
+
for key, value in (extra_headers or {}).items():
|
|
7221
|
+
handler.send_header(key, value)
|
|
6313
7222
|
handler.end_headers()
|
|
6314
7223
|
handler.wfile.write(b"retry: 1000\n\n")
|
|
6315
7224
|
handler.wfile.flush()
|
|
@@ -6318,6 +7227,62 @@ class DaemonApp:
|
|
|
6318
7227
|
while True:
|
|
6319
7228
|
current_event_state = self.quest_service._path_state(event_path)
|
|
6320
7229
|
if force_fetch or current_event_state != previous_event_state:
|
|
7230
|
+
used_incremental_delta = False
|
|
7231
|
+
delta_base_state = previous_event_state or cached_tail_state
|
|
7232
|
+
can_read_incremental = (
|
|
7233
|
+
current_cursor > 0
|
|
7234
|
+
and event_offset > 0
|
|
7235
|
+
and current_event_state is not None
|
|
7236
|
+
and delta_base_state is not None
|
|
7237
|
+
and tuple(delta_base_state)[0] == current_event_state[0]
|
|
7238
|
+
and current_event_state[2] >= int(tuple(delta_base_state)[2])
|
|
7239
|
+
)
|
|
7240
|
+
if can_read_incremental:
|
|
7241
|
+
fresh_events, event_offset, pending_bytes = self._read_quest_event_delta(
|
|
7242
|
+
event_path,
|
|
7243
|
+
offset=event_offset,
|
|
7244
|
+
pending=pending_bytes,
|
|
7245
|
+
)
|
|
7246
|
+
previous_event_state = current_event_state
|
|
7247
|
+
if fresh_events:
|
|
7248
|
+
for event in fresh_events:
|
|
7249
|
+
current_cursor += 1
|
|
7250
|
+
enriched_event = {
|
|
7251
|
+
"cursor": current_cursor,
|
|
7252
|
+
"event_id": event.get("event_id") or f"evt-{quest_id}-{current_cursor}",
|
|
7253
|
+
**event,
|
|
7254
|
+
}
|
|
7255
|
+
update = build_session_update(
|
|
7256
|
+
enriched_event,
|
|
7257
|
+
quest_id=quest_id,
|
|
7258
|
+
cursor=current_cursor,
|
|
7259
|
+
session_id=session_id,
|
|
7260
|
+
)
|
|
7261
|
+
self._write_sse_event(
|
|
7262
|
+
handler,
|
|
7263
|
+
event="acp_update",
|
|
7264
|
+
data=update,
|
|
7265
|
+
event_id=str(current_cursor),
|
|
7266
|
+
)
|
|
7267
|
+
self._write_sse_event(
|
|
7268
|
+
handler,
|
|
7269
|
+
event="cursor",
|
|
7270
|
+
data={"cursor": current_cursor, "quest_id": quest_id},
|
|
7271
|
+
)
|
|
7272
|
+
heartbeat_at = time.monotonic()
|
|
7273
|
+
used_incremental_delta = True
|
|
7274
|
+
force_fetch = False
|
|
7275
|
+
idle_sleep_seconds = 0.03
|
|
7276
|
+
else:
|
|
7277
|
+
force_fetch = False
|
|
7278
|
+
now = time.monotonic()
|
|
7279
|
+
if now - heartbeat_at >= 10:
|
|
7280
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
7281
|
+
handler.wfile.flush()
|
|
7282
|
+
heartbeat_at = now
|
|
7283
|
+
if used_incremental_delta:
|
|
7284
|
+
time.sleep(idle_sleep_seconds)
|
|
7285
|
+
continue
|
|
6321
7286
|
stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
|
|
6322
7287
|
payload = self.handlers.quest_events(quest_id, path=stream_path)
|
|
6323
7288
|
previous_event_state = current_event_state
|
|
@@ -6332,6 +7297,11 @@ class DaemonApp:
|
|
|
6332
7297
|
event_id=update_cursor or None,
|
|
6333
7298
|
)
|
|
6334
7299
|
current_cursor = int(payload.get("cursor") or current_cursor)
|
|
7300
|
+
if current_event_state is not None and not payload.get("has_more"):
|
|
7301
|
+
event_offset = int(current_event_state[2])
|
|
7302
|
+
pending_bytes = b""
|
|
7303
|
+
cached_tail_state = current_event_state
|
|
7304
|
+
cached_tail_total = current_cursor
|
|
6335
7305
|
self._write_sse_event(
|
|
6336
7306
|
handler,
|
|
6337
7307
|
event="cursor",
|
|
@@ -6339,22 +7309,25 @@ class DaemonApp:
|
|
|
6339
7309
|
)
|
|
6340
7310
|
heartbeat_at = time.monotonic()
|
|
6341
7311
|
force_fetch = bool(payload.get("has_more"))
|
|
6342
|
-
idle_sleep_seconds = 0.
|
|
7312
|
+
idle_sleep_seconds = 0.03 if force_fetch else 0.08
|
|
6343
7313
|
else:
|
|
7314
|
+
if current_event_state is not None:
|
|
7315
|
+
event_offset = int(current_event_state[2])
|
|
7316
|
+
cached_tail_state = current_event_state
|
|
6344
7317
|
force_fetch = False
|
|
6345
7318
|
now = time.monotonic()
|
|
6346
7319
|
if now - heartbeat_at >= 10:
|
|
6347
7320
|
handler.wfile.write(b": keep-alive\n\n")
|
|
6348
7321
|
handler.wfile.flush()
|
|
6349
7322
|
heartbeat_at = now
|
|
6350
|
-
idle_sleep_seconds = min(
|
|
7323
|
+
idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
|
|
6351
7324
|
else:
|
|
6352
7325
|
now = time.monotonic()
|
|
6353
7326
|
if now - heartbeat_at >= 10:
|
|
6354
7327
|
handler.wfile.write(b": keep-alive\n\n")
|
|
6355
7328
|
handler.wfile.flush()
|
|
6356
7329
|
heartbeat_at = now
|
|
6357
|
-
idle_sleep_seconds = min(
|
|
7330
|
+
idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
|
|
6358
7331
|
time.sleep(idle_sleep_seconds)
|
|
6359
7332
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
6360
7333
|
return
|
|
@@ -6365,6 +7338,7 @@ class DaemonApp:
|
|
|
6365
7338
|
*,
|
|
6366
7339
|
quest_id: str,
|
|
6367
7340
|
path: str,
|
|
7341
|
+
extra_headers: dict[str, str] | None = None,
|
|
6368
7342
|
) -> None:
|
|
6369
7343
|
quest_root = self.quest_service._quest_root(quest_id)
|
|
6370
7344
|
query = self.handlers.parse_query(path)
|
|
@@ -6401,6 +7375,8 @@ class DaemonApp:
|
|
|
6401
7375
|
handler.send_header("Cache-Control", "no-cache, no-transform")
|
|
6402
7376
|
handler.send_header("Connection", "keep-alive")
|
|
6403
7377
|
handler.send_header("X-Accel-Buffering", "no")
|
|
7378
|
+
for key, value in (extra_headers or {}).items():
|
|
7379
|
+
handler.send_header(key, value)
|
|
6404
7380
|
handler.end_headers()
|
|
6405
7381
|
handler.wfile.write(b"retry: 1000\n\n")
|
|
6406
7382
|
handler.wfile.flush()
|
|
@@ -6486,6 +7462,7 @@ class DaemonApp:
|
|
|
6486
7462
|
quest_id: str,
|
|
6487
7463
|
bash_id: str,
|
|
6488
7464
|
headers: dict[str, str] | None = None,
|
|
7465
|
+
extra_headers: dict[str, str] | None = None,
|
|
6489
7466
|
) -> None:
|
|
6490
7467
|
quest_root = self.quest_service._quest_root(quest_id)
|
|
6491
7468
|
last_event_raw = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
|
|
@@ -6496,6 +7473,8 @@ class DaemonApp:
|
|
|
6496
7473
|
handler.send_header("Cache-Control", "no-cache, no-transform")
|
|
6497
7474
|
handler.send_header("Connection", "keep-alive")
|
|
6498
7475
|
handler.send_header("X-Accel-Buffering", "no")
|
|
7476
|
+
for key, value in (extra_headers or {}).items():
|
|
7477
|
+
handler.send_header(key, value)
|
|
6499
7478
|
handler.end_headers()
|
|
6500
7479
|
handler.wfile.write(b"retry: 1000\n\n")
|
|
6501
7480
|
handler.wfile.flush()
|
|
@@ -6696,35 +7675,84 @@ class DaemonApp:
|
|
|
6696
7675
|
if route_name is None:
|
|
6697
7676
|
self._write_json(404, {"ok": False, "message": "Not Found"})
|
|
6698
7677
|
return
|
|
6699
|
-
|
|
7678
|
+
request_headers = dict(self.headers.items())
|
|
7679
|
+
auth_state = app.browser_auth_state_for_request(self.path, request_headers)
|
|
7680
|
+
auth_headers = app._auth_response_headers(auth_state)
|
|
7681
|
+
if app._route_requires_browser_auth(route_name) and not auth_state.authenticated:
|
|
7682
|
+
self._write_json(
|
|
7683
|
+
401,
|
|
7684
|
+
{
|
|
7685
|
+
"ok": False,
|
|
7686
|
+
"message": "Authentication required.",
|
|
7687
|
+
"auth_required": True,
|
|
7688
|
+
"auth_enabled": True,
|
|
7689
|
+
},
|
|
7690
|
+
extra_headers={
|
|
7691
|
+
**auth_headers,
|
|
7692
|
+
"WWW-Authenticate": f'Bearer realm="{_BROWSER_AUTH_REALM}"',
|
|
7693
|
+
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
|
7694
|
+
},
|
|
7695
|
+
)
|
|
7696
|
+
return
|
|
7697
|
+
if route_name == "quest_events" and app._wants_event_stream(self.path, request_headers):
|
|
6700
7698
|
try:
|
|
6701
|
-
app.stream_quest_events(self, **params, path=self.path, headers=
|
|
7699
|
+
app.stream_quest_events(self, **params, path=self.path, headers=request_headers, extra_headers=auth_headers)
|
|
6702
7700
|
except Exception as exc:
|
|
6703
|
-
|
|
7701
|
+
app.logger.log(
|
|
7702
|
+
"error",
|
|
7703
|
+
"http.stream_quest_events_failed",
|
|
7704
|
+
path=self.path,
|
|
7705
|
+
error=str(exc),
|
|
7706
|
+
)
|
|
7707
|
+
self.close_connection = True
|
|
6704
7708
|
return
|
|
6705
7709
|
if route_name == "bash_sessions_stream":
|
|
6706
7710
|
try:
|
|
6707
|
-
app.stream_bash_sessions(self, **params, path=self.path)
|
|
7711
|
+
app.stream_bash_sessions(self, **params, path=self.path, extra_headers=auth_headers)
|
|
6708
7712
|
except Exception as exc:
|
|
6709
|
-
|
|
7713
|
+
app.logger.log(
|
|
7714
|
+
"error",
|
|
7715
|
+
"http.stream_bash_sessions_failed",
|
|
7716
|
+
path=self.path,
|
|
7717
|
+
error=str(exc),
|
|
7718
|
+
)
|
|
7719
|
+
self.close_connection = True
|
|
6710
7720
|
return
|
|
6711
7721
|
if route_name == "bash_log_stream":
|
|
6712
7722
|
try:
|
|
6713
|
-
app.stream_bash_logs(self, **params, headers=
|
|
7723
|
+
app.stream_bash_logs(self, **params, headers=request_headers, extra_headers=auth_headers)
|
|
6714
7724
|
except Exception as exc:
|
|
6715
|
-
|
|
7725
|
+
app.logger.log(
|
|
7726
|
+
"error",
|
|
7727
|
+
"http.stream_bash_logs_failed",
|
|
7728
|
+
path=self.path,
|
|
7729
|
+
error=str(exc),
|
|
7730
|
+
)
|
|
7731
|
+
self.close_connection = True
|
|
6716
7732
|
return
|
|
6717
7733
|
if route_name == "terminal_stream":
|
|
6718
7734
|
try:
|
|
6719
|
-
app.stream_bash_logs(
|
|
7735
|
+
app.stream_bash_logs(
|
|
7736
|
+
self,
|
|
7737
|
+
quest_id=params["quest_id"],
|
|
7738
|
+
bash_id=params["session_id"],
|
|
7739
|
+
headers=request_headers,
|
|
7740
|
+
extra_headers=auth_headers,
|
|
7741
|
+
)
|
|
6720
7742
|
except Exception as exc:
|
|
6721
|
-
|
|
7743
|
+
app.logger.log(
|
|
7744
|
+
"error",
|
|
7745
|
+
"http.stream_terminal_logs_failed",
|
|
7746
|
+
path=self.path,
|
|
7747
|
+
error=str(exc),
|
|
7748
|
+
)
|
|
7749
|
+
self.close_connection = True
|
|
6722
7750
|
return
|
|
6723
7751
|
if route_name == "lingzhu_sse":
|
|
6724
7752
|
content_length = int(self.headers.get("Content-Length", "0"))
|
|
6725
7753
|
raw_body = self.rfile.read(content_length) if content_length else b""
|
|
6726
7754
|
try:
|
|
6727
|
-
app.stream_lingzhu_sse(self, raw_body=raw_body, headers=
|
|
7755
|
+
app.stream_lingzhu_sse(self, raw_body=raw_body, headers=request_headers)
|
|
6728
7756
|
except Exception as exc:
|
|
6729
7757
|
self._write_json(500, {"ok": False, "message": str(exc)})
|
|
6730
7758
|
return
|
|
@@ -6739,11 +7767,12 @@ class DaemonApp:
|
|
|
6739
7767
|
result = getattr(app.handlers, route_name)
|
|
6740
7768
|
if route_name == "asset":
|
|
6741
7769
|
status, headers, content = result(**params)
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
7770
|
+
app._write_handler_response(
|
|
7771
|
+
self,
|
|
7772
|
+
code=status,
|
|
7773
|
+
content=content,
|
|
7774
|
+
extra_headers=app._merge_response_headers(headers, auth_headers),
|
|
7775
|
+
)
|
|
6747
7776
|
return
|
|
6748
7777
|
if route_name in {
|
|
6749
7778
|
"quest_events",
|
|
@@ -6770,7 +7799,7 @@ class DaemonApp:
|
|
|
6770
7799
|
payload = result(**params, path=self.path)
|
|
6771
7800
|
elif method == "GET":
|
|
6772
7801
|
payload = result(**params) if params else result()
|
|
6773
|
-
elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create"}:
|
|
7802
|
+
elif route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}:
|
|
6774
7803
|
payload = result(**params, body=body)
|
|
6775
7804
|
elif route_name == "config_validate":
|
|
6776
7805
|
payload = result(body)
|
|
@@ -6783,33 +7812,43 @@ class DaemonApp:
|
|
|
6783
7812
|
else:
|
|
6784
7813
|
payload = result(**params) if params else result()
|
|
6785
7814
|
except Exception as exc:
|
|
6786
|
-
self._write_json(500, {"ok": False, "message": str(exc)})
|
|
7815
|
+
self._write_json(500, {"ok": False, "message": str(exc)}, extra_headers=auth_headers)
|
|
6787
7816
|
return
|
|
6788
7817
|
|
|
6789
7818
|
if isinstance(payload, tuple) and len(payload) == 2:
|
|
6790
7819
|
status, body = payload
|
|
6791
|
-
self._write_json(status, body)
|
|
7820
|
+
self._write_json(status, body, extra_headers=auth_headers)
|
|
6792
7821
|
return
|
|
6793
7822
|
if isinstance(payload, tuple) and len(payload) == 3:
|
|
6794
7823
|
status, headers, content = payload
|
|
6795
|
-
self.send_response(status)
|
|
6796
|
-
for key, value in headers.items():
|
|
6797
|
-
self.send_header(key, value)
|
|
6798
|
-
self.end_headers()
|
|
6799
7824
|
if isinstance(content, str):
|
|
6800
|
-
|
|
7825
|
+
encoded = content.encode("utf-8")
|
|
6801
7826
|
else:
|
|
6802
|
-
|
|
7827
|
+
encoded = content
|
|
7828
|
+
app._write_handler_response(
|
|
7829
|
+
self,
|
|
7830
|
+
code=status,
|
|
7831
|
+
content=encoded,
|
|
7832
|
+
extra_headers=app._merge_response_headers(headers, auth_headers),
|
|
7833
|
+
)
|
|
6803
7834
|
return
|
|
6804
|
-
self._write_json(200, payload)
|
|
6805
|
-
|
|
6806
|
-
def _write_json(
|
|
7835
|
+
self._write_json(200, payload, extra_headers=auth_headers)
|
|
7836
|
+
|
|
7837
|
+
def _write_json(
|
|
7838
|
+
self,
|
|
7839
|
+
code: int,
|
|
7840
|
+
payload: dict | list,
|
|
7841
|
+
*,
|
|
7842
|
+
extra_headers: dict[str, str] | None = None,
|
|
7843
|
+
) -> None:
|
|
6807
7844
|
encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
7845
|
+
app._write_handler_response(
|
|
7846
|
+
self,
|
|
7847
|
+
code=code,
|
|
7848
|
+
content=encoded,
|
|
7849
|
+
content_type="application/json; charset=utf-8",
|
|
7850
|
+
extra_headers=extra_headers,
|
|
7851
|
+
)
|
|
6813
7852
|
|
|
6814
7853
|
server = ThreadingHTTPServer((host, port), RequestHandler)
|
|
6815
7854
|
server.daemon_threads = True
|
|
@@ -6821,6 +7860,8 @@ class DaemonApp:
|
|
|
6821
7860
|
self._start_background_connectors()
|
|
6822
7861
|
self._resume_reconciled_quests()
|
|
6823
7862
|
print(f"DeepScientist daemon listening on http://{host}:{port}")
|
|
7863
|
+
if self.browser_auth_enabled and self.browser_auth_token:
|
|
7864
|
+
print(f"DeepScientist auth token: {self.browser_auth_token}")
|
|
6824
7865
|
try:
|
|
6825
7866
|
server.serve_forever()
|
|
6826
7867
|
except KeyboardInterrupt:
|