DataFlowing / collector /core /task_record.py
stouchinglhh
collector: 更新采集工程至 v1.3.17(时间轴修正 + 采集端自建任务)
ddb63ef
Raw History Blame Contribute Delete
28.9 kB
"""
Task record persistence — data/tasks.json is the single source of truth.
- 进度分账(多电脑协同,后端聚合口径):
local_count 本机累计完成段数(increment_task_completed 加它)
synced_count 后端已确认的本机段数(水位,只前进不后退)
backend_count 最近一次已知后端全局数(merge/flush 用后端值无条件替换)
completed_count = backend_count + (local_count - synced_count)
—— 派生显示值,字段名保留使 UI 零改动,所有写路径经 _recount() 重算。
- 迁移回填:旧数据没有上述字段时,local/synced/backend 一次性回填为
旧 completed_count(目录扫描兜底)——历史不补报、显示连续;
此后以持久化值为准,上传后删本地文件也不影响进度。
- Task metadata (id, name, description, total_required, etc.) is persisted.
- Backend tasks are merged into the local record, keeping local-only tasks.
- 本机任务(source="local"):
任务页「+ 新建任务」创建,id 前缀 local-,不进后端任务表、不参与进度上报。
* **仅创建者可见**:created_by 记创建时的身份("guest" 或用户名),
filter_by_identity() 对本机任务按 created_by 过滤(缺失时对所有人可见,
兼容手工写的数据);assigned_user 恒为 None,仍然算「公共任务」,
这样 merge_backend_tasks 的账号撤销清除不会删到它(另有显式豁免)。
* **进度只在本机**:local_count 累计,synced_count/backend_count 恒 0,
pending_sync_tasks() 跳过本机任务 —— 服务端没有对应任务,上报必被拒。
* **删除是真删记录**:remove_local_task() 整条移除(后端不会再推送它,
不需要 hidden 墓碑);服务端任务仍走 mark_hidden()。
* **名字即目录即上传项目名**:只允许字母/数字/汉字/下划线/连字符,
保证 <RECORDING_DIR>/<name> 与 task_tag(name) 完全一致。
"""
from __future__ import annotations
import json
import os
import threading
import uuid
from datetime import datetime, timezone
from config import settings
_TASKS_FILE = os.path.join(settings.DATA_DIR, "tasks.json")
# ── 本机任务常量 ──────────────────────────────────────
LOCAL_TASK_PREFIX = "local-"
MAX_TASK_NAME_LEN = 64
MAX_TASK_DESCRIPTION_LEN = 500
MAX_TOTAL_REQUIRED = 100000
# Windows 保留设备名(不能作为目录名;大小写不敏感)
_WINDOWS_RESERVED_NAMES = frozenset(
["CON", "PRN", "AUX", "NUL"]
+ [f"COM{i}" for i in range(1, 10)]
+ [f"LPT{i}" for i in range(1, 10)]
)
class TaskValidationError(ValueError):
"""本机任务创建/改名被拒。code 供 UI 本地化,不把中文写进核心层。"""
def __init__(self, code: str, detail: str = ""):
super().__init__(code)
self.code = code
self.detail = detail
def __str__(self) -> str: # 日志/调试可读
return f"{self.code}: {self.detail}" if self.detail else self.code
_lock = threading.RLock() # reentrant — merge_backend_tasks calls save_tasks/load_tasks
_SEED_TASKS = [
{
"id": "task_001",
"name": "cylinder_grasping",
"description": "Grasp cylinder parts with right hand, record 16x16 tactile sensor data.",
"status": "pending",
"total_required": 1000,
"assigned_at": "2026-08-07T09:00:00Z",
"params": {"object": "cylinder", "hand": "right"},
},
{
"id": "task_002",
"name": "cube_placement",
"description": "Place cube objects from point A to point B using both hands.",
"status": "pending",
"total_required": 500,
"assigned_at": "2026-08-06T14:30:00Z",
"params": {"object": "cube", "hand": "both"},
},
{
"id": "task_003",
"name": "valve_rotation",
"description": "Rotate circular valve 90 degrees with single hand, record torque and tactile feedback.",
"status": "pending",
"total_required": 800,
"assigned_at": "2026-08-05T11:00:00Z",
"params": {"object": "valve", "hand": "right", "angle": 90},
},
]
# ═══════════════════════════════════════════════════════════
# Public API
# ═══════════════════════════════════════════════════════════
def load_tasks(identity: str | None = None) -> list[dict]:
"""Load all tasks. Filters out hidden tasks.
completed_count 以持久化值为准(increment_task_completed 累计);
文件中缺该字段(旧版本数据)时一次性按目录扫描回填并写回,
此后删会话文件不再影响进度。
Status is auto-computed: pending(0%), in_progress(>0%), completed(100%).
排序: 按 assigned_at 从新到旧;null/空/解析失败排最后。
identity(新增,默认 None 兼容旧调用):
None → 返回全部任务(含他人创建的本机任务,仅供内部/统计调用)
"guest" → 仅公共任务(无 assigned_user 字段)+ 游客创建的本机任务
用户名 → 公共任务 + assigned_user == 该用户的任务
+ 该用户创建的本机任务
注意: 除迁移回填外不写回 tasks.json —— hidden 任务必须保留在文件中,
否则后端再次推送时无法识别为已删除,任务会死而复生。
"""
with _lock:
tasks = _read_raw()
need_backfill = False
for t in tasks:
if _ensure_sync_fields(t):
need_backfill = True
_recount(t)
t["status"] = _compute_status(t)
if need_backfill:
with _lock:
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
visible = [t for t in tasks if not t.get("hidden")]
visible = filter_by_identity(visible, identity)
visible.sort(key=_assigned_at_key, reverse=True) # 新→旧;null/空/非法排最后
return visible
def filter_by_identity(tasks: list[dict], identity: str | None = None) -> list[dict]:
"""按身份过滤任务列表(纯函数,供 UI 对已有列表即时过滤)。
identity 语义与 load_tasks 相同;返回新列表,不修改原列表。
本机任务按 **created_by** 过滤(仅创建者可见);assigned_user 语义只
用于服务端任务(本机任务 assigned_user 恒为 None)。created_by 缺失的
本机任务视为「无主」,对所有身份可见(兼容手工写的 tasks.json)。
"""
if identity is None:
return list(tasks)
out: list[dict] = []
for t in tasks:
if is_local_task(t):
if _local_visible(t, identity):
out.append(t)
continue
if identity == "guest":
if not _assigned_user(t):
out.append(t)
elif not _assigned_user(t) or _assigned_user(t) == identity:
out.append(t)
return out
def save_tasks(tasks: list[dict]):
"""Persist task metadata,含 completed_count(录制完成次数持久化权威值)。"""
clean = [dict(t) for t in tasks]
with _lock:
_write({"tasks": clean, "updated_at": datetime.now().isoformat()})
# ═══════════════════════════════════════════════════════════
# 本机任务(采集端自建)—— 创建 / 改名 / 删除 / 校验
# ═══════════════════════════════════════════════════════════
def is_local_task(task: dict) -> bool:
"""是否采集端自建任务。source 缺失时按 id 前缀兜底(双保险)。"""
if not isinstance(task, dict):
return False
if task.get("source") == "local":
return True
return str(_tid(task) or "").startswith(LOCAL_TASK_PREFIX)
def new_local_task_id() -> str:
"""本机任务 id:local-YYYYmmdd-HHMMSS-<4hex>,与后端 UUID 不会相撞。"""
return "local-{}-{:04x}".format(
datetime.now().strftime("%Y%m%d-%H%M%S"), uuid.uuid4().int & 0xFFFF)
def validate_task_name(name: str, exclude_id: str | None = None
) -> tuple[bool, str]:
"""校验本机任务名,返回 (ok, 错误码)。错误码由 UI 本地化。
规则(保证 name == task_tag(name),即名字、录制目录、上传项目名一致):
* 去首尾空白后非空、≤ 64 字符;
* 只允许字母/数字/汉字/下划线/连字符(空格、点、斜杠等一律拒绝);
* Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)拒绝;
* 与任何现有任务重名(大小写不敏感、含 hidden 与种子任务)拒绝。
"""
with _lock:
tasks = _read_raw()
return _validate_name_locked(tasks, name, exclude_id)
def create_local_task(name: str, total_required: int, description: str = "",
created_by: str = "guest") -> dict:
"""新建本机任务并落盘,返回任务 dict。
失败抛 TaskValidationError(code 见 validate_task_name / total_invalid /
description_too_long)。任务仅创建者可见,进度只在本机累计。
"""
total = _validate_total(total_required)
desc = (description or "").strip()
if len(desc) > MAX_TASK_DESCRIPTION_LEN:
raise TaskValidationError("description_too_long")
with _lock:
tasks = _read_raw()
ok, code = _validate_name_locked(tasks, name)
if not ok:
raise TaskValidationError(code)
existing_ids = {_tid(t) for t in tasks}
task_id = new_local_task_id()
while task_id in existing_ids:
task_id = new_local_task_id()
task = {
"id": task_id,
"name": (name or "").strip(),
"description": desc,
"source": "local",
"created_by": created_by or "guest",
"status": "pending",
"total_required": total,
"assigned_at": datetime.now(timezone.utc).isoformat(),
"assigned_user": None,
# 进度分账三字段显式写 0:避开旧数据迁移回填(否则会去扫目录)
"local_count": 0,
"synced_count": 0,
"backend_count": 0,
"completed_count": 0,
}
tasks.append(task)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
task["status"] = _compute_status(task)
return dict(task)
def rename_local_task(task_id: str, new_name: str,
allow_with_data: bool = False) -> dict:
"""本机任务改名。只允许改本机任务,且默认拒绝「已有录制目录」的任务。
改名不迁移录制目录:本地已有数据时改名字会让旧目录失去归属、且可能
与上传队列里的路径脱节,因此默认拒绝(code="has_data");新名字的
目录已存在(孤儿目录)也拒绝(code="name_dir_exists")。
"""
with _lock:
tasks = _read_raw()
target = _find(tasks, task_id)
if target is None:
raise TaskValidationError("task_not_found")
if not is_local_task(target):
raise TaskValidationError("not_local")
old_name = (target.get("name") or "").strip()
clean = (new_name or "").strip()
if clean == old_name:
target["status"] = _compute_status(target)
return dict(target)
ok, code = _validate_name_locked(tasks, clean, exclude_id=task_id)
if not ok:
raise TaskValidationError(code)
if not allow_with_data:
old_dir = _local_task_dir(old_name)
if old_dir and os.path.isdir(old_dir):
raise TaskValidationError("has_data")
new_dir = _local_task_dir(clean)
if new_dir and os.path.isdir(new_dir):
raise TaskValidationError("name_dir_exists")
target["name"] = clean
target["status"] = _compute_status(target)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
return dict(target)
def remove_local_task(task_id: str) -> bool:
"""硬移除本机任务(不写 hidden 墓碑)。
后端永远不会推送本机任务,不需要防复活;整条移除后同名任务可以重建。
只对 source=local 的条目生效,服务端任务返回 False(仍走 mark_hidden)。
"""
with _lock:
tasks = _read_raw()
target = _find(tasks, task_id)
if target is None or not is_local_task(target):
return False
kept = [t for t in tasks if _tid(t) != task_id]
_write({"tasks": kept, "updated_at": datetime.now().isoformat()})
return True
def _local_visible(task: dict, identity: str | None) -> bool:
"""本机任务可见性:只有创建者身份可见;无 created_by 视为无主(所有人可见)。"""
creator = task.get("created_by")
if not creator:
return True
return creator == identity
def _local_task_dir(name: str) -> str:
"""本机任务录制目录(懒 import helpers,避免 task_record 顶层拉重依赖)。"""
if not name:
return ""
from core.helpers import task_dir_of
return task_dir_of(settings.RECORDING_DIR, name)
def _validate_name_locked(tasks: list[dict], name: str,
exclude_id: str | None = None) -> tuple[bool, str]:
"""名字校验(调用方持 _lock 并传入当前任务表)。"""
clean = (name or "").strip()
if not clean:
return False, "name_empty"
if len(clean) > MAX_TASK_NAME_LEN:
return False, "name_too_long"
if any(not (c.isalnum() or c in "_-") for c in clean):
return False, "name_illegal"
if clean.upper() in _WINDOWS_RESERVED_NAMES:
return False, "name_reserved"
key = clean.casefold()
for t in tasks:
if exclude_id and _tid(t) == exclude_id:
continue
if str(t.get("name") or "").casefold() == key:
return False, "name_duplicate"
return True, ""
def _validate_total(total_required) -> int:
"""目标段数:1 ~ MAX_TOTAL_REQUIRED 的整数(bool 不算)。"""
if isinstance(total_required, bool) or not isinstance(total_required, int):
raise TaskValidationError("total_invalid")
if total_required < 1 or total_required > MAX_TOTAL_REQUIRED:
raise TaskValidationError("total_invalid")
return total_required
def merge_backend_tasks(backend_tasks: list[dict], view_scope: str | None = None) -> list[dict]:
"""Merge backend tasks into local record. Returns merged list.
- Backend tasks update existing entries by id, new ones are added.
- Local-only tasks are preserved.
- Invalid/incomplete tasks are filtered out.
- 同名去重: 同一 (name, assigned_user) 组出现多个可见任务时,只保留
assigned_at 最新的一个(后端推送的优先胜出——"根据后台指令更新任务细节"),
其余标 hidden。公共任务(均无 assigned_user)分组结果与旧版等价。
- view_scope(新增): 当前会话身份。"guest"/None 不触发撤销清除;
用户名时,本地 assigned_user == 该用户名、且本轮后端未返回的任务
(已被后端撤销)从本地清除。游客轮询绝不触发撤销清除——防止把
他人指派任务误删。本机任务(source=local)无论身份一律豁免:它们
不在后端任务表里,按「后端没返回」清除会直接删掉自建任务。
"""
with _lock:
local = _read_raw()
backend_ids = {_tid(t) for t in backend_tasks}
# ── 阶段 0: 撤销清除(仅账号轮询;游客/无身份完全跳过)──
if view_scope not in (None, "guest"):
local = [
lt for lt in local
if is_local_task(lt)
or not (_assigned_user(lt) == view_scope and _tid(lt) not in backend_ids)
]
candidates: list[dict] = []
# ── 阶段 1: 后端任务(按 id 合并,逻辑与现状一致)──
for bt in backend_tasks:
if not _is_valid(bt):
continue
bid = _tid(bt)
local_match = _find(local, bid)
backend_global = max(0, int(bt.get("completed_count") or 0))
if local_match:
# 保留本地覆盖字段
for key in ("total_required", "params", "assigned_user"):
if key in local_match and key not in bt:
bt[key] = local_match[key]
# 进度分账:后端全局数权威;本机完成数/已同步水位沿用本地
_ensure_sync_fields(local_match)
bt["local_count"] = local_match["local_count"]
bt["synced_count"] = local_match["synced_count"]
bt["backend_count"] = backend_global
# 保留删除标记:用户删过的任务不能被后端推送复活
if local_match.get("hidden"):
bt["hidden"] = True
else:
# 新任务:本机 0 段全同步;backend_count 取后端全局数
# (保留 current_count 作新任务初值的旧语义)
bt["local_count"] = 0
bt["synced_count"] = 0
bt["backend_count"] = backend_global
candidates.append(bt)
# ── 阶段 2: 本地独有任务 ──
for lt in local:
if not _is_valid(lt):
continue
if _tid(lt) not in backend_ids:
candidates.append(dict(lt))
# ── 阶段 3: 同名去重((name, assigned_user) 墓碑)──
groups: dict[tuple, list[dict]] = {}
for c in candidates:
groups.setdefault((c["name"], _assigned_user(c)), []).append(c)
for group in groups.values():
visible = [g for g in group if not g.get("hidden")]
if len(visible) <= 1:
continue # 无碰撞(含整组已 hidden)→ 不动
backend_visible = [v for v in visible if _tid(v) in backend_ids]
pool = backend_visible if backend_visible else visible
winner = max(pool, key=_assigned_at_key)
for g in visible:
if g is not winner:
g["hidden"] = True # 持久化墓碑,防止复活
for t in candidates:
_ensure_sync_fields(t)
_recount(t)
t["status"] = _compute_status(t)
save_tasks(candidates)
return load_tasks()
def mark_hidden(task_id: str):
"""Mark a task as hidden so it no longer appears in the UI."""
with _lock:
tasks = _read_raw()
for t in tasks:
if _tid(t) == task_id:
t["hidden"] = True
break
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
def refresh_progress() -> list[dict]:
"""Reload task list with persisted completion counts."""
return load_tasks()
def increment_task_completed(task_name: str, task_id: str | None = None,
assigned_user: str | None = None) -> dict | None:
"""一次录制完成:把对应任务的录制完成次数持久化 +1,返回更新后的任务。
匹配优先级:
1) task_id 提供时按 id 精确匹配(须 name 一致)
2) (name, assigned_user) 精确匹配(assigned_user 为 None 时匹配公共任务)
3) 同名条目唯一时回退唯一匹配(兼容旧数据/手动临时任务名)
4) 多名同名且身份不匹配 → 返回 None 不计数(跨身份串扰防护)
task_id/assigned_user 由调用方从所选任务(_current_task)提供,
而非会话身份——避免"登录用户录制公共任务却按用户名匹配"的错配。
任务不在 tasks.json(手动临时任务名)时不计数,返回 None。
会话目录随后被删除也不影响该计数——进度以持久化值为准。
"""
if not task_name:
return None
with _lock:
tasks = _read_raw()
target = None
if task_id:
for t in tasks:
if _tid(t) == task_id and t.get("name") == task_name:
target = t
break
if target is None:
for t in tasks:
if (t.get("name") == task_name
and _assigned_user(t) == (assigned_user or None)):
target = t
break
if target is None:
matches = [t for t in tasks if t.get("name") == task_name]
if len(matches) == 1:
target = matches[0]
if target is None:
return None
_ensure_sync_fields(target)
target["local_count"] = int(target.get("local_count", 0) or 0) + 1
_recount(target)
target["status"] = _compute_status(target)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
return dict(target)
def pending_sync_tasks() -> list[dict]:
"""所有 local_count > synced_count 的任务快照(含 hidden,不按身份过滤——
身份不符由服务端 401/403 处理,跳过本轮即可)。
纯读路径:直接 _read_raw(),跳过未迁移记录(无 local_count),
绝不触发迁移写盘(flush 只上报,不改变本地计数语义)。
本机任务(source=local)显式跳过:后端没有对应任务,上报必被拒 400。
"""
with _lock:
tasks = _read_raw()
out = []
for t in tasks:
if is_local_task(t):
continue
local = t.get("local_count")
if not isinstance(local, int):
continue
synced = int(t.get("synced_count", 0) or 0)
tid = _tid(t)
if local > synced and tid:
out.append({"id": tid, "name": t.get("name", ""),
"local_count": local, "synced_count": synced})
return out
def mark_synced(task_id: str, synced_count: int, backend_count: int) -> dict | None:
"""flush 成功后回写同步状态。
synced_count 必须是「快照水位 + 本次增量」(不是当前 local_count——
快照与响应之间 UI 线程可能又录了一段);水位只前进不后退;
backend_count 用响应值无条件替换(服务端权威,迁移基线可能高于真实
全局数,不能 max-前向)。返回更新后的任务。
"""
with _lock:
tasks = _read_raw()
target = _find(tasks, task_id)
if target is None or not isinstance(target.get("local_count"), int):
return None
if synced_count > (int(target.get("synced_count", 0) or 0)):
target["synced_count"] = synced_count
target["backend_count"] = max(0, int(backend_count or 0))
_recount(target)
target["status"] = _compute_status(target)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
return dict(target)
def task_by_id(task_id: str) -> dict | None:
tasks = load_tasks()
return _find(tasks, task_id)
# ═══════════════════════════════════════════════════════════
# Internal
# ═══════════════════════════════════════════════════════════
def _read_raw() -> list[dict]:
if not os.path.isfile(_TASKS_FILE):
_init_seed()
try:
with open(_TASKS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("tasks", [])
except (json.JSONDecodeError, OSError):
_init_seed()
return [dict(t) for t in _SEED_TASKS]
def _write(data: dict):
os.makedirs(os.path.dirname(_TASKS_FILE), exist_ok=True)
tmp = _TASKS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, _TASKS_FILE)
def _init_seed():
_write({"tasks": [dict(t) for t in _SEED_TASKS], "updated_at": datetime.now().isoformat()})
def _tid(task: dict) -> str:
return task.get("id", task.get("task_id", ""))
def _assigned_user(task: dict):
"""任务的指派用户;null/空串/缺失统一归一化为 None(= 公共任务)。"""
return task.get("assigned_user") or None
def _assigned_at_key(task: dict) -> tuple:
"""排序键: 带有效时间的排前,null/空/解析失败排后。
(0, raw) 与 (1, ts) 首元素不同,跨类不会比较第二元素。"""
raw = task.get("assigned_at") or ""
if not raw:
return (0, "")
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return (1, dt.timestamp())
except ValueError:
return (0, raw) # 解析失败: 排最后,按原始字符串保序
def _find(tasks: list[dict], task_id: str) -> dict | None:
for t in tasks:
if _tid(t) == task_id:
return t
return None
def _is_valid(task: dict) -> bool:
name = task.get("name", task.get("task_name", ""))
tid = task.get("id", task.get("task_id", ""))
return bool(name and name.strip()) and bool(tid and tid.strip()) and task.get("total_required", 0) > 0
def _compute_status(task: dict) -> str:
total = task.get("total_required", 0)
completed = task.get("completed_count", 0)
if total <= 0:
return "pending"
if completed >= total:
return "completed"
if completed > 0:
return "in_progress"
return "pending"
def _count_with_fallback(task: dict) -> int:
"""任务的录制完成次数:优先持久化值,缺字段(旧版本数据)回退目录扫描。"""
cur = task.get("completed_count")
if isinstance(cur, int) and cur >= 0:
return cur
return _count_sessions(task.get("name", ""))
def _count_sessions(task_name: str) -> int:
"""扫描录制目录统计 episode 数 —— 仅用于旧数据迁移回填初值,非进度权威来源。
v1.1.0 池化布局:list_task_episodes(videos/data 文件组扫描,与
episodes 每段文件一致——删除时两者同步移除);旧格式任务目录
回退按会话子目录计数。
"""
if not task_name:
return 0
task_dir = os.path.join(settings.RECORDING_DIR, task_name)
if not os.path.isdir(task_dir):
return 0
try:
from core.helpers import detect_session_format, list_task_episodes
if detect_session_format(task_dir) == "pooled":
return len(list_task_episodes(task_dir))
except ImportError:
pass
count = 0
try:
for entry in os.listdir(task_dir):
ses_dir = os.path.join(task_dir, entry)
if not os.path.isdir(ses_dir):
continue
if os.path.isfile(os.path.join(ses_dir, "meta", "info.json")):
count += 1
except OSError:
pass
return count
def _ensure_sync_fields(task: dict) -> bool:
"""补全 local/synced/backend 三字段(迁移);返回是否发生迁移(需写回)。
迁移(幂等,字段存在且为合法 int 即跳过):
local_count = 旧 completed_count(目录扫描兜底)
synced_count = local_count(历史不补报,避免重复计数)
backend_count = local_count(历史基线保显示连续;首次 merge/flush
用真实全局数无条件替换)
"""
changed = False
local = task.get("local_count")
if not isinstance(local, int) or local < 0:
task["local_count"] = _count_with_fallback(task)
changed = True
synced = task.get("synced_count")
if not isinstance(synced, int) or synced < 0:
task["synced_count"] = task["local_count"]
changed = True
backend = task.get("backend_count")
if not isinstance(backend, int) or backend < 0:
task["backend_count"] = task["local_count"]
changed = True
return changed
def _recount(task: dict) -> int:
"""显示值 = backend_count + (local_count - synced_count),写回 completed_count。
防御:synced 不超 local(异常水位按已同步处理,宁可少显示不可多显示)。
"""
local = int(task.get("local_count", 0) or 0)
synced = min(int(task.get("synced_count", 0) or 0), local)
task["completed_count"] = int(task.get("backend_count", 0) or 0) + (local - synced)
return task["completed_count"]