@tt-a1i/mco 0.1.2 → 0.1.3
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 +7 -8
- package/README.zh-CN.md +196 -0
- package/package.json +1 -1
- package/runtime/adapters/shim.py +37 -5
- package/runtime/cli.py +24 -13
- package/runtime/orchestrator.py +11 -2
- package/runtime/schemas/review_findings.schema.json +0 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# MCO Docs Index
|
|
2
2
|
|
|
3
|
+
English | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
3
5
|
## Read First
|
|
4
6
|
1. [multi-cli-orchestrator-proposal.md](./multi-cli-orchestrator-proposal.md)
|
|
5
7
|
2. [capability-research.md](./capability-research.md)
|
|
@@ -34,10 +36,10 @@
|
|
|
34
36
|
|
|
35
37
|
## Installation
|
|
36
38
|
|
|
37
|
-
Python
|
|
39
|
+
NPM wrapper (available now, Python 3 required on PATH):
|
|
38
40
|
|
|
39
41
|
```bash
|
|
40
|
-
|
|
42
|
+
npm i -g @tt-a1i/mco
|
|
41
43
|
mco --help
|
|
42
44
|
```
|
|
43
45
|
|
|
@@ -50,12 +52,9 @@ python3 -m pip install -e .
|
|
|
50
52
|
mco --help
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
npm i -g @tt-a1i/mco
|
|
57
|
-
mco --help
|
|
58
|
-
```
|
|
55
|
+
Python package via PyPI:
|
|
56
|
+
- Not published yet.
|
|
57
|
+
- Publish workflow is ready and will be enabled after PyPI Trusted Publisher setup.
|
|
59
58
|
|
|
60
59
|
Quick start:
|
|
61
60
|
```bash
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# MCO 文档索引
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
## 先读这些
|
|
6
|
+
1. [multi-cli-orchestrator-proposal.md](./multi-cli-orchestrator-proposal.md)
|
|
7
|
+
2. [capability-research.md](./capability-research.md)
|
|
8
|
+
3. [notes.md](./notes.md)
|
|
9
|
+
|
|
10
|
+
## 门禁产物
|
|
11
|
+
1. [capability-probe-spec.md](./capability-probe-spec.md)
|
|
12
|
+
2. [adapter-contract-tests.md](./adapter-contract-tests.md)
|
|
13
|
+
3. [dry-run-plan.md](./dry-run-plan.md)
|
|
14
|
+
4. [implementation-gate-checklist.md](./implementation-gate-checklist.md)
|
|
15
|
+
|
|
16
|
+
## 接口冻结
|
|
17
|
+
1. [docs/implementation/step0-interface-freeze.md](./docs/implementation/step0-interface-freeze.md)
|
|
18
|
+
2. [docs/contracts/cli-json-v0.1.x.md](./docs/contracts/cli-json-v0.1.x.md)
|
|
19
|
+
3. [docs/contracts/provider-permissions-v0.1.x.md](./docs/contracts/provider-permissions-v0.1.x.md)
|
|
20
|
+
|
|
21
|
+
## 计划与跟踪
|
|
22
|
+
1. [task_plan.md](./task_plan.md)
|
|
23
|
+
|
|
24
|
+
## 发布说明
|
|
25
|
+
1. [docs/releases/v0.1.2.md](./docs/releases/v0.1.2.md)
|
|
26
|
+
2. [docs/releases/v0.1.2.zh-CN.md](./docs/releases/v0.1.2.zh-CN.md)
|
|
27
|
+
3. [docs/releases/v0.1.1.md](./docs/releases/v0.1.1.md)
|
|
28
|
+
4. [docs/releases/v0.1.1.zh-CN.md](./docs/releases/v0.1.1.zh-CN.md)
|
|
29
|
+
5. [docs/releases/v0.1.0.md](./docs/releases/v0.1.0.md)
|
|
30
|
+
6. [docs/releases/v0.1.0.zh-CN.md](./docs/releases/v0.1.0.zh-CN.md)
|
|
31
|
+
|
|
32
|
+
## 统一 CLI(Step 2)
|
|
33
|
+
`mco review`:统一的审查入口。
|
|
34
|
+
`mco run`:通用任务执行入口(不强制 findings schema)。
|
|
35
|
+
|
|
36
|
+
## 安装
|
|
37
|
+
|
|
38
|
+
npm 包装器(当前可用,系统需有 Python 3):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm i -g @tt-a1i/mco
|
|
42
|
+
mco --help
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
源码可编辑安装:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git clone https://github.com/tt-a1i/mco.git
|
|
49
|
+
cd mco
|
|
50
|
+
python3 -m pip install -e .
|
|
51
|
+
mco --help
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Python 包(PyPI):
|
|
55
|
+
- 目前尚未发布。
|
|
56
|
+
- 发布流程已就绪,待完成 PyPI Trusted Publisher 配置后开启。
|
|
57
|
+
|
|
58
|
+
快速开始:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
./mco review \
|
|
62
|
+
--repo . \
|
|
63
|
+
--prompt "Review this repository for high-risk bugs and security issues." \
|
|
64
|
+
--providers claude,codex
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
机器可读输出:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
./mco review --repo . --prompt "Review for bugs." --providers claude,codex --json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
仅 stdout 输出结果(不写 `summary.md/decision.md/findings.json/run.json`):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
./mco review --repo . --prompt "Review for bugs." --providers claude,codex --result-mode stdout --json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
通用 run 模式:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
./mco run --repo . --prompt "Summarize the current repo architecture." --providers claude,codex --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
配置文件(JSON):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"providers": ["claude", "codex"],
|
|
90
|
+
"artifact_base": "reports/review",
|
|
91
|
+
"state_file": ".mco/state.json",
|
|
92
|
+
"policy": {
|
|
93
|
+
"timeout_seconds": 180,
|
|
94
|
+
"stall_timeout_seconds": 900,
|
|
95
|
+
"poll_interval_seconds": 1.0,
|
|
96
|
+
"review_hard_timeout_seconds": 1800,
|
|
97
|
+
"enforce_findings_contract": false,
|
|
98
|
+
"max_retries": 1,
|
|
99
|
+
"high_escalation_threshold": 1,
|
|
100
|
+
"require_non_empty_findings": true,
|
|
101
|
+
"max_provider_parallelism": 0,
|
|
102
|
+
"allow_paths": [".", "runtime", "scripts"],
|
|
103
|
+
"enforcement_mode": "strict",
|
|
104
|
+
"provider_permissions": {
|
|
105
|
+
"claude": {
|
|
106
|
+
"permission_mode": "plan"
|
|
107
|
+
},
|
|
108
|
+
"codex": {
|
|
109
|
+
"sandbox": "workspace-write"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"provider_timeouts": {
|
|
113
|
+
"claude": 300,
|
|
114
|
+
"codex": 240,
|
|
115
|
+
"qwen": 240
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
按配置运行:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
./mco review --config ./mco.example.json --repo . --prompt "Review for bugs and security issues."
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
CLI 覆盖并发与超时:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
./mco review \
|
|
131
|
+
--repo . \
|
|
132
|
+
--prompt "Review for bugs and security issues." \
|
|
133
|
+
--providers claude,codex,gemini,opencode,qwen \
|
|
134
|
+
--strict-contract \
|
|
135
|
+
--max-provider-parallelism 2 \
|
|
136
|
+
--stall-timeout 900 \
|
|
137
|
+
--review-hard-timeout 1800 \
|
|
138
|
+
--provider-timeouts qwen=900,codex=900
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
带路径硬约束的 run 模式:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
./mco run \
|
|
145
|
+
--repo . \
|
|
146
|
+
--prompt "Compare adapter behaviors and return a short markdown summary." \
|
|
147
|
+
--providers claude,codex \
|
|
148
|
+
--allow-paths runtime,scripts \
|
|
149
|
+
--target-paths runtime/adapters,runtime/review_engine.py \
|
|
150
|
+
--enforcement-mode strict \
|
|
151
|
+
--provider-permissions-json '{"codex":{"sandbox":"workspace-write"},"claude":{"permission_mode":"plan"}}' \
|
|
152
|
+
--json
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
产物目录:
|
|
156
|
+
- `<artifact_base>/<task_id>/summary.md`
|
|
157
|
+
- `<artifact_base>/<task_id>/decision.md`
|
|
158
|
+
- `<artifact_base>/<task_id>/findings.json`
|
|
159
|
+
- `<artifact_base>/<task_id>/run.json`
|
|
160
|
+
- `<artifact_base>/<task_id>/providers/*.json`
|
|
161
|
+
- `<artifact_base>/<task_id>/raw/*.log`
|
|
162
|
+
|
|
163
|
+
`run.json` 审计字段:
|
|
164
|
+
- `effective_cwd`
|
|
165
|
+
- `allow_paths_hash`
|
|
166
|
+
- `permissions_hash`
|
|
167
|
+
|
|
168
|
+
说明:
|
|
169
|
+
- YAML 配置需要 `pyyaml`;否则使用 JSON 配置。
|
|
170
|
+
- review 提示词会附加 JSON finding 合同;是否强制由策略控制。
|
|
171
|
+
- 可用 `--strict-contract`(或配置 `policy.enforce_findings_contract=true`)开启严格合同。
|
|
172
|
+
- `run` 模式不强制 findings schema,聚焦执行与聚合。
|
|
173
|
+
- `result_mode=artifact`(默认):写产物并输出简报。
|
|
174
|
+
- `result_mode=stdout`:输出 provider 结果,不写用户侧产物。
|
|
175
|
+
- `result_mode=both`:既写产物又输出 provider 结果。
|
|
176
|
+
- 执行模型为 `wait-all`:单 provider 失败/超时不会中断其它 provider。
|
|
177
|
+
- 超时是进度驱动:
|
|
178
|
+
- `stall_timeout_seconds`:仅在长时间无输出进展时取消。
|
|
179
|
+
- `review_hard_timeout_seconds`:仅 `review` 模式硬截止。
|
|
180
|
+
- `max_provider_parallelism=0`(或省略)表示全并行。
|
|
181
|
+
- `provider_timeouts` 为 provider 级 stall-timeout 覆盖项。
|
|
182
|
+
- `allow_paths` 与 `target_paths` 会对 `repo_root` 做越界校验。
|
|
183
|
+
- `enforcement_mode=strict`(默认)下,权限要求不满足会 fail-closed。
|
|
184
|
+
|
|
185
|
+
## Step5 性能脚本
|
|
186
|
+
用于产出串行 vs 全并行对比报告(写入 `reports/adapter-contract/<date>/`):
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
./scripts/run_step5_parallel_benchmark.sh
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
生成的 summary JSON 包含:
|
|
193
|
+
- `providers_total`
|
|
194
|
+
- `parse_success_rate`
|
|
195
|
+
- `effective_findings_count`
|
|
196
|
+
- `zero_finding_provider_count`
|
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
|
@@ -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
|
|
|
@@ -263,7 +266,11 @@ def main(argv: List[str] | None = None) -> int:
|
|
|
263
266
|
parser.error("unsupported command")
|
|
264
267
|
return 2
|
|
265
268
|
|
|
266
|
-
|
|
269
|
+
try:
|
|
270
|
+
cfg = _resolve_config(args)
|
|
271
|
+
except (FileNotFoundError, RuntimeError, ValueError) as exc:
|
|
272
|
+
print(f"Configuration error: {exc}", file=sys.stderr)
|
|
273
|
+
return 2
|
|
267
274
|
repo_root = str(Path(args.repo).resolve())
|
|
268
275
|
providers = [item for item in cfg.providers if item in ("claude", "codex", "gemini", "opencode", "qwen")]
|
|
269
276
|
if not providers:
|
|
@@ -283,7 +290,11 @@ def main(argv: List[str] | None = None) -> int:
|
|
|
283
290
|
)
|
|
284
291
|
review_mode = args.command == "review"
|
|
285
292
|
write_artifacts = args.result_mode in ("artifact", "both")
|
|
286
|
-
|
|
293
|
+
try:
|
|
294
|
+
result = run_review(req, review_mode=review_mode, write_artifacts=write_artifacts)
|
|
295
|
+
except ValueError as exc:
|
|
296
|
+
print(f"Input error: {exc}", file=sys.stderr)
|
|
297
|
+
return 2
|
|
287
298
|
|
|
288
299
|
payload = {
|
|
289
300
|
"command": args.command,
|
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:
|