常规归档
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewResult:
|
||||
has_serious_issue: bool
|
||||
comment: str = ""
|
||||
codex_session_id: str = ""
|
||||
|
||||
|
||||
def _format_args(command: Sequence[str], values: Mapping[str, str]) -> list[str]:
|
||||
return [Template(part).safe_substitute(values) for part in command]
|
||||
|
||||
|
||||
def _run_command(command: Sequence[str], values: Mapping[str, str], timeout: int) -> str:
|
||||
args = _format_args(command, values)
|
||||
logging.info("Running: %s", args[0] if args else "")
|
||||
t0 = time.monotonic()
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
logging.info("Finished in %.1fs, exit=%d", elapsed, result.returncode)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Command failed with exit code {result.returncode}: {args}\n"
|
||||
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
class AgentRunner:
|
||||
"""Boundary around Codex CLI calls.
|
||||
|
||||
The Claude Code steps run in the current Claude Code session through the
|
||||
skill instructions. This runner only invokes Codex review commands.
|
||||
Commands are injected by passing argv lists with Template placeholders.
|
||||
Supported placeholders: task_id, user_prompt, work_dir, task_path,
|
||||
latest_discuss_path, discuss_path, notes_dir, codex_session_id.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
codex_review_command: Sequence[str],
|
||||
timeout: int = 600,
|
||||
) -> None:
|
||||
self.codex_review_command = codex_review_command
|
||||
self.timeout = timeout
|
||||
|
||||
def review(self, values: Mapping[str, str]) -> ReviewResult:
|
||||
enriched = {**values, "timeout": str(self.timeout)}
|
||||
output = _run_command(self.codex_review_command, enriched, self.timeout)
|
||||
if not output:
|
||||
return ReviewResult(has_serious_issue=False)
|
||||
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return ReviewResult(has_serious_issue=True, comment=output)
|
||||
|
||||
return ReviewResult(
|
||||
has_serious_issue=bool(payload.get("has_serious_issue")),
|
||||
comment=str(payload.get("comment", "")).strip(),
|
||||
codex_session_id=str(payload.get("codex_session_id", "")).strip(),
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Codex review shim called by agents.py.
|
||||
|
||||
argv: task_path latest_discuss_path codex_session_id work_dir
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_jsonl(stdout: str) -> tuple[str, str]:
|
||||
session_id = ""
|
||||
final_message = ""
|
||||
text_parts: list[str] = []
|
||||
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event: dict[str, Any] = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
text_parts.append(line)
|
||||
continue
|
||||
|
||||
for key in (
|
||||
"session_id",
|
||||
"sessionId",
|
||||
"thread_id",
|
||||
"threadId",
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
):
|
||||
value = event.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
session_id = value
|
||||
|
||||
event_type = str(event.get("type", ""))
|
||||
if event_type in {"session.started", "session.created", "thread.started"}:
|
||||
value = event.get("id") or event.get("session_id") or event.get("thread_id")
|
||||
if isinstance(value, str) and value:
|
||||
session_id = value
|
||||
|
||||
if event_type == "item.completed":
|
||||
item = event.get("item")
|
||||
if isinstance(item, dict) and item.get("type") == "agent_message":
|
||||
value = item.get("text")
|
||||
if isinstance(value, str) and value:
|
||||
text_parts.append(value)
|
||||
|
||||
if event_type in {"agent_message", "message", "response.output_text.done"}:
|
||||
value = event.get("text") or event.get("message") or event.get("content")
|
||||
if isinstance(value, str) and value:
|
||||
text_parts.append(value)
|
||||
|
||||
if event_type in {"exec.completed", "turn.completed", "task.completed"}:
|
||||
value = event.get("last_message") or event.get("message") or event.get("output")
|
||||
if isinstance(value, str) and value:
|
||||
final_message = value
|
||||
|
||||
comment = (final_message or "\n".join(text_parts)).strip()
|
||||
return session_id, comment
|
||||
|
||||
|
||||
def resolve_codex() -> str:
|
||||
import shutil
|
||||
# 1. Explicit env var
|
||||
exe = os.environ.get("CODEX_EXE", "")
|
||||
if exe and Path(exe).exists():
|
||||
return exe
|
||||
# 2. PATH lookup
|
||||
found = shutil.which("codex") or shutil.which("codex.cmd")
|
||||
if found:
|
||||
return found
|
||||
# 3. Hardcoded fallback for the original developer machine
|
||||
fallback = r"C:\Users\bowei\.conda\envs\langgraph\codex.cmd"
|
||||
if Path(fallback).exists():
|
||||
return fallback
|
||||
raise FileNotFoundError(
|
||||
"Cannot find codex executable. Set CODEX_EXE environment variable or add codex to PATH."
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
task_path = sys.argv[1]
|
||||
latest_discuss = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else ""
|
||||
codex_session_id = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else ""
|
||||
work_dir = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else str(Path.cwd())
|
||||
timeout = int(sys.argv[5]) if len(sys.argv) > 5 and sys.argv[5] else 300
|
||||
|
||||
prompt = (
|
||||
f"Working directory: {work_dir}\n"
|
||||
f"Review the plan document at {task_path}.\n"
|
||||
"Only report severe issues.\n"
|
||||
"Final answer rules: if there is no severe issue, output exactly NO_SERIOUS_ISSUE.\n"
|
||||
"If there is a severe issue, briefly describe it in one paragraph.\n"
|
||||
)
|
||||
if latest_discuss:
|
||||
prompt += f"Also verify that issues raised in {latest_discuss} were addressed.\n"
|
||||
|
||||
codex = resolve_codex()
|
||||
|
||||
fd, last_message_name = tempfile.mkstemp(
|
||||
prefix="codex-last-message-",
|
||||
suffix=".txt",
|
||||
dir=None,
|
||||
text=True,
|
||||
)
|
||||
os.close(fd)
|
||||
last_message_path = Path(last_message_name)
|
||||
try:
|
||||
flags = ["--json", "--skip-git-repo-check", "--output-last-message", str(last_message_path)]
|
||||
if codex_session_id:
|
||||
command_args = ["cmd", "/c", codex, "exec", "resume"] + flags + [codex_session_id, "-"]
|
||||
else:
|
||||
command_args = ["cmd", "/c", codex, "exec"] + flags + ["-"]
|
||||
logging.info("Invoking codex: %s", command_args[0] if command_args else "")
|
||||
t0 = time.monotonic()
|
||||
result = subprocess.run(
|
||||
command_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
input=prompt,
|
||||
cwd=work_dir,
|
||||
)
|
||||
logging.info("Codex finished in %.1fs, exit=%d", time.monotonic() - t0, result.returncode)
|
||||
session_id, stream_comment = parse_jsonl(result.stdout)
|
||||
comment = last_message_path.read_text(encoding="utf-8").strip()
|
||||
if not comment:
|
||||
comment = stream_comment
|
||||
if comment.strip() == "NO_SERIOUS_ISSUE":
|
||||
comment = ""
|
||||
if codex_session_id:
|
||||
session_id = codex_session_id
|
||||
elif not session_id:
|
||||
session_id = codex_session_id
|
||||
finally:
|
||||
try:
|
||||
last_message_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if result.returncode != 0 and not comment:
|
||||
sys.stderr.write(result.stderr)
|
||||
return result.returncode
|
||||
|
||||
print(json.dumps(
|
||||
{
|
||||
"has_serious_issue": bool(comment),
|
||||
"comment": comment,
|
||||
"codex_session_id": session_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,104 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TaskId,
|
||||
|
||||
[string]$Prompt = "",
|
||||
|
||||
[string]$NotesDir = "Notes",
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CodexReviewCommand,
|
||||
|
||||
[string]$CondaExe = "",
|
||||
[string]$CondaEnv = "langgraph",
|
||||
[string]$CodexHome = "",
|
||||
[int]$Timeout = 600,
|
||||
[string]$ThreadId = "",
|
||||
[string]$Resume = ""
|
||||
)
|
||||
|
||||
function Resolve-CondaExe {
|
||||
param([string]$ExplicitPath)
|
||||
|
||||
if ($ExplicitPath) {
|
||||
if (-not (Test-Path -LiteralPath $ExplicitPath)) {
|
||||
throw "Conda executable does not exist: $ExplicitPath"
|
||||
}
|
||||
return (Resolve-Path -LiteralPath $ExplicitPath).Path
|
||||
}
|
||||
|
||||
if ($env:CONDA_EXE -and (Test-Path -LiteralPath $env:CONDA_EXE)) {
|
||||
return (Resolve-Path -LiteralPath $env:CONDA_EXE).Path
|
||||
}
|
||||
|
||||
$CondaCommand = Get-Command conda -ErrorAction SilentlyContinue
|
||||
if ($CondaCommand -and $CondaCommand.Source) {
|
||||
return $CondaCommand.Source
|
||||
}
|
||||
|
||||
$UserProfile = [Environment]::GetFolderPath("UserProfile")
|
||||
if ($UserProfile) {
|
||||
$UserFallback = Join-Path $UserProfile "miniconda3\Scripts\conda.exe"
|
||||
if (Test-Path -LiteralPath $UserFallback) {
|
||||
return (Resolve-Path -LiteralPath $UserFallback).Path
|
||||
}
|
||||
}
|
||||
|
||||
$LegacyFallback = "C:\Users\bowei\miniconda3\Scripts\conda.exe"
|
||||
if (Test-Path -LiteralPath $LegacyFallback) {
|
||||
return $LegacyFallback
|
||||
}
|
||||
|
||||
throw "Unable to locate conda. Set -CondaExe or CONDA_EXE, or ensure conda is on PATH."
|
||||
}
|
||||
|
||||
function Resolve-CodexHome {
|
||||
param([string]$ExplicitPath)
|
||||
|
||||
if ($ExplicitPath) {
|
||||
return $ExplicitPath
|
||||
}
|
||||
|
||||
if ($env:CODEX_HOME) {
|
||||
return $env:CODEX_HOME
|
||||
}
|
||||
|
||||
$UserProfile = [Environment]::GetFolderPath("UserProfile")
|
||||
if (-not $UserProfile) {
|
||||
throw "Unable to determine user profile for CODEX_HOME. Set -CodexHome or CODEX_HOME."
|
||||
}
|
||||
|
||||
return (Join-Path $UserProfile ".codex")
|
||||
}
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$LaunchWorkDir = (Get-Location).Path
|
||||
Push-Location $PSScriptRoot
|
||||
# Ensure conda run child processes can encode Unicode (Chinese, etc.) output
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
try {
|
||||
$env:CODEX_HOME = Resolve-CodexHome -ExplicitPath $CodexHome
|
||||
|
||||
$Conda = Resolve-CondaExe -ExplicitPath $CondaExe
|
||||
$ArgsList = @(
|
||||
"run", "-n", $CondaEnv, "python", "run_workflow.py",
|
||||
"--task-id", $TaskId,
|
||||
"--notes-dir", $NotesDir,
|
||||
"--work-dir", $LaunchWorkDir,
|
||||
"--timeout", "$Timeout"
|
||||
)
|
||||
|
||||
if ($Prompt) { $ArgsList += @("--prompt", $Prompt) }
|
||||
$CodexReviewCommandB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CodexReviewCommand))
|
||||
$ArgsList += @("--codex-review-command-b64", $CodexReviewCommandB64)
|
||||
if ($ThreadId) { $ArgsList += @("--thread-id", $ThreadId) }
|
||||
if ($Resume) {
|
||||
$ResumeB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Resume))
|
||||
$ArgsList += @("--resume-b64", $ResumeB64)
|
||||
}
|
||||
|
||||
& $Conda @ArgsList
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.types import Command
|
||||
|
||||
from agents import AgentRunner
|
||||
from workflow import build_workflow, resolve_notes_dir, resolve_work_dir
|
||||
|
||||
|
||||
DB_FILENAME = "workflow_state.db"
|
||||
|
||||
|
||||
def parse_command(value: str | None) -> list[str] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback: the string may contain Windows paths with unescaped backslashes
|
||||
# that make it invalid JSON. Re-escape backslashes and retry before giving up.
|
||||
try:
|
||||
import re
|
||||
# Replace bare backslashes (not already doubled) with \\
|
||||
re_escaped = re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', value)
|
||||
parsed = json.loads(re_escaped)
|
||||
except json.JSONDecodeError:
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
# Last-resort comma split; strip surrounding quotes from each item.
|
||||
parsed = [item.strip().strip('"').strip("'")
|
||||
for item in stripped[1:-1].split(",")]
|
||||
else:
|
||||
raise
|
||||
if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed):
|
||||
raise argparse.ArgumentTypeError("command must be a JSON string array")
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_command_b64(value: str | None) -> list[str] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
decoded = base64.b64decode(value).decode("utf-8")
|
||||
except Exception as exc:
|
||||
raise argparse.ArgumentTypeError(f"invalid base64 command: {exc}") from exc
|
||||
return parse_command(decoded)
|
||||
|
||||
|
||||
def parse_json_b64(value: str | None):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
decoded = base64.b64decode(value).decode("utf-8")
|
||||
except Exception as exc:
|
||||
raise argparse.ArgumentTypeError(f"invalid base64 JSON: {exc}") from exc
|
||||
return json.loads(decoded)
|
||||
|
||||
|
||||
def run_workflow(
|
||||
*,
|
||||
task_id: str,
|
||||
user_prompt: str = "",
|
||||
notes_dir: str = "Notes",
|
||||
work_dir: str = "",
|
||||
runner: AgentRunner,
|
||||
thread_id: str | None = None,
|
||||
resume_payload=None,
|
||||
) -> dict:
|
||||
effective_thread = thread_id or task_id
|
||||
resolved_work_dir = resolve_work_dir(work_dir)
|
||||
resolved_notes_dir = resolve_notes_dir(resolved_work_dir, notes_dir)
|
||||
resolved_notes_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = resolved_notes_dir / DB_FILENAME
|
||||
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as checkpointer:
|
||||
app = build_workflow(runner, checkpointer=checkpointer)
|
||||
config = {"configurable": {"thread_id": effective_thread}}
|
||||
|
||||
if resume_payload is not None:
|
||||
result = app.invoke(Command(resume=resume_payload), config=config)
|
||||
else:
|
||||
result = app.invoke(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"user_prompt": user_prompt,
|
||||
"notes_dir": str(resolved_notes_dir),
|
||||
"work_dir": str(resolved_work_dir),
|
||||
},
|
||||
config=config,
|
||||
)
|
||||
|
||||
interrupts = result.get("__interrupt__", [])
|
||||
if interrupts:
|
||||
intr = interrupts[0]
|
||||
action_required = intr.value if hasattr(intr, "value") else intr
|
||||
return {
|
||||
"status": "waiting",
|
||||
"thread_id": effective_thread,
|
||||
"db_path": str(db_path),
|
||||
"action_required": action_required,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "done",
|
||||
"thread_id": effective_thread,
|
||||
"db_path": str(db_path),
|
||||
"task_path": result.get("task_path", ""),
|
||||
"latest_discuss_path": result.get("latest_discuss_path", ""),
|
||||
"work_dir": result.get("work_dir", ""),
|
||||
"codex_session_id": result.get("codex_session_id", ""),
|
||||
"has_serious_issue": result.get("has_serious_issue", False),
|
||||
"should_exit": result.get("should_exit", True),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the LangGraph Claude/Codex review workflow."
|
||||
)
|
||||
parser.add_argument("--task-id", required=True, help="Task id, for example 02")
|
||||
parser.add_argument("--prompt", default="", help="User request recorded for traceability")
|
||||
parser.add_argument("--notes-dir", default="Notes", help="Directory for Task/discuss files")
|
||||
parser.add_argument("--work-dir", default="", help="Claude Code launch working directory")
|
||||
parser.add_argument(
|
||||
"--codex-review-command",
|
||||
type=parse_command,
|
||||
default=None,
|
||||
help="JSON argv for Codex review step.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--codex-review-command-b64",
|
||||
type=parse_command_b64,
|
||||
default=None,
|
||||
help="Base64-encoded JSON argv for Codex review step.",
|
||||
)
|
||||
parser.add_argument("--timeout", type=int, default=600)
|
||||
parser.add_argument(
|
||||
"--thread-id",
|
||||
default="",
|
||||
help="Thread ID for resuming an existing run (defaults to task-id)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
default="",
|
||||
help=(
|
||||
"JSON payload for Command(resume=...). "
|
||||
"Use true for plan/revise/rejection steps; {\"accepts\":true} for decision step."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume-b64",
|
||||
default="",
|
||||
help="Base64-encoded JSON payload for Command(resume=...).",
|
||||
)
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging to stderr.")
|
||||
args = parser.parse_args()
|
||||
if args.verbose:
|
||||
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
codex_review_command = args.codex_review_command_b64 or args.codex_review_command
|
||||
if not codex_review_command:
|
||||
parser.error("--codex-review-command or --codex-review-command-b64 is required")
|
||||
|
||||
runner = AgentRunner(
|
||||
codex_review_command=codex_review_command,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
# resume=True tells LangGraph to resume from the last interrupt without a specific value.
|
||||
# Callers should pass 'true' (JSON boolean) for plan/revise/rejection steps.
|
||||
resume_payload = parse_json_b64(args.resume_b64) if args.resume_b64 else (
|
||||
json.loads(args.resume) if args.resume else None
|
||||
)
|
||||
|
||||
output = run_workflow(
|
||||
task_id=args.task_id,
|
||||
user_prompt=args.prompt,
|
||||
notes_dir=args.notes_dir,
|
||||
work_dir=args.work_dir,
|
||||
runner=runner,
|
||||
thread_id=args.thread_id or None,
|
||||
resume_payload=resume_payload,
|
||||
)
|
||||
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TypedDict
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from agents import AgentRunner
|
||||
|
||||
|
||||
class WorkflowState(TypedDict):
|
||||
task_id: str
|
||||
user_prompt: str
|
||||
work_dir: str
|
||||
yy: int
|
||||
notes_dir: str
|
||||
task_path: str
|
||||
latest_discuss_path: str
|
||||
codex_session_id: str
|
||||
has_serious_issue: bool
|
||||
review_comment: str
|
||||
should_exit: bool
|
||||
claude_accepts: bool
|
||||
rejection_reason: str
|
||||
|
||||
|
||||
def format_yy(value: int) -> str:
|
||||
return f"{value:02d}"
|
||||
|
||||
|
||||
def discuss_path(notes_dir: str, task_id: str, yy: int) -> str:
|
||||
return str(Path(notes_dir) / f"discuss{task_id}-{format_yy(yy)}.md")
|
||||
|
||||
|
||||
def resolve_work_dir(value: str) -> Path:
|
||||
return Path(value or ".").resolve()
|
||||
|
||||
|
||||
def resolve_notes_dir(work_dir: Path, value: str) -> Path:
|
||||
notes_dir = Path(value or "Notes")
|
||||
if not notes_dir.is_absolute():
|
||||
notes_dir = work_dir / notes_dir
|
||||
return notes_dir.resolve()
|
||||
|
||||
|
||||
def next_discuss_index(notes_dir: str, task_id: str) -> int:
|
||||
notes = Path(notes_dir)
|
||||
if not notes.exists():
|
||||
return 0
|
||||
pattern = re.compile(rf"^discuss{re.escape(task_id)}-(\d+)\.md$")
|
||||
indexes: list[int] = []
|
||||
for path in notes.iterdir():
|
||||
match = pattern.match(path.name)
|
||||
if match:
|
||||
indexes.append(int(match.group(1)))
|
||||
return max(indexes, default=-1) + 1
|
||||
|
||||
|
||||
def latest_discuss_path(notes_dir: str, task_id: str) -> str:
|
||||
notes = Path(notes_dir)
|
||||
if not notes.exists():
|
||||
return ""
|
||||
pattern = re.compile(rf"^discuss{re.escape(task_id)}-(\d+)\.md$")
|
||||
candidates: list[tuple[int, Path]] = []
|
||||
for path in notes.iterdir():
|
||||
match = pattern.match(path.name)
|
||||
if match:
|
||||
candidates.append((int(match.group(1)), path))
|
||||
if not candidates:
|
||||
return ""
|
||||
return str(max(candidates, key=lambda item: item[0])[1])
|
||||
|
||||
|
||||
def validate_task_file(task_path: str) -> None:
|
||||
path = Path(task_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Task file does not exist. Current Claude Code session must write it first: {task_path}"
|
||||
)
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
if not text:
|
||||
raise ValueError(f"Task file is empty: {task_path}")
|
||||
|
||||
# Count level-2 headings (lines starting with ## )
|
||||
lines = text.split('\n')
|
||||
level2_count = sum(1 for line in lines if line.startswith('## '))
|
||||
|
||||
if level2_count < 3:
|
||||
raise ValueError(
|
||||
f"Task file must contain at least 3 level-2 sections (## ...). Found: {level2_count}. "
|
||||
f"File: {task_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_rejection_file(path: str) -> None:
|
||||
rejection_path = Path(path)
|
||||
if not rejection_path.exists():
|
||||
raise FileNotFoundError(
|
||||
"Rejection file was not written. Current Claude Code session must "
|
||||
f"write it first: {path}"
|
||||
)
|
||||
text = rejection_path.read_text(encoding="utf-8").strip()
|
||||
if not text:
|
||||
raise ValueError(f"Rejection file is empty: {path}")
|
||||
required_markers = ["Claude Code Rejection", "拒绝"]
|
||||
if not any(marker in text for marker in required_markers):
|
||||
raise ValueError(
|
||||
f"Rejection file must contain a Claude rejection marker: {path}"
|
||||
)
|
||||
if len(text) < 30:
|
||||
raise ValueError(
|
||||
f"Rejection file is too short to contain a meaningful reason: {path}"
|
||||
)
|
||||
|
||||
|
||||
def values_from_state(state: WorkflowState) -> dict[str, str]:
|
||||
return {
|
||||
"task_id": state["task_id"],
|
||||
"user_prompt": state["user_prompt"],
|
||||
"work_dir": state["work_dir"],
|
||||
"yy": format_yy(state["yy"]),
|
||||
"notes_dir": state["notes_dir"],
|
||||
"task_path": state["task_path"],
|
||||
"discuss_path": discuss_path(state["notes_dir"], state["task_id"], state["yy"]),
|
||||
"latest_discuss_path": state["latest_discuss_path"],
|
||||
"codex_session_id": state["codex_session_id"],
|
||||
}
|
||||
|
||||
|
||||
def build_workflow(runner: AgentRunner, *, checkpointer=None):
|
||||
graph = StateGraph(WorkflowState)
|
||||
|
||||
def initialize(state: WorkflowState) -> WorkflowState:
|
||||
work_dir = resolve_work_dir(state.get("work_dir", ""))
|
||||
notes_dir = resolve_notes_dir(work_dir, state.get("notes_dir") or "Notes")
|
||||
task_id = state["task_id"]
|
||||
task_path = str(notes_dir / f"Task{task_id}.md")
|
||||
yy = next_discuss_index(str(notes_dir), task_id)
|
||||
latest_path = latest_discuss_path(str(notes_dir), task_id)
|
||||
return {
|
||||
**state,
|
||||
"work_dir": str(work_dir),
|
||||
"yy": yy,
|
||||
"notes_dir": str(notes_dir),
|
||||
"task_path": task_path,
|
||||
"latest_discuss_path": latest_path,
|
||||
"codex_session_id": state.get("codex_session_id", ""),
|
||||
"has_serious_issue": False,
|
||||
"review_comment": "",
|
||||
"should_exit": False,
|
||||
"claude_accepts": False,
|
||||
"rejection_reason": "",
|
||||
}
|
||||
|
||||
def request_plan(state: WorkflowState) -> WorkflowState:
|
||||
interrupt({
|
||||
"action": "write_plan",
|
||||
"task_path": state["task_path"],
|
||||
"user_prompt": state["user_prompt"],
|
||||
})
|
||||
validate_task_file(state["task_path"])
|
||||
return state
|
||||
|
||||
def review(state: WorkflowState) -> WorkflowState:
|
||||
result = runner.review(values_from_state(state))
|
||||
updates = {
|
||||
**state,
|
||||
"has_serious_issue": result.has_serious_issue,
|
||||
"review_comment": result.comment,
|
||||
"should_exit": not result.has_serious_issue,
|
||||
}
|
||||
if result.codex_session_id:
|
||||
updates["codex_session_id"] = result.codex_session_id
|
||||
return updates
|
||||
|
||||
def write_discuss(state: WorkflowState) -> WorkflowState:
|
||||
path = discuss_path(state["notes_dir"], state["task_id"], state["yy"])
|
||||
comment = state["review_comment"].strip()
|
||||
if not comment:
|
||||
comment = "Codex reported a serious issue but did not provide details."
|
||||
|
||||
Path(path).write_text(
|
||||
"\n".join([
|
||||
f"# discuss{state['task_id']}-{format_yy(state['yy'])}",
|
||||
"",
|
||||
"## Codex Review",
|
||||
"",
|
||||
comment,
|
||||
"",
|
||||
]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {
|
||||
**state,
|
||||
"latest_discuss_path": path,
|
||||
"yy": state["yy"] + 1,
|
||||
}
|
||||
|
||||
def request_decision(state: WorkflowState) -> WorkflowState:
|
||||
result = interrupt({
|
||||
"action": "decide",
|
||||
"discuss_path": state["latest_discuss_path"],
|
||||
})
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
return {
|
||||
**state,
|
||||
"claude_accepts": bool(result.get("accepts", False)),
|
||||
"rejection_reason": str(result.get("reason", "")),
|
||||
}
|
||||
|
||||
def request_revision(state: WorkflowState) -> WorkflowState:
|
||||
interrupt({
|
||||
"action": "revise",
|
||||
"task_path": state["task_path"],
|
||||
"discuss_path": state["latest_discuss_path"],
|
||||
})
|
||||
validate_task_file(state["task_path"])
|
||||
return state
|
||||
|
||||
def request_rejection(state: WorkflowState) -> WorkflowState:
|
||||
path = discuss_path(state["notes_dir"], state["task_id"], state["yy"])
|
||||
interrupt({
|
||||
"action": "write_rejection",
|
||||
"output_path": path,
|
||||
"reason_hint": state["rejection_reason"],
|
||||
"required_heading": "## Claude Code Rejection",
|
||||
})
|
||||
validate_rejection_file(path)
|
||||
return {
|
||||
**state,
|
||||
"latest_discuss_path": path,
|
||||
"yy": state["yy"] + 1,
|
||||
}
|
||||
|
||||
def review_route(state: WorkflowState) -> str:
|
||||
return "done" if state["should_exit"] else "write_discuss"
|
||||
|
||||
def decision_route(state: WorkflowState) -> str:
|
||||
return "revise" if state["claude_accepts"] else "reject"
|
||||
|
||||
graph.add_node("initialize", initialize)
|
||||
graph.add_node("request_plan", request_plan)
|
||||
graph.add_node("review", review)
|
||||
graph.add_node("write_discuss", write_discuss)
|
||||
graph.add_node("request_decision", request_decision)
|
||||
graph.add_node("request_revision", request_revision)
|
||||
graph.add_node("request_rejection", request_rejection)
|
||||
|
||||
graph.add_edge(START, "initialize")
|
||||
graph.add_edge("initialize", "request_plan")
|
||||
graph.add_edge("request_plan", "review")
|
||||
graph.add_conditional_edges(
|
||||
"review",
|
||||
review_route,
|
||||
{"done": END, "write_discuss": "write_discuss"},
|
||||
)
|
||||
graph.add_edge("write_discuss", "request_decision")
|
||||
graph.add_conditional_edges(
|
||||
"request_decision",
|
||||
decision_route,
|
||||
{"revise": "request_revision", "reject": "request_rejection"},
|
||||
)
|
||||
graph.add_edge("request_revision", "review")
|
||||
graph.add_edge("request_rejection", "review")
|
||||
|
||||
return graph.compile(checkpointer=checkpointer)
|
||||
Reference in New Issue
Block a user