修改agent流程,分步构建cdsl
This commit is contained in:
+163
-102
@@ -12,6 +12,7 @@ from app.settings import Settings
|
||||
|
||||
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
|
||||
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
|
||||
CANDIDATE_ID = re.compile(r"^candidate_[a-z0-9]{12,32}$")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -96,15 +97,6 @@ class WorkspaceStore:
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str:
|
||||
"""Persist structured intake/planning evidence before a task exists."""
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning"
|
||||
relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json"
|
||||
path = self.conversation_dir(conversation) / relative
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def ensure_conversation(
|
||||
self,
|
||||
conversation_id: str | None,
|
||||
@@ -184,10 +176,14 @@ class WorkspaceStore:
|
||||
return self._migrate_task(current, path)
|
||||
task_dir = self.task_dir(tid)
|
||||
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
|
||||
source_requirements = str(request or "").strip()
|
||||
source_path = task_dir / "source-requirements.md"
|
||||
source_path.write_text(source_requirements + "\n", encoding="utf-8")
|
||||
record = {
|
||||
"schema_version": "1.3",
|
||||
"schema_version": "2.0",
|
||||
"task_id": tid,
|
||||
"request": request,
|
||||
"source_requirements_path": "source-requirements.md",
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"current_revision": "",
|
||||
@@ -195,9 +191,10 @@ class WorkspaceStore:
|
||||
"published_revision": "",
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"requirements_path": "",
|
||||
"agent_state_path": "",
|
||||
"active_candidate_id": "",
|
||||
"active_branch_id": "main",
|
||||
"run_failure_path": "",
|
||||
"revisions": [],
|
||||
}
|
||||
@@ -205,24 +202,42 @@ class WorkspaceStore:
|
||||
return record
|
||||
|
||||
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
|
||||
"""Add run-state fields lazily without rewriting successful history."""
|
||||
"""Fill in omitted fields for native autonomous tasks only.
|
||||
|
||||
A task from a retired protocol must never be made to look resumable by
|
||||
rewriting its schema version. In particular, doing so would make an
|
||||
old revision and its obsolete planning state appear to be a valid
|
||||
autonomous work head. Historical tasks remain read-only records; the
|
||||
service startup path marks only *running* legacy tasks as obsolete.
|
||||
"""
|
||||
if str(task.get("schema_version") or "") != "2.0":
|
||||
return task
|
||||
changed = False
|
||||
current = str(task.get("current_revision") or "")
|
||||
defaults = {
|
||||
"schema_version": "1.3",
|
||||
"active_revision": current,
|
||||
"published_revision": current,
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"requirements_path": "",
|
||||
"source_requirements_path": "",
|
||||
"completion_checklist_path": "",
|
||||
"agent_state_path": "",
|
||||
"active_candidate_id": "",
|
||||
"active_branch_id": "main",
|
||||
"run_failure_path": "",
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
if key not in task:
|
||||
task[key] = value
|
||||
changed = True
|
||||
if not str(task.get("source_requirements_path") or ""):
|
||||
relative = Path("source-requirements.md")
|
||||
source_path = path.parent / relative
|
||||
if not source_path.exists():
|
||||
source_path.write_text(str(task.get("request") or "").strip() + "\n", encoding="utf-8")
|
||||
task["source_requirements_path"] = relative.as_posix()
|
||||
changed = True
|
||||
for revision in task.get("revisions") or ():
|
||||
if not isinstance(revision, dict):
|
||||
continue
|
||||
@@ -267,10 +282,11 @@ class WorkspaceStore:
|
||||
task.update({
|
||||
"lifecycle": "running",
|
||||
"run_id": run_id or new_id("run"),
|
||||
"active_node_id": "",
|
||||
"active_candidate_id": "",
|
||||
"run_failure_path": "",
|
||||
"request": request or task.get("request") or "",
|
||||
"active_revision": str(task.get("current_revision") or ""),
|
||||
"active_branch_id": str(task.get("active_branch_id") or "main"),
|
||||
"updated_at": now_iso(),
|
||||
})
|
||||
write_json(self.task_path(task_id), task)
|
||||
@@ -291,7 +307,7 @@ class WorkspaceStore:
|
||||
revision["visibility"] = "final"
|
||||
task.update({
|
||||
"lifecycle": lifecycle,
|
||||
"active_node_id": "",
|
||||
"active_candidate_id": "",
|
||||
"run_failure_path": failure_path,
|
||||
"updated_at": now_iso(),
|
||||
})
|
||||
@@ -303,6 +319,8 @@ class WorkspaceStore:
|
||||
if not revision_id:
|
||||
task["active_revision"] = ""
|
||||
task["current_revision"] = ""
|
||||
if branch_id:
|
||||
task["active_branch_id"] = branch_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
@@ -317,13 +335,122 @@ class WorkspaceStore:
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]:
|
||||
def set_agent_state(self, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Persist enough autonomous-agent state to resume after a restart."""
|
||||
task = self.ensure_task(task_id, "")
|
||||
task["active_node_id"] = node_id
|
||||
relative = Path("agent-state.json")
|
||||
write_json(self.task_dir(task_id) / relative, values)
|
||||
task["agent_state_path"] = relative.as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def read_agent_state(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("agent_state_path") or "")
|
||||
value = read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def requirements_document_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "requirements.md"
|
||||
|
||||
def read_source_requirements(self, task_id: str) -> str:
|
||||
"""Return the server-owned, immutable user request for this task.
|
||||
|
||||
Older tasks predate the artifact. Their persisted request is a
|
||||
read-only compatibility fallback; new tasks always have the artifact.
|
||||
"""
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("source_requirements_path") or "")
|
||||
if relative:
|
||||
path = self.artifact_path(task_id, relative)
|
||||
if path.is_file():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return str(task.get("request") or "")
|
||||
|
||||
def write_requirements_document(self, task_id: str, markdown: str) -> Path:
|
||||
"""Write the one immutable requirements document for an agent run."""
|
||||
text = str(markdown or "").strip()
|
||||
if not text:
|
||||
raise ValueError("requirements.md must not be empty")
|
||||
task = self.ensure_task(task_id, "")
|
||||
relative = Path("requirements.md")
|
||||
path = self.task_dir(task_id) / relative
|
||||
if path.exists() or str(task.get("requirements_path") or ""):
|
||||
raise ValueError("requirements.md is frozen and cannot be rewritten")
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
task["requirements_path"] = relative.as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_requirements_document(self, task_id: str) -> str:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("requirements_path") or "")
|
||||
if not relative:
|
||||
return ""
|
||||
path = self.artifact_path(task_id, relative)
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
def completion_checklist_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "completion.md"
|
||||
|
||||
def write_completion_checklist(self, task_id: str, markdown: str) -> Path:
|
||||
"""Persist the one immutable, author-owned completion checklist."""
|
||||
text = str(markdown or "").strip()
|
||||
if not text:
|
||||
raise ValueError("completion.md must not be empty")
|
||||
task = self.ensure_task(task_id, "")
|
||||
if not self.read_requirements_document(task_id):
|
||||
raise ValueError("requirements.md must be written before completion.md")
|
||||
relative = Path("completion.md")
|
||||
path = self.task_dir(task_id) / relative
|
||||
if path.exists() or str(task.get("completion_checklist_path") or ""):
|
||||
raise ValueError("completion.md is frozen and cannot be rewritten")
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
task["completion_checklist_path"] = relative.as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_completion_checklist(self, task_id: str) -> str:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("completion_checklist_path") or "")
|
||||
if not relative:
|
||||
return ""
|
||||
path = self.artifact_path(task_id, relative)
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
def new_candidate(self, task_id: str) -> tuple[str, Path]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
candidate_id = f"candidate_{secrets.token_hex(8)}"
|
||||
path = self.task_dir(task_id) / "candidates" / candidate_id
|
||||
path.mkdir(parents=True, exist_ok=False)
|
||||
task["active_candidate_id"] = candidate_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return candidate_id, path
|
||||
|
||||
def candidate_dir(self, task_id: str, candidate_id: str) -> Path:
|
||||
if not CANDIDATE_ID.fullmatch(str(candidate_id or "")):
|
||||
raise ValueError("Invalid candidate id")
|
||||
return self.task_dir(task_id) / "candidates" / candidate_id
|
||||
|
||||
def clear_active_candidate(self, task_id: str, candidate_id: str | None = None) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
if candidate_id and str(task.get("active_candidate_id") or "") not in {"", candidate_id}:
|
||||
raise ValueError("Candidate is not active")
|
||||
task["active_candidate_id"] = ""
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def append_agent_audit(self, task_id: str, kind: str, payload: dict[str, Any]) -> str:
|
||||
safe_kind = re.sub(r"[^a-z0-9_-]+", "-", str(kind).lower()).strip("-") or "event"
|
||||
relative = Path("agent-audit") / f"{now_iso().replace(':', '-').replace('+', '_')}_{safe_kind}_{secrets.token_hex(4)}.json"
|
||||
write_json(self.task_dir(task_id) / relative, {"recorded_at": now_iso(), "kind": safe_kind, **payload})
|
||||
return relative.as_posix()
|
||||
|
||||
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
@@ -336,21 +463,25 @@ class WorkspaceStore:
|
||||
|
||||
def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]:
|
||||
"""Move the generation head without deleting immutable checkpoint artifacts."""
|
||||
task = self.set_active_revision(task_id, revision_id, branch_id=branch_id)
|
||||
task = self.ensure_task(task_id, "")
|
||||
source_branch_id = str(task.get("active_branch_id") or "main")
|
||||
if revision_id:
|
||||
target = next(
|
||||
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id),
|
||||
None,
|
||||
)
|
||||
if not isinstance(target, dict) or target.get("status") != "success":
|
||||
raise ValueError("Active revision must be a successful revision")
|
||||
children: dict[str, set[str]] = {}
|
||||
for revision in task.get("revisions") or ():
|
||||
if not isinstance(revision, dict):
|
||||
continue
|
||||
parent = str(revision.get("parent_revision_id") or "")
|
||||
child = str(revision.get("revision_id") or "")
|
||||
if parent and child:
|
||||
if child and str(revision.get("branch_id") or "main") == source_branch_id:
|
||||
children.setdefault(parent, set()).add(child)
|
||||
superseded: set[str] = set()
|
||||
pending = list(children.get(revision_id, set())) if revision_id else [
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and not str(item.get("parent_revision_id") or "")
|
||||
]
|
||||
pending = list(children.get(revision_id, set()))
|
||||
while pending:
|
||||
child = pending.pop()
|
||||
if not child or child in superseded:
|
||||
@@ -360,6 +491,9 @@ class WorkspaceStore:
|
||||
for revision in task.get("revisions") or ():
|
||||
if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint":
|
||||
revision["visibility"] = "superseded"
|
||||
task["active_revision"] = revision_id
|
||||
task["current_revision"] = revision_id
|
||||
task["active_branch_id"] = branch_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
@@ -415,37 +549,6 @@ class WorkspaceStore:
|
||||
write_json(self.task_dir(task_id) / relative, payload)
|
||||
return relative.as_posix()
|
||||
|
||||
def generation_spec_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-spec.json"
|
||||
|
||||
def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path:
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_spec_path(task_id)
|
||||
write_json(path, spec)
|
||||
task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def generation_run_context_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-run-context.json"
|
||||
|
||||
def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path:
|
||||
"""Persist the frozen authoring inputs needed to resume after restart."""
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_run_context_path(task_id)
|
||||
write_json(path, context)
|
||||
task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("run_context_path") or "")
|
||||
context = read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
return context if isinstance(context, dict) else None
|
||||
|
||||
def running_tasks(self) -> list[dict[str, Any]]:
|
||||
"""Enumerate durable tasks that need a process-local worker."""
|
||||
tasks: list[dict[str, Any]] = []
|
||||
@@ -457,11 +560,6 @@ class WorkspaceStore:
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
||||
def read_generation_spec(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("generation_spec_path") or "")
|
||||
return read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
|
||||
def revision_dir(self, task_id: str, revision_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "revisions" / revision_id
|
||||
|
||||
@@ -473,43 +571,6 @@ class WorkspaceStore:
|
||||
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None:
|
||||
"""Return the latest revision CDSL when a quality failure needs repair."""
|
||||
task = self.read_task(task_id)
|
||||
for revision in reversed((task or {}).get("revisions") or []):
|
||||
revision_id = str(revision.get("revision_id") or "")
|
||||
if not revision_id or str(revision.get("quality_status") or "") != "needs_repair":
|
||||
continue
|
||||
path = self.revision_cdsl_path(task_id, revision_id)
|
||||
if path is not None:
|
||||
return revision_id, path
|
||||
return None
|
||||
|
||||
def feature_plan_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "feature-plan.json"
|
||||
|
||||
def read_feature_plan(self, task_id: str) -> dict[str, Any] | None:
|
||||
safe_task = safe_task_id(task_id)
|
||||
plan = read_json(self.feature_plan_path(safe_task))
|
||||
if not isinstance(plan, dict):
|
||||
return None
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
return None
|
||||
return plan
|
||||
|
||||
def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path:
|
||||
safe_task = safe_task_id(task_id)
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("Feature plan must be an object")
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
raise ValueError("Feature plan task_id does not match its task")
|
||||
plan["task_id"] = safe_task
|
||||
path = self.feature_plan_path(safe_task)
|
||||
write_json(path, plan)
|
||||
return path
|
||||
|
||||
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
|
||||
task = self.read_task(task_id)
|
||||
revision = next(
|
||||
|
||||
Reference in New Issue
Block a user