Files

710 lines
26 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""按 session 解析活动任务。
面向用户的概念是唯一的“活动任务”。Trellis 按 AI session/window 将指针存放在
`.trellis/.runtime/sessions/` 下;没有稳定的 session key 时就不存在活动任务。
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DIR_WORKFLOW = ".trellis"
DIR_TASKS = "tasks"
DIR_RUNTIME = ".runtime"
DIR_SESSIONS = "sessions"
DIR_SHELL_TICKETS = "shell-tickets"
# 这是 0.6.13 之前的名称,当时桥接只支持 Cursor。仍然读取它,避免升级过程中
# 正在执行命令的 session 静默降级;不会再写入该名称。ticket 只存活 30 秒,旧目录
# 会自行过期——无需迁移,只需对通常不存在的目录执行 glob。完全忽略旧目录会让
# 那条命令落到当前正在工作的错误平台上。
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
SHELL_TICKET_TTL_SECONDS = 30
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
_KNOWN_PLATFORMS = {
"claude",
"codex",
"cursor",
"opencode",
"gemini",
"droid",
"qoder",
"codebuddy",
"kiro",
"copilot",
"pi",
"trae",
"grok",
"kimi",
"zcode",
"snow",
}
# 下面每个名称都记录了核验依据。不要因为相邻平台相似就类推添加名称:2026-08-05
# 对全部 21 个平台的审计发现,已声明的 21 个名称中有 12 个从未在任何地方存在——
# 它们只是按没有厂商认可的 `<PLATFORM>_SESSION_ID` 形状猜出来的,所谓统一性就是
# 唯一的“证据”。没有已验证名称的平台不应加入任何表,而应通过 TRELLIS_CONTEXT_ID
# 或其 hook/plugin bridge 解析。
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# 已确认但未文档化(2026-08-05 在 Claude Code 2.1.221 的真实 bash 子进程中
# 验证;code.claude.com/docs/en/env-vars 中不存在)。CLAUDE_SESSION_ID 已从此处
# 移除,同一真实环境也确认它不存在。
("claude", ("CLAUDE_CODE_SESSION_ID",)),
# 已确认但未文档化(2026-08-05 验证:由 codex-cli 0.146.0 注入 shell 子进程,
# 父进程环境中不存在;见 openai/codex#19937)。CODEX_SESSION_ID 已移除;真实的
# `codex exec` 环境中不存在它。
("codex", ("CODEX_THREAD_ID",)),
# 已确认但仅限 HOOK 作用域(2026-08-05 验证):由 Gemini 的 hookRunner.ts 设置。
# 其 shell tool 在 shellExecutionService.ts 中构建子进程环境,只添加
# GEMINI_CLI/TERM/PAGER/GIT_PAGER,因此不会到达 bash 子进程,只能在 hook 进程内解析。
("gemini", ("GEMINI_SESSION_ID",)),
# 已确认但仅限 HOOK 作用域(2026-08-05 验证):docs.qoder.com/zh/extensions/hooks
# 说明它由 Qoder *IDE plugin* 在 hook 执行时注入;Qoder CLI hook 文档和灵码文档中
# 没有该变量。
("qoder", ("QODER_SESSION_ID",)),
# 未验证(2026-08-05):kiro.dev/docs/hooks/ 中没有它,但 Dynatrace dtctl、
# oh-my-agent 和 gastown 都用它做 agent 检测,其中一处说明它“在 interactive 和
# --no-interactive 下都会设置”。保留它是因为没有证据不等于证伪。要最终确认,
# 需在装有 Kiro 的机器上从 Kiro shell-tool 调用 `env | grep KIRO`。
("kiro", ("KIRO_SESSION_ID",)),
# 未验证(2026-08-05):docs.github.com/en/copilot/reference/hooks-reference 和
# CLI programmatic reference 中都没有它。要最终确认,应运行 `copilot help environment`
# (上述文档称其为权威列表);当前无法运行,因为本机未安装 CLI,copilot-cli 也不带源码。
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
# 推断但未验证(2026-08-05):ZCode 闭源,当前无法安装。它在其他地方沿用 Claude
# 的命名(其文档有 CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA 兼容别名),而此前声明的
# CLAUDE_SESSION_ID 在 Claude Code 中也不存在,因此 ZCode 实际会复用
# CLAUDE_CODE_SESSION_ID。先尝试该名称,并保留历史名称作为回退;两者都不存在时
# 行为不变。平台限定查询(_iter_env_keys 按平台过滤),因此只有解析出 "zcode" 后
# 才会命中,不会与上面的 claude 条目冲突。
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
# 按厂商设计已确认(2026-08-05 验证):Snow 的 sessionIdentityEnv.ts 会把
# SNOW_SESSION_ID 导出到 hook/terminal/sub-agent 子进程,并在源码头部注明 Trellis。
# TRELLIS_CONTEXT_ID 仍是首选覆盖值,Snow 也会设置它。
("snow", ("SNOW_SESSION_ID",)),
)
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# cursor-agent(CLI)中已确认但未文档化(2026-08-05 验证:值匹配
# ~/.cursor/chats/<ws>/<id>)。Cursor *IDE* 尚未验证;2026-05 的论坛请求没有得到
# staff 回复。臆造的 CURSOR_SESSION_ID 已从 session 表移除:在真实 cursor-agent
# shell 中为空。Cursor 的另一条路径是下面的 shell ticket
# (_lookup_shell_ticket_context_key),它并不专属于 Cursor。
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
)
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# 已确认但仅限 HOOK 作用域(2026-08-05 验证):Cursor hook script 文档有说明,
# 但 agent 自己的 shell 环境为空。
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
# 未验证——从未调查。2026-08-05 审计只覆盖 session 表,不能据此推断这些变量
# 真实或虚构(CLAUDE_/CODEX_TRANSCRIPT_PATH 已移除,因为它们确实经过检查:文档和
# 真实环境都不存在)。要分别确认,需在 hook 和 shell-tool 调用中运行
# `env | grep _TRANSCRIPT_PATH`。
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
("qoder", ("QODER_TRANSCRIPT_PATH",)),
("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
)
_ENV_PLATFORM_ALIASES = {
"claude-code": "claude",
"factory": "droid",
"factory-ai": "droid",
"github-copilot": "copilot",
}
# ZCode 有意复用 Claude 的 session 环境变量名。Hook 知道 host 是 ZCode,而后续 shell
# 命令只能看到共享变量名,并通过 claude 条目解析。两条路径统一为一个 runtime 文件名。
_CONTEXT_KEY_PLATFORM_ALIASES = {
"zcode": "claude",
# Factory Droid 的配置目录是 `.factory/`,因此按安装目录命名平台的 hook 会报告
# "factory",其同级 hook 则报告 "droid"。无论哪种情况都使用同一个 runtime 文件名。
"factory": "droid",
}
@dataclass(frozen=True)
class ActiveTask:
"""解析后的活动任务状态。"""
task_path: str | None
source_type: str
context_key: str | None = None
stale: bool = False
@property
def source(self) -> str:
"""返回供人阅读的来源标签。"""
if self.source_type == "session" and self.context_key:
return f"session:{self.context_key}"
if self.source_type == "session-fallback" and self.context_key:
return f"session-fallback:{self.context_key}"
return self.source_type
def normalize_task_ref(task_ref: str) -> str:
"""归一化 task ref,保证存储和比较稳定。"""
normalized = task_ref.strip()
if not normalized:
return ""
path_obj = Path(normalized)
if path_obj.is_absolute():
return str(path_obj)
normalized = normalized.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
if normalized.startswith(f"{DIR_TASKS}/"):
return f"{DIR_WORKFLOW}/{normalized}"
return normalized
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
"""将 task ref 解析为绝对任务目录。"""
normalized = normalize_task_ref(task_ref)
if not normalized:
return None
path_obj = Path(normalized)
if path_obj.is_absolute():
return path_obj
if normalized.startswith(f"{DIR_WORKFLOW}/"):
return repo_root / path_obj
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
def _runtime_sessions_dir(repo_root: Path) -> Path:
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
def _sanitize_key(raw: str) -> str:
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
safe = safe.strip("._-")
return safe[:160] if safe else ""
def _hash_value(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
def _as_dict(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def _string_value(value: Any) -> str | None:
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return None
def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
for key in keys:
value = _string_value(data.get(key))
if value:
return value
for nested_key in _NESTED_KEYS:
nested = _as_dict(data.get(nested_key))
if not nested:
continue
value = _lookup_string(nested, keys)
if value:
return value
return None
def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
if platform:
return _sanitize_key(platform) or "session"
if platform_input:
for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
value = _string_value(platform_input.get(key))
if value:
return _sanitize_key(value) or "session"
if _string_value(platform_input.get("cursor_version")):
return "cursor"
return "session"
def _context_key(platform_name: str, kind: str, value: str) -> str:
platform_name = _CONTEXT_KEY_PLATFORM_ALIASES.get(platform_name, platform_name)
if kind == "transcript":
return f"{platform_name}_transcript_{_hash_value(value)}"
safe_value = _sanitize_key(value)
if safe_value:
return f"{platform_name}_{safe_value}"
return f"{platform_name}_{_hash_value(value)}"
def _iter_env_keys(
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
platform_name: str | None,
) -> tuple[tuple[str, tuple[str, ...]], ...]:
"""将环境变量表收窄到一个平台;未指定平台时返回完整表。
没有条目的平台返回空 tuple,调用方的 `for` 循环自然不会执行。这是正常情况,
不是错误:没有已验证环境变量名称的平台会有意从这些表中缺席。
"""
if not platform_name:
return env_keys
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
return matched
def _env_platform_name(platform_name: str | None) -> str | None:
if not platform_name or platform_name == "session":
return None
return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
def _lookup_env_context_key(platform_name: str | None) -> str | None:
"""从平台提供的环境变量解析 context key。
Hook 会把 `TRELLIS_CONTEXT_ID` 传给它启动的子进程,但 AI 运行的 shell 命令只有在
host platform 将 session identity 导出到命令环境时才能看到它。这些名称只是尽力而
为的适配;都不存在时,就没有按 session 隔离的活动任务。
"""
env_platform_name = _env_platform_name(platform_name)
for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
for key in keys:
value = _string_value(os.environ.get(key))
if value:
return _context_key(name, "session", value)
for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
for key in keys:
value = _string_value(os.environ.get(key))
if value:
return _context_key(name, "conversation", value)
for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
for key in keys:
value = _string_value(os.environ.get(key))
if value:
return _context_key(name, "transcript", value)
return None
def _find_repo_root_from_cwd() -> Path | None:
current = Path.cwd().resolve()
while True:
if (current / DIR_WORKFLOW).is_dir():
return current
if current == current.parent:
return None
current = current.parent
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
return (
runtime_dir / DIR_SHELL_TICKETS,
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
)
def _remove_file(path: Path) -> bool:
try:
path.unlink()
return True
except OSError:
return False
def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
if not left or not right:
return False
left_path = resolve_task_ref(left, repo_root)
right_path = resolve_task_ref(right, repo_root)
if left_path is not None and right_path is not None:
return left_path == right_path
return normalize_task_ref(left) == normalize_task_ref(right)
def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
if Path(sys.argv[0]).name != "task.py":
return False
args = tuple(sys.argv[1:])
if not args:
return False
command_name = args[0]
if command_name not in TASK_SESSION_COMMANDS:
return False
subcommands = ticket.get("subcommands")
if not isinstance(subcommands, list):
return False
for subcommand in subcommands:
if not isinstance(subcommand, dict):
continue
if _string_value(subcommand.get("name")) != command_name:
continue
if command_name != "start":
return True
task_ref = args[1] if len(args) > 1 else None
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
return True
return False
def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
expires_at = ticket.get("expires_at_epoch")
if isinstance(expires_at, (int, float)) and expires_at < now:
_remove_file(ticket_path)
return False
created_at = ticket.get("created_at_epoch")
if isinstance(created_at, (int, float)):
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
return True
_remove_file(ticket_path)
return False
return True
def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
cwd = _string_value(ticket.get("cwd"))
if not cwd:
return True
try:
Path(cwd).resolve().relative_to(repo_root)
except ValueError:
return False
return True
def _matching_ticket_context_key(
ticket_path: Path,
repo_root: Path,
now: float,
) -> str | None:
"""只按 ticket 自身条件接受它,不按写入 ticket 的平台筛选。
ticket 携带的 `platform` 字段只是调试元数据;以前按它做门控,导致除 Cursor 外的
所有平台都看不到这条桥接路径。
"""
ticket = _read_json(ticket_path)
if ticket is None:
return None
if not _ticket_is_fresh(ticket, ticket_path, now):
return None
if not _ticket_cwd_matches_repo(ticket, repo_root):
return None
if not _pending_ticket_matches_args(ticket, repo_root):
return None
return _string_value(ticket.get("context_key"))
def _lookup_shell_ticket_context_key() -> str | None:
"""从短时 shell ticket 解析 session identity。
调查过的平台都不会把 session id 导出到 shell 子进程,但支持 hook 的平台都会把
该 id 交给 hook。因此,在 shell 命令执行前运行的 hook 写入 ticket,本函数再读回。
ticket 只有同时满足“仍然新鲜、为本仓库写入、匹配当前执行的 `task.py` 子命令”,且
恰好只有一个 context key 匹配时才有效。两个并发窗口会一起降级,不会让一个窗口
继承另一个窗口的指针。
"""
repo_root = _find_repo_root_from_cwd()
if repo_root is None:
return None
now = time.time()
candidates: set[str] = set()
for ticket_dir in _shell_ticket_dirs(repo_root):
if not ticket_dir.is_dir():
continue
for ticket_path in ticket_dir.glob("*.json"):
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
if context_key:
candidates.add(context_key)
if len(candidates) == 1:
return next(iter(candidates))
return None
def resolve_context_key(
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
*,
allow_environment_context: bool = True,
) -> str | None:
"""在可用时解析稳定的 session/window context key。
`TRELLIS_CONTEXT_ID` 是 CLI script 和子进程使用的显式 context-key 覆盖值;它本身
不存储任务。
"""
if allow_environment_context:
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
if override:
return _sanitize_key(override) or _hash_value(override)
data = _as_dict(platform_input)
platform_name = _detect_platform(data, platform) if data or platform else None
if data:
session_id = _lookup_string(data, _SESSION_KEYS)
if session_id:
return _context_key(platform_name or "session", "session", session_id)
conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
if conversation_id:
return _context_key(platform_name or "session", "conversation", conversation_id)
transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
if transcript_path:
return _context_key(platform_name or "session", "transcript", transcript_path)
if allow_environment_context:
env_context_key = _lookup_env_context_key(platform_name)
if env_context_key:
return env_context_key
# 有意放在链条最后:真正把 identity 导出到 shell 的平台优先于 ticket,且查询不按
# 平台名称做门控。
if allow_environment_context:
return _lookup_shell_ticket_context_key()
return None
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
return data if isinstance(data, dict) else None
def _write_json(path: Path, data: dict[str, Any]) -> bool:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return True
except OSError:
return False
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
normalized = normalize_task_ref(task_path)
if not normalized:
return None
full_path = resolve_task_ref(normalized, repo_root)
if full_path is None or not full_path.is_dir():
return None
try:
return full_path.relative_to(repo_root).as_posix()
except ValueError:
return str(full_path)
def _active_from_ref(
task_ref: str | None,
repo_root: Path,
source_type: str,
context_key: str | None = None,
) -> ActiveTask | None:
if not task_ref:
return None
resolved = resolve_task_ref(task_ref, repo_root)
stale = resolved is None or not resolved.is_dir()
return ActiveTask(task_ref, source_type, context_key, stale)
def _context_path(repo_root: Path, context_key: str) -> Path:
return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
def resolve_active_task(
repo_root: Path,
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
*,
allow_single_session_fallback: bool = True,
allow_environment_context: bool = True,
) -> ActiveTask:
"""仅从 session runtime state 解析活动任务。
过期的 session task 会以 stale 标记返回。缺少 context identity 或 session context
缺失/为空时,回退到单 session 推断:如果 runtime 中恰好有一个 session 文件,就以
``source_type="session-fallback"`` 返回其中的任务——覆盖不会继承父 session id 的
pull-based platform sub-agent(copilot、gemini、qoder)。文件数为 0 或至少 2 时返回
ActiveTask(None),拒绝跨窗口猜测。
"""
context_key = resolve_context_key(
platform_input,
platform,
allow_environment_context=allow_environment_context,
)
if context_key:
context = _read_json(_context_path(repo_root, context_key)) or {}
task_ref = _string_value(context.get("current_task"))
active = _active_from_ref(task_ref, repo_root, "session", context_key)
if active:
return active
if allow_single_session_fallback:
fallback = _resolve_single_session_fallback(repo_root)
if fallback is not None:
return fallback
return ActiveTask(None, "none", context_key)
def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
"""在恰好存在一个 session 文件时返回其指向的任务。
当 context-key 解析失败时使用(class-2 platform sub-agent 很常见)。存在 0 个或
至少 2 个 session 文件时返回 None,拒绝跨窗口选择,以保持 04-21 的多 session 隔离契约。
"""
sessions_dir = _runtime_sessions_dir(repo_root)
if not sessions_dir.is_dir():
return None
session_files = sorted(sessions_dir.glob("*.json"))
if len(session_files) != 1:
return None
session_file = session_files[0]
context = _read_json(session_file) or {}
task_ref = _string_value(context.get("current_task"))
if not task_ref:
return None
fallback_key = session_file.stem
return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _context_metadata(
platform_input: dict[str, Any] | None,
platform: str | None,
context_key: str | None = None,
) -> dict[str, Any]:
data = _as_dict(platform_input) or {}
platform_name = _detect_platform(data, platform)
if platform_name == "session" and context_key:
prefix = context_key.split("_", 1)[0]
if prefix in _KNOWN_PLATFORMS:
platform_name = prefix
metadata: dict[str, Any] = {
"platform": platform_name,
"last_seen_at": _utc_now(),
}
for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
value = _lookup_string(data, (key,))
if value:
metadata[key] = value
return metadata
def set_active_task(
task_path: str,
repo_root: Path,
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
) -> ActiveTask | None:
"""在 session 作用域设置活动任务。
没有 context key 时返回 None;调用方应向用户显示如何提供 session identity 的错误。
"""
canonical = _canonical_task_ref(task_path, repo_root)
if canonical is None:
return None
context_key = resolve_context_key(platform_input, platform)
if not context_key:
return None
context_path = _context_path(repo_root, context_key)
context = _read_json(context_path) or {}
context.update(_context_metadata(platform_input, platform, context_key))
context["current_task"] = canonical
context.setdefault("current_run", None)
if not _write_json(context_path, context):
return None
return ActiveTask(canonical, "session", context_key)
def clear_active_task(
repo_root: Path,
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
) -> ActiveTask:
"""删除已解析的 session context 文件,从而清除活动任务。"""
context_key = resolve_context_key(platform_input, platform)
if not context_key:
return ActiveTask(None, "none")
previous = resolve_active_task(repo_root, platform_input, platform)
if not previous.task_path or not previous.context_key:
return previous
context_path = _context_path(repo_root, previous.context_key)
if context_path.is_file():
_remove_file(context_path)
return previous
def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
"""删除所有指向指定任务的 session runtime 文件。"""
target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
if not target:
return 0
cleared = 0
sessions_dir = _runtime_sessions_dir(repo_root)
if not sessions_dir.is_dir():
return cleared
for session_path in sessions_dir.glob("*.json"):
context = _read_json(session_path) or {}
current = _string_value(context.get("current_task"))
if not current:
continue
current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
if current_ref != target:
continue
if session_path.is_file() and _remove_file(session_path):
cleared += 1
return cleared
def get_current_task_source(
repo_root: Path,
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
) -> tuple[str, str | None, str | None]:
"""为兼容旧调用方返回 (`source_type`, `context_key`, `task_path`)。"""
active = resolve_active_task(repo_root, platform_input, platform)
return active.source_type, active.context_key, active.task_path