@researai/deepscientist 1.5.11 → 1.5.12
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 -8
- package/bin/ds.js +358 -61
- package/docs/en/00_QUICK_START.md +35 -3
- package/docs/en/01_SETTINGS_REFERENCE.md +11 -0
- package/docs/en/02_START_RESEARCH_GUIDE.md +68 -4
- package/docs/en/09_DOCTOR.md +28 -3
- package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +21 -2
- package/docs/en/15_CODEX_PROVIDER_SETUP.md +284 -0
- package/docs/en/README.md +4 -0
- package/docs/zh/00_QUICK_START.md +34 -2
- package/docs/zh/01_SETTINGS_REFERENCE.md +11 -0
- package/docs/zh/02_START_RESEARCH_GUIDE.md +69 -3
- package/docs/zh/09_DOCTOR.md +28 -1
- package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +21 -2
- package/docs/zh/15_CODEX_PROVIDER_SETUP.md +285 -0
- package/docs/zh/README.md +4 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/bash_exec/monitor.py +7 -5
- package/src/deepscientist/bash_exec/service.py +84 -21
- package/src/deepscientist/channels/local.py +3 -3
- package/src/deepscientist/channels/qq.py +7 -7
- package/src/deepscientist/channels/relay.py +7 -7
- package/src/deepscientist/channels/weixin_ilink.py +90 -19
- package/src/deepscientist/config/models.py +1 -0
- package/src/deepscientist/config/service.py +121 -20
- package/src/deepscientist/daemon/app.py +314 -6
- package/src/deepscientist/doctor.py +1 -5
- package/src/deepscientist/mcp/server.py +124 -3
- package/src/deepscientist/prompts/builder.py +113 -11
- package/src/deepscientist/quest/service.py +247 -31
- package/src/deepscientist/runners/codex.py +121 -22
- package/src/deepscientist/runners/runtime_overrides.py +6 -0
- package/src/deepscientist/shared.py +33 -14
- package/src/prompts/connectors/qq.md +2 -1
- package/src/prompts/connectors/weixin.md +2 -1
- package/src/prompts/contracts/shared_interaction.md +4 -1
- package/src/prompts/system.md +59 -9
- package/src/skills/analysis-campaign/SKILL.md +46 -6
- package/src/skills/analysis-campaign/references/campaign-plan-template.md +21 -8
- package/src/skills/baseline/SKILL.md +1 -1
- package/src/skills/decision/SKILL.md +1 -1
- package/src/skills/experiment/SKILL.md +1 -1
- package/src/skills/finalize/SKILL.md +1 -1
- package/src/skills/idea/SKILL.md +1 -1
- package/src/skills/intake-audit/SKILL.md +1 -1
- package/src/skills/rebuttal/SKILL.md +74 -1
- package/src/skills/rebuttal/references/response-letter-template.md +55 -11
- package/src/skills/review/SKILL.md +118 -1
- package/src/skills/review/references/experiment-todo-template.md +23 -0
- package/src/skills/review/references/review-report-template.md +16 -0
- package/src/skills/review/references/revision-log-template.md +4 -0
- package/src/skills/scout/SKILL.md +1 -1
- package/src/skills/write/SKILL.md +168 -7
- package/src/skills/write/references/paper-experiment-matrix-template.md +131 -0
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-D0mTXG4-.js → AiManusChatView-CnJcXynW.js} +12 -12
- package/src/ui/dist/assets/{AnalysisPlugin-Db0cTXxm.js → AnalysisPlugin-DeyzPEhV.js} +1 -1
- package/src/ui/dist/assets/{CliPlugin-DrV8je02.js → CliPlugin-CB1YODQn.js} +9 -9
- package/src/ui/dist/assets/{CodeEditorPlugin-QXMSCH71.js → CodeEditorPlugin-B-xicq1e.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-7hhtWj_E.js → CodeViewerPlugin-DT54ysXa.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-BWMSnRJe.js → DocViewerPlugin-DQtKT-VD.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-7J9h9Vy_.js → GitDiffViewerPlugin-hqHbCfnv.js} +20 -20
- package/src/ui/dist/assets/{ImageViewerPlugin-CHJl_0lr.js → ImageViewerPlugin-OcVo33jV.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-1qSow1es.js → LabCopilotPanel-DdGwhEUV.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-eQpPPCEp.js → LabPlugin-Ciz1gDaX.js} +2 -2
- package/src/ui/dist/assets/{LatexPlugin-BwRfi89Z.js → LatexPlugin-BhmjNQRC.js} +37 -11
- package/src/ui/dist/assets/{MarkdownViewerPlugin-836PVQWV.js → MarkdownViewerPlugin-BzdVH9Bx.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-C2y_556i.js → MarketplacePlugin-DmyHspXt.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-DIX7Mlzu.js → NotebookEditor-BMXKrDRk.js} +1 -1
- package/src/ui/dist/assets/{NotebookEditor-BRzJbGsn.js → NotebookEditor-BTVYRGkm.js} +11 -11
- package/src/ui/dist/assets/{PdfLoader-DzRaTAlq.js → PdfLoader-CvcjJHXv.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-DZUfIUnp.js → PdfMarkdownPlugin-DW2ej8Vk.js} +2 -2
- package/src/ui/dist/assets/{PdfViewerPlugin-BwtICzue.js → PdfViewerPlugin-CmlDxbhU.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-DHeIAMsx.js → SearchPlugin-DAjQZPSv.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-C3tCmFox.js → TextViewerPlugin-C-nVAZb_.js} +5 -5
- package/src/ui/dist/assets/{VNCViewer-CQsKVm3t.js → VNCViewer-D7-dIYon.js} +10 -10
- package/src/ui/dist/assets/{bot-BEA2vWuK.js → bot-C_G4WtNI.js} +1 -1
- package/src/ui/dist/assets/{code-XfbSR8K2.js → code-Cd7WfiWq.js} +1 -1
- package/src/ui/dist/assets/{file-content-BjxNaIfy.js → file-content-B57zsL9y.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-D_lLVQk0.js → file-diff-panel-DVoheLFq.js} +1 -1
- package/src/ui/dist/assets/{file-socket-D9x_5vlY.js → file-socket-B5kXFxZP.js} +1 -1
- package/src/ui/dist/assets/{image-BhWT33W1.js → image-LLOjkMHF.js} +1 -1
- package/src/ui/dist/assets/{index-Dqj-Mjb4.css → index-BQG-1s2o.css} +40 -2
- package/src/ui/dist/assets/{index--c4iXtuy.js → index-C3r2iGrp.js} +12 -12
- package/src/ui/dist/assets/{index-DZTZ8mWP.js → index-CLQauncb.js} +911 -120
- package/src/ui/dist/assets/{index-PJbSbPTy.js → index-Dxa2eYMY.js} +1 -1
- package/src/ui/dist/assets/{index-BDxipwrC.js → index-hOUOWbW2.js} +2 -2
- package/src/ui/dist/assets/{monaco-K8izTGgo.js → monaco-BGGAEii3.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-DfBors6y.js → pdf-effect-queue-DlEr1_y5.js} +1 -1
- package/src/ui/dist/assets/{popover-yFK1J4fL.js → popover-CWJbJuYY.js} +1 -1
- package/src/ui/dist/assets/{project-sync-PENr2zcz.js → project-sync-CRJiucYO.js} +18 -4
- package/src/ui/dist/assets/{select-CAbJDfYv.js → select-CoHB7pvH.js} +2 -2
- package/src/ui/dist/assets/{sigma-DEuYJqTl.js → sigma-D5aJWR8J.js} +1 -1
- package/src/ui/dist/assets/{square-check-big-omoSUmcd.js → square-check-big-DUK_mnkS.js} +1 -1
- package/src/ui/dist/assets/{trash--F119N47.js → trash-ChU3SEE3.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-D31UR23I.js → useCliAccess-BrJBV3tY.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-BH6KcMzq.js → useFileDiffOverlay-C2OQaVWc.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-CZ613PM5.js → wrap-text-C7Qqh-om.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-BgDLAv3z.js → zoom-out-rtX0FKya.js} +1 -1
- package/src/ui/dist/index.html +2 -2
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
from collections import deque
|
|
5
|
+
import faulthandler
|
|
4
6
|
import json
|
|
5
7
|
import mimetypes
|
|
6
8
|
import os
|
|
7
9
|
import re
|
|
10
|
+
import signal
|
|
8
11
|
import shutil
|
|
9
12
|
import subprocess
|
|
13
|
+
import sys
|
|
10
14
|
import threading
|
|
11
15
|
import time
|
|
16
|
+
import traceback
|
|
12
17
|
from datetime import UTC, datetime, timedelta
|
|
13
18
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
19
|
from pathlib import Path
|
|
@@ -67,7 +72,7 @@ from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, n
|
|
|
67
72
|
from ..quest import QuestService
|
|
68
73
|
from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
|
|
69
74
|
from ..runtime_logs import JsonlLogger
|
|
70
|
-
from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
|
|
75
|
+
from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
|
|
71
76
|
from ..skills import SkillInstaller
|
|
72
77
|
from ..team import SingleTeamService
|
|
73
78
|
from ..connector.weixin_support import (
|
|
@@ -87,6 +92,7 @@ from websockets.sync.server import Server as WebSocketServer
|
|
|
87
92
|
from websockets.sync.server import ServerConnection, serve as websocket_serve
|
|
88
93
|
|
|
89
94
|
TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
|
|
95
|
+
_AUTO_CONTINUE_DELAY_SECONDS = 0.2
|
|
90
96
|
CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
|
|
91
97
|
CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
|
|
92
98
|
CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
|
|
@@ -94,6 +100,8 @@ CODEX_RETRY_DEFAULT_MAX_BACKOFF_SEC = 1800.0
|
|
|
94
100
|
LEGACY_CODEX_RETRY_INITIAL_BACKOFF_SEC = 1.0
|
|
95
101
|
LEGACY_CODEX_RETRY_BACKOFF_MULTIPLIER = 2.0
|
|
96
102
|
LEGACY_CODEX_RETRY_MAX_BACKOFF_SEC = 8.0
|
|
103
|
+
_CRASH_AUTO_RESUME_COOLDOWN = timedelta(minutes=10)
|
|
104
|
+
_CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS = 2
|
|
97
105
|
_LINGZHU_SHORT_COMMAND_DIRECT_MAP = {
|
|
98
106
|
"帮助": "help",
|
|
99
107
|
"列表": "list",
|
|
@@ -188,6 +196,9 @@ class DaemonApp:
|
|
|
188
196
|
self._feishu_long_connection: dict[str, FeishuLongConnectionService] = {}
|
|
189
197
|
self._whatsapp_local_session: dict[str, WhatsAppLocalSessionService] = {}
|
|
190
198
|
self._weixin_login_sessions: dict[str, dict[str, Any]] = {}
|
|
199
|
+
self._process_hooks_installed = False
|
|
200
|
+
self._faulthandler_stream = None
|
|
201
|
+
self._recovered_quest_ids: set[str] = set()
|
|
191
202
|
self.handlers = ApiHandlers(self)
|
|
192
203
|
|
|
193
204
|
def list_connector_statuses(self) -> list[dict[str, object]]:
|
|
@@ -378,6 +389,262 @@ class DaemonApp:
|
|
|
378
389
|
"available_connectors": available_connectors,
|
|
379
390
|
}
|
|
380
391
|
|
|
392
|
+
def _log_unhandled_exception(
|
|
393
|
+
self,
|
|
394
|
+
*,
|
|
395
|
+
event_type: str,
|
|
396
|
+
exc_type: type[BaseException],
|
|
397
|
+
exc_value: BaseException,
|
|
398
|
+
exc_traceback: Any,
|
|
399
|
+
thread_name: str | None = None,
|
|
400
|
+
) -> None:
|
|
401
|
+
try:
|
|
402
|
+
traceback_text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
|
403
|
+
except Exception:
|
|
404
|
+
traceback_text = str(exc_value or "")
|
|
405
|
+
payload: dict[str, Any] = {
|
|
406
|
+
"exception_type": getattr(exc_type, "__name__", str(exc_type)),
|
|
407
|
+
"message": str(exc_value),
|
|
408
|
+
"traceback": traceback_text,
|
|
409
|
+
"pid": os.getpid(),
|
|
410
|
+
}
|
|
411
|
+
if thread_name:
|
|
412
|
+
payload["thread_name"] = thread_name
|
|
413
|
+
self.logger.log("error", event_type, **payload)
|
|
414
|
+
|
|
415
|
+
def _handle_process_signal(self, signame: str) -> None:
|
|
416
|
+
self.logger.log(
|
|
417
|
+
"warning",
|
|
418
|
+
"daemon.signal_received",
|
|
419
|
+
signal=signame,
|
|
420
|
+
pid=os.getpid(),
|
|
421
|
+
)
|
|
422
|
+
self.request_shutdown(source=f"signal:{str(signame).lower()}")
|
|
423
|
+
|
|
424
|
+
def _install_process_observability(self) -> None:
|
|
425
|
+
if self._process_hooks_installed:
|
|
426
|
+
return
|
|
427
|
+
self._process_hooks_installed = True
|
|
428
|
+
faulthandler_path = self.home / "logs" / "daemon-faulthandler.log"
|
|
429
|
+
try:
|
|
430
|
+
ensure_dir(faulthandler_path.parent)
|
|
431
|
+
self._faulthandler_stream = open(faulthandler_path, "a", encoding="utf-8")
|
|
432
|
+
faulthandler.enable(file=self._faulthandler_stream)
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
self.logger.log("warning", "daemon.faulthandler_enable_failed", error=str(exc))
|
|
435
|
+
|
|
436
|
+
def _sys_excepthook(exc_type: type[BaseException], exc_value: BaseException, exc_traceback: Any) -> None:
|
|
437
|
+
self._log_unhandled_exception(
|
|
438
|
+
event_type="daemon.unhandled_exception",
|
|
439
|
+
exc_type=exc_type,
|
|
440
|
+
exc_value=exc_value,
|
|
441
|
+
exc_traceback=exc_traceback,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def _thread_excepthook(args: threading.ExceptHookArgs) -> None:
|
|
445
|
+
self._log_unhandled_exception(
|
|
446
|
+
event_type="daemon.thread_exception",
|
|
447
|
+
exc_type=args.exc_type,
|
|
448
|
+
exc_value=args.exc_value,
|
|
449
|
+
exc_traceback=args.exc_traceback,
|
|
450
|
+
thread_name=getattr(args.thread, "name", None),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
sys.excepthook = _sys_excepthook
|
|
454
|
+
threading.excepthook = _thread_excepthook
|
|
455
|
+
for signame in ("SIGTERM", "SIGHUP", "SIGINT"):
|
|
456
|
+
signum = getattr(signal, signame, None)
|
|
457
|
+
if signum is None:
|
|
458
|
+
continue
|
|
459
|
+
try:
|
|
460
|
+
signal.signal(signum, lambda _signum, _frame, _signame=signame: self._handle_process_signal(_signame))
|
|
461
|
+
except Exception as exc:
|
|
462
|
+
self.logger.log(
|
|
463
|
+
"warning",
|
|
464
|
+
"daemon.signal_handler_install_failed",
|
|
465
|
+
signal=signame,
|
|
466
|
+
error=str(exc),
|
|
467
|
+
)
|
|
468
|
+
self.logger.log("info", "daemon.process_hooks_installed", pid=os.getpid())
|
|
469
|
+
|
|
470
|
+
def _resume_reconciled_quests(self) -> list[dict[str, Any]]:
|
|
471
|
+
resumed: list[dict[str, Any]] = []
|
|
472
|
+
for item in self.reconciled_quests:
|
|
473
|
+
if not isinstance(item, dict):
|
|
474
|
+
continue
|
|
475
|
+
quest_id = str(item.get("quest_id") or "").strip()
|
|
476
|
+
if not quest_id or quest_id in self._recovered_quest_ids:
|
|
477
|
+
continue
|
|
478
|
+
if not bool(item.get("recoverable")):
|
|
479
|
+
continue
|
|
480
|
+
recent_attempts = self._recent_crash_auto_resume_count(quest_id)
|
|
481
|
+
if recent_attempts >= _CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS:
|
|
482
|
+
self._record_auto_resume_suppressed(
|
|
483
|
+
quest_id=quest_id,
|
|
484
|
+
previous_status=str(item.get("previous_status") or "").strip() or None,
|
|
485
|
+
abandoned_run_id=str(item.get("abandoned_run_id") or "").strip() or None,
|
|
486
|
+
last_transition_at=str(item.get("last_transition_at") or "").strip() or None,
|
|
487
|
+
recent_attempts=recent_attempts,
|
|
488
|
+
)
|
|
489
|
+
continue
|
|
490
|
+
try:
|
|
491
|
+
resume_payload = self.resume_quest(quest_id, source="auto:daemon-recovery")
|
|
492
|
+
snapshot = (
|
|
493
|
+
dict(resume_payload.get("snapshot") or {})
|
|
494
|
+
if isinstance(resume_payload.get("snapshot"), dict)
|
|
495
|
+
else self.quest_service.snapshot(quest_id)
|
|
496
|
+
)
|
|
497
|
+
reason = (
|
|
498
|
+
"queued_user_messages"
|
|
499
|
+
if int(snapshot.get("pending_user_message_count") or 0) > 0
|
|
500
|
+
else "auto_continue"
|
|
501
|
+
)
|
|
502
|
+
scheduled = self.schedule_turn(quest_id, reason=reason)
|
|
503
|
+
event = {
|
|
504
|
+
"event_id": generate_id("evt"),
|
|
505
|
+
"type": "quest.runtime_auto_resumed",
|
|
506
|
+
"quest_id": quest_id,
|
|
507
|
+
"previous_status": item.get("previous_status"),
|
|
508
|
+
"abandoned_run_id": item.get("abandoned_run_id"),
|
|
509
|
+
"last_transition_at": item.get("last_transition_at"),
|
|
510
|
+
"reason": reason,
|
|
511
|
+
"scheduled": bool(scheduled.get("scheduled")),
|
|
512
|
+
"started": bool(scheduled.get("started")),
|
|
513
|
+
"queued": bool(scheduled.get("queued")),
|
|
514
|
+
"created_at": utc_now(),
|
|
515
|
+
}
|
|
516
|
+
append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", event)
|
|
517
|
+
self.logger.log(
|
|
518
|
+
"warning",
|
|
519
|
+
"quest.runtime_auto_resumed",
|
|
520
|
+
quest_id=quest_id,
|
|
521
|
+
previous_status=item.get("previous_status"),
|
|
522
|
+
abandoned_run_id=item.get("abandoned_run_id"),
|
|
523
|
+
last_transition_at=item.get("last_transition_at"),
|
|
524
|
+
reason=reason,
|
|
525
|
+
scheduled=bool(scheduled.get("scheduled")),
|
|
526
|
+
started=bool(scheduled.get("started")),
|
|
527
|
+
queued=bool(scheduled.get("queued")),
|
|
528
|
+
)
|
|
529
|
+
self._recovered_quest_ids.add(quest_id)
|
|
530
|
+
resumed.append(
|
|
531
|
+
{
|
|
532
|
+
"quest_id": quest_id,
|
|
533
|
+
"previous_status": item.get("previous_status"),
|
|
534
|
+
"abandoned_run_id": item.get("abandoned_run_id"),
|
|
535
|
+
"last_transition_at": item.get("last_transition_at"),
|
|
536
|
+
"reason": reason,
|
|
537
|
+
"scheduled": dict(scheduled),
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
self.logger.log(
|
|
542
|
+
"warning",
|
|
543
|
+
"quest.runtime_auto_resume_failed",
|
|
544
|
+
quest_id=quest_id,
|
|
545
|
+
previous_status=item.get("previous_status"),
|
|
546
|
+
abandoned_run_id=item.get("abandoned_run_id"),
|
|
547
|
+
error=str(exc),
|
|
548
|
+
)
|
|
549
|
+
return resumed
|
|
550
|
+
|
|
551
|
+
@staticmethod
|
|
552
|
+
def _parse_event_timestamp(value: Any) -> datetime | None:
|
|
553
|
+
normalized = str(value or "").strip()
|
|
554
|
+
if not normalized:
|
|
555
|
+
return None
|
|
556
|
+
candidate = normalized.replace("Z", "+00:00")
|
|
557
|
+
try:
|
|
558
|
+
parsed = datetime.fromisoformat(candidate)
|
|
559
|
+
except ValueError:
|
|
560
|
+
return None
|
|
561
|
+
if parsed.tzinfo is None:
|
|
562
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
563
|
+
return parsed.astimezone(UTC)
|
|
564
|
+
|
|
565
|
+
def _recent_crash_auto_resume_count(self, quest_id: str) -> int:
|
|
566
|
+
quest_root = self.home / "quests" / quest_id
|
|
567
|
+
events = read_jsonl_tail(quest_root / ".ds" / "events.jsonl", 400)
|
|
568
|
+
now = datetime.now(UTC)
|
|
569
|
+
count = 0
|
|
570
|
+
for event in reversed(events[-200:]):
|
|
571
|
+
if str(event.get("type") or "").strip() != "quest.runtime_auto_resumed":
|
|
572
|
+
continue
|
|
573
|
+
parsed = self._parse_event_timestamp(event.get("created_at"))
|
|
574
|
+
if parsed is None:
|
|
575
|
+
continue
|
|
576
|
+
if now - parsed > _CRASH_AUTO_RESUME_COOLDOWN:
|
|
577
|
+
continue
|
|
578
|
+
count += 1
|
|
579
|
+
return count
|
|
580
|
+
|
|
581
|
+
def _record_auto_resume_suppressed(
|
|
582
|
+
self,
|
|
583
|
+
*,
|
|
584
|
+
quest_id: str,
|
|
585
|
+
previous_status: str | None,
|
|
586
|
+
abandoned_run_id: str | None,
|
|
587
|
+
last_transition_at: str | None,
|
|
588
|
+
recent_attempts: int,
|
|
589
|
+
) -> None:
|
|
590
|
+
summary = self._polite_copy(
|
|
591
|
+
zh=(
|
|
592
|
+
"检测到 quest 在短时间内连续异常恢复,系统已暂时停止自动继续运行,"
|
|
593
|
+
"以避免进入重复崩溃循环。请先检查运行环境或 runner 侧问题,再手动恢复。"
|
|
594
|
+
),
|
|
595
|
+
en=(
|
|
596
|
+
"DeepScientist detected repeated crash recovery attempts in a short window, "
|
|
597
|
+
"so automatic continuation has been paused to avoid a crash loop. "
|
|
598
|
+
"Inspect the runtime or runner path before resuming manually."
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
payload = {
|
|
602
|
+
"event_id": generate_id("evt"),
|
|
603
|
+
"type": "quest.runtime_auto_resume_suppressed",
|
|
604
|
+
"quest_id": quest_id,
|
|
605
|
+
"previous_status": previous_status,
|
|
606
|
+
"abandoned_run_id": abandoned_run_id,
|
|
607
|
+
"last_transition_at": last_transition_at,
|
|
608
|
+
"recent_attempts": recent_attempts,
|
|
609
|
+
"cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
|
|
610
|
+
"summary": summary,
|
|
611
|
+
"created_at": utc_now(),
|
|
612
|
+
}
|
|
613
|
+
append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", payload)
|
|
614
|
+
self.logger.log(
|
|
615
|
+
"warning",
|
|
616
|
+
"quest.runtime_auto_resume_suppressed",
|
|
617
|
+
quest_id=quest_id,
|
|
618
|
+
previous_status=previous_status,
|
|
619
|
+
abandoned_run_id=abandoned_run_id,
|
|
620
|
+
last_transition_at=last_transition_at,
|
|
621
|
+
recent_attempts=recent_attempts,
|
|
622
|
+
cooldown_minutes=int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
|
|
623
|
+
)
|
|
624
|
+
self.quest_service.append_message(
|
|
625
|
+
quest_id,
|
|
626
|
+
role="assistant",
|
|
627
|
+
content=summary,
|
|
628
|
+
source="system-control",
|
|
629
|
+
)
|
|
630
|
+
self._relay_quest_message_to_bound_connectors(
|
|
631
|
+
quest_id,
|
|
632
|
+
message=summary,
|
|
633
|
+
kind="progress",
|
|
634
|
+
response_phase="control",
|
|
635
|
+
importance="warning",
|
|
636
|
+
attachments=[
|
|
637
|
+
{
|
|
638
|
+
"kind": "quest_control",
|
|
639
|
+
"action": "auto_resume_suppressed",
|
|
640
|
+
"previous_status": previous_status,
|
|
641
|
+
"abandoned_run_id": abandoned_run_id,
|
|
642
|
+
"recent_attempts": recent_attempts,
|
|
643
|
+
"cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
|
|
644
|
+
}
|
|
645
|
+
],
|
|
646
|
+
)
|
|
647
|
+
|
|
381
648
|
def _normalize_requested_connector_bindings(
|
|
382
649
|
self,
|
|
383
650
|
requested_connector_bindings: list[dict[str, object]] | None,
|
|
@@ -1361,6 +1628,7 @@ class DaemonApp:
|
|
|
1361
1628
|
status=str(snapshot.get("status") or next_status),
|
|
1362
1629
|
interrupted=False,
|
|
1363
1630
|
summary=summary,
|
|
1631
|
+
automated=source.startswith("auto:"),
|
|
1364
1632
|
)
|
|
1365
1633
|
notice = self._announce_control_state(
|
|
1366
1634
|
quest_id,
|
|
@@ -1425,6 +1693,7 @@ class DaemonApp:
|
|
|
1425
1693
|
message = self._control_notice_message(
|
|
1426
1694
|
quest_id,
|
|
1427
1695
|
action=action,
|
|
1696
|
+
source=source,
|
|
1428
1697
|
snapshot=snapshot,
|
|
1429
1698
|
interrupted=interrupted,
|
|
1430
1699
|
cancelled_pending_user_message_count=cancelled_pending_user_message_count,
|
|
@@ -1485,6 +1754,7 @@ class DaemonApp:
|
|
|
1485
1754
|
quest_id: str,
|
|
1486
1755
|
*,
|
|
1487
1756
|
action: str,
|
|
1757
|
+
source: str,
|
|
1488
1758
|
snapshot: dict,
|
|
1489
1759
|
interrupted: bool,
|
|
1490
1760
|
cancelled_pending_user_message_count: int,
|
|
@@ -1503,6 +1773,13 @@ class DaemonApp:
|
|
|
1503
1773
|
en="The current Git branch and worktree were kept intact, and the quest will continue from the existing research context.",
|
|
1504
1774
|
),
|
|
1505
1775
|
]
|
|
1776
|
+
if source.startswith("auto:daemon-recovery"):
|
|
1777
|
+
lines.append(
|
|
1778
|
+
self._polite_copy(
|
|
1779
|
+
zh="检测到 daemon 曾异常退出;当前 quest 已在自动恢复后继续运行。",
|
|
1780
|
+
en="The daemon exited unexpectedly before; this quest has now been recovered automatically and will continue.",
|
|
1781
|
+
)
|
|
1782
|
+
)
|
|
1506
1783
|
elif action == "pause":
|
|
1507
1784
|
lines = [
|
|
1508
1785
|
self._polite_copy(
|
|
@@ -2156,8 +2433,12 @@ class DaemonApp:
|
|
|
2156
2433
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
2157
2434
|
quest_root = Path(snapshot["quest_root"])
|
|
2158
2435
|
workspace_root = Path(str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or quest_root))
|
|
2159
|
-
|
|
2160
|
-
|
|
2436
|
+
run_events_window: deque[dict[str, Any]] = deque(maxlen=120)
|
|
2437
|
+
for event in iter_jsonl(quest_root / ".ds" / "events.jsonl"):
|
|
2438
|
+
if str(event.get("run_id") or "").strip() != failed_run_id:
|
|
2439
|
+
continue
|
|
2440
|
+
run_events_window.append(event)
|
|
2441
|
+
run_events = list(run_events_window)
|
|
2161
2442
|
|
|
2162
2443
|
recent_messages: list[str] = []
|
|
2163
2444
|
tool_progress: list[dict[str, str]] = []
|
|
@@ -2317,12 +2598,29 @@ class DaemonApp:
|
|
|
2317
2598
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2318
2599
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2319
2600
|
else:
|
|
2320
|
-
self.
|
|
2601
|
+
self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
|
|
2321
2602
|
return
|
|
2322
2603
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2323
2604
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2324
2605
|
return
|
|
2325
|
-
self.
|
|
2606
|
+
self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
|
|
2607
|
+
|
|
2608
|
+
def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
|
|
2609
|
+
def _delayed() -> None:
|
|
2610
|
+
time.sleep(max(0.0, delay_seconds))
|
|
2611
|
+
if self._turn_stop_requested(quest_id):
|
|
2612
|
+
return
|
|
2613
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2614
|
+
status = str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip().lower()
|
|
2615
|
+
if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
|
|
2616
|
+
return
|
|
2617
|
+
self.schedule_turn(quest_id, reason=reason)
|
|
2618
|
+
|
|
2619
|
+
threading.Thread(
|
|
2620
|
+
target=_delayed,
|
|
2621
|
+
daemon=True,
|
|
2622
|
+
name=f"deepscientist-turn-delay-{quest_id}",
|
|
2623
|
+
).start()
|
|
2326
2624
|
|
|
2327
2625
|
def _relay_quest_message_to_bound_connectors(
|
|
2328
2626
|
self,
|
|
@@ -5737,6 +6035,7 @@ class DaemonApp:
|
|
|
5737
6035
|
return
|
|
5738
6036
|
|
|
5739
6037
|
def serve(self, host: str, port: int) -> None:
|
|
6038
|
+
self._install_process_observability()
|
|
5740
6039
|
app = self
|
|
5741
6040
|
|
|
5742
6041
|
class RequestHandler(BaseHTTPRequestHandler):
|
|
@@ -5887,11 +6186,20 @@ class DaemonApp:
|
|
|
5887
6186
|
self._shutdown_requested.clear()
|
|
5888
6187
|
self._start_terminal_attach_server(host, port)
|
|
5889
6188
|
self._start_background_connectors()
|
|
6189
|
+
self._resume_reconciled_quests()
|
|
5890
6190
|
print(f"DeepScientist daemon listening on http://{host}:{port}")
|
|
5891
6191
|
try:
|
|
5892
6192
|
server.serve_forever()
|
|
5893
6193
|
except KeyboardInterrupt:
|
|
5894
|
-
|
|
6194
|
+
self.logger.log("warning", "daemon.keyboard_interrupt", pid=os.getpid())
|
|
6195
|
+
except BaseException as exc:
|
|
6196
|
+
self._log_unhandled_exception(
|
|
6197
|
+
event_type="daemon.serve_crashed",
|
|
6198
|
+
exc_type=type(exc),
|
|
6199
|
+
exc_value=exc,
|
|
6200
|
+
exc_traceback=exc.__traceback__,
|
|
6201
|
+
)
|
|
6202
|
+
raise
|
|
5895
6203
|
finally:
|
|
5896
6204
|
self._stop_background_connectors()
|
|
5897
6205
|
self._stop_terminal_attach_server()
|
|
@@ -263,11 +263,7 @@ def _check_codex(config_manager: ConfigManager) -> dict[str, Any]:
|
|
|
263
263
|
ok=False,
|
|
264
264
|
summary="Codex CLI is not available to DeepScientist.",
|
|
265
265
|
errors=[f"Runner binary `{binary}` could not be resolved."],
|
|
266
|
-
guidance=
|
|
267
|
-
"Run `npm install -g @researai/deepscientist` again so the bundled Codex dependency is installed.",
|
|
268
|
-
"If `codex` is still missing, install it explicitly with `npm install -g @openai/codex`.",
|
|
269
|
-
"Then run `codex --login` (or `codex`) once and complete login.",
|
|
270
|
-
],
|
|
266
|
+
guidance=config_manager._codex_missing_binary_guidance(codex_cfg),
|
|
271
267
|
details={"binary": binary},
|
|
272
268
|
)
|
|
273
269
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
from collections import deque
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
from typing import Any
|
|
5
7
|
|
|
6
8
|
from mcp.server.fastmcp import FastMCP
|
|
@@ -95,6 +97,125 @@ def _build_default_bash_log_payload(log_text: str) -> dict[str, Any]:
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
|
|
100
|
+
def _stream_bash_log_summary(path: Path) -> tuple[list[str], int, list[str]]:
|
|
101
|
+
total = 0
|
|
102
|
+
full_lines: list[str] = []
|
|
103
|
+
head_lines: list[str] = []
|
|
104
|
+
tail_lines: deque[str] = deque(maxlen=DEFAULT_INLINE_BASH_LOG_TAIL_LINES)
|
|
105
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
106
|
+
for raw_line in handle:
|
|
107
|
+
line = raw_line.rstrip("\n")
|
|
108
|
+
total += 1
|
|
109
|
+
if total <= DEFAULT_INLINE_BASH_LOG_LINE_LIMIT:
|
|
110
|
+
full_lines.append(line)
|
|
111
|
+
continue
|
|
112
|
+
if total == DEFAULT_INLINE_BASH_LOG_LINE_LIMIT + 1:
|
|
113
|
+
head_lines = full_lines[:DEFAULT_INLINE_BASH_LOG_HEAD_LINES]
|
|
114
|
+
tail_lines.extend(full_lines[-DEFAULT_INLINE_BASH_LOG_TAIL_LINES :])
|
|
115
|
+
full_lines = []
|
|
116
|
+
tail_lines.append(line)
|
|
117
|
+
return full_lines, total, list(head_lines or tail_lines)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_default_bash_log_payload_from_path(path: Path) -> dict[str, Any]:
|
|
121
|
+
if not path.exists():
|
|
122
|
+
return {
|
|
123
|
+
"log": "",
|
|
124
|
+
"log_line_count": 0,
|
|
125
|
+
"log_truncated": False,
|
|
126
|
+
}
|
|
127
|
+
full_lines, total, preview_seed = _stream_bash_log_summary(path)
|
|
128
|
+
if total <= DEFAULT_INLINE_BASH_LOG_LINE_LIMIT:
|
|
129
|
+
return {
|
|
130
|
+
"log": _join_bash_log_lines(full_lines),
|
|
131
|
+
"log_line_count": total,
|
|
132
|
+
"log_truncated": False,
|
|
133
|
+
}
|
|
134
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
135
|
+
tail_lines: deque[str] = deque(maxlen=DEFAULT_INLINE_BASH_LOG_TAIL_LINES)
|
|
136
|
+
for raw_line in handle:
|
|
137
|
+
tail_lines.append(raw_line.rstrip("\n"))
|
|
138
|
+
omitted = total - DEFAULT_INLINE_BASH_LOG_HEAD_LINES - DEFAULT_INLINE_BASH_LOG_TAIL_LINES
|
|
139
|
+
marker = (
|
|
140
|
+
f"[... omitted {omitted} lines from the middle of this log. {LONG_BASH_LOG_HINT}]"
|
|
141
|
+
)
|
|
142
|
+
preview_lines = preview_seed[:DEFAULT_INLINE_BASH_LOG_HEAD_LINES] + [marker] + list(tail_lines)
|
|
143
|
+
return {
|
|
144
|
+
"log": _join_bash_log_lines(preview_lines),
|
|
145
|
+
"log_line_count": total,
|
|
146
|
+
"log_truncated": True,
|
|
147
|
+
"log_preview_head_lines": DEFAULT_INLINE_BASH_LOG_HEAD_LINES,
|
|
148
|
+
"log_preview_tail_lines": DEFAULT_INLINE_BASH_LOG_TAIL_LINES,
|
|
149
|
+
"log_preview_omitted_lines": omitted,
|
|
150
|
+
"log_read_hint": LONG_BASH_LOG_HINT,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_bash_log_window_from_path(path: Path, *, start: int | None = None, tail: int | None = None) -> dict[str, Any]:
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return {
|
|
157
|
+
"log": "",
|
|
158
|
+
"log_line_count": 0,
|
|
159
|
+
"log_windowed": True,
|
|
160
|
+
"line_start": 1,
|
|
161
|
+
"line_end": 0,
|
|
162
|
+
"line_limit": _normalize_bash_log_window_size(tail),
|
|
163
|
+
"returned_line_count": 0,
|
|
164
|
+
"has_more_before": False,
|
|
165
|
+
"has_more_after": False,
|
|
166
|
+
"log_read_hint": LONG_BASH_LOG_HINT,
|
|
167
|
+
}
|
|
168
|
+
line_limit = _normalize_bash_log_window_size(tail)
|
|
169
|
+
if start is not None:
|
|
170
|
+
requested_start = max(1, int(start))
|
|
171
|
+
selected: list[str] = []
|
|
172
|
+
total = 0
|
|
173
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
174
|
+
for raw_line in handle:
|
|
175
|
+
total += 1
|
|
176
|
+
if total < requested_start:
|
|
177
|
+
continue
|
|
178
|
+
if len(selected) < line_limit:
|
|
179
|
+
selected.append(raw_line.rstrip("\n"))
|
|
180
|
+
returned_count = len(selected)
|
|
181
|
+
line_start = requested_start if total else 1
|
|
182
|
+
line_end = requested_start + returned_count - 1 if returned_count else requested_start - 1
|
|
183
|
+
return {
|
|
184
|
+
"log": _join_bash_log_lines(selected),
|
|
185
|
+
"log_line_count": total,
|
|
186
|
+
"log_windowed": True,
|
|
187
|
+
"line_start": line_start,
|
|
188
|
+
"line_end": line_end,
|
|
189
|
+
"line_limit": line_limit,
|
|
190
|
+
"returned_line_count": returned_count,
|
|
191
|
+
"has_more_before": line_start > 1,
|
|
192
|
+
"has_more_after": line_end < total,
|
|
193
|
+
"log_read_hint": LONG_BASH_LOG_HINT,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
tail_lines: deque[str] = deque(maxlen=line_limit)
|
|
197
|
+
total = 0
|
|
198
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
199
|
+
for raw_line in handle:
|
|
200
|
+
total += 1
|
|
201
|
+
tail_lines.append(raw_line.rstrip("\n"))
|
|
202
|
+
returned_count = len(tail_lines)
|
|
203
|
+
line_start = max(1, total - returned_count + 1) if total else 1
|
|
204
|
+
line_end = total
|
|
205
|
+
return {
|
|
206
|
+
"log": _join_bash_log_lines(list(tail_lines)),
|
|
207
|
+
"log_line_count": total,
|
|
208
|
+
"log_windowed": True,
|
|
209
|
+
"line_start": line_start,
|
|
210
|
+
"line_end": line_end,
|
|
211
|
+
"line_limit": line_limit,
|
|
212
|
+
"returned_line_count": returned_count,
|
|
213
|
+
"has_more_before": line_start > 1,
|
|
214
|
+
"has_more_after": False,
|
|
215
|
+
"log_read_hint": LONG_BASH_LOG_HINT,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
98
219
|
def build_memory_server(context: McpContext) -> FastMCP:
|
|
99
220
|
service = MemoryService(context.home)
|
|
100
221
|
server = FastMCP(
|
|
@@ -880,8 +1001,8 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
|
|
|
880
1001
|
export_log_to=export_log_to,
|
|
881
1002
|
)
|
|
882
1003
|
payload.update(
|
|
883
|
-
|
|
884
|
-
service.
|
|
1004
|
+
_build_bash_log_window_from_path(
|
|
1005
|
+
service.terminal_log_path(quest_root, bash_id),
|
|
885
1006
|
start=start,
|
|
886
1007
|
tail=tail if tail is not None else tail_limit,
|
|
887
1008
|
)
|
|
@@ -921,7 +1042,7 @@ def build_bash_exec_server(context: McpContext) -> FastMCP:
|
|
|
921
1042
|
export_log=export_log,
|
|
922
1043
|
export_log_to=export_log_to,
|
|
923
1044
|
)
|
|
924
|
-
payload.update(
|
|
1045
|
+
payload.update(_build_default_bash_log_payload_from_path(service.terminal_log_path(quest_root, bash_id)))
|
|
925
1046
|
return payload
|
|
926
1047
|
if normalized_mode == "kill":
|
|
927
1048
|
bash_id = service.resolve_session_id(quest_root, id)
|