codebee 0.1.73 → 0.1.74

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/CHANGELOG.md CHANGED
@@ -38,6 +38,12 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
38
38
  - 调度层加固:绑定链上的「纯模型条目」(只配模型名、没挂供应商)起跑前即被点名——所有 CLI 起跑日志新增凭据模式审计行(绑定链注入/无注入/本机默认,密钥值绝不入日志),缺凭据+登录闸门类错误按 CLI 附修复指引;不再只有 claude 有这层观测(2026-09-25 Mac claude 实案的三层加固通用化)。
39
39
  - **Mac 桌宠不再是「白板蜜蜂」**:macOS 的 Tk 没有逐像素窗口透明(`-transparentcolor` 是 Windows 专属),蜜蜂的矩形底板整个露出来还带系统边框(实拍=一块白板)。现在非 Windows 平台把底板画成深色卡片(对齐悬停清单面板色),桌宠呈「桌面小组件」形态;睡觉/失联的状态滤镜改为只作用于蜂体(原先整帧压暗靠键色噪点兜底,卡片底兜不回来会浮出一块更暗的小框)。Windows 逐像素透明不受影响。
40
40
 
41
+ ## v0.1.74(2026-09-30)
42
+
43
+ - 新增 `TOOL_LAUNCH` 错误码:本地 CLI 启动失败(找不到可执行文件)与供应商故障彻底区分——此前这类失败被记成供应商健康故障,把健康的 Bigmodel 评测作废、整链判死(claude 垫片被清实案)
44
+ - runner:Popen 异常统一包裹前缀「启动失败」零误判识别,先于一切上游分类
45
+ - 评测/重探间隔与失败探针刷新调优
46
+
41
47
  ## v0.1.73(2026-09-30)
42
48
 
43
49
  - **建书假「已建书」三修**(我的马甲藏不住实案):建书未取得 book_id 先按书名对账补回、仍空则**不落台账**(空条目曾让界面亮「已建书」);校准遇空 book_id 先对账、找不回明报「可能尚未建成」,不再盲跑作品列表页误报「可能改版」;七猫补齐对账通道(resolve_book_id)
package/README.md CHANGED
@@ -33,10 +33,10 @@ Trae Agent……),提供
33
33
  ---
34
34
 
35
35
  <!-- relnotes:start -->
36
- ### 最新版更新内容(v0.1.73)
36
+ ### 最新版更新内容(v0.1.74)
37
37
 
38
- - 建书假「已建书」三修:book_id 空时按书名对账补回、找不回不落台账不亮「已建书」、七猫补齐对账通道
39
- - 扫榜选材与排名扫描增强(flows/paihang)
38
+ - 新增 TOOL_LAUNCH 错误码:本地 CLI 启动失败与供应商故障彻底区分,不再误作废健康评测
39
+ - 评测/重探间隔与失败探针刷新调优
40
40
  <!-- relnotes:end -->
41
41
 
42
42
 
@@ -0,0 +1,38 @@
1
+ """Task cost/quality rollups for operator views."""
2
+ from __future__ import annotations
3
+
4
+ from collections import defaultdict
5
+
6
+
7
+ def summary(store):
8
+ by_task = defaultdict(lambda: {"runs": 0, "done": 0, "failed": 0,
9
+ "tokens": 0, "cost_usd": 0.0, "quality_sum": 0.0,
10
+ "quality_count": 0})
11
+ for run in store.list_runs(limit=None):
12
+ tid = run.get("task_id") or "_unowned"
13
+ row = by_task[tid]
14
+ row["runs"] += 1
15
+ row["done"] += int(run.get("status") == "done")
16
+ row["failed"] += int(run.get("status") == "failed")
17
+ row["tokens"] += int(run.get("tokens") or 0)
18
+ row["cost_usd"] += float(run.get("cost_usd") or 0)
19
+ verdict = run.get("verdict") or {}
20
+ quality = verdict.get("overall")
21
+ if isinstance(quality, (int, float)):
22
+ row["quality_sum"] += float(quality)
23
+ row["quality_count"] += 1
24
+ rows = []
25
+ for task_id, row in by_task.items():
26
+ item = dict(row)
27
+ item["task_id"] = task_id
28
+ item["cost_usd"] = round(item["cost_usd"], 6)
29
+ item["quality_avg"] = round(item.pop("quality_sum") / item.pop("quality_count"), 2) \
30
+ if item["quality_count"] else None
31
+ item.pop("quality_count", None)
32
+ rows.append(item)
33
+ rows.sort(key=lambda x: (-x["cost_usd"], x["task_id"]))
34
+ return {"tasks": rows, "totals": {
35
+ "runs": sum(x["runs"] for x in rows),
36
+ "tokens": sum(x["tokens"] for x in rows),
37
+ "cost_usd": round(sum(x["cost_usd"] for x in rows), 6),
38
+ }}
@@ -31,20 +31,6 @@ def template_only():
31
31
  return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
32
32
 
33
33
 
34
- def needs_book_meta(task):
35
- """这本书是否需要开书资料:连载首批(start_chapter <= 1)才需要。
36
-
37
- 开书资料属于「这本书」而不是某一批章节——续写批次沿用第一批的书名/简介/
38
- 标签即可,重复生成只会覆盖成同内容的近义版本。非连载任务一律不需要。"""
39
- serial = (task or {}).get("serial")
40
- if not isinstance(serial, dict):
41
- return False
42
- try:
43
- return int(serial.get("start_chapter") or 1) <= 1
44
- except (TypeError, ValueError):
45
- return True
46
-
47
-
48
34
  # 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
49
35
  # 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
50
36
  # +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
@@ -0,0 +1,255 @@
1
+ """Durable task contracts, evidence, approvals and completion receipts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ from . import paths
12
+
13
+ _DIR = paths.DATA_DIR / "contracts"
14
+ _LOCK = threading.RLock()
15
+ _ID_RE = re.compile(r"^[A-Za-z][0-9A-Za-z_-]{0,99}$")
16
+ _CRITERIA_MAX = 40
17
+ _EVIDENCE_MAX = 300
18
+ _MISSING = object()
19
+ _CASE_KEYWORDS = {
20
+ "correctness": ("正确", "验证", "测试", "质量", "correct", "test", "verify"),
21
+ "performance": ("性能", "耗时", "成本", "token", "performance", "latency", "cost"),
22
+ "boundary": ("边界", "极端", "boundary", "edge"),
23
+ "exception": ("异常", "失败处理", "容错", "exception", "error", "failure"),
24
+ "readability": ("可读", "文档", "表达", "readability", "documentation"),
25
+ "security": ("安全", "权限", "注入", "security", "auth", "injection"),
26
+ }
27
+
28
+
29
+ def _valid_id(task_id):
30
+ return bool(_ID_RE.fullmatch(str(task_id or "")))
31
+
32
+
33
+ def _path(task_id):
34
+ if not _valid_id(task_id):
35
+ raise ValueError("任务 ID 无效")
36
+ return _DIR / (str(task_id) + ".json")
37
+
38
+
39
+ def _now():
40
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
41
+
42
+
43
+ def _save(item):
44
+ p = _path(item["task_id"])
45
+ p.parent.mkdir(parents=True, exist_ok=True)
46
+ tmp = p.with_suffix(".tmp")
47
+ tmp.write_text(json.dumps(item, ensure_ascii=False, indent=2), encoding="utf-8")
48
+ tmp.replace(p)
49
+
50
+
51
+ def _load(task_id):
52
+ try:
53
+ value = json.loads(_path(task_id).read_text(encoding="utf-8"))
54
+ return value if isinstance(value, dict) else None
55
+ except (OSError, ValueError):
56
+ return None
57
+
58
+
59
+ def _actor(value):
60
+ return str(value or "local").strip()[:120] or "local"
61
+
62
+
63
+ def create(task_id, payload=None):
64
+ """Create or update a task's contract, preserving evidence and audit history."""
65
+ payload = payload if isinstance(payload, dict) else {}
66
+ raw_criteria = payload.get("acceptance_criteria", _MISSING)
67
+ if raw_criteria is not _MISSING and not isinstance(raw_criteria, list):
68
+ raise ValueError("acceptance_criteria 必须是列表")
69
+ clean = []
70
+ for value in (raw_criteria if raw_criteria is not _MISSING else [])[:_CRITERIA_MAX]:
71
+ text = str(value or "").strip()[:500]
72
+ if text and text not in clean:
73
+ clean.append(text)
74
+ with _LOCK:
75
+ item = _load(task_id) or {
76
+ "version": 1, "task_id": str(task_id), "created_at": _now(),
77
+ "acceptance_criteria": [], "evidence": [], "approval_required": False,
78
+ "approval": None, "blocked_reason": "", "completion_receipt": None,
79
+ "audit": [], "status": "open",
80
+ }
81
+ # Partial updates must not erase an existing contract field merely
82
+ # because the caller omitted it (MCP/UI frequently send sparse patches).
83
+ if raw_criteria is not _MISSING:
84
+ item["acceptance_criteria"] = clean
85
+ if "approval_required" in payload:
86
+ approval_required = payload.get("approval_required")
87
+ if not isinstance(approval_required, bool):
88
+ raise ValueError("approval_required 必须是布尔值")
89
+ item["approval_required"] = approval_required
90
+ item["updated_at"] = _now()
91
+ item["audit"].append({"at": item["updated_at"], "actor": _actor(payload.get("actor")),
92
+ "action": "contract_updated",
93
+ "criteria_count": len(item.get("acceptance_criteria") or [])})
94
+ _save(item)
95
+ return item
96
+
97
+
98
+ def get(task_id):
99
+ with _LOCK:
100
+ return _load(task_id)
101
+
102
+
103
+ def add_evidence(task_id, evidence, actor="local"):
104
+ if not isinstance(evidence, dict):
105
+ raise ValueError("证据必须是对象")
106
+ status = str(evidence.get("status") or "unknown").lower()
107
+ if status not in ("passed", "failed", "unknown", "not_evaluated"):
108
+ raise ValueError("证据状态无效")
109
+ with _LOCK:
110
+ item = _load(task_id)
111
+ if not item:
112
+ item = create(task_id, {})
113
+ row = {"id": "ev-%s" % uuid.uuid4().hex[:20],
114
+ "criterion": str(evidence.get("criterion") or "")[:500], "status": status,
115
+ "summary": str(evidence.get("summary") or "")[:2000],
116
+ "source": str(evidence.get("source") or "manual")[:80], "at": _now(),
117
+ "actor": _actor(actor)}
118
+ item["evidence"].append(row)
119
+ item["evidence"] = item["evidence"][-_EVIDENCE_MAX:]
120
+ item["audit"].append({"at": row["at"], "actor": row["actor"],
121
+ "action": "evidence_added", "evidence_id": row["id"]})
122
+ item["updated_at"] = row["at"]
123
+ _save(item)
124
+ return item
125
+
126
+
127
+ def record_acceptance_cases(task_id, cases, actor="system"):
128
+ """Map acceptance-matrix cases onto matching user criteria.
129
+
130
+ Matching is deliberately conservative: only criteria containing a known
131
+ case keyword (or the explicit ``acceptance:<case>`` form) are linked. Any
132
+ unmatched criterion remains unverified rather than being guessed green.
133
+ """
134
+ if not isinstance(cases, list):
135
+ return get(task_id)
136
+ item = get(task_id)
137
+ if not item:
138
+ return None
139
+ criteria = item.get("acceptance_criteria") or []
140
+ for case in cases:
141
+ if not isinstance(case, dict):
142
+ continue
143
+ name = str(case.get("case") or "").strip().lower()
144
+ status = str(case.get("status") or "unknown").lower()
145
+ if name not in _CASE_KEYWORDS or status not in ("passed", "failed", "unknown", "not_evaluated"):
146
+ continue
147
+ for criterion in criteria:
148
+ text = str(criterion or "").strip()
149
+ low = text.lower()
150
+ explicit = low == "acceptance:" + name
151
+ matched = explicit or any(keyword.lower() in low for keyword in _CASE_KEYWORDS[name])
152
+ if not matched:
153
+ continue
154
+ add_evidence(task_id, {"criterion": text, "status": status,
155
+ "summary": case.get("reason") or
156
+ "acceptance matrix: " + name,
157
+ "source": "acceptance"}, actor=actor)
158
+ return get(task_id)
159
+
160
+
161
+ def _decision(task_id, status, actor, note):
162
+ with _LOCK:
163
+ item = _load(task_id)
164
+ if not item:
165
+ return None, "任务契约不存在"
166
+ decision = {"status": status, "actor": _actor(actor),
167
+ "note": str(note or "").strip()[:1000], "at": _now()}
168
+ item["approval"] = decision
169
+ item["status"] = "approved" if status == "approved" else "open"
170
+ item["audit"].append(dict(decision, action=status))
171
+ item["updated_at"] = decision["at"]
172
+ _save(item)
173
+ return item, None
174
+
175
+
176
+ def approve(task_id, actor="local", note=""):
177
+ return _decision(task_id, "approved", actor, note)
178
+
179
+
180
+ def reject(task_id, actor="local", note=""):
181
+ if not str(note or "").strip():
182
+ return None, "拒绝审批必须说明原因"
183
+ return _decision(task_id, "rejected", actor, note)
184
+
185
+
186
+ def set_blocked(task_id, reason, actor="local"):
187
+ reason = str(reason or "").strip()[:1000]
188
+ if not reason:
189
+ return None, "Blocked 状态必须填写原因"
190
+ with _LOCK:
191
+ item = _load(task_id)
192
+ if not item:
193
+ return None, "任务契约不存在"
194
+ item["status"] = "blocked"
195
+ item["blocked_reason"] = reason
196
+ item["updated_at"] = _now()
197
+ item["audit"].append({"at": item["updated_at"], "actor": _actor(actor),
198
+ "action": "blocked", "reason": reason})
199
+ _save(item)
200
+ return item, None
201
+
202
+
203
+ def release_allowed(task_id):
204
+ item = get(task_id)
205
+ # Task creation persists the approval bit redundantly for backwards
206
+ # compatibility. Combine both sources with OR so a damaged/tampered
207
+ # sidecar cannot downgrade an approval-protected task to releasable.
208
+ try:
209
+ from . import store
210
+ task = store.get_task(task_id) or {}
211
+ except Exception:
212
+ task = {}
213
+ requires = bool((item or {}).get("approval_required")) or bool(task.get("approval_required"))
214
+ if not requires:
215
+ return True
216
+ if item is None:
217
+ return False
218
+ approval = item.get("approval") or {}
219
+ return approval.get("status") == "approved"
220
+
221
+
222
+ def complete(task_id, run):
223
+ if not isinstance(run, dict) or run.get("status") not in ("done", "failed", "cancelled", "timeout"):
224
+ return None, "完成回执只能关联终态运行"
225
+ with _LOCK:
226
+ item = _load(task_id)
227
+ if not item:
228
+ item = create(task_id, {})
229
+ criteria = item.get("acceptance_criteria") or []
230
+ evidence = item.get("evidence") or []
231
+ latest = {}
232
+ for row in evidence:
233
+ if row.get("criterion"):
234
+ latest[row["criterion"]] = row
235
+ checked = [latest.get(c) for c in criteria]
236
+ receipt = {"version": 1, "task_id": str(task_id), "run_id": run.get("id"),
237
+ "run_status": run.get("status"), "criteria_total": len(criteria),
238
+ "criteria_passed": sum(1 for row in checked if row and row.get("status") == "passed"),
239
+ "criteria_failed": sum(1 for row in checked if row and row.get("status") == "failed"),
240
+ "criteria_unverified": sum(1 for row in checked if not row or row.get("status") not in ("passed", "failed")),
241
+ "evidence_ids": [row.get("id") for row in checked if row],
242
+ "cost_usd": float(run.get("cost_usd") or 0), "tokens": int(run.get("tokens") or 0),
243
+ "completed_at": _now()}
244
+ # Completion is idempotent for the same run. A late duplicate status
245
+ # write must not manufacture a second audit event or mutate the
246
+ # original cost/token receipt.
247
+ previous = item.get("completion_receipt") or {}
248
+ if previous.get("run_id") == receipt["run_id"]:
249
+ return previous, None
250
+ item["completion_receipt"] = receipt
251
+ item["audit"].append({"at": receipt["completed_at"], "actor": "system",
252
+ "action": "completion_receipt_created", "run_id": run.get("id")})
253
+ item["updated_at"] = receipt["completed_at"]
254
+ _save(item)
255
+ return receipt, None
@@ -37,6 +37,7 @@ class ErrorCode(str, Enum):
37
37
  RATE_LIMIT = "RATE_LIMIT" # 短时速率/并发限制(429)
38
38
  LOCAL_STATE = "LOCAL_STATE" # 本地 CLI 状态/锁冲突
39
39
  CONTEXT_OVERFLOW = "CONTEXT_OVERFLOW" # 触发压缩(与 MAX_TOKENS 不同)
40
+ TOOL_LAUNCH = "TOOL_LAUNCH" # 本地 CLI 启动失败(找不到可执行文件)
40
41
 
41
42
  # === ok ===
42
43
  # "" 表示成功,不设枚举值
@@ -91,6 +92,11 @@ _NETWORK = ("stream disconnected", "stream closed", "connection reset", "connect
91
92
  "输出停滞")
92
93
  _LOCAL_STATE = ("database is locked", "database locked", "sqlite busy",
93
94
  "unable to open database file")
95
+ # run_process 对 Popen 异常的统一包裹前缀("启动失败: %r")。供应商侧任何
96
+ # 报错都不会长这个样子,所以单靠前缀即可零误判地识别「本地工具链起不来」。
97
+ # 2026-09-30 claude 垫片被清案:这类失败被记成供应商健康故障,把健康的
98
+ # Bigmodel 评测作废、整链判死——必须先于一切上游分类识别。
99
+ _TOOL_LAUNCH = ("启动失败",)
94
100
  _TRANSIENT_HINTS = ("stream closed before response.completed", "output stall", "timed out",
95
101
  "timeout", "output stalled", "ENOTFOUND", "Reconnecting...",
96
102
  "initialize", "[1211]")
@@ -116,6 +122,8 @@ def classify_error_text(error):
116
122
  text = str(error or "").lower()
117
123
  if not text:
118
124
  return None
125
+ if any(marker in text for marker in _TOOL_LAUNCH):
126
+ return ErrorCode.TOOL_LAUNCH
119
127
  if re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*)401\b", text):
120
128
  return ErrorCode.AUTH
121
129
  # An explicit HTTP 403 is authoritative even when the provider describes the
@@ -196,6 +204,7 @@ _SAFE_ERROR_SUMMARIES = {
196
204
  ErrorCode.RATE_LIMIT: "上游触发速率或并发限制",
197
205
  ErrorCode.LOCAL_STATE: "CLI 本地状态或数据库锁冲突",
198
206
  ErrorCode.CONTEXT_OVERFLOW: "输入上下文超出模型容量",
207
+ ErrorCode.TOOL_LAUNCH: "本地 CLI 启动失败(可执行文件不存在,与供应商无关)",
199
208
  }
200
209
 
201
210
 
@@ -143,9 +143,22 @@ def report_success(provider: str):
143
143
 
144
144
  def report_failure(provider: str, error: str = "", *, model: str = "",
145
145
  provider_id: str = ""):
146
- """一次真实失败调用。provider 用展示名(与 usage 台账一致)。"""
146
+ """一次真实失败调用。provider 用展示名(与 usage 台账一致)。
147
+
148
+ 本地 CLI 启动失败(TOOL_LAUNCH:找不到可执行文件等)与供应商无关,
149
+ 记进来会把健康供应商的评测结论作废、链被解析层过滤成死链——
150
+ 2026-09-30 claude 垫片被清案:Bigmodel 全程健康却被连续启动失败
151
+ 拖进 down。只打日志提醒修本地环境,不进健康台账。"""
147
152
  if not provider:
148
153
  return
154
+ try:
155
+ from .error_codes import ErrorCode, classify_error_text
156
+ if classify_error_text(error) == ErrorCode.TOOL_LAUNCH:
157
+ log.warning("[health] 本地 CLI 启动失败(与供应商 %s 无关,不记健康故障):%s",
158
+ provider, str(error or "")[:160])
159
+ return
160
+ except Exception:
161
+ pass
149
162
 
150
163
  # A failed live call invalidates capability evidence for this upstream.
151
164
  # Keep this hook lazy and best-effort: health telemetry must never make a
package/app/core/jobs.py CHANGED
@@ -422,6 +422,56 @@ def _schedule_enqueue(job, delay_s):
422
422
  return True
423
423
 
424
424
 
425
+ def enqueue_now(run_id):
426
+ """立即重试:退避窗口内的续跑副本跳过等待马上入队(详情页按钮触发)。
427
+
428
+ 返回 (ok, err)。先摘到点 Timer 再直接 enqueue——CAS 认领保证与 Timer
429
+ 竞态时只有一方生效,Timer 抢先起跑按目的已达成处理。resume_enqueue_at
430
+ 同步清空:副本一旦入队/起跑,UI 不该再显示「将于 HH:MM 自动续跑」;
431
+ 满载转排队后由补跑心跳接管,巡检也把它当普通排队记录。跳过退避是
432
+ 用户的主动选择(等效于手动重试),不受「别烧退避窗口」的巡检约束。"""
433
+ from . import store
434
+ run = store.get_run(run_id)
435
+ if not run:
436
+ return False, "运行不存在"
437
+ if run.get("status") != "queued":
438
+ return False, "该运行已不在排队等待中"
439
+ if not str(run.get("resume_enqueue_at") or ""):
440
+ return False, "该运行不在自动续跑等待中"
441
+ if str(run.get("kind") or "orchestration") != "orchestration" or not run.get("task_id"):
442
+ return False, "该运行不支持立即重试"
443
+ resume_at = str(run.get("resume_enqueue_at") or "")
444
+ with _timer_lock:
445
+ old = _deferred_timers.pop(run_id, None)
446
+ if old is not None:
447
+ old.cancel()
448
+ if store.update_run(run_id, resume_enqueue_at="") is None:
449
+ if old is not None:
450
+ _schedule_enqueue({"kind": "orchestration", "run_id": run_id,
451
+ "task_id": run.get("task_id")}, 1.0)
452
+ return False, "运行状态已变化,请刷新后重试"
453
+ try:
454
+ enqueue({"kind": "orchestration", "run_id": run_id,
455
+ "task_id": run.get("task_id")})
456
+ except DuplicateJobError:
457
+ pass # 到点 Timer 抢先起跑/运行已被取消接管:无需再排
458
+ except Exception as exc:
459
+ # A non-duplicate enqueue failure must not strand the run with the
460
+ # backoff marker cleared and its original timer removed.
461
+ current = store.get_run(run_id)
462
+ if current and current.get("status") == "queued":
463
+ store.update_run(run_id, resume_enqueue_at=resume_at)
464
+ try:
465
+ import time as _time
466
+ due = _time.mktime(_time.strptime(resume_at, "%Y-%m-%d %H:%M:%S"))
467
+ _schedule_enqueue({"kind": "orchestration", "run_id": run_id,
468
+ "task_id": run.get("task_id")}, max(0.0, due - _time.time()))
469
+ except Exception:
470
+ pass
471
+ return False, "立即重试失败,已恢复自动续跑:%s" % str(exc)[:160]
472
+ return True, ""
473
+
474
+
425
475
  def restore_deferred_resumes(limit=None, now=None):
426
476
  """重建重启前的自动续跑退避 Timer;返回成功恢复的数量。"""
427
477
  import time as _t
@@ -589,6 +589,30 @@ def read_model(entry):
589
589
  return None
590
590
 
591
591
 
592
+ def _atomic_write_bytes(path, data):
593
+ """CLI 自家配置写盘统一走临时文件 + os.replace 原子替换。
594
+
595
+ 就地 write_bytes 有「截断→重写」窗口:并行评审爆发期多个 CLI 同时被
596
+ 拉起(bind_agent 先同步配置再 spawn),正在启动的 CLI 恰好读配置就会
597
+ 读到半截文件——opencode 对残缺 jsonc 的反应正是打一行横幅就退出码 1
598
+ (2026-09-30 14:56 实案)。os.replace 原地换名,读者要么见旧要么见新。
599
+ """
600
+ path = str(path)
601
+ d = os.path.dirname(path) or "."
602
+ fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".",
603
+ suffix=".tmp", dir=d)
604
+ try:
605
+ with os.fdopen(fd, "wb") as fh:
606
+ fh.write(data)
607
+ os.replace(tmp, path)
608
+ except Exception:
609
+ try:
610
+ os.remove(tmp)
611
+ except OSError:
612
+ pass
613
+ raise
614
+
615
+
592
616
  def write_model(entry, model):
593
617
  """写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
594
618
  json / json-path / jsonc / yaml-line。"""
@@ -623,7 +647,7 @@ def write_model(entry, model):
623
647
  # settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
624
648
  # 内置默认」);它正是用户层覆盖的落点,缺了按需创建
625
649
  Path(path).parent.mkdir(parents=True, exist_ok=True)
626
- Path(path).write_bytes(b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
650
+ _atomic_write_bytes(path,b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
627
651
  if fmt == "jsonc":
628
652
  # mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
629
653
  # _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
@@ -634,7 +658,7 @@ def write_model(entry, model):
634
658
  if not ok:
635
659
  return {"ok": False,
636
660
  "error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
637
- Path(path).write_bytes(new_text.encode("utf-8"))
661
+ _atomic_write_bytes(path,new_text.encode("utf-8"))
638
662
  elif fmt == "toml-line":
639
663
  text = _read_text(path)
640
664
  new_line = 'model = "%s"' % model
@@ -642,17 +666,17 @@ def write_model(entry, model):
642
666
  text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
643
667
  else:
644
668
  text = text.rstrip("\n") + "\n" + new_line + "\n"
645
- Path(path).write_bytes(text.encode("utf-8"))
669
+ _atomic_write_bytes(path,text.encode("utf-8"))
646
670
  elif fmt == "toml-section":
647
671
  text = _read_text(path, preserve_newlines=True)
648
672
  text = _toml_write_value(text, *_dotted_key(cfg), value=model)
649
- Path(path).write_bytes(text.encode("utf-8"))
673
+ _atomic_write_bytes(path,text.encode("utf-8"))
650
674
  elif fmt == "yaml-line":
651
675
  # newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
652
676
  # 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
653
677
  text = _read_text(path, preserve_newlines=True)
654
678
  text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
655
- Path(path).write_bytes(text.encode("utf-8"))
679
+ _atomic_write_bytes(path,text.encode("utf-8"))
656
680
  elif fmt == "json-path":
657
681
  # openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
658
682
  # 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
@@ -662,7 +686,7 @@ def write_model(entry, model):
662
686
  except Exception:
663
687
  return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
664
688
  _json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
665
- Path(path).write_bytes(
689
+ _atomic_write_bytes(path,
666
690
  json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
667
691
  else:
668
692
  try:
@@ -673,7 +697,7 @@ def write_model(entry, model):
673
697
  except Exception:
674
698
  return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
675
699
  data["model"] = model
676
- Path(path).write_bytes(
700
+ _atomic_write_bytes(path,
677
701
  json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
678
702
  return {"ok": True, "model": read_model(entry)}
679
703
  except Exception as e:
@@ -835,7 +859,7 @@ def _sync_dsh_settings(entry, model, base_url):
835
859
  if text2 != text:
836
860
  if os.path.isfile(path):
837
861
  shutil.copyfile(path, path + ".bak")
838
- Path(path).write_bytes(text2.encode("utf-8"))
862
+ _atomic_write_bytes(path,text2.encode("utf-8"))
839
863
  return None
840
864
  except Exception as e:
841
865
  return repr(e)
@@ -992,7 +1016,7 @@ def _sync_codex_settings(entry, model, cp):
992
1016
  if text2 != text:
993
1017
  if os.path.isfile(path):
994
1018
  shutil.copyfile(path, path + ".bak")
995
- Path(path).write_bytes(text2.encode("utf-8"))
1019
+ _atomic_write_bytes(path,text2.encode("utf-8"))
996
1020
  return None
997
1021
  except Exception as e:
998
1022
  return repr(e)
@@ -1209,7 +1233,7 @@ def _sync_settings_env(path, updates, remove_keys=()):
1209
1233
  data["env"] = env
1210
1234
  if os.path.isfile(path):
1211
1235
  shutil.copyfile(path, path + ".bak")
1212
- Path(path).write_bytes(json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1236
+ _atomic_write_bytes(path,json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1213
1237
  return None
1214
1238
  except Exception as e:
1215
1239
  return repr(e)
@@ -1321,7 +1345,7 @@ def _sync_mimo_settings(entry, model, prov):
1321
1345
  json.dumps("codebee/" + model))
1322
1346
  if not ok:
1323
1347
  return "mimocode.jsonc 结构异常,未能写入 model(已避免覆盖)"
1324
- Path(path).write_bytes(new_text.encode("utf-8"))
1348
+ _atomic_write_bytes(path,new_text.encode("utf-8"))
1325
1349
  return None
1326
1350
  except OSError as e:
1327
1351
  return "写入 mimocode.jsonc 失败:%s" % str(e)[:80]
@@ -1401,7 +1425,7 @@ def _sync_opencode_settings(entry, model, prov):
1401
1425
  json.dumps(top_perms))
1402
1426
  if os.path.isfile(path):
1403
1427
  shutil.copyfile(path, path + ".bak")
1404
- Path(path).write_bytes(new_text.encode("utf-8"))
1428
+ _atomic_write_bytes(path,new_text.encode("utf-8"))
1405
1429
  except Exception as e:
1406
1430
  errs.append("%s: %r" % (path, e))
1407
1431
  return ";".join(errs) or None
@@ -1475,7 +1499,7 @@ def _sync_kimi_settings(entry, model, prov):
1475
1499
  new_text = top + "\n" + body + "\n" + tables
1476
1500
  else:
1477
1501
  new_text = (body + "\n" if body else "") + managed
1478
- Path(path).write_bytes(new_text.encode("utf-8"))
1502
+ _atomic_write_bytes(path,new_text.encode("utf-8"))
1479
1503
  except Exception as e:
1480
1504
  errs.append("%s: %r" % (path, e))
1481
1505
  return ";".join(errs) or None
@@ -1516,7 +1540,7 @@ def _pi_write_json(path, mutate):
1516
1540
  if os.path.isfile(path):
1517
1541
  shutil.copyfile(path, path + ".bak")
1518
1542
  Path(path).parent.mkdir(parents=True, exist_ok=True)
1519
- Path(path).write_bytes(
1543
+ _atomic_write_bytes(path,
1520
1544
  json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1521
1545
  return None
1522
1546