codebee 0.1.21 → 0.1.23

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.
@@ -38,6 +38,7 @@ def _user_pack_dir():
38
38
 
39
39
  MAX_INJECT_CHARS = 9000 # 单次注入上限(防止提示词爆炸;七猫+番茄双平台包并存后上调)
40
40
  MAX_LESSONS_INJECT = 8 # 注入的自动教训条数上限
41
+ WILDCARD_PACK_CHAR_CAP = 2400 # wildcard(scope=*)包单包注入预算:市场通配技能动辄数万字,全文注入会挤掉项目教训
41
42
 
42
43
  # 自动教训的问题分类:闭集枚举,对齐评审维度。沉淀时由复盘官归类(兜底路径按评审
43
44
  # 维度关键词映射),UI 据此分类过滤查看。刻意保持小而稳,避免类别爆炸让过滤失去意义。
@@ -390,54 +391,16 @@ def relevance_top(lessons, task, limit):
390
391
  if not probe:
391
392
  return lessons[:limit]
392
393
 
393
- def rank(x):
394
- grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
395
- return (-len(probe & grams), x.get("id") or "")
396
-
397
394
  # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
398
- hits = _load_hits()
399
395
  def rank(x):
400
396
  grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
401
397
  overlap = -len(probe & grams)
402
398
  lid = x.get("id") or ""
403
- return (overlap, -hits.get(lid, 0), lid)
399
+ return (overlap, -int(x.get("hits") or 0), lid)
404
400
 
405
401
  return sorted(lessons, key=rank)[:limit]
406
402
 
407
403
 
408
- def _hits_path():
409
- return paths.DATA_DIR / "skill_hits.json"
410
-
411
-
412
- def _load_hits():
413
- """读取教训使用计数({lesson_id: 次数})。文件缺失/损坏返回空 dict。"""
414
- try:
415
- p = _hits_path()
416
- if p.is_file():
417
- import json
418
- return json.loads(p.read_text(encoding="utf-8"))
419
- except Exception:
420
- pass
421
- return {}
422
-
423
-
424
- def _bump_hits(ids):
425
- """注入后递增使用计数并持久化(fire-and-forget,失败静默)。"""
426
- try:
427
- import json
428
- p = _hits_path()
429
- hits = _load_hits()
430
- for lid in ids:
431
- if lid:
432
- hits[lid] = hits.get(lid, 0) + 1
433
- p.parent.mkdir(parents=True, exist_ok=True)
434
- tmp = p.with_suffix(".tmp")
435
- tmp.write_text(json.dumps(hits, ensure_ascii=False), encoding="utf-8")
436
- tmp.replace(p)
437
- except Exception:
438
- pass
439
-
440
-
441
404
  def block_for(task, scope_override=None, *, stable_order=False):
442
405
  """生成注入提示词的经验块。命中即计数。返回 (文本, 命中的 id 列表)。
443
406
 
@@ -446,9 +409,14 @@ def block_for(task, scope_override=None, *, stable_order=False):
446
409
  stable_order=True(docs/migration/07-token-cost.md T1.2'):教训按 id 排序
447
410
  而非 hits——hits 在任务中途变化会让技能块字节级不稳定,打碎供应商的
448
411
  前缀缓存(同一任务 8 章应看到完全相同的技能块)。内容不变,只稳排序。
412
+
413
+ 预算纪律(2026-09-21 巡检实锤引入):wildcard(scope=*)包动辄数万字,
414
+ 39 个全文注入会先把 9000 字全局上限吃光,项目教训排在末尾被整段截掉。
415
+ 两道预算:①wildcard 包单包限额(定向命中的包不受限);②教训保底——
416
+ 包区最多吃到「全局上限 − 教训长度」,教训永远完整注入。
449
417
  """
450
418
  scope = scope_override or task.get("type") or "*"
451
- parts, used = [], []
419
+ parts, used, lesson_ids = [], [], []
452
420
 
453
421
  for p in all_packs():
454
422
  if scope not in p["scopes"] and "*" not in p["scopes"]:
@@ -458,6 +426,9 @@ def block_for(task, scope_override=None, *, stable_order=False):
458
426
  txt = pack_text(p).strip()
459
427
  if not txt and not p.get("persona"):
460
428
  continue
429
+ # wildcard 包(仅靠 * 命中,非定向)单包限预算;定向命中不限,走全局
430
+ if scope not in p["scopes"] and len(txt) > WILDCARD_PACK_CHAR_CAP:
431
+ txt = txt[:WILDCARD_PACK_CHAR_CAP] + "\n…(本包超出通配注入预算已截断,完整内容见技能库)"
461
432
  # 3B:persona 独立成块(角色设定与规范正文分开,模型更易区分 obey 层级)
462
433
  if p.get("persona"):
463
434
  parts.append("### 【角色设定:%s】\n%s" % (p["name"], str(p["persona"]).strip()))
@@ -467,6 +438,7 @@ def block_for(task, scope_override=None, *, stable_order=False):
467
438
 
468
439
  lessons = relevance_top(list_lessons(scope, only_enabled=True), task,
469
440
  MAX_LESSONS_INJECT)
441
+ lesson_part = ""
470
442
  if lessons:
471
443
  if stable_order:
472
444
  lessons.sort(key=lambda x: x.get("id") or "")
@@ -474,17 +446,24 @@ def block_for(task, scope_override=None, *, stable_order=False):
474
446
  for x in lessons:
475
447
  lines.append("- **%s**:%s" % (x["title"], x["content"]))
476
448
  used.append(x["id"])
477
- parts.append("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n" + "\n".join(lines))
449
+ lesson_ids.append(x["id"])
450
+ lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n"
451
+ + "\n".join(lines))
478
452
 
479
- if not parts:
453
+ if not parts and not lesson_part:
480
454
  return "", []
481
- text = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n" + "\n\n".join(parts)
455
+
456
+ header = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n"
457
+ budget = max(600, MAX_INJECT_CHARS - len(lesson_part))
458
+ body = "\n\n".join(parts)
459
+ if len(body) > budget:
460
+ body = body[:budget] + "\n…(包区已按预算截断,优先保住项目教训)"
461
+ body = (body + "\n\n" + lesson_part) if (body and lesson_part) else (body or lesson_part)
462
+ text = header + body
482
463
  if len(text) > MAX_INJECT_CHARS:
483
464
  text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
484
- if used:
485
- _bump_hits(used) # 使用反馈闭环:被选中的教训递增计数,下次排序升权
486
- if used:
487
- bump_hits(used)
465
+ if lesson_ids:
466
+ bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
488
467
  return text, used
489
468
 
490
469
 
@@ -0,0 +1,87 @@
1
+ # -*- coding: utf-8 -*-
2
+ """任务编译器:把用户任务和预置流程编译成统一、可审计的运行规格。"""
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from . import dispatch, flows
8
+
9
+ SCHEMA_VERSION = 1
10
+ DIFFICULTIES = ("easy", "default", "hard")
11
+
12
+
13
+ def _difficulty(task, flow):
14
+ explicit = str((task or {}).get("difficulty") or "").strip().lower()
15
+ if explicit in DIFFICULTIES:
16
+ return explicit, "用户指定"
17
+ try:
18
+ threshold = float((task or {}).get("threshold")
19
+ if (task or {}).get("threshold") is not None
20
+ else (flow or {}).get("threshold") or 7.0)
21
+ except (TypeError, ValueError):
22
+ threshold = 7.0
23
+ if threshold >= 8.5:
24
+ return "hard", "高质量门槛"
25
+ if threshold <= 6.0:
26
+ return "easy", "快速交付门槛"
27
+ if str((task or {}).get("type") or "").lower() in (
28
+ "research", "tech_proposal", "rank_scan"):
29
+ return "hard", "研究/方案类默认需要深度"
30
+ return "default", "标准难度"
31
+
32
+
33
+ def _capabilities(task, dimension, engine):
34
+ caps = [dimension]
35
+ if engine == "code" or (task or {}).get("verify_command"):
36
+ caps.extend(["filesystem", "verification"])
37
+ if (task or {}).get("serial"):
38
+ caps.extend(["long_context", "continuity"])
39
+ if (task or {}).get("attachments"):
40
+ caps.append("attachments")
41
+ return list(dict.fromkeys(caps))
42
+
43
+
44
+ def compile_task(task):
45
+ """返回统一任务规格;输入缺失或字段异常时保持可编排的安全兜底。"""
46
+ raw = dict(task) if isinstance(task, Mapping) else {}
47
+ ttype = str(raw.get("type") or "direct").strip().lower()
48
+ flow = flows.get_flow(ttype) or {}
49
+ engine = str(raw.get("engine") or flow.get("engine") or
50
+ ("code" if ttype == "code" else "review")).strip()
51
+ if engine not in flows.ENGINES:
52
+ engine = flow.get("engine") or ("code" if ttype == "code" else "review")
53
+ difficulty, difficulty_reason = _difficulty(raw, flow)
54
+ dimension = dispatch.task_dimension(ttype, raw.get("role") or "")
55
+ rubric = raw.get("rubric") or flow.get("rubric") or []
56
+ if isinstance(rubric, str):
57
+ rubric = [x.strip() for x in rubric.replace(",", ",").split(",") if x.strip()]
58
+ elif not isinstance(rubric, (list, tuple)):
59
+ rubric = flow.get("rubric") or []
60
+ rubric = [str(x).strip() for x in rubric if str(x).strip()][:8]
61
+ deliverable = str(raw.get("manuscript") or flow.get("manuscript") or "").strip()
62
+ serial = raw.get("serial") if isinstance(raw.get("serial"), dict) else None
63
+ return {
64
+ "schema_version": SCHEMA_VERSION,
65
+ "type": ttype,
66
+ "title": str(raw.get("title") or "")[:120],
67
+ "goal": str(raw.get("goal") or "")[:2000],
68
+ "engine": engine,
69
+ "dimension": dimension,
70
+ "difficulty": difficulty,
71
+ "difficulty_reason": difficulty_reason,
72
+ "capabilities": _capabilities(raw, dimension, engine),
73
+ "deliverable": deliverable,
74
+ "quality_dimensions": rubric,
75
+ "serial": serial,
76
+ "explicit_agent": str(raw.get("implementer") or "").strip(),
77
+ "has_verification": bool(raw.get("verify_command")),
78
+ }
79
+
80
+
81
+ def summary(spec):
82
+ """给运行日志/UI 的短说明,不包含用户正文。"""
83
+ spec = spec or {}
84
+ return "%s/%s/%s(能力:%s)" % (
85
+ spec.get("type") or "direct", spec.get("engine") or "direct",
86
+ spec.get("difficulty") or "default",
87
+ "、".join(spec.get("capabilities") or []) or "通用")
package/app/main.py CHANGED
@@ -296,6 +296,18 @@ class Handler(BaseHTTPRequestHandler):
296
296
  # 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
297
297
  from core import telemetry
298
298
  return self._json(200, telemetry.issue_report(days=30))
299
+ if path == "/api/ports":
300
+ # 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
301
+ # 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
302
+ from core import portscan
303
+ q = parse_qs(urlparse(self.path).query)
304
+ ports = portscan.listening_ports()
305
+ focus = (q.get("port") or [""])[0]
306
+ if focus.isdigit():
307
+ ports = [p for p in ports if p["port"] == int(focus)]
308
+ for p in ports:
309
+ p["self"] = p.get("pid") == os.getpid()
310
+ return self._json(200, {"ports": ports})
299
311
  m = re.match(r"^/api/runs/([^/]+)$", path)
300
312
  if m:
301
313
  run = store.get_run(m.group(1))
@@ -560,6 +572,22 @@ class Handler(BaseHTTPRequestHandler):
560
572
  return self._json(200, {"ok": True, "health": health.snapshot()})
561
573
  if path == "/api/attachments":
562
574
  return self._api_add_attachment()
575
+ if path == "/api/ports/close":
576
+ # 温和关闭端口占用进程(借鉴 leftopen):SIGTERM only、关前重验
577
+ # PID 绑定;系统进程/自身服务在 portscan 内拒关。设备控制权守卫
578
+ # 已在上方统一生效(不在豁免清单里)。
579
+ from core import portscan
580
+ body = self._body()
581
+ try:
582
+ cport = int(body.get("port") or 0)
583
+ except (TypeError, ValueError):
584
+ return self._json(400, {"error": "port 必须是数字"})
585
+ if not (1 <= cport <= 65535):
586
+ return self._json(400, {"error": "port 越界"})
587
+ ok, msg = portscan.close_port(cport)
588
+ return self._json(200 if ok else 409,
589
+ {"ok": ok, "message": msg,
590
+ "error": None if ok else msg})
563
591
  if path == "/api/dir/save":
564
592
  # 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
565
593
  return self._api_dir_save()
@@ -2412,6 +2440,20 @@ def main():
2412
2440
  hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
2413
2441
  "tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
2414
2442
  % args.port)
2443
+ # 端口占用自动指认(借鉴 leftopen 38★):直接报出 PID/进程/项目归属,
2444
+ # 用户不用再手跑 netstat+tasklist 两连。识别不出时回落上面的手工指路。
2445
+ try:
2446
+ from core import portscan as _ps
2447
+ for _h in _ps.listening_ports():
2448
+ if _h.get("port") != args.port:
2449
+ continue
2450
+ _who = _h.get("process") or "未知进程"
2451
+ _proj = (",项目 %s" % _h["project"]) if _h.get("project") else ""
2452
+ hint = ("占用者:PID %d(%s%s);旧进程杀掉或换 --port 重启"
2453
+ % (_h.get("pid") or 0, _who, _proj))
2454
+ break
2455
+ except Exception:
2456
+ pass
2415
2457
  raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
2416
2458
  % (args.port, e, hint))
2417
2459
 
package/app/pet.py CHANGED
@@ -30,8 +30,10 @@ import http.client
30
30
  import json
31
31
  import math
32
32
  import os
33
+ import queue
33
34
  import random
34
35
  import sys
36
+ import threading
35
37
  import time
36
38
  import webbrowser
37
39
  from pathlib import Path
@@ -39,7 +41,8 @@ from pathlib import Path
39
41
  # --------------------------------------------------------------- 纯逻辑(可测试)
40
42
 
41
43
  ACTIVE_STATUSES = ("queued", "running")
42
- BAD_STATUSES = ("failed", "cancelled")
44
+ GOOD_STATUSES = ("done",)
45
+ BAD_STATUSES = ("failed", "cancelled", "timeout")
43
46
  POLL_MS = 4000 # 轮询间隔
44
47
  POLL_TIMEOUT = 2.5 # 单次拉取超时(秒)
45
48
  MAX_MISS = 15 # 连续拉不到服务 N 次后自离(约 1 分钟,防孤儿常驻)
@@ -49,20 +52,32 @@ HOVER_MS = 250 # 指针轮询周期
49
52
  HOVER_DELAY_MS = 350 # 悬停多久后才弹任务清单(扫过不弹)
50
53
 
51
54
 
55
+ def _safe_int(value, default=0, minimum=0):
56
+ """外部状态里的计数可能是空值或脏字符串;统一兜底并限制为非负数。"""
57
+ try:
58
+ number = int(value)
59
+ except (TypeError, ValueError, OverflowError):
60
+ number = int(default)
61
+ return max(minimum, number)
62
+
63
+
52
64
  def parse_snapshot(raw):
53
65
  """把 /api/pet_state 响应裁成蜜蜂关心的最小集。字段缺失一律兜底,不抛。"""
54
66
  raw = raw if isinstance(raw, dict) else {}
55
- st = raw.get("settings") or {}
67
+ st = raw.get("settings") if isinstance(raw.get("settings"), dict) else {}
68
+ workers = raw.get("workers") if isinstance(raw.get("workers"), dict) else {}
69
+ digest = raw.get("digest") if isinstance(raw.get("digest"), dict) else {}
70
+ task_rows = raw.get("tasks") if isinstance(raw.get("tasks"), list) else []
56
71
  tasks = []
57
- for t in (raw.get("tasks") or [])[:300]:
72
+ for t in task_rows[:300]:
58
73
  if not isinstance(t, dict):
59
74
  continue
60
75
  tasks.append({
61
76
  "id": str(t.get("id") or ""),
62
77
  "title": str(t.get("title") or t.get("id") or ""),
63
78
  "run_status": str(t.get("run_status") or ""),
64
- "steps_done": int(t.get("steps_done") or 0),
65
- "steps_total": int(t.get("steps_total") or 0),
79
+ "steps_done": _safe_int(t.get("steps_done")),
80
+ "steps_total": _safe_int(t.get("steps_total")),
66
81
  "step_current": str(t.get("step_current") or ""),
67
82
  "error": str(t.get("error") or ""),
68
83
  })
@@ -73,15 +88,14 @@ def parse_snapshot(raw):
73
88
  "pet_skin": str(st.get("pet_skin") or DEFAULT_SKIN),
74
89
  },
75
90
  "workers": {
76
- "running": int((raw.get("workers") or {}).get("running") or 0),
77
- "queued": int((raw.get("workers") or {}).get("queued") or 0),
91
+ "running": _safe_int(workers.get("running")),
92
+ "queued": _safe_int(workers.get("queued")),
78
93
  },
79
94
  "tasks": tasks,
80
95
  "digest": {
81
- "unseen": int((raw.get("digest") or {}).get("unseen") or 0),
82
- "latest": (raw.get("digest") or {}).get("latest")
83
- if isinstance((raw.get("digest") or {}).get("latest"), dict)
84
- else None,
96
+ "unseen": _safe_int(digest.get("unseen")),
97
+ "latest": digest.get("latest")
98
+ if isinstance(digest.get("latest"), dict) else None,
85
99
  },
86
100
  }
87
101
 
@@ -105,10 +119,12 @@ def derive_state(prev_active_ids, tasks):
105
119
  by_id = {t["id"]: t for t in tasks}
106
120
  for tid in prev_active_ids:
107
121
  t = by_id.get(tid)
108
- if t is None or t["run_status"] not in BAD_STATUSES:
109
- info["good"].append(t or {"id": tid, "title": tid})
110
- else:
122
+ if t is None:
123
+ continue
124
+ if t["run_status"] in BAD_STATUSES:
111
125
  info["bad"].append(t)
126
+ elif t["run_status"] in GOOD_STATUSES:
127
+ info["good"].append(t)
112
128
  if info["bad"]:
113
129
  return "alert", info
114
130
  if info["good"]:
@@ -276,7 +292,10 @@ def _http_json(port, path, body=None):
276
292
  method = "POST"
277
293
  conn.request(method, path, body=payload, headers=headers)
278
294
  resp = conn.getresponse()
279
- return json.loads(resp.read().decode("utf-8"))
295
+ data = resp.read()
296
+ if not 200 <= resp.status < 300:
297
+ raise RuntimeError("CodeBee API 返回 HTTP %d" % resp.status)
298
+ return json.loads(data.decode("utf-8"))
280
299
  finally:
281
300
  conn.close()
282
301
 
@@ -405,6 +424,14 @@ class PetApp:
405
424
  self.hop_until = 0.0 # 冒气泡时的蹦跶动作截止
406
425
  self.next_chatter = time.time() + random.uniform(90, 240)
407
426
  self.seen_digests = 0 # 本轮已通知过的摘要未读数(涨了才报,不重复轰炸)
427
+ self._polling = False
428
+ self._poll_results = queue.Queue(maxsize=1)
429
+ self._settings_lock = threading.Lock()
430
+ self._settings_pending = {}
431
+ self._settings_desired = {}
432
+ self._settings_confirmed = {}
433
+ self._settings_seq = 0
434
+ self._settings_worker_running = False
408
435
 
409
436
  if self.frames:
410
437
  self._build_sprite()
@@ -583,10 +610,7 @@ class PetApp:
583
610
  return
584
611
  self.skin = skin
585
612
  self._save_cfg(skin=skin)
586
- try:
587
- _http_json(self.port, "/api/settings", body={"pet_skin": skin})
588
- except Exception:
589
- pass
613
+ self._post_settings({"pet_skin": skin})
590
614
  self._rebuild_sprite()
591
615
 
592
616
  def _rebuild_sprite(self):
@@ -841,16 +865,92 @@ class PetApp:
841
865
  else:
842
866
  self._show([self.pbar_bg, self.pbar_fg], False)
843
867
 
844
- # ---- 数据轮询(4s 一拍)----
868
+ def _post_settings(self, body):
869
+ """合并并串行发送设置,保证快速连点时最后一次选择最终生效。"""
870
+ patch = dict(body or {})
871
+ if not patch:
872
+ return
873
+ with self._settings_lock:
874
+ self._settings_seq += 1
875
+ seq = self._settings_seq
876
+ for key, value in patch.items():
877
+ self._settings_pending[key] = (seq, value)
878
+ self._settings_desired[key] = (seq, value)
879
+ if self._settings_worker_running:
880
+ return
881
+ self._settings_worker_running = True
882
+
883
+ def send():
884
+ while True:
885
+ with self._settings_lock:
886
+ if not self._settings_pending:
887
+ self._settings_worker_running = False
888
+ return
889
+ current = dict(self._settings_pending)
890
+ self._settings_pending.clear()
891
+ try:
892
+ _http_json(self.port, "/api/settings",
893
+ body={key: item[1] for key, item in current.items()})
894
+ with self._settings_lock:
895
+ for key, item in current.items():
896
+ self._settings_confirmed[key] = max(
897
+ item[0], self._settings_confirmed.get(key, 0))
898
+ except Exception:
899
+ # 新值优先;失败的旧值只补回尚未被新点击覆盖的键。
900
+ with self._settings_lock:
901
+ for key, item in current.items():
902
+ self._settings_pending.setdefault(key, item)
903
+ time.sleep(1.0)
904
+ threading.Thread(target=send, name="pet-settings", daemon=True).start()
905
+
906
+ def _reconcile_desired_settings(self, snap, confirmed_at_poll_start):
907
+ """只覆盖写入确认前已发出的旧轮询;确认后的快照以服务端为准。"""
908
+ with self._settings_lock:
909
+ for key, item in list(self._settings_desired.items()):
910
+ seq, value = item
911
+ if confirmed_at_poll_start.get(key, 0) >= seq:
912
+ del self._settings_desired[key]
913
+ else:
914
+ snap["settings"][key] = value
915
+
916
+ # ---- 数据轮询(4s 一拍;网络请求不占 Tk 主线程)----
845
917
  def _tick(self):
846
918
  self._touch_lock()
847
- snap = None
919
+ if self._polling:
920
+ return
921
+ self._polling = True
922
+ with self._settings_lock:
923
+ confirmed_at_poll_start = dict(self._settings_confirmed)
924
+
925
+ def fetch():
926
+ snap = None
927
+ error = None
928
+ try:
929
+ snap = parse_snapshot(_http_json(self.port, "/api/pet_state"))
930
+ except Exception as exc:
931
+ error = exc
932
+ try:
933
+ self._poll_results.put_nowait(
934
+ (snap, error, confirmed_at_poll_start))
935
+ except queue.Full:
936
+ pass
937
+
938
+ threading.Thread(target=fetch, name="pet-poll", daemon=True).start()
939
+ self.root.after(50, self._collect_poll)
940
+
941
+ def _collect_poll(self):
848
942
  try:
849
- snap = parse_snapshot(_http_json(self.port, "/api/pet_state"))
850
- self.miss = 0
851
- except Exception:
943
+ snap, error, confirmed_at_poll_start = self._poll_results.get_nowait()
944
+ except queue.Empty:
945
+ self.root.after(50, self._collect_poll)
946
+ return
947
+ self._polling = False
948
+ if error is not None:
852
949
  self.miss += 1
950
+ else:
951
+ self.miss = 0
853
952
  if snap is not None:
953
+ self._reconcile_desired_settings(snap, confirmed_at_poll_start)
854
954
  if not snap["settings"]["pet_enabled"]:
855
955
  return self._bye()
856
956
  self.snap = snap
@@ -1187,10 +1287,7 @@ class PetApp:
1187
1287
  return m
1188
1288
 
1189
1289
  def _set_mode(self, mode):
1190
- try:
1191
- _http_json(self.port, "/api/settings", body={"pet_mode": mode})
1192
- except Exception:
1193
- pass
1290
+ self._post_settings({"pet_mode": mode})
1194
1291
 
1195
1292
  def _set_lang(self, lang):
1196
1293
  self.lang = lang