Skip to content

Commit 76e0908

Browse files
fix(pipeline): 诊断卡死的候选子步骤,禁止静默换新候选掩盖失败
Session 7be8bcd32aa44fd399f3e5bf7ec7db7d 中候选 096430be 的 cost_estimating 子步骤长时间 conclusion=null / status=working,流水线 既未诊断也未收敛,而是直接开启新的 evaluate_candidates,由一个缩减模板 的新候选顶替,原失败被完全掩盖,交付方案被静默改写。 三处根因分别修复: 1. 子步骤卡死不可观测。SubPipelineSpec 新增 sub_step_stall_timeout_s / sub_step_stall_retries,SubPipelineExecutor 用看门狗按"相邻事件间隔" 判定卡死(而非总时长),卡死时以 SubStepStalled 记录显式失败原因、 卡死秒数与重试信息。等待用户回答与流水线暂停期间窗口作废,避免把 合法等待误判为卡死。 2. 卡死后不重试原子步骤。重试预算内优先原地重试同一子步骤,候选状态保持 running、已完成子步骤结论保留;预算耗尽才让候选进入终态。 3. 候选无终态、失败无原因。取消候选时记录 cancel_reason 并落终态条目 (status 仍为 failed 以兼容 restore/replay 的三态路由,terminal_reason 区分 canceled / superseded);原地重启不写终态。聚合阶段不再产出无原因 的 {failed: True},缺失结果时补 CandidateOutcomeMissing 原因。 selling 流水线 evaluate_candidate 启用 600s 阈值 + 1 次原地重试。 Aone: 84817164
1 parent 88ed89c commit 76e0908

8 files changed

Lines changed: 791 additions & 15 deletions

File tree

‎src/iac_code/pipeline/engine/loader.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,28 @@ def _parse_sub_pipelines(
245245
max_rollbacks=sub_raw.get("max_rollbacks", 5),
246246
iterate_over=sub_raw.get("iterate_over", ""),
247247
context_fields_from_parent=sub_raw.get("context_fields_from_parent", []),
248+
sub_step_stall_timeout_s=_parse_sub_step_stall_timeout(sub_name, sub_raw.get("sub_step_stall_timeout_s")),
249+
sub_step_stall_retries=_parse_sub_step_stall_retries(sub_name, sub_raw.get("sub_step_stall_retries", 1)),
248250
)
249251
return result
250252

251253

254+
def _parse_sub_step_stall_timeout(sub_name: str, raw: object) -> float | None:
255+
if raw is None:
256+
return None
257+
if not isinstance(raw, (int, float)) or isinstance(raw, bool) or raw <= 0:
258+
raise ValueError(
259+
f"sub_pipelines.{sub_name}.sub_step_stall_timeout_s must be a positive number or omitted, got {raw!r}"
260+
)
261+
return float(raw)
262+
263+
264+
def _parse_sub_step_stall_retries(sub_name: str, raw: object) -> int:
265+
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 0:
266+
raise ValueError(f"sub_pipelines.{sub_name}.sub_step_stall_retries must be a non-negative integer, got {raw!r}")
267+
return raw
268+
269+
252270
def _parse_steps(raw_steps: list[dict]) -> list[StepSpec]:
253271
steps: list[StepSpec] = []
254272
for raw in raw_steps:

‎src/iac_code/pipeline/engine/pipeline_runner.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,11 @@ def _cancel_candidate_task(
31973197

31983198
parent_step_id = parent_step_id or self.state_machine.current_step.step_id
31993199
candidate_name = state.get("name", "")
3200+
# A candidate that is about to restart in place keeps running; every other
3201+
# cancellation reason must leave a terminal, diagnosable state behind so a
3202+
# replacement candidate cannot mask why this one stopped.
3203+
if reason != "candidate_restart":
3204+
state["cancel_reason"] = reason
32003205
if not state.get("_candidate_cancelled_observed", False):
32013206
state["_candidate_cancelled_observed"] = True
32023207
self._observability.candidate_cancelled(
@@ -4605,6 +4610,50 @@ async def save_candidate_failed(i: int, state: dict[str, Any]) -> None:
46054610
failed_by_index[i] = dict(entry)
46064611
await self._save_running(step.step_id, reason="parallel candidate failed")
46074612

4613+
def save_candidate_cancelled_sync(i: int, state: dict[str, Any], reason: str) -> None:
4614+
"""Persist a terminal entry for a cancelled candidate, preserving the reason.
4615+
4616+
Runs synchronously because the owning task is already cancelled. The
4617+
status stays ``failed`` so restore/replay keep routing on the three
4618+
known states; ``terminal_reason`` carries why it stopped.
4619+
"""
4620+
active_attempt_id = state.get("active_attempt_id")
4621+
if active_attempt_id:
4622+
self._mark_attempt_status(active_attempt_id, "failed")
4623+
terminal_reason = "superseded" if reason == "hard_interrupt_parent_rollback" else "canceled"
4624+
failure = public_error(
4625+
message=_("Candidate {index} was {reason} before completing sub-step {sub_step}.").format(
4626+
index=i + 1,
4627+
reason=terminal_reason,
4628+
sub_step=state.get("current_sub_step") or "?",
4629+
),
4630+
error_type="CandidateCancelled",
4631+
extra_details={
4632+
"cancel_reason": reason,
4633+
"terminal_reason": terminal_reason,
4634+
"sub_step_id": state.get("current_sub_step") or None,
4635+
},
4636+
)
4637+
entry = {
4638+
"status": "failed",
4639+
"terminal_reason": terminal_reason,
4640+
"candidate": candidates[i],
4641+
"sub_pipeline_id": state.get("sub_pipeline_id") or f"{sub_spec.name}_candidate_{i}",
4642+
"state_machine": state.get("state_machine"),
4643+
"context": state.get("context"),
4644+
"current_sub_step": state.get("current_sub_step", ""),
4645+
"current_index": state.get("current_index"),
4646+
"active_attempt_id": active_attempt_id,
4647+
"transcript_id": state.get("transcript_id"),
4648+
"conclusions": state.get("conclusions", {}),
4649+
"step_conclusions": state.get("step_conclusions", {}),
4650+
"error": state.get("error") or failure.summary,
4651+
"error_details": state.get("error_details") or failure.details,
4652+
}
4653+
self._execution.setdefault("candidates", {})[str(i)] = entry
4654+
failed_by_index[i] = dict(entry)
4655+
self._save_running_sync(step.step_id, reason="parallel candidate cancelled")
4656+
46084657
async def put_candidate_event(candidate_index: int, event: Any) -> None:
46094658
nonlocal event_sequence
46104659
event_sequence += 1
@@ -4781,6 +4830,12 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None:
47814830
await put_candidate_event(i, event)
47824831
except asyncio.CancelledError:
47834832
logger.debug("Candidate %d cancelled", i)
4833+
cancel_reason = state.get("cancel_reason")
4834+
if isinstance(cancel_reason, str) and i not in conclusions_by_index and i not in failed_by_index:
4835+
try:
4836+
save_candidate_cancelled_sync(i, state, cancel_reason)
4837+
except PipelineStatePersistenceError:
4838+
logger.warning("Failed to persist cancelled candidate %d terminal state", i)
47844839
except PipelineStatePersistenceError as exc:
47854840
await put_candidate_event(i, exc)
47864841
except SessionBackupBlocked as exc:
@@ -4964,9 +5019,27 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None:
49645019
result["error"] = restored["error"]
49655020
if restored.get("error_details") is not None:
49665021
result["error_details"] = restored["error_details"]
5022+
if restored.get("terminal_reason") is not None:
5023+
result["terminal_reason"] = restored["terminal_reason"]
49675024
aggregated.append(result)
49685025
else:
4969-
aggregated.append({"candidate": candidate, "failed": True})
5026+
# Never aggregate a reason-less failure: a replacement candidate must
5027+
# not be able to silently take over from an undiagnosed one.
5028+
failure = public_error(
5029+
message=_("Candidate {index} ended without a result or a recorded failure reason.").format(
5030+
index=i + 1
5031+
),
5032+
error_type="CandidateOutcomeMissing",
5033+
extra_details={"parent_step_id": step.step_id, "candidate_index": i},
5034+
)
5035+
aggregated.append(
5036+
{
5037+
"candidate": candidate,
5038+
"failed": True,
5039+
"error": failure.summary,
5040+
"error_details": failure.details,
5041+
}
5042+
)
49705043

49715044
self.context.set_conclusion(step.conclusion_field, aggregated)
49725045
candidate_success_count = sum(1 for item in aggregated if isinstance(item, dict) and not item.get("failed"))

‎src/iac_code/pipeline/engine/step_spec.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ class SubPipelineSpec:
117117
max_rollbacks: int
118118
iterate_over: str
119119
context_fields_from_parent: list[str] = field(default_factory=list)
120+
# Seconds a sub-step may go without emitting any event before it is treated
121+
# as stalled. ``None`` disables stall detection.
122+
sub_step_stall_timeout_s: float | None = None
123+
# How many times a stalled sub-step is retried in place before the candidate
124+
# is failed. Retrying the same sub-step is preferred over replacing the
125+
# candidate so the original failure stays visible.
126+
sub_step_stall_retries: int = 1
120127

121128

122129
@dataclass

0 commit comments

Comments
 (0)