源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s

This commit is contained in:
agent-radar
2026-09-01 20:01:50 +08:00
commit 1a3a4ccde1
4652 changed files with 1891610 additions and 0 deletions
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""Shared slash-command argument parsing helpers."""
from __future__ import annotations
import shlex
def split_args(raw: str) -> list[str] | None:
"""Split *raw* with shlex; return ``None`` on unbalanced quotes."""
try:
return shlex.split(raw)
except ValueError:
return None
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""Shared constants for OMP workflow modes."""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Registered tool names used in OMP role configs / controller prompts.
# Keep in sync with QwenPaw ToolRegistry names (not display labels).
# ---------------------------------------------------------------------------
TOOL_READ_FILE = "read_file"
TOOL_GREP_SEARCH = "grep_search"
TOOL_GLOB_SEARCH = "glob_search"
TOOL_WRITE_FILE = "write_file"
TOOL_AST_SEARCH = "ast_search"
TOOL_EXECUTE_SHELL = "execute_shell_command"
# Read-oriented defaults used by multiple reviewer roles.
_TOOLS_READ = (
TOOL_READ_FILE,
TOOL_GREP_SEARCH,
TOOL_GLOB_SEARCH,
)
_TOOLS_READ_AST = _TOOLS_READ + (TOOL_AST_SEARCH,)
_TOOLS_READ_SHELL = _TOOLS_READ + (TOOL_EXECUTE_SHELL,)
_TOOLS_READ_AST_SHELL = _TOOLS_READ_AST + (TOOL_EXECUTE_SHELL,)
_TOOLS_READ_WRITE_SHELL = _TOOLS_READ + (
TOOL_WRITE_FILE,
TOOL_EXECUTE_SHELL,
)
_TOOLS_ARCHITECT = _TOOLS_READ + (
TOOL_WRITE_FILE,
TOOL_AST_SEARCH,
TOOL_EXECUTE_SHELL,
)
_TOOLS_PLANNER = _TOOLS_READ + (TOOL_WRITE_FILE, TOOL_AST_SEARCH)
# Role -> allowed_tools. ``None`` means inherit the parent's full set.
ROLE_ALLOWED_TOOLS: dict[str, list[str] | None] = {
"executor": None,
"debugger": None,
"architect": list(_TOOLS_ARCHITECT),
"analyst": list(_TOOLS_READ_WRITE_SHELL),
"critic": list(_TOOLS_READ_AST),
"security-reviewer": list(_TOOLS_READ_AST_SHELL),
"code-reviewer": list(_TOOLS_READ_AST_SHELL),
"qa-tester": list(_TOOLS_READ_SHELL),
"planner": list(_TOOLS_PLANNER),
"explore": list(_TOOLS_READ_AST),
"verifier": list(_TOOLS_READ_AST_SHELL),
}
# Role -> skills whitelist. Empty list clears inherited skills.
ROLE_SKILLS: dict[str, list[str] | None] = {
"executor": None,
"debugger": [],
"architect": [],
"analyst": [],
"critic": [],
"security-reviewer": [],
"code-reviewer": [],
"qa-tester": [],
"planner": [],
"explore": [],
"verifier": [],
}
ULTRAQA_MAX_CYCLES = 5
ULTRAQA_MAX_SAME_FAILURE = 3
AUTOPILOT_MAX_ITERATIONS = 60
AUTOPILOT_MAX_PHASE_ITERATIONS = 10
AUTOPILOT_MAX_VALIDATION_ROUNDS = 3
RALPH_MAX_ITERATIONS = 20
RALPH_MAX_SAME_ISSUE = 3
TEAM_MAX_FIX_ATTEMPTS = 3
TEAM_MAX_ITERATIONS = 30
ULTRAWORK_MAX_ITERATIONS = 30
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Hard checks for forked-worker integration before phase advance."""
from __future__ import annotations
from pathlib import Path
from typing import Any
def forks_integrated(
state: dict[str, Any] | None,
workspace_dir: Path | str | None = None,
) -> bool:
"""Return True when the controller flag is set and forks are merged.
- ``forks_integrated`` must be the JSON/Python boolean ``True``.
- When an integration project can be resolved, verification uses the
coding-project registry (via workspace pointer / git root) and the
active fork *scope* started at mode activation.
- When no integration project can be resolved (non-git workspace, no
pointer), treat as "no registry forks are possible" and allow — the
protocol still sets ``forks_integrated=true`` on no-fork paths.
- Import failures fail closed. Missing *workspace_dir* fails closed.
- Only active (pending/finalized) forks in the current scope are checked;
failed/superseded/merged leftovers cannot block later runs.
"""
if not isinstance(state, dict):
return False
if state.get("forks_integrated") is not True:
return False
if workspace_dir is None:
return False
try:
from qwenpaw.agents.fork_project import (
forks_merged_into_head,
get_active_fork_scope,
resolve_integration_project_dir,
)
except ImportError:
return False
project_dir = resolve_integration_project_dir(workspace_dir)
if project_dir is None:
# No git project / pointer → cannot register registry forks; the
# explicit boolean flag is enough for the no-fork protocol path.
return True
scope_id = get_active_fork_scope(workspace_dir) or None
return forks_merged_into_head(project_dir, scope_id=scope_id)
FORKS_INTEGRATED_REMINDER = """\
BLOCKED: forked worker results are not integrated yet.
{protocol}
Keep the current workflow phase unchanged (resume_phase / target phase
must stay as written — do NOT rewrite it to execution/exec/working).
After merges succeed, update state.json with the JSON boolean
forks_integrated=true (not a string). Then the gate resumes the
original target phase — do not re-dispatch executor workers.
"""
def merge_blocked_continuation(protocol: str) -> str:
"""Controller prompt when the gate rejects a premature phase advance."""
return FORKS_INTEGRATED_REMINDER.format(protocol=protocol)
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""OMP loop UI copy: names, menu summaries, and full help texts.
Menu short copy is bilingual (en / zh-CN). Full ``help`` is English for now;
zh-CN can be added later without changing call sites.
"""
from __future__ import annotations
from typing import Any
LOOP_UI_I18N: dict[str, dict[str, Any]] = {
"ultrawork": {
"name": {
"en": "Ultrawork",
"zh-CN": "Ultrawork",
},
"description": {
"en": "**Ultrawork** — parallel task execution engine",
"zh-CN": "**Ultrawork** — 并行任务执行引擎",
},
"help": {
"en": (
"**Ultrawork** — parallel task execution engine\n\n"
"Usage: `/ultrawork <task description>`\n\n"
"Decomposes the task into independent sub-tasks and executes\n"
"them in parallel via spawn_subagent batch mode."
),
},
},
"ralph": {
"name": {
"en": "Ralph",
"zh-CN": "Ralph",
},
"description": {
"en": ("**Ralph** — PRD-driven continuous implementation loop"),
"zh-CN": "**Ralph** — PRD 驱动的持续实现循环",
},
"help": {
"en": (
"**Ralph** — PRD-driven continuous implementation loop\n\n"
"Usage: `/ralph [--no-deslop] "
"[--critic=architect|critic|codex] <task>`\n\n"
"Creates a PRD with user stories, implements each one,\n"
"verifies acceptance criteria, and runs reviewer verification."
),
},
},
"autopilot": {
"name": {
"en": "Autopilot",
"zh-CN": "Autopilot",
},
"description": {
"en": "**Autopilot** — full lifecycle pipeline",
"zh-CN": "**Autopilot** — 全生命周期流水线",
},
"help": {
"en": (
"**Autopilot** — full lifecycle pipeline\n\n"
"Usage: `/autopilot [--skip-qa] "
"[--skip-validation] <task>`\n\n"
"Phases: expansion -> planning -> execution "
"-> qa -> validation -> cleanup -> completed\n"
"Validation uses 3 parallel reviewers "
"(architect + security + code)."
),
},
},
"ultraqa": {
"name": {
"en": "UltraQA",
"zh-CN": "UltraQA",
},
"description": {
"en": "**UltraQA** — automated QA cycle engine",
"zh-CN": "**UltraQA** — 自动化 QA 循环引擎",
},
"help": {
"en": (
"**UltraQA** — automated QA cycle engine\n\n"
"Usage:\n"
" `/ultraqa [--tests|--build|--lint|"
'--typecheck|--custom "cmd"]'
" [--interactive]`\n\n"
"Runs repeated QA cycles: check → diagnose → fix → re-check.\n"
"Stops when all checks pass or max cycles reached."
),
},
},
"team": {
"name": {
"en": "Team",
"zh-CN": "Team",
},
"description": {
"en": "**Team** — multi-agent collaboration pipeline",
"zh-CN": "**Team** — 多智能体协作流水线",
},
"help": {
"en": (
"**Team** — multi-agent collaboration pipeline\n\n"
"Usage: `/team [N:role] <task>`\n\n"
"Examples:\n"
" `/team 3:executor Implement authentication`\n"
" `/team ralph Build the REST API`\n\n"
"Phases: plan -> prd -> exec -> verify -> fix (retry)"
),
},
},
}
def loop_ui(mode_key: str) -> dict[str, Any]:
"""Return the UI copy bundle for one OMP mode."""
try:
return LOOP_UI_I18N[mode_key]
except KeyError as exc:
raise KeyError(f"Unknown OMP loop UI key: {mode_key}") from exc
def _pick_locale(mapping: dict[str, str], lang: str = "en") -> str:
if not mapping:
return ""
if lang in mapping and mapping[lang]:
return mapping[lang]
short = lang.split("-", 1)[0].lower()
for key, value in mapping.items():
if key.split("-", 1)[0].lower() == short and value:
return value
for key in ("en", "en-US", "zh-CN", "zh"):
if mapping.get(key):
return mapping[key]
for value in mapping.values():
if value:
return value
return ""
def loop_help_text(mode_key: str, lang: str = "en") -> str:
"""Full help text for CommandSpec / slash help handlers."""
help_map = loop_ui(mode_key).get("help") or {}
if not isinstance(help_map, dict):
return ""
return _pick_locale(help_map, lang)
def loop_command_metadata(mode_key: str) -> dict[str, Any]:
"""Metadata for CommandSpec: loop_name + i18n maps for GET /loops."""
ui = loop_ui(mode_key)
name_map = ui.get("name") or {}
desc_map = ui.get("description") or {}
return {
"builtin": True,
"loop_name": _pick_locale(name_map, "en") or mode_key,
"name_i18n": dict(name_map) if isinstance(name_map, dict) else {},
"description_i18n": dict(desc_map)
if isinstance(desc_map, dict)
else {},
}
@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""Shared AgentMode scaffolding for OMP workflow modes."""
from __future__ import annotations
import logging
import weakref
from typing import Any, ClassVar
from agentscope.message import Msg, TextBlock
from qwenpaw.modes.base import AgentMode
from qwenpaw.runtime.hooks import HookContext
logger = logging.getLogger(__name__)
class OMPModeBase(AgentMode):
"""Common setup / lifecycle for OMP workflow modes.
Subclasses must set ``name``, ``gate_cls``, ``plugin_id``,
``handler_name``, and ``scope``.
"""
gate_cls: type
plugin_id: str
handler_name: str
scope: str
_instances: ClassVar[list[weakref.ReferenceType]] = []
def __init__(self) -> None:
self._gate: Any = None
self._workspace_ref: weakref.ReferenceType[Any] | None = None
OMPModeBase._instances.append(weakref.ref(self))
def setup(self, workspace: object) -> None:
super().setup(workspace)
self._workspace_ref = weakref.ref(workspace)
from qwenpaw.loop.gates import StopHandler, StopHandlerRegistration
handler = StopHandler()
gate = self.gate_cls()
handler.register(gate)
self._gate = gate
plugins = getattr(workspace, "plugins", None)
if plugins is not None:
# Defensive: matches MissionMode / older WorkspacePlugins
if not hasattr(plugins, "stop_handlers"):
plugins.stop_handlers = []
plugins.stop_handlers.append(
StopHandlerRegistration(
plugin_id=self.plugin_id,
handler=handler,
priority=0,
name=self.handler_name,
scope=self.scope,
),
)
def is_active(
self,
ctx: Any, # pylint: disable=unused-argument
) -> bool:
# Follows upstream MissionMode pattern (LoopGate lacks public API)
# pylint: disable=protected-access
return self._gate is not None and self._gate._state() is not None
async def on_conversation_reset(
self,
ctx: HookContext, # pylint: disable=unused-argument
) -> None:
"""Clear gate state on /new and /clear."""
if self._gate is not None:
self._gate.reset_session()
def claim_workflow(self) -> None:
"""Deactivate peer OMP workflows so only this scope is active.
``_filter_by_scope`` keeps the first active non-default scope;
without mutual exclusion a later ``/ralph`` would be ignored
while an earlier ``/ultraqa`` gate still holds session state.
Only peers bound to the **same workspace** are deactivated —
modes from other workspaces must keep their own gates.
"""
my_ws = self._workspace_ref() if self._workspace_ref else None
alive: list[weakref.ReferenceType] = []
for ref in OMPModeBase._instances:
mode = ref()
if mode is None:
continue
alive.append(ref)
# pylint: disable=protected-access
peer_gate = mode._gate
if mode is self or peer_gate is None:
continue
peer_ws = mode._workspace_ref() if mode._workspace_ref else None
if my_ws is not None and peer_ws is not my_ws:
continue
if peer_gate._state() is not None:
logger.info(
"Deactivating peer OMP mode '%s' for '%s'",
mode.name,
self.name,
)
peer_gate.reset_session()
OMPModeBase._instances = alive
def info_msg(text: str) -> Msg:
"""Build a system info message for slash-command help/errors."""
return Msg(
name="system",
content=[TextBlock(type="text", text=text)],
role="system",
)
def rewrite_user_msg(ctx: Any, text: str) -> None:
"""Replace the last user message content with *text*."""
msgs = getattr(ctx, "input_msgs", None)
if not msgs:
return
last = msgs[-1]
if isinstance(last, Msg):
last.content = [TextBlock(type="text", text=text)]
__all__ = ["OMPModeBase", "info_msg", "rewrite_user_msg"]
@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
"""Reusable role identity prompts and spawn_subagent formatting."""
from __future__ import annotations
from .constants import ROLE_ALLOWED_TOOLS, ROLE_SKILLS
try:
from qwenpaw.agents.fork_project import FORK_WORKER_COMMIT_PROTOCOL
except ImportError: # pragma: no cover - offline / partial installs
FORK_WORKER_COMMIT_PROTOCOL = (
"## Fork worktree commit protocol (REQUIRED)\n"
"Commit all changes in the worktree before finishing.\n"
)
ROLE_PROMPTS: dict[str, str] = {
"analyst": (
"You are a requirements analyst. "
"Extract concrete requirements, identify hidden constraints, "
"define measurable acceptance criteria.\n"
"DO NOT: write code, create files, run commands.\n"
"OUTPUT: structured requirements in JSON format."
),
"architect": (
"You are a system architect. "
"Diagnose failures with root cause analysis, "
"design system interfaces and module boundaries.\n"
"OUTPUT: diagnosis with specific fix recommendations."
),
"executor": (
"You are a code executor. "
"Implement the assigned task following the design spec. "
"Run quality checks. Follow existing patterns.\n"
"OUTPUT: working code + progress report."
),
"qa-tester": (
"You are an interactive QA tester. "
"Test CLI/service interactions by starting the service, "
"running test cases, and verifying expected behavior.\n"
"OUTPUT: test results with PASS/FAIL per scenario."
),
"security-reviewer": (
"You are a security reviewer. "
"Identify vulnerabilities, injection vectors, "
"auth/authz weaknesses, and data exposure risks.\n"
"DO NOT modify any project files.\n"
"OUTPUT: security findings with severity ratings."
),
"code-reviewer": (
"You are a code reviewer. "
"Review code quality, correctness, maintainability, "
"and adherence to project conventions.\n"
"DO NOT modify any project files.\n"
"OUTPUT: review findings with actionable feedback."
),
"critic": (
"You are a plan/design critic. "
"Challenge assumptions, find gaps, identify risks.\n"
"DO NOT create plans or write code.\n"
"OUTPUT: critique with specific concerns and alternatives."
),
"planner": (
"You are a strategic planner. "
"Create implementation plans from specifications. "
"Define task breakdown and dependency order.\n"
"DO NOT write implementation code.\n"
"OUTPUT: ordered task list with dependencies."
),
"explore": (
"You are a code explorer. "
"Search and read the codebase to map structure, "
"dependencies, and relevant code locations.\n"
"DO NOT modify any files.\n"
"OUTPUT: structured findings with file paths and relationships."
),
"debugger": (
"You are a debugging expert. "
"Perform root cause analysis on build/test failures. "
"Apply targeted fixes.\n"
"DO NOT add new features.\n"
"OUTPUT: root cause + applied fix summary."
),
"verifier": (
"You are an adversarial verifier. "
"Your job is to BREAK the implementation. "
"Try every edge case, invalid input, and race condition.\n"
"DO NOT modify any project files.\n"
"OUTPUT: VERDICT: PASS/FAIL/PARTIAL with evidence."
),
}
# UX / flag aliases → ROLE_PROMPTS keys.
_ROLE_ALIASES = {
"codex": "critic", # Ralph --critic=codex
"ralph": "executor", # Team `/team ralph ...`
}
def resolve_role(role: str) -> str:
"""Map *role* (and aliases) onto a known ROLE_PROMPTS key."""
key = _ROLE_ALIASES.get(role, role)
if key in ROLE_PROMPTS:
return key
return "executor"
def get_role_prompt(role: str) -> str:
"""Return the prompt fragment for *role*, falling back to executor."""
return ROLE_PROMPTS[resolve_role(role)]
def build_worker_prompt(
role: str,
task: str,
context: str = "",
*,
fork: bool = False,
) -> str:
"""Build a complete worker prompt for spawn_subagent's *task* param."""
parts = [get_role_prompt(role)]
if fork:
parts.append("\n" + FORK_WORKER_COMMIT_PROTOCOL)
parts.append(f"\n## Task\n{task}")
if context:
parts.append(f"\n## Context\n{context}")
return "\n".join(parts)
def tools_literal(role: str) -> str | None:
"""Return a Python-list literal for *role*'s tools, or None to omit."""
tools = ROLE_ALLOWED_TOOLS.get(resolve_role(role))
if tools is None:
return None
inner = ", ".join(f'"{t}"' for t in tools)
return f"[{inner}]"
def skills_literal(role: str) -> str | None:
"""Return a Python-list literal for *role*'s skills, or None to omit."""
skills = ROLE_SKILLS.get(resolve_role(role))
if skills is None:
return None
inner = ", ".join(f'"{s}"' for s in skills)
return f"[{inner}]"
def format_spawn_call(
role: str,
task_body: str,
*,
fork: bool = False,
background: bool = True,
indent: str = " ",
) -> str:
"""Format a ``spawn_subagent(...)`` example for controller prompts.
The *task* argument embeds the role identity from
:func:`build_worker_prompt` so prompts stay aligned with
``ROLE_PROMPTS`` / ``omp-roles`` skill config.
"""
# Keep the example readable: show role identity + task placeholder.
task_preview = (
f"<role:{resolve_role(role)} identity from omp-roles>\\n\\n"
f"## Task\\n{task_body}"
)
pad = indent
lines = [
f"{pad}spawn_subagent(",
f'{pad} task="{task_preview}",',
]
tools = tools_literal(role)
if tools is not None:
lines.append(f"{pad} allowed_tools={tools},")
skills = skills_literal(role)
if skills is not None:
lines.append(f"{pad} skills={skills},")
if fork:
lines.append(f"{pad} fork=True,")
if background:
lines.append(f"{pad} background=True,")
lines.append(f"{pad})")
return "\n".join(lines)
def format_batch_item(
role: str,
task_body: str,
*,
fork: bool = False,
indent: str = " ",
) -> str:
"""Format one batch-mode dict entry for controller prompts."""
pad = indent
task_preview = (
f"<role:{resolve_role(role)} identity from omp-roles>\\n\\n"
f"## Task\\n{task_body}"
)
lines = [
f"{pad}{{",
f'{pad} "task": "{task_preview}",',
]
tools = tools_literal(role)
if tools is not None:
lines.append(f'{pad} "allowed_tools": {tools},')
skills = skills_literal(role)
if skills is not None:
lines.append(f'{pad} "skills": {skills},')
if fork:
lines.append(f'{pad} "fork": true,')
lines.append(f"{pad}}}")
return "\n".join(lines)
FORK_MERGE_PROTOCOL = """\
Fork integration (REQUIRED when any worker used fork=True):
- Each forked worker result includes [FORK_BRANCH: <branch>] and works
in an isolated git worktree. Workers must commit before finishing;
`git merge <branch>` only brings commits, not dirty files.
- After ALL workers finish, for each [FORK_BRANCH]:
1. `git merge --no-ff <branch>` (or cherry-pick the commits) into
the current branch of the main workspace.
2. On conflict: resolve or abort. Do NOT advance the workflow phase
and do NOT report completion while conflicts remain.
- Keep the target workflow phase as-is while merging (do not rewrite
it back to execution/exec/working).
- If no worker used fork=True, still set forks_integrated=true
(JSON boolean, not a string).
- After successful integration, update state.json with the JSON
boolean forks_integrated=true.
- The stop gate REJECTS phase completion until that flag is true AND
registered fork commits are ancestors of HEAD."""
def fork_merge_instructions(indent: str = "") -> str:
"""Return the fork merge protocol block, optionally indented."""
if not indent:
return FORK_MERGE_PROTOCOL
return "\n".join(
indent + line for line in FORK_MERGE_PROTOCOL.splitlines()
)
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
"""WorkflowState — state directory and file management for OMP modes."""
from __future__ import annotations
import json
import logging
import shutil
import time
import uuid
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class WorkflowState:
"""Manage per-instance state directory and files.
Path convention:
{workspace_dir}/.qwenpaw/omp_workflows/{mode_name}-{timestamp}/
Writers should prefer :meth:`update_state` so concurrent agent
updates to other keys are preserved (read-merge-write + atomic
replace).
"""
def __init__(self, workspace_dir: Path, mode_name: str) -> None:
self.workspace_dir = workspace_dir
self.mode_name = mode_name
self._instance_dir: Path | None = None
def create_instance(self) -> Path:
"""Create a timestamped instance directory."""
ts = time.strftime("%Y%m%d-%H%M%S")
suffix = uuid.uuid4().hex[:6]
base = self.workspace_dir / ".qwenpaw" / "omp_workflows"
self._instance_dir = base / f"{self.mode_name}-{ts}-{suffix}"
self._instance_dir.mkdir(parents=True, exist_ok=True)
self.append_log(f"[{self.mode_name}] instance created")
return self._instance_dir
@property
def instance_dir(self) -> Path | None:
return self._instance_dir
@classmethod
def from_existing(
cls,
workspace_dir: Path,
mode_name: str,
instance_dir: Path,
) -> WorkflowState:
"""Attach to an already-created instance directory."""
wf = cls(workspace_dir, mode_name)
wf._instance_dir = instance_dir
return wf
def read_state(self) -> dict[str, Any]:
"""Read state.json, returning empty dict if absent."""
if not self._instance_dir:
return {}
p = self._instance_dir / "state.json"
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
logger.warning("Failed to read %s", p, exc_info=True)
return {}
def write_state(self, data: dict[str, Any]) -> None:
"""Atomically replace state.json with *data*."""
if not self._instance_dir:
return
self._atomic_write_json(
self._instance_dir / "state.json",
data,
)
def update_state(self, patch: dict[str, Any]) -> dict[str, Any]:
"""Merge *patch* into state.json and write atomically.
Returns the merged document. Prefer this over
:meth:`write_state` when the gate only owns some keys.
"""
data = self.read_state()
data.update(patch)
self.write_state(data)
return data
def read_prd(self) -> dict[str, Any]:
"""Read prd.json, returning empty dict if absent."""
if not self._instance_dir:
return {}
p = self._instance_dir / "prd.json"
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
logger.warning("Failed to read %s", p, exc_info=True)
return {}
def append_log(self, entry: str) -> None:
"""Append a line to progress.txt (survives :meth:`cleanup`)."""
if not self._instance_dir:
return
p = self._instance_dir / "progress.txt"
with p.open("a", encoding="utf-8") as f:
f.write(entry + "\n")
def cleanup(self) -> None:
"""Remove temporary control files; keep audit artifacts.
Deletes ``state.json`` / ``prd.json`` (and ``*.tmp`` sidecars)
only. Specs, plans, handoffs, worker results, and
``progress.txt`` are retained for post-run inspection.
"""
if not self._instance_dir or not self._instance_dir.exists():
return
remove_names = {"state.json", "prd.json"}
for child in list(self._instance_dir.iterdir()):
should_remove = child.name in remove_names or (
child.is_file() and child.name.endswith(".tmp")
)
if not should_remove:
continue
try:
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
except OSError:
logger.warning(
"Failed to remove %s during cleanup",
child,
exc_info=True,
)
self.append_log(f"[{self.mode_name}] cleanup complete")
logger.info("Cleaned up control files in %s", self._instance_dir)
@staticmethod
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
"""Write JSON via temp file + replace."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
tmp.replace(path)