Implement 3 plans
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.memory.retrieval import retrieve_memories_for_prompt
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.models import (
|
||||
AI,
|
||||
@@ -43,6 +45,78 @@ _EPIC_CREATE_RE = re.compile(
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EPIC_TOKEN_RE = re.compile(r"\[\s*epic\s*:\s*([^\]]+?)\s*\]", re.IGNORECASE)
|
||||
_LIST_TASKS_CMD_RE = re.compile(
|
||||
r"^\s*\.task\s+list\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TASK_SHOW_RE = re.compile(
|
||||
r"^\s*\.task\s+show\s+#?(?P<reference>[A-Za-z0-9_-]+)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TASK_COMPLETE_CMD_RE = re.compile(
|
||||
r"^\s*\.task\s+(?:complete|done|close)\s+#?(?P<reference>[A-Za-z0-9_-]+)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DUE_ISO_RE = re.compile(
|
||||
r"\b(?:due|by)\s+(\d{4}-\d{2}-\d{2})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DUE_RELATIVE_RE = re.compile(
|
||||
r"\b(?:due|by)\s+(?P<token>today|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ASSIGNEE_AT_RE = re.compile(r"@([A-Za-z0-9_.-]+)")
|
||||
_ASSIGNEE_PHRASE_RE = re.compile(
|
||||
r"\b(?:assign(?:ed)?\s+to|for)\s+([A-Za-z0-9_.-]+)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_WEEKDAY_MAP = {
|
||||
"monday": 0,
|
||||
"tuesday": 1,
|
||||
"wednesday": 2,
|
||||
"thursday": 3,
|
||||
"friday": 4,
|
||||
"saturday": 5,
|
||||
"sunday": 6,
|
||||
}
|
||||
|
||||
|
||||
def _parse_due_date(text: str) -> datetime.date | None:
|
||||
body = str(text or "")
|
||||
m = _DUE_ISO_RE.search(body)
|
||||
if m:
|
||||
try:
|
||||
return datetime.date.fromisoformat(m.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
m = _DUE_RELATIVE_RE.search(body)
|
||||
if not m:
|
||||
return None
|
||||
token = m.group("token").strip().lower()
|
||||
today = datetime.date.today()
|
||||
if token == "today":
|
||||
return today
|
||||
if token == "tomorrow":
|
||||
return today + datetime.timedelta(days=1)
|
||||
target_weekday = _WEEKDAY_MAP.get(token)
|
||||
if target_weekday is None:
|
||||
return None
|
||||
days_ahead = (target_weekday - today.weekday()) % 7
|
||||
if days_ahead == 0:
|
||||
days_ahead = 7
|
||||
return today + datetime.timedelta(days=days_ahead)
|
||||
|
||||
|
||||
def _parse_assignee(text: str) -> str:
|
||||
body = str(text or "")
|
||||
m = _ASSIGNEE_AT_RE.search(body)
|
||||
if m:
|
||||
return str(m.group(1) or "").strip()
|
||||
m = _ASSIGNEE_PHRASE_RE.search(body)
|
||||
if m:
|
||||
return str(m.group(1) or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _channel_variants(service: str, channel: str) -> list[str]:
|
||||
@@ -319,11 +393,22 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
cached_epic = task._state.fields_cache.get("epic")
|
||||
project_name = str(getattr(cached_project, "name", "") or "")
|
||||
epic_name = str(getattr(cached_epic, "name", "") or "")
|
||||
memory_context: list = []
|
||||
try:
|
||||
memory_context = await sync_to_async(retrieve_memories_for_prompt)(
|
||||
user_id=int(task.user_id),
|
||||
query=str(task.title or ""),
|
||||
limit=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
request_payload = {
|
||||
"task_id": str(task.id),
|
||||
"reference_code": str(task.reference_code or ""),
|
||||
"title": str(task.title or ""),
|
||||
"external_key": str(task.external_key or ""),
|
||||
"due_date": task.due_date.isoformat() if task.due_date else "",
|
||||
"assignee_identifier": str(task.assignee_identifier or ""),
|
||||
"project_name": project_name,
|
||||
"epic_name": epic_name,
|
||||
"source_service": str(task.source_service or ""),
|
||||
@@ -333,6 +418,7 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
"trigger_message_id": str(getattr(event, "source_message_id", "") or getattr(task, "origin_message_id", "") or ""),
|
||||
"mode": "default",
|
||||
"payload": event.payload,
|
||||
"memory_context": memory_context,
|
||||
}
|
||||
codex_run = await sync_to_async(CodexRun.objects.create)(
|
||||
user=task.user,
|
||||
@@ -439,7 +525,7 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
|
||||
return False
|
||||
body = str(text or "").strip()
|
||||
source = sources[0]
|
||||
if _LIST_TASKS_RE.match(body):
|
||||
if _LIST_TASKS_RE.match(body) or _LIST_TASKS_CMD_RE.match(body):
|
||||
open_rows = await sync_to_async(list)(
|
||||
DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
@@ -494,6 +580,64 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
|
||||
await _send_scope_message(source, message, f"[task] removed #{ref}: {title}")
|
||||
return True
|
||||
|
||||
show_match = _TASK_SHOW_RE.match(body)
|
||||
if show_match:
|
||||
reference = str(show_match.group("reference") or "").strip()
|
||||
task = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
reference_code=reference,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)()
|
||||
if task is None:
|
||||
await _send_scope_message(source, message, f"[task] #{reference} not found.")
|
||||
return True
|
||||
due_str = f"\ndue: {task.due_date}" if task.due_date else ""
|
||||
assignee_str = f"\nassignee: {task.assignee_identifier}" if task.assignee_identifier else ""
|
||||
detail = (
|
||||
f"[task] #{task.reference_code}: {task.title}"
|
||||
f"\nstatus: {task.status_snapshot}"
|
||||
f"{due_str}"
|
||||
f"{assignee_str}"
|
||||
)
|
||||
await _send_scope_message(source, message, detail)
|
||||
return True
|
||||
|
||||
complete_match = _TASK_COMPLETE_CMD_RE.match(body)
|
||||
if complete_match:
|
||||
reference = str(complete_match.group("reference") or "").strip()
|
||||
task = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
reference_code=reference,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)()
|
||||
if task is None:
|
||||
await _send_scope_message(source, message, f"[task] #{reference} not found.")
|
||||
return True
|
||||
task.status_snapshot = "completed"
|
||||
await sync_to_async(task.save)(update_fields=["status_snapshot"])
|
||||
event = await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||
task=task,
|
||||
event_type="completion_marked",
|
||||
actor_identifier=str(message.sender_uuid or ""),
|
||||
source_message=message,
|
||||
payload={"marker": reference, "command": ".task complete", "via": "chat_command"},
|
||||
)
|
||||
await _emit_sync_event(task, event, "complete")
|
||||
await _send_scope_message(source, message, f"[task] completed #{task.reference_code}: {task.title}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -543,7 +687,14 @@ def _is_task_command_candidate(text: str) -> bool:
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return False
|
||||
if _LIST_TASKS_RE.match(body) or _UNDO_TASK_RE.match(body) or _EPIC_CREATE_RE.match(body):
|
||||
if (
|
||||
_LIST_TASKS_RE.match(body)
|
||||
or _LIST_TASKS_CMD_RE.match(body)
|
||||
or _TASK_SHOW_RE.match(body)
|
||||
or _TASK_COMPLETE_CMD_RE.match(body)
|
||||
or _UNDO_TASK_RE.match(body)
|
||||
or _EPIC_CREATE_RE.match(body)
|
||||
):
|
||||
return True
|
||||
return _has_task_prefix(body.lower(), ["task:", "todo:"])
|
||||
|
||||
@@ -646,6 +797,8 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
)
|
||||
title = await _derive_title_with_flags(cloned_message, flags)
|
||||
reference = await sync_to_async(_next_reference)(message.user, source.project)
|
||||
parsed_due_date = _parse_due_date(task_text)
|
||||
parsed_assignee = _parse_assignee(task_text)
|
||||
task = await sync_to_async(DerivedTask.objects.create)(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
@@ -656,6 +809,8 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
origin_message=message,
|
||||
reference_code=reference,
|
||||
status_snapshot="open",
|
||||
due_date=parsed_due_date,
|
||||
assignee_identifier=parsed_assignee,
|
||||
immutable_payload={"origin_text": text, "task_text": task_text, "flags": flags},
|
||||
)
|
||||
event = await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import TaskProvider
|
||||
from .claude_cli import ClaudeCLITaskProvider
|
||||
from .codex_cli import CodexCLITaskProvider
|
||||
from .mock import MockTaskProvider
|
||||
|
||||
PROVIDERS = {
|
||||
"mock": MockTaskProvider(),
|
||||
"codex_cli": CodexCLITaskProvider(),
|
||||
"claude_cli": ClaudeCLITaskProvider(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
209
core/tasks/providers/claude_cli.py
Normal file
209
core/tasks/providers/claude_cli.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from hashlib import sha1
|
||||
|
||||
from .base import ProviderResult, TaskProvider
|
||||
|
||||
|
||||
class ClaudeCLITaskProvider(TaskProvider):
|
||||
name = "claude_cli"
|
||||
run_in_worker = True
|
||||
|
||||
def _timeout(self, config: dict) -> int:
|
||||
try:
|
||||
return max(1, int(config.get("timeout_seconds") or 60))
|
||||
except Exception:
|
||||
return 60
|
||||
|
||||
def _command(self, config: dict) -> str:
|
||||
return str(config.get("command") or "claude").strip() or "claude"
|
||||
|
||||
def _workspace(self, config: dict) -> str:
|
||||
return str(config.get("workspace_root") or "").strip()
|
||||
|
||||
def _profile(self, config: dict) -> str:
|
||||
return str(config.get("default_profile") or "").strip()
|
||||
|
||||
def _is_task_sync_contract_mismatch(self, stderr: str) -> bool:
|
||||
text = str(stderr or "").lower()
|
||||
if "unexpected argument '--op'" in text:
|
||||
return True
|
||||
if "unexpected argument 'create'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unexpected argument 'append_update'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unexpected argument 'mark_complete'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unexpected argument 'link_task'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'create'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'append_update'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'mark_complete'" in text and "usage: claude" in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _builtin_stub_result(self, op: str, payload: dict, stderr: str) -> ProviderResult:
|
||||
mode = str(payload.get("mode") or "default").strip().lower()
|
||||
external_key = (
|
||||
str(payload.get("external_key") or "").strip()
|
||||
or str(payload.get("task_id") or "").strip()
|
||||
)
|
||||
if mode == "approval_response":
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "ok",
|
||||
"summary": "approval acknowledged; resumed by builtin claude stub",
|
||||
"requires_approval": False,
|
||||
"output": "",
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
task_id = str(payload.get("task_id") or "").strip()
|
||||
key_basis = f"{op}:{task_id}:{payload.get('trigger_message_id') or payload.get('origin_message_id') or ''}"
|
||||
approval_key = sha1(key_basis.encode("utf-8")).hexdigest()[:12]
|
||||
summary = "Claude approval required (builtin stub fallback)"
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "requires_approval",
|
||||
"requires_approval": True,
|
||||
"summary": summary,
|
||||
"approval_key": approval_key,
|
||||
"permission_request": {
|
||||
"summary": summary,
|
||||
"requested_permissions": ["workspace_write"],
|
||||
},
|
||||
"resume_payload": {
|
||||
"task_id": task_id,
|
||||
"op": op,
|
||||
},
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
|
||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
||||
base_cmd = [self._command(config), "task-sync"]
|
||||
workspace = self._workspace(config)
|
||||
profile = self._profile(config)
|
||||
command_timeout = self._timeout(config)
|
||||
data = json.dumps(dict(payload or {}), separators=(",", ":"))
|
||||
common_args: list[str] = []
|
||||
if workspace:
|
||||
common_args.extend(["--workspace", workspace])
|
||||
if profile:
|
||||
common_args.extend(["--profile", profile])
|
||||
|
||||
primary_cmd = [*base_cmd, "--op", str(op), *common_args, "--payload-json", data]
|
||||
fallback_cmd = [*base_cmd, str(op), *common_args, "--payload-json", data]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
primary_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
stderr_probe = str(completed.stderr or "").lower()
|
||||
if completed.returncode != 0 and "unexpected argument '--op'" in stderr_probe:
|
||||
completed = subprocess.run(
|
||||
fallback_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
ok=False,
|
||||
error=f"claude_cli_timeout_{command_timeout}s",
|
||||
payload={"op": op, "timeout_seconds": command_timeout},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"claude_cli_exec_error:{exc}", payload={"op": op})
|
||||
|
||||
stdout = str(completed.stdout or "").strip()
|
||||
stderr = str(completed.stderr or "").strip()
|
||||
parsed = {}
|
||||
if stdout:
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"raw_stdout": stdout}
|
||||
except Exception:
|
||||
parsed = {"raw_stdout": stdout}
|
||||
|
||||
parsed_status = str(parsed.get("status") or "").strip().lower()
|
||||
permission_request = parsed.get("permission_request")
|
||||
requires_approval = bool(
|
||||
parsed.get("requires_approval")
|
||||
or parsed_status in {"requires_approval", "waiting_approval"}
|
||||
or permission_request
|
||||
)
|
||||
|
||||
ext = (
|
||||
str(parsed.get("external_key") or "").strip()
|
||||
or str(parsed.get("task_id") or "").strip()
|
||||
or str(payload.get("external_key") or "").strip()
|
||||
)
|
||||
|
||||
ok = completed.returncode == 0
|
||||
out_payload = {
|
||||
"op": op,
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": stdout[:4000],
|
||||
"stderr": stderr[:4000],
|
||||
"parsed_status": parsed_status,
|
||||
"requires_approval": requires_approval,
|
||||
}
|
||||
out_payload.update(parsed)
|
||||
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
|
||||
return self._builtin_stub_result(op, dict(payload or {}), stderr)
|
||||
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
command = self._command(config)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(1, min(20, self._timeout(config))),
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"claude_cli_unavailable:{exc}")
|
||||
return ProviderResult(
|
||||
ok=(completed.returncode == 0),
|
||||
payload={
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": str(completed.stdout or "").strip()[:1000],
|
||||
"stderr": str(completed.stderr or "").strip()[:1000],
|
||||
},
|
||||
error=("" if completed.returncode == 0 else str(completed.stderr or "").strip()[:1000]),
|
||||
)
|
||||
|
||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "create", payload)
|
||||
|
||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "append_update", payload)
|
||||
|
||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "mark_complete", payload)
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "link_task", payload)
|
||||
Reference in New Issue
Block a user