opencode-agora 0.1.0__py3-none-any.whl
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.
- agora_mcp.py +240 -0
- agorad/__init__.py +1 -0
- agorad/access/__init__.py +1 -0
- agorad/access/anchors.py +22 -0
- agorad/access/gitx.py +206 -0
- agorad/access/oc.py +124 -0
- agorad/access/pages.py +25 -0
- agorad/access/store.py +181 -0
- agorad/core/__init__.py +1 -0
- agorad/core/app.py +137 -0
- agorad/core/auth.py +134 -0
- agorad/core/gate.py +281 -0
- agorad/core/health.py +182 -0
- agorad/core/msg.py +92 -0
- agorad/core/peer.py +90 -0
- agorad/core/recover.py +83 -0
- agorad/core/report.py +189 -0
- agorad/core/state.py +183 -0
- agorad/core/task.py +308 -0
- agorad/installer.py +44 -0
- agorad/main.py +181 -0
- agorad/transport/__init__.py +1 -0
- agorad/transport/httpd.py +316 -0
- board_tui.py +649 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora +244 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/cred.ts +112 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/envPath.ts +33 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/hook.ts +132 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/index.ts +160 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/inject.ts +49 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/lift.ts +161 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/scope.ts +36 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/install.sh +222 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-dispatch/SKILL.md +95 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-ops/SKILL.md +60 -0
- opencode_agora-0.1.0.dist-info/METADATA +195 -0
- opencode_agora-0.1.0.dist-info/RECORD +41 -0
- opencode_agora-0.1.0.dist-info/WHEEL +5 -0
- opencode_agora-0.1.0.dist-info/entry_points.txt +5 -0
- opencode_agora-0.1.0.dist-info/licenses/LICENSE +21 -0
- opencode_agora-0.1.0.dist-info/top_level.txt +3 -0
agorad/core/recover.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""崩溃恢复协调(F6,设计 §4.6):启动复查重注入、死 worker 自愈原语。
|
|
2
|
+
|
|
3
|
+
startup:State 构造已恢复末行快照,此处对 in-flight 任务探活,仅
|
|
4
|
+
dispatched/running 重建 worker 会话(终态/blocked/verify_failed 等续修态
|
|
5
|
+
复查后一律不注入)。reinject:oc.create 同 BOOT 语义(任务页+环境+行为
|
|
6
|
+
合同),新 sid 覆写 worker_sid;旧 worker 牌经 auth.rebind 改绑新 sid
|
|
7
|
+
(token 本体不变、identify 归新会话)并随 dispatch 同形送达凭据(直投失败经
|
|
8
|
+
注入的 router 落库兜底);审计一行 reinjected。任何失败返回 False 不抛:先
|
|
9
|
+
create 成功才覆写行,不半写。core 只 import stdlib,依赖全构造注入。
|
|
10
|
+
"""
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
_REINJECTABLE = {"dispatched", "running"}
|
|
14
|
+
# 未结案嵌套子任务均随父 worker 改绑;todo 虽尚未派发,也不能在恢复后丢失所有权。
|
|
15
|
+
_NESTED_OPEN = {"todo", "dispatched", "running", "done", "verify_failed", "reopened", "blocked"}
|
|
16
|
+
|
|
17
|
+
EV_REINJECTED = "reinjected"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Recover:
|
|
21
|
+
"""startup 由 main 装配期调用一次;reinject 供 health.tick 与启动共用。"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, state, oc, gitx, store, auth, router=None):
|
|
24
|
+
self._state, self._oc, self._gitx = state, oc, gitx
|
|
25
|
+
self._store, self._auth, self._router = store, auth, router
|
|
26
|
+
|
|
27
|
+
def startup(self):
|
|
28
|
+
"""启动复查重注入;先清残留门闩——门线程不跨进程存活,kill -9 残闩必清。"""
|
|
29
|
+
self._state.clear_gate_latches()
|
|
30
|
+
for tid, t in self._state.snapshot()["tasks"].items():
|
|
31
|
+
if t["state"] not in _REINJECTABLE or not t.get("worker_sid"):
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
alive = self._oc.alive(t["worker_sid"])
|
|
35
|
+
except Exception: # oc 不可达≠会话死:本轮免判,tick 再兜
|
|
36
|
+
continue
|
|
37
|
+
if not alive:
|
|
38
|
+
self.reinject(tid)
|
|
39
|
+
|
|
40
|
+
def reinject(self, tid) -> bool:
|
|
41
|
+
"""死 worker 重建:成功 True;复查不通过/创建失败一律 False。"""
|
|
42
|
+
try:
|
|
43
|
+
t = self._state.get(tid)
|
|
44
|
+
except KeyError:
|
|
45
|
+
return False
|
|
46
|
+
if t["state"] not in _REINJECTABLE or not t.get("env_dir"):
|
|
47
|
+
return False # 复查:终态/blocked/verify_failed(等续修) 不重注入
|
|
48
|
+
try:
|
|
49
|
+
self._gitx.head(t["env_dir"]) # 环境在册探针:worktree 已失不盲注
|
|
50
|
+
wsid = self._oc.create(t["env_dir"], self._boot(tid, t))
|
|
51
|
+
except Exception:
|
|
52
|
+
return False
|
|
53
|
+
old_wsid = t.get("worker_sid")
|
|
54
|
+
self._state.set_fields(tid, worker_sid=wsid) # 新 sid 覆写
|
|
55
|
+
for cid, child in self._state.snapshot()["tasks"].items():
|
|
56
|
+
if (child.get("dispatcher_sid") == old_wsid
|
|
57
|
+
and child["state"] in _NESTED_OPEN):
|
|
58
|
+
self._state.set_fields(cid, dispatcher_sid=wsid)
|
|
59
|
+
self._store.append_event(
|
|
60
|
+
{"ts": time.time(), "actor": "agorad",
|
|
61
|
+
"event": "dispatcher_rebind", "task": cid,
|
|
62
|
+
"from": old_wsid, "to": wsid})
|
|
63
|
+
tok = self._auth.rebind(old_wsid, wsid) # 旧牌改绑新会话
|
|
64
|
+
self._store.append_event({"ts": time.time(), "actor": "agorad",
|
|
65
|
+
"event": EV_REINJECTED, "task": tid,
|
|
66
|
+
"worker": wsid})
|
|
67
|
+
if tok: # 凭据随 dispatch 同形送达(签牌文本 inject 进新会话)
|
|
68
|
+
text = f"[agora] credential {tid}\ntoken: {tok}"
|
|
69
|
+
try:
|
|
70
|
+
ok = self._oc.inject(wsid, text)
|
|
71
|
+
except Exception: # oc 抖动同死会话口径:走落库兜底
|
|
72
|
+
ok = False
|
|
73
|
+
if not ok and self._router is not None: # 直投失败落库:补拉可取回
|
|
74
|
+
self._router.post(t["dispatcher_sid"], ("down", tid),
|
|
75
|
+
"credential", text)
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def _boot(self, tid, t):
|
|
79
|
+
page = self._store.home / "wiki" / "tasks" / f"{tid}.md"
|
|
80
|
+
return (f"[agora] {tid}: {t.get('title', tid)} (reinject)\npage: {page}\n"
|
|
81
|
+
f"env: {t['env_dir']}\n"
|
|
82
|
+
"contract: 会话曾死亡已重建——先 check_mail 补拉断点上下文,"
|
|
83
|
+
"继续任务页目标;结论经 post(done) 回填")
|
agorad/core/report.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""状态视图(F10,设计 §4.1):五桶/链视图/明细/doctor/HTML——纯函数集。
|
|
2
|
+
|
|
3
|
+
snap/store 进来、dict/list/str 出去,零状态零副作用(store 仅读原语)。
|
|
4
|
+
五桶机械口径:已合并=merged+closed(closed 是合并后唯一后继);在飞=其余
|
|
5
|
+
非终态全部(含 todo 未派、done 待并;verify_failed/reopened 是续修态归在飞);
|
|
6
|
+
失败=空集保留位——v2 状态集无独立"失败"终态(质量败走 verify_failed 续修,
|
|
7
|
+
3 连败自动 blocked 归阻塞桶);阻塞=blocked;已取消=cancelled。
|
|
8
|
+
"""
|
|
9
|
+
import html
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_MERGED = {"merged", "closed"}
|
|
15
|
+
_STALE_AFTER = 1200
|
|
16
|
+
# 僵态候选口径(F5 决策记录 2026-10-28):blocked 已属失控拍过人、终态与 todo
|
|
17
|
+
# 无 worker 活动面——均排除。
|
|
18
|
+
_STALE_STATES = {"dispatched", "running", "done", "verify_failed", "reopened"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _env_activity(env_dir):
|
|
22
|
+
"""env 工作区文件活性(F15):目录树最新 mtime(含目录)——worker 原生干活期
|
|
23
|
+
不碰工具面,文件在写即活着;缺失/不可读返 0.0。纯 stdlib(core 勿 import access)。
|
|
24
|
+
"""
|
|
25
|
+
if not env_dir:
|
|
26
|
+
return 0.0
|
|
27
|
+
latest = 0.0
|
|
28
|
+
for root, _dirs, files in os.walk(env_dir):
|
|
29
|
+
for p in [root, *(os.path.join(root, f) for f in files)]:
|
|
30
|
+
try:
|
|
31
|
+
latest = max(latest, os.stat(p).st_mtime)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
return latest
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _stale_baseline(t):
|
|
38
|
+
"""僵态活性基线(F15):max(last_seen or created, env 活性)——文件持续在写=活着。"""
|
|
39
|
+
return max(t.get("last_seen") or t.get("created", 0.0),
|
|
40
|
+
_env_activity(t.get("env_dir")))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def buckets(snap):
|
|
44
|
+
"""五桶汇总:桶名→任务 id 列表(失败桶恒空,口径见模块 docstring)。"""
|
|
45
|
+
out = {"已合并": [], "在飞": [], "失败": [], "阻塞": [], "已取消": []}
|
|
46
|
+
for tid, t in sorted(snap["tasks"].items()):
|
|
47
|
+
s = t["state"]
|
|
48
|
+
if s in _MERGED:
|
|
49
|
+
out["已合并"].append(tid)
|
|
50
|
+
elif s == "blocked":
|
|
51
|
+
out["阻塞"].append(tid)
|
|
52
|
+
elif s == "cancelled":
|
|
53
|
+
out["已取消"].append(tid)
|
|
54
|
+
else:
|
|
55
|
+
out["在飞"].append(tid)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def chain_view(snap, dispatcher_sid):
|
|
60
|
+
"""链级视图:该会话直接派的任务+其 worker 再派的下级(逐跳传递展开)。
|
|
61
|
+
|
|
62
|
+
seen 集合防环(账本行语义上无环,但环行入账时 /view 不得挂死)兼去重。
|
|
63
|
+
"""
|
|
64
|
+
out, frontier, seen = [], [dispatcher_sid], {dispatcher_sid}
|
|
65
|
+
while frontier:
|
|
66
|
+
sid = frontier.pop(0)
|
|
67
|
+
for tid, t in sorted(snap["tasks"].items()):
|
|
68
|
+
if t.get("dispatcher_sid") == sid:
|
|
69
|
+
out.append(tid)
|
|
70
|
+
w = t.get("worker_sid")
|
|
71
|
+
if w and w not in seen:
|
|
72
|
+
seen.add(w)
|
|
73
|
+
frontier.append(w)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _layer_key(tasks):
|
|
78
|
+
"""同层排序键(gt-c05d6e):created 降序(近者在前);缺失/0 兜底 tid 字典序垫底(稳定)。"""
|
|
79
|
+
def key(tid):
|
|
80
|
+
c = tasks[tid].get("created")
|
|
81
|
+
return (1, tid) if not c else (-c, tid)
|
|
82
|
+
return key
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def forest_view(snap):
|
|
86
|
+
"""全局链森林(gt-6def73):父子边仍按 worker_sid↔dispatcher_sid 上溯,
|
|
87
|
+
但在全任务集找父;根=全任务集内无父的任务。输出 DFS 子树聚簇序 tid 列表
|
|
88
|
+
(同层按 created 降序近者在前,缺失兜底 tid 序,稳定可测);显式栈防递归,
|
|
89
|
+
seen 集环防护有界(环岛 DFS 不可达,按同键序尾附兜底,全员在列)。
|
|
90
|
+
"""
|
|
91
|
+
tasks = snap["tasks"]
|
|
92
|
+
parent = {tid: next((p for p in sorted(tasks)
|
|
93
|
+
if tasks[p].get("worker_sid")
|
|
94
|
+
== t.get("dispatcher_sid")), None)
|
|
95
|
+
for tid, t in tasks.items()}
|
|
96
|
+
rank = _layer_key(tasks)
|
|
97
|
+
kids, roots = {tid: [] for tid in tasks}, []
|
|
98
|
+
for tid in sorted(tasks, key=rank):
|
|
99
|
+
p = parent[tid]
|
|
100
|
+
(roots if p is None else kids[p]).append(tid)
|
|
101
|
+
out, seen, stack = [], set(), roots[::-1]
|
|
102
|
+
while stack:
|
|
103
|
+
tid = stack.pop()
|
|
104
|
+
if tid in seen:
|
|
105
|
+
continue
|
|
106
|
+
seen.add(tid)
|
|
107
|
+
out.append(tid)
|
|
108
|
+
stack.extend(reversed(kids[tid]))
|
|
109
|
+
return out + [tid for tid in sorted(tasks, key=rank) if tid not in seen]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def detail(snap, store, tid):
|
|
113
|
+
"""单任务明细三件套:全字段行+事件链+done 正文(最新一条=当前结论)。
|
|
114
|
+
|
|
115
|
+
F15 增 env_active_seconds_ago:env 在盘时 now-最新 mtime(int 秒);回收/缺失省略。
|
|
116
|
+
"""
|
|
117
|
+
done = [m for m in store.read_messages()
|
|
118
|
+
if m.get("task") == tid and m.get("kind") == "done"]
|
|
119
|
+
out = {"task": dict(snap["tasks"][tid]), "events": store.read_events(tid),
|
|
120
|
+
"done_body": done[-1]["body"] if done else None}
|
|
121
|
+
env = out["task"].get("env_dir")
|
|
122
|
+
if env and Path(env).is_dir():
|
|
123
|
+
act = _env_activity(env)
|
|
124
|
+
if act > 0.0: # is_dir 后被并发回收→walk 空,省略而非输出 1.7e9 假值
|
|
125
|
+
out["env_active_seconds_ago"] = int(time.time() - act)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stale_candidates(snap, now, base=None):
|
|
130
|
+
"""僵态候选公开口径(F5;F15 融 env 活性):[[tid, 基线ts],...] 基线升序(最僵在前)。
|
|
131
|
+
|
|
132
|
+
在飞口径状态(_STALE_STATES)且 now-_stale_baseline(t)>_STALE_AFTER,
|
|
133
|
+
基线=max(last_seen or created, env 文件活性);health tick 构造注入复用同一口径。
|
|
134
|
+
base=doctor_view 预计算基线 map(评审环 round1:免同请求二遍全树 walk),缺省现算。
|
|
135
|
+
"""
|
|
136
|
+
cand = []
|
|
137
|
+
for tid, t in snap["tasks"].items():
|
|
138
|
+
# shortcut: 门线程挂死(verify 永不返回)随 in_gate 永久免检,门故障类报警另立,when: 出现首例门挂死
|
|
139
|
+
if t["state"] in _STALE_STATES and not t.get("in_gate"):
|
|
140
|
+
b = base.get(tid) if base is not None else None
|
|
141
|
+
if b is None:
|
|
142
|
+
b = _stale_baseline(t)
|
|
143
|
+
if now - b > _STALE_AFTER:
|
|
144
|
+
cand.append([tid, b])
|
|
145
|
+
cand.sort(key=lambda kv: kv[1])
|
|
146
|
+
return cand
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def doctor_view(snap, store_home, now=None):
|
|
150
|
+
"""账本侧事实:任务年龄/最后活动排行+僵态候选+env_dir 在盘性(盘上孤儿扫描属②期)。
|
|
151
|
+
|
|
152
|
+
排行活性基线(F15):age_rank/last_seen_rank 同取 _stale_baseline(与
|
|
153
|
+
stale_candidates 同口径),字段名不变、值改为融合基线的 now- 差与原值。
|
|
154
|
+
僵态候选(F5):同 stale_candidates 口径,纯显示,处置归上级判断。
|
|
155
|
+
"""
|
|
156
|
+
now = time.time() if now is None else now
|
|
157
|
+
home, tasks = Path(store_home), snap["tasks"]
|
|
158
|
+
base = {tid: _stale_baseline(t) for tid, t in tasks.items()}
|
|
159
|
+
ranked = sorted(tasks, key=lambda tid: base[tid])
|
|
160
|
+
cand = stale_candidates(snap, now, base=base)
|
|
161
|
+
env = []
|
|
162
|
+
for tid, t in sorted(tasks.items()):
|
|
163
|
+
e = t.get("env_dir")
|
|
164
|
+
if not e:
|
|
165
|
+
continue
|
|
166
|
+
p = Path(e)
|
|
167
|
+
env.append({"tid": tid, "env_dir": e,
|
|
168
|
+
"on_disk": (p if p.is_absolute() else home / p).exists()})
|
|
169
|
+
return {"age_rank": [[tid, now - base[tid]] for tid in ranked],
|
|
170
|
+
"last_seen_rank": [[tid, base[tid]] for tid in ranked],
|
|
171
|
+
"stale_candidates": cand,
|
|
172
|
+
"env": env}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def view_html(snap):
|
|
176
|
+
"""/view 最小页:五桶计数明细+链表(dispatcher→worker 链边)。"""
|
|
177
|
+
esc = lambda v: html.escape(str(v))
|
|
178
|
+
parts = ["<html><body><h1>agora board</h1>"]
|
|
179
|
+
for name, tids in buckets(snap).items():
|
|
180
|
+
parts.append(f"<h2>{name} {len(tids)}</h2><ul>")
|
|
181
|
+
parts.extend(f"<li>{esc(i)}</li>" for i in tids)
|
|
182
|
+
parts.append("</ul>")
|
|
183
|
+
parts.append("<h2>链</h2><ul>")
|
|
184
|
+
for tid, t in sorted(snap["tasks"].items()):
|
|
185
|
+
parts.append(f"<li>{esc(tid)}: {esc(t['state'])} "
|
|
186
|
+
f"{esc(t.get('dispatcher_sid'))}→{esc(t.get('worker_sid'))} "
|
|
187
|
+
f"{esc(t.get('title', ''))}</li>")
|
|
188
|
+
parts.append("</ul></body></html>")
|
|
189
|
+
return "".join(parts)
|
agorad/core/state.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""权威任务状态机+内存快照+fail_streak+审计事件名(设计 §4.1/§4.8)。
|
|
2
|
+
|
|
3
|
+
单写者内存直接迁移、无事件推导;每次迁移/计数变更 append 一行全量快照
|
|
4
|
+
(含 mail_read 游标);last_seen/mail_read 为内存态、崩溃回退接受;
|
|
5
|
+
fail_streak 随快照行持久化,kill -9 不丢。Store 对象构造注入,零磁盘接触。
|
|
6
|
+
三线程(httpd 消费/gate 门体/health 定时)并发 mutate+append:内置锁使
|
|
7
|
+
内存迁移与 append 同临界区(§4.8 防线);cancel 排队(queue_cancel)与
|
|
8
|
+
门尾消费(gate_tail)各为单一原子操作——pending_cancel 不悬挂。
|
|
9
|
+
"""
|
|
10
|
+
import copy
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
# 审计事件名常量:事件名=里程碑迁移后的状态名(F8:迁移状态+审计)
|
|
15
|
+
EV_DISPATCHED = "dispatched"
|
|
16
|
+
EV_RUNNING = "running"
|
|
17
|
+
EV_DONE = "done"
|
|
18
|
+
EV_VERIFY_FAILED = "verify_failed"
|
|
19
|
+
EV_REOPENED = "reopened"
|
|
20
|
+
EV_MERGED = "merged"
|
|
21
|
+
EV_CLOSED = "closed"
|
|
22
|
+
EV_BLOCKED = "blocked"
|
|
23
|
+
EV_CANCELLED = "cancelled"
|
|
24
|
+
|
|
25
|
+
# 迁移矩阵:主链 todo→dispatched→running→done→merged→closed;blocked 双出口
|
|
26
|
+
# (cancel/对同任务再 dispatch);done→verify_failed(verify 败)与 reopened
|
|
27
|
+
# (集成败/非 ff)→running 续修再 done。verify_failed→blocked 不在此列——
|
|
28
|
+
# 它只由 fail_streak≥3 自动触发,非手工边。
|
|
29
|
+
LEGAL = {
|
|
30
|
+
"todo": {"dispatched", "cancelled"},
|
|
31
|
+
"dispatched": {"running", "cancelled"},
|
|
32
|
+
"running": {"done", "cancelled"},
|
|
33
|
+
"done": {"merged", "verify_failed", "reopened"},
|
|
34
|
+
"verify_failed": {"running", "cancelled"},
|
|
35
|
+
"reopened": {"running", "cancelled"},
|
|
36
|
+
"merged": {"closed"},
|
|
37
|
+
"blocked": {"cancelled", "dispatched"},
|
|
38
|
+
"cancelled": set(),
|
|
39
|
+
"closed": set(),
|
|
40
|
+
}
|
|
41
|
+
TERMINAL = {"cancelled", "closed"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InvalidTransition(Exception):
|
|
45
|
+
def __init__(self, tid, frm, to, legal):
|
|
46
|
+
self.tid, self.frm, self.to, self.legal = tid, frm, to, legal
|
|
47
|
+
super().__init__(f"{tid}: {frm}->{to} 非法(合法后继:{sorted(legal)})")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class State:
|
|
51
|
+
"""权威任务状态:内存迁移+append_state 落盘;末行快照=权威(重启 load 恢复)。
|
|
52
|
+
|
|
53
|
+
全部读写持内置锁(RLock:gate_tail 结算内重入迁移);持久化在锁内完成,
|
|
54
|
+
快照行序=迁移序(§4.8)。
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, store):
|
|
58
|
+
self._store = store
|
|
59
|
+
self._lock = threading.RLock()
|
|
60
|
+
snap = store.load_state() or {}
|
|
61
|
+
self._tasks = {tid: dict(t) for tid, t in snap.get("tasks", {}).items()}
|
|
62
|
+
self._mail_read = dict(snap.get("mail_read", {}))
|
|
63
|
+
|
|
64
|
+
def get(self, tid):
|
|
65
|
+
with self._lock:
|
|
66
|
+
return copy.deepcopy(self._tasks[tid])
|
|
67
|
+
|
|
68
|
+
def add(self, tid, row):
|
|
69
|
+
"""播种新任务行(task_create;行构造归调用方)——迁移矩阵只管既有行。"""
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._tasks[tid] = dict(row)
|
|
72
|
+
self._persist()
|
|
73
|
+
|
|
74
|
+
def set_fields(self, tid, **fields):
|
|
75
|
+
"""非迁移字段直改(verify 登记/pending-cancel)——不越迁移矩阵。"""
|
|
76
|
+
with self._lock:
|
|
77
|
+
self._tasks[tid].update(fields)
|
|
78
|
+
self._tasks[tid]["updated"] = time.time()
|
|
79
|
+
self._persist()
|
|
80
|
+
|
|
81
|
+
def snapshot(self):
|
|
82
|
+
with self._lock:
|
|
83
|
+
return self._snap()
|
|
84
|
+
|
|
85
|
+
def touch_mail(self, sid):
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._mail_read[sid] = time.time()
|
|
88
|
+
|
|
89
|
+
def touch_seen(self, tid):
|
|
90
|
+
"""last_seen 心跳只进内存(gt-a18ef6 写放大根因修复):O(调用数) 全量
|
|
91
|
+
快照落盘曾致账本 4.4GB——追加仅随真实迁移;崩溃回退按模块头语义
|
|
92
|
+
接受(同 touch_mail 口径)。"""
|
|
93
|
+
with self._lock:
|
|
94
|
+
t = self._tasks.get(tid)
|
|
95
|
+
if t is not None:
|
|
96
|
+
t["last_seen"] = time.time()
|
|
97
|
+
|
|
98
|
+
def transition(self, tid, to, **fields):
|
|
99
|
+
with self._lock:
|
|
100
|
+
t = self._tasks[tid]
|
|
101
|
+
legal = LEGAL.get(t["state"], set())
|
|
102
|
+
if to not in legal:
|
|
103
|
+
raise InvalidTransition(tid, t["state"], to, legal)
|
|
104
|
+
t["state"] = to
|
|
105
|
+
t["updated"] = time.time()
|
|
106
|
+
t.update(fields)
|
|
107
|
+
if to == EV_DISPATCHED:
|
|
108
|
+
t["fail_streak"] = 0 # 对同任务再 dispatch 清零连败
|
|
109
|
+
self._persist()
|
|
110
|
+
|
|
111
|
+
def queue_cancel(self, tid, cancel_reason=None) -> bool:
|
|
112
|
+
"""门临界段 cancel 排队(持锁判定+置位一体):True=待门尾消费。
|
|
113
|
+
|
|
114
|
+
done 一律排队(迁移矩阵 done 无 cancelled 出口,必经门尾);与 gate_tail
|
|
115
|
+
同锁互斥——门尾结算前后到达的排队要么被取走、要么待下一门跑/重启重排
|
|
116
|
+
(F-4)消费,判定与置位不撕裂。
|
|
117
|
+
"""
|
|
118
|
+
with self._lock:
|
|
119
|
+
t = self._tasks[tid]
|
|
120
|
+
if t["state"] != EV_DONE:
|
|
121
|
+
return False
|
|
122
|
+
t["pending_cancel"] = True
|
|
123
|
+
if cancel_reason is not None:
|
|
124
|
+
t["pending_cancel_reason"] = cancel_reason
|
|
125
|
+
else:
|
|
126
|
+
t.pop("pending_cancel_reason", None) # 后来的主动取消覆盖自动停链原因
|
|
127
|
+
t["updated"] = time.time()
|
|
128
|
+
self._persist()
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def clear_gate_latches(self):
|
|
132
|
+
"""启动清残留门闩:门线程不跨进程存活,kill -9 残闩是撒谎闩(二批决策)。"""
|
|
133
|
+
with self._lock:
|
|
134
|
+
for tid, t in self._tasks.items():
|
|
135
|
+
if t.get("in_gate"):
|
|
136
|
+
t["in_gate"] = False
|
|
137
|
+
self._store.append_event({"ts": time.time(), "actor": "agorad",
|
|
138
|
+
"event": "gate_latch_reset", "task": tid})
|
|
139
|
+
self._persist()
|
|
140
|
+
|
|
141
|
+
def gate_tail(self, tid) -> dict:
|
|
142
|
+
"""门尾原子结算:pending 取走+终态写入同临界区(cancel×门尾 TOCTOU 封闭)。
|
|
143
|
+
|
|
144
|
+
pending 且非 merged→cancelled;merged→closed(不逆转历史);余者仅清
|
|
145
|
+
门闩。返回结算后行快照(含 env/会话指针,供门侧副作用)。
|
|
146
|
+
"""
|
|
147
|
+
with self._lock:
|
|
148
|
+
t = self._tasks[tid]
|
|
149
|
+
pending = bool(t.pop("pending_cancel", False))
|
|
150
|
+
cancel_reason = t.pop("pending_cancel_reason", None)
|
|
151
|
+
if pending and t["state"] != EV_MERGED:
|
|
152
|
+
fields = {"cancel_reason": cancel_reason} if cancel_reason else {}
|
|
153
|
+
self.transition(tid, EV_CANCELLED, in_gate=False, **fields)
|
|
154
|
+
elif t["state"] == EV_MERGED:
|
|
155
|
+
self.transition(tid, EV_CLOSED, in_gate=False)
|
|
156
|
+
else:
|
|
157
|
+
t["in_gate"] = False
|
|
158
|
+
t["updated"] = time.time()
|
|
159
|
+
self._persist()
|
|
160
|
+
return copy.deepcopy(t)
|
|
161
|
+
|
|
162
|
+
def fail_streak_bump(self, tid):
|
|
163
|
+
with self._lock:
|
|
164
|
+
t = self._tasks[tid]
|
|
165
|
+
t["fail_streak"] = t.get("fail_streak", 0) + 1
|
|
166
|
+
t["updated"] = time.time()
|
|
167
|
+
if t["fail_streak"] >= 3 and t["state"] not in TERMINAL | {"blocked"}:
|
|
168
|
+
t["state"] = EV_BLOCKED # 连续 3 次质量败→自动 blocked
|
|
169
|
+
self._persist()
|
|
170
|
+
|
|
171
|
+
def fail_streak_reset(self, tid):
|
|
172
|
+
with self._lock:
|
|
173
|
+
t = self._tasks[tid]
|
|
174
|
+
t["fail_streak"] = 0
|
|
175
|
+
t["updated"] = time.time()
|
|
176
|
+
self._persist()
|
|
177
|
+
|
|
178
|
+
def _snap(self):
|
|
179
|
+
return {"version": 1, "tasks": copy.deepcopy(self._tasks),
|
|
180
|
+
"mail_read": dict(self._mail_read)}
|
|
181
|
+
|
|
182
|
+
def _persist(self):
|
|
183
|
+
self._store.append_state(self._snap()) # 调用方持锁:迁移+append 同临界区
|