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.
Files changed (41) hide show
  1. agora_mcp.py +240 -0
  2. agorad/__init__.py +1 -0
  3. agorad/access/__init__.py +1 -0
  4. agorad/access/anchors.py +22 -0
  5. agorad/access/gitx.py +206 -0
  6. agorad/access/oc.py +124 -0
  7. agorad/access/pages.py +25 -0
  8. agorad/access/store.py +181 -0
  9. agorad/core/__init__.py +1 -0
  10. agorad/core/app.py +137 -0
  11. agorad/core/auth.py +134 -0
  12. agorad/core/gate.py +281 -0
  13. agorad/core/health.py +182 -0
  14. agorad/core/msg.py +92 -0
  15. agorad/core/peer.py +90 -0
  16. agorad/core/recover.py +83 -0
  17. agorad/core/report.py +189 -0
  18. agorad/core/state.py +183 -0
  19. agorad/core/task.py +308 -0
  20. agorad/installer.py +44 -0
  21. agorad/main.py +181 -0
  22. agorad/transport/__init__.py +1 -0
  23. agorad/transport/httpd.py +316 -0
  24. board_tui.py +649 -0
  25. opencode_agora-0.1.0.data/data/share/opencode-agora/agora +244 -0
  26. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/cred.ts +112 -0
  27. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/envPath.ts +33 -0
  28. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/hook.ts +132 -0
  29. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/index.ts +160 -0
  30. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/inject.ts +49 -0
  31. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/lift.ts +161 -0
  32. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/scope.ts +36 -0
  33. opencode_agora-0.1.0.data/data/share/opencode-agora/install.sh +222 -0
  34. opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-dispatch/SKILL.md +95 -0
  35. opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-ops/SKILL.md +60 -0
  36. opencode_agora-0.1.0.dist-info/METADATA +195 -0
  37. opencode_agora-0.1.0.dist-info/RECORD +41 -0
  38. opencode_agora-0.1.0.dist-info/WHEEL +5 -0
  39. opencode_agora-0.1.0.dist-info/entry_points.txt +5 -0
  40. opencode_agora-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. opencode_agora-0.1.0.dist-info/top_level.txt +3 -0
agorad/core/gate.py ADDED
@@ -0,0 +1,281 @@
1
+ """verify 门与自动合并:任务环境验收→集成环境试合并/验收→目标支最终推进。
2
+
3
+ done 受理仍执行状态、verify、commit 格式与评审锚前置校验。门体先在任务 env 跑
4
+ verify,再在目标分支当前 HEAD 的 detached integration worktree 内 ff-only 试合并
5
+ 并重跑 verify;只有全绿且目标主检出两次干净检查通过,才推进真实目标分支。集成
6
+ 失败不再依赖 reset --hard 回滚,用户未提交工作与目标分支均不受测试副作用影响。
7
+
8
+ 默认 runner 以独立进程组执行命令,超时杀全组并返回 124,避免单个挂死验证永久
9
+ 堵住串行门。崩溃重排先探测目标支是否已含任务 commit:已含说明上轮最终推进已
10
+ 完成,只补 merged/closed 终态,不重验后假 reopened。gate_env 与 target_branch 的
11
+ 原始分支位随任务快照持久化,集成环境和分支位均可跨重启收敛。
12
+
13
+ 全程 in-gate 门闩、pending-cancel 门尾原子结算、三连败 blocked 与事件审计保持
14
+ 不变。core 只 import stdlib,依赖全构造注入。
15
+ """
16
+ import os
17
+ import signal
18
+ import subprocess
19
+ import time
20
+ from pathlib import Path
21
+
22
+ # 升级 kind 与 health 的类别词汇对齐(core 禁 import 兄弟模块,字面量本地定义)
23
+ KIND_QUALITY = "quality"
24
+ KIND_GATEFILES = "gatefiles"
25
+
26
+ _TAIL = 2000
27
+ # 闸体=verify 依赖面(构建/测试配置):任务 diff 触及即升级拍人
28
+ _GATE_PATTERNS = ("Makefile", "*.mk", "pyproject.toml", "setup.py", "setup.cfg",
29
+ "requirements*.txt", "package.json", "package-lock.json",
30
+ "go.mod", "Cargo.toml", "tox.ini", "noxfile.py",
31
+ ".github/workflows/*")
32
+
33
+
34
+ def _subprocess_runner(env_dir, cmd, timeout=300):
35
+ """真 runner:独立进程组跑 verify;超时杀全组,rc=124 按质量败处理。"""
36
+ proc = subprocess.Popen(cmd, shell=True, cwd=env_dir,
37
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
38
+ text=True, start_new_session=True)
39
+ try:
40
+ out, err = proc.communicate(timeout=timeout)
41
+ except subprocess.TimeoutExpired:
42
+ try:
43
+ os.killpg(proc.pid, signal.SIGKILL)
44
+ except ProcessLookupError:
45
+ pass # 超时与正常退出竞速:进程组已消失即无需补杀
46
+ out, err = proc.communicate()
47
+ return 124, ((out or "") + (err or "") +
48
+ f"\nverify 超时(>{timeout:g}s)——已终止验证进程组;"
49
+ "可用 AGORA_VERIFY_TIMEOUT 调整上限")
50
+ return proc.returncode, (out or "") + (err or "")
51
+
52
+
53
+ class Gate:
54
+ """accept_done 单写者串行受理;run 为后台线程体(httpd 包 Thread 调用)。"""
55
+
56
+ def __init__(self, state, gitx, router, oc, read_anchor, health, auth,
57
+ store, runner=None, verify_timeout=300, on_blocked=None):
58
+ self._state, self._gitx, self._router, self._oc = state, gitx, router, oc
59
+ self._read_anchor, self._health, self._auth = read_anchor, health, auth
60
+ self._store, self._on_blocked = store, on_blocked
61
+ self._runner = runner or (lambda env, cmd: _subprocess_runner(
62
+ env, cmd, timeout=verify_timeout))
63
+
64
+ # ---- done 受理(前置六拒,拒收落审计并附动作指引) ----
65
+
66
+ def accept_done(self, tid, worker_sid, commit):
67
+ t = self._state.get(tid)
68
+ if t["state"] != "running": # 含二次 done:防重入门
69
+ return self._deny_done(
70
+ tid, "state",
71
+ f"任务 {tid} 当前 {t['state']} 非 running——"
72
+ "board 查看状态,仅 running 受理 done")
73
+ if t["verify"]["state"] != "registered":
74
+ return self._deny_done(
75
+ tid, "verify-unregistered",
76
+ f"任务 {tid} verify 未登记——"
77
+ "post 通知派发方 task_update 登记 verify 命令,"
78
+ "登记后重新 post(done, commit)")
79
+ if not t["verify"]["cmd"].strip(): # 兜底:空命令=无质量闸,拒入门
80
+ return self._deny_done(
81
+ tid, "verify-empty",
82
+ f"任务 {tid} verify 命令为空——"
83
+ "task_update 登记非空命令后重新 post(done, commit)")
84
+ c = commit.lower()
85
+ if len(c) != 40 or any(ch not in "0123456789abcdef" for ch in c):
86
+ return self._deny_done( # F13:body-format 先行,参数错不再伪装成锚问题
87
+ tid, "body-format",
88
+ f"done body 须为纯 40 位十六进制完整 commit sha(收到 {commit[:20]!r})"
89
+ "——无前缀无附言;结论文本另发 post(kind=note)")
90
+ commit = c # 归一一次:锚比较与落库均用小写,大写 sha 不再伪装成锚问题(OCR)
91
+ children = [(cid, x) for cid, x in self._state.snapshot()["tasks"].items()
92
+ if x.get("dispatcher_sid") == worker_sid]
93
+ open_children = [cid for cid, x in children
94
+ if x["state"] not in ("merged", "closed", "cancelled")
95
+ or str(x.get("cancel_reason", "")).startswith("dependency_failed")]
96
+ if open_children:
97
+ return self._deny_done(
98
+ tid, "children",
99
+ f"嵌套子任务未结案:{', '.join(sorted(open_children))}——"
100
+ "等待 result 回传,或由你取消子任务;成功/有意跳过后再 post(done)")
101
+ # 锚检查仅评审环在位时适用:缺失→放行 done,由门体 review_absent 分支驻留
102
+ if (Path(t["env_dir"]) / ".review-loop").is_dir():
103
+ a = self._read_anchor(t["env_dir"]) # 锚=worktree 分支 HEAD 且无 blocker/high
104
+ if a is None:
105
+ return self._deny_done(
106
+ tid, "anchor",
107
+ f"任务 {tid} 评审环无锚或锚落后(.review-loop/.last-reviewed 缺失"
108
+ "或不等于分支 HEAD)——跑 /review-loop 至锚绿,重新 post(done, commit)")
109
+ if a.get("head") != commit:
110
+ return self._deny_done(
111
+ tid, "anchor",
112
+ f"任务 {tid} {commit} ≠ 分支 HEAD {a.get('head')}——"
113
+ "以 env 内完整 HEAD sha 重新 post(done, commit)")
114
+ if not a.get("clean"):
115
+ return self._deny_done(
116
+ tid, "anchor",
117
+ f"任务 {tid} 评审环未绿——修复 blocker/high 后"
118
+ "重新 post(done, commit)")
119
+ self._state.transition(tid, "done", commit=commit)
120
+ self._audit(tid, "done", commit=commit)
121
+ return "accepted"
122
+
123
+ def _deny_done(self, tid, tag, msg):
124
+ """F13:done 拒收统一出口——先审计 done_rejected(note=tag)再带因拒回。"""
125
+ self._audit(tid, "done_rejected", note=tag)
126
+ return f"rejected:{tag}: {msg}"
127
+
128
+ # ---- 门体(后台线程体) ----
129
+
130
+ def run(self, tid):
131
+ t = self._state.get(tid)
132
+ branch = t.get("target_branch") # 合入目标支(外部工作流);缺省=仓默认支
133
+ env, target, cmd = t["env_dir"], t["project_dir"], t["verify"]["cmd"]
134
+ integration = t.get("gate_env") or f"{env}__gate"
135
+ origin = t.get("gate_origin_branch") # 崩溃重排沿用首轮分支位,不采信漂移后的现场
136
+ self._state.set_fields(tid, in_gate=True, gate_env=integration)
137
+ try:
138
+ # kill 落在“目标已推进、merged 未落库”窗口:任务提交已在目标支即只补终态,
139
+ # 绝不重跑集成 verify 后把已合法合并的任务假 reopened,更不需要 reset --hard。
140
+ if self._gitx.contains(target, t["commit"], branch):
141
+ self._succeed(tid)
142
+ return
143
+ self._gitx.ensure_clean(target) # 主检出写前硬闸:用户未提交/未跟踪工作一律不碰
144
+ if branch:
145
+ origin = origin or self._gitx.symref(target)
146
+ self._state.set_fields(tid, gate_origin_branch=origin)
147
+ if self.gate_files_touched(tid): # 闸体变更:拍人不拦门
148
+ self._health.escalate(KIND_GATEFILES, tid)
149
+ warn = self._gitx.diff_check(env, branch) # 冲突标记机械检查,先于用户 verify
150
+ if warn: # gt-83185d R3:非空走 verify_failed 口径(截 2000 与 verify 败一致)
151
+ self._quality_fail(tid, "verify_failed",
152
+ "diff 检查发现冲突标记/空白错误——清理后重新"
153
+ f" post(done, commit)\n输出尾部:\n{warn[-_TAIL:]}")
154
+ return
155
+ rc, out = self._runner(env, cmd)
156
+ if rc != 0:
157
+ self._quality_fail(tid, "verify_failed",
158
+ f"verify 失败——修复后重新 post(done, commit)"
159
+ f"\n输出尾部:\n{out[-_TAIL:]}")
160
+ return
161
+ a = self._read_anchor(env) # 合并前锚重验:堵 accept→run 窗口 TOCTOU
162
+ if not a or a.get("head") != t.get("commit") or not a.get("clean"):
163
+ self._audit(tid, "review_absent_no_merge")
164
+ self._post(tid, "verify 绿但合并时 .review-loop 锚缺失/落后/未绿——"
165
+ "任务驻留 done 待人工审阅合并(dispatcher 可 "
166
+ "cancel 或人工 git 合并)")
167
+ return
168
+ # 集成阶段全部落在 detached worktree:试合并/测试失败都不会改目标主检出。
169
+ self._gitx.build_integration(target, integration, branch)
170
+ before = self._gitx.head(integration)
171
+ if self._gitx.merge_ff(env, integration) != "ok":
172
+ self._non_ff(tid, branch, before)
173
+ return
174
+ irc, iout = self._runner(integration, cmd)
175
+ if irc != 0:
176
+ self._quality_fail(tid, "reopened",
177
+ f"集成 verify 失败,目标分支未改动——修复后重新"
178
+ f" post(done, commit)\n输出尾部:\n{iout[-_TAIL:]}")
179
+ return
180
+ # 集成验证可能耗时较长,最终推进前复查,关闭“开始干净、结束时变脏”窗口。
181
+ self._gitx.ensure_clean(target)
182
+ if self._gitx.merge_ff(env, target, branch) != "ok":
183
+ self._non_ff(tid, branch, self._gitx.head(target, branch))
184
+ return
185
+ self._succeed(tid)
186
+ except Exception as e: # 门内崩(checkout 拒/分支失/脏工作区):转 reopened 通知
187
+ if self._state.get(tid)["state"] == "done":
188
+ self._state.transition(tid, "reopened")
189
+ self._audit(tid, "reopened")
190
+ self._post(tid, f"门内异常:{e}——处理目标分支后重新 post(done, commit)")
191
+ else: # 终态后异常(如环境回收败):不逆转门结果,但留痕+实时提示派发方
192
+ self._audit(tid, "reclaim_failed", note=str(e)[:200])
193
+ t = self._state.get(tid)
194
+ self._router.post(t["worker_sid"], ("up", tid), "milestone",
195
+ f"环境回收失败:{e}——任务终态不变,残留目录需人工清理")
196
+ finally:
197
+ try:
198
+ self._gitx.remove_integration(integration, target)
199
+ self._state.set_fields(tid, gate_env=None)
200
+ except Exception as e: # 回收失败不掩盖门结果;gate_env 保留供下轮/人工定位
201
+ self._audit(tid, "reclaim_failed", note=f"integration: {e}"[:200])
202
+ if origin:
203
+ if not self._gitx.checkout(target, origin):
204
+ self._audit(tid, "restore_failed") # 还原败留痕并保留原分支位供下轮复用
205
+ else:
206
+ self._state.set_fields(tid, gate_origin_branch=None)
207
+ self._tail(tid)
208
+
209
+ def _succeed(self, tid):
210
+ """唯一成功出口:目标支已含任务 commit,补终态、审计与任务环境回收。"""
211
+ t = self._state.get(tid)
212
+ self._state.fail_streak_reset(tid) # 全绿清连败(R12)
213
+ self._state.transition(tid, "merged", commit=t.get("commit"))
214
+ self._audit(tid, "merged", commit=t.get("commit"))
215
+ self._gitx.remove_env(t["env_dir"])
216
+ self._post(tid, f"merged @{t.get('commit')}——环境已回收")
217
+
218
+ def _non_ff(self, tid, branch, before):
219
+ """试合并或最终推进遇真 non-ff:重开不计质量连败,目标主检出保持不动。"""
220
+ t = self._state.get(tid)
221
+ # 窄竞态守卫:试合并窗口内 commit 已被人工推进目标支,则补终态成功,绝不重开
222
+ if self._gitx.contains(t["project_dir"], t.get("commit"), branch):
223
+ self._succeed(tid)
224
+ return
225
+ self._state.transition(tid, "reopened")
226
+ self._audit(tid, "reopened")
227
+ self._post(tid, f"non-ff 拒并:"
228
+ f"{branch or self._gitx.symref(t['project_dir'])}@{before}"
229
+ f" vs branch@{self._gitx.head(t['env_dir'])}——同步后"
230
+ "重新 post(done, commit)")
231
+
232
+ def gate_files_touched(self, tid):
233
+ t = self._state.get(tid)
234
+ return bool(t.get("env_dir")) and self._gitx.touches_gate(
235
+ t["env_dir"], _GATE_PATTERNS, t.get("target_branch"))
236
+
237
+ # ---- 内部 ----
238
+
239
+ def _quality_fail(self, tid, to, body):
240
+ """质量败(verify 败/集成败):迁移+审计+实时回 worker+计连败(3 连败拍人)。"""
241
+ self._state.transition(tid, to)
242
+ self._audit(tid, to)
243
+ self._post(tid, body)
244
+ self._state.fail_streak_bump(tid)
245
+ if self._state.get(tid)["state"] == "blocked":
246
+ self._audit(tid, "blocked")
247
+ self._health.escalate(KIND_QUALITY, tid)
248
+ self._router.notify_dispatcher(
249
+ tid, "result", f"任务 {tid} 已 blocked;请重派或取消")
250
+ if self._on_blocked is not None:
251
+ self._on_blocked(tid)
252
+
253
+ def _tail(self, tid):
254
+ """门走完:pending-cancel 经 State.gate_tail 原子结算(merged 不逆转历史);副作用随终态。"""
255
+ t = self._state.gate_tail(tid)
256
+ if t["state"] == "cancelled":
257
+ self._audit(tid, "cancelled")
258
+ if t.get("env_dir"): # 同 T7 直路径口径:cancelled 即环境回收
259
+ try:
260
+ self._gitx.remove_env(t["env_dir"])
261
+ except Exception as e: # 回收败留痕后继续:终止通知+凭据作废不得被拦断
262
+ self._audit(tid, "reclaim_failed", note=str(e)[:200])
263
+ if t.get("worker_sid"): # 同 T7 直路径:终止通知+凭据作废
264
+ self._router.post(t["dispatcher_sid"], ("down", tid), "cancel",
265
+ f"task {tid} cancelled at gate tail")
266
+ self._auth.revoke_session(t["worker_sid"])
267
+ self._router.notify_dispatcher(tid, "result", f"任务 {tid} 已取消")
268
+ elif t["state"] == "closed":
269
+ self._audit(tid, "closed")
270
+ self._router.notify_dispatcher(
271
+ tid, "result",
272
+ f"任务 {tid} 已 closed;commit={t.get('commit') or '-'}")
273
+
274
+ def _audit(self, tid, event, **extra):
275
+ """F8 门段审计:迁移即 events.jsonl 一行(事件名=迁移后状态名)。"""
276
+ self._store.append_event({"ts": time.time(), "actor": "gate",
277
+ "event": event, "task": tid, **extra})
278
+
279
+ def _post(self, tid, body):
280
+ t = self._state.get(tid)
281
+ self._router.post(t["dispatcher_sid"], ("down", tid), "milestone", body)
agorad/core/health.py ADDED
@@ -0,0 +1,182 @@
1
+ """健康探活与升级拍人(F5,设计 §4.4):定时探死+边沿判僵、失控实时拍主会话。
2
+
3
+ tick 只探活动任务的直接下级会话(无全局巡检);死下级→recover.reinject 自愈
4
+ (静默),失败→升级。父 worker 若正管着在飞链也优先重注入,并同步改绑嵌套
5
+ 子任务;只有无法重注入的失管 dispatcher 才直接拍人。
6
+ worker 凭据自愈已推改拉(F25 修正):inject_silent 推送通道证伪(v2.2.0
7
+ synthetic 不触发 prompt 钩子),改由插件 hook 缓存未命中经 /cred_for_sid 拉取。
8
+ 边沿判僵(F5 决策记录 2026-10-28,只提醒不处置):tick 顺带按 doctor 同口径
9
+ (20min 无活动,report.stale_candidates 构造注入)算僵态候选,新进候选集→
10
+ stalled 升级+可选邮件(email.json opt-in);离开候选集复位,oc 不可达轮次
11
+ 免判;"僵"的处置仍归人工。升级正文由服务权威合成;任务/worker 供给文本以
12
+ "不可信原文"段携带(拍平换行,防伪造服务行)。escalate 沿 dispatcher_sid
13
+ 上行至根任务(根 dispatcher=主会话 sid,R25-A 活绑定)经 router post——
14
+ 落库先行,注入即视为拍到(stalled 例外:synthetic 免通知通道,不弹窗);
15
+ escalate 同时落 events.jsonl 审计行(F8)。
16
+ core 只 import stdlib,依赖全注入。
17
+ """
18
+ import json
19
+ import shlex
20
+ import subprocess
21
+ import time
22
+
23
+ # 升级事件类别(R9 初始集;gate 的质量连败/闸体变更引用此收口)
24
+ KIND_QUALITY = "quality"
25
+ KIND_GATEFILES = "gatefiles"
26
+ KIND_REINJECT = "reinject-fail"
27
+ KIND_DISPATCHER_DEAD = "dispatcher-dead"
28
+ KIND_STALLED = "stalled"
29
+
30
+ _UNTRUSTED = "---- 不可信原文 ----"
31
+ _RAW_MAX = 500
32
+ # 活动面=探活与 last_seen 的任务集(verify_failed/reopened 归在飞,同 task 口径)
33
+ _ACTIVE = {"dispatched", "running", "done", "verify_failed", "reopened", "blocked"}
34
+ _IN_FLIGHT = {"dispatched", "running"} # 自愈重注入仅限 worker 在跑的两态
35
+
36
+ _KIND_NOTE = {
37
+ KIND_QUALITY: "同一任务连续 3 次质量失败,已自动 blocked",
38
+ KIND_GATEFILES: "任务 diff 触及闸体(构建/测试配置)",
39
+ KIND_REINJECT: "下级会话死亡且重注入失败",
40
+ KIND_DISPATCHER_DEAD: "派发方会话死亡,其在飞子链失管",
41
+ KIND_STALLED: "在飞超 20min 无活动(僵态候选),请人工关注",
42
+ }
43
+
44
+
45
+ def _subprocess_runner(cmd, input_text):
46
+ """默认邮件外呼:正文走 stdin,30s 超时;非零退出/超时抛异常由调用方吞。"""
47
+ r = subprocess.run(cmd, input=input_text, text=True, timeout=30,
48
+ capture_output=True)
49
+ if r.returncode:
50
+ raise subprocess.CalledProcessError(r.returncode, cmd)
51
+ return r
52
+
53
+
54
+ class Health:
55
+ """tick 由 main 定时线程驱动;单写者口径(httpd 串行调用)。"""
56
+
57
+ def __init__(self, state, oc, router, recover, app, store,
58
+ runner=None, stale_candidates=None):
59
+ """app 按引用注入:main_sid 是活绑定(R25-A),escalate 每次取当前值。
60
+
61
+ stale_candidates=report.stale_candidates(core 禁兄弟 import,构造注入);
62
+ runner=邮件外呼缝(gate 同款),缺省真 subprocess。
63
+ """
64
+ self._state, self._oc, self._router = state, oc, router
65
+ self._recover, self._app = recover, app
66
+ self._store = store
67
+ self._runner = runner or _subprocess_runner
68
+ self._stale_candidates = stale_candidates
69
+ self._stale_seen = set() # 僵态候选边沿记忆(内存态,崩溃回退=重拍一次)
70
+
71
+ def tick(self):
72
+ """探每个活动任务的直接下跳:死→自愈/上报;边沿判僵只提醒不处置。"""
73
+ tasks = self._state.snapshot()["tasks"]
74
+ oc_down = False
75
+ busy = set() # updated>idle=回合进行中:僵态本轮免判(doctor 口径减 busy 免决)
76
+ for tid, t in tasks.items():
77
+ wsid = t.get("worker_sid")
78
+ if t["state"] not in _ACTIVE or not wsid:
79
+ continue
80
+ try:
81
+ data = self._oc.session(wsid)
82
+ except Exception: # oc 不可达≠会话死:本轮免判(含僵态),勿误杀
83
+ oc_down = True
84
+ continue
85
+ if data is None:
86
+ manages = any(x.get("dispatcher_sid") == wsid
87
+ and x["state"] in _IN_FLIGHT for x in tasks.values())
88
+ if t["state"] in _IN_FLIGHT:
89
+ if self._recover.reinject(tid):
90
+ continue # 父 worker 一并恢复;recover 同步改绑嵌套子任务
91
+ self.escalate(KIND_DISPATCHER_DEAD if manages else KIND_REINJECT, tid)
92
+ elif manages:
93
+ self.escalate(KIND_DISPATCHER_DEAD, tid)
94
+ else:
95
+ tm = data.get("time") or {} # 缺形状→不 busy→照判(安全回退)
96
+ if (tm.get("updated") or 0) > (tm.get("idle") or 0):
97
+ busy.add(tid)
98
+ if not oc_down:
99
+ self._stale_scan(tasks, busy)
100
+
101
+ def _stale_scan(self, tasks, busy):
102
+ """边沿判僵(F5 决策记录 2026-10-28):新进候选集→stalled 拍人。
103
+
104
+ 免判口径(二批):busy(updated>idle,回合进行中)本轮从候选集剔除;
105
+ 离开 busy 再超阈重新边沿触发。_stale_seen 每轮同步为当前候选集
106
+ (离开即移除,之后再僵再拍)。
107
+ """
108
+ if self._stale_candidates is None:
109
+ return
110
+ cand = [(tid, b) for tid, b
111
+ in self._stale_candidates({"tasks": tasks}, time.time())
112
+ if tid not in busy]
113
+ for tid, _ in cand:
114
+ if tid not in self._stale_seen: # 边沿:在候选集而不在记忆集
115
+ self.escalate(KIND_STALLED, tid)
116
+ self._stale_seen.add(tid) # 拍到才记:escalate 失败下轮重试不丢报
117
+ self._stale_seen &= {tid for tid, _ in cand} # 离开候选集即移除(复位再拍)
118
+
119
+ def _notify_email(self, tid, body_text):
120
+ """可选邮件外呼(F5 决策记录 2026-10-28,opt-in):未配置=零行为变化。
121
+
122
+ email.json 缺失/缺字段→静默跳过;失败/超时吞异常(邮件尽力而为),
123
+ 成败各落一行审计事件。
124
+ """
125
+ try:
126
+ cfg = json.loads((self._store.home / ".agora" / "email.json")
127
+ .read_text(encoding="utf-8"))
128
+ to, command = cfg["to"], cfg["command"]
129
+ except Exception:
130
+ return
131
+ try:
132
+ self._runner(shlex.split(command), body_text)
133
+ event = "email_sent"
134
+ except Exception:
135
+ event = "email_failed"
136
+ # shortcut: 逐任务同步外呼,批量同僵最坏 N×30s 阻塞 tick 线程;规模化再聚合单邮件
137
+ self._store.append_event({"ts": time.time(), "actor": "health",
138
+ "event": event, "task": tid, "to": to})
139
+
140
+ def touch(self, sid):
141
+ """last_seen 刷新入口:仅 worker 服务调用刷——dispatcher 查看≠worker
142
+ 干活,查看不续命(二批决策;018676 真僵晚拍 40min 实证)。"""
143
+ for tid, t in self._state.snapshot()["tasks"].items():
144
+ if sid == t.get("worker_sid"):
145
+ self._state.touch_seen(tid) # 心跳只进内存:快照仅随真实迁移追加
146
+
147
+ def escalate(self, kind, tid, raw=None):
148
+ """失控拍人:正文服务权威合成;raw 为任务/worker 供给文本(不可信)。
149
+
150
+ stalled 走免通知通道(synthetic,落转录不弹窗,gt-5bc312 用户裁定),
151
+ 其余 kind 照常会话注入。"""
152
+ t = self._state.get(tid)
153
+ body = (f"失控[{kind}] 任务{tid}:{_KIND_NOTE[kind]};"
154
+ f"状态 {t['state']},worker={t.get('worker_sid') or '-'},"
155
+ f"dispatcher={t.get('dispatcher_sid') or '-'},"
156
+ f"连败 {t.get('fail_streak', 0)}")
157
+ if raw:
158
+ flat = " ".join(str(raw).split()) # 拍平换行:供给文本不得伪造服务合成行
159
+ body += f"\n{_UNTRUSTED}\n{flat[:_RAW_MAX]}"
160
+ root = self._root(tid)
161
+ rt = self._state.get(root)
162
+ self._store.append_event({"ts": time.time(), "actor": "health",
163
+ "event": "escalate", "task": tid, "kind": kind})
164
+ self._router.post(rt.get("worker_sid"), ("up", root), "escalate", body,
165
+ silent=(kind == KIND_STALLED)) # stalled 免通知通道:不弹窗(gt-5bc312)
166
+ if kind == KIND_STALLED: # 僵态邮件例外(§6 决策记录):仅此 kind 外呼
167
+ self._notify_email(tid, body)
168
+
169
+ def _root(self, tid):
170
+ """沿 dispatcher_sid 上行至根任务(根 dispatcher=主会话 sid,R25-A 活绑定)。"""
171
+ tasks = self._state.snapshot()["tasks"]
172
+ seen = set()
173
+ while (tid not in seen
174
+ and tasks.get(tid, {}).get("dispatcher_sid") != self._app.main_sid):
175
+ seen.add(tid)
176
+ parent = next((p for p, x in tasks.items()
177
+ if x.get("worker_sid")
178
+ == tasks[tid].get("dispatcher_sid")), None)
179
+ if parent is None:
180
+ break # 链头不在册(main 缺位)——就地升级到已达的最上级
181
+ tid = parent
182
+ return tid
agorad/core/msg.py ADDED
@@ -0,0 +1,92 @@
1
+ """消息路由(F4,设计 §4.3):链边寻址+落库先行+幂等补拉。
2
+
3
+ post 是链上唯一投递路径(cancel 终止通知也走此):先 write_message 落库
4
+ (库在即不丢),后 inject 一次;inject 失败仅落库,对方 check_mail 补拉。
5
+ 已投递集为内存态,与 mail_read 游标(快照持久化)配合:进程内 inject 成功
6
+ 即不再补拉,重启后由持久化游标兜底——崩溃回退口径同 State。会话换号重绑定后,
7
+ check_mail 沿牌血统补拉发往旧 sid 的离线消息,嵌套结果不因恢复而丢失。状态迁移
8
+ 不在此——post(kind) 只管投递,状态由调用方(gate/task)驱动。
9
+ """
10
+ import time
11
+
12
+
13
+ class NotChainEdge(Exception):
14
+ """寻址非链边:方向非法/任务缺席/对方未登记/from_sid 非任务另一方。"""
15
+
16
+
17
+ class Router:
18
+ """单写者路由:无锁无线程,httpd 串行调用(设计 §4.8 并发口径)。"""
19
+
20
+ def __init__(self, store, state, oc, same_lineage=None):
21
+ self._store = store
22
+ self._state = state
23
+ self._oc = oc
24
+ self._same_lineage = same_lineage # F16 血统判定(core 禁兄弟 import,装配注入)
25
+ self._delivered = set()
26
+
27
+ def is_dispatcher(self, sid, t):
28
+ """sid 是否本任务派发方:现 sid 相等,或血统含旧 dispatcher_sid
29
+ (F16 dispatcher 换号链边连续;血统判定缺失=仅相等)。"""
30
+ d = t.get("dispatcher_sid")
31
+ return (sid == d
32
+ or (d is not None and self._same_lineage is not None
33
+ and self._same_lineage(sid, d)))
34
+
35
+ def post(self, from_sid, to_edge: tuple, kind: str, body: str,
36
+ silent: bool = False) -> int:
37
+ to_sid = self._resolve(from_sid, to_edge)
38
+ return self._deliver(from_sid, to_sid, to_edge[1], kind, body, silent)
39
+
40
+ def notify_dispatcher(self, tid, kind, body):
41
+ """服务侧结果回传:子任务尚无 worker 链边时也能通知其父 dispatcher。"""
42
+ to_sid = self._state.get(tid).get("dispatcher_sid")
43
+ if to_sid is None:
44
+ raise NotChainEdge(f"{tid}: dispatcher 未登记")
45
+ return self._deliver("agorad", to_sid, tid, kind, body)
46
+
47
+ def _deliver(self, from_sid, to_sid, tid, kind, body, silent=False):
48
+ mid = self._store.write_message( # 铁则:先落库后注入
49
+ {"ts": time.time(), "task": tid, "kind": kind,
50
+ "from": from_sid, "to": to_sid}, body)
51
+ text = f"[agora] task {tid} {kind} from {from_sid}\n{body}"
52
+ try:
53
+ # 取属性入 try:oc 缺 inject_silent 时同落兜底网(评审环 B 册)
54
+ ok = (self._oc.inject_silent if silent else self._oc.inject)(to_sid, text)
55
+ except Exception: # oc 异常(凭据轮换 401/网络故障)同失败口径:落库兜底——
56
+ ok = False # done 链路不得因投递面异常断裂(T7 e2e 实测:raise→门不排队)
57
+ if ok:
58
+ self._delivered.add(mid) # 在线送达一次;失败留库待补拉
59
+ return mid
60
+
61
+ def dedup_key(self, msg_id: int) -> bool:
62
+ """已投递过返回 True(重发不再投递);否则登记并返回 False。"""
63
+ if msg_id in self._delivered:
64
+ return True
65
+ self._delivered.add(msg_id)
66
+ return False
67
+
68
+ def check_mail(self, sid: str) -> list:
69
+ cursor = self._state.snapshot()["mail_read"].get(sid, 0.0)
70
+ got = [m for m in self._store.read_messages()
71
+ if (m["to"] == sid or (self._same_lineage is not None
72
+ and self._same_lineage(sid, m["to"])))
73
+ and m["id"] not in self._delivered and float(m["ts"]) > cursor]
74
+ self._state.touch_mail(sid)
75
+ self._delivered.update(m["id"] for m in got) # 拉取即送达(无 ack)
76
+ return got
77
+
78
+ def _resolve(self, from_sid, to_edge) -> str:
79
+ d, tid = to_edge
80
+ try:
81
+ t = self._state.get(tid)
82
+ except KeyError:
83
+ raise NotChainEdge(f"{tid}: 任务不存在") from None
84
+ if d == "up": # worker 边不加血统:recover 已覆写 worker_sid,无换号缺口
85
+ to_sid, ok = t.get("dispatcher_sid"), from_sid == t.get("worker_sid")
86
+ elif d == "down":
87
+ to_sid, ok = t.get("worker_sid"), self.is_dispatcher(from_sid, t)
88
+ else:
89
+ raise NotChainEdge(f"{to_edge}: 方向非法")
90
+ if to_sid is None or not ok:
91
+ raise NotChainEdge(f"{from_sid}->{to_edge}: 非链边")
92
+ return to_sid
agorad/core/peer.py ADDED
@@ -0,0 +1,90 @@
1
+ """同仓协作面(F22):peer 留言板+同仓在飞摘要。
2
+
3
+ 留言复用消息库(kind=peer,头带 repo/作者任务;to="" 保持消息库全员有 to
4
+ 的不变量——Router.check_mail 按 m["to"] 直取),无收件人=不入任何信箱。
5
+ 分区=project_dir(normpath 单点归一);写面仅 worker 牌、作者任务=
6
+ worker_sid 命中行;四护栏:读只取最近 READ_N 条(噪音)、作者任务终态带
7
+ is_stale(陈旧降权)、单任务软限 SOFT_LIMIT 条(滥用)、留言属不可信文本
8
+ 一律带 untrusted 标记(注入防线)。core 零兄弟 import(store/state 注入)。
9
+ """
10
+ import os
11
+ import time
12
+
13
+ MAX_LEN = 500 # 单条长度上限(写面超限截断)
14
+ SOFT_LIMIT = 10 # 同作者任务留言软限(超限拒写)
15
+ READ_N = 20 # 读面条数上限(最新在前)
16
+ _TERMINAL = {"merged", "closed", "cancelled"} # is_stale 口径:作者任务终态
17
+ _INFLIGHT_EXCL = _TERMINAL | {"blocked"} # 在飞=非终态/非阻塞(含 todo)
18
+ EV_PEER = "peer_post"
19
+ _LIMIT_MSG = f"留言软限 {SOFT_LIMIT} 条/任务已满——沉淀请走 knowledge index"
20
+
21
+
22
+ class Rejected(Exception):
23
+ """peer 域拒绝(写权限/软限/无作者任务)——报因在消息。"""
24
+
25
+
26
+ def _own_task(snap, sid):
27
+ """调用者作者任务 (tid, row):worker_sid==sid,活动态优先(终态后读面仍可用)。"""
28
+ hit = [(tid, t) for tid, t in snap["tasks"].items()
29
+ if t.get("worker_sid") == sid]
30
+ active = [kv for kv in hit if kv[1]["state"] not in _TERMINAL]
31
+ return (active or hit or [(None, None)])[0]
32
+
33
+
34
+ class PeerBoard:
35
+ """单写者串行调用(httpd 单线程口径);store/state 构造注入。"""
36
+
37
+ def __init__(self, store, state):
38
+ self._store = store
39
+ self._state = state
40
+
41
+ def partition(self, sid):
42
+ """worker 牌分区=其任务 project_dir(normpath);无任务行即拒。"""
43
+ _tid, t = _own_task(self._state.snapshot(), sid)
44
+ if t is None:
45
+ raise Rejected(f"repo_board: {sid} 无 worker 任务行(分区不可定)")
46
+ return os.path.normpath(t["project_dir"])
47
+
48
+ def post(self, sid, body):
49
+ """写面:作者任务锁 tid,软限内超长截断落库+审计。返回 msg id。"""
50
+ tid, t = _own_task(self._state.snapshot(), sid)
51
+ if t is None:
52
+ raise Rejected(f"peer 留言: {sid} 无 worker 任务行(作者不可定)")
53
+ repo = os.path.normpath(t["project_dir"])
54
+ peers = [m for m in self._store.read_messages()
55
+ if m.get("kind") == "peer" and m.get("task") == tid]
56
+ if len(peers) >= SOFT_LIMIT:
57
+ raise Rejected(_LIMIT_MSG)
58
+ mid = self._store.write_message(
59
+ {"ts": time.time(), "task": tid, "kind": "peer", "repo": repo,
60
+ "from": sid, "to": ""}, str(body)[:MAX_LEN])
61
+ self._store.append_event({"ts": time.time(), "actor": sid,
62
+ "event": EV_PEER, "task": tid,
63
+ "repo": repo, "mid": mid})
64
+ return mid
65
+
66
+ def board(self, snap, repo=None):
67
+ """读面:同仓在飞四字段+最近 READ_N 条留言。repo=None=全仓(root/
68
+ dispatcher 诊断面);worker 分区由 transport 经 partition() 先定。"""
69
+ if repo:
70
+ repo = os.path.normpath(repo)
71
+ now = time.time()
72
+ tasks = [{"tid": tid, "title": t.get("title", ""), "state": t["state"],
73
+ "last_seen": t.get("last_seen")}
74
+ for tid, t in sorted(snap["tasks"].items())
75
+ if t["state"] not in _INFLIGHT_EXCL
76
+ and (repo is None or os.path.normpath(t.get("project_dir") or "")
77
+ == repo)]
78
+ peers = [m for m in self._store.read_messages()
79
+ if m.get("kind") == "peer"
80
+ and (repo is None or m.get("repo") == repo)][-READ_N:]
81
+ msgs = []
82
+ for m in reversed(peers): # 最新在前
83
+ author = snap["tasks"].get(m.get("task"))
84
+ state = author["state"] if author else None
85
+ msgs.append({"id": m["id"], "author": m.get("task"),
86
+ "author_state": state,
87
+ "age_seconds": int(now - float(m["ts"])),
88
+ "is_stale": state in _TERMINAL if author else True,
89
+ "body": m.get("body", ""), "untrusted": True})
90
+ return {"repo": repo, "tasks": tasks, "messages": msgs}