tloop-cli 0.5.0__tar.gz → 0.6.0__tar.gz
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.
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/PKG-INFO +34 -1
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/README.md +33 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/pyproject.toml +1 -1
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/claude_runner.py +15 -6
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_edit.py +2 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/config.py +13 -2
- tloop_cli-0.6.0/src/runner/claude.py +89 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/task.py +18 -5
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/PKG-INFO +34 -1
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/SOURCES.txt +1 -0
- tloop_cli-0.6.0/test/test_claude_runner.py +89 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/test/test_cli.py +119 -0
- tloop_cli-0.5.0/src/runner/claude.py +0 -10
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/LICENSE +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/setup.cfg +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_archive.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_migrate.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_run.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/git_ops.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/main.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/runner/__init__.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/runner/cybervisor.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/state.py +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/dependency_links.txt +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/entry_points.txt +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/requires.txt +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/top_level.txt +0 -0
- {tloop_cli-0.5.0 → tloop_cli-0.6.0}/test/test_git_ops.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tloop-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Automated Claude Code task runner
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.9
|
|
@@ -60,6 +60,8 @@ tasks:
|
|
|
60
60
|
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
61
61
|
| `model` | 覆盖该任务的模型 |
|
|
62
62
|
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
63
|
+
| `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
|
|
64
|
+
| `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
|
|
63
65
|
|
|
64
66
|
## 用法
|
|
65
67
|
|
|
@@ -76,6 +78,37 @@ tloop archive --latest # 显示最近一次归档详情
|
|
|
76
78
|
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
77
79
|
```
|
|
78
80
|
|
|
81
|
+
## 执行器
|
|
82
|
+
|
|
83
|
+
t-loop 支持两种任务执行器,通过 `use` 字段选择:
|
|
84
|
+
|
|
85
|
+
| 执行器 | 说明 | 适用场景 |
|
|
86
|
+
|--------|------|----------|
|
|
87
|
+
| `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
|
|
88
|
+
| `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
|
|
89
|
+
|
|
90
|
+
### ClaudeRunner 循环机制
|
|
91
|
+
|
|
92
|
+
`use: claude` 时,任务以多轮迭代方式执行:
|
|
93
|
+
|
|
94
|
+
1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
|
|
95
|
+
2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
|
|
96
|
+
3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
|
|
97
|
+
4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
|
|
98
|
+
|
|
99
|
+
使用示例:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
tasks:
|
|
103
|
+
- name: 修复空指针 bug
|
|
104
|
+
dir: ~/proj
|
|
105
|
+
use: claude
|
|
106
|
+
max_rounds: 3
|
|
107
|
+
prompt_file: bugfix.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
|
|
111
|
+
|
|
79
112
|
## 工作原理
|
|
80
113
|
|
|
81
114
|
每个任务的执行流程:
|
|
@@ -49,6 +49,8 @@ tasks:
|
|
|
49
49
|
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
50
50
|
| `model` | 覆盖该任务的模型 |
|
|
51
51
|
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
52
|
+
| `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
|
|
53
|
+
| `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
|
|
52
54
|
|
|
53
55
|
## 用法
|
|
54
56
|
|
|
@@ -65,6 +67,37 @@ tloop archive --latest # 显示最近一次归档详情
|
|
|
65
67
|
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
66
68
|
```
|
|
67
69
|
|
|
70
|
+
## 执行器
|
|
71
|
+
|
|
72
|
+
t-loop 支持两种任务执行器,通过 `use` 字段选择:
|
|
73
|
+
|
|
74
|
+
| 执行器 | 说明 | 适用场景 |
|
|
75
|
+
|--------|------|----------|
|
|
76
|
+
| `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
|
|
77
|
+
| `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
|
|
78
|
+
|
|
79
|
+
### ClaudeRunner 循环机制
|
|
80
|
+
|
|
81
|
+
`use: claude` 时,任务以多轮迭代方式执行:
|
|
82
|
+
|
|
83
|
+
1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
|
|
84
|
+
2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
|
|
85
|
+
3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
|
|
86
|
+
4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
|
|
87
|
+
|
|
88
|
+
使用示例:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
tasks:
|
|
92
|
+
- name: 修复空指针 bug
|
|
93
|
+
dir: ~/proj
|
|
94
|
+
use: claude
|
|
95
|
+
max_rounds: 3
|
|
96
|
+
prompt_file: bugfix.md
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
|
|
100
|
+
|
|
68
101
|
## 工作原理
|
|
69
102
|
|
|
70
103
|
每个任务的执行流程:
|
|
@@ -45,12 +45,21 @@ def run_claude(prompt, cwd, max_retries=DEFAULT_MAX_RETRIES, verify_fn=None, log
|
|
|
45
45
|
else:
|
|
46
46
|
attempt_cmd = cmd
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
print(f" Running claude (attempt {attempt}/{max_retries})...")
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
attempt_cmd,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=300,
|
|
56
|
+
)
|
|
57
|
+
except subprocess.TimeoutExpired:
|
|
58
|
+
print(f"{YELLOW} Claude timed out after 300s (attempt {attempt}/{max_retries}){RESET}")
|
|
59
|
+
if attempt >= max_retries:
|
|
60
|
+
print(f"{RED} Max retries reached. Giving up.{RESET}")
|
|
61
|
+
return False
|
|
62
|
+
continue
|
|
54
63
|
|
|
55
64
|
if log_file:
|
|
56
65
|
with open(log_file, "a") as log:
|
|
@@ -31,6 +31,8 @@ Task file format (~/.tloop/tasks.yaml):
|
|
|
31
31
|
# OR:
|
|
32
32
|
prompt_file: ./prompts/my-task.md
|
|
33
33
|
branch: true # true=auto, "custom/name", false=skip
|
|
34
|
+
use: cybervisor # cybervisor (default) or claude
|
|
35
|
+
max_rounds: 5 # only for use: claude
|
|
34
36
|
|
|
35
37
|
Each task runs in the specified directory. Completed tasks are
|
|
36
38
|
archived to ~/.tloop/archive/ after each run cycle.
|
|
@@ -39,5 +39,16 @@ def load_config():
|
|
|
39
39
|
print(f"{RED}Error: {TASKS_FILE} not found{RESET}")
|
|
40
40
|
print(f"Run tloop run to initialize, then edit tasks.yaml.")
|
|
41
41
|
sys.exit(1)
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
try:
|
|
43
|
+
with open(TASKS_FILE) as f:
|
|
44
|
+
return yaml.safe_load(f) or {}
|
|
45
|
+
except yaml.YAMLError as e:
|
|
46
|
+
location = ""
|
|
47
|
+
if hasattr(e, "problem_mark") and e.problem_mark:
|
|
48
|
+
mark = e.problem_mark
|
|
49
|
+
location = f" (line {mark.line + 1}, column {mark.column + 1})"
|
|
50
|
+
print(f"{RED}Error: {TASKS_FILE} 解析失败{location}{RESET}")
|
|
51
|
+
if hasattr(e, "problem") and e.problem:
|
|
52
|
+
print(f" {e.problem}")
|
|
53
|
+
print("请检查 YAML 格式,常见问题:含冒号/特殊字符的值需要用引号包裹。")
|
|
54
|
+
sys.exit(1)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Claude Code runner backend with round-loop execution."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from runner import Runner
|
|
7
|
+
|
|
8
|
+
COMPLETION_SUFFIX = (
|
|
9
|
+
|
|
10
|
+
"\n\nWhen you have fully completed all the requested work, "
|
|
11
|
+
"output the following on its own line to signal completion:\n"
|
|
12
|
+
"<promise>COMPLETE</promise>\n"
|
|
13
|
+
"Do NOT output this unless you have finished everything. "
|
|
14
|
+
"If there is still work to do, end your response normally — "
|
|
15
|
+
"another iteration will pick up where you left off."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClaudeRunner(Runner):
|
|
20
|
+
def run(self, prompt, cwd, log_file=None, max_rounds=5, prompt_file=None):
|
|
21
|
+
"""
|
|
22
|
+
Run Claude Code in a loop with configurable round limit.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
prompt: The prompt to send to Claude Code
|
|
26
|
+
cwd: Working directory for the task
|
|
27
|
+
log_file: Optional path to log file
|
|
28
|
+
max_rounds: Maximum number of loop iterations (default 5)
|
|
29
|
+
prompt_file: Optional path to prompt file (used as stdin like CybervisorRunner)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
0 on success (completion signal detected), non-zero on failure
|
|
33
|
+
"""
|
|
34
|
+
enriched_prompt = prompt + COMPLETION_SUFFIX
|
|
35
|
+
|
|
36
|
+
log = open(log_file, "a") if log_file else open("/dev/null", "a")
|
|
37
|
+
try:
|
|
38
|
+
for round_num in range(1, max_rounds + 1):
|
|
39
|
+
log.write(f"\n{'='*60}\n")
|
|
40
|
+
log.write(f"Round {round_num}/{max_rounds}\n")
|
|
41
|
+
log.write(f"{'='*60}\n\n")
|
|
42
|
+
log.flush()
|
|
43
|
+
|
|
44
|
+
print(f"\n{'='*60}")
|
|
45
|
+
print(f" Round {round_num}/{max_rounds} ")
|
|
46
|
+
print(f"{'='*60}")
|
|
47
|
+
|
|
48
|
+
process = subprocess.Popen(
|
|
49
|
+
["claude", "-p", "--dangerously-skip-permissions"],
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
stdin=subprocess.PIPE,
|
|
52
|
+
stdout=subprocess.PIPE,
|
|
53
|
+
stderr=subprocess.STDOUT,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
output_parts = []
|
|
58
|
+
if prompt_file:
|
|
59
|
+
with open(prompt_file, "r") as f:
|
|
60
|
+
process.stdin.write(f.read())
|
|
61
|
+
process.stdin.write(COMPLETION_SUFFIX)
|
|
62
|
+
else:
|
|
63
|
+
process.stdin.write(enriched_prompt)
|
|
64
|
+
process.stdin.close()
|
|
65
|
+
|
|
66
|
+
for line in process.stdout:
|
|
67
|
+
print(line, end="")
|
|
68
|
+
log.write(line)
|
|
69
|
+
output_parts.append(line)
|
|
70
|
+
|
|
71
|
+
process.wait()
|
|
72
|
+
log.flush()
|
|
73
|
+
|
|
74
|
+
accumulated = "".join(output_parts)
|
|
75
|
+
if "<promise>COMPLETE</promise>" in accumulated:
|
|
76
|
+
log.write("\n[Completion signal detected - exiting loop]\n")
|
|
77
|
+
log.flush()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
if round_num < max_rounds:
|
|
81
|
+
log.write(f"\n[Round {round_num} complete, sleeping 2s before next round]\n")
|
|
82
|
+
log.flush()
|
|
83
|
+
time.sleep(2)
|
|
84
|
+
|
|
85
|
+
log.write(f"\n[All {max_rounds} rounds exhausted without completion signal]\n")
|
|
86
|
+
log.flush()
|
|
87
|
+
return 1
|
|
88
|
+
finally:
|
|
89
|
+
log.close()
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import config
|
|
8
8
|
from git_ops import ensure_clean_git, create_task_branch
|
|
9
9
|
from runner.cybervisor import CybervisorRunner
|
|
10
|
+
from runner.claude import ClaudeRunner
|
|
10
11
|
from state import save_state
|
|
11
12
|
|
|
12
13
|
|
|
@@ -100,12 +101,24 @@ def run_task(task, index, state):
|
|
|
100
101
|
try:
|
|
101
102
|
with open(log_file, "a") as log:
|
|
102
103
|
log.write(f"Started: {started}\n")
|
|
103
|
-
log.write(f"Command: cybervisor run < {'<prompt_file>' if resolved_pf else '<prompt>'}\n")
|
|
104
|
-
log.write("-" * 60 + "\n\n")
|
|
105
104
|
log.flush()
|
|
106
105
|
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
runner_name = task.get("use", "cybervisor")
|
|
107
|
+
if runner_name == "claude":
|
|
108
|
+
runner = ClaudeRunner()
|
|
109
|
+
max_rounds = task.get("max_rounds", 5)
|
|
110
|
+
log.write(f"Runner: ClaudeRunner (max_rounds={max_rounds})\n")
|
|
111
|
+
log.write(f"Command: claude -p --dangerously-skip-permissions\n")
|
|
112
|
+
log.write("-" * 60 + "\n\n")
|
|
113
|
+
log.flush()
|
|
114
|
+
returncode = runner.run(prompt, dir_path, log_file=log_file, max_rounds=max_rounds, prompt_file=resolved_pf)
|
|
115
|
+
else:
|
|
116
|
+
runner = CybervisorRunner()
|
|
117
|
+
log.write(f"Runner: CybervisorRunner\n")
|
|
118
|
+
log.write(f"Command: cybervisor run < {'<prompt_file>' if resolved_pf else '<prompt>'}\n")
|
|
119
|
+
log.write("-" * 60 + "\n\n")
|
|
120
|
+
log.flush()
|
|
121
|
+
returncode = runner.run(prompt, dir_path, log_file=log_file, prompt_file=resolved_pf)
|
|
109
122
|
|
|
110
123
|
if returncode == 0:
|
|
111
124
|
state["tasks"][str(index)] = {
|
|
@@ -139,4 +152,4 @@ def run_task(task, index, state):
|
|
|
139
152
|
print(f"\n{config.RED}❌ Task [{index + 1}] error: {e}{config.RESET}")
|
|
140
153
|
return False
|
|
141
154
|
|
|
142
|
-
return True
|
|
155
|
+
return True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tloop-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Automated Claude Code task runner
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.9
|
|
@@ -60,6 +60,8 @@ tasks:
|
|
|
60
60
|
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
61
61
|
| `model` | 覆盖该任务的模型 |
|
|
62
62
|
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
63
|
+
| `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
|
|
64
|
+
| `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
|
|
63
65
|
|
|
64
66
|
## 用法
|
|
65
67
|
|
|
@@ -76,6 +78,37 @@ tloop archive --latest # 显示最近一次归档详情
|
|
|
76
78
|
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
77
79
|
```
|
|
78
80
|
|
|
81
|
+
## 执行器
|
|
82
|
+
|
|
83
|
+
t-loop 支持两种任务执行器,通过 `use` 字段选择:
|
|
84
|
+
|
|
85
|
+
| 执行器 | 说明 | 适用场景 |
|
|
86
|
+
|--------|------|----------|
|
|
87
|
+
| `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
|
|
88
|
+
| `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
|
|
89
|
+
|
|
90
|
+
### ClaudeRunner 循环机制
|
|
91
|
+
|
|
92
|
+
`use: claude` 时,任务以多轮迭代方式执行:
|
|
93
|
+
|
|
94
|
+
1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
|
|
95
|
+
2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
|
|
96
|
+
3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
|
|
97
|
+
4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
|
|
98
|
+
|
|
99
|
+
使用示例:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
tasks:
|
|
103
|
+
- name: 修复空指针 bug
|
|
104
|
+
dir: ~/proj
|
|
105
|
+
use: claude
|
|
106
|
+
max_rounds: 3
|
|
107
|
+
prompt_file: bugfix.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
|
|
111
|
+
|
|
79
112
|
## 工作原理
|
|
80
113
|
|
|
81
114
|
每个任务的执行流程:
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Tests for ClaudeRunner."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
|
|
7
|
+
from runner.claude import ClaudeRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MockProcess:
|
|
11
|
+
def __init__(self, stdout_lines, returncode=0):
|
|
12
|
+
self._stdout_lines = stdout_lines
|
|
13
|
+
self.returncode = returncode
|
|
14
|
+
self.stdin = MagicMock()
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def stdout(self):
|
|
18
|
+
return iter(self._stdout_lines)
|
|
19
|
+
|
|
20
|
+
def wait(self):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClaudeRunnerTests(unittest.TestCase):
|
|
25
|
+
|
|
26
|
+
def test_completion_signal_exits_early(self):
|
|
27
|
+
runner = ClaudeRunner()
|
|
28
|
+
mock_process = MockProcess(["Some output\n", "<promise>COMPLETE</promise>\n", "More output\n"])
|
|
29
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
30
|
+
with patch("time.sleep"):
|
|
31
|
+
returncode = runner.run("test prompt", "/tmp", max_rounds=5)
|
|
32
|
+
self.assertEqual(returncode, 0)
|
|
33
|
+
|
|
34
|
+
def test_no_signal_exhausts_rounds(self):
|
|
35
|
+
runner = ClaudeRunner()
|
|
36
|
+
mock_process = MockProcess(["Some output\n", "No completion here\n"])
|
|
37
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
38
|
+
with patch("time.sleep"):
|
|
39
|
+
returncode = runner.run("test prompt", "/tmp", max_rounds=3)
|
|
40
|
+
self.assertEqual(returncode, 1)
|
|
41
|
+
|
|
42
|
+
def test_default_max_rounds_is_5(self):
|
|
43
|
+
runner = ClaudeRunner()
|
|
44
|
+
calls = []
|
|
45
|
+
mock_process = MockProcess(["No signal\n"])
|
|
46
|
+
|
|
47
|
+
def track_popen(*args, **kwargs):
|
|
48
|
+
calls.append(args)
|
|
49
|
+
return mock_process
|
|
50
|
+
|
|
51
|
+
with patch("subprocess.Popen", side_effect=track_popen):
|
|
52
|
+
with patch("time.sleep"):
|
|
53
|
+
runner.run("test prompt", "/tmp")
|
|
54
|
+
self.assertEqual(len(calls), 5)
|
|
55
|
+
|
|
56
|
+
def test_custom_max_rounds_respected(self):
|
|
57
|
+
runner = ClaudeRunner()
|
|
58
|
+
calls = []
|
|
59
|
+
mock_process = MockProcess(["No signal\n"])
|
|
60
|
+
|
|
61
|
+
def track_popen(*args, **kwargs):
|
|
62
|
+
calls.append(args)
|
|
63
|
+
return mock_process
|
|
64
|
+
|
|
65
|
+
with patch("subprocess.Popen", side_effect=track_popen):
|
|
66
|
+
with patch("time.sleep"):
|
|
67
|
+
runner.run("test prompt", "/tmp", max_rounds=3)
|
|
68
|
+
self.assertEqual(len(calls), 3)
|
|
69
|
+
|
|
70
|
+
def test_first_round_complete_exits_early(self):
|
|
71
|
+
runner = ClaudeRunner()
|
|
72
|
+
mock_process = MockProcess(["<promise>COMPLETE</promise>\n"])
|
|
73
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
74
|
+
with patch("time.sleep") as mock_sleep:
|
|
75
|
+
returncode = runner.run("test prompt", "/tmp", max_rounds=5)
|
|
76
|
+
self.assertEqual(returncode, 0)
|
|
77
|
+
mock_sleep.assert_not_called()
|
|
78
|
+
|
|
79
|
+
def test_sleep_between_rounds(self):
|
|
80
|
+
runner = ClaudeRunner()
|
|
81
|
+
mock_process = MockProcess(["No signal\n"])
|
|
82
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
83
|
+
with patch("time.sleep") as mock_sleep:
|
|
84
|
+
runner.run("test prompt", "/tmp", max_rounds=3)
|
|
85
|
+
self.assertEqual(mock_sleep.call_count, 2)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -814,6 +814,125 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
|
|
|
814
814
|
self.assertEqual(state["tasks"]["0"]["status"], "failed")
|
|
815
815
|
|
|
816
816
|
|
|
817
|
+
class RunnerSelectionTests(unittest.TestCase):
|
|
818
|
+
"""T5.11: Tests for runner selection based on task 'use' field."""
|
|
819
|
+
|
|
820
|
+
def setUp(self):
|
|
821
|
+
self.tmpdir = tempfile.mkdtemp()
|
|
822
|
+
_init_git_repo(self.tmpdir)
|
|
823
|
+
self.home = Path(tempfile.mkdtemp()) / "tloop"
|
|
824
|
+
self.home.mkdir(parents=True)
|
|
825
|
+
(self.home / "logs").mkdir()
|
|
826
|
+
(self.home / "archive").mkdir()
|
|
827
|
+
self.patches = [
|
|
828
|
+
patch.object(config, "TLOOP_HOME", self.home),
|
|
829
|
+
patch.object(config, "TASKS_FILE", self.home / "tasks.yaml"),
|
|
830
|
+
patch.object(config, "STATE_FILE", self.home / "state.json"),
|
|
831
|
+
patch.object(config, "LOGS_DIR", self.home / "logs"),
|
|
832
|
+
patch.object(config, "ARCHIVE_DIR", self.home / "archive"),
|
|
833
|
+
]
|
|
834
|
+
for p in self.patches:
|
|
835
|
+
p.start()
|
|
836
|
+
|
|
837
|
+
def tearDown(self):
|
|
838
|
+
for p in self.patches:
|
|
839
|
+
p.stop()
|
|
840
|
+
|
|
841
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
842
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
843
|
+
@patch("runner.claude.subprocess.Popen")
|
|
844
|
+
def test_use_claude_selects_claude_runner(self, mock_popen, mock_branch, mock_clean):
|
|
845
|
+
mock_proc = MagicMock()
|
|
846
|
+
mock_proc.stdout = iter(["<promise>COMPLETE</promise>\n"])
|
|
847
|
+
mock_proc.returncode = 0
|
|
848
|
+
mock_proc.stdin = MagicMock()
|
|
849
|
+
mock_proc.wait.return_value = 0
|
|
850
|
+
mock_popen.return_value = mock_proc
|
|
851
|
+
|
|
852
|
+
state = {"tasks": {}, "version": 1}
|
|
853
|
+
task = {
|
|
854
|
+
"name": "test claude task",
|
|
855
|
+
"dir": self.tmpdir,
|
|
856
|
+
"prompt": "do something",
|
|
857
|
+
"use": "claude",
|
|
858
|
+
"branch": "feat/test",
|
|
859
|
+
}
|
|
860
|
+
result = task_mod.run_task(task, 0, state)
|
|
861
|
+
self.assertTrue(result)
|
|
862
|
+
self.assertEqual(state["tasks"]["0"]["status"], "done")
|
|
863
|
+
|
|
864
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
865
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
866
|
+
@patch("runner.claude.subprocess.Popen")
|
|
867
|
+
def test_use_claude_max_rounds_respected(self, mock_popen, mock_branch, mock_clean):
|
|
868
|
+
mock_proc = MagicMock()
|
|
869
|
+
mock_proc.stdout = iter(["no completion\n"])
|
|
870
|
+
mock_proc.returncode = 0
|
|
871
|
+
mock_proc.stdin = MagicMock()
|
|
872
|
+
mock_proc.wait.return_value = 0
|
|
873
|
+
mock_popen.return_value = mock_proc
|
|
874
|
+
|
|
875
|
+
state = {"tasks": {}, "version": 1}
|
|
876
|
+
task = {
|
|
877
|
+
"name": "test claude task",
|
|
878
|
+
"dir": self.tmpdir,
|
|
879
|
+
"prompt": "do something",
|
|
880
|
+
"use": "claude",
|
|
881
|
+
"max_rounds": 3,
|
|
882
|
+
"branch": "feat/test",
|
|
883
|
+
}
|
|
884
|
+
with patch("time.sleep"):
|
|
885
|
+
result = task_mod.run_task(task, 0, state)
|
|
886
|
+
self.assertFalse(result)
|
|
887
|
+
self.assertEqual(state["tasks"]["0"]["status"], "failed")
|
|
888
|
+
self.assertEqual(mock_popen.call_count, 3)
|
|
889
|
+
|
|
890
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
891
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
892
|
+
@patch("runner.cybervisor.subprocess.Popen")
|
|
893
|
+
def test_use_cybervisor_selects_cybervisor_runner(self, mock_popen, mock_branch, mock_clean):
|
|
894
|
+
mock_proc = MagicMock()
|
|
895
|
+
mock_proc.stdout = iter(["output\n"])
|
|
896
|
+
mock_proc.returncode = 0
|
|
897
|
+
mock_proc.stdin = MagicMock()
|
|
898
|
+
mock_proc.wait.return_value = 0
|
|
899
|
+
mock_popen.return_value = mock_proc
|
|
900
|
+
|
|
901
|
+
state = {"tasks": {}, "version": 1}
|
|
902
|
+
task = {
|
|
903
|
+
"name": "test cybervisor task",
|
|
904
|
+
"dir": self.tmpdir,
|
|
905
|
+
"prompt": "do something",
|
|
906
|
+
"use": "cybervisor",
|
|
907
|
+
"branch": "feat/test",
|
|
908
|
+
}
|
|
909
|
+
result = task_mod.run_task(task, 0, state)
|
|
910
|
+
self.assertTrue(result)
|
|
911
|
+
self.assertEqual(state["tasks"]["0"]["status"], "done")
|
|
912
|
+
|
|
913
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
914
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
915
|
+
@patch("runner.cybervisor.subprocess.Popen")
|
|
916
|
+
def test_missing_use_field_defaults_to_cybervisor(self, mock_popen, mock_branch, mock_clean):
|
|
917
|
+
mock_proc = MagicMock()
|
|
918
|
+
mock_proc.stdout = iter(["output\n"])
|
|
919
|
+
mock_proc.returncode = 0
|
|
920
|
+
mock_proc.stdin = MagicMock()
|
|
921
|
+
mock_proc.wait.return_value = 0
|
|
922
|
+
mock_popen.return_value = mock_proc
|
|
923
|
+
|
|
924
|
+
state = {"tasks": {}, "version": 1}
|
|
925
|
+
task = {
|
|
926
|
+
"name": "test default task",
|
|
927
|
+
"dir": self.tmpdir,
|
|
928
|
+
"prompt": "do something",
|
|
929
|
+
"branch": "feat/test",
|
|
930
|
+
}
|
|
931
|
+
result = task_mod.run_task(task, 0, state)
|
|
932
|
+
self.assertTrue(result)
|
|
933
|
+
self.assertEqual(state["tasks"]["0"]["status"], "done")
|
|
934
|
+
|
|
935
|
+
|
|
817
936
|
class EditorSelectionTests(unittest.TestCase):
|
|
818
937
|
"""Tests for editor selection and persistence in cmd_edit."""
|
|
819
938
|
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
"""Claude Code runner backend (placeholder)."""
|
|
2
|
-
|
|
3
|
-
from runner import Runner
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class ClaudeRunner(Runner):
|
|
7
|
-
"""Claude Code runner (not yet implemented)."""
|
|
8
|
-
|
|
9
|
-
def run(self, prompt, cwd, model=None, log_file=None):
|
|
10
|
-
raise NotImplementedError("Claude Code runner not yet implemented")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|