|
|
@@ -13,9 +13,13 @@ codex 评审(§15)的修复已内建:
|
|
|
P2-E 运行时 ArtifactSpec 契约校验(gate 前解析、required 缺失即 fail、记 hash 进 verdict)
|
|
|
P3-F PipelineRunner 自己拥有 stage 目录(create_run_workspace(run_dir=...))
|
|
|
|
|
|
-未实现(留 P2/P3,已用 NotImplementedError / TODO 标出):
|
|
|
- - LLM-judge gate(须建成 term 图里的真 agent,§15 P2-D)
|
|
|
- - human gate 的完整 resume 语义(§6.3)
|
|
|
+P3 已完成:
|
|
|
+ - human gate YIELD + resume 语义(§6.3):_run_stage 在 gate 前拦截
|
|
|
+ kind=human → 写 pending verdict、stage 标 awaiting_human、干净返回;
|
|
|
+ 审批走 resolve_human_gate(approved/rejected),approved 续跑、
|
|
|
+ rejected 按 on_fail 处置(retry 重做再审 / skip / halt)。
|
|
|
+
|
|
|
+未实现(留后续):
|
|
|
- 崩溃-孤儿 attempt 的确定性恢复(§15 P1-B 的完整版)
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
@@ -884,6 +888,24 @@ class PipelineRunner:
|
|
|
self._write_verdict(ws, st, last_verdict)
|
|
|
continue # 契约不符 → 不判 gate,直接重试
|
|
|
|
|
|
+ # human gate(§6.3 YIELD 语义):产物已合格(契约校验通过),
|
|
|
+ # 在此处停下等人 —— 写 pending verdict、stage 标 awaiting_human
|
|
|
+ # 并干净返回;审批走 resume_pipeline(approved/rejected)。
|
|
|
+ if st.gate.kind == "human":
|
|
|
+ pending = Verdict(
|
|
|
+ stage_id=st.id, passed=False, kind="human", attempt=attempt,
|
|
|
+ reasons=[f"PENDING_HUMAN: {st.gate.prompt or '等待人工审批'}"],
|
|
|
+ artifacts=[asdict(r) for r in resolved.values()],
|
|
|
+ timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
|
+ self._write_verdict(ws, st, pending)
|
|
|
+ self._set_stage_state(
|
|
|
+ st.id, status=StageStatus.AWAITING_HUMAN.value,
|
|
|
+ workspace_path=ws, attempt_no=attempt,
|
|
|
+ gate_prompt=st.gate.prompt or "",
|
|
|
+ verdict_path=os.path.join(ws, "verdict.json"))
|
|
|
+ _write_state_atomic(self.ws, self.state)
|
|
|
+ return StageResult(st.id, StageStatus.AWAITING_HUMAN, attempt, ws, pending)
|
|
|
+
|
|
|
# gate
|
|
|
passed, reasons, score = self._eval_gate(st, resolved)
|
|
|
verdict = Verdict(stage_id=st.id, passed=passed, kind=st.gate.kind,
|
|
|
@@ -940,8 +962,9 @@ class PipelineRunner:
|
|
|
reasons.append(f"judge_usage={jr.usage}")
|
|
|
return (jr.score >= threshold), reasons, jr.score
|
|
|
if st.gate.kind == "human":
|
|
|
- # TODO P3:human gate 走 YIELD + resume(approved/rejected) 语义(§6.3)。
|
|
|
- raise NotImplementedError("human gate not implemented in P1 skeleton (see §6.3)")
|
|
|
+ # human gate 不在这里评估 —— _run_stage 在 gate 之前拦截并
|
|
|
+ # YIELD(awaiting_human);审批经 resume_pipeline 写 verdict。
|
|
|
+ raise GateError("human gate must be handled by _run_stage YIELD path")
|
|
|
raise GateError(f"unknown gate kind: {st.gate.kind}")
|
|
|
|
|
|
def _apply_on_fail(self, st: Stage, stage_ws: str, attempts: int, err: str,
|
|
|
@@ -1003,6 +1026,89 @@ def run_pipeline(pipeline_cfg: Any, input_text: str, agent_dir: str, *,
|
|
|
return runner.run()
|
|
|
|
|
|
|
|
|
+def resolve_human_gate(pipeline_cfg: Any, pipeline_ws: str, decision: str, *,
|
|
|
+ note: str = "", base_dir: str = "",
|
|
|
+ stage_executor: Optional[StageExecutor] = None,
|
|
|
+ judge: Optional[JudgeFn] = None) -> PipelineResult:
|
|
|
+ """对 awaiting_human 的阶段做出审批决定并从断点续跑(§6.3,P3 完成版)。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ pipeline_cfg: 与 run_pipeline 相同的流水线配置(yml 路径或 dict)。
|
|
|
+ pipeline_ws: run_pipeline 返回的 workspace_path(含 pipeline_state.json)。
|
|
|
+ decision: "approved" | "rejected"。
|
|
|
+ note: 审批意见,写进 human verdict 的 reasons。
|
|
|
+
|
|
|
+ 语义(设计 §6.3):
|
|
|
+ - approved → 该阶段 PASSED(写 human verdict),从下一阶段续跑;
|
|
|
+ 已 PASSED/SKIPPED 的阶段不重算(产物从各自 workspace 读回)。
|
|
|
+ - rejected → 写拒绝 verdict,按该阶段 on_fail 处置:
|
|
|
+ retry → 清该阶段状态重跑(重做后会再次停在 human gate 等审);
|
|
|
+ skip → 标 SKIPPED 后继续后续阶段;
|
|
|
+ halt / escalate → 流水线 HALTED(escalate 的"升级给人"已经发生过)。
|
|
|
+
|
|
|
+ 续跑部分复用 resume_pipeline(崩溃续跑同一条路径)。
|
|
|
+ """
|
|
|
+ if decision not in ("approved", "rejected"):
|
|
|
+ raise PipelineError(f"decision must be approved|rejected, got {decision!r}")
|
|
|
+
|
|
|
+ p = load_pipeline(pipeline_cfg)
|
|
|
+ errs = validate_pipeline(p)
|
|
|
+ if errs:
|
|
|
+ raise PipelineError("invalid pipeline: " + "; ".join(errs))
|
|
|
+
|
|
|
+ state = _read_state(pipeline_ws)
|
|
|
+ if not state:
|
|
|
+ raise PipelineError(f"no pipeline_state.json under {pipeline_ws}")
|
|
|
+
|
|
|
+ awaiting = [(sid, s) for sid, s in (state.get("stages") or {}).items()
|
|
|
+ if s.get("status") == StageStatus.AWAITING_HUMAN.value]
|
|
|
+ if not awaiting:
|
|
|
+ raise PipelineError("no stage is awaiting human approval")
|
|
|
+ stage_id, sstate = awaiting[0]
|
|
|
+ stage = next((s for s in p.stages if s.id == stage_id), None)
|
|
|
+ if stage is None:
|
|
|
+ raise PipelineError(f"awaiting stage {stage_id!r} not in pipeline config")
|
|
|
+
|
|
|
+ ts = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
+ stage_ws = sstate.get("workspace_path") or os.path.join(pipeline_ws, stage_id)
|
|
|
+ resolved, _ = resolve_artifacts(stage, stage_ws)
|
|
|
+
|
|
|
+ # 写 human verdict(覆盖 pending 占位)
|
|
|
+ verdict = Verdict(
|
|
|
+ stage_id=stage_id, passed=(decision == "approved"), kind="human",
|
|
|
+ attempt=int(sstate.get("attempt_no", 1)),
|
|
|
+ reasons=[f"human {decision}" + (f": {note}" if note else "")],
|
|
|
+ artifacts=[asdict(r) for r in resolved.values()], timestamp=ts)
|
|
|
+ try:
|
|
|
+ with open(os.path.join(stage_ws, "verdict.json"), "w", encoding="utf-8") as f:
|
|
|
+ json.dump(asdict(verdict), f, ensure_ascii=False, indent=2)
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
+
|
|
|
+ if decision == "approved":
|
|
|
+ sstate.update(status=StageStatus.PASSED.value, completed_at=ts,
|
|
|
+ verdict_path=os.path.join(stage_ws, "verdict.json"))
|
|
|
+ else:
|
|
|
+ if stage.on_fail in (OnFail.HALT, OnFail.ESCALATE):
|
|
|
+ sstate.update(status=StageStatus.FAILED.value,
|
|
|
+ error=f"human rejected: {note}", completed_at=ts)
|
|
|
+ state["status"] = PipelineStatus.HALTED.value
|
|
|
+ _write_state_atomic(pipeline_ws, state)
|
|
|
+ return PipelineResult(p.id, PipelineStatus.HALTED, pipeline_ws, [
|
|
|
+ StageResult(stage_id, StageStatus.FAILED,
|
|
|
+ int(sstate.get("attempt_no", 1)), stage_ws, verdict,
|
|
|
+ error=f"human rejected: {note}")])
|
|
|
+ if stage.on_fail == OnFail.SKIP:
|
|
|
+ sstate.update(status=StageStatus.SKIPPED.value, completed_at=ts,
|
|
|
+ error=f"human rejected: {note}")
|
|
|
+ else: # RETRY:清状态 → 重入 runner 重跑该阶段(重做后再次等审)
|
|
|
+ state["stages"].pop(stage_id, None)
|
|
|
+ _write_state_atomic(pipeline_ws, state)
|
|
|
+
|
|
|
+ return resume_pipeline(pipeline_ws, pipeline_cfg, base_dir=base_dir,
|
|
|
+ stage_executor=stage_executor, judge=judge)
|
|
|
+
|
|
|
+
|
|
|
def resume_pipeline(workspace_path: str, pipeline_cfg: Any, input_text: str = "", *,
|
|
|
base_dir: str = "", stage_executor: Optional[StageExecutor] = None,
|
|
|
judge: Optional[JudgeFn] = None) -> PipelineResult:
|