@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.
Files changed (119) hide show
  1. package/README.md +8 -0
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +134 -49
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  7. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  8. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  9. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  10. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  11. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  12. package/docs/en/README.md +6 -0
  13. package/docs/zh/00_QUICK_START.md +2 -2
  14. package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
  15. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  16. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  17. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  18. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  19. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  20. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  21. package/docs/zh/README.md +6 -0
  22. package/install.sh +2 -0
  23. package/package.json +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/deepscientist/__init__.py +1 -1
  26. package/src/deepscientist/artifact/charts.py +567 -0
  27. package/src/deepscientist/artifact/guidance.py +50 -10
  28. package/src/deepscientist/artifact/metrics.py +228 -5
  29. package/src/deepscientist/artifact/schemas.py +3 -0
  30. package/src/deepscientist/artifact/service.py +3534 -191
  31. package/src/deepscientist/bash_exec/models.py +23 -0
  32. package/src/deepscientist/bash_exec/monitor.py +147 -67
  33. package/src/deepscientist/bash_exec/runtime.py +218 -156
  34. package/src/deepscientist/bash_exec/service.py +79 -64
  35. package/src/deepscientist/bash_exec/shells.py +87 -0
  36. package/src/deepscientist/bridges/connectors.py +51 -2
  37. package/src/deepscientist/config/models.py +6 -3
  38. package/src/deepscientist/config/service.py +7 -2
  39. package/src/deepscientist/connector/weixin_support.py +122 -1
  40. package/src/deepscientist/daemon/api/handlers.py +75 -4
  41. package/src/deepscientist/daemon/api/router.py +1 -0
  42. package/src/deepscientist/daemon/app.py +758 -206
  43. package/src/deepscientist/doctor.py +51 -0
  44. package/src/deepscientist/file_lock.py +48 -0
  45. package/src/deepscientist/gitops/diff.py +167 -1
  46. package/src/deepscientist/mcp/server.py +173 -5
  47. package/src/deepscientist/process_control.py +161 -0
  48. package/src/deepscientist/prompts/builder.py +267 -442
  49. package/src/deepscientist/quest/service.py +2255 -163
  50. package/src/deepscientist/quest/stage_views.py +171 -0
  51. package/src/deepscientist/runners/base.py +2 -0
  52. package/src/deepscientist/runners/codex.py +88 -5
  53. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  54. package/src/prompts/contracts/shared_interaction.md +13 -4
  55. package/src/prompts/system.md +916 -72
  56. package/src/skills/analysis-campaign/SKILL.md +31 -2
  57. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  58. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  59. package/src/skills/baseline/SKILL.md +2 -0
  60. package/src/skills/decision/SKILL.md +19 -2
  61. package/src/skills/experiment/SKILL.md +8 -2
  62. package/src/skills/finalize/SKILL.md +18 -0
  63. package/src/skills/idea/SKILL.md +78 -0
  64. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  65. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  66. package/src/skills/intake-audit/SKILL.md +1 -1
  67. package/src/skills/optimize/SKILL.md +1644 -0
  68. package/src/skills/rebuttal/SKILL.md +2 -1
  69. package/src/skills/review/SKILL.md +2 -1
  70. package/src/skills/write/SKILL.md +80 -12
  71. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  72. package/src/tui/dist/app/AppContainer.js +3 -0
  73. package/src/tui/package.json +1 -1
  74. package/src/ui/dist/assets/{AiManusChatView-DaF9Nge_.js → AiManusChatView-DDjbFnbt.js} +12 -12
  75. package/src/ui/dist/assets/{AnalysisPlugin-BSVx6dXE.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
  76. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
  77. package/src/ui/dist/assets/{CodeEditorPlugin-DU9G0Tox.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
  78. package/src/ui/dist/assets/{CodeViewerPlugin-DoX_fI9l.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
  79. package/src/ui/dist/assets/{DocViewerPlugin-C4FWIXuU.js → DocViewerPlugin-CLChbllo.js} +3 -3
  80. package/src/ui/dist/assets/{GitDiffViewerPlugin-BgfFMgtf.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
  81. package/src/ui/dist/assets/{ImageViewerPlugin-tcPkfY_x.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
  82. package/src/ui/dist/assets/{LabCopilotPanel-_dKV60Bf.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
  83. package/src/ui/dist/assets/{LabPlugin-Bje0ayoC.js → LabPlugin-DQPg-NrB.js} +2 -2
  84. package/src/ui/dist/assets/{LatexPlugin-CVsBzAln.js → LatexPlugin-CI05XAV9.js} +7 -7
  85. package/src/ui/dist/assets/{MarkdownViewerPlugin-xjmrqv_8.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
  86. package/src/ui/dist/assets/{MarketplacePlugin-mMM2A8wP.js → MarketplacePlugin-DolE58Q2.js} +3 -3
  87. package/src/ui/dist/assets/{NotebookEditor-3kVDSOBo.js → NotebookEditor-7Qm2rSWD.js} +11 -11
  88. package/src/ui/dist/assets/{NotebookEditor-SoJ8X-MO.js → NotebookEditor-C1kWaxKi.js} +1 -1
  89. package/src/ui/dist/assets/{PdfLoader-DElVuHl9.js → PdfLoader-BfOHw8Zw.js} +1 -1
  90. package/src/ui/dist/assets/{PdfMarkdownPlugin-Bq88XT4G.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
  91. package/src/ui/dist/assets/{PdfViewerPlugin-CsCXMo9S.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
  92. package/src/ui/dist/assets/{SearchPlugin-oUPvy19k.js → SearchPlugin-CjpaiJ3A.js} +1 -1
  93. package/src/ui/dist/assets/{TextViewerPlugin-CRkT9yNy.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
  94. package/src/ui/dist/assets/{VNCViewer-BgbuvWhR.js → VNCViewer-HAg9mF7M.js} +10 -10
  95. package/src/ui/dist/assets/{bot-v_RASACv.js → bot-0DYntytV.js} +1 -1
  96. package/src/ui/dist/assets/{code-5hC9d0VH.js → code-B20Slj_w.js} +1 -1
  97. package/src/ui/dist/assets/{file-content-D1PxfOrp.js → file-content-DT24KFma.js} +1 -1
  98. package/src/ui/dist/assets/{file-diff-panel-DG1oT_Hj.js → file-diff-panel-DK13YPql.js} +1 -1
  99. package/src/ui/dist/assets/{file-socket-BmdFYQlk.js → file-socket-B4T2o4nR.js} +1 -1
  100. package/src/ui/dist/assets/{image-Dqe2X2tW.js → image-DSeR_sDS.js} +1 -1
  101. package/src/ui/dist/assets/{index-RDlNXXx1.js → index-BrFje2Uk.js} +2 -2
  102. package/src/ui/dist/assets/{index-DVsMKK_y.js → index-BwRJaoTl.js} +1 -1
  103. package/src/ui/dist/assets/{index-Nt9hS4ck.js → index-D_E4281X.js} +5007 -28514
  104. package/src/ui/dist/assets/{index-Duvz8Ip0.js → index-DnYB3xb1.js} +12 -12
  105. package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
  106. package/src/ui/dist/assets/{monaco-DIXge1CP.js → monaco-LExaAN3Y.js} +1 -1
  107. package/src/ui/dist/assets/{pdf-effect-queue-BBTTQaO-.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
  108. package/src/ui/dist/assets/{popover-BWlolyxo.js → popover-D3Gg_FoV.js} +1 -1
  109. package/src/ui/dist/assets/{project-sync-BM5PkFH4.js → project-sync-C_ygLlVU.js} +1 -1
  110. package/src/ui/dist/assets/{select-D4dAtrA8.js → select-CpAK6uWm.js} +2 -2
  111. package/src/ui/dist/assets/{sigma-CKbE5jJT.js → sigma-DEccaSgk.js} +1 -1
  112. package/src/ui/dist/assets/{square-check-big-CZNGMgiB.js → square-check-big-uUfyVsbD.js} +1 -1
  113. package/src/ui/dist/assets/{trash-DaB37xAz.js → trash-CXvwwSe8.js} +1 -1
  114. package/src/ui/dist/assets/{useCliAccess-C2OmAcWe.js → useCliAccess-Bnop4mgR.js} +1 -1
  115. package/src/ui/dist/assets/{useFileDiffOverlay-Dowd1Ij4.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
  116. package/src/ui/dist/assets/{wrap-text-BGjAhAUq.js → wrap-text-9vbOBpkW.js} +1 -1
  117. package/src/ui/dist/assets/{zoom-out-dMZQMXzc.js → zoom-out-BgVMmOW4.js} +1 -1
  118. package/src/ui/dist/index.html +2 -2
  119. 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.runtime import TerminalClient
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
- with self._turn_lock:
1306
- turn_state = dict(self._turn_state.get(quest_id) or {})
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
- self._run_quest_turn(quest_id)
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
- skill_id = self._turn_skill_for(snapshot, latest_user_message, turn_reason=turn_reason)
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
- result = runner.run(request)
2008
- except Exception as exc: # pragma: no cover - exercised via integration behavior
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
- failure_summary = f"Runner `{runner_name}` failed on attempt {attempt_index}/{max_attempts}: {exc}"
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=current_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="exception",
2281
+ failure_kind="exit_code",
2019
2282
  failure_summary=failure_summary,
2020
- previous_exit_code=None,
2021
- previous_output_text="",
2022
- stderr_text=str(exc),
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": current_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=current_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=current_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=current_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=current_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
- if self._turn_stop_requested(quest_id):
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
- event_type="runner.turn_retry_aborted",
2186
- runner_name=runner_name,
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 _turn_skill_for(snapshot: dict, latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
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
- active_anchor = str(snapshot.get("active_anchor") or "").strip()
2231
- return active_anchor if active_anchor in STANDARD_SKILLS else "decision"
2232
- reply_target = str(latest_user_message.get("reply_to_interaction_id") or "").strip()
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 and (
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 active_anchor if active_anchor in STANDARD_SKILLS else "decision"
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 _normalize_status_after_turn(self, quest_id: str) -> None:
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
- self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
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
- stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
5806
- payload = self.handlers.quest_events(quest_id, path=stream_path)
5807
- updates = payload.get("acp_updates") or []
5808
- if updates:
5809
- for update in updates:
5810
- update_cursor = str(((update.get("params") or {}).get("update") or {}).get("cursor") or "")
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="acp_update",
5814
- data=update,
5815
- event_id=update_cursor or None,
6337
+ event="cursor",
6338
+ data={"cursor": current_cursor, "quest_id": quest_id},
5816
6339
  )
5817
- current_cursor = int(payload.get("cursor") or current_cursor)
5818
- self._write_sse_event(
5819
- handler,
5820
- event="cursor",
5821
- data={"cursor": current_cursor, "quest_id": quest_id},
5822
- )
5823
- heartbeat_at = time.monotonic()
5824
- idle_sleep_seconds = 0.2
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
- sessions = list_payload()
5887
- current_snapshot = {
5888
- str(item.get("bash_id") or ""): item
5889
- for item in sessions
5890
- if item.get("bash_id")
5891
- }
5892
- if not previous_snapshot:
5893
- self._write_sse_event(
5894
- handler,
5895
- event="snapshot",
5896
- data={"sessions": sessions},
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
- previous_snapshot = current_snapshot
5899
- heartbeat_at = time.monotonic()
5900
- else:
5901
- changed = [
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="session",
5911
- data={"session": session},
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
- elif time.monotonic() - heartbeat_at >= 10:
5923
- handler.wfile.write(b": keep-alive\n\n")
5924
- handler.wfile.flush()
5925
- heartbeat_at = time.monotonic()
5926
- time.sleep(0.4)
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