@tt-a1i/mco 0.1.2 → 0.2.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.
- package/README.md +124 -141
- package/README.zh-CN.md +173 -0
- package/package.json +1 -1
- package/runtime/adapters/shim.py +37 -5
- package/runtime/cli.py +27 -17
- package/runtime/config.py +1 -158
- package/runtime/orchestrator.py +11 -2
- package/runtime/schemas/review_findings.schema.json +0 -2
package/README.md
CHANGED
|
@@ -1,190 +1,173 @@
|
|
|
1
|
-
# MCO
|
|
1
|
+
# MCO
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
1. [multi-cli-orchestrator-proposal.md](./multi-cli-orchestrator-proposal.md)
|
|
5
|
-
2. [capability-research.md](./capability-research.md)
|
|
6
|
-
3. [notes.md](./notes.md)
|
|
3
|
+
**MCO — One Prompt. Five AI Agents. One Result.**
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
1. [capability-probe-spec.md](./capability-probe-spec.md)
|
|
10
|
-
2. [adapter-contract-tests.md](./adapter-contract-tests.md)
|
|
11
|
-
3. [dry-run-plan.md](./dry-run-plan.md)
|
|
12
|
-
4. [implementation-gate-checklist.md](./implementation-gate-checklist.md)
|
|
5
|
+
English | [简体中文](./README.zh-CN.md)
|
|
13
6
|
|
|
14
|
-
##
|
|
15
|
-
1. [docs/implementation/step0-interface-freeze.md](./docs/implementation/step0-interface-freeze.md)
|
|
16
|
-
2. [docs/contracts/cli-json-v0.1.x.md](./docs/contracts/cli-json-v0.1.x.md)
|
|
17
|
-
3. [docs/contracts/provider-permissions-v0.1.x.md](./docs/contracts/provider-permissions-v0.1.x.md)
|
|
7
|
+
## What is MCO
|
|
18
8
|
|
|
19
|
-
|
|
20
|
-
1. [task_plan.md](./task_plan.md)
|
|
9
|
+
MCO (Multi-CLI Orchestrator) is a neutral orchestration layer that dispatches a single prompt to multiple AI coding agents in parallel and aggregates their results. No vendor lock-in. No workflow rewrite. Just fan-out, wait-all, and collect.
|
|
21
10
|
|
|
22
|
-
|
|
23
|
-
1. [docs/releases/v0.1.2.md](./docs/releases/v0.1.2.md)
|
|
24
|
-
2. [docs/releases/v0.1.2.zh-CN.md](./docs/releases/v0.1.2.zh-CN.md)
|
|
25
|
-
3. [docs/releases/v0.1.1.md](./docs/releases/v0.1.1.md)
|
|
26
|
-
4. [docs/releases/v0.1.1.zh-CN.md](./docs/releases/v0.1.1.zh-CN.md)
|
|
27
|
-
5. [docs/releases/v0.1.0.md](./docs/releases/v0.1.0.md)
|
|
28
|
-
6. [docs/releases/v0.1.0.zh-CN.md](./docs/releases/v0.1.0.zh-CN.md)
|
|
11
|
+
You keep using Claude Code, Codex CLI, Gemini CLI, OpenCode, and Qwen Code as they are. MCO wires them into a unified execution pipeline with structured output, progress-driven timeouts, and reproducible artifacts.
|
|
29
12
|
|
|
30
|
-
##
|
|
31
|
-
`mco review` is the unified entrypoint for running a review task.
|
|
13
|
+
## Key Highlights
|
|
32
14
|
|
|
33
|
-
|
|
15
|
+
- **Parallel fan-out** — dispatch to all providers simultaneously, wait-all semantics
|
|
16
|
+
- **Progress-driven timeouts** — agents run freely until completion; cancel only when output goes idle
|
|
17
|
+
- **Dual mode** — `mco review` for structured code review findings, `mco run` for general task execution
|
|
18
|
+
- **Provider-neutral** — uniform adapter contract across 5 CLI tools, no favoring any vendor
|
|
19
|
+
- **Machine-readable output** — JSON result payloads and per-provider artifact trees for downstream automation
|
|
34
20
|
|
|
35
|
-
##
|
|
21
|
+
## Supported Providers
|
|
36
22
|
|
|
37
|
-
|
|
23
|
+
| Provider | CLI | Status |
|
|
24
|
+
|----------|-----|--------|
|
|
25
|
+
| Claude Code | `claude` | Supported |
|
|
26
|
+
| Codex CLI | `codex` | Supported |
|
|
27
|
+
| Gemini CLI | `gemini` | Supported |
|
|
28
|
+
| OpenCode | `opencode` | Supported |
|
|
29
|
+
| Qwen Code | `qwen` | Supported |
|
|
30
|
+
|
|
31
|
+
No project migration. No command relearning. No single-tool lock-in.
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
Install via npm (Python 3 required on PATH):
|
|
38
36
|
|
|
39
37
|
```bash
|
|
40
|
-
|
|
41
|
-
mco --help
|
|
38
|
+
npm i -g @tt-a1i/mco
|
|
42
39
|
```
|
|
43
40
|
|
|
44
|
-
|
|
41
|
+
Or install from source:
|
|
45
42
|
|
|
46
43
|
```bash
|
|
47
44
|
git clone https://github.com/tt-a1i/mco.git
|
|
48
45
|
cd mco
|
|
49
46
|
python3 -m pip install -e .
|
|
50
|
-
mco --help
|
|
51
47
|
```
|
|
52
48
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
npm i -g @tt-a1i/mco
|
|
57
|
-
mco --help
|
|
58
|
-
```
|
|
49
|
+
Run your first multi-agent review:
|
|
59
50
|
|
|
60
|
-
Quick start:
|
|
61
51
|
```bash
|
|
62
|
-
|
|
52
|
+
mco review \
|
|
63
53
|
--repo . \
|
|
64
54
|
--prompt "Review this repository for high-risk bugs and security issues." \
|
|
65
|
-
--providers claude,codex
|
|
55
|
+
--providers claude,codex,qwen
|
|
66
56
|
```
|
|
67
57
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
### Review Mode
|
|
61
|
+
|
|
62
|
+
Structured code review with findings schema. Each provider returns normalized findings with severity, category, evidence, and recommendations.
|
|
72
63
|
|
|
73
|
-
Stdout-only result mode (for caller rendering, no `summary.md/decision.md/findings.json/run.json` write):
|
|
74
64
|
```bash
|
|
75
|
-
|
|
65
|
+
mco review \
|
|
66
|
+
--repo . \
|
|
67
|
+
--prompt "Review for security vulnerabilities and performance issues." \
|
|
68
|
+
--providers claude,codex,gemini,opencode,qwen \
|
|
69
|
+
--json
|
|
76
70
|
```
|
|
77
71
|
|
|
78
|
-
|
|
72
|
+
### Run Mode
|
|
73
|
+
|
|
74
|
+
General-purpose multi-agent execution. No forced output schema — providers complete the task freely.
|
|
75
|
+
|
|
79
76
|
```bash
|
|
80
|
-
|
|
77
|
+
mco run \
|
|
78
|
+
--repo . \
|
|
79
|
+
--prompt "Summarize the architecture of this project." \
|
|
80
|
+
--providers claude,codex \
|
|
81
|
+
--json
|
|
81
82
|
```
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"enforce_findings_contract": false,
|
|
95
|
-
"max_retries": 1,
|
|
96
|
-
"high_escalation_threshold": 1,
|
|
97
|
-
"require_non_empty_findings": true,
|
|
98
|
-
"max_provider_parallelism": 0,
|
|
99
|
-
"allow_paths": [".", "runtime", "scripts"],
|
|
100
|
-
"enforcement_mode": "strict",
|
|
101
|
-
"provider_permissions": {
|
|
102
|
-
"claude": {
|
|
103
|
-
"permission_mode": "plan"
|
|
104
|
-
},
|
|
105
|
-
"codex": {
|
|
106
|
-
"sandbox": "workspace-write"
|
|
107
|
-
}
|
|
108
|
-
},
|
|
109
|
-
"provider_timeouts": {
|
|
110
|
-
"claude": 300,
|
|
111
|
-
"codex": 240,
|
|
112
|
-
"qwen": 240
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
```
|
|
84
|
+
### Result Modes
|
|
85
|
+
|
|
86
|
+
| Mode | Behavior |
|
|
87
|
+
|------|----------|
|
|
88
|
+
| `--result-mode artifact` | Write artifact files, print summary (default) |
|
|
89
|
+
| `--result-mode stdout` | Print full result to stdout, skip artifact files |
|
|
90
|
+
| `--result-mode both` | Write artifacts and print full result |
|
|
91
|
+
|
|
92
|
+
### Path Constraints
|
|
93
|
+
|
|
94
|
+
Restrict which files agents can access:
|
|
117
95
|
|
|
118
|
-
Run with config:
|
|
119
96
|
```bash
|
|
120
|
-
|
|
97
|
+
mco run \
|
|
98
|
+
--repo . \
|
|
99
|
+
--prompt "Analyze the adapter layer." \
|
|
100
|
+
--providers claude,codex \
|
|
101
|
+
--allow-paths runtime,scripts \
|
|
102
|
+
--target-paths runtime/adapters \
|
|
103
|
+
--enforcement-mode strict
|
|
121
104
|
```
|
|
122
105
|
|
|
123
|
-
|
|
106
|
+
## Defaults and Overrides
|
|
107
|
+
|
|
108
|
+
MCO is zero-config by default. You can run it directly with built-in defaults and override behavior with CLI flags only.
|
|
109
|
+
|
|
110
|
+
### Key Runtime Flags
|
|
111
|
+
|
|
112
|
+
| Flag | Default | Description |
|
|
113
|
+
|------|---------|-------------|
|
|
114
|
+
| `--providers` | `claude,codex` | Comma-separated provider list |
|
|
115
|
+
| `--stall-timeout` | `900` | Cancel when no output progress for this duration |
|
|
116
|
+
| `--review-hard-timeout` | `1800` | Hard deadline for review mode (`0` = disabled) |
|
|
117
|
+
| `--max-provider-parallelism` | `0` | `0` = full parallelism across selected providers |
|
|
118
|
+
| `--enforcement-mode` | `strict` | `strict` fails closed on unmet permissions |
|
|
119
|
+
| `--provider-timeouts` | unset | Per-provider stall-timeout overrides (`provider=seconds`) |
|
|
120
|
+
| `--provider-permissions-json` | unset | Provider permission mapping JSON |
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
|
|
124
124
|
```bash
|
|
125
|
-
|
|
125
|
+
mco review \
|
|
126
126
|
--repo . \
|
|
127
|
-
--prompt "Review for bugs
|
|
128
|
-
--providers claude,codex,
|
|
129
|
-
--strict-contract \
|
|
130
|
-
--max-provider-parallelism 2 \
|
|
127
|
+
--prompt "Review for bugs." \
|
|
128
|
+
--providers claude,codex,qwen \
|
|
131
129
|
--stall-timeout 900 \
|
|
132
130
|
--review-hard-timeout 1800 \
|
|
131
|
+
--max-provider-parallelism 0 \
|
|
133
132
|
--provider-timeouts qwen=900,codex=900
|
|
134
133
|
```
|
|
135
134
|
|
|
136
|
-
Run
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
--json
|
|
135
|
+
Run `mco review --help` for the full flag list.
|
|
136
|
+
|
|
137
|
+
## How It Works
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
prompt ─> MCO ─┬─> Claude Code ─┐
|
|
141
|
+
├─> Codex CLI ├─> aggregate ─> artifacts + JSON
|
|
142
|
+
├─> Gemini CLI │
|
|
143
|
+
├─> OpenCode │
|
|
144
|
+
└─> Qwen Code ──┘
|
|
147
145
|
```
|
|
148
146
|
|
|
149
|
-
|
|
150
|
-
- `<artifact_base>/<task_id>/summary.md`
|
|
151
|
-
- `<artifact_base>/<task_id>/decision.md`
|
|
152
|
-
- `<artifact_base>/<task_id>/findings.json`
|
|
153
|
-
- `<artifact_base>/<task_id>/run.json`
|
|
154
|
-
- `<artifact_base>/<task_id>/providers/*.json`
|
|
155
|
-
- `<artifact_base>/<task_id>/raw/*.log`
|
|
156
|
-
|
|
157
|
-
`run.json` includes audit fields for reproducibility:
|
|
158
|
-
- `effective_cwd`
|
|
159
|
-
- `allow_paths_hash`
|
|
160
|
-
- `permissions_hash`
|
|
161
|
-
|
|
162
|
-
Notes:
|
|
163
|
-
- YAML config requires `pyyaml` installed; otherwise use JSON config.
|
|
164
|
-
- Review prompt is wrapped with a JSON finding contract, but strict parse enforcement is optional.
|
|
165
|
-
- Enable strict gate behavior with `--strict-contract` (or `policy.enforce_findings_contract=true` in config).
|
|
166
|
-
- `run` mode does not force findings schema; it focuses on execution aggregation and provider success.
|
|
167
|
-
- `result_mode=artifact` (default): write user-facing artifacts and print compact result.
|
|
168
|
-
- `result_mode=stdout`: print provider-level result payload to stdout, skip user-facing artifact files.
|
|
169
|
-
- `result_mode=both`: write artifacts and print provider-level payload.
|
|
170
|
-
- Execution model is `wait-all`: one provider timeout/failure does not stop others.
|
|
171
|
-
- Timeout behavior is progress-driven:
|
|
172
|
-
- `stall_timeout_seconds`: cancel only when output progress is idle beyond threshold.
|
|
173
|
-
- `review_hard_timeout_seconds`: hard deadline applied only in `review` mode.
|
|
174
|
-
- `max_provider_parallelism=0` (or omitted) means full parallelism across selected providers.
|
|
175
|
-
- `provider_timeouts` are provider-specific stall-timeout overrides.
|
|
176
|
-
- `allow_paths` and `target_paths` are validated against `repo_root`; path escape is rejected.
|
|
177
|
-
- `enforcement_mode=strict` (default) fails closed when provider permission requirements cannot be honored.
|
|
178
|
-
|
|
179
|
-
## Step5 Benchmark Script
|
|
180
|
-
Use this script to generate serial vs full-parallel evidence and write reports under `reports/adapter-contract/<date>/`:
|
|
147
|
+
Each provider runs as an independent subprocess through a uniform adapter contract:
|
|
181
148
|
|
|
182
|
-
|
|
183
|
-
|
|
149
|
+
1. **Detect** — check binary presence and auth status
|
|
150
|
+
2. **Run** — spawn CLI process with prompt, capture stdout/stderr
|
|
151
|
+
3. **Poll** — monitor process + output byte growth for progress detection
|
|
152
|
+
4. **Cancel** — SIGTERM/SIGKILL on stall timeout or hard deadline
|
|
153
|
+
5. **Normalize** — extract structured findings from raw output
|
|
154
|
+
|
|
155
|
+
Execution model is **wait-all**: one provider's timeout or failure never stops others.
|
|
156
|
+
|
|
157
|
+
## Artifacts
|
|
158
|
+
|
|
159
|
+
Each run produces a structured artifact tree:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
reports/review/<task_id>/
|
|
163
|
+
summary.md # Human-readable summary
|
|
164
|
+
decision.md # PASS / FAIL / ESCALATE / PARTIAL
|
|
165
|
+
findings.json # Aggregated normalized findings (review mode)
|
|
166
|
+
run.json # Machine-readable execution metadata
|
|
167
|
+
providers/ # Per-provider result JSON
|
|
168
|
+
raw/ # Raw stdout/stderr logs
|
|
184
169
|
```
|
|
185
170
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
- `effective_findings_count`
|
|
190
|
-
- `zero_finding_provider_count`
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
UNLICENSED
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# MCO
|
|
2
|
+
|
|
3
|
+
**MCO — 一条提示词,五个 AI Agent,一份结果。**
|
|
4
|
+
|
|
5
|
+
[English](./README.md) | 简体中文
|
|
6
|
+
|
|
7
|
+
## MCO 是什么
|
|
8
|
+
|
|
9
|
+
MCO(Multi-CLI Orchestrator)是一个中立的编排层,将单条提示词并行分发给多个 AI 编程 Agent,汇总执行结果。不绑定任何厂商,不改变你的工作流。Fan-out、Wait-all、Collect。
|
|
10
|
+
|
|
11
|
+
你继续照常使用 Claude Code、Codex CLI、Gemini CLI、OpenCode、Qwen Code。MCO 负责把它们串联成统一的执行管线,提供结构化输出、进度驱动超时、可复现的产物。
|
|
12
|
+
|
|
13
|
+
## 核心特性
|
|
14
|
+
|
|
15
|
+
- **并行扇出** — 同时分发到所有 provider,wait-all 语义
|
|
16
|
+
- **进度驱动超时** — agent 自由跑完,仅在长时间无输出时取消
|
|
17
|
+
- **双模式** — `mco review` 结构化代码审查,`mco run` 通用任务执行
|
|
18
|
+
- **厂商中立** — 5 个 CLI 工具统一适配器契约,不偏向任何厂商
|
|
19
|
+
- **机器可读输出** — JSON 结果 + 每个 provider 独立产物树,便于下游自动化
|
|
20
|
+
|
|
21
|
+
## 支持的 Provider
|
|
22
|
+
|
|
23
|
+
| Provider | CLI | 状态 |
|
|
24
|
+
|----------|-----|------|
|
|
25
|
+
| Claude Code | `claude` | 已支持 |
|
|
26
|
+
| Codex CLI | `codex` | 已支持 |
|
|
27
|
+
| Gemini CLI | `gemini` | 已支持 |
|
|
28
|
+
| OpenCode | `opencode` | 已支持 |
|
|
29
|
+
| Qwen Code | `qwen` | 已支持 |
|
|
30
|
+
|
|
31
|
+
无需迁移项目,无需重学命令,无需绑定单一工具。
|
|
32
|
+
|
|
33
|
+
## 快速开始
|
|
34
|
+
|
|
35
|
+
通过 npm 安装(需要系统有 Python 3):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm i -g @tt-a1i/mco
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
或从源码安装:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/tt-a1i/mco.git
|
|
45
|
+
cd mco
|
|
46
|
+
python3 -m pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
运行第一次多 Agent 审查:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
mco review \
|
|
53
|
+
--repo . \
|
|
54
|
+
--prompt "Review this repository for high-risk bugs and security issues." \
|
|
55
|
+
--providers claude,codex,qwen
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 使用方式
|
|
59
|
+
|
|
60
|
+
### Review 模式
|
|
61
|
+
|
|
62
|
+
结构化代码审查,输出标准化的 findings(含严重级别、分类、证据、建议)。
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
mco review \
|
|
66
|
+
--repo . \
|
|
67
|
+
--prompt "Review for security vulnerabilities and performance issues." \
|
|
68
|
+
--providers claude,codex,gemini,opencode,qwen \
|
|
69
|
+
--json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Run 模式
|
|
73
|
+
|
|
74
|
+
通用多 Agent 任务执行,不强制输出格式,provider 自由完成任务。
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
mco run \
|
|
78
|
+
--repo . \
|
|
79
|
+
--prompt "Summarize the architecture of this project." \
|
|
80
|
+
--providers claude,codex \
|
|
81
|
+
--json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 结果模式
|
|
85
|
+
|
|
86
|
+
| 模式 | 行为 |
|
|
87
|
+
|------|------|
|
|
88
|
+
| `--result-mode artifact` | 写产物文件,输出摘要(默认) |
|
|
89
|
+
| `--result-mode stdout` | 完整结果输出到 stdout,不写产物文件 |
|
|
90
|
+
| `--result-mode both` | 既写产物又输出完整结果 |
|
|
91
|
+
|
|
92
|
+
### 路径约束
|
|
93
|
+
|
|
94
|
+
限制 agent 可访问的文件范围:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
mco run \
|
|
98
|
+
--repo . \
|
|
99
|
+
--prompt "Analyze the adapter layer." \
|
|
100
|
+
--providers claude,codex \
|
|
101
|
+
--allow-paths runtime,scripts \
|
|
102
|
+
--target-paths runtime/adapters \
|
|
103
|
+
--enforcement-mode strict
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## 默认值与参数覆盖
|
|
107
|
+
|
|
108
|
+
MCO 默认零配置可用。直接运行即可,按需通过命令行参数覆盖行为。
|
|
109
|
+
|
|
110
|
+
### 关键运行参数
|
|
111
|
+
|
|
112
|
+
| 参数 | 默认值 | 说明 |
|
|
113
|
+
|------|--------|------|
|
|
114
|
+
| `--providers` | `claude,codex` | 逗号分隔 provider 列表 |
|
|
115
|
+
| `--stall-timeout` | `900` | 无输出进展超过此时间才取消 |
|
|
116
|
+
| `--review-hard-timeout` | `1800` | review 模式硬截止(`0` = 禁用) |
|
|
117
|
+
| `--max-provider-parallelism` | `0` | `0` = 选中 provider 全并行 |
|
|
118
|
+
| `--enforcement-mode` | `strict` | 权限不满足时 fail-closed |
|
|
119
|
+
| `--provider-timeouts` | 未设置 | provider 级 stall timeout 覆盖(`provider=seconds`) |
|
|
120
|
+
| `--provider-permissions-json` | 未设置 | provider 权限映射 JSON |
|
|
121
|
+
|
|
122
|
+
示例:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
mco review \
|
|
126
|
+
--repo . \
|
|
127
|
+
--prompt "Review for bugs." \
|
|
128
|
+
--providers claude,codex,qwen \
|
|
129
|
+
--stall-timeout 900 \
|
|
130
|
+
--review-hard-timeout 1800 \
|
|
131
|
+
--max-provider-parallelism 0 \
|
|
132
|
+
--provider-timeouts qwen=900,codex=900
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
运行 `mco review --help` 查看完整参数列表。
|
|
136
|
+
|
|
137
|
+
## 工作原理
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
prompt ─> MCO ─┬─> Claude Code ─┐
|
|
141
|
+
├─> Codex CLI ├─> 聚合 ─> 产物 + JSON
|
|
142
|
+
├─> Gemini CLI │
|
|
143
|
+
├─> OpenCode │
|
|
144
|
+
└─> Qwen Code ──┘
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
每个 provider 通过统一的适配器契约作为独立子进程运行:
|
|
148
|
+
|
|
149
|
+
1. **Detect** — 检测二进制文件和认证状态
|
|
150
|
+
2. **Run** — 启动 CLI 进程,传入提示词,捕获 stdout/stderr
|
|
151
|
+
3. **Poll** — 监控进程状态 + 输出字节增长,判断活跃度
|
|
152
|
+
4. **Cancel** — stall timeout 或硬截止时 SIGTERM/SIGKILL
|
|
153
|
+
5. **Normalize** — 从原始输出中提取结构化 findings
|
|
154
|
+
|
|
155
|
+
执行模型是 **wait-all**:单个 provider 超时或失败不会中断其他 provider。
|
|
156
|
+
|
|
157
|
+
## 产物结构
|
|
158
|
+
|
|
159
|
+
每次执行生成结构化产物树:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
reports/review/<task_id>/
|
|
163
|
+
summary.md # 人类可读摘要
|
|
164
|
+
decision.md # PASS / FAIL / ESCALATE / PARTIAL
|
|
165
|
+
findings.json # 聚合后的标准化 findings(review 模式)
|
|
166
|
+
run.json # 机器可读执行元数据
|
|
167
|
+
providers/ # 各 provider 结果 JSON
|
|
168
|
+
raw/ # 原始 stdout/stderr 日志
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 许可证
|
|
172
|
+
|
|
173
|
+
UNLICENSED
|
package/package.json
CHANGED
package/runtime/adapters/shim.py
CHANGED
|
@@ -40,6 +40,19 @@ class ShimRunHandle:
|
|
|
40
40
|
stderr_file: TextIO
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
_ENV_VARS_TO_STRIP = (
|
|
44
|
+
"CLAUDECODE",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sanitize_env() -> Dict[str, str]:
|
|
49
|
+
"""Return a copy of os.environ with known conflicting variables removed."""
|
|
50
|
+
env = os.environ.copy()
|
|
51
|
+
for key in _ENV_VARS_TO_STRIP:
|
|
52
|
+
env.pop(key, None)
|
|
53
|
+
return env
|
|
54
|
+
|
|
55
|
+
|
|
43
56
|
class ShimAdapterBase:
|
|
44
57
|
id: ProviderId
|
|
45
58
|
|
|
@@ -104,6 +117,7 @@ class ShimAdapterBase:
|
|
|
104
117
|
stderr=stderr_file,
|
|
105
118
|
text=True,
|
|
106
119
|
start_new_session=True,
|
|
120
|
+
env=_sanitize_env(),
|
|
107
121
|
)
|
|
108
122
|
self._runs[run_id] = ShimRunHandle(
|
|
109
123
|
process=process,
|
|
@@ -123,6 +137,17 @@ class ShimAdapterBase:
|
|
|
123
137
|
session_id=None,
|
|
124
138
|
)
|
|
125
139
|
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _close_io(handle: ShimRunHandle) -> None:
|
|
142
|
+
try:
|
|
143
|
+
handle.stdout_file.close()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
try:
|
|
147
|
+
handle.stderr_file.close()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
126
151
|
def poll(self, ref: TaskRunRef) -> TaskStatus:
|
|
127
152
|
handle = self._runs.get(ref.run_id)
|
|
128
153
|
if handle is None:
|
|
@@ -154,11 +179,7 @@ class ShimAdapterBase:
|
|
|
154
179
|
message="running",
|
|
155
180
|
)
|
|
156
181
|
|
|
157
|
-
|
|
158
|
-
handle.stdout_file.close()
|
|
159
|
-
handle.stderr_file.close()
|
|
160
|
-
except Exception:
|
|
161
|
-
pass
|
|
182
|
+
self._close_io(handle)
|
|
162
183
|
|
|
163
184
|
stdout_text = handle.stdout_path.read_text(encoding="utf-8") if handle.stdout_path.exists() else ""
|
|
164
185
|
stderr_text = handle.stderr_path.read_text(encoding="utf-8") if handle.stderr_path.exists() else ""
|
|
@@ -182,6 +203,7 @@ class ShimAdapterBase:
|
|
|
182
203
|
"stderr_path": str(handle.stderr_path),
|
|
183
204
|
}
|
|
184
205
|
handle.provider_result_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8")
|
|
206
|
+
self._runs.pop(ref.run_id, None)
|
|
185
207
|
|
|
186
208
|
return TaskStatus(
|
|
187
209
|
task_id=ref.task_id,
|
|
@@ -201,17 +223,27 @@ class ShimAdapterBase:
|
|
|
201
223
|
if handle is None:
|
|
202
224
|
return
|
|
203
225
|
if handle.process.poll() is not None:
|
|
226
|
+
self._close_io(handle)
|
|
227
|
+
self._runs.pop(ref.run_id, None)
|
|
204
228
|
return
|
|
205
229
|
try:
|
|
206
230
|
os.killpg(os.getpgid(handle.process.pid), signal.SIGTERM)
|
|
207
231
|
except ProcessLookupError:
|
|
232
|
+
self._close_io(handle)
|
|
233
|
+
self._runs.pop(ref.run_id, None)
|
|
208
234
|
return
|
|
209
235
|
time.sleep(0.2)
|
|
210
236
|
if handle.process.poll() is None:
|
|
211
237
|
try:
|
|
212
238
|
os.killpg(os.getpgid(handle.process.pid), signal.SIGKILL)
|
|
213
239
|
except ProcessLookupError:
|
|
240
|
+
self._close_io(handle)
|
|
241
|
+
self._runs.pop(ref.run_id, None)
|
|
214
242
|
return
|
|
243
|
+
time.sleep(0.1)
|
|
244
|
+
if handle.process.poll() is not None:
|
|
245
|
+
self._close_io(handle)
|
|
246
|
+
self._runs.pop(ref.run_id, None)
|
|
215
247
|
|
|
216
248
|
def normalize(self, raw: object, ctx: NormalizeContext) -> List[NormalizedFinding]:
|
|
217
249
|
raise NotImplementedError
|
package/runtime/cli.py
CHANGED
|
@@ -6,7 +6,7 @@ import sys
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Dict, List
|
|
8
8
|
|
|
9
|
-
from .config import ReviewConfig, ReviewPolicy
|
|
9
|
+
from .config import ReviewConfig, ReviewPolicy
|
|
10
10
|
from .review_engine import ReviewRequest, run_review
|
|
11
11
|
|
|
12
12
|
|
|
@@ -75,18 +75,20 @@ def _parse_provider_timeouts(raw: str) -> Dict[str, int]:
|
|
|
75
75
|
return result
|
|
76
76
|
for chunk in raw.split(","):
|
|
77
77
|
pair = chunk.strip()
|
|
78
|
-
if not pair
|
|
78
|
+
if not pair:
|
|
79
79
|
continue
|
|
80
|
+
if "=" not in pair:
|
|
81
|
+
raise ValueError(f"invalid provider timeout entry: {pair}")
|
|
80
82
|
provider, timeout_text = pair.split("=", 1)
|
|
81
83
|
provider_name = provider.strip()
|
|
82
84
|
if not provider_name:
|
|
83
|
-
|
|
85
|
+
raise ValueError(f"invalid provider timeout entry: {pair}")
|
|
84
86
|
try:
|
|
85
87
|
timeout = int(timeout_text.strip())
|
|
86
88
|
except Exception:
|
|
87
|
-
|
|
89
|
+
raise ValueError(f"invalid timeout value for provider '{provider_name}': {timeout_text.strip()}") from None
|
|
88
90
|
if timeout <= 0:
|
|
89
|
-
|
|
91
|
+
raise ValueError(f"timeout must be > 0 for provider '{provider_name}'")
|
|
90
92
|
result[provider_name] = timeout
|
|
91
93
|
return result
|
|
92
94
|
|
|
@@ -102,23 +104,24 @@ def _parse_provider_permissions_json(raw: str) -> Dict[str, Dict[str, str]]:
|
|
|
102
104
|
try:
|
|
103
105
|
payload = json.loads(raw)
|
|
104
106
|
except Exception:
|
|
105
|
-
|
|
107
|
+
raise ValueError("--provider-permissions-json must be valid JSON") from None
|
|
106
108
|
if not isinstance(payload, dict):
|
|
107
|
-
|
|
109
|
+
raise ValueError("--provider-permissions-json root must be an object")
|
|
108
110
|
|
|
109
111
|
result: Dict[str, Dict[str, str]] = {}
|
|
110
112
|
for provider, permissions in payload.items():
|
|
111
113
|
provider_name = str(provider).strip()
|
|
112
|
-
if not provider_name
|
|
113
|
-
|
|
114
|
+
if not provider_name:
|
|
115
|
+
raise ValueError("--provider-permissions-json contains empty provider name")
|
|
116
|
+
if not isinstance(permissions, dict):
|
|
117
|
+
raise ValueError(f"permissions for provider '{provider_name}' must be an object")
|
|
114
118
|
normalized: Dict[str, str] = {}
|
|
115
119
|
for key, value in permissions.items():
|
|
116
120
|
key_name = str(key).strip()
|
|
117
121
|
if not key_name:
|
|
118
|
-
|
|
122
|
+
raise ValueError(f"provider '{provider_name}' contains empty permission key")
|
|
119
123
|
normalized[key_name] = str(value)
|
|
120
|
-
|
|
121
|
-
result[provider_name] = normalized
|
|
124
|
+
result[provider_name] = normalized
|
|
122
125
|
return result
|
|
123
126
|
|
|
124
127
|
|
|
@@ -138,7 +141,6 @@ def _add_common_execution_args(parser: argparse.ArgumentParser) -> None:
|
|
|
138
141
|
parser.add_argument("--repo", default=".", help="Repository root path")
|
|
139
142
|
parser.add_argument("--prompt", required=True, help="Task prompt")
|
|
140
143
|
parser.add_argument("--providers", default="", help="Comma-separated providers, e.g. claude,codex")
|
|
141
|
-
parser.add_argument("--config", default="", help="Config file path (.json or .yaml/.yml)")
|
|
142
144
|
parser.add_argument("--artifact-base", default="", help="Artifact base directory override")
|
|
143
145
|
parser.add_argument("--state-file", default="", help="Runtime state file override")
|
|
144
146
|
parser.add_argument("--task-id", default="", help="Optional stable task id")
|
|
@@ -212,7 +214,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
212
214
|
|
|
213
215
|
|
|
214
216
|
def _resolve_config(args: argparse.Namespace) -> ReviewConfig:
|
|
215
|
-
cfg =
|
|
217
|
+
cfg = ReviewConfig()
|
|
216
218
|
providers = _parse_providers(args.providers) if args.providers else cfg.providers
|
|
217
219
|
artifact_base = args.artifact_base or cfg.artifact_base
|
|
218
220
|
state_file = args.state_file or cfg.state_file
|
|
@@ -236,7 +238,7 @@ def _resolve_config(args: argparse.Namespace) -> ReviewConfig:
|
|
|
236
238
|
review_hard_timeout_seconds = cfg.policy.review_hard_timeout_seconds
|
|
237
239
|
if args.review_hard_timeout is not None and args.review_hard_timeout >= 0:
|
|
238
240
|
review_hard_timeout_seconds = args.review_hard_timeout
|
|
239
|
-
enforce_findings_contract =
|
|
241
|
+
enforce_findings_contract = bool(args.strict_contract)
|
|
240
242
|
|
|
241
243
|
policy = ReviewPolicy(
|
|
242
244
|
timeout_seconds=cfg.policy.timeout_seconds,
|
|
@@ -263,7 +265,11 @@ def main(argv: List[str] | None = None) -> int:
|
|
|
263
265
|
parser.error("unsupported command")
|
|
264
266
|
return 2
|
|
265
267
|
|
|
266
|
-
|
|
268
|
+
try:
|
|
269
|
+
cfg = _resolve_config(args)
|
|
270
|
+
except ValueError as exc:
|
|
271
|
+
print(f"Configuration error: {exc}", file=sys.stderr)
|
|
272
|
+
return 2
|
|
267
273
|
repo_root = str(Path(args.repo).resolve())
|
|
268
274
|
providers = [item for item in cfg.providers if item in ("claude", "codex", "gemini", "opencode", "qwen")]
|
|
269
275
|
if not providers:
|
|
@@ -283,7 +289,11 @@ def main(argv: List[str] | None = None) -> int:
|
|
|
283
289
|
)
|
|
284
290
|
review_mode = args.command == "review"
|
|
285
291
|
write_artifacts = args.result_mode in ("artifact", "both")
|
|
286
|
-
|
|
292
|
+
try:
|
|
293
|
+
result = run_review(req, review_mode=review_mode, write_artifacts=write_artifacts)
|
|
294
|
+
except ValueError as exc:
|
|
295
|
+
print(f"Input error: {exc}", file=sys.stderr)
|
|
296
|
+
return 2
|
|
287
297
|
|
|
288
298
|
payload = {
|
|
289
299
|
"command": args.command,
|
package/runtime/config.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
from dataclasses import dataclass, field
|
|
5
|
-
from
|
|
6
|
-
from typing import Any, Dict, List, Optional
|
|
4
|
+
from typing import Dict, List
|
|
7
5
|
|
|
8
6
|
DEFAULT_PROVIDER_TIMEOUTS: Dict[str, int] = {
|
|
9
7
|
}
|
|
@@ -32,158 +30,3 @@ class ReviewConfig:
|
|
|
32
30
|
artifact_base: str = "reports/review"
|
|
33
31
|
state_file: str = ".mco/state.json"
|
|
34
32
|
policy: ReviewPolicy = field(default_factory=ReviewPolicy)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _as_bool(value: Any, default: bool) -> bool:
|
|
38
|
-
if isinstance(value, bool):
|
|
39
|
-
return value
|
|
40
|
-
if isinstance(value, str):
|
|
41
|
-
lowered = value.strip().lower()
|
|
42
|
-
if lowered in ("true", "1", "yes", "y", "on"):
|
|
43
|
-
return True
|
|
44
|
-
if lowered in ("false", "0", "no", "n", "off"):
|
|
45
|
-
return False
|
|
46
|
-
return default
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _to_policy(payload: Dict[str, Any]) -> ReviewPolicy:
|
|
50
|
-
raw_provider_timeouts = payload.get("provider_timeouts", {})
|
|
51
|
-
provider_timeouts: Dict[str, int] = dict(DEFAULT_PROVIDER_TIMEOUTS)
|
|
52
|
-
if isinstance(raw_provider_timeouts, dict):
|
|
53
|
-
for key, value in raw_provider_timeouts.items():
|
|
54
|
-
provider = str(key).strip()
|
|
55
|
-
if not provider:
|
|
56
|
-
continue
|
|
57
|
-
try:
|
|
58
|
-
timeout = int(value)
|
|
59
|
-
except Exception:
|
|
60
|
-
continue
|
|
61
|
-
if timeout <= 0:
|
|
62
|
-
continue
|
|
63
|
-
provider_timeouts[provider] = timeout
|
|
64
|
-
|
|
65
|
-
try:
|
|
66
|
-
max_parallel = int(payload.get("max_provider_parallelism", 0))
|
|
67
|
-
except Exception:
|
|
68
|
-
max_parallel = 0
|
|
69
|
-
if max_parallel < 0:
|
|
70
|
-
max_parallel = 0
|
|
71
|
-
|
|
72
|
-
raw_allow_paths = payload.get("allow_paths", ["."])
|
|
73
|
-
allow_paths: List[str]
|
|
74
|
-
if isinstance(raw_allow_paths, str):
|
|
75
|
-
allow_paths = [item.strip() for item in raw_allow_paths.split(",") if item.strip()]
|
|
76
|
-
elif isinstance(raw_allow_paths, list):
|
|
77
|
-
allow_paths = [str(item).strip() for item in raw_allow_paths if str(item).strip()]
|
|
78
|
-
else:
|
|
79
|
-
allow_paths = ["."]
|
|
80
|
-
if not allow_paths:
|
|
81
|
-
allow_paths = ["."]
|
|
82
|
-
|
|
83
|
-
raw_provider_permissions = payload.get("provider_permissions", {})
|
|
84
|
-
provider_permissions: Dict[str, Dict[str, str]] = {}
|
|
85
|
-
if isinstance(raw_provider_permissions, dict):
|
|
86
|
-
for provider, permissions in raw_provider_permissions.items():
|
|
87
|
-
provider_name = str(provider).strip()
|
|
88
|
-
if not provider_name or not isinstance(permissions, dict):
|
|
89
|
-
continue
|
|
90
|
-
normalized: Dict[str, str] = {}
|
|
91
|
-
for key, value in permissions.items():
|
|
92
|
-
key_name = str(key).strip()
|
|
93
|
-
if not key_name:
|
|
94
|
-
continue
|
|
95
|
-
normalized[key_name] = str(value)
|
|
96
|
-
if normalized:
|
|
97
|
-
provider_permissions[provider_name] = normalized
|
|
98
|
-
|
|
99
|
-
enforcement_mode = str(payload.get("enforcement_mode", "strict")).strip().lower()
|
|
100
|
-
if enforcement_mode not in ("strict", "best_effort"):
|
|
101
|
-
enforcement_mode = "strict"
|
|
102
|
-
|
|
103
|
-
try:
|
|
104
|
-
stall_timeout_seconds = int(payload.get("stall_timeout_seconds", 900))
|
|
105
|
-
except Exception:
|
|
106
|
-
stall_timeout_seconds = 900
|
|
107
|
-
if stall_timeout_seconds <= 0:
|
|
108
|
-
stall_timeout_seconds = 900
|
|
109
|
-
|
|
110
|
-
try:
|
|
111
|
-
poll_interval_seconds = float(payload.get("poll_interval_seconds", 1.0))
|
|
112
|
-
except Exception:
|
|
113
|
-
poll_interval_seconds = 1.0
|
|
114
|
-
if poll_interval_seconds <= 0:
|
|
115
|
-
poll_interval_seconds = 1.0
|
|
116
|
-
|
|
117
|
-
try:
|
|
118
|
-
review_hard_timeout_seconds = int(payload.get("review_hard_timeout_seconds", 1800))
|
|
119
|
-
except Exception:
|
|
120
|
-
review_hard_timeout_seconds = 1800
|
|
121
|
-
if review_hard_timeout_seconds < 0:
|
|
122
|
-
review_hard_timeout_seconds = 1800
|
|
123
|
-
|
|
124
|
-
return ReviewPolicy(
|
|
125
|
-
timeout_seconds=int(payload.get("timeout_seconds", 180)),
|
|
126
|
-
stall_timeout_seconds=stall_timeout_seconds,
|
|
127
|
-
poll_interval_seconds=poll_interval_seconds,
|
|
128
|
-
review_hard_timeout_seconds=review_hard_timeout_seconds,
|
|
129
|
-
enforce_findings_contract=_as_bool(payload.get("enforce_findings_contract", False), False),
|
|
130
|
-
max_retries=int(payload.get("max_retries", 1)),
|
|
131
|
-
high_escalation_threshold=int(payload.get("high_escalation_threshold", 1)),
|
|
132
|
-
require_non_empty_findings=_as_bool(payload.get("require_non_empty_findings", True), True),
|
|
133
|
-
max_provider_parallelism=max_parallel,
|
|
134
|
-
provider_timeouts=provider_timeouts,
|
|
135
|
-
allow_paths=allow_paths,
|
|
136
|
-
provider_permissions=provider_permissions,
|
|
137
|
-
enforcement_mode=enforcement_mode,
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _normalize_payload(payload: Dict[str, Any]) -> ReviewConfig:
|
|
142
|
-
policy_payload = payload.get("policy", {})
|
|
143
|
-
if not isinstance(policy_payload, dict):
|
|
144
|
-
policy_payload = {}
|
|
145
|
-
providers = payload.get("providers", ["claude", "codex"])
|
|
146
|
-
if isinstance(providers, str):
|
|
147
|
-
providers = [item.strip() for item in providers.split(",") if item.strip()]
|
|
148
|
-
if not isinstance(providers, list):
|
|
149
|
-
providers = ["claude", "codex"]
|
|
150
|
-
providers = [str(item).strip() for item in providers if str(item).strip()]
|
|
151
|
-
if not providers:
|
|
152
|
-
providers = ["claude", "codex"]
|
|
153
|
-
|
|
154
|
-
return ReviewConfig(
|
|
155
|
-
providers=providers,
|
|
156
|
-
artifact_base=str(payload.get("artifact_base", "reports/review")),
|
|
157
|
-
state_file=str(payload.get("state_file", ".mco/state.json")),
|
|
158
|
-
policy=_to_policy(policy_payload),
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def load_review_config(config_path: Optional[str]) -> ReviewConfig:
|
|
163
|
-
if not config_path:
|
|
164
|
-
return ReviewConfig()
|
|
165
|
-
path = Path(config_path)
|
|
166
|
-
if not path.exists():
|
|
167
|
-
raise FileNotFoundError(f"config file not found: {config_path}")
|
|
168
|
-
|
|
169
|
-
suffix = path.suffix.lower()
|
|
170
|
-
raw_text = path.read_text(encoding="utf-8")
|
|
171
|
-
if suffix == ".json":
|
|
172
|
-
payload = json.loads(raw_text)
|
|
173
|
-
if not isinstance(payload, dict):
|
|
174
|
-
raise ValueError("config root must be an object")
|
|
175
|
-
return _normalize_payload(payload)
|
|
176
|
-
|
|
177
|
-
if suffix in (".yaml", ".yml"):
|
|
178
|
-
try:
|
|
179
|
-
import yaml # type: ignore
|
|
180
|
-
except Exception as exc:
|
|
181
|
-
raise RuntimeError(
|
|
182
|
-
"YAML config requires pyyaml. Install with: pip install pyyaml, or use a .json config."
|
|
183
|
-
) from exc
|
|
184
|
-
payload = yaml.safe_load(raw_text)
|
|
185
|
-
if not isinstance(payload, dict):
|
|
186
|
-
raise ValueError("config root must be a map")
|
|
187
|
-
return _normalize_payload(payload)
|
|
188
|
-
|
|
189
|
-
raise ValueError(f"unsupported config format: {config_path}")
|
package/runtime/orchestrator.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import threading
|
|
5
|
+
import time
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Callable, Dict, List, Optional, Set, Tuple
|
|
@@ -50,8 +51,14 @@ class TaskStateMachine:
|
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
class OrchestratorRuntime:
|
|
53
|
-
def __init__(
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
retry_policy: Optional[RetryPolicy] = None,
|
|
57
|
+
state_file: Optional[str] = None,
|
|
58
|
+
sleep_fn: Optional[Callable[[float], None]] = None,
|
|
59
|
+
) -> None:
|
|
54
60
|
self.retry_policy = retry_policy or RetryPolicy()
|
|
61
|
+
self.sleep_fn = sleep_fn or time.sleep
|
|
55
62
|
self.dispatch_cache: Dict[str, RunResult] = {}
|
|
56
63
|
self.idempotency_index: Dict[str, str] = {}
|
|
57
64
|
self.sent_notifications: Set[Tuple[str, str, str]] = set()
|
|
@@ -205,7 +212,9 @@ class OrchestratorRuntime:
|
|
|
205
212
|
return final
|
|
206
213
|
|
|
207
214
|
retry_index = attempts
|
|
208
|
-
|
|
215
|
+
delay_seconds = self.retry_policy.compute_delay(retry_index)
|
|
216
|
+
delays.append(delay_seconds)
|
|
217
|
+
self.sleep_fn(delay_seconds)
|
|
209
218
|
|
|
210
219
|
def send_terminal_notification(self, task_id: str, state: TaskState, channel: str) -> bool:
|
|
211
220
|
with self._lock:
|