ai_co_discuss 0.2.46 → 0.2.48
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/.agents/skills/discuss-host/SKILL.md +82 -43
- package/.agents/skills/discuss-host/references/launch.md +18 -4
- package/.agents/skills/discuss-participant/SKILL.md +57 -34
- package/.agents/skills/discuss-participant/references/free.md +23 -0
- package/.agents/skills/discuss-participant/references/manual.md +11 -3
- package/backend/free_room.py +20 -10
- package/backend/schema.sql +3 -1
- package/backend/server.py +69 -6
- package/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/runner.md +1 -1
- package/docs/skills.md +1 -1
- package/docs/workflow.md +169 -169
- package/package.json +1 -1
- package/runner/api_participant.mjs +6 -4
- package/runner/discuss.py +4 -2
- package/runner/discussion_adapters.mjs +3 -2
- package/runner/discussion_launch.mjs +11 -7
- package/runner/discussion_runner.mjs +2 -1
- package/runner/free_runner.mjs +6 -3
- package/web/app.js +23 -13
- package/web/modern.css +1 -1
- package/web/modern.js +1055 -1012
package/backend/server.py
CHANGED
|
@@ -214,6 +214,7 @@ def require_room(connection, room_id):
|
|
|
214
214
|
room["round_budget"] = dict(budget) if budget else None
|
|
215
215
|
if budget:
|
|
216
216
|
room["opinion_count"] = opinion_count(connection, room_id, room["experts"])
|
|
217
|
+
room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"]) if budget else room["rounds"]
|
|
217
218
|
room["floor"] = dict(connection.execute("SELECT holder,next_speaker,revision FROM room_floor WHERE room_id=?", (room_id,)).fetchone())
|
|
218
219
|
room["runtime"] = runtime_view(connection, room_id)
|
|
219
220
|
room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
|
|
@@ -233,6 +234,7 @@ def require_room(connection, room_id):
|
|
|
233
234
|
for decision in room["scheduler_decisions"]:
|
|
234
235
|
decision["detail"] = details.get(decision["id"])
|
|
235
236
|
room["host_arrangements"] = host_arrangements(connection, room_id, room["scheduler_decisions"])
|
|
237
|
+
room["cancelled_turns"] = cancelled_turns(connection, room_id)
|
|
236
238
|
parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
|
|
237
239
|
WHERE room_links.room_id=?""", (room_id,)).fetchone()
|
|
238
240
|
room["parent_room"] = dict(parent) if parent else None
|
|
@@ -296,6 +298,21 @@ def host_arrangements(connection, room_id, decisions):
|
|
|
296
298
|
return [plan for plan in plans if plan["after_message_id"] not in applied][-RECENT_DECISIONS:]
|
|
297
299
|
|
|
298
300
|
|
|
301
|
+
def cancelled_turns(connection, room_id):
|
|
302
|
+
"""Assignments that ended without an answer, anchored where their wake card first appeared.
|
|
303
|
+
|
|
304
|
+
The anchor is the later of the previous turn's answer and the latest message at creation time, so the card keeps its place in the transcript.
|
|
305
|
+
"""
|
|
306
|
+
rows, latest_answer = [], 0
|
|
307
|
+
for row in connection.execute("""SELECT id,expert,question,status,answer_id,
|
|
308
|
+
(SELECT COALESCE(MAX(messages.id),0) FROM messages WHERE messages.room_id=discussion_turns.room_id AND messages.created_at<=discussion_turns.created_at) AS created_after
|
|
309
|
+
FROM discussion_turns WHERE room_id=? ORDER BY id""", (room_id,)):
|
|
310
|
+
if row["status"] == "cancelled":
|
|
311
|
+
rows.append({"id": row["id"], "expert": row["expert"], "question": row["question"], "after_message_id": max(latest_answer, row["created_after"])})
|
|
312
|
+
latest_answer = max(latest_answer, row["answer_id"] or 0)
|
|
313
|
+
return rows[-RECENT_DECISIONS:]
|
|
314
|
+
|
|
315
|
+
|
|
299
316
|
def discussion_rounds(connection, room_id, experts):
|
|
300
317
|
"""Group complete history metadata until every configured expert has spoken.
|
|
301
318
|
|
|
@@ -327,6 +344,34 @@ def discussion_rounds(connection, room_id, experts):
|
|
|
327
344
|
return rounds
|
|
328
345
|
|
|
329
346
|
|
|
347
|
+
def budget_round_groups(connection, room_id, experts, base):
|
|
348
|
+
"""Group history by the round budget's rule: every len(experts) opinions form a round, and a new segment (base) starts a new one.
|
|
349
|
+
|
|
350
|
+
`rounds` stays speaker-based because scheduling needs who still owes a turn; this view only labels history.
|
|
351
|
+
"""
|
|
352
|
+
if not experts:
|
|
353
|
+
return []
|
|
354
|
+
rows = connection.execute("""SELECT messages.id,messages.author,
|
|
355
|
+
COALESCE(details.kind,'opinion') AS kind FROM messages
|
|
356
|
+
LEFT JOIN message_details AS details ON details.message_id=messages.id
|
|
357
|
+
WHERE room_id=? ORDER BY messages.id""", (room_id,))
|
|
358
|
+
groups, next_start, seen = [], None, 0
|
|
359
|
+
for message in rows:
|
|
360
|
+
if next_start is None:
|
|
361
|
+
next_start = message["id"]
|
|
362
|
+
if message["kind"] != "opinion" or message["author"] not in experts:
|
|
363
|
+
continue
|
|
364
|
+
if not groups or groups[-1]["completed"]:
|
|
365
|
+
groups.append({"number": len(groups) + 1, "start_id": next_start, "opinions": 0, "completed": False})
|
|
366
|
+
current = groups[-1]
|
|
367
|
+
seen += 1
|
|
368
|
+
current["opinions"] += 1
|
|
369
|
+
current["completed"] = current["opinions"] >= len(experts) or seen == base
|
|
370
|
+
if current["completed"]:
|
|
371
|
+
next_start = None
|
|
372
|
+
return groups
|
|
373
|
+
|
|
374
|
+
|
|
330
375
|
def opinion_count(connection, room_id, experts):
|
|
331
376
|
"""Count formal expert opinions; the program-controlled round budget is measured in these."""
|
|
332
377
|
if not experts:
|
|
@@ -969,7 +1014,7 @@ def append_free_message(connection, room, payload, actor):
|
|
|
969
1014
|
def record_reported_session(connection, room_id, token, payload):
|
|
970
1015
|
"""Keep the software session a manual member posted from, so the local service can take the member over."""
|
|
971
1016
|
session_id, client = payload.get("session_id"), payload.get("session_client")
|
|
972
|
-
if client not in ("claude", "codex") or not isinstance(session_id, str) or not SESSION_ID_PATTERN.fullmatch(session_id) or not token:
|
|
1017
|
+
if client not in ("claude", "codex", "antigravity") or not isinstance(session_id, str) or not SESSION_ID_PATTERN.fullmatch(session_id) or not token:
|
|
973
1018
|
return
|
|
974
1019
|
member = connection.execute("SELECT id FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access='manual' AND adapter='manual'",
|
|
975
1020
|
(room_id, token_hash(token))).fetchone()
|
|
@@ -998,7 +1043,7 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
|
|
|
998
1043
|
return {"message": previous, "duplicate": True}
|
|
999
1044
|
if author != "用户" and details["kind"] == "opinion":
|
|
1000
1045
|
require_within_limits(room)
|
|
1001
|
-
if room["phase"] in ("paused", "concluded") and details["kind"] == "opinion":
|
|
1046
|
+
if room["phase"] in ("paused", "concluded") and details["kind"] == "opinion" and (author != "用户" or room["archived"]):
|
|
1002
1047
|
raise ValueError("话题已暂停或收束;恢复阶段后才能提交新观点,运行回执请用 receipt")
|
|
1003
1048
|
check_message_references(connection, room_id, expected)
|
|
1004
1049
|
check_message_attachments(connection, room_id, details["submitted_by"], identifiers)
|
|
@@ -1065,12 +1110,28 @@ def roster_view(room):
|
|
|
1065
1110
|
"""Who is present and who still owes a turn in the open round; its revision changes whenever either changes."""
|
|
1066
1111
|
last = room["rounds"][-1] if room["rounds"] else None
|
|
1067
1112
|
spoken = set(last["speakers"]) if last and not last["completed"] else set()
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1113
|
+
pending_registration = unregistered_experts(room)
|
|
1114
|
+
experts = [{"name": name, "mode": mode, "host": name == room["host"], "spoken": name in spoken,
|
|
1115
|
+
"registered": name not in pending_registration} for name, mode in room["experts"].items()]
|
|
1116
|
+
state = json.dumps([sorted(room["experts"]), sorted(spoken), sorted(pending_registration)], ensure_ascii=False)
|
|
1117
|
+
return {"experts": experts, "unregistered": pending_registration,
|
|
1118
|
+
"waiting": [item["name"] for item in experts if not item["spoken"] and not item["host"] and item["registered"]],
|
|
1071
1119
|
"revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
|
|
1072
1120
|
|
|
1073
1121
|
|
|
1122
|
+
def unregistered_experts(room):
|
|
1123
|
+
"""Manual experts of a woken discussion (adapter 'manual') that have not registered from their own session; they cannot be scheduled until they do."""
|
|
1124
|
+
return [member["name"] for member in room["members"]
|
|
1125
|
+
if member["adapter"] == "manual" and member["status"] == "active" and not member["registered_at"] and member["name"] in room["experts"]]
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def require_registered(room, names):
|
|
1129
|
+
"""Reject scheduling an expert that has not registered yet; it joins the round queue by itself once it registers."""
|
|
1130
|
+
missing = [name for name in dict.fromkeys(names) if name in unregistered_experts(room)]
|
|
1131
|
+
if missing:
|
|
1132
|
+
raise ValueError(f"以下手动专家尚未注册,不能安排发言:{'、'.join(missing)};它在自己的会话中注册后会自动加入本轮队列,也可先让其暂离")
|
|
1133
|
+
|
|
1134
|
+
|
|
1074
1135
|
def require_round_coverage(room, plan, payload):
|
|
1075
1136
|
"""A multi-turn plan is only accepted against the latest roster and must schedule everyone still waiting in the round."""
|
|
1076
1137
|
if len(plan) < 2:
|
|
@@ -1100,6 +1161,7 @@ def create_turn(connection, room_id, payload):
|
|
|
1100
1161
|
require_within_limits(room)
|
|
1101
1162
|
if any(expert not in room["experts"] or len(question) > MAX_QUESTION_LENGTH for expert, question, _ in plan):
|
|
1102
1163
|
raise ValueError("请选择参与专家,待回应问题最多 1000 字")
|
|
1164
|
+
require_registered(room, [expert for expert, _, _ in plan])
|
|
1103
1165
|
require_round_coverage(room, plan, payload)
|
|
1104
1166
|
if room["phase"] in ("paused", "concluded"):
|
|
1105
1167
|
raise ValueError("话题已暂停或收束,请先调整阶段")
|
|
@@ -1151,6 +1213,7 @@ def continue_turns(connection, room_id, payload):
|
|
|
1151
1213
|
if not room["queued_turns"]:
|
|
1152
1214
|
raise FileExistsError("没有排队中的安排;请创建新的安排")
|
|
1153
1215
|
require_within_limits(room)
|
|
1216
|
+
require_registered(room, [room["queued_turns"][0]["expert"]])
|
|
1154
1217
|
require_floor(room, room["host"])
|
|
1155
1218
|
turn_id = room["queued_turns"][0]["id"]
|
|
1156
1219
|
connection.execute("UPDATE discussion_turns SET status='pending' WHERE id=? AND status='queued'", (turn_id,))
|
|
@@ -1240,7 +1303,7 @@ def update_turn(connection, room_id, turn_id, payload):
|
|
|
1240
1303
|
status = payload.get("status")
|
|
1241
1304
|
if status not in ("notified", "cancelled"):
|
|
1242
1305
|
raise ValueError("安排状态仅可显式改为 notified 或 cancelled")
|
|
1243
|
-
if turn["status"] == status:
|
|
1306
|
+
if turn["status"] == status or (status == "cancelled" and turn["status"] == "skipped"):
|
|
1244
1307
|
return turn
|
|
1245
1308
|
if status == "notified" and room["phase"] in ("paused", "concluded"):
|
|
1246
1309
|
raise ValueError("话题已暂停或收束,不能继续通知")
|
package/docs/architecture.md
CHANGED
|
@@ -257,7 +257,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
|
|
|
257
257
|
- **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
|
|
258
258
|
- **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
|
|
259
259
|
- **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 runner 成员(没有则手动成员)接任;建房和每条房主消息前也做同样的补位,因此旧房间在下一条消息时自动获得主持人。房主用 `POST members/ID {action:"role", role:"host"}` 更换主持人(在线成员不能担任),把现任主持人改为 `member` 会被拒绝。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己),这些请求组成一个批次(`free_batches`);批次内全部进入终态后,只要有一个完成就给主持人生成一次 `handoff` 请求,全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
|
|
260
|
-
- **智能调度推荐**:接入程序用控制凭据 `POST recommendations {message_id, member_id, provider, confidence}` 为一条已交给主持人(`route
|
|
260
|
+
- **智能调度推荐**:接入程序用控制凭据 `POST recommendations {message_id, member_id, provider, confidence}` 为一条已交给主持人(`route`)的未点名消息记录推荐对象(在场的非该主持人成员,含手动与在线成员),写入 `member_recommendations`(每条消息一条,重复提交忽略;独立表,D1 重新应用 schema 即可),有效期 60 秒,主持人的请求不受影响。房主用 `POST recommendations/MESSAGE_ID {action:"adopt", mode}` 采纳:`interrupt` 取消主持人这条请求(已投递则作废主持人本次整个执行),`after` 等主持人这条请求进入终态。两种方式都先置为 `waiting`,由 `release`(采纳时与每次 `inbox` 读取时执行)在主持人请求已终态时为推荐成员建一条 `ask` 请求(`created_by=user`、`chain_id` 为原消息),状态改为 `adopted`。过期 5 秒宽限后采纳返回 409,重复采纳返回原结果。房间视图 `free.recommendations` 带成员名、状态、方式、置信度与剩余秒数 `expires_in`;备份、导入(仅作历史,不再生效)与删除一并处理。`inbox` 的待投递项带 `purpose` 供接入程序识别。`inbox.members` 只列由本机唤醒的成员;手动与在线成员另列于 `inbox.observers`(含待处理请求,供智能调度识别主持人与推荐对象),接入程序从不向它们投递。
|
|
261
261
|
- **回应建议**:普通成员新发言的结构化 `to` 与正文在场成员 @ 合并保存到 `member_suggestions`(原消息编号、稳定成员编号),排除自己、代码与引用;不会创建请求。消息读取返回 `suggestions`,包含目标在场状态及已转换的请求编号。房主用 `POST suggestions {message_id,member_id}` 转换:同一事务记录用户确认消息,调用现有建请求路径,`message_id` 保留原回答、`chain_id` 使用用户确认消息编号、`created_by=user`。`UNIQUE(message_id,member_id)` 使重复或并发点击只生成一个请求及一条确认,终态不重新派发。权限、在场状态和未存档条件在写入事务内校验;Worker 使用批次断言。升级须应用包含新表的 schema;Python 重启时自动建表,D1 发布前显式执行。已有历史消息不补写建议。
|
|
262
262
|
- **在线成员**:`access=online` 的成员只读,不进入 `inbox`,不能用自己的凭证发言;房主以控制凭据代发其回答(`author` 为该成员、`submitted_by` 为用户、`source=proxy`),可带 `request_ids` 关闭它的待处理请求。
|
|
263
263
|
- **关联房间**:从自由房间发起的讨论房间在建房时带 `parent_room_id`,记录在 `room_links`;讨论房间视图有 `parent_room`,自由房间视图列出 `linked_rooms`。头脑风暴的轮次、时长与名单因此天然独立于自由房间。
|
package/docs/cli.md
CHANGED
|
@@ -134,7 +134,7 @@ codiscuss floor view $room
|
|
|
134
134
|
| 主持发言 | 领取后 `message post ROOM --as Codex --kind moderation --body-file FILE --submission-id UUID` |
|
|
135
135
|
| 在线专家代发 | 被正式安排后,由主持人 `floor take ROOM --as Codex --speaker Claude --revision N`,再 `message post ROOM --as Codex --author Claude --turn TURN --summary-file SUMMARY --summary-by Claude --body-file FILE --submission-id UUID` |
|
|
136
136
|
| 放弃发言 | `floor return ROOM --as NAME --revision N`;未完成安排仍需处理 |
|
|
137
|
-
| 整轮安排 | 主持人领取后 `turn roster ROOM` 后 `turn create ROOM --as Codex --plan-file PLAN.json --roster REV --submission-id UUID`;PLAN 为 `[{"expert":"Claude","question":"…"}]`,按顺序接力;多项计划须先 `turn roster ROOM` 取得最新专家状态,把其 `revision` 用 `--roster` 带上,并包含 `waiting`
|
|
137
|
+
| 整轮安排 | 主持人领取后 `turn roster ROOM` 后 `turn create ROOM --as Codex --plan-file PLAN.json --roster REV --submission-id UUID`;PLAN 为 `[{"expert":"Claude","question":"…"}]`,按顺序接力;多项计划须先 `turn roster ROOM` 取得最新专家状态,把其 `revision` 用 `--roster` 带上,并包含 `waiting` 中的每位专家(主持人除外;尚未注册的手动专家列在 `unregistered`,不在 `waiting` 中,也不能被安排,否则返回 400 并列出名字):未带 revision 返回 400,名单已变化返回 409,缺人返回 400 并列出名字 |
|
|
138
138
|
| 继续原计划 | 用户介入或打断后,主持人领取并 `turn continue ROOM --as Codex`;改用新安排则排队自动作废 |
|
|
139
139
|
| 取消安排 | 主持人领取后 `turn cancel ROOM TURN --as Codex` |
|
|
140
140
|
| 切换阶段 | `room update ROOM --as Codex --phase discussing` |
|
package/docs/runner.md
CHANGED
|
@@ -283,7 +283,7 @@ Windows 的 `executable` 填原生 `.exe`,不填 npm 的 `.cmd` / `.ps1` 包
|
|
|
283
283
|
|
|
284
284
|
agy 只用一套方法控制权限:
|
|
285
285
|
|
|
286
|
-
1. **只在本工具新建的会话中执行**:每位参与者对应一个独立原生讨论项目,权限只写在该项目里。外部已有会话(包括从当前 agy 对话发起主持、在启动配置中填写 `session_id
|
|
286
|
+
1. **只在本工具新建的会话中执行**:每位参与者对应一个独立原生讨论项目,权限只写在该项目里。外部已有会话(包括从当前 agy 对话发起主持、在启动配置中填写 `session_id`)会恢复其原项目,无法套用讨论项目权限,因此启动时即拒绝,交付时也再次拒绝。唯一例外是手动专家的托管:agy 在自己的会话里执行 `codiscuss` 时由 `ANTIGRAVITY_CONVERSATION_ID` 上报会话,用户注册后服务把它转为 `antigravity_cli` 并以 `--conversation ID` 续接,不加 `--project`,沿用该会话原项目和用户自己的 agy 权限(无讨论项目规则,也不检查全局放行);无头模式下 shell 命令须已由用户的规则放行,例如 `command(regex:^codiscuss(\.cmd)? .*$)`,否则被拒绝。
|
|
287
287
|
2. **项目内规则**:allow 为受限的讨论命令规则与草稿目录写入(`write` 时加项目目录写入);deny 为项目目录写入(`read` 时)、运行目录、技能目录、`~/.gemini` 写入与 `mcp(*)`。规则按目录前缀递归匹配,不使用 `/**`;Deny > Ask > Allow,因此草稿不得位于受保护目录中,冲突在交付前报告。
|
|
288
288
|
3. **交付前检查全局设置**:CLI 与共享全局 allow 中若有 `*`、`command(*)`、`unsandboxed(*)` 或 `unsandboxed_command(*)` 这类放行全部命令的规则,则拒绝交付,因为它会让 shell 绕过项目的文件规则。具体命令的放行和 `toolPermission` 不检查,属于用户自己的授权。未授权 shell 在非交互模式下被拒绝。
|
|
289
289
|
4. **不改用户全局设置**:本工具不向 agy 全局设置写入任何规则;`setup` 只移除早期版本记录在 `agy-permission.json` 中的全局规则(先备份到服务目录 `agy-settings.backup.json`),其他设置与用户规则不变。
|
package/docs/skills.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
主持开始新讨论时确认全自动或手动复制冷启动。自动任务明确引用仓库内的当前技能版本,并提供任务文件与 CLI 凭据文件路径;参与者先 `execution enter`,再按阶段读取材料。自动执行不建原生定时。手动方式沿用本文的原有接入;受保护房间另由用户提供私有接入文件。讨论项目和 cwd 可选,不能把 CLI 仓库路径当作讨论资料目录或擅自增加目录权限。
|
|
8
8
|
|
|
9
|
-
两份主技能把讨论质量与判断放在前面,仅保留当前任务必需的执行边界。主持人的 `references/manual.md` 只在手动接入、在线转交或原生任务场景读取;参与者的 `references/manual.md`
|
|
9
|
+
两份主技能把讨论质量与判断放在前面,仅保留当前任务必需的执行边界。主持人的 `references/manual.md` 只在手动接入、在线转交或原生任务场景读取;参与者的 `references/manual.md` 管手动成员的接入文件、注册与原生定时持续参与,`references/free.md` 只在自由协作房间读取,`references/writing.md` 在首次使用某类写入、代发或失败恢复时按需查阅。自动任务不加载手动流程,也不逐轮复读帮助、全量历史和故障清单。
|
|
10
10
|
|
|
11
11
|
启动提示可能已要求 `execution enter`;当前调用成功认领一次后,加载技能不再次认领。主持人先用一次 `state set` 保存判断、简报和下一步意图,再创建安排;安排落库即结束该次资格,不追加状态写入。专家提交原文即结束,由程序续租和交付后续任务。返回记录已含完整保存证据时直接核对,缺少证据或结果不明才补查询;这减少重复读写,但不削弱署名、发言权、提交去重或到限约束。
|
|
12
12
|
|