@researai/deepscientist 1.5.14 → 1.5.16
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 +336 -90
- package/assets/branding/logo-raster.png +0 -0
- package/bin/ds.js +816 -131
- package/docs/en/00_QUICK_START.md +36 -15
- package/docs/en/01_SETTINGS_REFERENCE.md +53 -4
- package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -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 +11 -5
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
- package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
- package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- 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/README.md +24 -0
- package/docs/zh/00_QUICK_START.md +36 -15
- package/docs/zh/01_SETTINGS_REFERENCE.md +53 -4
- package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/zh/05_TUI_GUIDE.md +6 -0
- package/docs/zh/09_DOCTOR.md +11 -5
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
- package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
- package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- 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/README.md +24 -0
- package/install.sh +2 -0
- package/package.json +1 -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/charts.py +567 -0
- package/src/deepscientist/artifact/guidance.py +50 -10
- package/src/deepscientist/artifact/metrics.py +228 -5
- package/src/deepscientist/artifact/schemas.py +3 -0
- package/src/deepscientist/artifact/service.py +4276 -308
- package/src/deepscientist/bash_exec/models.py +23 -0
- package/src/deepscientist/bash_exec/monitor.py +147 -67
- package/src/deepscientist/bash_exec/runtime.py +218 -156
- package/src/deepscientist/bash_exec/service.py +309 -69
- package/src/deepscientist/bash_exec/shells.py +87 -0
- package/src/deepscientist/bridges/connectors.py +51 -2
- package/src/deepscientist/cli.py +115 -19
- package/src/deepscientist/codex_cli_compat.py +232 -0
- package/src/deepscientist/config/models.py +8 -4
- package/src/deepscientist/config/service.py +38 -11
- package/src/deepscientist/connector/weixin_support.py +122 -1
- package/src/deepscientist/daemon/api/handlers.py +199 -9
- package/src/deepscientist/daemon/api/router.py +5 -0
- package/src/deepscientist/daemon/app.py +1458 -289
- package/src/deepscientist/doctor.py +51 -0
- package/src/deepscientist/file_lock.py +48 -0
- package/src/deepscientist/gitops/__init__.py +10 -1
- package/src/deepscientist/gitops/diff.py +296 -1
- package/src/deepscientist/gitops/service.py +4 -1
- package/src/deepscientist/mcp/server.py +212 -5
- package/src/deepscientist/process_control.py +161 -0
- package/src/deepscientist/prompts/builder.py +501 -453
- package/src/deepscientist/quest/layout.py +15 -2
- package/src/deepscientist/quest/service.py +2539 -195
- package/src/deepscientist/quest/stage_views.py +177 -1
- package/src/deepscientist/runners/base.py +2 -0
- package/src/deepscientist/runners/codex.py +169 -31
- package/src/deepscientist/runners/runtime_overrides.py +17 -1
- 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 +24 -4
- package/src/prompts/system.md +921 -72
- package/src/prompts/system_copilot.md +43 -0
- package/src/skills/analysis-campaign/SKILL.md +32 -2
- package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
- package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
- package/src/skills/baseline/SKILL.md +10 -0
- package/src/skills/decision/SKILL.md +27 -2
- package/src/skills/experiment/SKILL.md +16 -2
- package/src/skills/figure-polish/SKILL.md +1 -0
- package/src/skills/finalize/SKILL.md +19 -0
- package/src/skills/idea/SKILL.md +79 -0
- package/src/skills/idea/references/idea-generation-playbook.md +100 -0
- package/src/skills/idea/references/outline-seeding-example.md +60 -0
- package/src/skills/intake-audit/SKILL.md +9 -1
- 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 +1645 -0
- package/src/skills/rebuttal/SKILL.md +3 -1
- package/src/skills/review/SKILL.md +3 -1
- package/src/skills/scout/SKILL.md +8 -0
- package/src/skills/write/SKILL.md +81 -12
- package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
- package/src/tui/dist/app/AppContainer.js +22 -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-COFACy7V.js +204 -0
- package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
- package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
- package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
- package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
- package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
- package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
- package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
- package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
- package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
- package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
- package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
- package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
- package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
- package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -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-C-4Kt1p9.js +81 -0
- package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
- package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
- package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
- package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
- package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
- package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
- package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
- package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
- package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
- package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
- package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
- package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
- package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
- package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
- package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
- package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
- package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
- package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
- package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
- package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
- package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
- package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
- package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
- package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
- package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
- package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
- package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.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-DL6h35vr.js +1 -0
- package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
- package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
- package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
- package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
- package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
- package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
- package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
- package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
- package/src/ui/dist/index.html +5 -2
- package/src/ui/dist/assets/AiManusChatView-DaF9Nge_.js +0 -26597
- package/src/ui/dist/assets/AnalysisPlugin-BSVx6dXE.js +0 -123
- package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
- package/src/ui/dist/assets/CodeEditorPlugin-DU9G0Tox.js +0 -427
- package/src/ui/dist/assets/CodeViewerPlugin-DoX_fI9l.js +0 -905
- package/src/ui/dist/assets/DocViewerPlugin-C4FWIXuU.js +0 -278
- package/src/ui/dist/assets/GitDiffViewerPlugin-BgfFMgtf.js +0 -2661
- package/src/ui/dist/assets/ImageViewerPlugin-tcPkfY_x.js +0 -500
- package/src/ui/dist/assets/LabCopilotPanel-_dKV60Bf.js +0 -4104
- package/src/ui/dist/assets/LabPlugin-Bje0ayoC.js +0 -2677
- package/src/ui/dist/assets/LatexPlugin-CVsBzAln.js +0 -1792
- package/src/ui/dist/assets/MarkdownViewerPlugin-xjmrqv_8.js +0 -308
- package/src/ui/dist/assets/MarketplacePlugin-mMM2A8wP.js +0 -413
- package/src/ui/dist/assets/NotebookEditor-3kVDSOBo.js +0 -4214
- package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
- package/src/ui/dist/assets/NotebookEditor-SoJ8X-MO.js +0 -84873
- package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
- package/src/ui/dist/assets/PdfLoader-DElVuHl9.js +0 -25468
- package/src/ui/dist/assets/PdfMarkdownPlugin-Bq88XT4G.js +0 -409
- package/src/ui/dist/assets/PdfViewerPlugin-CsCXMo9S.js +0 -3095
- package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
- package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
- package/src/ui/dist/assets/SearchPlugin-oUPvy19k.js +0 -741
- package/src/ui/dist/assets/TextViewerPlugin-CRkT9yNy.js +0 -472
- package/src/ui/dist/assets/VNCViewer-BgbuvWhR.js +0 -18821
- package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
- package/src/ui/dist/assets/bot-v_RASACv.js +0 -21
- package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
- package/src/ui/dist/assets/code-5hC9d0VH.js +0 -17
- package/src/ui/dist/assets/file-content-D1PxfOrp.js +0 -377
- package/src/ui/dist/assets/file-diff-panel-DG1oT_Hj.js +0 -92
- package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
- package/src/ui/dist/assets/file-socket-BmdFYQlk.js +0 -58
- package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
- package/src/ui/dist/assets/image-Dqe2X2tW.js +0 -18
- package/src/ui/dist/assets/index-BQG-1s2o.css +0 -12553
- package/src/ui/dist/assets/index-DVsMKK_y.js +0 -25
- package/src/ui/dist/assets/index-Duvz8Ip0.js +0 -159
- package/src/ui/dist/assets/index-Nt9hS4ck.js +0 -244829
- package/src/ui/dist/assets/index-RDlNXXx1.js +0 -120
- package/src/ui/dist/assets/monaco-DIXge1CP.js +0 -623
- package/src/ui/dist/assets/pdf-effect-queue-BBTTQaO-.js +0 -47
- package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
- package/src/ui/dist/assets/popover-BWlolyxo.js +0 -476
- package/src/ui/dist/assets/project-sync-BM5PkFH4.js +0 -297
- package/src/ui/dist/assets/select-D4dAtrA8.js +0 -1690
- package/src/ui/dist/assets/sigma-CKbE5jJT.js +0 -22
- package/src/ui/dist/assets/square-check-big-CZNGMgiB.js +0 -17
- package/src/ui/dist/assets/trash-DaB37xAz.js +0 -32
- package/src/ui/dist/assets/useCliAccess-C2OmAcWe.js +0 -957
- package/src/ui/dist/assets/useFileDiffOverlay-Dowd1Ij4.js +0 -53
- package/src/ui/dist/assets/wrap-text-BGjAhAUq.js +0 -35
- package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
- package/src/ui/dist/assets/zoom-out-dMZQMXzc.js +0 -34
|
@@ -2,11 +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
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
6
9
|
import json
|
|
7
10
|
import mimetypes
|
|
8
11
|
import os
|
|
9
12
|
import re
|
|
13
|
+
import secrets
|
|
10
14
|
import signal
|
|
11
15
|
import shutil
|
|
12
16
|
import subprocess
|
|
@@ -15,6 +19,7 @@ import threading
|
|
|
15
19
|
import time
|
|
16
20
|
import traceback
|
|
17
21
|
from datetime import UTC, datetime, timedelta
|
|
22
|
+
from http.cookies import SimpleCookie
|
|
18
23
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
24
|
from pathlib import Path
|
|
20
25
|
from typing import Any
|
|
@@ -23,9 +28,11 @@ from urllib.request import Request
|
|
|
23
28
|
|
|
24
29
|
from .. import __version__
|
|
25
30
|
from ..annotations import AnnotationService
|
|
31
|
+
from ..acp import build_session_update
|
|
26
32
|
from ..artifact import ArtifactService
|
|
27
33
|
from ..bash_exec import BashExecService
|
|
28
|
-
from ..bash_exec.
|
|
34
|
+
from ..bash_exec.models import TerminalClient
|
|
35
|
+
from ..bash_exec.service import DEFAULT_TERMINAL_SESSION_ID
|
|
29
36
|
from ..bridges import register_builtin_connector_bridges
|
|
30
37
|
from ..bridges.connectors import QQConnectorBridge
|
|
31
38
|
from ..channels import QQRelayChannel, get_channel_factory, list_channel_names, register_builtin_channels
|
|
@@ -68,7 +75,7 @@ from ..connector.lingzhu_support import (
|
|
|
68
75
|
lingzhu_verify_auth_header,
|
|
69
76
|
)
|
|
70
77
|
from ..prompts import PromptBuilder
|
|
71
|
-
from ..prompts.builder import
|
|
78
|
+
from ..prompts.builder import classify_turn_intent, current_standard_skills
|
|
72
79
|
from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
|
|
73
80
|
from ..quest import QuestService
|
|
74
81
|
from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
|
|
@@ -79,9 +86,11 @@ from ..team import SingleTeamService
|
|
|
79
86
|
from ..connector.weixin_support import (
|
|
80
87
|
DEFAULT_WEIXIN_BOT_TYPE,
|
|
81
88
|
fetch_weixin_qrcode,
|
|
89
|
+
get_weixin_replay_cursor,
|
|
82
90
|
normalize_weixin_base_url,
|
|
83
91
|
normalize_weixin_cdn_base_url,
|
|
84
92
|
poll_weixin_qrcode_status,
|
|
93
|
+
update_weixin_replay_cursor,
|
|
85
94
|
)
|
|
86
95
|
from .api import ApiHandlers, match_route
|
|
87
96
|
from .sessions import SessionStore
|
|
@@ -93,7 +102,9 @@ from websockets.sync.server import Server as WebSocketServer
|
|
|
93
102
|
from websockets.sync.server import ServerConnection, serve as websocket_serve
|
|
94
103
|
|
|
95
104
|
TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
|
|
96
|
-
_AUTO_CONTINUE_DELAY_SECONDS = 0
|
|
105
|
+
_AUTO_CONTINUE_DELAY_SECONDS = 240.0
|
|
106
|
+
_AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS = 0.2
|
|
107
|
+
_TERMINAL_PREWARM_DEBOUNCE_SECONDS = 20.0
|
|
97
108
|
CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
|
|
98
109
|
CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
|
|
99
110
|
CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
|
|
@@ -138,13 +149,42 @@ _LINGZHU_SHORT_COMMAND_PREFIX_MAP = {
|
|
|
138
149
|
"恢复": "resume",
|
|
139
150
|
}
|
|
140
151
|
_LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新", "最新的"}
|
|
152
|
+
_WEIXIN_STALE_REPLAY_LIMIT_DEFAULT = 5
|
|
153
|
+
_WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT = 2.0
|
|
141
154
|
_LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
|
|
155
|
+
_BROWSER_AUTH_COOKIE_NAME = "ds_local_auth"
|
|
156
|
+
_BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365
|
|
157
|
+
_BROWSER_AUTH_QUERY_PARAM = "token"
|
|
158
|
+
_BROWSER_AUTH_STORAGE_KEY = "ds_local_auth_token"
|
|
159
|
+
_BROWSER_AUTH_PUBLIC_ROUTE_NAMES = {"root", "spa_root", "ui_asset", "asset", "auth_login"}
|
|
160
|
+
_BROWSER_AUTH_EXEMPT_ROUTE_NAMES = {"lingzhu_health", "lingzhu_sse"}
|
|
161
|
+
_BROWSER_AUTH_REALM = "DeepScientist"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class BrowserAuthState:
|
|
166
|
+
authenticated: bool
|
|
167
|
+
token_source: str | None = None
|
|
168
|
+
response_cookie: str | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|
172
|
+
if os.name == "nt" and hasattr(subprocess, "CREATE_NO_WINDOW"):
|
|
173
|
+
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW")}
|
|
174
|
+
return {}
|
|
142
175
|
|
|
143
176
|
|
|
144
177
|
class DaemonApp:
|
|
145
178
|
_MAX_INBOUND_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
146
179
|
|
|
147
|
-
def __init__(
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
home: Path,
|
|
183
|
+
*,
|
|
184
|
+
browser_auth_enabled: bool | None = None,
|
|
185
|
+
browser_auth_token: str | None = None,
|
|
186
|
+
prompt_version_selection: str | None = None,
|
|
187
|
+
) -> None:
|
|
148
188
|
self.home = home.resolve()
|
|
149
189
|
self.daemon_id = str(os.environ.get("DS_DAEMON_ID") or "").strip() or generate_id("daemon")
|
|
150
190
|
self.daemon_managed_by = str(os.environ.get("DS_DAEMON_MANAGED_BY") or "manual").strip() or "manual"
|
|
@@ -180,7 +220,11 @@ class DaemonApp:
|
|
|
180
220
|
abandoned_run_id=item.get("abandoned_run_id"),
|
|
181
221
|
status=item.get("status"),
|
|
182
222
|
)
|
|
183
|
-
self.prompt_builder = PromptBuilder(
|
|
223
|
+
self.prompt_builder = PromptBuilder(
|
|
224
|
+
self.repo_root,
|
|
225
|
+
home,
|
|
226
|
+
prompt_version_selection=prompt_version_selection,
|
|
227
|
+
)
|
|
184
228
|
self.codex_runner = CodexRunner(
|
|
185
229
|
home=home,
|
|
186
230
|
repo_root=self.repo_root,
|
|
@@ -200,6 +244,8 @@ class DaemonApp:
|
|
|
200
244
|
self._canonicalize_lingzhu_binding_state()
|
|
201
245
|
self._turn_lock = threading.Lock()
|
|
202
246
|
self._turn_state: dict[str, dict[str, object]] = {}
|
|
247
|
+
self._terminal_prewarm_lock = threading.Lock()
|
|
248
|
+
self._terminal_prewarm_recent: dict[str, float] = {}
|
|
203
249
|
self._server: ThreadingHTTPServer | None = None
|
|
204
250
|
self._terminal_attach_server: WebSocketServer | None = None
|
|
205
251
|
self._terminal_attach_thread: threading.Thread | None = None
|
|
@@ -219,8 +265,190 @@ class DaemonApp:
|
|
|
219
265
|
self._process_hooks_installed = False
|
|
220
266
|
self._faulthandler_stream = None
|
|
221
267
|
self._recovered_quest_ids: set[str] = set()
|
|
268
|
+
ui_config = config.get("ui") if isinstance(config.get("ui"), dict) else {}
|
|
269
|
+
configured_browser_auth_enabled = self._parse_browser_auth_bool(ui_config.get("auth_enabled"))
|
|
270
|
+
env_browser_auth_enabled = self._parse_browser_auth_bool(os.environ.get("DS_UI_AUTH_ENABLED"))
|
|
271
|
+
explicit_browser_auth_enabled = self._parse_browser_auth_bool(browser_auth_enabled)
|
|
272
|
+
if explicit_browser_auth_enabled is not None:
|
|
273
|
+
self.browser_auth_enabled = explicit_browser_auth_enabled
|
|
274
|
+
elif env_browser_auth_enabled is not None:
|
|
275
|
+
self.browser_auth_enabled = env_browser_auth_enabled
|
|
276
|
+
elif configured_browser_auth_enabled is not None:
|
|
277
|
+
self.browser_auth_enabled = configured_browser_auth_enabled
|
|
278
|
+
else:
|
|
279
|
+
self.browser_auth_enabled = False
|
|
280
|
+
explicit_browser_auth_token = self._normalize_browser_auth_token(browser_auth_token)
|
|
281
|
+
env_browser_auth_token = self._normalize_browser_auth_token(os.environ.get("DS_UI_AUTH_TOKEN"))
|
|
282
|
+
if self.browser_auth_enabled:
|
|
283
|
+
self.browser_auth_token = explicit_browser_auth_token or env_browser_auth_token or self.generate_browser_auth_token()
|
|
284
|
+
else:
|
|
285
|
+
self.browser_auth_token = None
|
|
222
286
|
self.handlers = ApiHandlers(self)
|
|
223
287
|
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _parse_browser_auth_bool(value: object) -> bool | None:
|
|
290
|
+
if isinstance(value, bool):
|
|
291
|
+
return value
|
|
292
|
+
normalized = str(value or "").strip().lower()
|
|
293
|
+
if not normalized:
|
|
294
|
+
return None
|
|
295
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
296
|
+
return True
|
|
297
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
298
|
+
return False
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _normalize_browser_auth_token(value: object) -> str | None:
|
|
303
|
+
token = str(value or "").strip()
|
|
304
|
+
return token or None
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def generate_browser_auth_token() -> str:
|
|
308
|
+
return secrets.token_hex(8)
|
|
309
|
+
|
|
310
|
+
def masked_browser_auth_token(self) -> str | None:
|
|
311
|
+
token = self.browser_auth_token
|
|
312
|
+
if not token:
|
|
313
|
+
return None
|
|
314
|
+
if len(token) <= 6:
|
|
315
|
+
return "*" * len(token)
|
|
316
|
+
return f"{token[:3]}{'*' * (len(token) - 6)}{token[-3:]}"
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _header_value(headers: dict[str, str] | None, name: str) -> str:
|
|
320
|
+
if not isinstance(headers, dict):
|
|
321
|
+
return ""
|
|
322
|
+
target = name.strip().lower()
|
|
323
|
+
for key, value in headers.items():
|
|
324
|
+
if str(key).strip().lower() == target:
|
|
325
|
+
return str(value or "")
|
|
326
|
+
return ""
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _parse_bearer_token(header_value: str) -> str | None:
|
|
330
|
+
normalized = str(header_value or "").strip()
|
|
331
|
+
prefix = "bearer "
|
|
332
|
+
if not normalized or normalized[: len(prefix)].lower() != prefix:
|
|
333
|
+
return None
|
|
334
|
+
token = normalized[len(prefix) :].strip()
|
|
335
|
+
return token or None
|
|
336
|
+
|
|
337
|
+
def _request_cookie_token(self, headers: dict[str, str] | None) -> str | None:
|
|
338
|
+
raw_cookie = self._header_value(headers, "Cookie")
|
|
339
|
+
if not raw_cookie:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
cookie = SimpleCookie()
|
|
343
|
+
cookie.load(raw_cookie)
|
|
344
|
+
except Exception:
|
|
345
|
+
return None
|
|
346
|
+
morsel = cookie.get(_BROWSER_AUTH_COOKIE_NAME)
|
|
347
|
+
if morsel is None:
|
|
348
|
+
return None
|
|
349
|
+
token = str(getattr(morsel, "value", "") or "").strip()
|
|
350
|
+
return token or None
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _request_query_token(path: str) -> str | None:
|
|
354
|
+
query = parse_qs(urlparse(path).query, keep_blank_values=True)
|
|
355
|
+
token = str((query.get(_BROWSER_AUTH_QUERY_PARAM) or [""])[0] or "").strip()
|
|
356
|
+
return token or None
|
|
357
|
+
|
|
358
|
+
def _browser_auth_cookie_header(self, token: str | None = None) -> str:
|
|
359
|
+
cookie = SimpleCookie()
|
|
360
|
+
cookie[_BROWSER_AUTH_COOKIE_NAME] = token or (self.browser_auth_token or "")
|
|
361
|
+
morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
|
|
362
|
+
morsel["path"] = "/"
|
|
363
|
+
morsel["httponly"] = True
|
|
364
|
+
morsel["samesite"] = "Strict"
|
|
365
|
+
morsel["max-age"] = str(_BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS)
|
|
366
|
+
return morsel.OutputString()
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _browser_auth_clear_cookie_header() -> str:
|
|
370
|
+
cookie = SimpleCookie()
|
|
371
|
+
cookie[_BROWSER_AUTH_COOKIE_NAME] = ""
|
|
372
|
+
morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
|
|
373
|
+
morsel["path"] = "/"
|
|
374
|
+
morsel["httponly"] = True
|
|
375
|
+
morsel["samesite"] = "Strict"
|
|
376
|
+
morsel["max-age"] = "0"
|
|
377
|
+
morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
|
|
378
|
+
return morsel.OutputString()
|
|
379
|
+
|
|
380
|
+
def browser_auth_matches(self, token: str | None) -> bool:
|
|
381
|
+
expected = self.browser_auth_token
|
|
382
|
+
candidate = self._normalize_browser_auth_token(token)
|
|
383
|
+
return bool(expected and candidate and hmac.compare_digest(candidate, expected))
|
|
384
|
+
|
|
385
|
+
def rotate_browser_auth_token(self) -> str:
|
|
386
|
+
if not self.browser_auth_enabled:
|
|
387
|
+
raise RuntimeError("Browser authentication is disabled.")
|
|
388
|
+
rotated = self.generate_browser_auth_token()
|
|
389
|
+
self.browser_auth_token = rotated
|
|
390
|
+
return rotated
|
|
391
|
+
|
|
392
|
+
def browser_auth_state_for_request(self, path: str, headers: dict[str, str] | None = None) -> BrowserAuthState:
|
|
393
|
+
if not self.browser_auth_enabled:
|
|
394
|
+
return BrowserAuthState(authenticated=True)
|
|
395
|
+
expected = self.browser_auth_token
|
|
396
|
+
if not expected:
|
|
397
|
+
return BrowserAuthState(authenticated=False)
|
|
398
|
+
|
|
399
|
+
candidates = (
|
|
400
|
+
("authorization", self._parse_bearer_token(self._header_value(headers, "Authorization"))),
|
|
401
|
+
("query", self._request_query_token(path)),
|
|
402
|
+
("cookie", self._request_cookie_token(headers)),
|
|
403
|
+
)
|
|
404
|
+
for source, candidate in candidates:
|
|
405
|
+
if candidate and hmac.compare_digest(candidate, expected):
|
|
406
|
+
response_cookie = self._browser_auth_cookie_header(expected) if source in {"authorization", "query"} else None
|
|
407
|
+
return BrowserAuthState(authenticated=True, token_source=source, response_cookie=response_cookie)
|
|
408
|
+
return BrowserAuthState(authenticated=False, response_cookie=self._browser_auth_clear_cookie_header())
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def _auth_response_headers(auth_state: BrowserAuthState | None) -> dict[str, str]:
|
|
412
|
+
if auth_state is None or not auth_state.response_cookie:
|
|
413
|
+
return {}
|
|
414
|
+
return {"Set-Cookie": auth_state.response_cookie}
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _merge_response_headers(
|
|
418
|
+
base: dict[str, str] | None = None,
|
|
419
|
+
extra: dict[str, str] | None = None,
|
|
420
|
+
) -> dict[str, str]:
|
|
421
|
+
merged: dict[str, str] = {}
|
|
422
|
+
if isinstance(extra, dict):
|
|
423
|
+
merged.update(extra)
|
|
424
|
+
if isinstance(base, dict):
|
|
425
|
+
merged.update(base)
|
|
426
|
+
return merged
|
|
427
|
+
|
|
428
|
+
def _route_requires_browser_auth(self, route_name: str | None) -> bool:
|
|
429
|
+
if not self.browser_auth_enabled or not route_name:
|
|
430
|
+
return False
|
|
431
|
+
if route_name in _BROWSER_AUTH_PUBLIC_ROUTE_NAMES:
|
|
432
|
+
return False
|
|
433
|
+
if route_name in _BROWSER_AUTH_EXEMPT_ROUTE_NAMES:
|
|
434
|
+
return False
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
def browser_auth_runtime_payload(self) -> dict[str, object]:
|
|
438
|
+
return {
|
|
439
|
+
"enabled": self.browser_auth_enabled,
|
|
440
|
+
"tokenQueryParam": _BROWSER_AUTH_QUERY_PARAM,
|
|
441
|
+
"storageKey": _BROWSER_AUTH_STORAGE_KEY,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
def browser_auth_tokenized_url(self, url: str) -> str:
|
|
445
|
+
if not self.browser_auth_enabled or not self.browser_auth_token:
|
|
446
|
+
return url
|
|
447
|
+
parsed = urlparse(url)
|
|
448
|
+
query = parse_qs(parsed.query, keep_blank_values=True)
|
|
449
|
+
query[_BROWSER_AUTH_QUERY_PARAM] = [self.browser_auth_token]
|
|
450
|
+
return parsed._replace(query=urlencode(query, doseq=True)).geturl()
|
|
451
|
+
|
|
224
452
|
def list_connector_statuses(self) -> list[dict[str, object]]:
|
|
225
453
|
title_by_quest = self._quest_titles_by_id()
|
|
226
454
|
items = [
|
|
@@ -450,6 +678,14 @@ class DaemonApp:
|
|
|
450
678
|
ensure_dir(faulthandler_path.parent)
|
|
451
679
|
self._faulthandler_stream = open(faulthandler_path, "a", encoding="utf-8")
|
|
452
680
|
faulthandler.enable(file=self._faulthandler_stream)
|
|
681
|
+
dump_signal = getattr(signal, "SIGUSR1", None)
|
|
682
|
+
if dump_signal is not None:
|
|
683
|
+
faulthandler.register(
|
|
684
|
+
dump_signal,
|
|
685
|
+
file=self._faulthandler_stream,
|
|
686
|
+
all_threads=True,
|
|
687
|
+
chain=False,
|
|
688
|
+
)
|
|
453
689
|
except Exception as exc:
|
|
454
690
|
self.logger.log("warning", "daemon.faulthandler_enable_failed", error=str(exc))
|
|
455
691
|
|
|
@@ -716,6 +952,7 @@ class DaemonApp:
|
|
|
716
952
|
timeout=8,
|
|
717
953
|
check=False,
|
|
718
954
|
env=os.environ.copy(),
|
|
955
|
+
**_windows_hidden_subprocess_kwargs(),
|
|
719
956
|
)
|
|
720
957
|
except subprocess.TimeoutExpired as exc:
|
|
721
958
|
raise RuntimeError("DeepScientist update check timed out.") from exc
|
|
@@ -763,6 +1000,7 @@ class DaemonApp:
|
|
|
763
1000
|
timeout=8,
|
|
764
1001
|
check=False,
|
|
765
1002
|
env=os.environ.copy(),
|
|
1003
|
+
**_windows_hidden_subprocess_kwargs(),
|
|
766
1004
|
)
|
|
767
1005
|
except subprocess.TimeoutExpired as exc:
|
|
768
1006
|
raise RuntimeError("DeepScientist update request timed out.") from exc
|
|
@@ -1290,6 +1528,7 @@ class DaemonApp:
|
|
|
1290
1528
|
client_message_id=client_message_id,
|
|
1291
1529
|
)
|
|
1292
1530
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
1531
|
+
snapshot = self._reconcile_stale_active_turn(quest_id, snapshot=snapshot)
|
|
1293
1532
|
runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip()
|
|
1294
1533
|
auto_resumed = previous_status in {"stopped", "paused", "completed"} and runtime_status not in {"stopped", "paused", "completed"}
|
|
1295
1534
|
if auto_resumed:
|
|
@@ -1302,9 +1541,8 @@ class DaemonApp:
|
|
|
1302
1541
|
summary=f"Quest {quest_id} automatically resumed after a new user message.",
|
|
1303
1542
|
automated=True,
|
|
1304
1543
|
)
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
has_live_turn = bool(turn_state.get("running")) or bool(snapshot.get("active_run_id"))
|
|
1544
|
+
turn_state = self._refresh_turn_worker_state(quest_id)
|
|
1545
|
+
has_live_turn = bool(turn_state.get("running"))
|
|
1308
1546
|
if runtime_status == "running" and has_live_turn:
|
|
1309
1547
|
scheduled = {
|
|
1310
1548
|
"scheduled": True,
|
|
@@ -1474,6 +1712,7 @@ class DaemonApp:
|
|
|
1474
1712
|
return snapshot
|
|
1475
1713
|
|
|
1476
1714
|
def schedule_turn(self, quest_id: str, *, reason: str = "user_message") -> dict:
|
|
1715
|
+
self._refresh_turn_worker_state(quest_id)
|
|
1477
1716
|
with self._turn_lock:
|
|
1478
1717
|
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1479
1718
|
state["pending"] = True
|
|
@@ -1502,6 +1741,120 @@ class DaemonApp:
|
|
|
1502
1741
|
"reason": reason,
|
|
1503
1742
|
}
|
|
1504
1743
|
|
|
1744
|
+
@staticmethod
|
|
1745
|
+
def _turn_worker_is_alive(worker: object) -> bool:
|
|
1746
|
+
return isinstance(worker, threading.Thread) and worker.is_alive()
|
|
1747
|
+
|
|
1748
|
+
def schedule_latest_quest_terminal_prewarm(
|
|
1749
|
+
self,
|
|
1750
|
+
quest_id: str,
|
|
1751
|
+
*,
|
|
1752
|
+
source: str = "quest_session_prewarm",
|
|
1753
|
+
) -> None:
|
|
1754
|
+
normalized_quest_id = str(quest_id or "").strip()
|
|
1755
|
+
if not normalized_quest_id or os.name == "nt":
|
|
1756
|
+
return
|
|
1757
|
+
try:
|
|
1758
|
+
quests = self.quest_service.list_quests()
|
|
1759
|
+
except Exception:
|
|
1760
|
+
return
|
|
1761
|
+
latest_quest_id = str((quests[0].get("quest_id") if quests else "") or "").strip()
|
|
1762
|
+
if latest_quest_id != normalized_quest_id:
|
|
1763
|
+
return
|
|
1764
|
+
now = time.monotonic()
|
|
1765
|
+
with self._terminal_prewarm_lock:
|
|
1766
|
+
last_attempt = float(self._terminal_prewarm_recent.get(normalized_quest_id) or 0.0)
|
|
1767
|
+
if now - last_attempt < _TERMINAL_PREWARM_DEBOUNCE_SECONDS:
|
|
1768
|
+
return
|
|
1769
|
+
self._terminal_prewarm_recent[normalized_quest_id] = now
|
|
1770
|
+
threading.Thread(
|
|
1771
|
+
target=self._prewarm_terminal_for_quest,
|
|
1772
|
+
args=(normalized_quest_id, source),
|
|
1773
|
+
daemon=True,
|
|
1774
|
+
name=f"deepscientist-terminal-prewarm-{normalized_quest_id}",
|
|
1775
|
+
).start()
|
|
1776
|
+
|
|
1777
|
+
def _prewarm_terminal_for_quest(self, quest_id: str, source: str) -> None:
|
|
1778
|
+
try:
|
|
1779
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
1780
|
+
workspace_root = self.quest_service.active_workspace_root(quest_root)
|
|
1781
|
+
self.bash_exec_service.ensure_terminal_session(
|
|
1782
|
+
quest_root,
|
|
1783
|
+
quest_id=quest_id,
|
|
1784
|
+
bash_id=DEFAULT_TERMINAL_SESSION_ID,
|
|
1785
|
+
cwd=workspace_root,
|
|
1786
|
+
source=source,
|
|
1787
|
+
)
|
|
1788
|
+
except Exception as exc:
|
|
1789
|
+
with self._terminal_prewarm_lock:
|
|
1790
|
+
self._terminal_prewarm_recent.pop(quest_id, None)
|
|
1791
|
+
self.logger.log(
|
|
1792
|
+
"warning",
|
|
1793
|
+
"terminal.prewarm_failed",
|
|
1794
|
+
quest_id=quest_id,
|
|
1795
|
+
source=source,
|
|
1796
|
+
error=str(exc),
|
|
1797
|
+
)
|
|
1798
|
+
|
|
1799
|
+
def _refresh_turn_worker_state(self, quest_id: str) -> dict[str, object]:
|
|
1800
|
+
with self._turn_lock:
|
|
1801
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1802
|
+
if bool(state.get("running")) and not self._turn_worker_is_alive(state.get("worker")):
|
|
1803
|
+
state["running"] = False
|
|
1804
|
+
state.pop("worker", None)
|
|
1805
|
+
return dict(state)
|
|
1806
|
+
|
|
1807
|
+
def _reconcile_stale_active_turn(self, quest_id: str, *, snapshot: dict | None = None) -> dict:
|
|
1808
|
+
snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
|
|
1809
|
+
active_run_id = str(snapshot.get("active_run_id") or "").strip()
|
|
1810
|
+
if not active_run_id:
|
|
1811
|
+
self._refresh_turn_worker_state(quest_id)
|
|
1812
|
+
return snapshot
|
|
1813
|
+
turn_state = self._refresh_turn_worker_state(quest_id)
|
|
1814
|
+
if turn_state.get("running"):
|
|
1815
|
+
return snapshot
|
|
1816
|
+
|
|
1817
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
1818
|
+
result_payload = read_json(quest_root / ".ds" / "runs" / active_run_id / "result.json", {})
|
|
1819
|
+
completed_at = str(result_payload.get("completed_at") or "").strip() if isinstance(result_payload, dict) else ""
|
|
1820
|
+
exit_code = result_payload.get("exit_code") if isinstance(result_payload, dict) else None
|
|
1821
|
+
previous_status = (
|
|
1822
|
+
str(snapshot.get("runtime_status") or snapshot.get("status") or snapshot.get("display_status") or "running").strip()
|
|
1823
|
+
or "running"
|
|
1824
|
+
)
|
|
1825
|
+
normalized_status = "active" if previous_status == "running" else previous_status
|
|
1826
|
+
summary = (
|
|
1827
|
+
f"Cleared stale active turn state for run `{active_run_id}` after no live worker was found."
|
|
1828
|
+
if not completed_at
|
|
1829
|
+
else f"Cleared stale active turn state for completed run `{active_run_id}`."
|
|
1830
|
+
)
|
|
1831
|
+
append_jsonl(
|
|
1832
|
+
quest_root / ".ds" / "events.jsonl",
|
|
1833
|
+
{
|
|
1834
|
+
"event_id": generate_id("evt"),
|
|
1835
|
+
"type": "quest.turn_state_reconciled",
|
|
1836
|
+
"quest_id": quest_id,
|
|
1837
|
+
"abandoned_run_id": active_run_id,
|
|
1838
|
+
"previous_status": previous_status,
|
|
1839
|
+
"status": normalized_status,
|
|
1840
|
+
"completed_at": completed_at or None,
|
|
1841
|
+
"exit_code": exit_code if isinstance(exit_code, int) else None,
|
|
1842
|
+
"summary": summary,
|
|
1843
|
+
"created_at": utc_now(),
|
|
1844
|
+
},
|
|
1845
|
+
)
|
|
1846
|
+
self.logger.log(
|
|
1847
|
+
"warning",
|
|
1848
|
+
"quest.turn_state_reconciled",
|
|
1849
|
+
quest_id=quest_id,
|
|
1850
|
+
abandoned_run_id=active_run_id,
|
|
1851
|
+
previous_status=previous_status,
|
|
1852
|
+
status=normalized_status,
|
|
1853
|
+
completed_at=completed_at or None,
|
|
1854
|
+
exit_code=exit_code if isinstance(exit_code, int) else None,
|
|
1855
|
+
)
|
|
1856
|
+
return self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
1857
|
+
|
|
1505
1858
|
def control_quest(self, quest_id: str, *, action: str, source: str = "local") -> dict:
|
|
1506
1859
|
normalized_action = str(action or "").strip().lower()
|
|
1507
1860
|
if normalized_action == "pause":
|
|
@@ -1544,7 +1897,7 @@ class DaemonApp:
|
|
|
1544
1897
|
reason=f"quest_{action}",
|
|
1545
1898
|
user_id=source,
|
|
1546
1899
|
)
|
|
1547
|
-
if action == "stop":
|
|
1900
|
+
if action == "stop" and source == "local-admin":
|
|
1548
1901
|
cancel_reason = "cancelled_by_daemon_shutdown" if source == "local-admin" else "cancelled_by_stop"
|
|
1549
1902
|
cancelled_pending = self.quest_service.cancel_pending_user_messages(
|
|
1550
1903
|
quest_id,
|
|
@@ -1640,6 +1993,23 @@ class DaemonApp:
|
|
|
1640
1993
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
1641
1994
|
next_status = "running" if snapshot.get("status") == "running" else "active"
|
|
1642
1995
|
snapshot = self.quest_service.set_status(quest_id, next_status)
|
|
1996
|
+
recovery_abandoned_run_id = None
|
|
1997
|
+
recovery_summary = None
|
|
1998
|
+
if source.startswith("auto:daemon-recovery"):
|
|
1999
|
+
recent_events = self.quest_service.events(quest_id)["events"]
|
|
2000
|
+
for item in reversed(recent_events[-20:]):
|
|
2001
|
+
if str(item.get("type") or "").strip() != "quest.runtime_reconciled":
|
|
2002
|
+
continue
|
|
2003
|
+
recovery_abandoned_run_id = str(item.get("abandoned_run_id") or "").strip() or None
|
|
2004
|
+
recovery_summary = str(item.get("summary") or "").strip() or None
|
|
2005
|
+
break
|
|
2006
|
+
self.quest_service.update_runtime_state(
|
|
2007
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
2008
|
+
last_resume_source=source,
|
|
2009
|
+
last_resume_at=utc_now(),
|
|
2010
|
+
last_recovery_abandoned_run_id=recovery_abandoned_run_id,
|
|
2011
|
+
last_recovery_summary=recovery_summary,
|
|
2012
|
+
)
|
|
1643
2013
|
summary = f"Quest {quest_id} resumed."
|
|
1644
2014
|
event = self._append_control_event(
|
|
1645
2015
|
quest_id,
|
|
@@ -1780,52 +2150,50 @@ class DaemonApp:
|
|
|
1780
2150
|
cancelled_pending_user_message_count: int,
|
|
1781
2151
|
previous_snapshot: dict | None = None,
|
|
1782
2152
|
) -> str:
|
|
1783
|
-
branch = str(snapshot.get("branch") or "unknown").strip() or "unknown"
|
|
1784
|
-
workspace_root = str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or "").strip()
|
|
1785
2153
|
if action == "resume":
|
|
1786
2154
|
lines = [
|
|
1787
2155
|
self._polite_copy(
|
|
1788
|
-
zh="
|
|
1789
|
-
en="
|
|
2156
|
+
zh=f"我回来继续干活啦,Quest `{quest_id}` 已恢复。",
|
|
2157
|
+
en=f"I’m back on it. Quest `{quest_id}` has resumed. ✨",
|
|
1790
2158
|
),
|
|
1791
2159
|
self._polite_copy(
|
|
1792
|
-
zh="
|
|
1793
|
-
en="The current
|
|
2160
|
+
zh="刚才的进度都还在,我会直接接着往下推。",
|
|
2161
|
+
en="The current progress is still here, and I’ll pick up right where I left off.",
|
|
1794
2162
|
),
|
|
1795
2163
|
]
|
|
1796
2164
|
if source.startswith("auto:daemon-recovery"):
|
|
1797
2165
|
lines.append(
|
|
1798
2166
|
self._polite_copy(
|
|
1799
|
-
zh="
|
|
1800
|
-
en="The daemon
|
|
2167
|
+
zh="刚才是 daemon 意外断开了,不过现在已经自动接回来了。",
|
|
2168
|
+
en="The daemon dropped unexpectedly, but it has been recovered automatically. 🔧",
|
|
1801
2169
|
)
|
|
1802
2170
|
)
|
|
1803
2171
|
elif action == "pause":
|
|
1804
2172
|
lines = [
|
|
1805
2173
|
self._polite_copy(
|
|
1806
|
-
zh="
|
|
1807
|
-
en="
|
|
2174
|
+
zh=f"我先帮您把 Quest `{quest_id}` 稳稳停在这里啦。",
|
|
2175
|
+
en=f"I’ve paused Quest `{quest_id}` right here for now. ⏸️",
|
|
1808
2176
|
),
|
|
1809
2177
|
self._polite_copy(
|
|
1810
|
-
zh="
|
|
1811
|
-
en="
|
|
2178
|
+
zh="当前进度我都保留好了,您发新消息或者执行 `/resume`,我就会继续。",
|
|
2179
|
+
en="I kept the current progress safe. Send a new message or use `/resume`, and I’ll continue.",
|
|
1812
2180
|
),
|
|
1813
2181
|
]
|
|
1814
2182
|
else:
|
|
1815
2183
|
lines = [
|
|
1816
2184
|
self._polite_copy(
|
|
1817
|
-
zh="
|
|
1818
|
-
en="
|
|
2185
|
+
zh=f"这轮我先收住啦,Quest `{quest_id}` 已停止运行。",
|
|
2186
|
+
en=f"I’m wrapping this round here. Quest `{quest_id}` has stopped. 📌",
|
|
1819
2187
|
),
|
|
1820
2188
|
self._polite_copy(
|
|
1821
|
-
zh="
|
|
1822
|
-
en="
|
|
2189
|
+
zh="不过别担心,当前进度我都保留好了;您发新消息或者执行 `/resume`,我就能接着干。",
|
|
2190
|
+
en="Don’t worry, the current progress is still preserved. Send a new message or use `/resume`, and I’ll keep going.",
|
|
1823
2191
|
),
|
|
1824
2192
|
]
|
|
1825
2193
|
if interrupted:
|
|
1826
2194
|
lines.append(
|
|
1827
2195
|
self._polite_copy(
|
|
1828
|
-
zh="
|
|
2196
|
+
zh="刚才正在跑的任务已经被打断了。",
|
|
1829
2197
|
en="The active runner was interrupted.",
|
|
1830
2198
|
)
|
|
1831
2199
|
)
|
|
@@ -1833,8 +2201,8 @@ class DaemonApp:
|
|
|
1833
2201
|
if cancelled_count > 0:
|
|
1834
2202
|
lines.append(
|
|
1835
2203
|
self._polite_copy(
|
|
1836
|
-
zh=f"
|
|
1837
|
-
en=f"
|
|
2204
|
+
zh=f"另外我还顺手清掉了 {cancelled_count} 条排队消息,避免旧指令继续堆着。",
|
|
2205
|
+
en=f"I also cleared {cancelled_count} queued message(s) so stale instructions do not pile up.",
|
|
1838
2206
|
)
|
|
1839
2207
|
)
|
|
1840
2208
|
previous_status = str(
|
|
@@ -1845,17 +2213,10 @@ class DaemonApp:
|
|
|
1845
2213
|
if previous_status and action == "resume":
|
|
1846
2214
|
lines.append(
|
|
1847
2215
|
self._polite_copy(
|
|
1848
|
-
zh=f"
|
|
2216
|
+
zh=f"恢复前的状态是:`{previous_status}`。",
|
|
1849
2217
|
en=f"Previous status: `{previous_status}`.",
|
|
1850
2218
|
)
|
|
1851
2219
|
)
|
|
1852
|
-
lines.extend(
|
|
1853
|
-
[
|
|
1854
|
-
f"- Quest: `{quest_id}`",
|
|
1855
|
-
f"- Branch: `{branch}`",
|
|
1856
|
-
f"- Workspace: `{workspace_root or snapshot.get('quest_root')}`",
|
|
1857
|
-
]
|
|
1858
|
-
)
|
|
1859
2220
|
return "\n".join(lines)
|
|
1860
2221
|
|
|
1861
2222
|
def _drain_turns(self, quest_id: str) -> None:
|
|
@@ -1867,7 +2228,16 @@ class DaemonApp:
|
|
|
1867
2228
|
state.pop("worker", None)
|
|
1868
2229
|
return
|
|
1869
2230
|
state["pending"] = False
|
|
1870
|
-
|
|
2231
|
+
try:
|
|
2232
|
+
self._run_quest_turn(quest_id)
|
|
2233
|
+
except Exception as exc:
|
|
2234
|
+
self.logger.log(
|
|
2235
|
+
"error",
|
|
2236
|
+
"daemon.turn_worker_crashed",
|
|
2237
|
+
quest_id=quest_id,
|
|
2238
|
+
error=str(exc),
|
|
2239
|
+
traceback=traceback.format_exc(),
|
|
2240
|
+
)
|
|
1871
2241
|
|
|
1872
2242
|
def _run_quest_turn(self, quest_id: str) -> None:
|
|
1873
2243
|
with self._turn_lock:
|
|
@@ -1885,7 +2255,9 @@ class DaemonApp:
|
|
|
1885
2255
|
|
|
1886
2256
|
runner_name = self._runner_name_for(snapshot)
|
|
1887
2257
|
runner_cfg = self.runners_config.get(runner_name, {})
|
|
1888
|
-
|
|
2258
|
+
turn_intent = self._turn_intent_for(latest_user_message, turn_reason=turn_reason)
|
|
2259
|
+
turn_mode = self._turn_mode_for(snapshot, latest_user_message, turn_reason=turn_reason)
|
|
2260
|
+
skill_id = self._turn_skill_for(snapshot, latest_user_message, turn_reason=turn_reason, turn_mode=turn_mode)
|
|
1889
2261
|
run_id = generate_id("run")
|
|
1890
2262
|
model = str(runner_cfg.get("model", "gpt-5.4"))
|
|
1891
2263
|
run_message = ""
|
|
@@ -1982,6 +2354,8 @@ class DaemonApp:
|
|
|
1982
2354
|
approval_policy=str(runner_cfg.get("approval_policy", "on-request")),
|
|
1983
2355
|
sandbox_mode=str(runner_cfg.get("sandbox_mode", "workspace-write")),
|
|
1984
2356
|
turn_reason=turn_reason,
|
|
2357
|
+
turn_intent=turn_intent,
|
|
2358
|
+
turn_mode=turn_mode,
|
|
1985
2359
|
reasoning_effort=reasoning_effort,
|
|
1986
2360
|
turn_id=turn_id,
|
|
1987
2361
|
attempt_index=attempt_index,
|
|
@@ -2002,24 +2376,172 @@ class DaemonApp:
|
|
|
2002
2376
|
"next_retry_at": None,
|
|
2003
2377
|
},
|
|
2004
2378
|
)
|
|
2005
|
-
|
|
2006
2379
|
try:
|
|
2007
|
-
|
|
2008
|
-
|
|
2380
|
+
try:
|
|
2381
|
+
result = runner.run(request)
|
|
2382
|
+
except Exception as exc: # pragma: no cover - exercised via integration behavior
|
|
2383
|
+
if self._turn_stop_requested(quest_id):
|
|
2384
|
+
return
|
|
2385
|
+
failure_summary = f"Runner `{runner_name}` failed on attempt {attempt_index}/{max_attempts}: {exc}"
|
|
2386
|
+
retry_context = self._build_retry_context(
|
|
2387
|
+
quest_id=quest_id,
|
|
2388
|
+
failed_run_id=current_run_id,
|
|
2389
|
+
turn_id=turn_id,
|
|
2390
|
+
attempt_index=attempt_index,
|
|
2391
|
+
max_attempts=max_attempts,
|
|
2392
|
+
failure_kind="exception",
|
|
2393
|
+
failure_summary=failure_summary,
|
|
2394
|
+
previous_exit_code=None,
|
|
2395
|
+
previous_output_text="",
|
|
2396
|
+
stderr_text=str(exc),
|
|
2397
|
+
)
|
|
2398
|
+
if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
|
|
2399
|
+
delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
|
|
2400
|
+
next_retry_at = self._retry_next_timestamp(delay_seconds)
|
|
2401
|
+
self.quest_service.update_runtime_state(
|
|
2402
|
+
quest_root=quest_root,
|
|
2403
|
+
status="running",
|
|
2404
|
+
display_status="retrying",
|
|
2405
|
+
active_run_id=None,
|
|
2406
|
+
retry_state={
|
|
2407
|
+
"turn_id": turn_id,
|
|
2408
|
+
"attempt_index": attempt_index,
|
|
2409
|
+
"max_attempts": max_attempts,
|
|
2410
|
+
"last_run_id": current_run_id,
|
|
2411
|
+
"last_error": failure_summary,
|
|
2412
|
+
"next_retry_at": next_retry_at,
|
|
2413
|
+
},
|
|
2414
|
+
)
|
|
2415
|
+
self._append_retry_event(
|
|
2416
|
+
quest_id,
|
|
2417
|
+
event_type="runner.turn_retry_scheduled",
|
|
2418
|
+
runner_name=runner_name,
|
|
2419
|
+
run_id=current_run_id,
|
|
2420
|
+
turn_id=turn_id,
|
|
2421
|
+
skill_id=skill_id,
|
|
2422
|
+
model=model,
|
|
2423
|
+
attempt_index=attempt_index,
|
|
2424
|
+
max_attempts=max_attempts,
|
|
2425
|
+
summary=f"Attempt {attempt_index}/{max_attempts} failed. Retrying in {delay_seconds:.1f}s.",
|
|
2426
|
+
failure_summary=failure_summary,
|
|
2427
|
+
backoff_seconds=delay_seconds,
|
|
2428
|
+
next_attempt_index=attempt_index + 1,
|
|
2429
|
+
)
|
|
2430
|
+
if self._wait_for_retry_delay(quest_id, delay_seconds):
|
|
2431
|
+
continue
|
|
2432
|
+
self._append_retry_event(
|
|
2433
|
+
quest_id,
|
|
2434
|
+
event_type="runner.turn_retry_aborted",
|
|
2435
|
+
runner_name=runner_name,
|
|
2436
|
+
run_id=current_run_id,
|
|
2437
|
+
turn_id=turn_id,
|
|
2438
|
+
skill_id=skill_id,
|
|
2439
|
+
model=model,
|
|
2440
|
+
attempt_index=attempt_index,
|
|
2441
|
+
max_attempts=max_attempts,
|
|
2442
|
+
summary="Retry sequence aborted because the quest was stopped or paused.",
|
|
2443
|
+
failure_summary=failure_summary,
|
|
2444
|
+
)
|
|
2445
|
+
return
|
|
2446
|
+
exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
|
|
2447
|
+
self._append_retry_event(
|
|
2448
|
+
quest_id,
|
|
2449
|
+
event_type="runner.turn_retry_exhausted",
|
|
2450
|
+
runner_name=runner_name,
|
|
2451
|
+
run_id=current_run_id,
|
|
2452
|
+
turn_id=turn_id,
|
|
2453
|
+
skill_id=skill_id,
|
|
2454
|
+
model=model,
|
|
2455
|
+
attempt_index=attempt_index,
|
|
2456
|
+
max_attempts=max_attempts,
|
|
2457
|
+
summary=exhausted_summary,
|
|
2458
|
+
failure_summary=failure_summary,
|
|
2459
|
+
)
|
|
2460
|
+
self._record_turn_error(
|
|
2461
|
+
quest_id=quest_id,
|
|
2462
|
+
runner_name=runner_name,
|
|
2463
|
+
run_id=current_run_id,
|
|
2464
|
+
skill_id=skill_id,
|
|
2465
|
+
model=model,
|
|
2466
|
+
summary=exhausted_summary,
|
|
2467
|
+
retry_state=None,
|
|
2468
|
+
)
|
|
2469
|
+
return
|
|
2470
|
+
|
|
2009
2471
|
if self._turn_stop_requested(quest_id):
|
|
2010
2472
|
return
|
|
2011
|
-
|
|
2473
|
+
|
|
2474
|
+
if result.ok:
|
|
2475
|
+
self.quest_service.update_runtime_state(quest_root=quest_root, retry_state=None)
|
|
2476
|
+
if result.output_text:
|
|
2477
|
+
result_attachment = [
|
|
2478
|
+
{
|
|
2479
|
+
"kind": "runner_result",
|
|
2480
|
+
"run_id": result.run_id,
|
|
2481
|
+
"skill_id": skill_id,
|
|
2482
|
+
"runner": runner_name,
|
|
2483
|
+
"model": result.model,
|
|
2484
|
+
"exit_code": result.exit_code,
|
|
2485
|
+
"history_root": str(result.history_root),
|
|
2486
|
+
"run_root": str(result.run_root),
|
|
2487
|
+
}
|
|
2488
|
+
]
|
|
2489
|
+
try:
|
|
2490
|
+
self.quest_service.append_message(
|
|
2491
|
+
quest_id,
|
|
2492
|
+
role="assistant",
|
|
2493
|
+
content=result.output_text,
|
|
2494
|
+
source=runner_name,
|
|
2495
|
+
run_id=result.run_id,
|
|
2496
|
+
skill_id=skill_id,
|
|
2497
|
+
)
|
|
2498
|
+
except Exception as exc:
|
|
2499
|
+
self._record_turn_postprocess_warning(
|
|
2500
|
+
quest_id=quest_id,
|
|
2501
|
+
runner_name=runner_name,
|
|
2502
|
+
run_id=result.run_id,
|
|
2503
|
+
skill_id=skill_id,
|
|
2504
|
+
model=result.model,
|
|
2505
|
+
stage="append_message",
|
|
2506
|
+
error=exc,
|
|
2507
|
+
)
|
|
2508
|
+
try:
|
|
2509
|
+
self._relay_quest_message_to_bound_connectors(
|
|
2510
|
+
quest_id,
|
|
2511
|
+
message=result.output_text,
|
|
2512
|
+
kind="assistant",
|
|
2513
|
+
response_phase="final",
|
|
2514
|
+
importance="normal",
|
|
2515
|
+
attachments=result_attachment,
|
|
2516
|
+
)
|
|
2517
|
+
except Exception as exc:
|
|
2518
|
+
self._record_turn_postprocess_warning(
|
|
2519
|
+
quest_id=quest_id,
|
|
2520
|
+
runner_name=runner_name,
|
|
2521
|
+
run_id=result.run_id,
|
|
2522
|
+
skill_id=skill_id,
|
|
2523
|
+
model=result.model,
|
|
2524
|
+
stage="connector_relay",
|
|
2525
|
+
error=exc,
|
|
2526
|
+
)
|
|
2527
|
+
self._normalize_status_after_turn(quest_id, turn_reason=turn_reason)
|
|
2528
|
+
return
|
|
2529
|
+
|
|
2530
|
+
failure_summary = f"Runner `{runner_name}` exited with code {result.exit_code} on attempt {attempt_index}/{max_attempts}."
|
|
2531
|
+
stderr_excerpt = self._trim_text(result.stderr_text, limit=240)
|
|
2532
|
+
if stderr_excerpt:
|
|
2533
|
+
failure_summary = f"{failure_summary} stderr: {stderr_excerpt}"
|
|
2012
2534
|
retry_context = self._build_retry_context(
|
|
2013
2535
|
quest_id=quest_id,
|
|
2014
|
-
failed_run_id=
|
|
2536
|
+
failed_run_id=result.run_id,
|
|
2015
2537
|
turn_id=turn_id,
|
|
2016
2538
|
attempt_index=attempt_index,
|
|
2017
2539
|
max_attempts=max_attempts,
|
|
2018
|
-
failure_kind="
|
|
2540
|
+
failure_kind="exit_code",
|
|
2019
2541
|
failure_summary=failure_summary,
|
|
2020
|
-
previous_exit_code=
|
|
2021
|
-
previous_output_text=
|
|
2022
|
-
stderr_text=
|
|
2542
|
+
previous_exit_code=result.exit_code,
|
|
2543
|
+
previous_output_text=result.output_text,
|
|
2544
|
+
stderr_text=result.stderr_text,
|
|
2023
2545
|
)
|
|
2024
2546
|
if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
|
|
2025
2547
|
delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
|
|
@@ -2033,7 +2555,7 @@ class DaemonApp:
|
|
|
2033
2555
|
"turn_id": turn_id,
|
|
2034
2556
|
"attempt_index": attempt_index,
|
|
2035
2557
|
"max_attempts": max_attempts,
|
|
2036
|
-
"last_run_id":
|
|
2558
|
+
"last_run_id": result.run_id,
|
|
2037
2559
|
"last_error": failure_summary,
|
|
2038
2560
|
"next_retry_at": next_retry_at,
|
|
2039
2561
|
},
|
|
@@ -2042,7 +2564,7 @@ class DaemonApp:
|
|
|
2042
2564
|
quest_id,
|
|
2043
2565
|
event_type="runner.turn_retry_scheduled",
|
|
2044
2566
|
runner_name=runner_name,
|
|
2045
|
-
run_id=
|
|
2567
|
+
run_id=result.run_id,
|
|
2046
2568
|
turn_id=turn_id,
|
|
2047
2569
|
skill_id=skill_id,
|
|
2048
2570
|
model=model,
|
|
@@ -2059,7 +2581,7 @@ class DaemonApp:
|
|
|
2059
2581
|
quest_id,
|
|
2060
2582
|
event_type="runner.turn_retry_aborted",
|
|
2061
2583
|
runner_name=runner_name,
|
|
2062
|
-
run_id=
|
|
2584
|
+
run_id=result.run_id,
|
|
2063
2585
|
turn_id=turn_id,
|
|
2064
2586
|
skill_id=skill_id,
|
|
2065
2587
|
model=model,
|
|
@@ -2069,12 +2591,13 @@ class DaemonApp:
|
|
|
2069
2591
|
failure_summary=failure_summary,
|
|
2070
2592
|
)
|
|
2071
2593
|
return
|
|
2594
|
+
|
|
2072
2595
|
exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
|
|
2073
2596
|
self._append_retry_event(
|
|
2074
2597
|
quest_id,
|
|
2075
2598
|
event_type="runner.turn_retry_exhausted",
|
|
2076
2599
|
runner_name=runner_name,
|
|
2077
|
-
run_id=
|
|
2600
|
+
run_id=result.run_id,
|
|
2078
2601
|
turn_id=turn_id,
|
|
2079
2602
|
skill_id=skill_id,
|
|
2080
2603
|
model=model,
|
|
@@ -2086,150 +2609,155 @@ class DaemonApp:
|
|
|
2086
2609
|
self._record_turn_error(
|
|
2087
2610
|
quest_id=quest_id,
|
|
2088
2611
|
runner_name=runner_name,
|
|
2089
|
-
run_id=
|
|
2612
|
+
run_id=result.run_id,
|
|
2090
2613
|
skill_id=skill_id,
|
|
2091
2614
|
model=model,
|
|
2092
2615
|
summary=exhausted_summary,
|
|
2093
2616
|
retry_state=None,
|
|
2094
2617
|
)
|
|
2095
2618
|
return
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
return
|
|
2099
|
-
|
|
2100
|
-
if result.ok:
|
|
2101
|
-
self.quest_service.update_runtime_state(quest_root=quest_root, retry_state=None)
|
|
2102
|
-
if result.output_text:
|
|
2103
|
-
self.quest_service.append_message(
|
|
2104
|
-
quest_id,
|
|
2105
|
-
role="assistant",
|
|
2106
|
-
content=result.output_text,
|
|
2107
|
-
source=runner_name,
|
|
2108
|
-
run_id=result.run_id,
|
|
2109
|
-
skill_id=skill_id,
|
|
2110
|
-
)
|
|
2111
|
-
self._relay_quest_message_to_bound_connectors(
|
|
2112
|
-
quest_id,
|
|
2113
|
-
message=result.output_text,
|
|
2114
|
-
kind="assistant",
|
|
2115
|
-
response_phase="final",
|
|
2116
|
-
importance="normal",
|
|
2117
|
-
attachments=[
|
|
2118
|
-
{
|
|
2119
|
-
"kind": "runner_result",
|
|
2120
|
-
"run_id": result.run_id,
|
|
2121
|
-
"skill_id": skill_id,
|
|
2122
|
-
"runner": runner_name,
|
|
2123
|
-
"model": result.model,
|
|
2124
|
-
"exit_code": result.exit_code,
|
|
2125
|
-
"history_root": str(result.history_root),
|
|
2126
|
-
"run_root": str(result.run_root),
|
|
2127
|
-
}
|
|
2128
|
-
],
|
|
2129
|
-
)
|
|
2130
|
-
self._normalize_status_after_turn(quest_id)
|
|
2131
|
-
return
|
|
2132
|
-
|
|
2133
|
-
failure_summary = f"Runner `{runner_name}` exited with code {result.exit_code} on attempt {attempt_index}/{max_attempts}."
|
|
2134
|
-
stderr_excerpt = self._trim_text(result.stderr_text, limit=240)
|
|
2135
|
-
if stderr_excerpt:
|
|
2136
|
-
failure_summary = f"{failure_summary} stderr: {stderr_excerpt}"
|
|
2137
|
-
retry_context = self._build_retry_context(
|
|
2138
|
-
quest_id=quest_id,
|
|
2139
|
-
failed_run_id=result.run_id,
|
|
2140
|
-
turn_id=turn_id,
|
|
2141
|
-
attempt_index=attempt_index,
|
|
2142
|
-
max_attempts=max_attempts,
|
|
2143
|
-
failure_kind="exit_code",
|
|
2144
|
-
failure_summary=failure_summary,
|
|
2145
|
-
previous_exit_code=result.exit_code,
|
|
2146
|
-
previous_output_text=result.output_text,
|
|
2147
|
-
stderr_text=result.stderr_text,
|
|
2148
|
-
)
|
|
2149
|
-
if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
|
|
2150
|
-
delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
|
|
2151
|
-
next_retry_at = self._retry_next_timestamp(delay_seconds)
|
|
2152
|
-
self.quest_service.update_runtime_state(
|
|
2153
|
-
quest_root=quest_root,
|
|
2154
|
-
status="running",
|
|
2155
|
-
display_status="retrying",
|
|
2156
|
-
active_run_id=None,
|
|
2157
|
-
retry_state={
|
|
2158
|
-
"turn_id": turn_id,
|
|
2159
|
-
"attempt_index": attempt_index,
|
|
2160
|
-
"max_attempts": max_attempts,
|
|
2161
|
-
"last_run_id": result.run_id,
|
|
2162
|
-
"last_error": failure_summary,
|
|
2163
|
-
"next_retry_at": next_retry_at,
|
|
2164
|
-
},
|
|
2165
|
-
)
|
|
2166
|
-
self._append_retry_event(
|
|
2167
|
-
quest_id,
|
|
2168
|
-
event_type="runner.turn_retry_scheduled",
|
|
2169
|
-
runner_name=runner_name,
|
|
2170
|
-
run_id=result.run_id,
|
|
2171
|
-
turn_id=turn_id,
|
|
2172
|
-
skill_id=skill_id,
|
|
2173
|
-
model=model,
|
|
2174
|
-
attempt_index=attempt_index,
|
|
2175
|
-
max_attempts=max_attempts,
|
|
2176
|
-
summary=f"Attempt {attempt_index}/{max_attempts} failed. Retrying in {delay_seconds:.1f}s.",
|
|
2177
|
-
failure_summary=failure_summary,
|
|
2178
|
-
backoff_seconds=delay_seconds,
|
|
2179
|
-
next_attempt_index=attempt_index + 1,
|
|
2180
|
-
)
|
|
2181
|
-
if self._wait_for_retry_delay(quest_id, delay_seconds):
|
|
2182
|
-
continue
|
|
2183
|
-
self._append_retry_event(
|
|
2619
|
+
finally:
|
|
2620
|
+
self._ensure_turn_cleanup(
|
|
2184
2621
|
quest_id,
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
run_id=result.run_id,
|
|
2188
|
-
turn_id=turn_id,
|
|
2189
|
-
skill_id=skill_id,
|
|
2190
|
-
model=model,
|
|
2191
|
-
attempt_index=attempt_index,
|
|
2192
|
-
max_attempts=max_attempts,
|
|
2193
|
-
summary="Retry sequence aborted because the quest was stopped or paused.",
|
|
2194
|
-
failure_summary=failure_summary,
|
|
2622
|
+
run_id=current_run_id,
|
|
2623
|
+
turn_reason=turn_reason,
|
|
2195
2624
|
)
|
|
2196
|
-
return
|
|
2197
|
-
|
|
2198
|
-
exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
|
|
2199
|
-
self._append_retry_event(
|
|
2200
|
-
quest_id,
|
|
2201
|
-
event_type="runner.turn_retry_exhausted",
|
|
2202
|
-
runner_name=runner_name,
|
|
2203
|
-
run_id=result.run_id,
|
|
2204
|
-
turn_id=turn_id,
|
|
2205
|
-
skill_id=skill_id,
|
|
2206
|
-
model=model,
|
|
2207
|
-
attempt_index=attempt_index,
|
|
2208
|
-
max_attempts=max_attempts,
|
|
2209
|
-
summary=exhausted_summary,
|
|
2210
|
-
failure_summary=failure_summary,
|
|
2211
|
-
)
|
|
2212
|
-
self._record_turn_error(
|
|
2213
|
-
quest_id=quest_id,
|
|
2214
|
-
runner_name=runner_name,
|
|
2215
|
-
run_id=result.run_id,
|
|
2216
|
-
skill_id=skill_id,
|
|
2217
|
-
model=model,
|
|
2218
|
-
summary=exhausted_summary,
|
|
2219
|
-
retry_state=None,
|
|
2220
|
-
)
|
|
2221
|
-
return
|
|
2222
2625
|
|
|
2223
2626
|
def _runner_name_for(self, snapshot: dict) -> str:
|
|
2224
2627
|
configured = self.config_manager.load_named("config")
|
|
2225
2628
|
return str(snapshot.get("runner") or configured.get("default_runner", "codex")).strip().lower()
|
|
2226
2629
|
|
|
2227
2630
|
@staticmethod
|
|
2228
|
-
def
|
|
2631
|
+
def _stage_state_fingerprint(snapshot: dict) -> str:
|
|
2632
|
+
paper_health = (
|
|
2633
|
+
dict(snapshot.get("paper_contract_health") or {})
|
|
2634
|
+
if isinstance(snapshot.get("paper_contract_health"), dict)
|
|
2635
|
+
else {}
|
|
2636
|
+
)
|
|
2637
|
+
payload = {
|
|
2638
|
+
"active_anchor": str(snapshot.get("active_anchor") or "").strip() or None,
|
|
2639
|
+
"active_run_id": str(snapshot.get("active_run_id") or "").strip() or None,
|
|
2640
|
+
"active_analysis_campaign_id": str(snapshot.get("active_analysis_campaign_id") or "").strip() or None,
|
|
2641
|
+
"next_pending_slice_id": str(snapshot.get("next_pending_slice_id") or "").strip() or None,
|
|
2642
|
+
"current_workspace_branch": str(snapshot.get("current_workspace_branch") or "").strip() or None,
|
|
2643
|
+
"continuation_policy": str(snapshot.get("continuation_policy") or "").strip() or None,
|
|
2644
|
+
"paper": {
|
|
2645
|
+
"closure_state": str(paper_health.get("closure_state") or "").strip() or None,
|
|
2646
|
+
"delivery_state": str(paper_health.get("delivery_state") or "").strip() or None,
|
|
2647
|
+
"recommended_next_stage": str(paper_health.get("recommended_next_stage") or "").strip() or None,
|
|
2648
|
+
"recommended_action": str(paper_health.get("recommended_action") or "").strip() or None,
|
|
2649
|
+
"blocking_reasons": list(paper_health.get("blocking_reasons") or []),
|
|
2650
|
+
"keep_bundle_fixed_by_default": bool(paper_health.get("keep_bundle_fixed_by_default")),
|
|
2651
|
+
},
|
|
2652
|
+
}
|
|
2653
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
2654
|
+
|
|
2655
|
+
@staticmethod
|
|
2656
|
+
def _turn_intent_for(latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
|
|
2229
2657
|
if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
|
|
2658
|
+
return "continue_stage"
|
|
2659
|
+
return classify_turn_intent(str(latest_user_message.get("content") or "").strip())
|
|
2660
|
+
|
|
2661
|
+
@staticmethod
|
|
2662
|
+
def _turn_mode_for(snapshot: dict, latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
|
|
2663
|
+
normalized_reason = str(turn_reason or "").strip() or "user_message"
|
|
2664
|
+
if normalized_reason == "auto_continue":
|
|
2665
|
+
resume_source = str(snapshot.get("last_resume_source") or "").strip()
|
|
2666
|
+
if resume_source.startswith("auto:daemon-recovery"):
|
|
2667
|
+
return "recovering"
|
|
2668
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
2669
|
+
if continuation_policy == "when_external_progress":
|
|
2670
|
+
return "monitoring"
|
|
2671
|
+
if continuation_policy in {"wait_for_user_or_resume", "none"}:
|
|
2672
|
+
return "parked"
|
|
2673
|
+
return "stage_execution"
|
|
2674
|
+
turn_intent = DaemonApp._turn_intent_for(latest_user_message, turn_reason=turn_reason)
|
|
2675
|
+
if turn_intent == "answer_user_question_first":
|
|
2676
|
+
return "answering"
|
|
2677
|
+
if turn_intent == "execute_user_command_first":
|
|
2678
|
+
return "command_execution"
|
|
2679
|
+
return "stage_execution"
|
|
2680
|
+
|
|
2681
|
+
@staticmethod
|
|
2682
|
+
def _continuation_anchor_for(snapshot: dict) -> str:
|
|
2683
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
2684
|
+
continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
|
|
2685
|
+
if continuation_anchor in available_stage_skills:
|
|
2686
|
+
return continuation_anchor
|
|
2687
|
+
active_anchor = str(snapshot.get("active_anchor") or "").strip()
|
|
2688
|
+
return active_anchor if active_anchor in available_stage_skills else "decision"
|
|
2689
|
+
|
|
2690
|
+
@staticmethod
|
|
2691
|
+
def _workspace_mode_for(snapshot: dict) -> str:
|
|
2692
|
+
value = str(snapshot.get("workspace_mode") or "").strip().lower()
|
|
2693
|
+
if value in {"copilot", "autonomous"}:
|
|
2694
|
+
return value
|
|
2695
|
+
startup_contract = snapshot.get("startup_contract")
|
|
2696
|
+
if isinstance(startup_contract, dict):
|
|
2697
|
+
value = str(startup_contract.get("workspace_mode") or "").strip().lower()
|
|
2698
|
+
if value in {"copilot", "autonomous"}:
|
|
2699
|
+
return value
|
|
2700
|
+
return "autonomous"
|
|
2701
|
+
|
|
2702
|
+
def _resolve_continuation_policy(self, snapshot: dict, *, current_policy: str) -> tuple[str, str]:
|
|
2703
|
+
normalized = str(current_policy or "auto").strip().lower() or "auto"
|
|
2704
|
+
if normalized != "auto":
|
|
2705
|
+
return normalized, str(snapshot.get("continuation_reason") or "").strip() or "explicit_continuation_policy"
|
|
2706
|
+
if self._workspace_mode_for(snapshot) == "copilot":
|
|
2707
|
+
return "wait_for_user_or_resume", "copilot_mode"
|
|
2708
|
+
if self._has_external_progress(snapshot):
|
|
2709
|
+
return "when_external_progress", "background_external_progress_active"
|
|
2710
|
+
return "auto", "autonomous_prepare_or_launch_long_run"
|
|
2711
|
+
|
|
2712
|
+
@staticmethod
|
|
2713
|
+
def _auto_continue_delay_for_policy(policy: str) -> float:
|
|
2714
|
+
normalized = str(policy or "").strip().lower() or "auto"
|
|
2715
|
+
if normalized == "when_external_progress":
|
|
2716
|
+
return _AUTO_CONTINUE_DELAY_SECONDS
|
|
2717
|
+
return _AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS
|
|
2718
|
+
|
|
2719
|
+
@staticmethod
|
|
2720
|
+
def _turn_skill_stage_gate(snapshot: dict, candidate_skill: str) -> str:
|
|
2721
|
+
skill = str(candidate_skill or "").strip()
|
|
2722
|
+
baseline_gate = str(snapshot.get("baseline_gate") or "pending").strip().lower() or "pending"
|
|
2723
|
+
startup_contract = snapshot.get("startup_contract") if isinstance(snapshot.get("startup_contract"), dict) else {}
|
|
2724
|
+
raw_need_research_paper = startup_contract.get("need_research_paper")
|
|
2725
|
+
need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
|
|
2726
|
+
active_idea_id = str(snapshot.get("active_idea_id") or "").strip()
|
|
2727
|
+
|
|
2728
|
+
if (
|
|
2729
|
+
baseline_gate == "pending"
|
|
2730
|
+
and skill in {"idea", "optimize", "experiment", "analysis-campaign", "write", "review", "rebuttal", "finalize"}
|
|
2731
|
+
):
|
|
2732
|
+
return "baseline"
|
|
2733
|
+
|
|
2734
|
+
if skill == "experiment" and not active_idea_id:
|
|
2735
|
+
return "idea" if need_research_paper else "optimize"
|
|
2736
|
+
|
|
2737
|
+
return skill
|
|
2738
|
+
|
|
2739
|
+
@staticmethod
|
|
2740
|
+
def _turn_skill_for(
|
|
2741
|
+
snapshot: dict,
|
|
2742
|
+
latest_user_message: dict | None,
|
|
2743
|
+
*,
|
|
2744
|
+
turn_reason: str = "user_message",
|
|
2745
|
+
turn_mode: str = "stage_execution",
|
|
2746
|
+
) -> str:
|
|
2747
|
+
available_stage_skills = current_standard_skills(repo_root())
|
|
2748
|
+
workspace_mode = DaemonApp._workspace_mode_for(snapshot)
|
|
2749
|
+
|
|
2750
|
+
def copilot_default_skill() -> str:
|
|
2230
2751
|
active_anchor = str(snapshot.get("active_anchor") or "").strip()
|
|
2231
|
-
|
|
2232
|
-
|
|
2752
|
+
if active_anchor in available_stage_skills and active_anchor != "decision":
|
|
2753
|
+
return DaemonApp._turn_skill_stage_gate(snapshot, active_anchor)
|
|
2754
|
+
continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
|
|
2755
|
+
if continuation_anchor in available_stage_skills and continuation_anchor != "decision":
|
|
2756
|
+
return DaemonApp._turn_skill_stage_gate(snapshot, continuation_anchor)
|
|
2757
|
+
fallback = "baseline" if "baseline" in available_stage_skills else "scout"
|
|
2758
|
+
return DaemonApp._turn_skill_stage_gate(snapshot, fallback)
|
|
2759
|
+
|
|
2760
|
+
reply_target = str((latest_user_message or {}).get("reply_to_interaction_id") or "").strip()
|
|
2233
2761
|
if reply_target:
|
|
2234
2762
|
for item in (snapshot.get("active_interactions") or []):
|
|
2235
2763
|
candidate_ids = {
|
|
@@ -2247,13 +2775,42 @@ class DaemonApp:
|
|
|
2247
2775
|
str(item.get("interaction_id") or "").strip(),
|
|
2248
2776
|
str(item.get("artifact_id") or "").strip(),
|
|
2249
2777
|
}
|
|
2250
|
-
if reply_target in candidate_ids
|
|
2778
|
+
if reply_target not in candidate_ids:
|
|
2779
|
+
continue
|
|
2780
|
+
if (
|
|
2251
2781
|
str(item.get("reply_mode") or "") == "blocking"
|
|
2252
2782
|
or str(item.get("kind") or "") == "decision_request"
|
|
2253
2783
|
):
|
|
2254
2784
|
return "decision"
|
|
2785
|
+
if str(item.get("reply_mode") or "") == "threaded":
|
|
2786
|
+
if workspace_mode == "copilot":
|
|
2787
|
+
return copilot_default_skill()
|
|
2788
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2789
|
+
snapshot,
|
|
2790
|
+
DaemonApp._continuation_anchor_for(snapshot),
|
|
2791
|
+
)
|
|
2792
|
+
if turn_mode == "recovering":
|
|
2793
|
+
return "decision"
|
|
2794
|
+
if workspace_mode == "copilot" and latest_user_message is not None:
|
|
2795
|
+
return copilot_default_skill()
|
|
2796
|
+
if turn_mode in {"answering", "command_execution"}:
|
|
2797
|
+
return "decision"
|
|
2798
|
+
if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
|
|
2799
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2800
|
+
snapshot,
|
|
2801
|
+
DaemonApp._continuation_anchor_for(snapshot),
|
|
2802
|
+
)
|
|
2803
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
2804
|
+
if continuation_policy == "wait_for_user_or_resume":
|
|
2805
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2806
|
+
snapshot,
|
|
2807
|
+
DaemonApp._continuation_anchor_for(snapshot),
|
|
2808
|
+
)
|
|
2255
2809
|
active_anchor = str(snapshot.get("active_anchor") or "").strip()
|
|
2256
|
-
return
|
|
2810
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2811
|
+
snapshot,
|
|
2812
|
+
active_anchor if active_anchor in available_stage_skills else "decision",
|
|
2813
|
+
)
|
|
2257
2814
|
|
|
2258
2815
|
def _latest_user_message(self, quest_id: str) -> dict | None:
|
|
2259
2816
|
for item in reversed(self.quest_service.history(quest_id, limit=200)):
|
|
@@ -2601,7 +3158,80 @@ class DaemonApp:
|
|
|
2601
3158
|
],
|
|
2602
3159
|
)
|
|
2603
3160
|
|
|
2604
|
-
def
|
|
3161
|
+
def _record_turn_postprocess_warning(
|
|
3162
|
+
self,
|
|
3163
|
+
*,
|
|
3164
|
+
quest_id: str,
|
|
3165
|
+
runner_name: str,
|
|
3166
|
+
run_id: str,
|
|
3167
|
+
skill_id: str,
|
|
3168
|
+
model: str,
|
|
3169
|
+
stage: str,
|
|
3170
|
+
error: Exception,
|
|
3171
|
+
) -> None:
|
|
3172
|
+
quest_root = self.home / "quests" / quest_id
|
|
3173
|
+
summary = f"Runner post-run stage `{stage}` failed for run `{run_id}`: {error}"
|
|
3174
|
+
append_jsonl(
|
|
3175
|
+
quest_root / ".ds" / "events.jsonl",
|
|
3176
|
+
{
|
|
3177
|
+
"event_id": generate_id("evt"),
|
|
3178
|
+
"type": "runner.turn_postprocess_warning",
|
|
3179
|
+
"quest_id": quest_id,
|
|
3180
|
+
"run_id": run_id,
|
|
3181
|
+
"source": runner_name,
|
|
3182
|
+
"skill_id": skill_id,
|
|
3183
|
+
"model": model,
|
|
3184
|
+
"stage": stage,
|
|
3185
|
+
"summary": summary,
|
|
3186
|
+
"created_at": utc_now(),
|
|
3187
|
+
},
|
|
3188
|
+
)
|
|
3189
|
+
self.logger.log(
|
|
3190
|
+
"error",
|
|
3191
|
+
"runner.turn_postprocess_warning",
|
|
3192
|
+
quest_id=quest_id,
|
|
3193
|
+
run_id=run_id,
|
|
3194
|
+
runner=runner_name,
|
|
3195
|
+
skill_id=skill_id,
|
|
3196
|
+
model=model,
|
|
3197
|
+
stage=stage,
|
|
3198
|
+
error=str(error),
|
|
3199
|
+
)
|
|
3200
|
+
|
|
3201
|
+
def _ensure_turn_cleanup(self, quest_id: str, *, run_id: str, turn_reason: str) -> None:
|
|
3202
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
3203
|
+
if str(snapshot.get("active_run_id") or "").strip() != str(run_id or "").strip():
|
|
3204
|
+
return
|
|
3205
|
+
try:
|
|
3206
|
+
self._normalize_status_after_turn(quest_id, turn_reason=turn_reason)
|
|
3207
|
+
return
|
|
3208
|
+
except Exception as exc:
|
|
3209
|
+
current_status = str(snapshot.get("status") or snapshot.get("display_status") or "active").strip() or "active"
|
|
3210
|
+
normalized_status = "active" if current_status == "running" else current_status
|
|
3211
|
+
self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
3212
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
3213
|
+
append_jsonl(
|
|
3214
|
+
quest_root / ".ds" / "events.jsonl",
|
|
3215
|
+
{
|
|
3216
|
+
"event_id": generate_id("evt"),
|
|
3217
|
+
"type": "runner.turn_cleanup_recovered",
|
|
3218
|
+
"quest_id": quest_id,
|
|
3219
|
+
"run_id": run_id,
|
|
3220
|
+
"status": normalized_status,
|
|
3221
|
+
"summary": f"Recovered turn cleanup after `_normalize_status_after_turn` failed: {exc}",
|
|
3222
|
+
"created_at": utc_now(),
|
|
3223
|
+
},
|
|
3224
|
+
)
|
|
3225
|
+
self.logger.log(
|
|
3226
|
+
"error",
|
|
3227
|
+
"runner.turn_cleanup_recovered",
|
|
3228
|
+
quest_id=quest_id,
|
|
3229
|
+
run_id=run_id,
|
|
3230
|
+
status=normalized_status,
|
|
3231
|
+
error=str(exc),
|
|
3232
|
+
)
|
|
3233
|
+
|
|
3234
|
+
def _normalize_status_after_turn(self, quest_id: str, *, turn_reason: str = "user_message") -> None:
|
|
2605
3235
|
with self._turn_lock:
|
|
2606
3236
|
if bool((self._turn_state.get(quest_id) or {}).get("stop_requested")):
|
|
2607
3237
|
return
|
|
@@ -2609,6 +3239,46 @@ class DaemonApp:
|
|
|
2609
3239
|
current_status = str(snapshot.get("status") or snapshot.get("display_status") or "active").strip() or "active"
|
|
2610
3240
|
normalized_status = "active" if current_status == "running" else current_status
|
|
2611
3241
|
snapshot = self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
3242
|
+
runtime_updates: dict[str, Any] = {}
|
|
3243
|
+
current_fingerprint = self._stage_state_fingerprint(snapshot)
|
|
3244
|
+
previous_fingerprint = str(snapshot.get("last_stage_fingerprint") or "").strip() or None
|
|
3245
|
+
same_fingerprint_count = int(snapshot.get("same_fingerprint_auto_turn_count") or 0)
|
|
3246
|
+
if str(turn_reason or "").strip() == "auto_continue":
|
|
3247
|
+
same_fingerprint_count = same_fingerprint_count + 1 if previous_fingerprint == current_fingerprint else 1
|
|
3248
|
+
else:
|
|
3249
|
+
same_fingerprint_count = 0
|
|
3250
|
+
runtime_updates.update(
|
|
3251
|
+
{
|
|
3252
|
+
"last_stage_fingerprint": current_fingerprint,
|
|
3253
|
+
"last_stage_fingerprint_at": utc_now(),
|
|
3254
|
+
"same_fingerprint_auto_turn_count": same_fingerprint_count,
|
|
3255
|
+
}
|
|
3256
|
+
)
|
|
3257
|
+
if (
|
|
3258
|
+
str(turn_reason or "").strip() == "auto_continue"
|
|
3259
|
+
and str(snapshot.get("active_anchor") or "").strip() == "finalize"
|
|
3260
|
+
and same_fingerprint_count >= 2
|
|
3261
|
+
and int(snapshot.get("pending_user_message_count") or 0) == 0
|
|
3262
|
+
):
|
|
3263
|
+
runtime_updates.update(
|
|
3264
|
+
{
|
|
3265
|
+
"continuation_policy": "wait_for_user_or_resume",
|
|
3266
|
+
"continuation_anchor": "decision",
|
|
3267
|
+
"continuation_reason": "unchanged_finalize_state",
|
|
3268
|
+
"continuation_updated_at": utc_now(),
|
|
3269
|
+
}
|
|
3270
|
+
)
|
|
3271
|
+
self.quest_service.update_runtime_state(
|
|
3272
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3273
|
+
**runtime_updates,
|
|
3274
|
+
)
|
|
3275
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
3276
|
+
else:
|
|
3277
|
+
self.quest_service.update_runtime_state(
|
|
3278
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3279
|
+
**runtime_updates,
|
|
3280
|
+
)
|
|
3281
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2612
3282
|
status = str(snapshot.get("status") or "")
|
|
2613
3283
|
if status in {"stopped", "paused", "completed", "error"}:
|
|
2614
3284
|
return
|
|
@@ -2618,12 +3288,68 @@ class DaemonApp:
|
|
|
2618
3288
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2619
3289
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2620
3290
|
else:
|
|
2621
|
-
|
|
3291
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
3292
|
+
if continuation_policy == "auto":
|
|
3293
|
+
continuation_policy, continuation_reason = self._resolve_continuation_policy(
|
|
3294
|
+
snapshot,
|
|
3295
|
+
current_policy=continuation_policy,
|
|
3296
|
+
)
|
|
3297
|
+
self.quest_service.update_runtime_state(
|
|
3298
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3299
|
+
continuation_policy=continuation_policy,
|
|
3300
|
+
continuation_reason=continuation_reason,
|
|
3301
|
+
continuation_updated_at=utc_now(),
|
|
3302
|
+
)
|
|
3303
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
3304
|
+
if continuation_policy not in {"wait_for_user_or_resume", "none"}:
|
|
3305
|
+
self._schedule_turn_later(
|
|
3306
|
+
quest_id,
|
|
3307
|
+
reason="auto_continue",
|
|
3308
|
+
delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
|
|
3309
|
+
)
|
|
2622
3310
|
return
|
|
2623
3311
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2624
3312
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2625
3313
|
return
|
|
2626
|
-
|
|
3314
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
3315
|
+
if continuation_policy == "auto":
|
|
3316
|
+
continuation_policy, continuation_reason = self._resolve_continuation_policy(
|
|
3317
|
+
snapshot,
|
|
3318
|
+
current_policy=continuation_policy,
|
|
3319
|
+
)
|
|
3320
|
+
self.quest_service.update_runtime_state(
|
|
3321
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3322
|
+
continuation_policy=continuation_policy,
|
|
3323
|
+
continuation_reason=continuation_reason,
|
|
3324
|
+
continuation_updated_at=utc_now(),
|
|
3325
|
+
)
|
|
3326
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
3327
|
+
if continuation_policy == "none":
|
|
3328
|
+
return
|
|
3329
|
+
if continuation_policy == "wait_for_user_or_resume":
|
|
3330
|
+
return
|
|
3331
|
+
if continuation_policy == "when_external_progress":
|
|
3332
|
+
if not self._has_external_progress(snapshot):
|
|
3333
|
+
next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
|
|
3334
|
+
next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
|
|
3335
|
+
self.quest_service.update_runtime_state(
|
|
3336
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3337
|
+
continuation_policy=next_policy,
|
|
3338
|
+
continuation_reason=next_reason,
|
|
3339
|
+
continuation_updated_at=utc_now(),
|
|
3340
|
+
)
|
|
3341
|
+
if next_policy != "wait_for_user_or_resume":
|
|
3342
|
+
self._schedule_turn_later(
|
|
3343
|
+
quest_id,
|
|
3344
|
+
reason="auto_continue",
|
|
3345
|
+
delay_seconds=self._auto_continue_delay_for_policy(next_policy),
|
|
3346
|
+
)
|
|
3347
|
+
return
|
|
3348
|
+
self._schedule_turn_later(
|
|
3349
|
+
quest_id,
|
|
3350
|
+
reason="auto_continue",
|
|
3351
|
+
delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
|
|
3352
|
+
)
|
|
2627
3353
|
|
|
2628
3354
|
def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
|
|
2629
3355
|
def _delayed() -> None:
|
|
@@ -2634,6 +3360,32 @@ class DaemonApp:
|
|
|
2634
3360
|
status = str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip().lower()
|
|
2635
3361
|
if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
|
|
2636
3362
|
return
|
|
3363
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
3364
|
+
if continuation_policy == "auto":
|
|
3365
|
+
continuation_policy, continuation_reason = self._resolve_continuation_policy(
|
|
3366
|
+
snapshot,
|
|
3367
|
+
current_policy=continuation_policy,
|
|
3368
|
+
)
|
|
3369
|
+
self.quest_service.update_runtime_state(
|
|
3370
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3371
|
+
continuation_policy=continuation_policy,
|
|
3372
|
+
continuation_reason=continuation_reason,
|
|
3373
|
+
continuation_updated_at=utc_now(),
|
|
3374
|
+
)
|
|
3375
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
3376
|
+
if continuation_policy in {"none", "wait_for_user_or_resume"}:
|
|
3377
|
+
return
|
|
3378
|
+
if continuation_policy == "when_external_progress":
|
|
3379
|
+
if not self._has_external_progress(snapshot):
|
|
3380
|
+
next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
|
|
3381
|
+
next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
|
|
3382
|
+
self.quest_service.update_runtime_state(
|
|
3383
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
3384
|
+
continuation_policy=next_policy,
|
|
3385
|
+
continuation_reason=next_reason,
|
|
3386
|
+
continuation_updated_at=utc_now(),
|
|
3387
|
+
)
|
|
3388
|
+
return
|
|
2637
3389
|
self.schedule_turn(quest_id, reason=reason)
|
|
2638
3390
|
|
|
2639
3391
|
threading.Thread(
|
|
@@ -2642,6 +3394,23 @@ class DaemonApp:
|
|
|
2642
3394
|
name=f"deepscientist-turn-delay-{quest_id}",
|
|
2643
3395
|
).start()
|
|
2644
3396
|
|
|
3397
|
+
def _has_external_progress(self, snapshot: dict) -> bool:
|
|
3398
|
+
if bool(snapshot.get("active_run_id")):
|
|
3399
|
+
return True
|
|
3400
|
+
quest_id = str(snapshot.get("quest_id") or "").strip()
|
|
3401
|
+
if not quest_id:
|
|
3402
|
+
return False
|
|
3403
|
+
try:
|
|
3404
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
3405
|
+
except FileNotFoundError:
|
|
3406
|
+
return False
|
|
3407
|
+
try:
|
|
3408
|
+
sessions = self.bash_exec_service.list_sessions(quest_root, limit=200)
|
|
3409
|
+
return any(str(item.get("status") or "").strip().lower() == "running" for item in sessions if isinstance(item, dict))
|
|
3410
|
+
except Exception:
|
|
3411
|
+
counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
|
|
3412
|
+
return int(counts.get("bash_running_count") or 0) > 0
|
|
3413
|
+
|
|
2645
3414
|
def _relay_quest_message_to_bound_connectors(
|
|
2646
3415
|
self,
|
|
2647
3416
|
quest_id: str,
|
|
@@ -3492,6 +4261,13 @@ class DaemonApp:
|
|
|
3492
4261
|
**normalized,
|
|
3493
4262
|
"_qq_main_chat_binding": qq_binding,
|
|
3494
4263
|
}
|
|
4264
|
+
if connector_name == "weixin":
|
|
4265
|
+
replay = self._maybe_replay_weixin_pending_outbox(normalized)
|
|
4266
|
+
if replay is not None:
|
|
4267
|
+
normalized = {
|
|
4268
|
+
**normalized,
|
|
4269
|
+
"_weixin_replay": replay,
|
|
4270
|
+
}
|
|
3495
4271
|
reply = self._route_connector_message(connector_name, normalized)
|
|
3496
4272
|
return {
|
|
3497
4273
|
"ok": True,
|
|
@@ -3627,8 +4403,8 @@ class DaemonApp:
|
|
|
3627
4403
|
"quest_id": target_quest,
|
|
3628
4404
|
"kind": "ack",
|
|
3629
4405
|
"message": self._polite_copy(
|
|
3630
|
-
zh=f"
|
|
3631
|
-
en=f"
|
|
4406
|
+
zh=f"收到啦!这里已经切到 Quest `{target_quest}` 了,接下来我会直接在这个 {connector_label} 里继续同步进展。",
|
|
4407
|
+
en=f"Got it. This {connector_label} is now on Quest `{target_quest}`, and I’ll keep the next updates here. ✨",
|
|
3632
4408
|
),
|
|
3633
4409
|
}
|
|
3634
4410
|
)
|
|
@@ -4719,13 +5495,13 @@ class DaemonApp:
|
|
|
4719
5495
|
channel = self._channel_with_bindings(old_connector)
|
|
4720
5496
|
if mode == "disconnect":
|
|
4721
5497
|
message = self._polite_copy(
|
|
4722
|
-
zh=f"
|
|
4723
|
-
en=f"
|
|
5498
|
+
zh=f"Quest `{quest_id}` 已经从这里解绑啦,后面会只在本地继续推进。",
|
|
5499
|
+
en=f"Quest `{quest_id}` is no longer bound here. It will continue locally only. 📌",
|
|
4724
5500
|
)
|
|
4725
5501
|
else:
|
|
4726
5502
|
message = self._polite_copy(
|
|
4727
|
-
zh=f"
|
|
4728
|
-
en=f"
|
|
5503
|
+
zh=f"Quest `{quest_id}` 已经从这里切走啦,后面的进展请在 {current_label} 查看。",
|
|
5504
|
+
en=f"Quest `{quest_id}` has moved away from this conversation. Continue from {current_label}. 🔁",
|
|
4729
5505
|
)
|
|
4730
5506
|
channel.send(
|
|
4731
5507
|
{
|
|
@@ -4742,13 +5518,13 @@ class DaemonApp:
|
|
|
4742
5518
|
channel = self._channel_with_bindings(new_connector)
|
|
4743
5519
|
if mode == "bind":
|
|
4744
5520
|
message = self._polite_copy(
|
|
4745
|
-
zh=f"
|
|
4746
|
-
en=f"
|
|
5521
|
+
zh=f"收到!Quest `{quest_id}` 已经接上啦,后面的进展我都会直接在这里同步给您。",
|
|
5522
|
+
en=f"Quest `{quest_id}` is now connected here, and I’ll keep the next updates in this conversation. ✨",
|
|
4747
5523
|
)
|
|
4748
5524
|
elif mode == "switch":
|
|
4749
5525
|
message = self._polite_copy(
|
|
4750
|
-
zh=f"
|
|
4751
|
-
en=f"
|
|
5526
|
+
zh=f"收到!Quest `{quest_id}` 已经切到这里啦,后面的进展我都会直接在这里同步给您。",
|
|
5527
|
+
en=f"Quest `{quest_id}` has switched over here, and I’ll keep the next updates in this conversation. 🔄",
|
|
4752
5528
|
)
|
|
4753
5529
|
else:
|
|
4754
5530
|
message = ""
|
|
@@ -4982,6 +5758,20 @@ class DaemonApp:
|
|
|
4982
5758
|
)
|
|
4983
5759
|
return f"{notice}\n\n{base}"
|
|
4984
5760
|
|
|
5761
|
+
def _connector_goal_preview(self, goal: str, *, limit: int = 88) -> str:
|
|
5762
|
+
for raw_line in str(goal or "").replace("\r", "\n").split("\n"):
|
|
5763
|
+
line = re.sub(r"^[#>*\-\d\.)\s]+", "", raw_line).strip()
|
|
5764
|
+
if not line:
|
|
5765
|
+
continue
|
|
5766
|
+
normalized = re.sub(r"\s+", " ", line).strip()
|
|
5767
|
+
if len(normalized) <= limit:
|
|
5768
|
+
return normalized
|
|
5769
|
+
return normalized[: max(0, limit - 3)].rstrip() + "..."
|
|
5770
|
+
return self._polite_copy(
|
|
5771
|
+
zh="我会先把当前任务整理清楚,再继续推进。",
|
|
5772
|
+
en="I will clarify the current task first, then keep moving. ✨",
|
|
5773
|
+
)
|
|
5774
|
+
|
|
4985
5775
|
def _quest_created_connector_message(
|
|
4986
5776
|
self,
|
|
4987
5777
|
connector_name: str,
|
|
@@ -4990,29 +5780,29 @@ class DaemonApp:
|
|
|
4990
5780
|
goal: str,
|
|
4991
5781
|
previous_quest_id: str | None = None,
|
|
4992
5782
|
) -> str:
|
|
4993
|
-
normalized_goal = str(goal or "").strip() or "(未提供具体任务)"
|
|
4994
5783
|
previous = str(previous_quest_id or "").strip()
|
|
5784
|
+
goal_preview = self._connector_goal_preview(goal)
|
|
4995
5785
|
restore_zh = (
|
|
4996
|
-
f"\n
|
|
5786
|
+
f"\n如果想切回原先的 Quest `{previous}`,给我发 `/use {previous}` 就行。"
|
|
4997
5787
|
if previous and previous != quest_id
|
|
4998
5788
|
else ""
|
|
4999
5789
|
)
|
|
5000
5790
|
restore_en = (
|
|
5001
|
-
f"\nIf you
|
|
5791
|
+
f"\nIf you want to switch back to Quest `{previous}`, send `/use {previous}`. 🔁"
|
|
5002
5792
|
if previous and previous != quest_id
|
|
5003
5793
|
else ""
|
|
5004
5794
|
)
|
|
5005
5795
|
return self._polite_copy(
|
|
5006
5796
|
zh=(
|
|
5007
|
-
f"
|
|
5008
|
-
f"
|
|
5009
|
-
f"
|
|
5797
|
+
f"开工啦!新的 Quest `{quest_id}` 已经建好啦。\n"
|
|
5798
|
+
f"这轮我先做这件事:{goal_preview}\n"
|
|
5799
|
+
f"后面的进展我都会直接在这里同步给您。"
|
|
5010
5800
|
)
|
|
5011
5801
|
+ restore_zh,
|
|
5012
5802
|
en=(
|
|
5013
|
-
f"
|
|
5014
|
-
f"
|
|
5015
|
-
f"
|
|
5803
|
+
f"Quest `{quest_id}` is ready, and I’m starting now. 🚀\n"
|
|
5804
|
+
f"Current focus: {goal_preview}\n"
|
|
5805
|
+
f"I’ll keep the next updates right here."
|
|
5016
5806
|
)
|
|
5017
5807
|
+ restore_en,
|
|
5018
5808
|
)
|
|
@@ -5290,6 +6080,121 @@ class DaemonApp:
|
|
|
5290
6080
|
resolved = dict(config) if isinstance(config, dict) else {}
|
|
5291
6081
|
return lingzhu_health_payload(resolved, chat_completions_enabled=True)
|
|
5292
6082
|
|
|
6083
|
+
@staticmethod
|
|
6084
|
+
def _weixin_replay_limit(config: dict[str, Any]) -> int:
|
|
6085
|
+
try:
|
|
6086
|
+
limit = int(config.get("stale_replay_latest_limit") or _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT)
|
|
6087
|
+
except (TypeError, ValueError):
|
|
6088
|
+
limit = _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT
|
|
6089
|
+
return max(0, min(limit, 20))
|
|
6090
|
+
|
|
6091
|
+
@staticmethod
|
|
6092
|
+
def _weixin_replay_interval_seconds(config: dict[str, Any]) -> float:
|
|
6093
|
+
try:
|
|
6094
|
+
interval = float(
|
|
6095
|
+
config.get("stale_replay_interval_seconds") or _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT
|
|
6096
|
+
)
|
|
6097
|
+
except (TypeError, ValueError):
|
|
6098
|
+
interval = _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT
|
|
6099
|
+
return max(0.0, min(interval, 30.0))
|
|
6100
|
+
|
|
6101
|
+
def _weixin_connector_root(self) -> Path:
|
|
6102
|
+
return self.home / "logs" / "connectors" / "weixin"
|
|
6103
|
+
|
|
6104
|
+
def _weixin_queued_outbox_records(self, conversation_id: str) -> list[dict[str, Any]]:
|
|
6105
|
+
outbox_path = self._weixin_connector_root() / "outbox.jsonl"
|
|
6106
|
+
target_key = conversation_identity_key(conversation_id)
|
|
6107
|
+
items: list[dict[str, Any]] = []
|
|
6108
|
+
for record in read_jsonl(outbox_path):
|
|
6109
|
+
if not isinstance(record, dict):
|
|
6110
|
+
continue
|
|
6111
|
+
current_conversation_id = str(record.get("conversation_id") or "").strip()
|
|
6112
|
+
if not current_conversation_id:
|
|
6113
|
+
continue
|
|
6114
|
+
if conversation_identity_key(current_conversation_id) != target_key:
|
|
6115
|
+
continue
|
|
6116
|
+
delivery = record.get("delivery") if isinstance(record.get("delivery"), dict) else {}
|
|
6117
|
+
if not bool(delivery.get("queued", False)):
|
|
6118
|
+
continue
|
|
6119
|
+
attachments = [dict(item) for item in (record.get("attachments") or []) if isinstance(item, dict)]
|
|
6120
|
+
if not str(record.get("text") or "").strip() and not attachments:
|
|
6121
|
+
continue
|
|
6122
|
+
items.append(
|
|
6123
|
+
{
|
|
6124
|
+
**dict(record),
|
|
6125
|
+
"attachments": attachments,
|
|
6126
|
+
}
|
|
6127
|
+
)
|
|
6128
|
+
return items
|
|
6129
|
+
|
|
6130
|
+
def _weixin_pending_outbox_records(self, conversation_id: str, *, user_id: str) -> tuple[list[dict[str, Any]], int]:
|
|
6131
|
+
records = self._weixin_queued_outbox_records(conversation_id)
|
|
6132
|
+
baseline = get_weixin_replay_cursor(self._weixin_connector_root(), user_id)
|
|
6133
|
+
applied_baseline = max(0, min(int(baseline), len(records)))
|
|
6134
|
+
return records[applied_baseline:], len(records)
|
|
6135
|
+
|
|
6136
|
+
@staticmethod
|
|
6137
|
+
def _weixin_replay_payload(record: dict[str, Any]) -> dict[str, Any]:
|
|
6138
|
+
attachments = [dict(item) for item in (record.get("attachments") or []) if isinstance(item, dict)]
|
|
6139
|
+
surface_actions = [dict(item) for item in (record.get("surface_actions") or []) if isinstance(item, dict)]
|
|
6140
|
+
connector_hints = dict(record.get("connector_hints")) if isinstance(record.get("connector_hints"), dict) else {}
|
|
6141
|
+
return {
|
|
6142
|
+
"conversation_id": record.get("conversation_id"),
|
|
6143
|
+
"reply_to_message_id": record.get("reply_to_message_id"),
|
|
6144
|
+
"kind": record.get("kind"),
|
|
6145
|
+
"message": str(record.get("text") or ""),
|
|
6146
|
+
"attachments": attachments,
|
|
6147
|
+
"surface_actions": surface_actions,
|
|
6148
|
+
"connector_hints": connector_hints,
|
|
6149
|
+
"quest_id": record.get("quest_id"),
|
|
6150
|
+
"quest_root": record.get("quest_root"),
|
|
6151
|
+
"importance": record.get("importance"),
|
|
6152
|
+
"response_phase": record.get("response_phase"),
|
|
6153
|
+
}
|
|
6154
|
+
|
|
6155
|
+
def _maybe_replay_weixin_pending_outbox(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
6156
|
+
conversation_id = str(message.get("conversation_id") or "").strip()
|
|
6157
|
+
sender_id = str(message.get("sender_id") or message.get("direct_id") or "").strip()
|
|
6158
|
+
if not conversation_id or not sender_id:
|
|
6159
|
+
return None
|
|
6160
|
+
config = self.connectors_config.get("weixin", {})
|
|
6161
|
+
resolved = dict(config) if isinstance(config, dict) else {}
|
|
6162
|
+
limit = self._weixin_replay_limit(resolved)
|
|
6163
|
+
if limit <= 0:
|
|
6164
|
+
return {"replayed_count": 0, "dropped_count": 0, "total_pending": 0}
|
|
6165
|
+
pending_records, total_count = self._weixin_pending_outbox_records(conversation_id, user_id=sender_id)
|
|
6166
|
+
if not pending_records:
|
|
6167
|
+
return {"replayed_count": 0, "dropped_count": 0, "total_pending": 0}
|
|
6168
|
+
selected_records = pending_records[-limit:]
|
|
6169
|
+
dropped_count = max(0, len(pending_records) - len(selected_records))
|
|
6170
|
+
update_weixin_replay_cursor(
|
|
6171
|
+
self._weixin_connector_root(),
|
|
6172
|
+
user_id=sender_id,
|
|
6173
|
+
queued_replay_cursor=total_count,
|
|
6174
|
+
last_replay_trigger_message_id=str(message.get("message_id") or "").strip() or None,
|
|
6175
|
+
last_replayed_count=len(selected_records),
|
|
6176
|
+
last_replay_dropped_count=dropped_count,
|
|
6177
|
+
)
|
|
6178
|
+
channel = self._channel_with_bindings("weixin")
|
|
6179
|
+
interval_seconds = self._weixin_replay_interval_seconds(resolved)
|
|
6180
|
+
for index, record in enumerate(selected_records):
|
|
6181
|
+
channel.send(self._weixin_replay_payload(record))
|
|
6182
|
+
if index + 1 < len(selected_records) and interval_seconds > 0:
|
|
6183
|
+
time.sleep(interval_seconds)
|
|
6184
|
+
self.logger.log(
|
|
6185
|
+
"info",
|
|
6186
|
+
"connector.weixin_replay",
|
|
6187
|
+
conversation_id=conversation_id,
|
|
6188
|
+
replayed_count=len(selected_records),
|
|
6189
|
+
dropped_count=dropped_count,
|
|
6190
|
+
trigger_message_id=str(message.get("message_id") or "").strip() or None,
|
|
6191
|
+
)
|
|
6192
|
+
return {
|
|
6193
|
+
"replayed_count": len(selected_records),
|
|
6194
|
+
"dropped_count": dropped_count,
|
|
6195
|
+
"total_pending": len(pending_records),
|
|
6196
|
+
}
|
|
6197
|
+
|
|
5293
6198
|
def _lingzhu_state_path(self) -> Path:
|
|
5294
6199
|
return self.home / "logs" / "connectors" / "lingzhu" / "metis_state.json"
|
|
5295
6200
|
|
|
@@ -5449,6 +6354,13 @@ class DaemonApp:
|
|
|
5449
6354
|
return emitted
|
|
5450
6355
|
|
|
5451
6356
|
def _lingzhu_short_status_text(self, quest_id: str | None) -> str:
|
|
6357
|
+
normalized_quest_id = str(quest_id or "").strip()
|
|
6358
|
+
if normalized_quest_id:
|
|
6359
|
+
snapshot = self.quest_service.snapshot_fast(normalized_quest_id)
|
|
6360
|
+
runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
|
|
6361
|
+
if runtime_status in {"running", "active"}:
|
|
6362
|
+
return "进行中"
|
|
6363
|
+
return self._lingzhu_status_hint_text(normalized_quest_id)
|
|
5452
6364
|
return self._lingzhu_status_hint_text(quest_id)
|
|
5453
6365
|
|
|
5454
6366
|
@staticmethod
|
|
@@ -5717,6 +6629,33 @@ class DaemonApp:
|
|
|
5717
6629
|
handler.wfile.write(b"\n")
|
|
5718
6630
|
handler.wfile.flush()
|
|
5719
6631
|
|
|
6632
|
+
@staticmethod
|
|
6633
|
+
def _write_handler_response(
|
|
6634
|
+
handler: BaseHTTPRequestHandler,
|
|
6635
|
+
*,
|
|
6636
|
+
code: int,
|
|
6637
|
+
content: bytes,
|
|
6638
|
+
content_type: str | None = None,
|
|
6639
|
+
extra_headers: dict[str, str] | None = None,
|
|
6640
|
+
) -> bool:
|
|
6641
|
+
try:
|
|
6642
|
+
handler.send_response(code)
|
|
6643
|
+
if content_type:
|
|
6644
|
+
handler.send_header("Content-Type", content_type)
|
|
6645
|
+
handler.send_header("Content-Length", str(len(content)))
|
|
6646
|
+
for key, value in (extra_headers or {}).items():
|
|
6647
|
+
handler.send_header(key, value)
|
|
6648
|
+
handler.end_headers()
|
|
6649
|
+
if content:
|
|
6650
|
+
handler.wfile.write(content)
|
|
6651
|
+
return True
|
|
6652
|
+
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
6653
|
+
try:
|
|
6654
|
+
handler.close_connection = True
|
|
6655
|
+
except Exception:
|
|
6656
|
+
pass
|
|
6657
|
+
return False
|
|
6658
|
+
|
|
5720
6659
|
@staticmethod
|
|
5721
6660
|
def _parse_bash_log_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
|
|
5722
6661
|
stripped = raw_line.strip()
|
|
@@ -5730,6 +6669,19 @@ class DaemonApp:
|
|
|
5730
6669
|
return None
|
|
5731
6670
|
return payload
|
|
5732
6671
|
|
|
6672
|
+
@staticmethod
|
|
6673
|
+
def _parse_quest_event_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
|
|
6674
|
+
stripped = raw_line.strip()
|
|
6675
|
+
if not stripped:
|
|
6676
|
+
return None
|
|
6677
|
+
try:
|
|
6678
|
+
payload = json.loads(stripped.decode("utf-8", errors="replace"))
|
|
6679
|
+
except json.JSONDecodeError:
|
|
6680
|
+
return None
|
|
6681
|
+
if not isinstance(payload, dict):
|
|
6682
|
+
return None
|
|
6683
|
+
return payload
|
|
6684
|
+
|
|
5733
6685
|
@classmethod
|
|
5734
6686
|
def _read_bash_log_delta(
|
|
5735
6687
|
cls,
|
|
@@ -5773,6 +6725,42 @@ class DaemonApp:
|
|
|
5773
6725
|
|
|
5774
6726
|
return fresh_entries, next_offset, remainder
|
|
5775
6727
|
|
|
6728
|
+
@classmethod
|
|
6729
|
+
def _read_quest_event_delta(
|
|
6730
|
+
cls,
|
|
6731
|
+
event_path: Path,
|
|
6732
|
+
*,
|
|
6733
|
+
offset: int,
|
|
6734
|
+
pending: bytes,
|
|
6735
|
+
) -> tuple[list[dict[str, Any]], int, bytes]:
|
|
6736
|
+
if not event_path.exists():
|
|
6737
|
+
return [], 0, pending
|
|
6738
|
+
|
|
6739
|
+
current_size = event_path.stat().st_size
|
|
6740
|
+
safe_offset = max(0, min(offset, current_size))
|
|
6741
|
+
with event_path.open("rb") as handle:
|
|
6742
|
+
handle.seek(safe_offset)
|
|
6743
|
+
chunk = handle.read()
|
|
6744
|
+
next_offset = handle.tell()
|
|
6745
|
+
|
|
6746
|
+
if not chunk:
|
|
6747
|
+
return [], next_offset, pending
|
|
6748
|
+
|
|
6749
|
+
payload = pending + chunk
|
|
6750
|
+
lines = payload.split(b"\n")
|
|
6751
|
+
remainder = b""
|
|
6752
|
+
if payload and not payload.endswith(b"\n"):
|
|
6753
|
+
remainder = lines.pop()
|
|
6754
|
+
|
|
6755
|
+
fresh_entries: list[dict[str, Any]] = []
|
|
6756
|
+
for raw_line in lines:
|
|
6757
|
+
entry = cls._parse_quest_event_jsonl_line(raw_line.rstrip(b"\r"))
|
|
6758
|
+
if not entry:
|
|
6759
|
+
continue
|
|
6760
|
+
fresh_entries.append(entry)
|
|
6761
|
+
|
|
6762
|
+
return fresh_entries, next_offset, remainder
|
|
6763
|
+
|
|
5776
6764
|
def stream_quest_events(
|
|
5777
6765
|
self,
|
|
5778
6766
|
handler: BaseHTTPRequestHandler,
|
|
@@ -5780,6 +6768,7 @@ class DaemonApp:
|
|
|
5780
6768
|
quest_id: str,
|
|
5781
6769
|
path: str,
|
|
5782
6770
|
headers: dict[str, str] | None = None,
|
|
6771
|
+
extra_headers: dict[str, str] | None = None,
|
|
5783
6772
|
) -> None:
|
|
5784
6773
|
query = self.handlers.parse_query(path)
|
|
5785
6774
|
after = int((query.get("after") or ["0"])[0] or "0")
|
|
@@ -5789,46 +6778,132 @@ class DaemonApp:
|
|
|
5789
6778
|
last_event_id = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
|
|
5790
6779
|
current_cursor = max(after, int(last_event_id)) if last_event_id.isdigit() else after
|
|
5791
6780
|
heartbeat_at = time.monotonic()
|
|
5792
|
-
idle_sleep_seconds = 0.
|
|
6781
|
+
idle_sleep_seconds = 0.08
|
|
6782
|
+
force_fetch = True
|
|
6783
|
+
event_path = self.quest_service._quest_root(quest_id) / ".ds" / "events.jsonl"
|
|
6784
|
+
previous_event_state = None
|
|
6785
|
+
cached_tail = self.quest_service.jsonl_tail_cache_entry(event_path) or {}
|
|
6786
|
+
cached_tail_state = cached_tail.get("state") if isinstance(cached_tail.get("state"), (list, tuple)) else None
|
|
6787
|
+
cached_tail_total = int(cached_tail.get("total") or 0) if isinstance(cached_tail, dict) else 0
|
|
6788
|
+
event_offset = int(cached_tail_state[2]) if cached_tail_state and cached_tail_total == current_cursor else 0
|
|
6789
|
+
pending_bytes = b""
|
|
5793
6790
|
|
|
5794
6791
|
handler.send_response(200)
|
|
5795
6792
|
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
5796
6793
|
handler.send_header("Cache-Control", "no-cache, no-transform")
|
|
5797
6794
|
handler.send_header("Connection", "keep-alive")
|
|
5798
6795
|
handler.send_header("X-Accel-Buffering", "no")
|
|
6796
|
+
for key, value in (extra_headers or {}).items():
|
|
6797
|
+
handler.send_header(key, value)
|
|
5799
6798
|
handler.end_headers()
|
|
5800
6799
|
handler.wfile.write(b"retry: 1000\n\n")
|
|
5801
6800
|
handler.wfile.flush()
|
|
5802
6801
|
|
|
5803
6802
|
try:
|
|
5804
6803
|
while True:
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
6804
|
+
current_event_state = self.quest_service._path_state(event_path)
|
|
6805
|
+
if force_fetch or current_event_state != previous_event_state:
|
|
6806
|
+
used_incremental_delta = False
|
|
6807
|
+
delta_base_state = previous_event_state or cached_tail_state
|
|
6808
|
+
can_read_incremental = (
|
|
6809
|
+
current_cursor > 0
|
|
6810
|
+
and event_offset > 0
|
|
6811
|
+
and current_event_state is not None
|
|
6812
|
+
and delta_base_state is not None
|
|
6813
|
+
and tuple(delta_base_state)[0] == current_event_state[0]
|
|
6814
|
+
and current_event_state[2] >= int(tuple(delta_base_state)[2])
|
|
6815
|
+
)
|
|
6816
|
+
if can_read_incremental:
|
|
6817
|
+
fresh_events, event_offset, pending_bytes = self._read_quest_event_delta(
|
|
6818
|
+
event_path,
|
|
6819
|
+
offset=event_offset,
|
|
6820
|
+
pending=pending_bytes,
|
|
6821
|
+
)
|
|
6822
|
+
previous_event_state = current_event_state
|
|
6823
|
+
if fresh_events:
|
|
6824
|
+
for event in fresh_events:
|
|
6825
|
+
current_cursor += 1
|
|
6826
|
+
enriched_event = {
|
|
6827
|
+
"cursor": current_cursor,
|
|
6828
|
+
"event_id": event.get("event_id") or f"evt-{quest_id}-{current_cursor}",
|
|
6829
|
+
**event,
|
|
6830
|
+
}
|
|
6831
|
+
update = build_session_update(
|
|
6832
|
+
enriched_event,
|
|
6833
|
+
quest_id=quest_id,
|
|
6834
|
+
cursor=current_cursor,
|
|
6835
|
+
session_id=session_id,
|
|
6836
|
+
)
|
|
6837
|
+
self._write_sse_event(
|
|
6838
|
+
handler,
|
|
6839
|
+
event="acp_update",
|
|
6840
|
+
data=update,
|
|
6841
|
+
event_id=str(current_cursor),
|
|
6842
|
+
)
|
|
6843
|
+
self._write_sse_event(
|
|
6844
|
+
handler,
|
|
6845
|
+
event="cursor",
|
|
6846
|
+
data={"cursor": current_cursor, "quest_id": quest_id},
|
|
6847
|
+
)
|
|
6848
|
+
heartbeat_at = time.monotonic()
|
|
6849
|
+
used_incremental_delta = True
|
|
6850
|
+
force_fetch = False
|
|
6851
|
+
idle_sleep_seconds = 0.03
|
|
6852
|
+
else:
|
|
6853
|
+
force_fetch = False
|
|
6854
|
+
now = time.monotonic()
|
|
6855
|
+
if now - heartbeat_at >= 10:
|
|
6856
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
6857
|
+
handler.wfile.flush()
|
|
6858
|
+
heartbeat_at = now
|
|
6859
|
+
if used_incremental_delta:
|
|
6860
|
+
time.sleep(idle_sleep_seconds)
|
|
6861
|
+
continue
|
|
6862
|
+
stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
|
|
6863
|
+
payload = self.handlers.quest_events(quest_id, path=stream_path)
|
|
6864
|
+
previous_event_state = current_event_state
|
|
6865
|
+
updates = payload.get("acp_updates") or []
|
|
6866
|
+
if updates:
|
|
6867
|
+
for update in updates:
|
|
6868
|
+
update_cursor = str(((update.get("params") or {}).get("update") or {}).get("cursor") or "")
|
|
6869
|
+
self._write_sse_event(
|
|
6870
|
+
handler,
|
|
6871
|
+
event="acp_update",
|
|
6872
|
+
data=update,
|
|
6873
|
+
event_id=update_cursor or None,
|
|
6874
|
+
)
|
|
6875
|
+
current_cursor = int(payload.get("cursor") or current_cursor)
|
|
6876
|
+
if current_event_state is not None and not payload.get("has_more"):
|
|
6877
|
+
event_offset = int(current_event_state[2])
|
|
6878
|
+
pending_bytes = b""
|
|
6879
|
+
cached_tail_state = current_event_state
|
|
6880
|
+
cached_tail_total = current_cursor
|
|
5811
6881
|
self._write_sse_event(
|
|
5812
6882
|
handler,
|
|
5813
|
-
event="
|
|
5814
|
-
data=
|
|
5815
|
-
event_id=update_cursor or None,
|
|
6883
|
+
event="cursor",
|
|
6884
|
+
data={"cursor": current_cursor, "quest_id": quest_id},
|
|
5816
6885
|
)
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
6886
|
+
heartbeat_at = time.monotonic()
|
|
6887
|
+
force_fetch = bool(payload.get("has_more"))
|
|
6888
|
+
idle_sleep_seconds = 0.03 if force_fetch else 0.08
|
|
6889
|
+
else:
|
|
6890
|
+
if current_event_state is not None:
|
|
6891
|
+
event_offset = int(current_event_state[2])
|
|
6892
|
+
cached_tail_state = current_event_state
|
|
6893
|
+
force_fetch = False
|
|
6894
|
+
now = time.monotonic()
|
|
6895
|
+
if now - heartbeat_at >= 10:
|
|
6896
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
6897
|
+
handler.wfile.flush()
|
|
6898
|
+
heartbeat_at = now
|
|
6899
|
+
idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
|
|
5825
6900
|
else:
|
|
5826
6901
|
now = time.monotonic()
|
|
5827
6902
|
if now - heartbeat_at >= 10:
|
|
5828
6903
|
handler.wfile.write(b": keep-alive\n\n")
|
|
5829
6904
|
handler.wfile.flush()
|
|
5830
6905
|
heartbeat_at = now
|
|
5831
|
-
idle_sleep_seconds = min(
|
|
6906
|
+
idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
|
|
5832
6907
|
time.sleep(idle_sleep_seconds)
|
|
5833
6908
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
5834
6909
|
return
|
|
@@ -5839,6 +6914,7 @@ class DaemonApp:
|
|
|
5839
6914
|
*,
|
|
5840
6915
|
quest_id: str,
|
|
5841
6916
|
path: str,
|
|
6917
|
+
extra_headers: dict[str, str] | None = None,
|
|
5842
6918
|
) -> None:
|
|
5843
6919
|
quest_root = self.quest_service._quest_root(quest_id)
|
|
5844
6920
|
query = self.handlers.parse_query(path)
|
|
@@ -5875,55 +6951,83 @@ class DaemonApp:
|
|
|
5875
6951
|
handler.send_header("Cache-Control", "no-cache, no-transform")
|
|
5876
6952
|
handler.send_header("Connection", "keep-alive")
|
|
5877
6953
|
handler.send_header("X-Accel-Buffering", "no")
|
|
6954
|
+
for key, value in (extra_headers or {}).items():
|
|
6955
|
+
handler.send_header(key, value)
|
|
5878
6956
|
handler.end_headers()
|
|
5879
6957
|
handler.wfile.write(b"retry: 1000\n\n")
|
|
5880
6958
|
handler.wfile.flush()
|
|
5881
6959
|
|
|
5882
6960
|
previous_snapshot: dict[str, dict[str, object]] = {}
|
|
5883
6961
|
heartbeat_at = time.monotonic()
|
|
6962
|
+
summary_path = self.bash_exec_service.summary_path(quest_root)
|
|
6963
|
+
index_path = self.bash_exec_service.index_path(quest_root)
|
|
6964
|
+
previous_summary_state = None
|
|
6965
|
+
previous_index_state = None
|
|
6966
|
+
has_active_sessions = False
|
|
6967
|
+
last_full_refresh_at = 0.0
|
|
5884
6968
|
try:
|
|
5885
6969
|
while True:
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
6970
|
+
current_summary_state = self.quest_service._path_state(summary_path)
|
|
6971
|
+
current_index_state = self.quest_service._path_state(index_path)
|
|
6972
|
+
should_refresh = (
|
|
6973
|
+
not previous_snapshot
|
|
6974
|
+
or current_summary_state != previous_summary_state
|
|
6975
|
+
or current_index_state != previous_index_state
|
|
6976
|
+
or (has_active_sessions and time.monotonic() - last_full_refresh_at >= 3.0)
|
|
6977
|
+
)
|
|
6978
|
+
if should_refresh:
|
|
6979
|
+
sessions = list_payload()
|
|
6980
|
+
current_snapshot = {
|
|
6981
|
+
str(item.get("bash_id") or ""): item
|
|
6982
|
+
for item in sessions
|
|
6983
|
+
if item.get("bash_id")
|
|
6984
|
+
}
|
|
6985
|
+
has_active_sessions = any(
|
|
6986
|
+
str(item.get("status") or "").strip().lower() in {"running", "terminating"}
|
|
6987
|
+
for item in current_snapshot.values()
|
|
5897
6988
|
)
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
session
|
|
5903
|
-
for bash_id, session in current_snapshot.items()
|
|
5904
|
-
if previous_snapshot.get(bash_id) != session
|
|
5905
|
-
]
|
|
5906
|
-
removed = set(previous_snapshot) - set(current_snapshot)
|
|
5907
|
-
for session in changed:
|
|
5908
|
-
self._write_sse_event(
|
|
5909
|
-
handler,
|
|
5910
|
-
event="session",
|
|
5911
|
-
data={"session": session},
|
|
5912
|
-
)
|
|
5913
|
-
for bash_id in removed:
|
|
6989
|
+
previous_summary_state = self.quest_service._path_state(summary_path)
|
|
6990
|
+
previous_index_state = self.quest_service._path_state(index_path)
|
|
6991
|
+
last_full_refresh_at = time.monotonic()
|
|
6992
|
+
if not previous_snapshot:
|
|
5914
6993
|
self._write_sse_event(
|
|
5915
6994
|
handler,
|
|
5916
|
-
event="
|
|
5917
|
-
data={"
|
|
6995
|
+
event="snapshot",
|
|
6996
|
+
data={"sessions": sessions},
|
|
5918
6997
|
)
|
|
5919
|
-
if changed or removed:
|
|
5920
6998
|
previous_snapshot = current_snapshot
|
|
5921
6999
|
heartbeat_at = time.monotonic()
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
7000
|
+
else:
|
|
7001
|
+
changed = [
|
|
7002
|
+
session
|
|
7003
|
+
for bash_id, session in current_snapshot.items()
|
|
7004
|
+
if previous_snapshot.get(bash_id) != session
|
|
7005
|
+
]
|
|
7006
|
+
removed = set(previous_snapshot) - set(current_snapshot)
|
|
7007
|
+
for session in changed:
|
|
7008
|
+
self._write_sse_event(
|
|
7009
|
+
handler,
|
|
7010
|
+
event="session",
|
|
7011
|
+
data={"session": session},
|
|
7012
|
+
)
|
|
7013
|
+
for bash_id in removed:
|
|
7014
|
+
self._write_sse_event(
|
|
7015
|
+
handler,
|
|
7016
|
+
event="session",
|
|
7017
|
+
data={"session": {"bash_id": bash_id, "status": "terminated"}},
|
|
7018
|
+
)
|
|
7019
|
+
if changed or removed:
|
|
7020
|
+
previous_snapshot = current_snapshot
|
|
7021
|
+
heartbeat_at = time.monotonic()
|
|
7022
|
+
elif time.monotonic() - heartbeat_at >= 10:
|
|
7023
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
7024
|
+
handler.wfile.flush()
|
|
7025
|
+
heartbeat_at = time.monotonic()
|
|
7026
|
+
elif time.monotonic() - heartbeat_at >= 10:
|
|
7027
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
7028
|
+
handler.wfile.flush()
|
|
7029
|
+
heartbeat_at = time.monotonic()
|
|
7030
|
+
time.sleep(0.5 if has_active_sessions else 2.0)
|
|
5927
7031
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
5928
7032
|
return
|
|
5929
7033
|
|
|
@@ -5934,6 +7038,7 @@ class DaemonApp:
|
|
|
5934
7038
|
quest_id: str,
|
|
5935
7039
|
bash_id: str,
|
|
5936
7040
|
headers: dict[str, str] | None = None,
|
|
7041
|
+
extra_headers: dict[str, str] | None = None,
|
|
5937
7042
|
) -> None:
|
|
5938
7043
|
quest_root = self.quest_service._quest_root(quest_id)
|
|
5939
7044
|
last_event_raw = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
|
|
@@ -5944,6 +7049,8 @@ class DaemonApp:
|
|
|
5944
7049
|
handler.send_header("Cache-Control", "no-cache, no-transform")
|
|
5945
7050
|
handler.send_header("Connection", "keep-alive")
|
|
5946
7051
|
handler.send_header("X-Accel-Buffering", "no")
|
|
7052
|
+
for key, value in (extra_headers or {}).items():
|
|
7053
|
+
handler.send_header(key, value)
|
|
5947
7054
|
handler.end_headers()
|
|
5948
7055
|
handler.wfile.write(b"retry: 1000\n\n")
|
|
5949
7056
|
handler.wfile.flush()
|
|
@@ -6144,35 +7251,84 @@ class DaemonApp:
|
|
|
6144
7251
|
if route_name is None:
|
|
6145
7252
|
self._write_json(404, {"ok": False, "message": "Not Found"})
|
|
6146
7253
|
return
|
|
6147
|
-
|
|
7254
|
+
request_headers = dict(self.headers.items())
|
|
7255
|
+
auth_state = app.browser_auth_state_for_request(self.path, request_headers)
|
|
7256
|
+
auth_headers = app._auth_response_headers(auth_state)
|
|
7257
|
+
if app._route_requires_browser_auth(route_name) and not auth_state.authenticated:
|
|
7258
|
+
self._write_json(
|
|
7259
|
+
401,
|
|
7260
|
+
{
|
|
7261
|
+
"ok": False,
|
|
7262
|
+
"message": "Authentication required.",
|
|
7263
|
+
"auth_required": True,
|
|
7264
|
+
"auth_enabled": True,
|
|
7265
|
+
},
|
|
7266
|
+
extra_headers={
|
|
7267
|
+
**auth_headers,
|
|
7268
|
+
"WWW-Authenticate": f'Bearer realm="{_BROWSER_AUTH_REALM}"',
|
|
7269
|
+
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
|
7270
|
+
},
|
|
7271
|
+
)
|
|
7272
|
+
return
|
|
7273
|
+
if route_name == "quest_events" and app._wants_event_stream(self.path, request_headers):
|
|
6148
7274
|
try:
|
|
6149
|
-
app.stream_quest_events(self, **params, path=self.path, headers=
|
|
7275
|
+
app.stream_quest_events(self, **params, path=self.path, headers=request_headers, extra_headers=auth_headers)
|
|
6150
7276
|
except Exception as exc:
|
|
6151
|
-
|
|
7277
|
+
app.logger.log(
|
|
7278
|
+
"error",
|
|
7279
|
+
"http.stream_quest_events_failed",
|
|
7280
|
+
path=self.path,
|
|
7281
|
+
error=str(exc),
|
|
7282
|
+
)
|
|
7283
|
+
self.close_connection = True
|
|
6152
7284
|
return
|
|
6153
7285
|
if route_name == "bash_sessions_stream":
|
|
6154
7286
|
try:
|
|
6155
|
-
app.stream_bash_sessions(self, **params, path=self.path)
|
|
7287
|
+
app.stream_bash_sessions(self, **params, path=self.path, extra_headers=auth_headers)
|
|
6156
7288
|
except Exception as exc:
|
|
6157
|
-
|
|
7289
|
+
app.logger.log(
|
|
7290
|
+
"error",
|
|
7291
|
+
"http.stream_bash_sessions_failed",
|
|
7292
|
+
path=self.path,
|
|
7293
|
+
error=str(exc),
|
|
7294
|
+
)
|
|
7295
|
+
self.close_connection = True
|
|
6158
7296
|
return
|
|
6159
7297
|
if route_name == "bash_log_stream":
|
|
6160
7298
|
try:
|
|
6161
|
-
app.stream_bash_logs(self, **params, headers=
|
|
7299
|
+
app.stream_bash_logs(self, **params, headers=request_headers, extra_headers=auth_headers)
|
|
6162
7300
|
except Exception as exc:
|
|
6163
|
-
|
|
7301
|
+
app.logger.log(
|
|
7302
|
+
"error",
|
|
7303
|
+
"http.stream_bash_logs_failed",
|
|
7304
|
+
path=self.path,
|
|
7305
|
+
error=str(exc),
|
|
7306
|
+
)
|
|
7307
|
+
self.close_connection = True
|
|
6164
7308
|
return
|
|
6165
7309
|
if route_name == "terminal_stream":
|
|
6166
7310
|
try:
|
|
6167
|
-
app.stream_bash_logs(
|
|
7311
|
+
app.stream_bash_logs(
|
|
7312
|
+
self,
|
|
7313
|
+
quest_id=params["quest_id"],
|
|
7314
|
+
bash_id=params["session_id"],
|
|
7315
|
+
headers=request_headers,
|
|
7316
|
+
extra_headers=auth_headers,
|
|
7317
|
+
)
|
|
6168
7318
|
except Exception as exc:
|
|
6169
|
-
|
|
7319
|
+
app.logger.log(
|
|
7320
|
+
"error",
|
|
7321
|
+
"http.stream_terminal_logs_failed",
|
|
7322
|
+
path=self.path,
|
|
7323
|
+
error=str(exc),
|
|
7324
|
+
)
|
|
7325
|
+
self.close_connection = True
|
|
6170
7326
|
return
|
|
6171
7327
|
if route_name == "lingzhu_sse":
|
|
6172
7328
|
content_length = int(self.headers.get("Content-Length", "0"))
|
|
6173
7329
|
raw_body = self.rfile.read(content_length) if content_length else b""
|
|
6174
7330
|
try:
|
|
6175
|
-
app.stream_lingzhu_sse(self, raw_body=raw_body, headers=
|
|
7331
|
+
app.stream_lingzhu_sse(self, raw_body=raw_body, headers=request_headers)
|
|
6176
7332
|
except Exception as exc:
|
|
6177
7333
|
self._write_json(500, {"ok": False, "message": str(exc)})
|
|
6178
7334
|
return
|
|
@@ -6187,11 +7343,12 @@ class DaemonApp:
|
|
|
6187
7343
|
result = getattr(app.handlers, route_name)
|
|
6188
7344
|
if route_name == "asset":
|
|
6189
7345
|
status, headers, content = result(**params)
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
7346
|
+
app._write_handler_response(
|
|
7347
|
+
self,
|
|
7348
|
+
code=status,
|
|
7349
|
+
content=content,
|
|
7350
|
+
extra_headers=app._merge_response_headers(headers, auth_headers),
|
|
7351
|
+
)
|
|
6195
7352
|
return
|
|
6196
7353
|
if route_name in {
|
|
6197
7354
|
"quest_events",
|
|
@@ -6218,7 +7375,7 @@ class DaemonApp:
|
|
|
6218
7375
|
payload = result(**params, path=self.path)
|
|
6219
7376
|
elif method == "GET":
|
|
6220
7377
|
payload = result(**params) if params else result()
|
|
6221
|
-
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"}:
|
|
7378
|
+
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", "auth_login", "auth_rotate"}:
|
|
6222
7379
|
payload = result(**params, body=body)
|
|
6223
7380
|
elif route_name == "config_validate":
|
|
6224
7381
|
payload = result(body)
|
|
@@ -6231,33 +7388,43 @@ class DaemonApp:
|
|
|
6231
7388
|
else:
|
|
6232
7389
|
payload = result(**params) if params else result()
|
|
6233
7390
|
except Exception as exc:
|
|
6234
|
-
self._write_json(500, {"ok": False, "message": str(exc)})
|
|
7391
|
+
self._write_json(500, {"ok": False, "message": str(exc)}, extra_headers=auth_headers)
|
|
6235
7392
|
return
|
|
6236
7393
|
|
|
6237
7394
|
if isinstance(payload, tuple) and len(payload) == 2:
|
|
6238
7395
|
status, body = payload
|
|
6239
|
-
self._write_json(status, body)
|
|
7396
|
+
self._write_json(status, body, extra_headers=auth_headers)
|
|
6240
7397
|
return
|
|
6241
7398
|
if isinstance(payload, tuple) and len(payload) == 3:
|
|
6242
7399
|
status, headers, content = payload
|
|
6243
|
-
self.send_response(status)
|
|
6244
|
-
for key, value in headers.items():
|
|
6245
|
-
self.send_header(key, value)
|
|
6246
|
-
self.end_headers()
|
|
6247
7400
|
if isinstance(content, str):
|
|
6248
|
-
|
|
7401
|
+
encoded = content.encode("utf-8")
|
|
6249
7402
|
else:
|
|
6250
|
-
|
|
7403
|
+
encoded = content
|
|
7404
|
+
app._write_handler_response(
|
|
7405
|
+
self,
|
|
7406
|
+
code=status,
|
|
7407
|
+
content=encoded,
|
|
7408
|
+
extra_headers=app._merge_response_headers(headers, auth_headers),
|
|
7409
|
+
)
|
|
6251
7410
|
return
|
|
6252
|
-
self._write_json(200, payload)
|
|
6253
|
-
|
|
6254
|
-
def _write_json(
|
|
7411
|
+
self._write_json(200, payload, extra_headers=auth_headers)
|
|
7412
|
+
|
|
7413
|
+
def _write_json(
|
|
7414
|
+
self,
|
|
7415
|
+
code: int,
|
|
7416
|
+
payload: dict | list,
|
|
7417
|
+
*,
|
|
7418
|
+
extra_headers: dict[str, str] | None = None,
|
|
7419
|
+
) -> None:
|
|
6255
7420
|
encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
7421
|
+
app._write_handler_response(
|
|
7422
|
+
self,
|
|
7423
|
+
code=code,
|
|
7424
|
+
content=encoded,
|
|
7425
|
+
content_type="application/json; charset=utf-8",
|
|
7426
|
+
extra_headers=extra_headers,
|
|
7427
|
+
)
|
|
6261
7428
|
|
|
6262
7429
|
server = ThreadingHTTPServer((host, port), RequestHandler)
|
|
6263
7430
|
server.daemon_threads = True
|
|
@@ -6269,6 +7436,8 @@ class DaemonApp:
|
|
|
6269
7436
|
self._start_background_connectors()
|
|
6270
7437
|
self._resume_reconciled_quests()
|
|
6271
7438
|
print(f"DeepScientist daemon listening on http://{host}:{port}")
|
|
7439
|
+
if self.browser_auth_enabled and self.browser_auth_token:
|
|
7440
|
+
print(f"DeepScientist auth token: {self.browser_auth_token}")
|
|
6272
7441
|
try:
|
|
6273
7442
|
server.serve_forever()
|
|
6274
7443
|
except KeyboardInterrupt:
|