wezard 2.2.2 → 2.3.0
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/CLAUDE.md +7 -4
- package/dist/cli/init.js +2 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +6 -4
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-log.js +6 -1
- package/dist/daemon/chat-log.js.map +1 -1
- package/dist/daemon/danger.js +17 -0
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +41 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +8 -2
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/inbound.js +9 -4
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +744 -152
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +62 -23
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +15 -0
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/memory-steward.js +124 -68
- package/dist/daemon/memory-steward.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +461 -91
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +99 -0
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +7 -5
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +183 -60
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/receipts.js +391 -77
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +63 -10
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +24 -13
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/task-registry.js +1 -1
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +12 -4
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/usage.js +4 -11
- package/dist/daemon/usage.js.map +1 -1
- package/dist/daemon/wizard-memory.js +36 -4
- package/dist/daemon/wizard-memory.js.map +1 -1
- package/dist/daemon/wizard.js +102 -58
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +138 -111
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/charter-view.js +35 -0
- package/dist/shared/charter-view.js.map +1 -0
- package/dist/shared/chat-http.js +133 -31
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +6 -3
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +17 -6
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +9 -5
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/daemon-token.js +18 -0
- package/dist/shared/daemon-token.js.map +1 -0
- package/dist/shared/detail-render.js +140 -16
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +33 -12
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/effort.js +11 -0
- package/dist/shared/effort.js.map +1 -0
- package/dist/shared/model-prices.js +258 -0
- package/dist/shared/model-prices.js.map +1 -0
- package/dist/shared/pricing.js +55 -0
- package/dist/shared/pricing.js.map +1 -0
- package/dist/shared/reminder.js +18 -4
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +172 -8
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +89 -25
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +4 -0
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/task-file.js +16 -1
- package/dist/shared/task-file.js.map +1 -1
- package/dist/shared/turn-state.js +24 -0
- package/dist/shared/turn-state.js.map +1 -0
- package/dist/shared/world.js +37 -3
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +2 -1
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +16 -3
- package/package.json +1 -1
- package/web/chat.css +172 -40
- package/web/chat.js +725 -247
package/dist/daemon/receipts.js
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// 回执: 一次 tell_peer 之后, 对方干完那一轮, 守护进程把它的结论**自动**送回发话方
|
|
2
|
+
// 的输入框。
|
|
3
|
+
//
|
|
4
|
+
// 在这之前, 发话方只有两条路: 挂在 wait_peer 上阻塞等 (把一个能干活的 pane 锁死在
|
|
5
|
+
// 轮询里, 而且一次只能等一批、等完才醒), 或者自己记得回头 peek —— 后者实际上没人做。
|
|
6
|
+
// 异步回执把「问」和「收」拆开: 问完就接着干自己的事, 答案到了作为新的一轮进来。
|
|
7
|
+
//
|
|
8
|
+
// 这不是第二个编排器, 是一条投递通路。它只做四件事, 每一件都复用现成的判定:
|
|
9
|
+
// 等对方停下 (mirror.untilIdle, 注册表判闲) → 取它答**这一句**的那段话
|
|
10
|
+
// (peers.replyToPeer, 按信封定位而不是按时刻) → 等发话方停下 → paste 进去。
|
|
11
|
+
// 控制流仍在发话方自己的上下文里: 它没有被阻塞, 也没有被谁代理。
|
|
12
|
+
//
|
|
13
|
+
// 登记落盘 (`store`), boot 时把没收尾的重新守起来。不能纯内存: 这个仓库里被派活的
|
|
14
|
+
// wizard 自己就常以 `build + reload` 收尾 —— reload 恰好落在它那一轮里, 内存里的
|
|
15
|
+
// watcher 跟着进程一起没了, 发话方永远等不到回执。答案本来就在对方的 transcript 里
|
|
16
|
+
// (按信封定位), 重启后接着守, 取到的还是同一段。
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import { parseClosing } from "./peers.js";
|
|
19
|
+
import { isTerminal, turnState } from "../shared/turn-state.js";
|
|
20
|
+
import { sleep, truncate } from "../shared/std.js";
|
|
21
|
+
/** 对方最长允许干多久 (超过就投一份 timeout 回执, 不占着内存等到天亮)。默认值;
|
|
22
|
+
* tell_peer 的 `deadline` 可在 [MIN, MAX] 里改 —— 上限留在 KEEP_MS 之内, 超时那份
|
|
23
|
+
* 回执还得数得进工单。 */
|
|
3
24
|
const TARGET_WAIT_SEC = 3600;
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
25
|
+
const DEADLINE_MIN_SEC = 60;
|
|
26
|
+
const DEADLINE_MAX_SEC = 12 * 3600;
|
|
27
|
+
/** 交接把义务转进新会话时, 至少再给它这么久: 不然对方一交接, 这份就提前超时。 */
|
|
28
|
+
const CARRY_GRACE_MS = 10 * 60_000;
|
|
29
|
+
/** 一件活的编号: 注入之前生成, 写进信封, 回执按它定位答句。 */
|
|
30
|
+
export const newTurn = () => `t${randomBytes(3).toString("hex")}`;
|
|
31
|
+
/** `at` 发出、给了 `sec` 秒 (缺省 / 越界取默认或夹到边上) 的期限。 */
|
|
32
|
+
export const deadlineOf = (at, sec) => at + 1000 * (sec === undefined || !Number.isFinite(sec) ? TARGET_WAIT_SEC : Math.min(Math.max(sec, DEADLINE_MIN_SEC), DEADLINE_MAX_SEC));
|
|
33
|
+
/** 对方 pane 不在了, 隔这么久再看一眼才判 dead: 非交接的 pane 换新 (respawn) 也有
|
|
34
|
+
* 几秒是死的。 */
|
|
35
|
+
const DEAD_GRACE_MS = 5000;
|
|
36
|
+
/** 对方读进了我们这一句、停下了却没答 —— 再等下一次停下。连着这么多次都没答就认定
|
|
37
|
+
* 它不会答了, 别守到天亮。我们那一句还没被读进 (排在别的轮后面) 的那几次不算: 那是
|
|
38
|
+
* 在等前面的轮, 不是它不答; 只有整段 ramp 都闲着、问话仍不在 transcript 里, 才算一次
|
|
39
|
+
* (那一句多半丢了)。 */
|
|
8
40
|
const MAX_FRUITLESS = 3;
|
|
41
|
+
/** 等对方「动起来」的窗口: 注册表在它读进这一句之前仍报 idle, 不等这一下就会在它开工
|
|
42
|
+
* 之前读 transcript, 读到的还是上一件事。轮询放慢到 2s: idleNow 与 answered 都同步读
|
|
43
|
+
* tail, 十几路 fan-out 同时 ramp 时 1s 一次会压住事件循环。 */
|
|
44
|
+
const RAMP_MS = 20_000;
|
|
45
|
+
const RAMP_POLL_MS = 2000;
|
|
46
|
+
/** 每次最多等对方这么久就回来重读 deadline: 交接 transfer 会把 s.at 往后推, 一次定死的
|
|
47
|
+
* 超时会早于新的 deadline 放弃。 */
|
|
48
|
+
const IDLE_STEP_MS = 5 * 60_000;
|
|
49
|
+
/** codebuddy 没有注册表, 判闲靠 pane 采样 —— 它停在本地对话框上时 spinner 也会消失,
|
|
50
|
+
* 仍会被算作闲 (P1 在那边没修), 多采一次压低误判。发话方投递路径保持 2。 */
|
|
51
|
+
const IDLE_CONFIRM = 3;
|
|
52
|
+
/** 信封属性上的 k: `chat:<turn>:<base>` / `peer:<turn>:<key>` —— 只给机器读。chat 的
|
|
53
|
+
* `turn` 是开那一轮的那句人话的时刻: 人先后问的两件事各派了活, 不能被当成兄弟。 */
|
|
54
|
+
export const kAttr = (k) => `${k.kind}:${k.turn}:${k.kind === "chat" ? k.channel : k.from}`;
|
|
55
|
+
export const kOfAttr = (a) => {
|
|
56
|
+
const m = a.match(/^(chat|peer):([^:]+):(.+)$/);
|
|
57
|
+
return !m ? undefined : m[1] === "chat" ? { kind: "chat", turn: m[2], channel: m[3] } : { kind: "peer", turn: m[2], from: m[3] };
|
|
58
|
+
};
|
|
59
|
+
const sameK = (a, b) => kAttr(a) === kAttr(b);
|
|
9
60
|
/** 落盘的登记留多久。比对方最长允许干的时长宽, 好让续守的那一份仍数得进工单。 */
|
|
10
61
|
const KEEP_MS = 24 * 3600_000;
|
|
11
62
|
export const createReceipts = (deps) => {
|
|
@@ -20,6 +71,47 @@ export const createReceipts = (deps) => {
|
|
|
20
71
|
const settle = (s) => { s.settled = true; save(s); };
|
|
21
72
|
const stale = (s) => slots.get(keyOfPair(s.from, s.to))?.gen !== s.gen;
|
|
22
73
|
const ofJob = (from, job) => [...slots.values()].filter((x) => x.from === from && x.job === job);
|
|
74
|
+
/** P 的答话方在答 P 的那一轮里又派出去、还没落定的活。 */
|
|
75
|
+
const children = (p) => [...slots.values()].filter((c) => c.from === p.to && c.k?.kind === "peer" && c.k.from === p.from && c.k.turn === p.turn && !c.settled);
|
|
76
|
+
/** 与 c 同一个发话方、同一个父 k、定论还没送进去的那几份 (不含 c)。送进去的只是一份
|
|
77
|
+
* error 的那种还没落定: 续跑出来的真答案还要来。 */
|
|
78
|
+
const siblings = (c) => [...slots.values()].filter((x) => x !== c && x.from === c.from && !!x.k && sameK(x.k, c.k) && !x.settled && !(x.delivered && x.resolved));
|
|
79
|
+
/** 这份回执送进去之后, 发话方那一轮的终句去哪 (§3.3)。只在投递那一刻算: 兄弟们是
|
|
80
|
+
* 陆续回来的, 早算一步就会两份都以为自己不是最后一份。 */
|
|
81
|
+
const routeOf = (s, final) => {
|
|
82
|
+
const k = s.k;
|
|
83
|
+
if (!k || !final)
|
|
84
|
+
return { channel: s.channel, ...(k ? { k } : {}) };
|
|
85
|
+
const pending = siblings(s).length;
|
|
86
|
+
if (pending)
|
|
87
|
+
return { channel: "", k, pending };
|
|
88
|
+
if (k.kind === "chat")
|
|
89
|
+
return { channel: k.channel, k, replyTo: k };
|
|
90
|
+
const p = slots.get(keyOfPair(k.from, s.from));
|
|
91
|
+
return p && p.turn === k.turn && !p.resolved && !p.claimed ? { channel: "", k, replyTo: k, parent: p } : { channel: s.channel, k };
|
|
92
|
+
};
|
|
93
|
+
/** 续回: 上游那一份改锚到刚送进去的那份回执 (answerOf 按 reply-to 认它), 重新守。
|
|
94
|
+
* 正在守的那个 watcher 自己会发现锚变了; 挂起 (deferred) 的才要重新 arm。 */
|
|
95
|
+
const relay = (p, at) => {
|
|
96
|
+
p.at = at;
|
|
97
|
+
p.deadlineAt = Math.max(p.deadlineAt ?? 0, deadlineOf(at));
|
|
98
|
+
wake(p);
|
|
99
|
+
};
|
|
100
|
+
const wake = (p) => {
|
|
101
|
+
const was = p.deferred;
|
|
102
|
+
delete p.deferred;
|
|
103
|
+
save(p);
|
|
104
|
+
if (was)
|
|
105
|
+
arm(p, true);
|
|
106
|
+
};
|
|
107
|
+
/** 子活落定了却没有一份回执送进发话方 (发话方不在了 / 等不到它闲 / wait_peer 取走):
|
|
108
|
+
* 它若是挂起的上游等着的最后一份, 照样叫醒上游 —— 不然上游连一份 dead 都收不到。
|
|
109
|
+
* 锚不动: 上游按原来那一句重新守, 答话方不在了就落成 dead。 */
|
|
110
|
+
const release = (s) => {
|
|
111
|
+
const p = routeOf(s, true).parent;
|
|
112
|
+
if (p)
|
|
113
|
+
wake(p);
|
|
114
|
+
};
|
|
23
115
|
// 同一个 pane 的回执串行投递。两份回执同时等到发话方 idle 时, 两次 paste 会挤进
|
|
24
116
|
// 同一个输入框被当成一轮读掉 —— 正是异步化最容易带进来的那个故障。
|
|
25
117
|
const chains = new Map();
|
|
@@ -28,20 +120,88 @@ export const createReceipts = (deps) => {
|
|
|
28
120
|
chains.set(k, next.catch(() => undefined));
|
|
29
121
|
return next;
|
|
30
122
|
};
|
|
31
|
-
|
|
32
|
-
|
|
123
|
+
// 一份 slot 同时只有一个 watcher: reload 续守与 relay 叫醒可能撞在一起, 两个 watcher
|
|
124
|
+
// 会把同一个答案投两遍。叫醒挂起的那份要 `force`: 挂起那一刻旧 watcher 已经同步返回,
|
|
125
|
+
// 只是 finally 还没跑; 用 token 免得旧的 finally 删掉新登记的那个。
|
|
126
|
+
const watching = new Map();
|
|
127
|
+
const arm = (s, force = false) => {
|
|
128
|
+
if (watching.has(s) && !force)
|
|
129
|
+
return;
|
|
130
|
+
const token = Symbol();
|
|
131
|
+
watching.set(s, token);
|
|
132
|
+
void watch(s)
|
|
133
|
+
.catch((e) => deps.log.warn({ err: e.message }, "receipt watch failed"))
|
|
134
|
+
.finally(() => { if (watching.get(s) === token)
|
|
135
|
+
watching.delete(s); });
|
|
33
136
|
};
|
|
137
|
+
/** 对方这一轮结束了。判闲以注册表为准 (`untilIdle`): pane 的 spinner 在本地对话框上
|
|
138
|
+
* (hook 回落成 ask / AskUserQuestion) 和长工具调用的间隙里都会消失, 按它判闲会把
|
|
139
|
+
* 一个停在对话框上的 wizard 当成「答完了却没答」, 扑空三次就把回执扔了。
|
|
140
|
+
* 先等它动起来 (`rampEnd` 之前): 还没读进这一句的会话注册表照样报 idle; 已经答出
|
|
141
|
+
* 来了 (`answered`, 太快的一轮可能在两次采样之间就跑完) 就不必等满。
|
|
142
|
+
* `quiet` = 整段 ramp 它都闲着、什么也没答 —— 调用方据此判断那一句是不是丢了。 */
|
|
143
|
+
const settled = async (to, rampEnd, deadline, aborted, answered) => {
|
|
144
|
+
for (;;) {
|
|
145
|
+
if (aborted())
|
|
146
|
+
return { idle: false, reason: "stopped" };
|
|
147
|
+
if (Date.now() >= rampEnd)
|
|
148
|
+
return { idle: true, quiet: true };
|
|
149
|
+
if (!(await deps.idleNow(to)) || answered())
|
|
150
|
+
break;
|
|
151
|
+
await sleep(RAMP_POLL_MS);
|
|
152
|
+
}
|
|
153
|
+
for (;;) {
|
|
154
|
+
const left = deadline() - Date.now();
|
|
155
|
+
if (left <= 0)
|
|
156
|
+
return { idle: false, reason: "timeout" };
|
|
157
|
+
const r = await deps.untilIdle(to, Math.min(left, IDLE_STEP_MS), { aborted, confirm: IDLE_CONFIRM });
|
|
158
|
+
if (r.idle || aborted())
|
|
159
|
+
return r;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const canceled = (s) => s.outcome?.status === "canceled";
|
|
34
163
|
/** 守到对方答出**我们这一句**为止。信封是比发话时刻更硬的锚 (见 peers.replyToPeer):
|
|
35
164
|
* 对方正忙时我们那一句是排队的, 它先吐出来的是上一件事的结论 —— 按时刻取就会把
|
|
36
|
-
* 旧结论当成这一次的回执。所以"停下了但还没答我们"要接着等,
|
|
165
|
+
* 旧结论当成这一次的回执。所以"停下了但还没答我们"要接着等, 不能将就; 定位不到
|
|
166
|
+
* 问话 (undefined) 也只说明它还没读进, 绝不退回按时刻取。
|
|
167
|
+
* 那一轮以 CLI 报错收尾 → `error`; 报过一次之后只等续跑出来的真答案, 不再计扑空。
|
|
168
|
+
* `undefined` = 被 wait_peer 取走或被新的一句顶掉, 调用方自己知道。 */
|
|
37
169
|
const awaitReply = async (s) => {
|
|
38
170
|
// 锚在发话时刻而不是此刻: reload 后续守的那一份不该重新领一整份时长。交接转过来
|
|
39
171
|
// 的那份改锚到贴回简报的时刻, 所以每圈重算。
|
|
40
|
-
const deadline = () => s.at + TARGET_WAIT_SEC * 1000;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
172
|
+
const deadline = () => s.deadlineAt ?? s.at + TARGET_WAIT_SEC * 1000;
|
|
173
|
+
// cancel 落的终态也算: 守到一半被别人收掉, 不再等它的答案。
|
|
174
|
+
const aborted = () => s.claimed || stale(s) || canceled(s);
|
|
175
|
+
const reported = () => s.outcome?.status === "error";
|
|
176
|
+
// 反问过了: 同一段 NEED 不再投第二遍, 等发话方 tell_peer({re}) 答 (那会顶掉这份)。
|
|
177
|
+
const asked = () => s.outcome?.status === "need";
|
|
178
|
+
const reply = () => deps.replyFor(s.to, deps.nameOf(s.from), s.at, s.turn);
|
|
179
|
+
const news = (r) => !!r?.text.trim() && !(r.error && reported()) && !(asked() && r.text === s.outcome.body) && r.text !== s.bounced;
|
|
180
|
+
const failed = (status) => ({ status, body: failure(s, status) });
|
|
181
|
+
for (let fruitless = 0; fruitless < MAX_FRUITLESS;) {
|
|
182
|
+
if (Date.now() >= deadline())
|
|
183
|
+
return failed("timeout");
|
|
184
|
+
// 对方 pane 没了 (被收掉 / 崩了): 不会再答, 别空转三次 ramp。交接重开的那几秒
|
|
185
|
+
// pane 也是死的, 先等交接收尾再看; 别的换新 (respawn) 给一小段 grace。
|
|
186
|
+
await deps.handedOff?.(s.to);
|
|
187
|
+
if (s.answer !== undefined)
|
|
188
|
+
return { status: "done", body: s.answer };
|
|
189
|
+
if (!(await deps.paneLive(s.to))) {
|
|
190
|
+
await sleep(DEAD_GRACE_MS);
|
|
191
|
+
await deps.handedOff?.(s.to);
|
|
192
|
+
if (s.answer !== undefined)
|
|
193
|
+
return { status: "done", body: s.answer };
|
|
194
|
+
if (!(await deps.paneLive(s.to)))
|
|
195
|
+
return failed("dead");
|
|
196
|
+
}
|
|
197
|
+
const wr = await settled(s.to, Math.min(deadline(), Date.now() + RAMP_MS), deadline, aborted, () => news(reply()));
|
|
198
|
+
if (aborted())
|
|
199
|
+
return undefined;
|
|
200
|
+
if (!wr.idle) {
|
|
201
|
+
if (wr.reason === "timeout")
|
|
202
|
+
return failed("timeout");
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
45
205
|
// 它停下是因为在交接: 等交接收尾, 答案要么已钉在 s.answer, 要么义务转进了新会话
|
|
46
206
|
// (那就接着守新会话)。这一段与下面的 replyFor 之间不能有 await —— 交接一旦
|
|
47
207
|
// 开始就在旧会话还在时把 transfer 做完, 同步读到的必然还是旧 transcript。
|
|
@@ -49,113 +209,208 @@ export const createReceipts = (deps) => {
|
|
|
49
209
|
if (handing)
|
|
50
210
|
await deps.handedOff?.(s.to);
|
|
51
211
|
if (s.answer !== undefined)
|
|
52
|
-
return s.answer;
|
|
212
|
+
return { status: "done", body: s.answer };
|
|
53
213
|
if (handing)
|
|
54
214
|
continue;
|
|
55
|
-
const got =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
215
|
+
const got = reply();
|
|
216
|
+
if (got && news(got))
|
|
217
|
+
return { status: got.error ? "error" : parseClosing(got.text).kind === "need" ? "need" : "done", body: got.text };
|
|
218
|
+
// 读进了没答 = 扑空一次; 还没读进 = 前面有别的轮, 接着等 —— 除非整段 ramp 都静着。
|
|
219
|
+
// 报过 error / NEED 之后它停着是在等人续跑或答复, 不算扑空, 守到期限。
|
|
220
|
+
if (!reported() && !asked() && (got !== undefined || wr.quiet))
|
|
221
|
+
fruitless++;
|
|
62
222
|
}
|
|
63
|
-
|
|
223
|
+
// 打回过的那份: 它原样再交一遍 (按原文排除, 看不出是新的), 或打回那句因 reload 没注入
|
|
224
|
+
// 进去 —— 都扑空到这里。手上的原文就是它的交代, 照收, 别报 silent。
|
|
225
|
+
return s.bounced ? { status: "done", body: s.bounced } : failed("silent");
|
|
226
|
+
};
|
|
227
|
+
/** 失败回执的正文: 守护进程的一句说明 + 对方最后一句 (给发话方一点线索)。 */
|
|
228
|
+
const failure = (s, status) => {
|
|
229
|
+
const why = {
|
|
230
|
+
timeout: s.outcome?.status === "need"
|
|
231
|
+
? `它的反问 (NEED) 等了 ${Math.round((Date.now() - s.at) / 60_000)} 分钟没人答`
|
|
232
|
+
: `等了 ${Math.round((Date.now() - s.at) / 60_000)} 分钟它还没答完`,
|
|
233
|
+
silent: `它停下了 ${MAX_FRUITLESS} 次, 都没答这一句`,
|
|
234
|
+
dead: "它的 pane 没了",
|
|
235
|
+
};
|
|
236
|
+
const last = deps.lastWords?.(s.to, s.at).trim();
|
|
237
|
+
return `(守护进程: ${deps.nameOf(s.to)} 没有给出结论 —— ${why[status] ?? status}。)${last ? `\n它最后说的: ${truncate(last, 600)}` : ""}`;
|
|
64
238
|
};
|
|
65
239
|
const watch = async (s) => {
|
|
66
240
|
const lg = deps.log.child({ mod: "receipt", from: deps.nameOf(s.from), to: deps.nameOf(s.to), ...(s.job ? { job: s.job } : {}) });
|
|
67
|
-
const
|
|
241
|
+
const anchor = s.at;
|
|
242
|
+
// 终态写一次不再改: reload 打断投递后续守的那一份直接重投同一个结果, 不再重等。
|
|
243
|
+
const got = s.outcome && isTerminal(s.outcome.status) ? s.outcome : await awaitReply(s);
|
|
244
|
+
// 等的过程中被 cancel 落了终态: 以它为准 (pane 随后被杀, awaitReply 可能报的是 dead)。
|
|
245
|
+
const out = canceled(s) ? s.outcome : got;
|
|
68
246
|
if (stale(s))
|
|
69
247
|
return;
|
|
70
|
-
if (s.claimed) {
|
|
248
|
+
if (s.claimed || !out) { // wait_peer 抢先取走了
|
|
71
249
|
s.resolved = true;
|
|
72
250
|
settle(s);
|
|
251
|
+
if (s.job)
|
|
252
|
+
deps.settleJob?.(s.job, s.to, "done", []);
|
|
73
253
|
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
254
|
+
}
|
|
255
|
+
// 下面到 save 之间不能有 await: relay 与这里的判定必须看到同一个状态。
|
|
256
|
+
// 锚被 relay 挪到了回执那一句: 刚取到的是旧锚的答案 (那句「已派」), 重来。
|
|
257
|
+
if (s.at !== anchor)
|
|
258
|
+
return watch(s);
|
|
259
|
+
// 它这一轮又派了活, 结论要等那些回来 —— 这一句不是定论: 不投、不计数 (§3.2)。
|
|
260
|
+
if (out.status === "done" && children(s).length) {
|
|
261
|
+
s.deferred = true;
|
|
262
|
+
save(s);
|
|
263
|
+
lg.info({ children: children(s).map((c) => deps.nameOf(c.to)) }, "receipt: 答话方又派了活, 等子回执回来再续");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// 工单验收: 交了个空 RESULT (或缺约定的 ARTIFACT) —— 不投、不计数, 以发话方的名义同件号
|
|
267
|
+
// 打回一次; 再交上来的照收。新登记顶掉这一份 (它随即 stale), 守的是打回那一句的答案。
|
|
268
|
+
const why = out.status === "done" && s.job && !s.bounced ? deps.accept?.(s.job, out.body) : undefined;
|
|
269
|
+
if (why && deps.rebound) {
|
|
270
|
+
const at = Date.now();
|
|
271
|
+
const legs = (s.legs ?? 1) + 1;
|
|
272
|
+
register({ from: s.from, to: s.to, channel: s.channel, job: s.job, at, turn: s.turn, legs, deadlineAt: deadlineOf(at), ...(s.k ? { k: s.k } : {}), ...(s.asker ? { asker: s.asker } : {}), bounced: out.body });
|
|
273
|
+
const ok = await deps.rebound({ from: s.from, to: s.to, turn: s.turn, legs, channel: s.channel, ...(s.asker ? { asker: s.asker } : {}) }, why);
|
|
274
|
+
lg.info({ why, ok }, "receipt: 不合工单验收, 打回一次");
|
|
275
|
+
// 打回没注入进去: 原来那份照常交上去 (钉成新 slot 的答案, 它的 watcher 直接拿去投)。
|
|
276
|
+
if (!ok) {
|
|
277
|
+
const n = slots.get(keyOfPair(s.from, s.to));
|
|
278
|
+
if (n?.bounced && n.turn === s.turn) {
|
|
279
|
+
n.answer = out.body;
|
|
280
|
+
save(n);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
79
283
|
return;
|
|
80
284
|
}
|
|
285
|
+
const final = isTerminal(out.status);
|
|
286
|
+
s.outcome = out;
|
|
287
|
+
s.resolved = final;
|
|
288
|
+
save(s);
|
|
289
|
+
if (final && s.job)
|
|
290
|
+
deps.settleJob?.(s.job, s.to, out.status, parseClosing(out.body).artifacts);
|
|
81
291
|
// 发话方自己在交接: 重开的那几秒里 pane 是死的, 别把这当成"已经不在了"。
|
|
82
292
|
await deps.handedOff?.(s.from);
|
|
83
293
|
if (!(await deps.paneLive(s.from))) {
|
|
84
|
-
lg.info("receipt: 发话方已经不在了, 丢弃");
|
|
294
|
+
lg.info({ status: out.status }, "receipt: 发话方已经不在了, 丢弃");
|
|
85
295
|
settle(s);
|
|
296
|
+
if (final)
|
|
297
|
+
release(s);
|
|
86
298
|
return;
|
|
87
299
|
}
|
|
88
|
-
|
|
300
|
+
if (final)
|
|
301
|
+
s.claimed = true; // 占位先于投递: 这中间来的 wait_peer 不该把同一段再取一遍
|
|
89
302
|
const peers = s.job ? ofJob(s.from, s.job) : [s];
|
|
303
|
+
const tally = s.job ? deps.jobTally?.(s.job) : undefined;
|
|
90
304
|
const meta = {
|
|
91
305
|
from: s.to,
|
|
92
306
|
channel: s.channel,
|
|
93
307
|
job: s.job,
|
|
94
|
-
done: peers.filter((x) => x.resolved).length,
|
|
95
|
-
total: peers.length,
|
|
308
|
+
done: Math.max(peers.filter((x) => x.resolved).length, tally?.done ?? 0),
|
|
309
|
+
total: Math.max(peers.length, tally?.total ?? 0),
|
|
310
|
+
status: out.status,
|
|
311
|
+
turn: s.turn ?? "",
|
|
312
|
+
...(s.asker ? { asker: s.asker } : {}),
|
|
96
313
|
};
|
|
97
|
-
await serial(s.from, async () => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// 发话方正在生成 → 等它这一轮说完, 一结束就投。
|
|
314
|
+
const sent = await serial(s.from, async () => {
|
|
315
|
+
// 发话方正在生成 → 等它这一轮说完, 一结束就投。它的 pane 还活着就一直等 (上限是
|
|
316
|
+
// 登记的保留期): 回执到了却因为它在跑长活而扔掉, 它就只能永远等。
|
|
101
317
|
// 等的过程中它开始交接 → 等交接完再来一圈: 投进旧会话会被交接一并带走 (简报
|
|
102
318
|
// 已经写完了, 不含这一段), 投进还没贴回简报的新会话会抢在简报前面。
|
|
103
|
-
|
|
319
|
+
// 每圈都查 stale: 等发话方闲下来可能要很久, 同一对的新一句正是在这期间来的。
|
|
104
320
|
for (;;) {
|
|
105
321
|
await deps.handedOff?.(s.from);
|
|
322
|
+
// settled 只可能是 wait_peer 在这期间取走了 (watcher 自己投完才 settle)。
|
|
323
|
+
// 排队期间被 cancel 落了终态: 手上这份 (need / error) 已经过时, 改投 canceled。
|
|
324
|
+
if (stale(s) || s.settled || (canceled(s) && out !== s.outcome))
|
|
325
|
+
return false;
|
|
326
|
+
if (Date.now() >= s.at + KEEP_MS || !(await deps.paneLive(s.from))) {
|
|
327
|
+
lg.warn({ status: out.status }, "receipt: 发话方一直没空或已不在, 放弃回注");
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
106
330
|
if (!(await deps.idleNow(s.from))) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
lg.warn({ reason: w.reason }, "receipt: 发话方一直忙, 放弃回注");
|
|
110
|
-
settle(s);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
331
|
+
await deps.untilIdle(s.from, Math.min(IDLE_STEP_MS, s.at + KEEP_MS - Date.now()), { aborted: () => stale(s) });
|
|
332
|
+
continue;
|
|
113
333
|
}
|
|
114
334
|
if (!deps.handingOff?.(s.from))
|
|
115
335
|
break;
|
|
116
336
|
}
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
337
|
+
// 上面最后一圈的 await 期间可能被 wait_peer 取走; 从这里到 deliver 之间不能有 await。
|
|
338
|
+
if (stale(s) || s.settled)
|
|
339
|
+
return false;
|
|
340
|
+
const { parent, ...route } = routeOf(s, final);
|
|
341
|
+
const at = Date.now();
|
|
342
|
+
// 先记成已投: 投递途中被 wait_peer 取走的, 要告诉它「这段已经在你会话里了」。
|
|
343
|
+
s.delivered = true;
|
|
344
|
+
const r = await deps.deliver(s.from, out.body, { ...meta, ...route });
|
|
345
|
+
if (!r.ok) {
|
|
346
|
+
s.delivered = false;
|
|
347
|
+
s.undelivered = true;
|
|
122
348
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
349
|
+
if (r.ok && parent)
|
|
350
|
+
relay(parent, at);
|
|
351
|
+
lg.info({ ok: r.ok, reason: r.reason, status: out.status, len: out.body.length, done: meta.done, total: meta.total, ...(route.replyTo ? { replyTo: kAttr(route.replyTo) } : {}), ...(route.pending ? { pending: route.pending } : {}) }, "receipt: 回注");
|
|
352
|
+
return true;
|
|
127
353
|
});
|
|
354
|
+
if (stale(s)) {
|
|
355
|
+
lg.info("receipt: 被同一对的新一句顶掉, 不回注");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// error / need 只是中途报了一声: 接着守续跑 (或答复之后) 出来的答案。
|
|
359
|
+
if (!final && (sent || canceled(s)))
|
|
360
|
+
return watch(s);
|
|
361
|
+
settle(s);
|
|
362
|
+
if (!s.delivered)
|
|
363
|
+
release(s);
|
|
364
|
+
};
|
|
365
|
+
const register = (tell, watchIt = true) => {
|
|
366
|
+
const k = keyOfPair(tell.from, tell.to);
|
|
367
|
+
const at = tell.at ?? Date.now();
|
|
368
|
+
// 顶掉一份还没落定的工单活、自己又没带工单: 继承那张工单 —— 旧那份不会再投了,
|
|
369
|
+
// 这一句的答案就是那个成员的交代; 不继承它就永远不计 done, 工单齐不了。
|
|
370
|
+
const prev = slots.get(k);
|
|
371
|
+
const job = tell.job || (prev?.job && !prev.resolved ? prev.job : "");
|
|
372
|
+
const s = {
|
|
373
|
+
...tell,
|
|
374
|
+
job,
|
|
375
|
+
at,
|
|
376
|
+
turn: tell.turn ?? newTurn(),
|
|
377
|
+
legs: tell.legs ?? 1,
|
|
378
|
+
deadlineAt: tell.deadlineAt ?? deadlineOf(at),
|
|
379
|
+
gen: (slots.get(k)?.gen ?? 0) + 1,
|
|
380
|
+
claimed: false,
|
|
381
|
+
delivered: false,
|
|
382
|
+
resolved: false,
|
|
383
|
+
};
|
|
384
|
+
slots.set(k, s);
|
|
385
|
+
if (watchIt)
|
|
386
|
+
arm(s);
|
|
387
|
+
else {
|
|
388
|
+
s.claimed = true;
|
|
389
|
+
s.resolved = true;
|
|
390
|
+
s.settled = true;
|
|
391
|
+
} // 不守 = 这一份没人会投
|
|
392
|
+
save(s);
|
|
393
|
+
return { at: s.at, turn: s.turn };
|
|
128
394
|
};
|
|
129
395
|
return {
|
|
130
|
-
register
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
claimed: false,
|
|
138
|
-
delivered: false,
|
|
139
|
-
resolved: false,
|
|
140
|
-
};
|
|
141
|
-
slots.set(k, s);
|
|
142
|
-
if (watchIt)
|
|
143
|
-
arm(s);
|
|
144
|
-
else {
|
|
145
|
-
s.claimed = true;
|
|
146
|
-
s.resolved = true;
|
|
147
|
-
s.settled = true;
|
|
148
|
-
} // 不守 = 这一份没人会投
|
|
149
|
-
save(s);
|
|
150
|
-
return { at: s.at };
|
|
396
|
+
register,
|
|
397
|
+
prepare: (from, to, re) => {
|
|
398
|
+
const want = re?.replace(/[`\s]/g, "");
|
|
399
|
+
const s = want ? slots.get(keyOfPair(from, to)) : undefined;
|
|
400
|
+
return s?.turn && s.turn === want
|
|
401
|
+
? { turn: s.turn, legs: (s.legs ?? 1) + 1, job: s.job, channel: s.channel }
|
|
402
|
+
: { turn: newTurn(), legs: 1, ...(re ? { reUnknown: true } : {}) };
|
|
151
403
|
},
|
|
152
404
|
claim: (from, to) => {
|
|
153
405
|
const s = slots.get(keyOfPair(from, to));
|
|
154
406
|
if (!s)
|
|
155
407
|
return false;
|
|
408
|
+
const fresh = !s.settled;
|
|
156
409
|
s.claimed = true;
|
|
157
410
|
s.resolved = true;
|
|
158
411
|
settle(s);
|
|
412
|
+
if (fresh)
|
|
413
|
+
release(s);
|
|
159
414
|
return s.delivered;
|
|
160
415
|
},
|
|
161
416
|
sentAt: (from, to) => slots.get(keyOfPair(from, to))?.at ?? 0,
|
|
@@ -168,8 +423,10 @@ export const createReceipts = (deps) => {
|
|
|
168
423
|
const got = answered(s);
|
|
169
424
|
if (got.trim())
|
|
170
425
|
s.answer = got;
|
|
171
|
-
else
|
|
426
|
+
else {
|
|
172
427
|
s.at = carryAt;
|
|
428
|
+
s.deadlineAt = Math.max(s.deadlineAt ?? 0, carryAt + CARRY_GRACE_MS);
|
|
429
|
+
}
|
|
173
430
|
save(s);
|
|
174
431
|
return !got.trim();
|
|
175
432
|
});
|
|
@@ -177,8 +434,65 @@ export const createReceipts = (deps) => {
|
|
|
177
434
|
deps.log.info({ mod: "receipt", to: deps.nameOf(to), answered: owed.length - carried.length, carried: carried.map((s) => deps.nameOf(s.from)) }, "receipt: 交接时转交");
|
|
178
435
|
return carried;
|
|
179
436
|
},
|
|
437
|
+
pending: (from, to) => {
|
|
438
|
+
const s = slots.get(keyOfPair(from, to));
|
|
439
|
+
return s && !s.resolved && !s.claimed ? { job: s.job, turn: s.turn ?? "", need: s.outcome?.status === "need" } : undefined;
|
|
440
|
+
},
|
|
441
|
+
states: () => {
|
|
442
|
+
// 一个 target 欠好几份时只探一次 (探针要读它 transcript 的尾巴)。
|
|
443
|
+
const probed = new Map();
|
|
444
|
+
const parked = (t) => probed.get(t) ?? probed.set(t, deps.parkedNow?.(t) ?? false).get(t);
|
|
445
|
+
return [...slots.values()]
|
|
446
|
+
.filter((s) => !s.resolved && !s.claimed)
|
|
447
|
+
.map((s) => ({
|
|
448
|
+
from: s.from, to: s.to, turn: s.turn ?? "", job: s.job,
|
|
449
|
+
state: turnState(s, { parked: !s.outcome && parked(s.to) }),
|
|
450
|
+
ageMs: Date.now() - s.at,
|
|
451
|
+
}));
|
|
452
|
+
},
|
|
453
|
+
cancel: (target, by, only = () => true) => {
|
|
454
|
+
const hit = [...slots.values()].filter((s) => s.to === target && !s.resolved && !s.claimed && only({ from: s.from, turn: s.turn ?? "" }));
|
|
455
|
+
for (const s of hit) {
|
|
456
|
+
const last = deps.lastWords?.(s.to, s.at).trim();
|
|
457
|
+
s.outcome = {
|
|
458
|
+
status: "canceled",
|
|
459
|
+
body: `(守护进程: ${deps.nameOf(s.to)} 被 ${deps.nameOf(by)} 收掉 / 打断了, 这件活没有结论。)${last ? `\n它最后说的: ${truncate(last, 600)}` : ""}`,
|
|
460
|
+
};
|
|
461
|
+
if (s.job)
|
|
462
|
+
deps.settleJob?.(s.job, s.to, "canceled", []);
|
|
463
|
+
// 发起 stop 的那方自己知道, 不投; 守着的 watcher 看到 claimed 就收尾。
|
|
464
|
+
if (s.from === by) {
|
|
465
|
+
s.claimed = true;
|
|
466
|
+
s.resolved = true;
|
|
467
|
+
settle(s);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
save(s);
|
|
471
|
+
// 挂起等子活的没有 watcher 在守: 叫醒它去投这份 canceled。
|
|
472
|
+
if (s.deferred)
|
|
473
|
+
wake(s);
|
|
474
|
+
}
|
|
475
|
+
return hit.length;
|
|
476
|
+
},
|
|
477
|
+
parentOf: (self, env, channel, opening) => {
|
|
478
|
+
if (env?.receipt)
|
|
479
|
+
return env.k ? kOfAttr(env.k) : undefined;
|
|
480
|
+
if (env?.kind === "peer" && env.private) {
|
|
481
|
+
const p = [...slots.values()].find((x) => x.to === self && !x.resolved && !x.claimed && (env.turn ? x.turn === env.turn : deps.nameOf(x.from) === env.from));
|
|
482
|
+
return p && { kind: "peer", from: p.from, turn: p.turn };
|
|
483
|
+
}
|
|
484
|
+
return { kind: "chat", channel, turn: opening };
|
|
485
|
+
},
|
|
486
|
+
askerOf: (self, env) => {
|
|
487
|
+
// 回执轮: 那件活是 self 派出去的; 派来的活: 那件活是派给 self 的。同一对后来又说了一句,
|
|
488
|
+
// 槽被顶掉 —— 件号对不上就认不出, 宁可不知道。
|
|
489
|
+
const mine = (x) => (env.receipt ? x.from === self : x.to === self);
|
|
490
|
+
const hit = [...slots.values()].find((x) => mine(x) && (env.turn ? x.turn === env.turn : deps.nameOf(env.receipt ? x.to : x.from) === env.from));
|
|
491
|
+
return hit?.asker;
|
|
492
|
+
},
|
|
180
493
|
resume: () => {
|
|
181
|
-
|
|
494
|
+
// 挂起且子活还没落定的不守: 叫醒它是 relay / release 的事, 这里再 arm 一个就是两个 watcher。
|
|
495
|
+
const open = [...slots.values()].filter((s) => !s.settled && !(s.deferred && children(s).length));
|
|
182
496
|
// 投到一半被 reload 打断的那份: claimed 是上一个进程的占位, 这里重新来过。
|
|
183
497
|
open.forEach((s) => { s.claimed = false; arm(s); });
|
|
184
498
|
if (open.length)
|