@researai/deepscientist 1.5.14 → 1.5.15
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 +8 -0
- package/assets/branding/logo-raster.png +0 -0
- package/bin/ds.js +134 -49
- package/docs/en/00_QUICK_START.md +2 -2
- package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- 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/README.md +6 -0
- package/docs/zh/00_QUICK_START.md +2 -2
- package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- 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/README.md +6 -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/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 +3534 -191
- 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 +79 -64
- package/src/deepscientist/bash_exec/shells.py +87 -0
- package/src/deepscientist/bridges/connectors.py +51 -2
- package/src/deepscientist/config/models.py +6 -3
- package/src/deepscientist/config/service.py +7 -2
- package/src/deepscientist/connector/weixin_support.py +122 -1
- package/src/deepscientist/daemon/api/handlers.py +75 -4
- package/src/deepscientist/daemon/api/router.py +1 -0
- package/src/deepscientist/daemon/app.py +758 -206
- package/src/deepscientist/doctor.py +51 -0
- package/src/deepscientist/file_lock.py +48 -0
- package/src/deepscientist/gitops/diff.py +167 -1
- package/src/deepscientist/mcp/server.py +173 -5
- package/src/deepscientist/process_control.py +161 -0
- package/src/deepscientist/prompts/builder.py +267 -442
- package/src/deepscientist/quest/service.py +2255 -163
- package/src/deepscientist/quest/stage_views.py +171 -0
- package/src/deepscientist/runners/base.py +2 -0
- package/src/deepscientist/runners/codex.py +88 -5
- package/src/deepscientist/runners/runtime_overrides.py +17 -1
- package/src/prompts/contracts/shared_interaction.md +13 -4
- package/src/prompts/system.md +916 -72
- package/src/skills/analysis-campaign/SKILL.md +31 -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 +2 -0
- package/src/skills/decision/SKILL.md +19 -2
- package/src/skills/experiment/SKILL.md +8 -2
- package/src/skills/finalize/SKILL.md +18 -0
- package/src/skills/idea/SKILL.md +78 -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 +1 -1
- package/src/skills/optimize/SKILL.md +1644 -0
- package/src/skills/rebuttal/SKILL.md +2 -1
- package/src/skills/review/SKILL.md +2 -1
- package/src/skills/write/SKILL.md +80 -12
- package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
- package/src/tui/dist/app/AppContainer.js +3 -0
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-DaF9Nge_.js → AiManusChatView-DDjbFnbt.js} +12 -12
- package/src/ui/dist/assets/{AnalysisPlugin-BSVx6dXE.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
- package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
- package/src/ui/dist/assets/{CodeEditorPlugin-DU9G0Tox.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-DoX_fI9l.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-C4FWIXuU.js → DocViewerPlugin-CLChbllo.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-BgfFMgtf.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
- package/src/ui/dist/assets/{ImageViewerPlugin-tcPkfY_x.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-_dKV60Bf.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-Bje0ayoC.js → LabPlugin-DQPg-NrB.js} +2 -2
- package/src/ui/dist/assets/{LatexPlugin-CVsBzAln.js → LatexPlugin-CI05XAV9.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-xjmrqv_8.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-mMM2A8wP.js → MarketplacePlugin-DolE58Q2.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-3kVDSOBo.js → NotebookEditor-7Qm2rSWD.js} +11 -11
- package/src/ui/dist/assets/{NotebookEditor-SoJ8X-MO.js → NotebookEditor-C1kWaxKi.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-DElVuHl9.js → PdfLoader-BfOHw8Zw.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-Bq88XT4G.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
- package/src/ui/dist/assets/{PdfViewerPlugin-CsCXMo9S.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-oUPvy19k.js → SearchPlugin-CjpaiJ3A.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-CRkT9yNy.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
- package/src/ui/dist/assets/{VNCViewer-BgbuvWhR.js → VNCViewer-HAg9mF7M.js} +10 -10
- package/src/ui/dist/assets/{bot-v_RASACv.js → bot-0DYntytV.js} +1 -1
- package/src/ui/dist/assets/{code-5hC9d0VH.js → code-B20Slj_w.js} +1 -1
- package/src/ui/dist/assets/{file-content-D1PxfOrp.js → file-content-DT24KFma.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-DG1oT_Hj.js → file-diff-panel-DK13YPql.js} +1 -1
- package/src/ui/dist/assets/{file-socket-BmdFYQlk.js → file-socket-B4T2o4nR.js} +1 -1
- package/src/ui/dist/assets/{image-Dqe2X2tW.js → image-DSeR_sDS.js} +1 -1
- package/src/ui/dist/assets/{index-RDlNXXx1.js → index-BrFje2Uk.js} +2 -2
- package/src/ui/dist/assets/{index-DVsMKK_y.js → index-BwRJaoTl.js} +1 -1
- package/src/ui/dist/assets/{index-Nt9hS4ck.js → index-D_E4281X.js} +5007 -28514
- package/src/ui/dist/assets/{index-Duvz8Ip0.js → index-DnYB3xb1.js} +12 -12
- package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
- package/src/ui/dist/assets/{monaco-DIXge1CP.js → monaco-LExaAN3Y.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-BBTTQaO-.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
- package/src/ui/dist/assets/{popover-BWlolyxo.js → popover-D3Gg_FoV.js} +1 -1
- package/src/ui/dist/assets/{project-sync-BM5PkFH4.js → project-sync-C_ygLlVU.js} +1 -1
- package/src/ui/dist/assets/{select-D4dAtrA8.js → select-CpAK6uWm.js} +2 -2
- package/src/ui/dist/assets/{sigma-CKbE5jJT.js → sigma-DEccaSgk.js} +1 -1
- package/src/ui/dist/assets/{square-check-big-CZNGMgiB.js → square-check-big-uUfyVsbD.js} +1 -1
- package/src/ui/dist/assets/{trash-DaB37xAz.js → trash-CXvwwSe8.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-C2OmAcWe.js → useCliAccess-Bnop4mgR.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-Dowd1Ij4.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-BGjAhAUq.js → wrap-text-9vbOBpkW.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-dMZQMXzc.js → zoom-out-BgVMmOW4.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
from collections import deque
|
|
5
5
|
import faulthandler
|
|
6
|
+
import hashlib
|
|
6
7
|
import json
|
|
7
8
|
import mimetypes
|
|
8
9
|
import os
|
|
@@ -25,7 +26,7 @@ from .. import __version__
|
|
|
25
26
|
from ..annotations import AnnotationService
|
|
26
27
|
from ..artifact import ArtifactService
|
|
27
28
|
from ..bash_exec import BashExecService
|
|
28
|
-
from ..bash_exec.
|
|
29
|
+
from ..bash_exec.models import TerminalClient
|
|
29
30
|
from ..bridges import register_builtin_connector_bridges
|
|
30
31
|
from ..bridges.connectors import QQConnectorBridge
|
|
31
32
|
from ..channels import QQRelayChannel, get_channel_factory, list_channel_names, register_builtin_channels
|
|
@@ -68,7 +69,7 @@ from ..connector.lingzhu_support import (
|
|
|
68
69
|
lingzhu_verify_auth_header,
|
|
69
70
|
)
|
|
70
71
|
from ..prompts import PromptBuilder
|
|
71
|
-
from ..prompts.builder import STANDARD_SKILLS
|
|
72
|
+
from ..prompts.builder import STANDARD_SKILLS, classify_turn_intent
|
|
72
73
|
from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
|
|
73
74
|
from ..quest import QuestService
|
|
74
75
|
from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
|
|
@@ -79,9 +80,11 @@ from ..team import SingleTeamService
|
|
|
79
80
|
from ..connector.weixin_support import (
|
|
80
81
|
DEFAULT_WEIXIN_BOT_TYPE,
|
|
81
82
|
fetch_weixin_qrcode,
|
|
83
|
+
get_weixin_replay_cursor,
|
|
82
84
|
normalize_weixin_base_url,
|
|
83
85
|
normalize_weixin_cdn_base_url,
|
|
84
86
|
poll_weixin_qrcode_status,
|
|
87
|
+
update_weixin_replay_cursor,
|
|
85
88
|
)
|
|
86
89
|
from .api import ApiHandlers, match_route
|
|
87
90
|
from .sessions import SessionStore
|
|
@@ -138,9 +141,17 @@ _LINGZHU_SHORT_COMMAND_PREFIX_MAP = {
|
|
|
138
141
|
"恢复": "resume",
|
|
139
142
|
}
|
|
140
143
|
_LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新", "最新的"}
|
|
144
|
+
_WEIXIN_STALE_REPLAY_LIMIT_DEFAULT = 5
|
|
145
|
+
_WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT = 2.0
|
|
141
146
|
_LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
|
|
142
147
|
|
|
143
148
|
|
|
149
|
+
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|
150
|
+
if os.name == "nt" and hasattr(subprocess, "CREATE_NO_WINDOW"):
|
|
151
|
+
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW")}
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
|
|
144
155
|
class DaemonApp:
|
|
145
156
|
_MAX_INBOUND_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
146
157
|
|
|
@@ -450,6 +461,14 @@ class DaemonApp:
|
|
|
450
461
|
ensure_dir(faulthandler_path.parent)
|
|
451
462
|
self._faulthandler_stream = open(faulthandler_path, "a", encoding="utf-8")
|
|
452
463
|
faulthandler.enable(file=self._faulthandler_stream)
|
|
464
|
+
dump_signal = getattr(signal, "SIGUSR1", None)
|
|
465
|
+
if dump_signal is not None:
|
|
466
|
+
faulthandler.register(
|
|
467
|
+
dump_signal,
|
|
468
|
+
file=self._faulthandler_stream,
|
|
469
|
+
all_threads=True,
|
|
470
|
+
chain=False,
|
|
471
|
+
)
|
|
453
472
|
except Exception as exc:
|
|
454
473
|
self.logger.log("warning", "daemon.faulthandler_enable_failed", error=str(exc))
|
|
455
474
|
|
|
@@ -716,6 +735,7 @@ class DaemonApp:
|
|
|
716
735
|
timeout=8,
|
|
717
736
|
check=False,
|
|
718
737
|
env=os.environ.copy(),
|
|
738
|
+
**_windows_hidden_subprocess_kwargs(),
|
|
719
739
|
)
|
|
720
740
|
except subprocess.TimeoutExpired as exc:
|
|
721
741
|
raise RuntimeError("DeepScientist update check timed out.") from exc
|
|
@@ -763,6 +783,7 @@ class DaemonApp:
|
|
|
763
783
|
timeout=8,
|
|
764
784
|
check=False,
|
|
765
785
|
env=os.environ.copy(),
|
|
786
|
+
**_windows_hidden_subprocess_kwargs(),
|
|
766
787
|
)
|
|
767
788
|
except subprocess.TimeoutExpired as exc:
|
|
768
789
|
raise RuntimeError("DeepScientist update request timed out.") from exc
|
|
@@ -1290,6 +1311,7 @@ class DaemonApp:
|
|
|
1290
1311
|
client_message_id=client_message_id,
|
|
1291
1312
|
)
|
|
1292
1313
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
1314
|
+
snapshot = self._reconcile_stale_active_turn(quest_id, snapshot=snapshot)
|
|
1293
1315
|
runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip()
|
|
1294
1316
|
auto_resumed = previous_status in {"stopped", "paused", "completed"} and runtime_status not in {"stopped", "paused", "completed"}
|
|
1295
1317
|
if auto_resumed:
|
|
@@ -1302,9 +1324,8 @@ class DaemonApp:
|
|
|
1302
1324
|
summary=f"Quest {quest_id} automatically resumed after a new user message.",
|
|
1303
1325
|
automated=True,
|
|
1304
1326
|
)
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
has_live_turn = bool(turn_state.get("running")) or bool(snapshot.get("active_run_id"))
|
|
1327
|
+
turn_state = self._refresh_turn_worker_state(quest_id)
|
|
1328
|
+
has_live_turn = bool(turn_state.get("running"))
|
|
1308
1329
|
if runtime_status == "running" and has_live_turn:
|
|
1309
1330
|
scheduled = {
|
|
1310
1331
|
"scheduled": True,
|
|
@@ -1474,6 +1495,7 @@ class DaemonApp:
|
|
|
1474
1495
|
return snapshot
|
|
1475
1496
|
|
|
1476
1497
|
def schedule_turn(self, quest_id: str, *, reason: str = "user_message") -> dict:
|
|
1498
|
+
self._refresh_turn_worker_state(quest_id)
|
|
1477
1499
|
with self._turn_lock:
|
|
1478
1500
|
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1479
1501
|
state["pending"] = True
|
|
@@ -1502,6 +1524,69 @@ class DaemonApp:
|
|
|
1502
1524
|
"reason": reason,
|
|
1503
1525
|
}
|
|
1504
1526
|
|
|
1527
|
+
@staticmethod
|
|
1528
|
+
def _turn_worker_is_alive(worker: object) -> bool:
|
|
1529
|
+
return isinstance(worker, threading.Thread) and worker.is_alive()
|
|
1530
|
+
|
|
1531
|
+
def _refresh_turn_worker_state(self, quest_id: str) -> dict[str, object]:
|
|
1532
|
+
with self._turn_lock:
|
|
1533
|
+
state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
|
|
1534
|
+
if bool(state.get("running")) and not self._turn_worker_is_alive(state.get("worker")):
|
|
1535
|
+
state["running"] = False
|
|
1536
|
+
state.pop("worker", None)
|
|
1537
|
+
return dict(state)
|
|
1538
|
+
|
|
1539
|
+
def _reconcile_stale_active_turn(self, quest_id: str, *, snapshot: dict | None = None) -> dict:
|
|
1540
|
+
snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
|
|
1541
|
+
active_run_id = str(snapshot.get("active_run_id") or "").strip()
|
|
1542
|
+
if not active_run_id:
|
|
1543
|
+
self._refresh_turn_worker_state(quest_id)
|
|
1544
|
+
return snapshot
|
|
1545
|
+
turn_state = self._refresh_turn_worker_state(quest_id)
|
|
1546
|
+
if turn_state.get("running"):
|
|
1547
|
+
return snapshot
|
|
1548
|
+
|
|
1549
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
1550
|
+
result_payload = read_json(quest_root / ".ds" / "runs" / active_run_id / "result.json", {})
|
|
1551
|
+
completed_at = str(result_payload.get("completed_at") or "").strip() if isinstance(result_payload, dict) else ""
|
|
1552
|
+
exit_code = result_payload.get("exit_code") if isinstance(result_payload, dict) else None
|
|
1553
|
+
previous_status = (
|
|
1554
|
+
str(snapshot.get("runtime_status") or snapshot.get("status") or snapshot.get("display_status") or "running").strip()
|
|
1555
|
+
or "running"
|
|
1556
|
+
)
|
|
1557
|
+
normalized_status = "active" if previous_status == "running" else previous_status
|
|
1558
|
+
summary = (
|
|
1559
|
+
f"Cleared stale active turn state for run `{active_run_id}` after no live worker was found."
|
|
1560
|
+
if not completed_at
|
|
1561
|
+
else f"Cleared stale active turn state for completed run `{active_run_id}`."
|
|
1562
|
+
)
|
|
1563
|
+
append_jsonl(
|
|
1564
|
+
quest_root / ".ds" / "events.jsonl",
|
|
1565
|
+
{
|
|
1566
|
+
"event_id": generate_id("evt"),
|
|
1567
|
+
"type": "quest.turn_state_reconciled",
|
|
1568
|
+
"quest_id": quest_id,
|
|
1569
|
+
"abandoned_run_id": active_run_id,
|
|
1570
|
+
"previous_status": previous_status,
|
|
1571
|
+
"status": normalized_status,
|
|
1572
|
+
"completed_at": completed_at or None,
|
|
1573
|
+
"exit_code": exit_code if isinstance(exit_code, int) else None,
|
|
1574
|
+
"summary": summary,
|
|
1575
|
+
"created_at": utc_now(),
|
|
1576
|
+
},
|
|
1577
|
+
)
|
|
1578
|
+
self.logger.log(
|
|
1579
|
+
"warning",
|
|
1580
|
+
"quest.turn_state_reconciled",
|
|
1581
|
+
quest_id=quest_id,
|
|
1582
|
+
abandoned_run_id=active_run_id,
|
|
1583
|
+
previous_status=previous_status,
|
|
1584
|
+
status=normalized_status,
|
|
1585
|
+
completed_at=completed_at or None,
|
|
1586
|
+
exit_code=exit_code if isinstance(exit_code, int) else None,
|
|
1587
|
+
)
|
|
1588
|
+
return self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
1589
|
+
|
|
1505
1590
|
def control_quest(self, quest_id: str, *, action: str, source: str = "local") -> dict:
|
|
1506
1591
|
normalized_action = str(action or "").strip().lower()
|
|
1507
1592
|
if normalized_action == "pause":
|
|
@@ -1544,7 +1629,7 @@ class DaemonApp:
|
|
|
1544
1629
|
reason=f"quest_{action}",
|
|
1545
1630
|
user_id=source,
|
|
1546
1631
|
)
|
|
1547
|
-
if action == "stop":
|
|
1632
|
+
if action == "stop" and source == "local-admin":
|
|
1548
1633
|
cancel_reason = "cancelled_by_daemon_shutdown" if source == "local-admin" else "cancelled_by_stop"
|
|
1549
1634
|
cancelled_pending = self.quest_service.cancel_pending_user_messages(
|
|
1550
1635
|
quest_id,
|
|
@@ -1640,6 +1725,23 @@ class DaemonApp:
|
|
|
1640
1725
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
1641
1726
|
next_status = "running" if snapshot.get("status") == "running" else "active"
|
|
1642
1727
|
snapshot = self.quest_service.set_status(quest_id, next_status)
|
|
1728
|
+
recovery_abandoned_run_id = None
|
|
1729
|
+
recovery_summary = None
|
|
1730
|
+
if source.startswith("auto:daemon-recovery"):
|
|
1731
|
+
recent_events = self.quest_service.events(quest_id)["events"]
|
|
1732
|
+
for item in reversed(recent_events[-20:]):
|
|
1733
|
+
if str(item.get("type") or "").strip() != "quest.runtime_reconciled":
|
|
1734
|
+
continue
|
|
1735
|
+
recovery_abandoned_run_id = str(item.get("abandoned_run_id") or "").strip() or None
|
|
1736
|
+
recovery_summary = str(item.get("summary") or "").strip() or None
|
|
1737
|
+
break
|
|
1738
|
+
self.quest_service.update_runtime_state(
|
|
1739
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
1740
|
+
last_resume_source=source,
|
|
1741
|
+
last_resume_at=utc_now(),
|
|
1742
|
+
last_recovery_abandoned_run_id=recovery_abandoned_run_id,
|
|
1743
|
+
last_recovery_summary=recovery_summary,
|
|
1744
|
+
)
|
|
1643
1745
|
summary = f"Quest {quest_id} resumed."
|
|
1644
1746
|
event = self._append_control_event(
|
|
1645
1747
|
quest_id,
|
|
@@ -1867,7 +1969,16 @@ class DaemonApp:
|
|
|
1867
1969
|
state.pop("worker", None)
|
|
1868
1970
|
return
|
|
1869
1971
|
state["pending"] = False
|
|
1870
|
-
|
|
1972
|
+
try:
|
|
1973
|
+
self._run_quest_turn(quest_id)
|
|
1974
|
+
except Exception as exc:
|
|
1975
|
+
self.logger.log(
|
|
1976
|
+
"error",
|
|
1977
|
+
"daemon.turn_worker_crashed",
|
|
1978
|
+
quest_id=quest_id,
|
|
1979
|
+
error=str(exc),
|
|
1980
|
+
traceback=traceback.format_exc(),
|
|
1981
|
+
)
|
|
1871
1982
|
|
|
1872
1983
|
def _run_quest_turn(self, quest_id: str) -> None:
|
|
1873
1984
|
with self._turn_lock:
|
|
@@ -1885,7 +1996,9 @@ class DaemonApp:
|
|
|
1885
1996
|
|
|
1886
1997
|
runner_name = self._runner_name_for(snapshot)
|
|
1887
1998
|
runner_cfg = self.runners_config.get(runner_name, {})
|
|
1888
|
-
|
|
1999
|
+
turn_intent = self._turn_intent_for(latest_user_message, turn_reason=turn_reason)
|
|
2000
|
+
turn_mode = self._turn_mode_for(snapshot, latest_user_message, turn_reason=turn_reason)
|
|
2001
|
+
skill_id = self._turn_skill_for(snapshot, latest_user_message, turn_reason=turn_reason, turn_mode=turn_mode)
|
|
1889
2002
|
run_id = generate_id("run")
|
|
1890
2003
|
model = str(runner_cfg.get("model", "gpt-5.4"))
|
|
1891
2004
|
run_message = ""
|
|
@@ -1982,6 +2095,8 @@ class DaemonApp:
|
|
|
1982
2095
|
approval_policy=str(runner_cfg.get("approval_policy", "on-request")),
|
|
1983
2096
|
sandbox_mode=str(runner_cfg.get("sandbox_mode", "workspace-write")),
|
|
1984
2097
|
turn_reason=turn_reason,
|
|
2098
|
+
turn_intent=turn_intent,
|
|
2099
|
+
turn_mode=turn_mode,
|
|
1985
2100
|
reasoning_effort=reasoning_effort,
|
|
1986
2101
|
turn_id=turn_id,
|
|
1987
2102
|
attempt_index=attempt_index,
|
|
@@ -2002,24 +2117,172 @@ class DaemonApp:
|
|
|
2002
2117
|
"next_retry_at": None,
|
|
2003
2118
|
},
|
|
2004
2119
|
)
|
|
2005
|
-
|
|
2006
2120
|
try:
|
|
2007
|
-
|
|
2008
|
-
|
|
2121
|
+
try:
|
|
2122
|
+
result = runner.run(request)
|
|
2123
|
+
except Exception as exc: # pragma: no cover - exercised via integration behavior
|
|
2124
|
+
if self._turn_stop_requested(quest_id):
|
|
2125
|
+
return
|
|
2126
|
+
failure_summary = f"Runner `{runner_name}` failed on attempt {attempt_index}/{max_attempts}: {exc}"
|
|
2127
|
+
retry_context = self._build_retry_context(
|
|
2128
|
+
quest_id=quest_id,
|
|
2129
|
+
failed_run_id=current_run_id,
|
|
2130
|
+
turn_id=turn_id,
|
|
2131
|
+
attempt_index=attempt_index,
|
|
2132
|
+
max_attempts=max_attempts,
|
|
2133
|
+
failure_kind="exception",
|
|
2134
|
+
failure_summary=failure_summary,
|
|
2135
|
+
previous_exit_code=None,
|
|
2136
|
+
previous_output_text="",
|
|
2137
|
+
stderr_text=str(exc),
|
|
2138
|
+
)
|
|
2139
|
+
if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
|
|
2140
|
+
delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
|
|
2141
|
+
next_retry_at = self._retry_next_timestamp(delay_seconds)
|
|
2142
|
+
self.quest_service.update_runtime_state(
|
|
2143
|
+
quest_root=quest_root,
|
|
2144
|
+
status="running",
|
|
2145
|
+
display_status="retrying",
|
|
2146
|
+
active_run_id=None,
|
|
2147
|
+
retry_state={
|
|
2148
|
+
"turn_id": turn_id,
|
|
2149
|
+
"attempt_index": attempt_index,
|
|
2150
|
+
"max_attempts": max_attempts,
|
|
2151
|
+
"last_run_id": current_run_id,
|
|
2152
|
+
"last_error": failure_summary,
|
|
2153
|
+
"next_retry_at": next_retry_at,
|
|
2154
|
+
},
|
|
2155
|
+
)
|
|
2156
|
+
self._append_retry_event(
|
|
2157
|
+
quest_id,
|
|
2158
|
+
event_type="runner.turn_retry_scheduled",
|
|
2159
|
+
runner_name=runner_name,
|
|
2160
|
+
run_id=current_run_id,
|
|
2161
|
+
turn_id=turn_id,
|
|
2162
|
+
skill_id=skill_id,
|
|
2163
|
+
model=model,
|
|
2164
|
+
attempt_index=attempt_index,
|
|
2165
|
+
max_attempts=max_attempts,
|
|
2166
|
+
summary=f"Attempt {attempt_index}/{max_attempts} failed. Retrying in {delay_seconds:.1f}s.",
|
|
2167
|
+
failure_summary=failure_summary,
|
|
2168
|
+
backoff_seconds=delay_seconds,
|
|
2169
|
+
next_attempt_index=attempt_index + 1,
|
|
2170
|
+
)
|
|
2171
|
+
if self._wait_for_retry_delay(quest_id, delay_seconds):
|
|
2172
|
+
continue
|
|
2173
|
+
self._append_retry_event(
|
|
2174
|
+
quest_id,
|
|
2175
|
+
event_type="runner.turn_retry_aborted",
|
|
2176
|
+
runner_name=runner_name,
|
|
2177
|
+
run_id=current_run_id,
|
|
2178
|
+
turn_id=turn_id,
|
|
2179
|
+
skill_id=skill_id,
|
|
2180
|
+
model=model,
|
|
2181
|
+
attempt_index=attempt_index,
|
|
2182
|
+
max_attempts=max_attempts,
|
|
2183
|
+
summary="Retry sequence aborted because the quest was stopped or paused.",
|
|
2184
|
+
failure_summary=failure_summary,
|
|
2185
|
+
)
|
|
2186
|
+
return
|
|
2187
|
+
exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
|
|
2188
|
+
self._append_retry_event(
|
|
2189
|
+
quest_id,
|
|
2190
|
+
event_type="runner.turn_retry_exhausted",
|
|
2191
|
+
runner_name=runner_name,
|
|
2192
|
+
run_id=current_run_id,
|
|
2193
|
+
turn_id=turn_id,
|
|
2194
|
+
skill_id=skill_id,
|
|
2195
|
+
model=model,
|
|
2196
|
+
attempt_index=attempt_index,
|
|
2197
|
+
max_attempts=max_attempts,
|
|
2198
|
+
summary=exhausted_summary,
|
|
2199
|
+
failure_summary=failure_summary,
|
|
2200
|
+
)
|
|
2201
|
+
self._record_turn_error(
|
|
2202
|
+
quest_id=quest_id,
|
|
2203
|
+
runner_name=runner_name,
|
|
2204
|
+
run_id=current_run_id,
|
|
2205
|
+
skill_id=skill_id,
|
|
2206
|
+
model=model,
|
|
2207
|
+
summary=exhausted_summary,
|
|
2208
|
+
retry_state=None,
|
|
2209
|
+
)
|
|
2210
|
+
return
|
|
2211
|
+
|
|
2009
2212
|
if self._turn_stop_requested(quest_id):
|
|
2010
2213
|
return
|
|
2011
|
-
|
|
2214
|
+
|
|
2215
|
+
if result.ok:
|
|
2216
|
+
self.quest_service.update_runtime_state(quest_root=quest_root, retry_state=None)
|
|
2217
|
+
if result.output_text:
|
|
2218
|
+
result_attachment = [
|
|
2219
|
+
{
|
|
2220
|
+
"kind": "runner_result",
|
|
2221
|
+
"run_id": result.run_id,
|
|
2222
|
+
"skill_id": skill_id,
|
|
2223
|
+
"runner": runner_name,
|
|
2224
|
+
"model": result.model,
|
|
2225
|
+
"exit_code": result.exit_code,
|
|
2226
|
+
"history_root": str(result.history_root),
|
|
2227
|
+
"run_root": str(result.run_root),
|
|
2228
|
+
}
|
|
2229
|
+
]
|
|
2230
|
+
try:
|
|
2231
|
+
self.quest_service.append_message(
|
|
2232
|
+
quest_id,
|
|
2233
|
+
role="assistant",
|
|
2234
|
+
content=result.output_text,
|
|
2235
|
+
source=runner_name,
|
|
2236
|
+
run_id=result.run_id,
|
|
2237
|
+
skill_id=skill_id,
|
|
2238
|
+
)
|
|
2239
|
+
except Exception as exc:
|
|
2240
|
+
self._record_turn_postprocess_warning(
|
|
2241
|
+
quest_id=quest_id,
|
|
2242
|
+
runner_name=runner_name,
|
|
2243
|
+
run_id=result.run_id,
|
|
2244
|
+
skill_id=skill_id,
|
|
2245
|
+
model=result.model,
|
|
2246
|
+
stage="append_message",
|
|
2247
|
+
error=exc,
|
|
2248
|
+
)
|
|
2249
|
+
try:
|
|
2250
|
+
self._relay_quest_message_to_bound_connectors(
|
|
2251
|
+
quest_id,
|
|
2252
|
+
message=result.output_text,
|
|
2253
|
+
kind="assistant",
|
|
2254
|
+
response_phase="final",
|
|
2255
|
+
importance="normal",
|
|
2256
|
+
attachments=result_attachment,
|
|
2257
|
+
)
|
|
2258
|
+
except Exception as exc:
|
|
2259
|
+
self._record_turn_postprocess_warning(
|
|
2260
|
+
quest_id=quest_id,
|
|
2261
|
+
runner_name=runner_name,
|
|
2262
|
+
run_id=result.run_id,
|
|
2263
|
+
skill_id=skill_id,
|
|
2264
|
+
model=result.model,
|
|
2265
|
+
stage="connector_relay",
|
|
2266
|
+
error=exc,
|
|
2267
|
+
)
|
|
2268
|
+
self._normalize_status_after_turn(quest_id, turn_reason=turn_reason)
|
|
2269
|
+
return
|
|
2270
|
+
|
|
2271
|
+
failure_summary = f"Runner `{runner_name}` exited with code {result.exit_code} on attempt {attempt_index}/{max_attempts}."
|
|
2272
|
+
stderr_excerpt = self._trim_text(result.stderr_text, limit=240)
|
|
2273
|
+
if stderr_excerpt:
|
|
2274
|
+
failure_summary = f"{failure_summary} stderr: {stderr_excerpt}"
|
|
2012
2275
|
retry_context = self._build_retry_context(
|
|
2013
2276
|
quest_id=quest_id,
|
|
2014
|
-
failed_run_id=
|
|
2277
|
+
failed_run_id=result.run_id,
|
|
2015
2278
|
turn_id=turn_id,
|
|
2016
2279
|
attempt_index=attempt_index,
|
|
2017
2280
|
max_attempts=max_attempts,
|
|
2018
|
-
failure_kind="
|
|
2281
|
+
failure_kind="exit_code",
|
|
2019
2282
|
failure_summary=failure_summary,
|
|
2020
|
-
previous_exit_code=
|
|
2021
|
-
previous_output_text=
|
|
2022
|
-
stderr_text=
|
|
2283
|
+
previous_exit_code=result.exit_code,
|
|
2284
|
+
previous_output_text=result.output_text,
|
|
2285
|
+
stderr_text=result.stderr_text,
|
|
2023
2286
|
)
|
|
2024
2287
|
if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
|
|
2025
2288
|
delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
|
|
@@ -2033,7 +2296,7 @@ class DaemonApp:
|
|
|
2033
2296
|
"turn_id": turn_id,
|
|
2034
2297
|
"attempt_index": attempt_index,
|
|
2035
2298
|
"max_attempts": max_attempts,
|
|
2036
|
-
"last_run_id":
|
|
2299
|
+
"last_run_id": result.run_id,
|
|
2037
2300
|
"last_error": failure_summary,
|
|
2038
2301
|
"next_retry_at": next_retry_at,
|
|
2039
2302
|
},
|
|
@@ -2042,7 +2305,7 @@ class DaemonApp:
|
|
|
2042
2305
|
quest_id,
|
|
2043
2306
|
event_type="runner.turn_retry_scheduled",
|
|
2044
2307
|
runner_name=runner_name,
|
|
2045
|
-
run_id=
|
|
2308
|
+
run_id=result.run_id,
|
|
2046
2309
|
turn_id=turn_id,
|
|
2047
2310
|
skill_id=skill_id,
|
|
2048
2311
|
model=model,
|
|
@@ -2059,7 +2322,7 @@ class DaemonApp:
|
|
|
2059
2322
|
quest_id,
|
|
2060
2323
|
event_type="runner.turn_retry_aborted",
|
|
2061
2324
|
runner_name=runner_name,
|
|
2062
|
-
run_id=
|
|
2325
|
+
run_id=result.run_id,
|
|
2063
2326
|
turn_id=turn_id,
|
|
2064
2327
|
skill_id=skill_id,
|
|
2065
2328
|
model=model,
|
|
@@ -2069,12 +2332,13 @@ class DaemonApp:
|
|
|
2069
2332
|
failure_summary=failure_summary,
|
|
2070
2333
|
)
|
|
2071
2334
|
return
|
|
2335
|
+
|
|
2072
2336
|
exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
|
|
2073
2337
|
self._append_retry_event(
|
|
2074
2338
|
quest_id,
|
|
2075
2339
|
event_type="runner.turn_retry_exhausted",
|
|
2076
2340
|
runner_name=runner_name,
|
|
2077
|
-
run_id=
|
|
2341
|
+
run_id=result.run_id,
|
|
2078
2342
|
turn_id=turn_id,
|
|
2079
2343
|
skill_id=skill_id,
|
|
2080
2344
|
model=model,
|
|
@@ -2086,150 +2350,112 @@ class DaemonApp:
|
|
|
2086
2350
|
self._record_turn_error(
|
|
2087
2351
|
quest_id=quest_id,
|
|
2088
2352
|
runner_name=runner_name,
|
|
2089
|
-
run_id=
|
|
2353
|
+
run_id=result.run_id,
|
|
2090
2354
|
skill_id=skill_id,
|
|
2091
2355
|
model=model,
|
|
2092
2356
|
summary=exhausted_summary,
|
|
2093
2357
|
retry_state=None,
|
|
2094
2358
|
)
|
|
2095
2359
|
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(
|
|
2360
|
+
finally:
|
|
2361
|
+
self._ensure_turn_cleanup(
|
|
2184
2362
|
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,
|
|
2363
|
+
run_id=current_run_id,
|
|
2364
|
+
turn_reason=turn_reason,
|
|
2195
2365
|
)
|
|
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
2366
|
|
|
2223
2367
|
def _runner_name_for(self, snapshot: dict) -> str:
|
|
2224
2368
|
configured = self.config_manager.load_named("config")
|
|
2225
2369
|
return str(snapshot.get("runner") or configured.get("default_runner", "codex")).strip().lower()
|
|
2226
2370
|
|
|
2227
2371
|
@staticmethod
|
|
2228
|
-
def
|
|
2372
|
+
def _stage_state_fingerprint(snapshot: dict) -> str:
|
|
2373
|
+
paper_health = (
|
|
2374
|
+
dict(snapshot.get("paper_contract_health") or {})
|
|
2375
|
+
if isinstance(snapshot.get("paper_contract_health"), dict)
|
|
2376
|
+
else {}
|
|
2377
|
+
)
|
|
2378
|
+
payload = {
|
|
2379
|
+
"active_anchor": str(snapshot.get("active_anchor") or "").strip() or None,
|
|
2380
|
+
"active_run_id": str(snapshot.get("active_run_id") or "").strip() or None,
|
|
2381
|
+
"active_analysis_campaign_id": str(snapshot.get("active_analysis_campaign_id") or "").strip() or None,
|
|
2382
|
+
"next_pending_slice_id": str(snapshot.get("next_pending_slice_id") or "").strip() or None,
|
|
2383
|
+
"current_workspace_branch": str(snapshot.get("current_workspace_branch") or "").strip() or None,
|
|
2384
|
+
"continuation_policy": str(snapshot.get("continuation_policy") or "").strip() or None,
|
|
2385
|
+
"paper": {
|
|
2386
|
+
"closure_state": str(paper_health.get("closure_state") or "").strip() or None,
|
|
2387
|
+
"delivery_state": str(paper_health.get("delivery_state") or "").strip() or None,
|
|
2388
|
+
"recommended_next_stage": str(paper_health.get("recommended_next_stage") or "").strip() or None,
|
|
2389
|
+
"recommended_action": str(paper_health.get("recommended_action") or "").strip() or None,
|
|
2390
|
+
"blocking_reasons": list(paper_health.get("blocking_reasons") or []),
|
|
2391
|
+
"keep_bundle_fixed_by_default": bool(paper_health.get("keep_bundle_fixed_by_default")),
|
|
2392
|
+
},
|
|
2393
|
+
}
|
|
2394
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
2395
|
+
|
|
2396
|
+
@staticmethod
|
|
2397
|
+
def _turn_intent_for(latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
|
|
2229
2398
|
if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2399
|
+
return "continue_stage"
|
|
2400
|
+
return classify_turn_intent(str(latest_user_message.get("content") or "").strip())
|
|
2401
|
+
|
|
2402
|
+
@staticmethod
|
|
2403
|
+
def _turn_mode_for(snapshot: dict, latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
|
|
2404
|
+
normalized_reason = str(turn_reason or "").strip() or "user_message"
|
|
2405
|
+
if normalized_reason == "auto_continue":
|
|
2406
|
+
resume_source = str(snapshot.get("last_resume_source") or "").strip()
|
|
2407
|
+
if resume_source.startswith("auto:daemon-recovery"):
|
|
2408
|
+
return "recovering"
|
|
2409
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
2410
|
+
if continuation_policy == "when_external_progress":
|
|
2411
|
+
return "monitoring"
|
|
2412
|
+
if continuation_policy in {"wait_for_user_or_resume", "none"}:
|
|
2413
|
+
return "parked"
|
|
2414
|
+
return "stage_execution"
|
|
2415
|
+
turn_intent = DaemonApp._turn_intent_for(latest_user_message, turn_reason=turn_reason)
|
|
2416
|
+
if turn_intent == "answer_user_question_first":
|
|
2417
|
+
return "answering"
|
|
2418
|
+
if turn_intent == "execute_user_command_first":
|
|
2419
|
+
return "command_execution"
|
|
2420
|
+
return "stage_execution"
|
|
2421
|
+
|
|
2422
|
+
@staticmethod
|
|
2423
|
+
def _continuation_anchor_for(snapshot: dict) -> str:
|
|
2424
|
+
continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
|
|
2425
|
+
if continuation_anchor in STANDARD_SKILLS:
|
|
2426
|
+
return continuation_anchor
|
|
2427
|
+
active_anchor = str(snapshot.get("active_anchor") or "").strip()
|
|
2428
|
+
return active_anchor if active_anchor in STANDARD_SKILLS else "decision"
|
|
2429
|
+
|
|
2430
|
+
@staticmethod
|
|
2431
|
+
def _turn_skill_stage_gate(snapshot: dict, candidate_skill: str) -> str:
|
|
2432
|
+
skill = str(candidate_skill or "").strip()
|
|
2433
|
+
baseline_gate = str(snapshot.get("baseline_gate") or "pending").strip().lower() or "pending"
|
|
2434
|
+
startup_contract = snapshot.get("startup_contract") if isinstance(snapshot.get("startup_contract"), dict) else {}
|
|
2435
|
+
raw_need_research_paper = startup_contract.get("need_research_paper")
|
|
2436
|
+
need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
|
|
2437
|
+
active_idea_id = str(snapshot.get("active_idea_id") or "").strip()
|
|
2438
|
+
|
|
2439
|
+
if (
|
|
2440
|
+
baseline_gate == "pending"
|
|
2441
|
+
and skill in {"idea", "optimize", "experiment", "analysis-campaign", "write", "review", "rebuttal", "finalize"}
|
|
2442
|
+
):
|
|
2443
|
+
return "baseline"
|
|
2444
|
+
|
|
2445
|
+
if skill == "experiment" and not active_idea_id:
|
|
2446
|
+
return "idea" if need_research_paper else "optimize"
|
|
2447
|
+
|
|
2448
|
+
return skill
|
|
2449
|
+
|
|
2450
|
+
@staticmethod
|
|
2451
|
+
def _turn_skill_for(
|
|
2452
|
+
snapshot: dict,
|
|
2453
|
+
latest_user_message: dict | None,
|
|
2454
|
+
*,
|
|
2455
|
+
turn_reason: str = "user_message",
|
|
2456
|
+
turn_mode: str = "stage_execution",
|
|
2457
|
+
) -> str:
|
|
2458
|
+
reply_target = str((latest_user_message or {}).get("reply_to_interaction_id") or "").strip()
|
|
2233
2459
|
if reply_target:
|
|
2234
2460
|
for item in (snapshot.get("active_interactions") or []):
|
|
2235
2461
|
candidate_ids = {
|
|
@@ -2247,13 +2473,36 @@ class DaemonApp:
|
|
|
2247
2473
|
str(item.get("interaction_id") or "").strip(),
|
|
2248
2474
|
str(item.get("artifact_id") or "").strip(),
|
|
2249
2475
|
}
|
|
2250
|
-
if reply_target in candidate_ids
|
|
2476
|
+
if reply_target not in candidate_ids:
|
|
2477
|
+
continue
|
|
2478
|
+
if (
|
|
2251
2479
|
str(item.get("reply_mode") or "") == "blocking"
|
|
2252
2480
|
or str(item.get("kind") or "") == "decision_request"
|
|
2253
2481
|
):
|
|
2254
2482
|
return "decision"
|
|
2483
|
+
if str(item.get("reply_mode") or "") == "threaded":
|
|
2484
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2485
|
+
snapshot,
|
|
2486
|
+
DaemonApp._continuation_anchor_for(snapshot),
|
|
2487
|
+
)
|
|
2488
|
+
if turn_mode in {"answering", "command_execution", "recovering"}:
|
|
2489
|
+
return "decision"
|
|
2490
|
+
if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
|
|
2491
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2492
|
+
snapshot,
|
|
2493
|
+
DaemonApp._continuation_anchor_for(snapshot),
|
|
2494
|
+
)
|
|
2495
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
2496
|
+
if continuation_policy == "wait_for_user_or_resume":
|
|
2497
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2498
|
+
snapshot,
|
|
2499
|
+
DaemonApp._continuation_anchor_for(snapshot),
|
|
2500
|
+
)
|
|
2255
2501
|
active_anchor = str(snapshot.get("active_anchor") or "").strip()
|
|
2256
|
-
return
|
|
2502
|
+
return DaemonApp._turn_skill_stage_gate(
|
|
2503
|
+
snapshot,
|
|
2504
|
+
active_anchor if active_anchor in STANDARD_SKILLS else "decision",
|
|
2505
|
+
)
|
|
2257
2506
|
|
|
2258
2507
|
def _latest_user_message(self, quest_id: str) -> dict | None:
|
|
2259
2508
|
for item in reversed(self.quest_service.history(quest_id, limit=200)):
|
|
@@ -2601,7 +2850,80 @@ class DaemonApp:
|
|
|
2601
2850
|
],
|
|
2602
2851
|
)
|
|
2603
2852
|
|
|
2604
|
-
def
|
|
2853
|
+
def _record_turn_postprocess_warning(
|
|
2854
|
+
self,
|
|
2855
|
+
*,
|
|
2856
|
+
quest_id: str,
|
|
2857
|
+
runner_name: str,
|
|
2858
|
+
run_id: str,
|
|
2859
|
+
skill_id: str,
|
|
2860
|
+
model: str,
|
|
2861
|
+
stage: str,
|
|
2862
|
+
error: Exception,
|
|
2863
|
+
) -> None:
|
|
2864
|
+
quest_root = self.home / "quests" / quest_id
|
|
2865
|
+
summary = f"Runner post-run stage `{stage}` failed for run `{run_id}`: {error}"
|
|
2866
|
+
append_jsonl(
|
|
2867
|
+
quest_root / ".ds" / "events.jsonl",
|
|
2868
|
+
{
|
|
2869
|
+
"event_id": generate_id("evt"),
|
|
2870
|
+
"type": "runner.turn_postprocess_warning",
|
|
2871
|
+
"quest_id": quest_id,
|
|
2872
|
+
"run_id": run_id,
|
|
2873
|
+
"source": runner_name,
|
|
2874
|
+
"skill_id": skill_id,
|
|
2875
|
+
"model": model,
|
|
2876
|
+
"stage": stage,
|
|
2877
|
+
"summary": summary,
|
|
2878
|
+
"created_at": utc_now(),
|
|
2879
|
+
},
|
|
2880
|
+
)
|
|
2881
|
+
self.logger.log(
|
|
2882
|
+
"error",
|
|
2883
|
+
"runner.turn_postprocess_warning",
|
|
2884
|
+
quest_id=quest_id,
|
|
2885
|
+
run_id=run_id,
|
|
2886
|
+
runner=runner_name,
|
|
2887
|
+
skill_id=skill_id,
|
|
2888
|
+
model=model,
|
|
2889
|
+
stage=stage,
|
|
2890
|
+
error=str(error),
|
|
2891
|
+
)
|
|
2892
|
+
|
|
2893
|
+
def _ensure_turn_cleanup(self, quest_id: str, *, run_id: str, turn_reason: str) -> None:
|
|
2894
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2895
|
+
if str(snapshot.get("active_run_id") or "").strip() != str(run_id or "").strip():
|
|
2896
|
+
return
|
|
2897
|
+
try:
|
|
2898
|
+
self._normalize_status_after_turn(quest_id, turn_reason=turn_reason)
|
|
2899
|
+
return
|
|
2900
|
+
except Exception as exc:
|
|
2901
|
+
current_status = str(snapshot.get("status") or snapshot.get("display_status") or "active").strip() or "active"
|
|
2902
|
+
normalized_status = "active" if current_status == "running" else current_status
|
|
2903
|
+
self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
2904
|
+
quest_root = self.quest_service._quest_root(quest_id)
|
|
2905
|
+
append_jsonl(
|
|
2906
|
+
quest_root / ".ds" / "events.jsonl",
|
|
2907
|
+
{
|
|
2908
|
+
"event_id": generate_id("evt"),
|
|
2909
|
+
"type": "runner.turn_cleanup_recovered",
|
|
2910
|
+
"quest_id": quest_id,
|
|
2911
|
+
"run_id": run_id,
|
|
2912
|
+
"status": normalized_status,
|
|
2913
|
+
"summary": f"Recovered turn cleanup after `_normalize_status_after_turn` failed: {exc}",
|
|
2914
|
+
"created_at": utc_now(),
|
|
2915
|
+
},
|
|
2916
|
+
)
|
|
2917
|
+
self.logger.log(
|
|
2918
|
+
"error",
|
|
2919
|
+
"runner.turn_cleanup_recovered",
|
|
2920
|
+
quest_id=quest_id,
|
|
2921
|
+
run_id=run_id,
|
|
2922
|
+
status=normalized_status,
|
|
2923
|
+
error=str(exc),
|
|
2924
|
+
)
|
|
2925
|
+
|
|
2926
|
+
def _normalize_status_after_turn(self, quest_id: str, *, turn_reason: str = "user_message") -> None:
|
|
2605
2927
|
with self._turn_lock:
|
|
2606
2928
|
if bool((self._turn_state.get(quest_id) or {}).get("stop_requested")):
|
|
2607
2929
|
return
|
|
@@ -2609,6 +2931,46 @@ class DaemonApp:
|
|
|
2609
2931
|
current_status = str(snapshot.get("status") or snapshot.get("display_status") or "active").strip() or "active"
|
|
2610
2932
|
normalized_status = "active" if current_status == "running" else current_status
|
|
2611
2933
|
snapshot = self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
|
|
2934
|
+
runtime_updates: dict[str, Any] = {}
|
|
2935
|
+
current_fingerprint = self._stage_state_fingerprint(snapshot)
|
|
2936
|
+
previous_fingerprint = str(snapshot.get("last_stage_fingerprint") or "").strip() or None
|
|
2937
|
+
same_fingerprint_count = int(snapshot.get("same_fingerprint_auto_turn_count") or 0)
|
|
2938
|
+
if str(turn_reason or "").strip() == "auto_continue":
|
|
2939
|
+
same_fingerprint_count = same_fingerprint_count + 1 if previous_fingerprint == current_fingerprint else 1
|
|
2940
|
+
else:
|
|
2941
|
+
same_fingerprint_count = 0
|
|
2942
|
+
runtime_updates.update(
|
|
2943
|
+
{
|
|
2944
|
+
"last_stage_fingerprint": current_fingerprint,
|
|
2945
|
+
"last_stage_fingerprint_at": utc_now(),
|
|
2946
|
+
"same_fingerprint_auto_turn_count": same_fingerprint_count,
|
|
2947
|
+
}
|
|
2948
|
+
)
|
|
2949
|
+
if (
|
|
2950
|
+
str(turn_reason or "").strip() == "auto_continue"
|
|
2951
|
+
and str(snapshot.get("active_anchor") or "").strip() == "finalize"
|
|
2952
|
+
and same_fingerprint_count >= 2
|
|
2953
|
+
and int(snapshot.get("pending_user_message_count") or 0) == 0
|
|
2954
|
+
):
|
|
2955
|
+
runtime_updates.update(
|
|
2956
|
+
{
|
|
2957
|
+
"continuation_policy": "wait_for_user_or_resume",
|
|
2958
|
+
"continuation_anchor": "decision",
|
|
2959
|
+
"continuation_reason": "unchanged_finalize_state",
|
|
2960
|
+
"continuation_updated_at": utc_now(),
|
|
2961
|
+
}
|
|
2962
|
+
)
|
|
2963
|
+
self.quest_service.update_runtime_state(
|
|
2964
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
2965
|
+
**runtime_updates,
|
|
2966
|
+
)
|
|
2967
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2968
|
+
else:
|
|
2969
|
+
self.quest_service.update_runtime_state(
|
|
2970
|
+
quest_root=self.quest_service._quest_root(quest_id),
|
|
2971
|
+
**runtime_updates,
|
|
2972
|
+
)
|
|
2973
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2612
2974
|
status = str(snapshot.get("status") or "")
|
|
2613
2975
|
if status in {"stopped", "paused", "completed", "error"}:
|
|
2614
2976
|
return
|
|
@@ -2618,11 +2980,23 @@ class DaemonApp:
|
|
|
2618
2980
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2619
2981
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2620
2982
|
else:
|
|
2621
|
-
|
|
2983
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
2984
|
+
if continuation_policy not in {"wait_for_user_or_resume", "none"}:
|
|
2985
|
+
self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
|
|
2622
2986
|
return
|
|
2623
2987
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2624
2988
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2625
2989
|
return
|
|
2990
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
2991
|
+
if continuation_policy == "none":
|
|
2992
|
+
return
|
|
2993
|
+
if continuation_policy == "wait_for_user_or_resume":
|
|
2994
|
+
return
|
|
2995
|
+
if continuation_policy == "when_external_progress":
|
|
2996
|
+
counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
|
|
2997
|
+
has_external_progress = bool(snapshot.get("active_run_id")) or int(counts.get("bash_running_count") or 0) > 0
|
|
2998
|
+
if not has_external_progress:
|
|
2999
|
+
return
|
|
2626
3000
|
self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
|
|
2627
3001
|
|
|
2628
3002
|
def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
|
|
@@ -2634,6 +3008,14 @@ class DaemonApp:
|
|
|
2634
3008
|
status = str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip().lower()
|
|
2635
3009
|
if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
|
|
2636
3010
|
return
|
|
3011
|
+
continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
|
|
3012
|
+
if continuation_policy in {"none", "wait_for_user_or_resume"}:
|
|
3013
|
+
return
|
|
3014
|
+
if continuation_policy == "when_external_progress":
|
|
3015
|
+
counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
|
|
3016
|
+
has_external_progress = bool(snapshot.get("active_run_id")) or int(counts.get("bash_running_count") or 0) > 0
|
|
3017
|
+
if not has_external_progress:
|
|
3018
|
+
return
|
|
2637
3019
|
self.schedule_turn(quest_id, reason=reason)
|
|
2638
3020
|
|
|
2639
3021
|
threading.Thread(
|
|
@@ -3492,6 +3874,13 @@ class DaemonApp:
|
|
|
3492
3874
|
**normalized,
|
|
3493
3875
|
"_qq_main_chat_binding": qq_binding,
|
|
3494
3876
|
}
|
|
3877
|
+
if connector_name == "weixin":
|
|
3878
|
+
replay = self._maybe_replay_weixin_pending_outbox(normalized)
|
|
3879
|
+
if replay is not None:
|
|
3880
|
+
normalized = {
|
|
3881
|
+
**normalized,
|
|
3882
|
+
"_weixin_replay": replay,
|
|
3883
|
+
}
|
|
3495
3884
|
reply = self._route_connector_message(connector_name, normalized)
|
|
3496
3885
|
return {
|
|
3497
3886
|
"ok": True,
|
|
@@ -5290,6 +5679,121 @@ class DaemonApp:
|
|
|
5290
5679
|
resolved = dict(config) if isinstance(config, dict) else {}
|
|
5291
5680
|
return lingzhu_health_payload(resolved, chat_completions_enabled=True)
|
|
5292
5681
|
|
|
5682
|
+
@staticmethod
|
|
5683
|
+
def _weixin_replay_limit(config: dict[str, Any]) -> int:
|
|
5684
|
+
try:
|
|
5685
|
+
limit = int(config.get("stale_replay_latest_limit") or _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT)
|
|
5686
|
+
except (TypeError, ValueError):
|
|
5687
|
+
limit = _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT
|
|
5688
|
+
return max(0, min(limit, 20))
|
|
5689
|
+
|
|
5690
|
+
@staticmethod
|
|
5691
|
+
def _weixin_replay_interval_seconds(config: dict[str, Any]) -> float:
|
|
5692
|
+
try:
|
|
5693
|
+
interval = float(
|
|
5694
|
+
config.get("stale_replay_interval_seconds") or _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT
|
|
5695
|
+
)
|
|
5696
|
+
except (TypeError, ValueError):
|
|
5697
|
+
interval = _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT
|
|
5698
|
+
return max(0.0, min(interval, 30.0))
|
|
5699
|
+
|
|
5700
|
+
def _weixin_connector_root(self) -> Path:
|
|
5701
|
+
return self.home / "logs" / "connectors" / "weixin"
|
|
5702
|
+
|
|
5703
|
+
def _weixin_queued_outbox_records(self, conversation_id: str) -> list[dict[str, Any]]:
|
|
5704
|
+
outbox_path = self._weixin_connector_root() / "outbox.jsonl"
|
|
5705
|
+
target_key = conversation_identity_key(conversation_id)
|
|
5706
|
+
items: list[dict[str, Any]] = []
|
|
5707
|
+
for record in read_jsonl(outbox_path):
|
|
5708
|
+
if not isinstance(record, dict):
|
|
5709
|
+
continue
|
|
5710
|
+
current_conversation_id = str(record.get("conversation_id") or "").strip()
|
|
5711
|
+
if not current_conversation_id:
|
|
5712
|
+
continue
|
|
5713
|
+
if conversation_identity_key(current_conversation_id) != target_key:
|
|
5714
|
+
continue
|
|
5715
|
+
delivery = record.get("delivery") if isinstance(record.get("delivery"), dict) else {}
|
|
5716
|
+
if not bool(delivery.get("queued", False)):
|
|
5717
|
+
continue
|
|
5718
|
+
attachments = [dict(item) for item in (record.get("attachments") or []) if isinstance(item, dict)]
|
|
5719
|
+
if not str(record.get("text") or "").strip() and not attachments:
|
|
5720
|
+
continue
|
|
5721
|
+
items.append(
|
|
5722
|
+
{
|
|
5723
|
+
**dict(record),
|
|
5724
|
+
"attachments": attachments,
|
|
5725
|
+
}
|
|
5726
|
+
)
|
|
5727
|
+
return items
|
|
5728
|
+
|
|
5729
|
+
def _weixin_pending_outbox_records(self, conversation_id: str, *, user_id: str) -> tuple[list[dict[str, Any]], int]:
|
|
5730
|
+
records = self._weixin_queued_outbox_records(conversation_id)
|
|
5731
|
+
baseline = get_weixin_replay_cursor(self._weixin_connector_root(), user_id)
|
|
5732
|
+
applied_baseline = max(0, min(int(baseline), len(records)))
|
|
5733
|
+
return records[applied_baseline:], len(records)
|
|
5734
|
+
|
|
5735
|
+
@staticmethod
|
|
5736
|
+
def _weixin_replay_payload(record: dict[str, Any]) -> dict[str, Any]:
|
|
5737
|
+
attachments = [dict(item) for item in (record.get("attachments") or []) if isinstance(item, dict)]
|
|
5738
|
+
surface_actions = [dict(item) for item in (record.get("surface_actions") or []) if isinstance(item, dict)]
|
|
5739
|
+
connector_hints = dict(record.get("connector_hints")) if isinstance(record.get("connector_hints"), dict) else {}
|
|
5740
|
+
return {
|
|
5741
|
+
"conversation_id": record.get("conversation_id"),
|
|
5742
|
+
"reply_to_message_id": record.get("reply_to_message_id"),
|
|
5743
|
+
"kind": record.get("kind"),
|
|
5744
|
+
"message": str(record.get("text") or ""),
|
|
5745
|
+
"attachments": attachments,
|
|
5746
|
+
"surface_actions": surface_actions,
|
|
5747
|
+
"connector_hints": connector_hints,
|
|
5748
|
+
"quest_id": record.get("quest_id"),
|
|
5749
|
+
"quest_root": record.get("quest_root"),
|
|
5750
|
+
"importance": record.get("importance"),
|
|
5751
|
+
"response_phase": record.get("response_phase"),
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5754
|
+
def _maybe_replay_weixin_pending_outbox(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
5755
|
+
conversation_id = str(message.get("conversation_id") or "").strip()
|
|
5756
|
+
sender_id = str(message.get("sender_id") or message.get("direct_id") or "").strip()
|
|
5757
|
+
if not conversation_id or not sender_id:
|
|
5758
|
+
return None
|
|
5759
|
+
config = self.connectors_config.get("weixin", {})
|
|
5760
|
+
resolved = dict(config) if isinstance(config, dict) else {}
|
|
5761
|
+
limit = self._weixin_replay_limit(resolved)
|
|
5762
|
+
if limit <= 0:
|
|
5763
|
+
return {"replayed_count": 0, "dropped_count": 0, "total_pending": 0}
|
|
5764
|
+
pending_records, total_count = self._weixin_pending_outbox_records(conversation_id, user_id=sender_id)
|
|
5765
|
+
if not pending_records:
|
|
5766
|
+
return {"replayed_count": 0, "dropped_count": 0, "total_pending": 0}
|
|
5767
|
+
selected_records = pending_records[-limit:]
|
|
5768
|
+
dropped_count = max(0, len(pending_records) - len(selected_records))
|
|
5769
|
+
update_weixin_replay_cursor(
|
|
5770
|
+
self._weixin_connector_root(),
|
|
5771
|
+
user_id=sender_id,
|
|
5772
|
+
queued_replay_cursor=total_count,
|
|
5773
|
+
last_replay_trigger_message_id=str(message.get("message_id") or "").strip() or None,
|
|
5774
|
+
last_replayed_count=len(selected_records),
|
|
5775
|
+
last_replay_dropped_count=dropped_count,
|
|
5776
|
+
)
|
|
5777
|
+
channel = self._channel_with_bindings("weixin")
|
|
5778
|
+
interval_seconds = self._weixin_replay_interval_seconds(resolved)
|
|
5779
|
+
for index, record in enumerate(selected_records):
|
|
5780
|
+
channel.send(self._weixin_replay_payload(record))
|
|
5781
|
+
if index + 1 < len(selected_records) and interval_seconds > 0:
|
|
5782
|
+
time.sleep(interval_seconds)
|
|
5783
|
+
self.logger.log(
|
|
5784
|
+
"info",
|
|
5785
|
+
"connector.weixin_replay",
|
|
5786
|
+
conversation_id=conversation_id,
|
|
5787
|
+
replayed_count=len(selected_records),
|
|
5788
|
+
dropped_count=dropped_count,
|
|
5789
|
+
trigger_message_id=str(message.get("message_id") or "").strip() or None,
|
|
5790
|
+
)
|
|
5791
|
+
return {
|
|
5792
|
+
"replayed_count": len(selected_records),
|
|
5793
|
+
"dropped_count": dropped_count,
|
|
5794
|
+
"total_pending": len(pending_records),
|
|
5795
|
+
}
|
|
5796
|
+
|
|
5293
5797
|
def _lingzhu_state_path(self) -> Path:
|
|
5294
5798
|
return self.home / "logs" / "connectors" / "lingzhu" / "metis_state.json"
|
|
5295
5799
|
|
|
@@ -5449,6 +5953,13 @@ class DaemonApp:
|
|
|
5449
5953
|
return emitted
|
|
5450
5954
|
|
|
5451
5955
|
def _lingzhu_short_status_text(self, quest_id: str | None) -> str:
|
|
5956
|
+
normalized_quest_id = str(quest_id or "").strip()
|
|
5957
|
+
if normalized_quest_id:
|
|
5958
|
+
snapshot = self.quest_service.snapshot_fast(normalized_quest_id)
|
|
5959
|
+
runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
|
|
5960
|
+
if runtime_status in {"running", "active"}:
|
|
5961
|
+
return "进行中"
|
|
5962
|
+
return self._lingzhu_status_hint_text(normalized_quest_id)
|
|
5452
5963
|
return self._lingzhu_status_hint_text(quest_id)
|
|
5453
5964
|
|
|
5454
5965
|
@staticmethod
|
|
@@ -5790,6 +6301,9 @@ class DaemonApp:
|
|
|
5790
6301
|
current_cursor = max(after, int(last_event_id)) if last_event_id.isdigit() else after
|
|
5791
6302
|
heartbeat_at = time.monotonic()
|
|
5792
6303
|
idle_sleep_seconds = 0.35
|
|
6304
|
+
force_fetch = True
|
|
6305
|
+
event_path = self.quest_service._quest_root(quest_id) / ".ds" / "events.jsonl"
|
|
6306
|
+
previous_event_state = None
|
|
5793
6307
|
|
|
5794
6308
|
handler.send_response(200)
|
|
5795
6309
|
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
@@ -5802,26 +6316,38 @@ class DaemonApp:
|
|
|
5802
6316
|
|
|
5803
6317
|
try:
|
|
5804
6318
|
while True:
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
6319
|
+
current_event_state = self.quest_service._path_state(event_path)
|
|
6320
|
+
if force_fetch or current_event_state != previous_event_state:
|
|
6321
|
+
stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
|
|
6322
|
+
payload = self.handlers.quest_events(quest_id, path=stream_path)
|
|
6323
|
+
previous_event_state = current_event_state
|
|
6324
|
+
updates = payload.get("acp_updates") or []
|
|
6325
|
+
if updates:
|
|
6326
|
+
for update in updates:
|
|
6327
|
+
update_cursor = str(((update.get("params") or {}).get("update") or {}).get("cursor") or "")
|
|
6328
|
+
self._write_sse_event(
|
|
6329
|
+
handler,
|
|
6330
|
+
event="acp_update",
|
|
6331
|
+
data=update,
|
|
6332
|
+
event_id=update_cursor or None,
|
|
6333
|
+
)
|
|
6334
|
+
current_cursor = int(payload.get("cursor") or current_cursor)
|
|
5811
6335
|
self._write_sse_event(
|
|
5812
6336
|
handler,
|
|
5813
|
-
event="
|
|
5814
|
-
data=
|
|
5815
|
-
event_id=update_cursor or None,
|
|
6337
|
+
event="cursor",
|
|
6338
|
+
data={"cursor": current_cursor, "quest_id": quest_id},
|
|
5816
6339
|
)
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
6340
|
+
heartbeat_at = time.monotonic()
|
|
6341
|
+
force_fetch = bool(payload.get("has_more"))
|
|
6342
|
+
idle_sleep_seconds = 0.05 if force_fetch else 0.2
|
|
6343
|
+
else:
|
|
6344
|
+
force_fetch = False
|
|
6345
|
+
now = time.monotonic()
|
|
6346
|
+
if now - heartbeat_at >= 10:
|
|
6347
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
6348
|
+
handler.wfile.flush()
|
|
6349
|
+
heartbeat_at = now
|
|
6350
|
+
idle_sleep_seconds = min(1.5, idle_sleep_seconds * 1.35)
|
|
5825
6351
|
else:
|
|
5826
6352
|
now = time.monotonic()
|
|
5827
6353
|
if now - heartbeat_at >= 10:
|
|
@@ -5881,49 +6407,75 @@ class DaemonApp:
|
|
|
5881
6407
|
|
|
5882
6408
|
previous_snapshot: dict[str, dict[str, object]] = {}
|
|
5883
6409
|
heartbeat_at = time.monotonic()
|
|
6410
|
+
summary_path = self.bash_exec_service.summary_path(quest_root)
|
|
6411
|
+
index_path = self.bash_exec_service.index_path(quest_root)
|
|
6412
|
+
previous_summary_state = None
|
|
6413
|
+
previous_index_state = None
|
|
6414
|
+
has_active_sessions = False
|
|
6415
|
+
last_full_refresh_at = 0.0
|
|
5884
6416
|
try:
|
|
5885
6417
|
while True:
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
6418
|
+
current_summary_state = self.quest_service._path_state(summary_path)
|
|
6419
|
+
current_index_state = self.quest_service._path_state(index_path)
|
|
6420
|
+
should_refresh = (
|
|
6421
|
+
not previous_snapshot
|
|
6422
|
+
or current_summary_state != previous_summary_state
|
|
6423
|
+
or current_index_state != previous_index_state
|
|
6424
|
+
or (has_active_sessions and time.monotonic() - last_full_refresh_at >= 3.0)
|
|
6425
|
+
)
|
|
6426
|
+
if should_refresh:
|
|
6427
|
+
sessions = list_payload()
|
|
6428
|
+
current_snapshot = {
|
|
6429
|
+
str(item.get("bash_id") or ""): item
|
|
6430
|
+
for item in sessions
|
|
6431
|
+
if item.get("bash_id")
|
|
6432
|
+
}
|
|
6433
|
+
has_active_sessions = any(
|
|
6434
|
+
str(item.get("status") or "").strip().lower() in {"running", "terminating"}
|
|
6435
|
+
for item in current_snapshot.values()
|
|
5897
6436
|
)
|
|
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:
|
|
6437
|
+
previous_summary_state = self.quest_service._path_state(summary_path)
|
|
6438
|
+
previous_index_state = self.quest_service._path_state(index_path)
|
|
6439
|
+
last_full_refresh_at = time.monotonic()
|
|
6440
|
+
if not previous_snapshot:
|
|
5908
6441
|
self._write_sse_event(
|
|
5909
6442
|
handler,
|
|
5910
|
-
event="
|
|
5911
|
-
data={"
|
|
5912
|
-
)
|
|
5913
|
-
for bash_id in removed:
|
|
5914
|
-
self._write_sse_event(
|
|
5915
|
-
handler,
|
|
5916
|
-
event="session",
|
|
5917
|
-
data={"session": {"bash_id": bash_id, "status": "terminated"}},
|
|
6443
|
+
event="snapshot",
|
|
6444
|
+
data={"sessions": sessions},
|
|
5918
6445
|
)
|
|
5919
|
-
if changed or removed:
|
|
5920
6446
|
previous_snapshot = current_snapshot
|
|
5921
6447
|
heartbeat_at = time.monotonic()
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
6448
|
+
else:
|
|
6449
|
+
changed = [
|
|
6450
|
+
session
|
|
6451
|
+
for bash_id, session in current_snapshot.items()
|
|
6452
|
+
if previous_snapshot.get(bash_id) != session
|
|
6453
|
+
]
|
|
6454
|
+
removed = set(previous_snapshot) - set(current_snapshot)
|
|
6455
|
+
for session in changed:
|
|
6456
|
+
self._write_sse_event(
|
|
6457
|
+
handler,
|
|
6458
|
+
event="session",
|
|
6459
|
+
data={"session": session},
|
|
6460
|
+
)
|
|
6461
|
+
for bash_id in removed:
|
|
6462
|
+
self._write_sse_event(
|
|
6463
|
+
handler,
|
|
6464
|
+
event="session",
|
|
6465
|
+
data={"session": {"bash_id": bash_id, "status": "terminated"}},
|
|
6466
|
+
)
|
|
6467
|
+
if changed or removed:
|
|
6468
|
+
previous_snapshot = current_snapshot
|
|
6469
|
+
heartbeat_at = time.monotonic()
|
|
6470
|
+
elif time.monotonic() - heartbeat_at >= 10:
|
|
6471
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
6472
|
+
handler.wfile.flush()
|
|
6473
|
+
heartbeat_at = time.monotonic()
|
|
6474
|
+
elif time.monotonic() - heartbeat_at >= 10:
|
|
6475
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
6476
|
+
handler.wfile.flush()
|
|
6477
|
+
heartbeat_at = time.monotonic()
|
|
6478
|
+
time.sleep(0.5 if has_active_sessions else 2.0)
|
|
5927
6479
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
5928
6480
|
return
|
|
5929
6481
|
|