active_task.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. #!/usr/bin/env python3
  2. """Session-scoped active task resolution.
  3. The user-facing concept is a single "active task". Trellis stores that pointer
  4. per AI session/window under `.trellis/.runtime/sessions/`; without a stable
  5. session key there is no active task.
  6. """
  7. from __future__ import annotations
  8. import hashlib
  9. import json
  10. import os
  11. import re
  12. import sys
  13. import time
  14. from dataclasses import dataclass
  15. from datetime import datetime, timezone
  16. from pathlib import Path
  17. from typing import Any
  18. DIR_WORKFLOW = ".trellis"
  19. DIR_TASKS = "tasks"
  20. DIR_RUNTIME = ".runtime"
  21. DIR_SESSIONS = "sessions"
  22. DIR_CURSOR_SHELL = "cursor-shell"
  23. CURSOR_SHELL_TICKET_TTL_SECONDS = 30
  24. TASK_SESSION_COMMANDS = {"start", "current", "finish"}
  25. _SESSION_KEYS = ("session_id", "sessionId", "sessionID")
  26. _CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
  27. _TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
  28. _NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
  29. _KNOWN_PLATFORMS = {
  30. "claude",
  31. "codex",
  32. "cursor",
  33. "opencode",
  34. "gemini",
  35. "droid",
  36. "qoder",
  37. "codebuddy",
  38. "kiro",
  39. "copilot",
  40. "pi",
  41. }
  42. _ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
  43. ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
  44. ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
  45. ("cursor", ("CURSOR_SESSION_ID",)),
  46. ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
  47. ("gemini", ("GEMINI_SESSION_ID",)),
  48. ("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
  49. ("qoder", ("QODER_SESSION_ID",)),
  50. ("codebuddy", ("CODEBUDDY_SESSION_ID",)),
  51. ("kiro", ("KIRO_SESSION_ID",)),
  52. ("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
  53. ("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
  54. )
  55. _ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
  56. ("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
  57. )
  58. _ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
  59. ("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
  60. ("codex", ("CODEX_TRANSCRIPT_PATH",)),
  61. ("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
  62. ("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
  63. ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
  64. ("qoder", ("QODER_TRANSCRIPT_PATH",)),
  65. ("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
  66. )
  67. _ENV_PLATFORM_ALIASES = {
  68. "claude-code": "claude",
  69. "factory": "droid",
  70. "factory-ai": "droid",
  71. "github-copilot": "copilot",
  72. }
  73. @dataclass(frozen=True)
  74. class ActiveTask:
  75. """Resolved active task state."""
  76. task_path: str | None
  77. source_type: str
  78. context_key: str | None = None
  79. stale: bool = False
  80. @property
  81. def source(self) -> str:
  82. """Human-readable source label."""
  83. if self.source_type == "session" and self.context_key:
  84. return f"session:{self.context_key}"
  85. if self.source_type == "session-fallback" and self.context_key:
  86. return f"session-fallback:{self.context_key}"
  87. return self.source_type
  88. def normalize_task_ref(task_ref: str) -> str:
  89. """Normalize a task ref for stable storage and comparison."""
  90. normalized = task_ref.strip()
  91. if not normalized:
  92. return ""
  93. path_obj = Path(normalized)
  94. if path_obj.is_absolute():
  95. return str(path_obj)
  96. normalized = normalized.replace("\\", "/")
  97. while normalized.startswith("./"):
  98. normalized = normalized[2:]
  99. if normalized.startswith(f"{DIR_TASKS}/"):
  100. return f"{DIR_WORKFLOW}/{normalized}"
  101. return normalized
  102. def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
  103. """Resolve a task ref to an absolute task directory."""
  104. normalized = normalize_task_ref(task_ref)
  105. if not normalized:
  106. return None
  107. path_obj = Path(normalized)
  108. if path_obj.is_absolute():
  109. return path_obj
  110. if normalized.startswith(f"{DIR_WORKFLOW}/"):
  111. return repo_root / path_obj
  112. return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
  113. def _runtime_sessions_dir(repo_root: Path) -> Path:
  114. return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
  115. def _sanitize_key(raw: str) -> str:
  116. safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
  117. safe = safe.strip("._-")
  118. return safe[:160] if safe else ""
  119. def _hash_value(raw: str) -> str:
  120. return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
  121. def _as_dict(value: Any) -> dict[str, Any] | None:
  122. return value if isinstance(value, dict) else None
  123. def _string_value(value: Any) -> str | None:
  124. if isinstance(value, str):
  125. stripped = value.strip()
  126. return stripped or None
  127. return None
  128. def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
  129. for key in keys:
  130. value = _string_value(data.get(key))
  131. if value:
  132. return value
  133. for nested_key in _NESTED_KEYS:
  134. nested = _as_dict(data.get(nested_key))
  135. if not nested:
  136. continue
  137. value = _lookup_string(nested, keys)
  138. if value:
  139. return value
  140. return None
  141. def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
  142. if platform:
  143. return _sanitize_key(platform) or "session"
  144. if platform_input:
  145. for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
  146. value = _string_value(platform_input.get(key))
  147. if value:
  148. return _sanitize_key(value) or "session"
  149. if _string_value(platform_input.get("cursor_version")):
  150. return "cursor"
  151. return "session"
  152. def _context_key(platform_name: str, kind: str, value: str) -> str:
  153. if kind == "transcript":
  154. return f"{platform_name}_transcript_{_hash_value(value)}"
  155. safe_value = _sanitize_key(value)
  156. if safe_value:
  157. return f"{platform_name}_{safe_value}"
  158. return f"{platform_name}_{_hash_value(value)}"
  159. def _iter_env_keys(
  160. env_keys: tuple[tuple[str, tuple[str, ...]], ...],
  161. platform_name: str | None,
  162. ) -> tuple[tuple[str, tuple[str, ...]], ...]:
  163. if not platform_name:
  164. return env_keys
  165. matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
  166. return matched
  167. def _env_platform_name(platform_name: str | None) -> str | None:
  168. if not platform_name or platform_name == "session":
  169. return None
  170. return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
  171. def _lookup_env_context_key(platform_name: str | None) -> str | None:
  172. """Resolve a context key from platform-provided environment variables.
  173. Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run
  174. shell command can only see session identity if the host platform exports it
  175. in the command environment. These names are best-effort adapters; if none
  176. are present, there is no session-scoped active task.
  177. """
  178. env_platform_name = _env_platform_name(platform_name)
  179. for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
  180. for key in keys:
  181. value = _string_value(os.environ.get(key))
  182. if value:
  183. return _context_key(name, "session", value)
  184. for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
  185. for key in keys:
  186. value = _string_value(os.environ.get(key))
  187. if value:
  188. return _context_key(name, "conversation", value)
  189. for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
  190. for key in keys:
  191. value = _string_value(os.environ.get(key))
  192. if value:
  193. return _context_key(name, "transcript", value)
  194. return None
  195. def _find_repo_root_from_cwd() -> Path | None:
  196. current = Path.cwd().resolve()
  197. while True:
  198. if (current / DIR_WORKFLOW).is_dir():
  199. return current
  200. if current == current.parent:
  201. return None
  202. current = current.parent
  203. def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
  204. return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
  205. def _remove_file(path: Path) -> bool:
  206. try:
  207. path.unlink()
  208. return True
  209. except OSError:
  210. return False
  211. def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
  212. if not left or not right:
  213. return False
  214. left_path = resolve_task_ref(left, repo_root)
  215. right_path = resolve_task_ref(right, repo_root)
  216. if left_path is not None and right_path is not None:
  217. return left_path == right_path
  218. return normalize_task_ref(left) == normalize_task_ref(right)
  219. def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
  220. if Path(sys.argv[0]).name != "task.py":
  221. return False
  222. args = tuple(sys.argv[1:])
  223. if not args:
  224. return False
  225. command_name = args[0]
  226. if command_name not in TASK_SESSION_COMMANDS:
  227. return False
  228. subcommands = ticket.get("subcommands")
  229. if not isinstance(subcommands, list):
  230. return False
  231. for subcommand in subcommands:
  232. if not isinstance(subcommand, dict):
  233. continue
  234. if _string_value(subcommand.get("name")) != command_name:
  235. continue
  236. if command_name != "start":
  237. return True
  238. task_ref = args[1] if len(args) > 1 else None
  239. if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
  240. return True
  241. return False
  242. def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
  243. expires_at = ticket.get("expires_at_epoch")
  244. if isinstance(expires_at, (int, float)) and expires_at < now:
  245. _remove_file(ticket_path)
  246. return False
  247. created_at = ticket.get("created_at_epoch")
  248. if isinstance(created_at, (int, float)):
  249. if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
  250. return True
  251. _remove_file(ticket_path)
  252. return False
  253. return True
  254. def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
  255. cwd = _string_value(ticket.get("cwd"))
  256. if not cwd:
  257. return True
  258. try:
  259. Path(cwd).resolve().relative_to(repo_root)
  260. except ValueError:
  261. return False
  262. return True
  263. def _matching_cursor_ticket_context_key(
  264. ticket_path: Path,
  265. repo_root: Path,
  266. now: float,
  267. ) -> str | None:
  268. ticket = _read_json(ticket_path)
  269. if ticket is None or ticket.get("platform") != "cursor":
  270. return None
  271. if not _ticket_is_fresh(ticket, ticket_path, now):
  272. return None
  273. if not _ticket_cwd_matches_repo(ticket, repo_root):
  274. return None
  275. if not _pending_ticket_matches_args(ticket, repo_root):
  276. return None
  277. return _string_value(ticket.get("context_key"))
  278. def _lookup_cursor_shell_ticket_context_key() -> str | None:
  279. """Resolve Cursor conversation identity from a short-lived shell ticket.
  280. Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
  281. export it into the shell command environment. The Cursor hook writes a
  282. short-lived ticket just before `task.py` runs. We accept a ticket only when
  283. the current `task.py` subcommand matches and exactly one fresh context key
  284. matches, which avoids cross-window pointer contamination.
  285. """
  286. repo_root = _find_repo_root_from_cwd()
  287. if repo_root is None:
  288. return None
  289. ticket_dir = _cursor_shell_ticket_dir(repo_root)
  290. if not ticket_dir.is_dir():
  291. return None
  292. now = time.time()
  293. candidates: set[str] = set()
  294. for ticket_path in ticket_dir.glob("*.json"):
  295. context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
  296. if context_key:
  297. candidates.add(context_key)
  298. if len(candidates) == 1:
  299. return next(iter(candidates))
  300. return None
  301. def resolve_context_key(
  302. platform_input: dict[str, Any] | None = None,
  303. platform: str | None = None,
  304. ) -> str | None:
  305. """Resolve a stable session/window context key, if one is available.
  306. `TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI
  307. scripts and subprocesses. It does not store the task itself.
  308. """
  309. override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
  310. if override:
  311. return _sanitize_key(override) or _hash_value(override)
  312. data = _as_dict(platform_input)
  313. platform_name = _detect_platform(data, platform) if data or platform else None
  314. if data:
  315. session_id = _lookup_string(data, _SESSION_KEYS)
  316. if session_id:
  317. return _context_key(platform_name or "session", "session", session_id)
  318. conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
  319. if conversation_id:
  320. return _context_key(platform_name or "session", "conversation", conversation_id)
  321. transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
  322. if transcript_path:
  323. return _context_key(platform_name or "session", "transcript", transcript_path)
  324. env_context_key = _lookup_env_context_key(platform_name)
  325. if env_context_key:
  326. return env_context_key
  327. if platform_name in (None, "session", "cursor"):
  328. return _lookup_cursor_shell_ticket_context_key()
  329. return None
  330. def _read_json(path: Path) -> dict[str, Any] | None:
  331. try:
  332. data = json.loads(path.read_text(encoding="utf-8"))
  333. except (FileNotFoundError, json.JSONDecodeError, OSError):
  334. return None
  335. return data if isinstance(data, dict) else None
  336. def _write_json(path: Path, data: dict[str, Any]) -> bool:
  337. try:
  338. path.parent.mkdir(parents=True, exist_ok=True)
  339. path.write_text(
  340. json.dumps(data, indent=2, ensure_ascii=False) + "\n",
  341. encoding="utf-8",
  342. )
  343. return True
  344. except OSError:
  345. return False
  346. def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
  347. normalized = normalize_task_ref(task_path)
  348. if not normalized:
  349. return None
  350. full_path = resolve_task_ref(normalized, repo_root)
  351. if full_path is None or not full_path.is_dir():
  352. return None
  353. try:
  354. return full_path.relative_to(repo_root).as_posix()
  355. except ValueError:
  356. return str(full_path)
  357. def _active_from_ref(
  358. task_ref: str | None,
  359. repo_root: Path,
  360. source_type: str,
  361. context_key: str | None = None,
  362. ) -> ActiveTask | None:
  363. if not task_ref:
  364. return None
  365. resolved = resolve_task_ref(task_ref, repo_root)
  366. stale = resolved is None or not resolved.is_dir()
  367. return ActiveTask(task_ref, source_type, context_key, stale)
  368. def _context_path(repo_root: Path, context_key: str) -> Path:
  369. return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
  370. def resolve_active_task(
  371. repo_root: Path,
  372. platform_input: dict[str, Any] | None = None,
  373. platform: str | None = None,
  374. ) -> ActiveTask:
  375. """Resolve the active task from session runtime state only.
  376. A stale session task is returned as stale. Missing context identity or a
  377. missing/empty session context falls back to single-session inference: if
  378. exactly one session file exists in the runtime, return its task with
  379. source_type="session-fallback" — covers class-2 platform sub-agents (codex,
  380. copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
  381. files or 0 files yield ActiveTask(None) — refuses to guess across windows.
  382. """
  383. context_key = resolve_context_key(platform_input, platform)
  384. if context_key:
  385. context = _read_json(_context_path(repo_root, context_key)) or {}
  386. task_ref = _string_value(context.get("current_task"))
  387. active = _active_from_ref(task_ref, repo_root, "session", context_key)
  388. if active:
  389. return active
  390. fallback = _resolve_single_session_fallback(repo_root)
  391. if fallback is not None:
  392. return fallback
  393. return ActiveTask(None, "none", context_key)
  394. def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
  395. """Return the task pointed at by the sole session file, if exactly one exists.
  396. Used when context-key resolution fails (typical for class-2 platform
  397. sub-agents). Returns None if 0 or ≥2 session files are present — refuses
  398. to pick across windows so 04-21's multi-session isolation contract holds.
  399. """
  400. sessions_dir = _runtime_sessions_dir(repo_root)
  401. if not sessions_dir.is_dir():
  402. return None
  403. session_files = sorted(sessions_dir.glob("*.json"))
  404. if len(session_files) != 1:
  405. return None
  406. session_file = session_files[0]
  407. context = _read_json(session_file) or {}
  408. task_ref = _string_value(context.get("current_task"))
  409. if not task_ref:
  410. return None
  411. fallback_key = session_file.stem
  412. return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
  413. def _utc_now() -> str:
  414. return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
  415. def _context_metadata(
  416. platform_input: dict[str, Any] | None,
  417. platform: str | None,
  418. context_key: str | None = None,
  419. ) -> dict[str, Any]:
  420. data = _as_dict(platform_input) or {}
  421. platform_name = _detect_platform(data, platform)
  422. if platform_name == "session" and context_key:
  423. prefix = context_key.split("_", 1)[0]
  424. if prefix in _KNOWN_PLATFORMS:
  425. platform_name = prefix
  426. metadata: dict[str, Any] = {
  427. "platform": platform_name,
  428. "last_seen_at": _utc_now(),
  429. }
  430. for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
  431. value = _lookup_string(data, (key,))
  432. if value:
  433. metadata[key] = value
  434. return metadata
  435. def set_active_task(
  436. task_path: str,
  437. repo_root: Path,
  438. platform_input: dict[str, Any] | None = None,
  439. platform: str | None = None,
  440. ) -> ActiveTask | None:
  441. """Set the active task in session scope.
  442. Returns None when no context key is available; callers should surface a
  443. user-facing error that explains how to provide session identity.
  444. """
  445. canonical = _canonical_task_ref(task_path, repo_root)
  446. if canonical is None:
  447. return None
  448. context_key = resolve_context_key(platform_input, platform)
  449. if not context_key:
  450. return None
  451. context_path = _context_path(repo_root, context_key)
  452. context = _read_json(context_path) or {}
  453. context.update(_context_metadata(platform_input, platform, context_key))
  454. context["current_task"] = canonical
  455. context.setdefault("current_run", None)
  456. if not _write_json(context_path, context):
  457. return None
  458. return ActiveTask(canonical, "session", context_key)
  459. def clear_active_task(
  460. repo_root: Path,
  461. platform_input: dict[str, Any] | None = None,
  462. platform: str | None = None,
  463. ) -> ActiveTask:
  464. """Clear the active task by deleting the current session context file."""
  465. context_key = resolve_context_key(platform_input, platform)
  466. if not context_key:
  467. return ActiveTask(None, "none")
  468. previous = resolve_active_task(repo_root, platform_input, platform)
  469. context_path = _context_path(repo_root, context_key)
  470. if context_path.is_file():
  471. _remove_file(context_path)
  472. return previous
  473. def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
  474. """Delete all session runtime files that point at a task."""
  475. target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
  476. if not target:
  477. return 0
  478. cleared = 0
  479. sessions_dir = _runtime_sessions_dir(repo_root)
  480. if not sessions_dir.is_dir():
  481. return cleared
  482. for session_path in sessions_dir.glob("*.json"):
  483. context = _read_json(session_path) or {}
  484. current = _string_value(context.get("current_task"))
  485. if not current:
  486. continue
  487. current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
  488. if current_ref != target:
  489. continue
  490. if session_path.is_file() and _remove_file(session_path):
  491. cleared += 1
  492. return cleared
  493. def get_current_task_source(
  494. repo_root: Path,
  495. platform_input: dict[str, Any] | None = None,
  496. platform: str | None = None,
  497. ) -> tuple[str, str | None, str | None]:
  498. """Return (`source_type`, `context_key`, `task_path`) for compatibility."""
  499. active = resolve_active_task(repo_root, platform_input, platform)
  500. return active.source_type, active.context_key, active.task_path