codebee 0.1.20 → 0.1.22

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/app/core/store.py CHANGED
@@ -448,11 +448,16 @@ def load_all():
448
448
  # 错误串(`[91m[1mError:`、U+FFFD 乱码墙),读盘时统一洗一遍——
449
449
  # 老运行不必等重跑才干净(2026-09-20「咋还有乱码」实测)
450
450
  _sanitize_run_text(r)
451
- # 队列不跨进程持久化:磁盘上仍是 queued/running 的运行必是上次进程中断的残骸
452
- if r.get("status") in ("queued", "running"):
453
- r["status"] = "failed"
454
- r["error"] = r.get("error") or "服务重启中断,可重试"
455
- _save_json(p, r)
451
+ # 直接执行线程不跨进程:running 与无截止时间的 queued 是上次
452
+ # 进程中断残骸。带 resume_enqueue_at queued 是有意的自动续跑
453
+ # 退避,保留给 jobs.restore_deferred_resumes 重建 Timer。
454
+ interrupted = (r.get("status") == "running" or
455
+ (r.get("status") == "queued" and
456
+ not r.get("resume_enqueue_at")))
457
+ if interrupted:
458
+ r["status"] = "failed"
459
+ r["error"] = r.get("error") or "服务重启中断,可重试"
460
+ _save_json(p, r)
456
461
  _RUNS[r["id"]] = r
457
462
  except Exception:
458
463
  pass
@@ -507,10 +512,11 @@ def get_run(run_id):
507
512
  return _RUNS.get(run_id)
508
513
 
509
514
 
510
- def list_runs(limit=60):
511
- with LOCK:
512
- ids = sorted(_RUNS.keys(), reverse=True)
513
- return [_RUNS[i] for i in ids[:limit]]
515
+ def list_runs(limit=60):
516
+ with LOCK:
517
+ ids = sorted(_RUNS.keys(), reverse=True)
518
+ selected = ids if limit is None else ids[:limit]
519
+ return [_RUNS[i] for i in selected]
514
520
 
515
521
 
516
522
  def latest_run_by_task():
@@ -635,17 +641,17 @@ def task_side(task_id):
635
641
  }
636
642
 
637
643
 
638
- def update_run(run_id, expected_status=None, **fields):
644
+ def update_run(run_id, expected_status=None, **fields):
639
645
  """更新运行字段。expected_status 非 None 时做 CAS(§2C):
640
646
  当前状态不等于 expected_status 则拒绝写入并返回 None,
641
647
  防止陈旧执行方(被取消的 worker、崩溃恢复前的旧线程)覆盖新状态
642
648
  ——防御模式「异步状态不是同步状态」。不传则保持原行为。"""
643
649
  with LOCK:
644
650
  run = _RUNS.get(run_id)
645
- if not run:
646
- return None
647
- if expected_status is not None and run.get("status") != expected_status:
648
- return None
651
+ if not run:
652
+ return None
653
+ if expected_status is not None and run.get("status") != expected_status:
654
+ return None
649
655
  for k in ("error", "summary"):
650
656
  if isinstance(fields.get(k), str):
651
657
  # 错误串多是我们自己拼的 CLI 尾巴(含 ANSI/覆写/乱码墙):
@@ -0,0 +1,83 @@
1
+ # -*- coding: utf-8 -*-
2
+ """任务编译器:把用户任务和预置流程编译成统一、可审计的运行规格。"""
3
+ from __future__ import annotations
4
+
5
+ from . import dispatch, flows
6
+
7
+ SCHEMA_VERSION = 1
8
+ DIFFICULTIES = ("easy", "default", "hard")
9
+
10
+
11
+ def _difficulty(task, flow):
12
+ explicit = str((task or {}).get("difficulty") or "").strip().lower()
13
+ if explicit in DIFFICULTIES:
14
+ return explicit, "用户指定"
15
+ try:
16
+ threshold = float((task or {}).get("threshold")
17
+ if (task or {}).get("threshold") is not None
18
+ else (flow or {}).get("threshold") or 7.0)
19
+ except (TypeError, ValueError):
20
+ threshold = 7.0
21
+ if threshold >= 8.5:
22
+ return "hard", "高质量门槛"
23
+ if threshold <= 6.0:
24
+ return "easy", "快速交付门槛"
25
+ if str((task or {}).get("type") or "").lower() in (
26
+ "research", "tech_proposal", "rank_scan"):
27
+ return "hard", "研究/方案类默认需要深度"
28
+ return "default", "标准难度"
29
+
30
+
31
+ def _capabilities(task, dimension, engine):
32
+ caps = [dimension]
33
+ if engine == "code" or (task or {}).get("verify_command"):
34
+ caps.extend(["filesystem", "verification"])
35
+ if (task or {}).get("serial"):
36
+ caps.extend(["long_context", "continuity"])
37
+ if (task or {}).get("attachments"):
38
+ caps.append("attachments")
39
+ return list(dict.fromkeys(caps))
40
+
41
+
42
+ def compile_task(task):
43
+ """返回统一任务规格;输入缺失或字段异常时保持可编排的安全兜底。"""
44
+ raw = dict(task or {})
45
+ ttype = str(raw.get("type") or "direct").strip().lower()
46
+ flow = flows.get_flow(ttype) or {}
47
+ engine = str(raw.get("engine") or flow.get("engine") or
48
+ ("code" if ttype == "code" else "review")).strip()
49
+ if engine not in flows.ENGINES:
50
+ engine = flow.get("engine") or ("code" if ttype == "code" else "review")
51
+ difficulty, difficulty_reason = _difficulty(raw, flow)
52
+ dimension = dispatch.task_dimension(ttype, raw.get("role") or "")
53
+ rubric = raw.get("rubric") or flow.get("rubric") or []
54
+ if isinstance(rubric, str):
55
+ rubric = [x.strip() for x in rubric.replace(",", ",").split(",") if x.strip()]
56
+ rubric = [str(x).strip() for x in rubric if str(x).strip()][:8]
57
+ deliverable = str(raw.get("manuscript") or flow.get("manuscript") or "").strip()
58
+ serial = raw.get("serial") if isinstance(raw.get("serial"), dict) else None
59
+ return {
60
+ "schema_version": SCHEMA_VERSION,
61
+ "type": ttype,
62
+ "title": str(raw.get("title") or "")[:120],
63
+ "goal": str(raw.get("goal") or "")[:2000],
64
+ "engine": engine,
65
+ "dimension": dimension,
66
+ "difficulty": difficulty,
67
+ "difficulty_reason": difficulty_reason,
68
+ "capabilities": _capabilities(raw, dimension, engine),
69
+ "deliverable": deliverable,
70
+ "quality_dimensions": rubric,
71
+ "serial": serial,
72
+ "explicit_agent": str(raw.get("implementer") or "").strip(),
73
+ "has_verification": bool(raw.get("verify_command")),
74
+ }
75
+
76
+
77
+ def summary(spec):
78
+ """给运行日志/UI 的短说明,不包含用户正文。"""
79
+ spec = spec or {}
80
+ return "%s/%s/%s(能力:%s)" % (
81
+ spec.get("type") or "direct", spec.get("engine") or "direct",
82
+ spec.get("difficulty") or "default",
83
+ "、".join(spec.get("capabilities") or []) or "通用")
@@ -1077,7 +1077,7 @@ def _goal_text(bug, side=None):
1077
1077
 
1078
1078
 
1079
1079
  def _launch_fix(bug, profile, side, cfg):
1080
- """为一个 bug 的某一端建修复任务并入队。与 automation._launch_run 同一条链。"""
1080
+ """为一个 bug 的某一端建修复任务并立即启动。"""
1081
1081
  bid = bug.get("id")
1082
1082
  repo = _repo_of(profile, side)
1083
1083
  wd = str(repo.get("workdir") or "").strip() or settings.default_workdir()
@@ -1088,11 +1088,31 @@ def _launch_fix(bug, profile, side, cfg):
1088
1088
  payload["git_rev"] = str(repo["git_rev"]).strip()
1089
1089
  if str(repo.get("verify_command") or "").strip():
1090
1090
  payload["verify_command"] = str(repo["verify_command"]).strip()
1091
- task = store.create_task(payload)
1092
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
1093
- store.update_task_status(task["id"], "queued")
1094
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1095
- return task, run
1091
+ task = None
1092
+ run = None
1093
+ try:
1094
+ task = store.create_task(payload)
1095
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
1096
+ store.update_task_status(task["id"], "queued")
1097
+ jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
1098
+ "task_id": task["id"]})
1099
+ return task, run
1100
+ except Exception:
1101
+ log.exception("zentao: 修复任务启动失败 bug=%s side=%s task=%s run=%s",
1102
+ bid, side, (task or {}).get("id"), (run or {}).get("id"))
1103
+ if run:
1104
+ try:
1105
+ store.update_run(run["id"], status="failed",
1106
+ error="禅道修复任务启动失败,本次未排队,请稍后重试",
1107
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1108
+ except Exception:
1109
+ log.exception("zentao: 修复运行失败收口失败 run=%s", run.get("id"))
1110
+ if task:
1111
+ try:
1112
+ store.update_task_status(task["id"], "failed")
1113
+ except Exception:
1114
+ log.exception("zentao: 修复任务失败收口失败 task=%s", task.get("id"))
1115
+ raise
1096
1116
 
1097
1117
 
1098
1118
  # ---------------------------------------------------------------- 回写文本
package/app/main.py CHANGED
@@ -972,7 +972,7 @@ class Handler(BaseHTTPRequestHandler):
972
972
  m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
973
973
  if m:
974
974
  # 一键打开(web 类起服务+开浏览器 / console 类新终端窗口)。
975
- # 即时返回不走任务队列;body 可传 {"open": false} 供测试免开浏览器
975
+ # 即时返回不走任务执行器;body 可传 {"open": false} 供测试免开浏览器
976
976
  entry = catalog.by_id(m.group(1))
977
977
  if not entry:
978
978
  return self._json(404, {"error": "catalog 中无此条目"})
@@ -1713,9 +1713,13 @@ class Handler(BaseHTTPRequestHandler):
1713
1713
  ok, err, new_run = store.retry_task(run["task_id"])
1714
1714
  if ok:
1715
1715
  store.update_run(new_run["id"], op="qa", qa_text=text)
1716
- self._enqueue_run(new_run["id"], run["task_id"],
1717
- {"kind": "orchestration", "run_id": new_run["id"],
1718
- "task_id": run["task_id"]})
1716
+ started, start_err = self._enqueue_run(
1717
+ new_run["id"], run["task_id"],
1718
+ {"kind": "orchestration", "run_id": new_run["id"],
1719
+ "task_id": run["task_id"]})
1720
+ if not started:
1721
+ return self._json(503, {"error": start_err,
1722
+ "run_id": new_run["id"]})
1719
1723
  return self._json(200, {"ok": True, "message": msg,
1720
1724
  "qa_run": new_run["id"]})
1721
1725
  return self._json(200, {"ok": True, "message": msg})
@@ -2100,15 +2104,17 @@ class Handler(BaseHTTPRequestHandler):
2100
2104
  return self._json(status, resp)
2101
2105
 
2102
2106
  def _enqueue_run(self, run_id, task_id, job):
2103
- """入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
2107
+ """立即启动失败时收口到 failed;系统默认没有等待队列。"""
2104
2108
  try:
2105
2109
  jobs.enqueue(job)
2106
2110
  return True, ""
2107
- except Exception:
2111
+ except Exception as exc:
2108
2112
  # 不把异常文本(本机路径、命令行参数、供应商响应)返回给客户端;
2109
2113
  # 详细堆栈只进服务端日志,run 记录也保留稳定的用户可读文案。
2110
2114
  log.exception("任务入队失败 run=%s task=%s", run_id, task_id)
2111
- err = "任务入队失败,请稍后重试"
2115
+ busy = isinstance(exc, jobs.JobsBusyError)
2116
+ err = ("当前运行任务已达并发保护上限,本次未排队,请稍后重试"
2117
+ if busy else "任务启动失败,请稍后重试")
2112
2118
  try:
2113
2119
  closed = store.update_run(run_id, status="failed", error=err,
2114
2120
  ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
@@ -2161,8 +2167,7 @@ def _state_payload(client_id="", ver=None):
2161
2167
  "control": remote.control_view(client_id),
2162
2168
  # 供应商健康/告警(顶栏横幅数据源;有告警时 bump_state 会推给所有端)
2163
2169
  "health": health.snapshot(),
2164
- # 任务队列观测(worker 池目标/存活 + 队列深度):排队问题排障一眼定位
2165
- # 是「并发满载在等」还是「job 蒸发没人管」(后者由看门狗 2 分钟自愈)
2170
+ # 直接执行观测:并发保护上限、运行数和可用位;queued 恒为 0。
2166
2171
  "jobs": jobs.workers_info(),
2167
2172
  }
2168
2173
 
@@ -2355,14 +2360,17 @@ def main():
2355
2360
  telemetry.start_background() # 匿名错误回传+版本 ping(默认开可关;未配端点自动休眠,延迟 45s 不挡启动)
2356
2361
  except Exception:
2357
2362
  pass
2358
- _step("正在启动任务队列…")
2363
+ _step("正在启动任务执行器…")
2359
2364
  jobs.start_worker()
2365
+ n_wait = jobs.restore_deferred_resumes()
2366
+ if n_wait:
2367
+ print("[CodeBee] 已恢复 %d 个定时退避中的自动续跑任务" % n_wait)
2360
2368
  n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
2361
2369
  if n_resume:
2362
2370
  print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
2363
- n_rq = jobs.requeue_pending() # 启动补队:队列在内存里,重启会让排队项变僵尸
2371
+ n_rq = jobs.requeue_pending() # 兼容旧版本遗留的无退避 queued 记录
2364
2372
  if n_rq:
2365
- print("[CodeBee] 已重新入队 %d 个遗留排队运行" % n_rq)
2373
+ print("[CodeBee] 已直接启动 %d 个遗留运行" % n_rq)
2366
2374
  _step("正在启动自动化调度…")
2367
2375
  n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
2368
2376
  if n_auto:
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"]:
@@ -117,7 +133,7 @@ def derive_state(prev_active_ids, tasks):
117
133
 
118
134
 
119
135
  def tooltip_lines(snap, lang="zh"):
120
- """悬停清单:未读摘要 + 进行中 + 排队,最多 6 行。
136
+ """悬停清单:未读摘要 + 进行中 + 待启动/自动续跑,最多 6 行。
121
137
 
122
138
  只列「还在跑 / 待跑」的任务——历史失败项会刷满清单(截图实测十几条
123
139
  ✘ 把面板撑成一堵墙),失败已由警示气泡点名,这里不再重复。
@@ -167,7 +183,7 @@ LANG = {
167
183
  "all_clear": "蜂群闲着,都在打盹…",
168
184
  "title": "蜂群动态",
169
185
  "n_running": "%d 个进行中",
170
- "queued": "排队中",
186
+ "queued": "正在启动",
171
187
  "cheer": "🎉 %d 个任务完工!",
172
188
  "alert": "⚠️ 「%s」出岔子了,点我看看",
173
189
  "bye": "蜜蜂回巢啦",
@@ -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