workflow-loop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. workflow_loop/__init__.py +6 -0
  2. workflow_loop/acceptance_records.py +338 -0
  3. workflow_loop/artifact_paths.py +278 -0
  4. workflow_loop/artifact_validation.py +1738 -0
  5. workflow_loop/bug_record.py +203 -0
  6. workflow_loop/cli.py +3257 -0
  7. workflow_loop/data/Standardized_Repository/acceptance/acceptance.md +119 -0
  8. workflow_loop/data/Standardized_Repository/acceptance/acceptance_plan.md +105 -0
  9. workflow_loop/data/Standardized_Repository/code_design/code_design.md +204 -0
  10. workflow_loop/data/Standardized_Repository/code_design/project_design_init.md +152 -0
  11. workflow_loop/data/Standardized_Repository/code_design/revise_code_design.md +32 -0
  12. workflow_loop/data/Standardized_Repository/code_design/update_code_design.md +94 -0
  13. workflow_loop/data/Standardized_Repository/global/document_writing.md +77 -0
  14. workflow_loop/data/Standardized_Repository/global/workflow_lifecycle.md +91 -0
  15. workflow_loop/data/Standardized_Repository/impl/code_implementation.md +85 -0
  16. workflow_loop/data/Standardized_Repository/impl/impl.md +164 -0
  17. workflow_loop/data/Standardized_Repository/qa/test.md +167 -0
  18. workflow_loop/data/Standardized_Repository/qa/test_code.md +121 -0
  19. workflow_loop/data/Standardized_Repository/qa/test_code_implementation.md +67 -0
  20. workflow_loop/data/Standardized_Repository/qa/test_plan.md +160 -0
  21. workflow_loop/data/Standardized_Repository/reproduce/reproduce.md +60 -0
  22. workflow_loop/data/Standardized_Repository/spec/spec.md +138 -0
  23. workflow_loop/data/Standardized_Repository/spike/spike.md +236 -0
  24. workflow_loop/data/Template_Repository/acceptance/acceptance_plan.md +142 -0
  25. workflow_loop/data/Template_Repository/acceptance/acceptance_result.md +108 -0
  26. workflow_loop/data/Template_Repository/code_design/code_design.md +260 -0
  27. workflow_loop/data/Template_Repository/code_design/project_design_init_evidence.md +39 -0
  28. workflow_loop/data/Template_Repository/impl/impl.md +112 -0
  29. workflow_loop/data/Template_Repository/qa/test.md +102 -0
  30. workflow_loop/data/Template_Repository/qa/test_plan.md +100 -0
  31. workflow_loop/data/Template_Repository/reproduce/reproduce.md +82 -0
  32. workflow_loop/data/Template_Repository/spec/spec.md +222 -0
  33. workflow_loop/data/Template_Repository/spike/spike.md +135 -0
  34. workflow_loop/installer.py +632 -0
  35. workflow_loop/journal.py +78 -0
  36. workflow_loop/path_composer.py +152 -0
  37. workflow_loop/process_runner.py +176 -0
  38. workflow_loop/project.py +397 -0
  39. workflow_loop/role_doc.py +133 -0
  40. workflow_loop/rollback.py +1738 -0
  41. workflow_loop/spike_validation.py +379 -0
  42. workflow_loop/stage_materials.py +169 -0
  43. workflow_loop/stages/__init__.py +45 -0
  44. workflow_loop/stages/base.py +164 -0
  45. workflow_loop/stages/stages.py +1191 -0
  46. workflow_loop/state.py +582 -0
  47. workflow_loop/test_entry.py +123 -0
  48. workflow_loop/test_execution.py +619 -0
  49. workflow_loop/test_mapping.py +568 -0
  50. workflow_loop/test_runner.py +134 -0
  51. workflow_loop/topic.py +114 -0
  52. workflow_loop/topic_relations.py +202 -0
  53. workflow_loop/traceability.py +533 -0
  54. workflow_loop/verification.py +971 -0
  55. workflow_loop-0.1.0.dist-info/METADATA +187 -0
  56. workflow_loop-0.1.0.dist-info/RECORD +60 -0
  57. workflow_loop-0.1.0.dist-info/WHEEL +5 -0
  58. workflow_loop-0.1.0.dist-info/entry_points.txt +2 -0
  59. workflow_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. workflow_loop-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,619 @@
1
+ """测试执行阶段的任务登记、依赖调度和安全子进程执行。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import sys
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from dataclasses import dataclass
10
+
11
+ from . import artifact_paths as artifact_paths_mod
12
+ from . import journal as journal_mod
13
+ from . import process_runner as process_runner_mod
14
+ from . import state as state_mod
15
+ from . import test_mapping
16
+ from . import traceability as traceability_mod
17
+ from . import verification
18
+ from .project import DEFAULT_TEST_PARALLELISM, load_project
19
+ from .state import TestExecutionRecord, TestTaskState, WorkflowState, now_iso
20
+ from .topic import topic_paths
21
+ from .topic_relations import read_topic_index
22
+
23
+
24
+ FORBIDDEN_SHELL_TOKENS = ("|", "&&", ";", ">", "<", "$(", "`")
25
+ INLINE_CODE_FLAGS = {"-c", "-e", "--eval", "--evaluate"}
26
+ DEFAULT_TIMEOUT_SECONDS = 600
27
+ SAFE_ENVIRONMENT_KEYS = {
28
+ "CI",
29
+ "GITHUB_ACTIONS",
30
+ "PYTHON_VERSION",
31
+ "NODE_ENV",
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ExecutionAttempt:
37
+ topic: str
38
+ test_id: str
39
+ status: str
40
+ command: list[str]
41
+ started_at: str
42
+ finished_at: str
43
+ duration_seconds: float
44
+ exit_code: int | None
45
+ output_tail: str
46
+ error: str | None = None
47
+
48
+
49
+ def normalize_task_cwd(project_root: str, cwd: str | None) -> str:
50
+ """校验并规范化项目内工作目录;返回相对项目根的路径(空串表示项目根)。"""
51
+ if not cwd or cwd in (".", "./"):
52
+ return ""
53
+ candidate = cwd
54
+ if not os.path.isabs(candidate):
55
+ candidate = os.path.join(project_root, candidate)
56
+ project_real = os.path.realpath(project_root)
57
+ candidate_real = os.path.realpath(candidate)
58
+ if os.path.commonpath([project_real, candidate_real]) != project_real:
59
+ raise ValueError(f"测试工作目录必须在项目内: {cwd}")
60
+ if not os.path.isdir(candidate_real):
61
+ raise ValueError(f"测试工作目录不存在: {cwd}")
62
+ relative = os.path.relpath(candidate_real, project_real)
63
+ return "" if relative == "." else relative.replace(os.sep, "/")
64
+
65
+
66
+ def validate_command(argv: list[str]) -> tuple[bool, str]:
67
+ """检查命令是否能以 shell=False 安全执行。"""
68
+ if not argv:
69
+ return False, "测试命令不能为空"
70
+ for token in argv:
71
+ if not isinstance(token, str) or not token.strip():
72
+ return False, "测试命令参数必须是非空字符串"
73
+ if any(operator in token for operator in FORBIDDEN_SHELL_TOKENS):
74
+ return False, "测试命令不能包含管道、重定向、命令串联或命令替换"
75
+ return True, ""
76
+
77
+
78
+ def _command_value_tokens(command: list[str]) -> set[str]:
79
+ values = set(command)
80
+ for token in command:
81
+ if "=" in token:
82
+ values.add(token.split("=", 1)[1])
83
+ return values
84
+
85
+
86
+ def _entry_is_selected(entry: str, command_values: set[str]) -> bool:
87
+ normalized_entry = entry.replace("\\", "/")
88
+ parts = normalized_entry.split("::")
89
+ path = parts[0]
90
+ symbol = parts[-1] if len(parts) > 1 else ""
91
+ candidates = {normalized_entry, path}
92
+ if symbol:
93
+ candidates.add(symbol)
94
+ for value in command_values:
95
+ normalized_value = value.replace("\\", "/").rstrip("/")
96
+ if normalized_value in candidates:
97
+ return True
98
+ if normalized_value and path.startswith(normalized_value + "/"):
99
+ return True
100
+ return False
101
+
102
+
103
+ def validate_command_entries(command: list[str], entries: list[str]) -> tuple[bool, str]:
104
+ """确认登记命令确实选择了当前 TC 的测试入口,而不是任意成功命令。"""
105
+ if any(flag in command for flag in INLINE_CODE_FLAGS):
106
+ return False, "测试命令不能使用 -c、-e 或 --eval 执行临时代码,必须运行真实测试入口"
107
+ command_values = _command_value_tokens(command)
108
+ missing = [entry for entry in entries if not _entry_is_selected(entry, command_values)]
109
+ if missing:
110
+ return False, f"测试命令没有明确选择当前测试入口: {missing}"
111
+ return True, ""
112
+
113
+
114
+ def safe_environment() -> dict[str, str]:
115
+ """只记录少量不含密钥的环境事实,避免把整个环境写进 state.json。"""
116
+ return {
117
+ "platform": sys.platform,
118
+ "python": sys.version.split()[0],
119
+ **{
120
+ key: os.environ[key]
121
+ for key in sorted(SAFE_ENVIRONMENT_KEYS)
122
+ if os.environ.get(key)
123
+ },
124
+ }
125
+
126
+
127
+ def _markers_by_test(project_root: str, topics: list[str]) -> dict[tuple[str, str], list[str]]:
128
+ markers = test_mapping.collect_workflow_test_markers(project_root, topics)
129
+ result: dict[tuple[str, str], list[str]] = {}
130
+ for marker in markers:
131
+ key = (marker.topic, marker.test_id)
132
+ result.setdefault(key, []).append(marker.test_entry)
133
+ return result
134
+
135
+
136
+ def prepare_task(
137
+ project_root: str,
138
+ workflow_state: WorkflowState,
139
+ topic: str,
140
+ test_id: str,
141
+ command: list[str],
142
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
143
+ cwd: str | None = None,
144
+ ) -> TestTaskState:
145
+ """登记一个已经由用户确认过的测试项命令;登记不启动测试。"""
146
+ valid, detail = validate_command(command)
147
+ if not valid:
148
+ raise ValueError(detail)
149
+ if timeout_seconds <= 0:
150
+ raise ValueError("测试超时时间必须大于 0 秒")
151
+ if topic not in workflow_state.topics:
152
+ raise ValueError(f"主题不属于当前工作流: {topic}")
153
+ normalized_cwd = normalize_task_cwd(project_root, cwd)
154
+
155
+ items = test_mapping.parse_test_plan_items(project_root, topic)
156
+ item = next((candidate for candidate in items if candidate.test_id == test_id), None)
157
+ if item is None:
158
+ raise ValueError(f"{topic} 的测试计划没有 {test_id}")
159
+ if not item.requires_test_code:
160
+ raise ValueError(f"{topic} / {test_id} 不是自动化或混合测试项,不需要登记自动化命令")
161
+
162
+ marker_ok, marker_detail = test_mapping.validate_workflow_test_markers(
163
+ project_root,
164
+ [topic],
165
+ )
166
+ if not marker_ok:
167
+ raise ValueError(marker_detail)
168
+ entries = _markers_by_test(project_root, [topic]).get((topic, test_id), [])
169
+ if not entries:
170
+ raise ValueError(f"{topic} / {test_id} 没有可追踪的测试入口")
171
+ entries_ok, entries_detail = validate_command_entries(command, entries)
172
+ if not entries_ok:
173
+ raise ValueError(f"{topic} / {test_id}: {entries_detail}")
174
+
175
+ stage_state = workflow_state.stages.get("test_execution")
176
+ if stage_state is None:
177
+ raise ValueError("当前工作流没有 test_execution 阶段")
178
+ stage_state.test_tasks.setdefault(topic, {})[test_id] = TestTaskState(
179
+ test_entries=sorted(set(entries)),
180
+ command=list(command),
181
+ cwd=normalized_cwd,
182
+ dependencies=list(item.dependencies),
183
+ timeout_seconds=timeout_seconds,
184
+ status="pending",
185
+ prepared_at=now_iso(),
186
+ last_error=None,
187
+ current_record=None,
188
+ )
189
+ return stage_state.test_tasks[topic][test_id]
190
+
191
+
192
+ def missing_prepared_tasks(
193
+ project_root: str,
194
+ workflow_state: WorkflowState,
195
+ ) -> list[str]:
196
+ """返回所有需要自动执行但尚未登记命令的主题/测试项。"""
197
+ missing: list[str] = []
198
+ for item in test_mapping.automated_test_items(project_root, workflow_state.topics):
199
+ task = workflow_state.stages.get("test_execution", state_mod.StageState()).test_tasks.get(
200
+ item.topic,
201
+ {},
202
+ ).get(item.test_id)
203
+ if task is None:
204
+ missing.append(f"{item.topic} / {item.test_id}")
205
+ return missing
206
+
207
+
208
+ def validate_prepared_tasks(
209
+ project_root: str,
210
+ workflow_state: WorkflowState,
211
+ ) -> tuple[bool, str]:
212
+ """核对登记任务与当前测试计划、依赖和 Workflow-Test 入口完全一致。"""
213
+ stage_state = workflow_state.stages.get("test_execution")
214
+ if stage_state is None:
215
+ return False, "当前工作流没有 test_execution 阶段"
216
+ try:
217
+ expected_items = test_mapping.automated_test_items(
218
+ project_root,
219
+ workflow_state.topics,
220
+ )
221
+ marker_ok, marker_detail = test_mapping.validate_workflow_test_markers(
222
+ project_root,
223
+ workflow_state.topics,
224
+ )
225
+ except ValueError as exc:
226
+ return False, str(exc)
227
+ if not marker_ok:
228
+ return False, marker_detail
229
+
230
+ expected = {(item.topic, item.test_id): item for item in expected_items}
231
+ actual = {
232
+ (topic, test_id): task
233
+ for topic, tasks in stage_state.test_tasks.items()
234
+ for test_id, task in tasks.items()
235
+ }
236
+ missing = sorted(set(expected) - set(actual))
237
+ extra = sorted(set(actual) - set(expected))
238
+ if missing:
239
+ return False, f"尚未登记测试命令: {[f'{topic} / {test_id}' for topic, test_id in missing]}"
240
+ if extra:
241
+ return False, f"登记了当前测试计划不存在的测试任务: {extra}"
242
+
243
+ current_entries = _markers_by_test(project_root, workflow_state.topics)
244
+ for key, item in expected.items():
245
+ task = actual[key]
246
+ command_ok, command_detail = validate_command(task.command)
247
+ if not command_ok:
248
+ return False, f"{item.topic} / {item.test_id}: {command_detail}"
249
+ if task.timeout_seconds <= 0:
250
+ return False, f"{item.topic} / {item.test_id} 的超时时间必须大于 0 秒"
251
+ try:
252
+ normalize_task_cwd(project_root, task.cwd or None)
253
+ except ValueError as exc:
254
+ return False, f"{item.topic} / {item.test_id}: {exc}"
255
+ if tuple(task.dependencies) != item.dependencies:
256
+ return False, f"{item.topic} / {item.test_id} 的登记依赖与当前测试计划不一致"
257
+ expected_entries = sorted(set(current_entries.get(key, [])))
258
+ if sorted(set(task.test_entries)) != expected_entries:
259
+ return False, f"{item.topic} / {item.test_id} 的登记测试入口与当前测试代码不一致"
260
+ entries_ok, entries_detail = validate_command_entries(task.command, expected_entries)
261
+ if not entries_ok:
262
+ return False, f"{item.topic} / {item.test_id}: {entries_detail}"
263
+ return True, f"{len(expected)} 个自动化测试项的登记任务与当前计划和测试代码一致"
264
+
265
+
266
+ def _record_id_for(attempt_result: process_runner_mod.ProcessResult, topic: str, test_id: str) -> str:
267
+ """测试机器记录编号:可从结果文档逐字段回查状态快照。"""
268
+ payload = (
269
+ f"{topic}|{test_id}|{attempt_result.started_at}|{attempt_result.finished_at}|"
270
+ f"{attempt_result.exit_code}|{attempt_result.output_sha256}"
271
+ )
272
+ digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8]
273
+ compact_time = (attempt_result.started_at or "").replace(":", "").replace("-", "")
274
+ return f"RUN-{compact_time}-{digest}"
275
+
276
+
277
+ def _run_one(
278
+ project_root: str,
279
+ topic: str,
280
+ test_id: str,
281
+ task: TestTaskState,
282
+ code_hash: str,
283
+ test_hash: str,
284
+ ) -> tuple[ExecutionAttempt, process_runner_mod.ProcessResult]:
285
+ """通过共同受控执行器运行一个测试项,返回尝试摘要和完整机器事实。"""
286
+ cwd = os.path.join(project_root, task.cwd) if task.cwd else project_root
287
+ result = process_runner_mod.run_process(
288
+ process_runner_mod.ProcessRequest(
289
+ argv=list(task.command),
290
+ cwd=cwd,
291
+ timeout_seconds=task.timeout_seconds,
292
+ )
293
+ )
294
+ if result.status == "passed":
295
+ error = None
296
+ elif result.status == "timeout":
297
+ error = result.error_message
298
+ elif result.status == "error":
299
+ error = result.error_message or "启动测试命令失败"
300
+ else:
301
+ error = f"退出码为 {result.exit_code}"
302
+ attempt = ExecutionAttempt(
303
+ topic=topic,
304
+ test_id=test_id,
305
+ status=result.status if result.status != "error" else "unavailable",
306
+ command=list(task.command),
307
+ started_at=result.started_at,
308
+ finished_at=result.finished_at,
309
+ duration_seconds=result.duration_seconds,
310
+ exit_code=result.exit_code,
311
+ output_tail=result.output_tail,
312
+ error=error,
313
+ )
314
+ return attempt, result
315
+
316
+
317
+ def _topic_execution_order(
318
+ project_root: str,
319
+ topic: str,
320
+ tasks: dict[str, TestTaskState],
321
+ ) -> list[str]:
322
+ items = {item.test_id: item for item in test_mapping.parse_test_plan_items(project_root, topic)}
323
+ ordered: list[str] = []
324
+ visited: set[str] = set()
325
+
326
+ def visit(test_id: str) -> None:
327
+ if test_id in visited:
328
+ return
329
+ for dependency in items[test_id].dependencies:
330
+ if dependency in tasks:
331
+ visit(dependency)
332
+ visited.add(test_id)
333
+ ordered.append(test_id)
334
+
335
+ for test_id in tasks:
336
+ visit(test_id)
337
+ return ordered
338
+
339
+
340
+ def _has_current_success(task: TestTaskState) -> bool:
341
+ """判断任务是否已有可继续使用的当前成功记录。"""
342
+ record = task.current_record
343
+ return (
344
+ task.status == "passed"
345
+ and record is not None
346
+ and record.status == "passed"
347
+ and record.exit_code == 0
348
+ and record.command == task.command
349
+ and set(record.test_entries) == set(task.test_entries)
350
+ and bool(record.code_snapshot_hash)
351
+ and bool(record.test_code_hash)
352
+ )
353
+
354
+
355
+ def _topic_prerequisites(
356
+ project_root: str,
357
+ workflow_state: WorkflowState,
358
+ topics: set[str],
359
+ ) -> tuple[list[str], dict[str, tuple[str, ...]]]:
360
+ """读取 qa/索引.md 的主题顺序,只保留本阶段有自动化任务的前置主题。"""
361
+ relations = read_topic_index(
362
+ project_root,
363
+ artifact_paths_mod.QA_INDEX_DOC,
364
+ workflow_state.workflow_id,
365
+ ["展示顺序", "验收主题", "前置主题", "验收计划", "测试计划", "测试结果"],
366
+ {"测试结果": {"无自动化测试项"}},
367
+ )
368
+ ordered_topics = [relation.topic for relation in relations if relation.topic in topics]
369
+ missing = sorted(topics - set(ordered_topics))
370
+ if missing:
371
+ raise ValueError(f"{artifact_paths_mod.QA_INDEX_DOC} 缺少需要执行自动化测试的主题: {missing}")
372
+ prerequisites = {
373
+ relation.topic: tuple(
374
+ prerequisite
375
+ for prerequisite in relation.prerequisites
376
+ if prerequisite in topics
377
+ )
378
+ for relation in relations
379
+ if relation.topic in topics
380
+ }
381
+ return ordered_topics, prerequisites
382
+
383
+
384
+ def run_prepared_tasks(
385
+ project_root: str,
386
+ workflow_state: WorkflowState,
387
+ parallelism: int | None = None,
388
+ ) -> list[ExecutionAttempt]:
389
+ """按主题并行、主题内按 TC 依赖顺序执行已登记任务。"""
390
+ stage_state = workflow_state.stages.get("test_execution")
391
+ if stage_state is None:
392
+ raise ValueError("当前工作流没有 test_execution 阶段")
393
+ tasks_ok, tasks_detail = validate_prepared_tasks(project_root, workflow_state)
394
+ if not tasks_ok:
395
+ raise ValueError(tasks_detail)
396
+
397
+ project = load_project(project_root)
398
+ max_workers = parallelism or (
399
+ project.test_parallelism if project is not None else DEFAULT_TEST_PARALLELISM
400
+ )
401
+ max_workers = max(1, int(max_workers))
402
+ code_hash = verification.compute_non_test_code_snapshot_hash(project_root)
403
+ test_hash = verification.compute_test_code_snapshot_hash(project_root)
404
+ topic_tasks = {
405
+ topic: tasks
406
+ for topic, tasks in stage_state.test_tasks.items()
407
+ if tasks
408
+ }
409
+ ordered_topics, topic_prerequisites = _topic_prerequisites(
410
+ project_root,
411
+ workflow_state,
412
+ set(topic_tasks),
413
+ )
414
+ topics_to_run = [
415
+ topic
416
+ for topic in ordered_topics
417
+ if any(not _has_current_success(task) for task in topic_tasks[topic].values())
418
+ ]
419
+
420
+ # 本主题要重新执行时,旧的正式结果已经不能代表本次代码状态。
421
+ if topics_to_run:
422
+ trace_detail = traceability_mod.reset_topic_test_results(
423
+ project_root,
424
+ workflow_state.workflow_id,
425
+ topics_to_run,
426
+ )
427
+ journal_mod.append_entry(
428
+ project_root,
429
+ "主题测试追踪状态重置",
430
+ "workflow.py",
431
+ workflow_id=workflow_state.workflow_id,
432
+ topics=topics_to_run,
433
+ detail=trace_detail,
434
+ )
435
+ for topic in topics_to_run:
436
+ result_path = os.path.join(project_root, topic_paths(project_root, topic)["test_result"])
437
+ if os.path.exists(result_path):
438
+ os.remove(result_path)
439
+ journal_mod.append_entry(
440
+ project_root,
441
+ "主题测试结果失效",
442
+ "workflow.py",
443
+ workflow_id=workflow_state.workflow_id,
444
+ topic=topic,
445
+ reason="主题测试重新执行,旧结果不再代表当前代码",
446
+ )
447
+
448
+ def run_topic(topic: str) -> list[ExecutionAttempt]:
449
+ attempts: list[ExecutionAttempt] = []
450
+ tasks = topic_tasks[topic]
451
+ for test_id in _topic_execution_order(project_root, topic, tasks):
452
+ task = tasks[test_id]
453
+ if _has_current_success(task):
454
+ continue
455
+ if any(
456
+ not _has_current_success(tasks[dependency])
457
+ for dependency in task.dependencies
458
+ if dependency in tasks
459
+ ):
460
+ task.status = "blocked"
461
+ task.current_record = None
462
+ task.last_error = "前置测试项没有通过,本测试项未执行"
463
+ attempts.append(
464
+ ExecutionAttempt(
465
+ topic=topic,
466
+ test_id=test_id,
467
+ status="blocked",
468
+ command=list(task.command),
469
+ started_at=now_iso(),
470
+ finished_at=now_iso(),
471
+ duration_seconds=0.0,
472
+ exit_code=None,
473
+ output_tail="",
474
+ error=task.last_error,
475
+ )
476
+ )
477
+ continue
478
+ attempt, result = _run_one(project_root, topic, test_id, task, code_hash, test_hash)
479
+ attempts.append(attempt)
480
+ if attempt.status == "passed":
481
+ task.status = "passed"
482
+ task.last_error = None
483
+ record = TestExecutionRecord(
484
+ test_entries=list(task.test_entries),
485
+ command=list(task.command),
486
+ cwd=task.cwd,
487
+ timeout_seconds=task.timeout_seconds,
488
+ started_at=attempt.started_at,
489
+ finished_at=attempt.finished_at,
490
+ duration_seconds=attempt.duration_seconds,
491
+ exit_code=attempt.exit_code,
492
+ status="passed",
493
+ environment=safe_environment(),
494
+ code_snapshot_hash=code_hash,
495
+ test_code_hash=test_hash,
496
+ output_tail=result.output_tail,
497
+ output_sha256=result.output_sha256,
498
+ output_bytes=result.output_bytes,
499
+ platform=result.platform,
500
+ executable=result.executable,
501
+ )
502
+ record.record_id = _record_id_for(result, topic, test_id)
503
+ task.current_record = record
504
+ else:
505
+ # 失败、超时或无法启动立即清除当前测试项旧成功
506
+ task.status = "needs_action"
507
+ task.last_error = attempt.error or "测试没有通过"
508
+ task.current_record = None
509
+ return attempts
510
+
511
+ all_attempts: list[ExecutionAttempt] = []
512
+ completed_topics = {
513
+ topic
514
+ for topic, tasks in topic_tasks.items()
515
+ if all(_has_current_success(task) for task in tasks.values())
516
+ }
517
+ failed_topics: set[str] = set()
518
+ remaining = set(topics_to_run)
519
+
520
+ while remaining:
521
+ blocked_topics = [
522
+ topic
523
+ for topic in ordered_topics
524
+ if topic in remaining
525
+ and any(
526
+ prerequisite in failed_topics
527
+ for prerequisite in topic_prerequisites.get(topic, ())
528
+ )
529
+ ]
530
+ for topic in blocked_topics:
531
+ for test_id in _topic_execution_order(project_root, topic, topic_tasks[topic]):
532
+ task = topic_tasks[topic][test_id]
533
+ if _has_current_success(task):
534
+ continue
535
+ task.status = "blocked"
536
+ task.current_record = None
537
+ task.last_error = "前置主题的自动化测试没有通过,本主题未执行"
538
+ all_attempts.append(
539
+ ExecutionAttempt(
540
+ topic=topic,
541
+ test_id=test_id,
542
+ status="blocked",
543
+ command=list(task.command),
544
+ started_at=now_iso(),
545
+ finished_at=now_iso(),
546
+ duration_seconds=0.0,
547
+ exit_code=None,
548
+ output_tail="",
549
+ error=task.last_error,
550
+ )
551
+ )
552
+ remaining.remove(topic)
553
+ failed_topics.add(topic)
554
+
555
+ ready_topics = [
556
+ topic
557
+ for topic in ordered_topics
558
+ if topic in remaining
559
+ and all(
560
+ prerequisite in completed_topics
561
+ for prerequisite in topic_prerequisites.get(topic, ())
562
+ )
563
+ ]
564
+ if not ready_topics:
565
+ # 失败会逐层阻塞后置主题;先重新计算,不把正常传播误报为非法依赖。
566
+ if blocked_topics:
567
+ continue
568
+ if remaining:
569
+ raise ValueError(
570
+ f"主题依赖无法继续执行,请检查 {artifact_paths_mod.QA_INDEX_DOC}: "
571
+ f"{sorted(remaining)}"
572
+ )
573
+ break
574
+
575
+ with ThreadPoolExecutor(max_workers=min(max_workers, len(ready_topics))) as executor:
576
+ futures = {executor.submit(run_topic, topic): topic for topic in ready_topics}
577
+ for future in as_completed(futures):
578
+ topic = futures[future]
579
+ attempts = future.result()
580
+ all_attempts.extend(attempts)
581
+ if all(_has_current_success(task) for task in topic_tasks[topic].values()):
582
+ completed_topics.add(topic)
583
+ else:
584
+ failed_topics.add(topic)
585
+ remaining.remove(topic)
586
+
587
+ # 并发执行只运行子进程;state 统一在这里保存,避免多个线程同时写 state.json。
588
+ for attempt in sorted(all_attempts, key=lambda item: (item.topic, item.test_id)):
589
+ journal_mod.append_entry(
590
+ project_root,
591
+ "测试项执行",
592
+ "workflow.py",
593
+ workflow_id=workflow_state.workflow_id,
594
+ topic=attempt.topic,
595
+ test_id=attempt.test_id,
596
+ status=attempt.status,
597
+ command=attempt.command,
598
+ started_at=attempt.started_at,
599
+ finished_at=attempt.finished_at,
600
+ duration_seconds=attempt.duration_seconds,
601
+ exit_code=attempt.exit_code,
602
+ error=attempt.error,
603
+ )
604
+ state_mod.save_state(project_root, workflow_state)
605
+ return sorted(all_attempts, key=lambda item: (item.topic, item.test_id))
606
+
607
+
608
+ def summarize_attempts(attempts: list[ExecutionAttempt]) -> str:
609
+ if not attempts:
610
+ return "本次没有执行测试命令:当前没有自动化测试项,或全部测试项已有当前成功记录"
611
+ passed = sum(attempt.status == "passed" for attempt in attempts)
612
+ failed = [attempt for attempt in attempts if attempt.status != "passed"]
613
+ detail = f"本次执行 {len(attempts)} 个测试项,{passed} 个通过"
614
+ if failed:
615
+ detail += ";未通过或未执行:" + ", ".join(
616
+ f"{attempt.topic}/{attempt.test_id}({attempt.status})"
617
+ for attempt in failed
618
+ )
619
+ return detail