flower-trellis 0.3.1 → 0.4.0-beta.0

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.
@@ -0,0 +1,782 @@
1
+ #!/usr/bin/env python3
2
+ """Trellis auto loop 的可恢复流程控制器。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ SCHEMA_VERSION = 1
17
+ DEFAULT_PROFILE = "commit-only"
18
+ MAX_FIX_RECHECK = 3
19
+ VALID_IMPLEMENT_ROUTES = {"inline", "subagent"}
20
+ VALID_CHECK_ROUTES = {"check-all-inline", "check-all-subagent"}
21
+ STEP_ACTIONS = {
22
+ "refresh_brief": "refresh_brief",
23
+ "start_task": "start_task",
24
+ "implement": "run_implement",
25
+ "check": "run_check_all",
26
+ "fix": "run_fix",
27
+ "recheck": "run_recheck",
28
+ "spec_update": "run_spec_update",
29
+ "commit_only": "commit_only",
30
+ }
31
+
32
+
33
+ def _utc_now() -> str:
34
+ """返回秒级 UTC 时间字符串。"""
35
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
36
+
37
+
38
+ def _repo_root() -> Path | None:
39
+ """从当前目录向上寻找 Trellis 项目根。"""
40
+ current = Path.cwd().resolve()
41
+ while True:
42
+ if (current / ".trellis").is_dir():
43
+ return current
44
+ if current == current.parent:
45
+ return None
46
+ current = current.parent
47
+
48
+
49
+ def _print(data: dict[str, Any]) -> int:
50
+ """输出给 agent 消费的紧凑 JSON。"""
51
+ print(json.dumps(data, ensure_ascii=False, sort_keys=True))
52
+ return 0
53
+
54
+
55
+ def _read_json(path: Path) -> dict[str, Any]:
56
+ """读取 JSON 对象,失败返回空对象。"""
57
+ try:
58
+ data = json.loads(path.read_text(encoding="utf-8"))
59
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
60
+ return {}
61
+ return data if isinstance(data, dict) else {}
62
+
63
+
64
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
65
+ """写入格式化 JSON。"""
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
68
+
69
+
70
+ def _rel_path(repo_root: Path, path: Path) -> str:
71
+ """尽量返回相对项目根的 POSIX 路径。"""
72
+ try:
73
+ return path.relative_to(repo_root).as_posix()
74
+ except ValueError:
75
+ return str(path)
76
+
77
+
78
+ def _auto_dir(repo_root: Path) -> Path:
79
+ """返回 auto-loop runtime 目录。"""
80
+ return repo_root / ".trellis/.runtime/auto-loop"
81
+
82
+
83
+ def _current_pointer(repo_root: Path) -> Path:
84
+ """返回当前 auto run 指针文件。"""
85
+ return _auto_dir(repo_root) / "current.json"
86
+
87
+
88
+ def _read_route_prefs(repo_root: Path) -> dict[str, str]:
89
+ """读取个人 route 默认配置,用于 start gate 判断是否需要 JSONL context。"""
90
+ path = repo_root / ".trellis/.route-prefs.tmp"
91
+ try:
92
+ lines = path.read_text(encoding="utf-8").splitlines()
93
+ except OSError:
94
+ return {}
95
+
96
+ prefs: dict[str, str] = {}
97
+ for raw in lines:
98
+ if "=" not in raw:
99
+ continue
100
+ key, value = raw.split("=", 1)
101
+ key = key.strip()
102
+ value = value.strip()
103
+ if key == "implement" and value in VALID_IMPLEMENT_ROUTES:
104
+ prefs[key] = value
105
+ elif key == "check" and value in VALID_CHECK_ROUTES:
106
+ prefs[key] = value
107
+ return prefs
108
+
109
+
110
+ def _effective_route_authorization(repo_root: Path, route_authorization: Any) -> dict[str, str]:
111
+ """按 route 优先级估算 start gate 需要的 context 类型。"""
112
+ effective: dict[str, str] = {}
113
+ if isinstance(route_authorization, dict):
114
+ implement = route_authorization.get("implement")
115
+ check = route_authorization.get("check")
116
+ if implement in VALID_IMPLEMENT_ROUTES:
117
+ effective["implement"] = str(implement)
118
+ if check in VALID_CHECK_ROUTES:
119
+ effective["check"] = str(check)
120
+
121
+ # 个人默认优先于 auto 临时授权;start gate 的 JSONL 判断也要遵守同一优先级,
122
+ # 否则可能在个人 subagent 默认下误放行,或在个人 inline 默认下误阻塞。
123
+ effective.update(_read_route_prefs(repo_root))
124
+ return effective
125
+
126
+
127
+ def _run_path(repo_root: Path, run_id: str) -> Path:
128
+ """返回指定 auto run 状态文件。"""
129
+ return _auto_dir(repo_root) / f"{run_id}.json"
130
+
131
+
132
+ def _new_run_id() -> str:
133
+ """生成短小稳定的 run id。"""
134
+ return "auto-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
135
+
136
+
137
+ def _normalize_task_ref(repo_root: Path, task_ref: str) -> str:
138
+ """把任务引用规范化为 `.trellis/tasks/<dir>`。"""
139
+ raw = task_ref.strip()
140
+ path_obj = Path(raw)
141
+ candidates: list[Path]
142
+ if path_obj.is_absolute():
143
+ candidates = [path_obj]
144
+ elif raw.startswith(".trellis/"):
145
+ candidates = [repo_root / raw]
146
+ elif raw.startswith("tasks/"):
147
+ candidates = [repo_root / ".trellis" / raw]
148
+ else:
149
+ candidates = [repo_root / ".trellis/tasks" / raw, repo_root / raw]
150
+
151
+ for candidate in candidates:
152
+ if candidate.is_dir():
153
+ return _rel_path(repo_root, candidate)
154
+ raise ValueError(f"任务不存在:{task_ref}")
155
+
156
+
157
+ def _task_dir(repo_root: Path, task_ref: str) -> Path:
158
+ """把规范化任务引用解析为绝对路径。"""
159
+ if task_ref.startswith(".trellis/"):
160
+ return repo_root / task_ref
161
+ return repo_root / ".trellis/tasks" / task_ref
162
+
163
+
164
+ def _load_task_json(repo_root: Path, task_ref: str) -> dict[str, Any]:
165
+ """读取任务 task.json。"""
166
+ return _read_json(_task_dir(repo_root, task_ref) / "task.json")
167
+
168
+
169
+ def _task_status(repo_root: Path, task_ref: str) -> str:
170
+ """读取任务状态。"""
171
+ return str(_load_task_json(repo_root, task_ref).get("status") or "unknown")
172
+
173
+
174
+ def _has_real_jsonl_entries(path: Path, repo_root: Path) -> bool:
175
+ """判断 JSONL 上下文清单是否至少有一条真实且存在的 file entry。"""
176
+ try:
177
+ lines = path.read_text(encoding="utf-8").splitlines()
178
+ except OSError:
179
+ return False
180
+
181
+ for line in lines:
182
+ if not line.strip():
183
+ continue
184
+ try:
185
+ data = json.loads(line)
186
+ except json.JSONDecodeError:
187
+ continue
188
+ file_path = data.get("file")
189
+ if not isinstance(file_path, str) or not file_path.strip():
190
+ continue
191
+ if (repo_root / file_path).exists():
192
+ return True
193
+ return False
194
+
195
+
196
+ def _open_questions(text: str) -> list[str]:
197
+ """提取 PRD 中仍存在的 open question 条目。"""
198
+ lines = text.splitlines()
199
+ collecting = False
200
+ questions: list[str] = []
201
+ for line in lines:
202
+ if line.startswith("## "):
203
+ collecting = line.strip().lower() == "## open questions"
204
+ continue
205
+ if collecting and line.strip().startswith("-"):
206
+ item = line.strip().lstrip("-").strip()
207
+ if item and item.upper() not in {"TBD", "N/A"}:
208
+ questions.append(item)
209
+ return questions
210
+
211
+
212
+ def _start_gate(
213
+ repo_root: Path,
214
+ task_ref: str,
215
+ route_authorization: dict[str, Any] | None = None,
216
+ ) -> tuple[str, dict[str, Any]]:
217
+ """检查 planning -> start 前置条件。"""
218
+ task_dir = _task_dir(repo_root, task_ref)
219
+ prd = task_dir / "prd.md"
220
+ if not prd.is_file():
221
+ return "blocked", {"reason": "missing-prd", "message": "缺少 prd.md"}
222
+
223
+ questions = _open_questions(prd.read_text(encoding="utf-8"))
224
+ if questions:
225
+ return "blocked", {
226
+ "reason": "open-questions",
227
+ "message": "PRD 仍有阻塞性 Open Questions",
228
+ "questions": questions,
229
+ }
230
+
231
+ design = task_dir / "design.md"
232
+ implement = task_dir / "implement.md"
233
+ if design.exists() != implement.exists():
234
+ missing = "implement.md" if design.exists() else "design.md"
235
+ return "blocked", {"reason": "incomplete-complex-artifacts", "message": f"复杂任务缺少 {missing}"}
236
+
237
+ auth = route_authorization if isinstance(route_authorization, dict) else {}
238
+ needs_implement_context = auth.get("implement") != "inline"
239
+ needs_check_context = auth.get("check") != "check-all-inline"
240
+ if needs_implement_context and not _has_real_jsonl_entries(task_dir / "implement.jsonl", repo_root):
241
+ return "blocked", {
242
+ "reason": "missing-implement-context",
243
+ "message": "implement.jsonl 未 curated(当前 route 可能需要 sub-agent context)",
244
+ }
245
+ if needs_check_context and not _has_real_jsonl_entries(task_dir / "check.jsonl", repo_root):
246
+ return "blocked", {
247
+ "reason": "missing-check-context",
248
+ "message": "check.jsonl 未 curated(当前 route 可能需要 sub-agent context)",
249
+ }
250
+
251
+ if not (task_dir / "brief.md").is_file():
252
+ return "action", {
253
+ "action": "refresh_brief",
254
+ "message": "缺少 brief.md,需先用 trellis-task-brief 生成任务摘要",
255
+ }
256
+
257
+ return "ok", {"message": "start gate satisfied"}
258
+
259
+
260
+ def _current_session_key(repo_root: Path) -> str | None:
261
+ """尽力解析当前 session key,用于把 auto run 绑定给 route helper。"""
262
+ override = os.environ.get("TRELLIS_CONTEXT_ID")
263
+ if override:
264
+ return override.strip() or None
265
+
266
+ result = subprocess.run(
267
+ ["python3", str(repo_root / ".trellis/scripts/task.py"), "current", "--source"],
268
+ cwd=repo_root,
269
+ check=False,
270
+ text=True,
271
+ capture_output=True,
272
+ )
273
+ for line in result.stdout.splitlines():
274
+ if line.startswith("Source: "):
275
+ source = line.split(": ", 1)[1].strip()
276
+ if source.startswith(("session:", "session-fallback:")):
277
+ return source.split(":", 1)[1].strip()
278
+ return None
279
+
280
+
281
+ def _link_session_run(repo_root: Path, run_id: str) -> None:
282
+ """把当前 auto run 写入 session runtime,方便 route_state.py 精确恢复。"""
283
+ context_key = _current_session_key(repo_root)
284
+ if not context_key:
285
+ return
286
+ path = repo_root / ".trellis/.runtime/sessions" / f"{context_key}.json"
287
+ context = _read_json(path)
288
+ context.setdefault("platform", context_key.split("_", 1)[0] if "_" in context_key else "session")
289
+ context["last_seen_at"] = _utc_now()
290
+ context["current_auto_run"] = run_id
291
+ _write_json(path, context)
292
+
293
+
294
+ def _load_current_state(repo_root: Path, run_id: str | None = None) -> tuple[Path, dict[str, Any]]:
295
+ """加载指定或当前 auto run 状态。"""
296
+ if run_id:
297
+ path = _run_path(repo_root, run_id)
298
+ state = _read_json(path)
299
+ if not state:
300
+ raise ValueError(f"auto run 不存在:{run_id}")
301
+ return path, state
302
+
303
+ pointer = _read_json(_current_pointer(repo_root))
304
+ current = pointer.get("run_id")
305
+ if isinstance(current, str) and current:
306
+ path = _run_path(repo_root, current)
307
+ state = _read_json(path)
308
+ if state:
309
+ return path, state
310
+
311
+ running: list[tuple[Path, dict[str, Any]]] = []
312
+ for path in sorted(_auto_dir(repo_root).glob("auto-*.json")):
313
+ state = _read_json(path)
314
+ if state.get("status") == "running":
315
+ running.append((path, state))
316
+ if len(running) == 1:
317
+ return running[0]
318
+ raise ValueError("没有可恢复的唯一 auto run")
319
+
320
+
321
+ def _write_state(path: Path, state: dict[str, Any]) -> None:
322
+ """刷新 auto run 状态和更新时间。"""
323
+ state["updated_at"] = _utc_now()
324
+ state["resume_capsule"] = _resume_capsule(state)
325
+ _write_json(path, state)
326
+
327
+
328
+ def _write_pointer(repo_root: Path, run_id: str) -> None:
329
+ """写入当前 auto run 指针。"""
330
+ _write_json(_current_pointer(repo_root), {"run_id": run_id, "updated_at": _utc_now()})
331
+
332
+
333
+ def _resume_capsule(state: dict[str, Any]) -> dict[str, Any]:
334
+ """生成短小的人类可读恢复摘要。"""
335
+ queue = state.get("queue") if isinstance(state.get("queue"), list) else []
336
+ current = None
337
+ for item in queue:
338
+ if isinstance(item, dict) and item.get("status") in {"pending", "running"}:
339
+ current = item
340
+ break
341
+ return {
342
+ "run_id": state.get("run_id"),
343
+ "status": state.get("status"),
344
+ "current_task": current.get("task") if current else None,
345
+ "next_step": current.get("current_step") if current else "done",
346
+ "completed": sum(1 for item in queue if isinstance(item, dict) and item.get("status") == "completed"),
347
+ "blocked": sum(1 for item in queue if isinstance(item, dict) and item.get("status") == "blocked"),
348
+ "remaining": sum(1 for item in queue if isinstance(item, dict) and item.get("status") in {"pending", "running"}),
349
+ }
350
+
351
+
352
+ def _summary(state: dict[str, Any]) -> dict[str, Any]:
353
+ """返回队列状态摘要。"""
354
+ queue = state.get("queue") if isinstance(state.get("queue"), list) else []
355
+ outstanding_action = None
356
+ for item in queue:
357
+ if not isinstance(item, dict) or item.get("status") != "running":
358
+ continue
359
+ last_action = item.get("last_action")
360
+ if isinstance(last_action, dict):
361
+ outstanding_action = {
362
+ "task": item.get("task"),
363
+ "action": last_action.get("action"),
364
+ "current_step": last_action.get("current_step"),
365
+ "issued_at": last_action.get("issued_at"),
366
+ }
367
+ break
368
+ return {
369
+ "run_id": state.get("run_id"),
370
+ "run_status": state.get("status"),
371
+ "profile": state.get("profile"),
372
+ "current_index": state.get("current_index"),
373
+ "outstanding_action": outstanding_action,
374
+ "completed": [i.get("task") for i in queue if isinstance(i, dict) and i.get("status") == "completed"],
375
+ "blocked": [
376
+ {"task": i.get("task"), "blocked": i.get("blocked")}
377
+ for i in queue
378
+ if isinstance(i, dict) and i.get("status") == "blocked"
379
+ ],
380
+ "pending": [i.get("task") for i in queue if isinstance(i, dict) and i.get("status") in {"pending", "running"}],
381
+ "resume_capsule": state.get("resume_capsule"),
382
+ }
383
+
384
+
385
+ def _make_item(repo_root: Path, task_ref: str) -> dict[str, Any]:
386
+ """构造队列任务项。"""
387
+ normalized = _normalize_task_ref(repo_root, task_ref)
388
+ status = _task_status(repo_root, normalized)
389
+ step = "start_task" if status == "planning" else "implement"
390
+ return {
391
+ "task": normalized,
392
+ "status": "pending",
393
+ "task_status": status,
394
+ "current_step": step,
395
+ "attempts": {"fix_recheck": 0},
396
+ "last_failure": None,
397
+ "last_action": None,
398
+ "commit": None,
399
+ "blocked": None,
400
+ "updated_at": _utc_now(),
401
+ }
402
+
403
+
404
+ def _action(action: str, item: dict[str, Any], extra: dict[str, Any] | None = None) -> dict[str, Any]:
405
+ """构造下一步动作对象。"""
406
+ task = item.get("task")
407
+ base: dict[str, Any] = {
408
+ "status": "action",
409
+ "action": action,
410
+ "task": task,
411
+ "current_step": item.get("current_step"),
412
+ }
413
+ if action == "start_task":
414
+ task_name = Path(str(task)).name
415
+ base["command"] = f"python3 ./.trellis/scripts/task.py start {task_name}"
416
+ elif action == "refresh_brief":
417
+ base["instruction"] = "运行 trellis-task-brief 生成 brief.md,然后 record --result ok。"
418
+ elif action == "run_implement":
419
+ base["instruction"] = "进入 Phase 2.1 implement route,并执行实现。"
420
+ elif action == "run_check_all":
421
+ base["instruction"] = "进入 Phase 2.2 check route,默认执行 check-all。"
422
+ elif action == "run_fix":
423
+ base["instruction"] = "根据最近失败摘要修复问题。"
424
+ elif action == "run_recheck":
425
+ base["instruction"] = "修复后重新执行 check-all。"
426
+ elif action == "run_spec_update":
427
+ base["instruction"] = "若有代码/测试证据支撑,执行 trellis-update-spec;否则直接 record ok。"
428
+ elif action == "commit_only":
429
+ base["instruction"] = "按 trellis-auto-loop / trellis-push commit-only 预授权语义提交当前任务可归属文件,不 push。"
430
+ if extra:
431
+ base.update(extra)
432
+ return base
433
+
434
+
435
+ def _remember_action(item: dict[str, Any], action_data: dict[str, Any]) -> dict[str, Any]:
436
+ """记录 runner 已发出的待回写 action。"""
437
+ item["last_action"] = {
438
+ "action": action_data.get("action"),
439
+ "current_step": action_data.get("current_step"),
440
+ "issued_at": _utc_now(),
441
+ }
442
+ item["updated_at"] = _utc_now()
443
+ return action_data
444
+
445
+
446
+ def _outstanding_action_name(item: dict[str, Any]) -> str | None:
447
+ """返回当前任务等待 record 回写的 action 名。"""
448
+ last_action = item.get("last_action")
449
+ if isinstance(last_action, dict) and isinstance(last_action.get("action"), str):
450
+ return last_action["action"]
451
+ return None
452
+
453
+
454
+ def _block_item(item: dict[str, Any], reason: str, summary: str, detail: dict[str, Any] | None = None) -> None:
455
+ """把当前任务标记为 blocked。"""
456
+ item["status"] = "blocked"
457
+ item["blocked"] = {
458
+ "reason": reason,
459
+ "summary": summary,
460
+ "detail": detail or {},
461
+ "blocked_at": _utc_now(),
462
+ }
463
+ item["updated_at"] = _utc_now()
464
+
465
+
466
+ def _next_item(repo_root: Path, state: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any]]:
467
+ """计算并更新队列中的下一步动作。"""
468
+ queue = state.get("queue") if isinstance(state.get("queue"), list) else []
469
+ for index, item in enumerate(queue):
470
+ if not isinstance(item, dict) or item.get("status") not in {"pending", "running"}:
471
+ continue
472
+
473
+ state["current_index"] = index
474
+ item["status"] = "running"
475
+ task = str(item.get("task"))
476
+ item["task_status"] = _task_status(repo_root, task)
477
+
478
+ if item["task_status"] == "planning":
479
+ gate_status, gate = _start_gate(repo_root, task, _effective_route_authorization(repo_root, state.get("route_authorization")))
480
+ if gate_status == "blocked":
481
+ _block_item(item, gate["reason"], gate["message"], gate)
482
+ continue
483
+ if gate_status == "action":
484
+ item["current_step"] = "refresh_brief"
485
+ return item, _remember_action(item, _action("refresh_brief", item, gate))
486
+ item["current_step"] = "start_task"
487
+ return item, _remember_action(item, _action("start_task", item))
488
+
489
+ step = item.get("current_step") or "implement"
490
+ if step in {"start_task", "implement"}:
491
+ item["current_step"] = "implement"
492
+ return item, _remember_action(item, _action("run_implement", item))
493
+ if step == "check":
494
+ return item, _remember_action(item, _action("run_check_all", item))
495
+ if step == "fix":
496
+ attempts = item.setdefault("attempts", {}).get("fix_recheck", 0)
497
+ if attempts >= MAX_FIX_RECHECK:
498
+ _block_item(item, "retry-budget-exhausted", "fix/recheck 已达到默认 3 轮预算")
499
+ continue
500
+ return item, _remember_action(
501
+ item,
502
+ _action("run_fix", item, {"attempt": attempts, "max_attempts": MAX_FIX_RECHECK}),
503
+ )
504
+ if step == "recheck":
505
+ return item, _remember_action(item, _action("run_recheck", item))
506
+ if step == "spec_update":
507
+ return item, _remember_action(item, _action("run_spec_update", item))
508
+ if step == "commit_only":
509
+ return item, _remember_action(item, _action("commit_only", item))
510
+
511
+ _block_item(item, "unknown-step", f"未知 current_step:{step}")
512
+
513
+ state["status"] = "completed"
514
+ return None, {"status": "done", "summary": _summary(state)}
515
+
516
+
517
+ def cmd_start(args: argparse.Namespace) -> int:
518
+ """创建 auto run。"""
519
+ repo_root = _repo_root()
520
+ if repo_root is None:
521
+ return _print({"status": "error", "reason": "not-trellis-project"})
522
+
523
+ try:
524
+ current_path, current = _load_current_state(repo_root)
525
+ if current.get("status") == "running" and not args.force:
526
+ return _print({
527
+ "status": "error",
528
+ "reason": "auto-run-already-running",
529
+ "run_id": current.get("run_id"),
530
+ "path": _rel_path(repo_root, current_path),
531
+ })
532
+ except ValueError:
533
+ pass
534
+
535
+ route_authorization: dict[str, str] = {}
536
+ if args.route_implement:
537
+ route_authorization["implement"] = args.route_implement
538
+ if args.route_check:
539
+ route_authorization["check"] = args.route_check
540
+
541
+ run_id = args.run_id or _new_run_id()
542
+ queue = [_make_item(repo_root, task) for task in args.tasks]
543
+ now = _utc_now()
544
+ state = {
545
+ "schema_version": SCHEMA_VERSION,
546
+ "run_id": run_id,
547
+ "status": "running",
548
+ "profile": args.profile,
549
+ "created_at": now,
550
+ "updated_at": now,
551
+ "owner": {"host": socket.gethostname(), "pid": os.getpid()},
552
+ "current_index": 0,
553
+ "route_authorization": route_authorization,
554
+ "queue": queue,
555
+ "resume_capsule": {},
556
+ }
557
+ path = _run_path(repo_root, run_id)
558
+ _write_state(path, state)
559
+ _write_pointer(repo_root, run_id)
560
+ _link_session_run(repo_root, run_id)
561
+ return _print({"status": "started", "path": _rel_path(repo_root, path), **_summary(state)})
562
+
563
+
564
+ def cmd_resume(args: argparse.Namespace) -> int:
565
+ """恢复 auto run 摘要。"""
566
+ repo_root = _repo_root()
567
+ if repo_root is None:
568
+ return _print({"status": "error", "reason": "not-trellis-project"})
569
+ try:
570
+ path, state = _load_current_state(repo_root, args.run_id)
571
+ except ValueError as exc:
572
+ return _print({"status": "error", "reason": "resume-failed", "message": str(exc)})
573
+ _link_session_run(repo_root, str(state.get("run_id")))
574
+ return _print({"status": "resumed", "path": _rel_path(repo_root, path), **_summary(state)})
575
+
576
+
577
+ def cmd_next(args: argparse.Namespace) -> int:
578
+ """计算下一步动作。"""
579
+ repo_root = _repo_root()
580
+ if repo_root is None:
581
+ return _print({"status": "error", "reason": "not-trellis-project"})
582
+ try:
583
+ path, state = _load_current_state(repo_root, args.run_id)
584
+ except ValueError as exc:
585
+ return _print({"status": "error", "reason": "next-failed", "message": str(exc)})
586
+ _link_session_run(repo_root, str(state.get("run_id")))
587
+ if state.get("status") != "running":
588
+ output_status = "done" if state.get("status") == "completed" else state.get("status") or "unknown"
589
+ return _print({"run_id": state.get("run_id"), "status": output_status, "summary": _summary(state)})
590
+ _, action = _next_item(repo_root, state)
591
+ _write_state(path, state)
592
+ if action.get("status") == "done":
593
+ action["summary"] = _summary(state)
594
+ return _print({"run_id": state.get("run_id"), **action})
595
+
596
+
597
+ def _find_record_item(state: dict[str, Any], task_ref: str | None) -> dict[str, Any] | None:
598
+ """找到 record 要更新的队列项。"""
599
+ queue = state.get("queue") if isinstance(state.get("queue"), list) else []
600
+ if task_ref:
601
+ for item in queue:
602
+ if isinstance(item, dict) and item.get("task") == task_ref:
603
+ return item
604
+ for item in queue:
605
+ if isinstance(item, dict) and item.get("status") == "running":
606
+ return item
607
+ return None
608
+
609
+
610
+ def _advance_after_ok(item: dict[str, Any], action: str, args: argparse.Namespace) -> None:
611
+ """根据成功动作推进 current_step。"""
612
+ if action == "refresh_brief":
613
+ item["current_step"] = "start_task"
614
+ elif action == "start_task":
615
+ item["current_step"] = "implement"
616
+ elif action in {"run_implement", "run_fix"}:
617
+ item["current_step"] = "check" if action == "run_implement" else "recheck"
618
+ elif action in {"run_check_all", "run_recheck"}:
619
+ item["current_step"] = "spec_update"
620
+ elif action == "run_spec_update":
621
+ item["current_step"] = "commit_only"
622
+ elif action == "commit_only":
623
+ item["status"] = "completed"
624
+ item["current_step"] = "done"
625
+ item["commit"] = args.commit
626
+ item["updated_at"] = _utc_now()
627
+
628
+
629
+ def _record_failure(item: dict[str, Any], action: str, args: argparse.Namespace) -> None:
630
+ """记录失败并决定是否进入 fix 或 blocked。"""
631
+ item["last_failure"] = {
632
+ "action": action,
633
+ "failure_type": args.failure_type,
634
+ "summary": args.summary,
635
+ "files": args.files or [],
636
+ "failed_at": _utc_now(),
637
+ }
638
+ if action in {"run_implement", "run_check_all", "run_fix", "run_recheck"}:
639
+ attempts = item.setdefault("attempts", {})
640
+ attempts["fix_recheck"] = int(attempts.get("fix_recheck", 0)) + 1
641
+ if attempts["fix_recheck"] > MAX_FIX_RECHECK:
642
+ _block_item(item, "retry-budget-exhausted", args.summary or "fix/recheck 达到默认 3 轮预算")
643
+ else:
644
+ item["current_step"] = "fix"
645
+ item["updated_at"] = _utc_now()
646
+ return
647
+ _block_item(item, args.failure_type or "action-failed", args.summary or f"{action} 执行失败")
648
+
649
+
650
+ def cmd_record(args: argparse.Namespace) -> int:
651
+ """记录 agent 执行结果。"""
652
+ repo_root = _repo_root()
653
+ if repo_root is None:
654
+ return _print({"status": "error", "reason": "not-trellis-project"})
655
+ try:
656
+ path, state = _load_current_state(repo_root, args.run_id)
657
+ except ValueError as exc:
658
+ return _print({"status": "error", "reason": "record-failed", "message": str(exc)})
659
+ if state.get("status") != "running":
660
+ return _print({
661
+ "status": "error",
662
+ "reason": "auto-run-not-running",
663
+ "run_status": state.get("status"),
664
+ "summary": _summary(state),
665
+ })
666
+
667
+ task = _normalize_task_ref(repo_root, args.task) if args.task else None
668
+ item = _find_record_item(state, task)
669
+ if item is None:
670
+ return _print({"status": "error", "reason": "no-running-task"})
671
+
672
+ if not args.action:
673
+ return _print({"status": "error", "reason": "missing-action", "message": "record 必须显式传入 --action"})
674
+ expected_action = _outstanding_action_name(item)
675
+ if expected_action is None:
676
+ return _print({
677
+ "status": "error",
678
+ "reason": "no-outstanding-action",
679
+ "message": "没有等待回写的 action;请先运行 next 获取下一步。",
680
+ "task": item.get("task"),
681
+ "current_step": item.get("current_step"),
682
+ })
683
+ if args.action != expected_action:
684
+ return _print({
685
+ "status": "error",
686
+ "reason": "action-mismatch",
687
+ "expected_action": expected_action,
688
+ "actual_action": args.action,
689
+ "task": item.get("task"),
690
+ "current_step": item.get("current_step"),
691
+ })
692
+
693
+ action = args.action
694
+ item["last_action"] = None
695
+ if args.result == "ok":
696
+ _advance_after_ok(item, action, args)
697
+ elif args.result == "failed":
698
+ _record_failure(item, action, args)
699
+ else:
700
+ _block_item(item, args.failure_type or "blocked", args.summary or "agent 标记 blocked")
701
+
702
+ _write_state(path, state)
703
+ return _print({"status": "recorded", "run_id": state.get("run_id"), "task": item.get("task"), "item": item, "summary": _summary(state)})
704
+
705
+
706
+ def cmd_status(args: argparse.Namespace) -> int:
707
+ """输出 auto run 状态。"""
708
+ repo_root = _repo_root()
709
+ if repo_root is None:
710
+ return _print({"status": "error", "reason": "not-trellis-project"})
711
+ try:
712
+ path, state = _load_current_state(repo_root, args.run_id)
713
+ except ValueError as exc:
714
+ return _print({"status": "error", "reason": "status-failed", "message": str(exc)})
715
+ return _print({"status": "ok", "path": _rel_path(repo_root, path), **_summary(state)})
716
+
717
+
718
+ def cmd_stop(args: argparse.Namespace) -> int:
719
+ """停止 auto run。"""
720
+ repo_root = _repo_root()
721
+ if repo_root is None:
722
+ return _print({"status": "error", "reason": "not-trellis-project"})
723
+ try:
724
+ path, state = _load_current_state(repo_root, args.run_id)
725
+ except ValueError as exc:
726
+ return _print({"status": "error", "reason": "stop-failed", "message": str(exc)})
727
+ state["status"] = "stopped"
728
+ state["stop_reason"] = args.reason
729
+ _write_state(path, state)
730
+ return _print({"status": "stopped", "run_id": state.get("run_id"), "path": _rel_path(repo_root, path), "reason": args.reason})
731
+
732
+
733
+ def build_parser() -> argparse.ArgumentParser:
734
+ """构造命令行解析器。"""
735
+ parser = argparse.ArgumentParser(description="Trellis auto loop runner.")
736
+ subparsers = parser.add_subparsers(dest="command", required=True)
737
+
738
+ start = subparsers.add_parser("start", help="create an auto loop run")
739
+ start.add_argument("--tasks", nargs="+", required=True)
740
+ start.add_argument("--run-id")
741
+ start.add_argument("--profile", choices=(DEFAULT_PROFILE,), default=DEFAULT_PROFILE)
742
+ start.add_argument("--route-implement", choices=sorted(VALID_IMPLEMENT_ROUTES))
743
+ start.add_argument("--route-check", choices=sorted(VALID_CHECK_ROUTES))
744
+ start.add_argument("--force", action="store_true")
745
+ start.set_defaults(func=cmd_start)
746
+
747
+ for name, func in (("resume", cmd_resume), ("next", cmd_next), ("status", cmd_status)):
748
+ sub = subparsers.add_parser(name)
749
+ sub.add_argument("--run-id")
750
+ sub.set_defaults(func=func)
751
+
752
+ record = subparsers.add_parser("record")
753
+ record.add_argument("--run-id")
754
+ record.add_argument("--task")
755
+ record.add_argument("--action")
756
+ record.add_argument("--result", choices=("ok", "failed", "blocked"), required=True)
757
+ record.add_argument("--failure-type")
758
+ record.add_argument("--summary")
759
+ record.add_argument("--files", nargs="*")
760
+ record.add_argument("--commit")
761
+ record.set_defaults(func=cmd_record)
762
+
763
+ stop = subparsers.add_parser("stop")
764
+ stop.add_argument("--run-id")
765
+ stop.add_argument("--reason")
766
+ stop.set_defaults(func=cmd_stop)
767
+
768
+ return parser
769
+
770
+
771
+ def main() -> int:
772
+ """脚本入口。"""
773
+ parser = build_parser()
774
+ args = parser.parse_args()
775
+ try:
776
+ return args.func(args)
777
+ except ValueError as exc:
778
+ return _print({"status": "error", "reason": "invalid-input", "message": str(exc)})
779
+
780
+
781
+ if __name__ == "__main__":
782
+ sys.exit(main())