wezard 2.7.3 → 2.7.7
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/dist/daemon/handoff.js +49 -35
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/inbound.js +1 -1
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +469 -245
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +7 -4
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/message-events.js +197 -29
- package/dist/daemon/message-events.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +477 -147
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +11 -6
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/router.js +93 -28
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/task-registry.js +47 -21
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +68 -26
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/tmux-buffer.js +1 -1
- package/dist/daemon/tmux-buffer.js.map +1 -1
- package/dist/daemon/wizard-names.js +63 -0
- package/dist/daemon/wizard-names.js.map +1 -0
- package/dist/daemon/wizard.js +16 -5
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +8 -16
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/task-file.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { wxMarkdown } from "../shared/wx-text.js";
|
|
|
2
2
|
// Daemon entry. Resident process — exits only on signal or fatal WS auth failure.
|
|
3
3
|
import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
5
|
+
import { timingSafeEqual, randomBytes, randomUUID } from "node:crypto";
|
|
6
6
|
import { loadOrCreateSvrToken } from "../shared/svr-token.js";
|
|
7
7
|
import { DAEMON_TOKEN_FILE, DAEMON_TOKEN_HEADER } from "../shared/daemon-token.js";
|
|
8
8
|
import { homedir } from "node:os";
|
|
@@ -26,8 +26,8 @@ import { applyHot, cfgSeenBy, patchedTop, pinChatKeys, configGet, fromLegacy, le
|
|
|
26
26
|
import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
|
|
27
27
|
import { installInboundRouter } from "./inbound.js";
|
|
28
28
|
import { loadMirrorStore } from "./mirror-store.js";
|
|
29
|
-
import { cancelOps, deadlineOf, eventRef, isEventRef, makeOutcome, openRouter, ops, pickPair } from "./router.js";
|
|
30
|
-
import { createMessageEvents, messageId, finalId } from "./message-events.js";
|
|
29
|
+
import { cancelOps, deadlineOf, eventRef, finalKind, isEventRef, makeOutcome, openRouter, ops, pickPair } from "./router.js";
|
|
30
|
+
import { createMessageEvents, messageId, finalId, reportSource, receiptCoverage, receiptFacts } from "./message-events.js";
|
|
31
31
|
import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
|
|
32
32
|
import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
33
33
|
import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
|
|
@@ -47,11 +47,12 @@ import { openTaskRegistry } from "./task-registry.js";
|
|
|
47
47
|
import { deliverScheduled } from "./scheduled-delivery.js";
|
|
48
48
|
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
49
49
|
import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
|
|
50
|
-
import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead,
|
|
50
|
+
import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
51
51
|
import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
|
|
52
52
|
import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
|
|
53
|
-
import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName
|
|
54
|
-
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
|
|
53
|
+
import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
|
|
54
|
+
import { bindWizardStore, loadWizardStore, pickName, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
|
|
55
|
+
import { AutoNameExhausted } from "./wizard-names.js";
|
|
55
56
|
import { activeRows, bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
|
|
56
57
|
import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
|
|
57
58
|
import { checkDirection } from "./direction.js";
|
|
@@ -204,11 +205,20 @@ const main = async () => {
|
|
|
204
205
|
let peerEvents;
|
|
205
206
|
/** T4: 路由表的投递层 —— 在 mirror 大块里接线 (要用到 jobs / 回执信封 / 事件泵), 这里先留个口。 */
|
|
206
207
|
let deliverFinal;
|
|
208
|
+
let handoffAllowed = () => false;
|
|
209
|
+
let handoffFinalSuppressed = (e) => !!e.handoffCut && !e.handoffRestored;
|
|
210
|
+
const ownsReplyEvent = (e) => {
|
|
211
|
+
const row = e.serves ? router.get(e.serves) : undefined;
|
|
212
|
+
if (row && row.owner !== e.target)
|
|
213
|
+
return false;
|
|
214
|
+
const latest = e.serves ? events.latestTransfer(e.serves) : undefined;
|
|
215
|
+
return !latest || latest.id === e.id || latest.at < e.at;
|
|
216
|
+
};
|
|
207
217
|
const events = createMessageEvents(join(cfg.daemon.stateDir, "message-events.json"), {
|
|
208
218
|
resource: (target) => bridge.sessionInfo(target)?.tmuxPane || target,
|
|
209
219
|
transcript: (target) => bridge.inputSessionInfo(target)?.jsonlPath ?? bridge.sessionInfo(target)?.jsonlPath,
|
|
210
220
|
sessionId: (target) => bridge.inputSessionInfo(target)?.sessionId,
|
|
211
|
-
valid: (e) => e.payload.peer ? !!peerEvents?.valid(e) : true,
|
|
221
|
+
valid: (e) => (!(e.payload.handoff || (e.id.startsWith("route:") && e.id.includes(":resume:"))) || handoffAllowed(e.target)) && (e.payload.peer ? !!peerEvents?.valid(e) : true),
|
|
212
222
|
accepted: (e) => { if (e.payload.peer)
|
|
213
223
|
peerEvents?.accepted(e); },
|
|
214
224
|
committed: (e) => { if (e.payload.peer)
|
|
@@ -223,15 +233,25 @@ const main = async () => {
|
|
|
223
233
|
events.result(e.id, body, finalId(e.id, e.channel, body));
|
|
224
234
|
// A canceled, unexposed input has a terminal response owned by its original channel.
|
|
225
235
|
events.delivery({ id: finalId(e.id, e.channel, body), eventId: e.id, target: e.target, turn: e.turn || e.id,
|
|
226
|
-
channel: e.channel, revision: finalId(e.id, e.channel, body), body, chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
|
|
236
|
+
channel: e.channel, revision: finalId(e.id, e.channel, body), body, purpose: "cancellation", reportKind: "canceled", chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
|
|
227
237
|
}
|
|
228
238
|
},
|
|
229
239
|
// 人话 (非 peer) 不再问「pane 闲不闲」: 忙就贴进去, 顺序交给 backend CLI 自己的输入队列
|
|
230
240
|
// (贴进运行中的一轮记成 queued 轮, 终句照样归到这条 event —— 见 brief 的 seal/bindLine)。
|
|
231
241
|
// 这一句曾是要 idleNow 的, 也就是那个反复 5s 超时的探测源头: 一超时就退避, 把同一 target
|
|
232
242
|
// 后面排着的每一笔 (含后到的人话) 一起钉住十几分钟。交接期间照旧等 —— 那几秒 pane 真是死的。
|
|
233
|
-
ready: async (e) => !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : true),
|
|
243
|
+
ready: async (e) => e.payload.handoff ? handoffAllowed(e.target) && bridge.idleNow(e.target) : !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : true),
|
|
234
244
|
inject: async (e, boundary) => {
|
|
245
|
+
if (e.payload.handoff) {
|
|
246
|
+
const expected = e.payload.handoff;
|
|
247
|
+
const same = () => { const info = bridge.sessionInfo(e.target); return info?.tmuxPane === expected.pane && info?.sessionId === expected.sessionId; };
|
|
248
|
+
if (!same())
|
|
249
|
+
return { ok: false, reason: "handoff original pane/session changed; refusing brief injection" };
|
|
250
|
+
return bridge.injectText(e.target, e.payload.text, undefined, {
|
|
251
|
+
channel: e.channel, eventId: e.id, envelope: renderReminder({ wezard: "envelope", kind: "peer", from: wizardStore()?.get(e.target)?.name || e.target, scope: "private", msg: e.id }, []), requireIdle: true, noRespawn: true, handoff: expected.clear,
|
|
252
|
+
guard: (phase, identity) => handoffAllowed(e.target) && same() && (!identity?.sessionId || identity.sessionId === expected.sessionId) && boundary(phase, identity),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
235
255
|
if (e.payload.peer)
|
|
236
256
|
return peerEvents.inject(e, boundary);
|
|
237
257
|
if (!bridge.hasMirrorTarget(e.target)) {
|
|
@@ -268,20 +288,34 @@ const main = async () => {
|
|
|
268
288
|
// 一轮以 CLI 报错收尾: 那段报错不是定论, 但要作为中途态 (error) 报给 receiver ——
|
|
269
289
|
// 所以照旧不落成 result, 只把原文交给 final (见 §9.4)。
|
|
270
290
|
return { consumed: true, interjected: !!turns[start].queued,
|
|
271
|
-
...(next >= 0 ? { closedAt: turns[next].ms ?? Date.now() }
|
|
272
|
-
|
|
291
|
+
...(next >= 0 ? { closedAt: turns[next].ms ?? Date.now() }
|
|
292
|
+
: last?.end && !last.text ? { closedAt: last.ms ?? Date.now() } : {}),
|
|
293
|
+
...(ended && last?.text && !last.mid
|
|
273
294
|
? last.apiError ? { body: last.text, error: true } : { body: last.text, revision: finalId(e.id, e.channel, last.text) }
|
|
274
295
|
: {}) };
|
|
275
296
|
}
|
|
276
297
|
return { consumed: false };
|
|
277
298
|
},
|
|
278
|
-
recovered: (e) =>
|
|
299
|
+
recovered: (e) => { if (!handoffFinalSuppressed(e))
|
|
300
|
+
bridge.recoverEventFinal(e); },
|
|
279
301
|
// T4: 终句 → 路由表。onFinal 连同投递一起做 —— 定论先落盘 (`answer`), 再按 receiver
|
|
280
302
|
// 投出去 (§9.3/§9.4); 实现在 mirror 大块里 (要 jobs / 信封 / 事件泵)。
|
|
281
303
|
final: (e, verdict) => deliverFinal?.(e, verdict),
|
|
282
304
|
format: (d) => ({ ...d, chunks: d.chunks.map((c) => responseTracker.freeze(d.channel.replace(/^(user|chat|group):/, ""), c)) }),
|
|
283
305
|
send: async (d, n) => {
|
|
284
306
|
await bridge.settleFinalProgress(d.id);
|
|
307
|
+
const allowed = () => {
|
|
308
|
+
const source = d.eventId ? events.authorityOf(d.eventId) : undefined;
|
|
309
|
+
if (!source)
|
|
310
|
+
return !d.eventId || d.purpose === "task";
|
|
311
|
+
const row = source.serves ? router.get(source.serves) : undefined;
|
|
312
|
+
const cancellation = d.purpose === "cancellation" && source.canceled && source.unexposedHuman;
|
|
313
|
+
return source.target === d.target && !source.interjected && !handoffFinalSuppressed(source) && ownsReplyEvent(source)
|
|
314
|
+
&& (cancellation || !source.canceled && row?.outcome?.status !== "canceled" && row?.status !== "held")
|
|
315
|
+
&& (d.purpose !== "progress" || !!row && ["open", "need", "error"].includes(row.status));
|
|
316
|
+
};
|
|
317
|
+
if (!allowed())
|
|
318
|
+
return { failed: "delivery authority withdrawn before transport" };
|
|
285
319
|
const chat = d.channel.replace(/^(user|chat|group):/, "");
|
|
286
320
|
if (wx.owns(chat)) {
|
|
287
321
|
const result = wx.sendFinal(chat, wxMarkdown(d.chunks[n]), `${d.id}:${n}`);
|
|
@@ -289,8 +323,8 @@ const main = async () => {
|
|
|
289
323
|
uncertain: result.state === "uncertain" ? result.reason ?? "Weixin send ACK unknown" : undefined,
|
|
290
324
|
failed: result.state === "failed" ? result.reason ?? "Weixin final rejected" : undefined };
|
|
291
325
|
}
|
|
292
|
-
const stream = d.stream && n === 0 &&
|
|
293
|
-
const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
|
|
326
|
+
const stream = d.stream && n === 0 && Date.now() < d.stream.expires ? d.stream : undefined;
|
|
327
|
+
const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, () => events.running() && allowed());
|
|
294
328
|
return ack && typeof ack === "object" && "errcode" in ack && ack.errcode === 0 ? { ack: JSON.stringify(ack) }
|
|
295
329
|
: { ack: JSON.stringify(ack), uncertain: "final transport returned no explicit success ACK" };
|
|
296
330
|
},
|
|
@@ -686,24 +720,25 @@ const main = async () => {
|
|
|
686
720
|
return;
|
|
687
721
|
}
|
|
688
722
|
const foreign = base !== baseOfKey(self);
|
|
689
|
-
const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""),
|
|
723
|
+
const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), [self]);
|
|
690
724
|
if (!slotR.ok) {
|
|
691
725
|
json(res, slotR.status, slotR.body);
|
|
692
726
|
return;
|
|
693
727
|
}
|
|
694
|
-
const { target,
|
|
695
|
-
if (!planStore.allowExternal(target, wizards.get(target)?.name ?? tag)) {
|
|
696
|
-
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
const existed = !!wizards.get(target);
|
|
700
|
-
const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
|
|
728
|
+
const { target, name } = slotR;
|
|
701
729
|
const model = (body.model ?? "").toString().trim();
|
|
702
730
|
log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
|
|
703
731
|
// silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
|
|
704
|
-
const r = await m.newSession(target, name, body.cli, { cwd, model, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) })
|
|
705
|
-
|
|
706
|
-
|
|
732
|
+
const r = await m.newSession(target, name, body.cli, { cwd, model, silent: true, guard: () => ownsSlot(slotR) && planStore.allowExternal(target, wizards.get(target)?.name ?? name) })
|
|
733
|
+
.catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}` }));
|
|
734
|
+
if (!ownsSlot(slotR)) {
|
|
735
|
+
json(res, 409, { ok: false, reason: "birth identity replaced during spawn" });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (!r.ok)
|
|
739
|
+
releaseSlot(slotR);
|
|
740
|
+
else
|
|
741
|
+
finishSlot(slotR);
|
|
707
742
|
if (r.ok)
|
|
708
743
|
postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
|
|
709
744
|
json(res, r.ok ? 200 : 500, r.ok
|
|
@@ -746,16 +781,7 @@ const main = async () => {
|
|
|
746
781
|
const r = await m.injectText(target, text, undefined, { guard });
|
|
747
782
|
json(res, r.ok ? 200 : 500, r);
|
|
748
783
|
});
|
|
749
|
-
//
|
|
750
|
-
// 活会话走交接 (handoff): 守护进程请它写简报 → 重开 (pendingCwd 在这一步被消费,
|
|
751
|
-
// 落进新目录, 宪章随之重渲染) → 简报贴回, 上下文跟着过去, 「📂 当前项目」气泡
|
|
752
|
-
// 照发 (announce —— 那是人看得见的回执)。一律 fire-and-forget: 调用方常常就是被换
|
|
753
|
-
// 的那个, 它得先拿到这份应答、把这一轮说完, 交接才动得了手 (other 的第一拍就是等它
|
|
754
|
-
// 闲)。交接没做成 → 捎一条提醒给它, pendingCwd 仍排着队, 人手 /new 照样完成切换
|
|
755
|
-
// (若它本就在交接中, 那场交接的重开同样会消费这笔 pendingCwd)。冷会话 (pane 不在)
|
|
756
|
-
// 没有可交接的上下文, 按老路直接新开。Resolved target
|
|
757
|
-
// precedence: explicit body.target → sessionId-derived (so MCP can omit
|
|
758
|
-
// it) → tmuxPane (stable across /clear) → cfg.defaultChat.
|
|
784
|
+
// 工作目录属于 CLI 进程; 活会话只记录 pending, 必须由人显式 /new 应用。
|
|
759
785
|
http.register("POST /mirror/workspace", async (req, res) => {
|
|
760
786
|
const body = (await readBody(req));
|
|
761
787
|
const cwd = (body.cwd ?? "").toString();
|
|
@@ -785,12 +811,8 @@ const main = async () => {
|
|
|
785
811
|
return;
|
|
786
812
|
}
|
|
787
813
|
if ((await liveOf([target])).length) {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
if (!r.ok)
|
|
791
|
-
notices.post([target], `换工作区到 ${set.pendingCwd} 的自动交接没做成: ${r.reason} —— 切换仍排着队, 手上这件告一段落后自己 \`handoff({brief})\`, 或让人在群里 /new 一下`);
|
|
792
|
-
});
|
|
793
|
-
json(res, 200, { ok: true, target, cwd: set.pendingCwd, scheduled: true });
|
|
814
|
+
json(res, 409, { ok: false, target, runningCwd: set.runningCwd, pendingCwd: set.pendingCwd,
|
|
815
|
+
reason: "原 CLI 进程不能切换工作目录;请求已保留为 pendingCwd,需要显式 /new 才生效,handoff /clear 不会应用它" });
|
|
794
816
|
return;
|
|
795
817
|
}
|
|
796
818
|
// Same shape as inbound's /new: windowName = tag for tagged sessions,
|
|
@@ -977,6 +999,7 @@ const main = async () => {
|
|
|
977
999
|
&& !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
|
|
978
1000
|
&& (r.plan.nodes.find((x) => x.id === n.id)?.after ?? []).every((id) => workflow.prereq(r.owner, r.nodes.find((x) => x.id === id).turn)?.state === "done");
|
|
979
1001
|
};
|
|
1002
|
+
handoffAllowed = (target) => planContinuationAllowed(target) && !planStore.reservation(target, wizards.get(target)?.name);
|
|
980
1003
|
/** 一笔活第几个来回 (§3.2): 同一行上已经注入过的续问数 + 1 —— 现算, 不落第二份数据。
|
|
981
1004
|
* 续问的注入事件 id 是 `route:<event>:r<legs>`, 所以从它自己就能数回来 (reload 也在盘上)。 */
|
|
982
1005
|
const legsOf = (event) => events.ids().reduce((n, id) => {
|
|
@@ -1032,21 +1055,67 @@ const main = async () => {
|
|
|
1032
1055
|
// 闸、回执清点与断点续做在那边。
|
|
1033
1056
|
/** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
|
|
1034
1057
|
const episodeName = (t) => wizards.get(t)?.name || tagOfKey(t) || "default";
|
|
1058
|
+
const handoffStore = loadJsonMap(cfg.wrc.mirror.handoffsFile, undefined, { strict: true });
|
|
1059
|
+
handoffFinalSuppressed = (event) => {
|
|
1060
|
+
const e = events.get(event.id) ?? event;
|
|
1061
|
+
if (e.handoffRestored)
|
|
1062
|
+
return false;
|
|
1063
|
+
if (e.handoffCut)
|
|
1064
|
+
return true;
|
|
1065
|
+
const p = handoffStore.get(e.target);
|
|
1066
|
+
// 先读在飞事务的明确旧事件集合, 覆盖 result 已写而事件 cut 尚未写的崩溃缝。
|
|
1067
|
+
return !!p && p.mode === "self" && !p.failure?.compensated && !!p.cut?.includes(e.id);
|
|
1068
|
+
};
|
|
1035
1069
|
const handoffs = createHandoffs({
|
|
1036
|
-
|
|
1070
|
+
currentEvents: (t) => {
|
|
1071
|
+
const info = m.sessionInfo(t);
|
|
1072
|
+
const active = info?.jsonlPath ? openingTurn(expandHome(info.jsonlPath))?.env?.eventId : undefined;
|
|
1073
|
+
return events.ids().filter((id) => {
|
|
1074
|
+
const e = events.get(id);
|
|
1075
|
+
return !!e && e.target === t && !e.payload.handoff && (id === active || (!e.finalized && e.execution !== "canceled" && e.exposure !== "none" && (!e.sessionId || e.sessionId === info?.sessionId)));
|
|
1076
|
+
});
|
|
1077
|
+
},
|
|
1078
|
+
suppress: (ids) => ids.forEach((id) => events.suppressHandoffFinal(id)),
|
|
1079
|
+
isBusy: async (t) => {
|
|
1080
|
+
if (!(await m.idleNow(t)))
|
|
1081
|
+
return true;
|
|
1082
|
+
// TUI 已 idle 不代表旧 final 已被事件泵消费; 先落旧轮结果, 再允许切断上下文。
|
|
1083
|
+
const old = events.ids().map((id) => events.get(id)).filter((e) => !!e && e.target === t && !e.id.startsWith("handoff:") && e.exposure !== "none" && !e.finalized && !e.releasedAt && e.execution !== "canceled" && (!e.sessionId || e.sessionId === m.sessionInfo(t)?.sessionId));
|
|
1084
|
+
await old.reduce(async (before, e) => { await before; await events.reconcile(e.id); }, Promise.resolve());
|
|
1085
|
+
return old.some((e) => { const now = events.get(e.id); return now && !now.finalized && now.execution !== "canceled"; });
|
|
1086
|
+
},
|
|
1037
1087
|
sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
|
|
1038
|
-
restart: (t, p) =>
|
|
1088
|
+
restart: (t, p, submitted) => clearForHandoff(t, p, submitted),
|
|
1039
1089
|
// 简报是新会话的开场白, 但它不挂任何信封 (服务不着哪一笔) —— 交接前欠着的那几笔由
|
|
1040
1090
|
// 「继续 <event>」逐笔续答 (见 resume), 别把简报那一轮的回复当成回执。
|
|
1041
|
-
inject: async (t, text, channel) => {
|
|
1091
|
+
inject: async (t, text, channel, id, timeoutMs, clear) => {
|
|
1042
1092
|
if (!planContinuationAllowed(t))
|
|
1043
1093
|
return { ok: false, reason: "workflow continuation withdrawn" };
|
|
1044
1094
|
if (planStore.reservation(t, wizards.get(t)?.name))
|
|
1045
1095
|
return { ok: false, reason: "workflow handoff requires the original attempt" };
|
|
1046
|
-
|
|
1096
|
+
if (clear && !(await m.verifyClearContext(t, clear)))
|
|
1097
|
+
return { ok: false, reason: "original cleared CLI process/session changed before carry" };
|
|
1098
|
+
// 事件先落盘; 同一 id 重入只恢复状态, 不重贴。内部事件没有 serves, 不替任何欠账收口。
|
|
1099
|
+
if (!events.known(id)) {
|
|
1100
|
+
const info = clear ? { tmuxPane: clear.pane, sessionId: clear.nextSid } : m.sessionInfo(t);
|
|
1101
|
+
if (!info?.tmuxPane || !info.sessionId)
|
|
1102
|
+
return { ok: false, reason: "original handoff pane/session binding is unavailable" };
|
|
1103
|
+
events.accept({ id, identity: JSON.stringify({ pane: info.tmuxPane, sid: info.sessionId }), target: t, speaker: t, channel, priority: "now", deadline: Date.now() + timeoutMs,
|
|
1104
|
+
payload: { text, handoff: { pane: info.tmuxPane, sessionId: info.sessionId, ...(clear ? { clear } : {}) } } });
|
|
1105
|
+
}
|
|
1106
|
+
for (const end = Date.now() + timeoutMs; Date.now() < end; await sleep(200)) {
|
|
1107
|
+
const e = await events.reconcile(id);
|
|
1108
|
+
if (e?.execution === "complete" || (!id.endsWith(":summary") && e?.exposure === "consumed"))
|
|
1109
|
+
return { ok: true };
|
|
1110
|
+
if (e?.execution === "uncertain" || e?.execution === "canceled")
|
|
1111
|
+
return { ok: false, reason: e.reason ?? "handoff event delivery is uncertain; not resubmitting" };
|
|
1112
|
+
}
|
|
1113
|
+
events.cancel((e) => e.id === id && e.exposure === "none", "handoff stopped before confirmed delivery");
|
|
1114
|
+
return { ok: false, reason: "handoff event consumption not confirmed; durable event retained" };
|
|
1047
1115
|
},
|
|
1048
1116
|
channelOf: m.currentChannel,
|
|
1049
1117
|
lastText: m.lastText,
|
|
1118
|
+
summary: (id) => { const e = events.get(id); return e?.execution === "complete" && e.exposure === "consumed" && typeof e.result?.body === "string" ? e.result.body : undefined; },
|
|
1050
1119
|
// 欠账不落盘在交接里: 路由表按 target 记着它欠谁、欠哪几笔, 换会话不影响 (T8)。
|
|
1051
1120
|
owed: (t) => router.owedBy(t).filter((r) => r.status === "open").map((r) => r.event),
|
|
1052
1121
|
resume: async (t, event, at) => injectResume(t, event, at),
|
|
@@ -1056,15 +1125,20 @@ const main = async () => {
|
|
|
1056
1125
|
// 交接没做成: 切断那一轮扣下的终句按原行补投 —— 走与轮终同一条 onFinal (行已被合成失败落定的, 照追述规则处理)。
|
|
1057
1126
|
uncut: (t, ids) => ids.forEach((id) => {
|
|
1058
1127
|
const e = events.get(id);
|
|
1059
|
-
if (!e
|
|
1060
|
-
log.warn({ target: t, event: id }, "handoff:
|
|
1128
|
+
if (!e) {
|
|
1129
|
+
log.warn({ target: t, event: id }, "handoff: 扣下的事件已不在事件库, 补投不了");
|
|
1061
1130
|
return;
|
|
1062
1131
|
}
|
|
1132
|
+
// 超时可能发生在旧轮尚未产出 final 时; 先释放耐久标记, 让它稍后的真终句正常落定。
|
|
1133
|
+
events.restoreHandoffFinal(id);
|
|
1134
|
+
if (!e.result)
|
|
1135
|
+
return;
|
|
1063
1136
|
log.info({ target: t, event: id, serves: e.serves }, "handoff: 交接没做成, 补投切断那一轮的终句");
|
|
1064
1137
|
settleFinal(e, { text: e.result.body });
|
|
1138
|
+
m.recoverEventFinal(events.get(id));
|
|
1065
1139
|
}),
|
|
1066
1140
|
log,
|
|
1067
|
-
store:
|
|
1141
|
+
store: handoffStore,
|
|
1068
1142
|
archive: (e) => {
|
|
1069
1143
|
if (!e.brief.trim())
|
|
1070
1144
|
return;
|
|
@@ -1078,7 +1152,9 @@ const main = async () => {
|
|
|
1078
1152
|
* `serves` 指回那一行 (注入事件 id `route:<event>:resume`), 于是它的终句照旧落定、
|
|
1079
1153
|
* 照旧投给 receiver —— 换会话丢掉的只是 transcript, 不是这笔账。
|
|
1080
1154
|
* id 按这一场交接的起点 (`at`) 区分: 同一场 reload 重放判重不重投, 换一场才配一条新的。 */
|
|
1081
|
-
function injectResume(t, event, at) {
|
|
1155
|
+
async function injectResume(t, event, at) {
|
|
1156
|
+
if (!handoffAllowed(t))
|
|
1157
|
+
return { ok: false, reason: "workflow continuation withdrawn or executor reserved" };
|
|
1082
1158
|
const row = router.get(event);
|
|
1083
1159
|
if (!row || row.owner !== t || row.status !== "open")
|
|
1084
1160
|
return { ok: false, reason: "no open row to resume" };
|
|
@@ -1086,7 +1162,7 @@ const main = async () => {
|
|
|
1086
1162
|
const waiting = r.kind === "wizard" ? r.to : baseOfKey(r.channel); // 等着它交代的那一方 (信封挂在它名下)
|
|
1087
1163
|
const channel = r.kind === "chat" ? r.channel : ""; // 结论要落回群里时才带频道 (§9.4)
|
|
1088
1164
|
const base = `route:${event}:resume`;
|
|
1089
|
-
const id =
|
|
1165
|
+
const id = `${base}:${at}`;
|
|
1090
1166
|
try {
|
|
1091
1167
|
events.accept({
|
|
1092
1168
|
id, identity: JSON.stringify([t, event, "resume", at]),
|
|
@@ -1101,7 +1177,15 @@ const main = async () => {
|
|
|
1101
1177
|
envelope: envelopeFor(waiting, channel, { ...(row.asker ? { asker: row.asker } : {}) }),
|
|
1102
1178
|
} },
|
|
1103
1179
|
});
|
|
1104
|
-
|
|
1180
|
+
// accept 仅表示排队; 等该 id 真被消费后才让交接记录收尾。
|
|
1181
|
+
for (const end = Date.now() + 600_000; Date.now() < end; await sleep(200)) {
|
|
1182
|
+
const e = await events.reconcile(id);
|
|
1183
|
+
if (e?.exposure === "consumed" || e?.execution === "complete")
|
|
1184
|
+
return { ok: true };
|
|
1185
|
+
if (e?.execution === "uncertain" || e?.execution === "canceled")
|
|
1186
|
+
return { ok: false, reason: e.reason ?? "resume delivery uncertain" };
|
|
1187
|
+
}
|
|
1188
|
+
return { ok: false, reason: "resume consumption not confirmed; handoff retained" };
|
|
1105
1189
|
}
|
|
1106
1190
|
catch (e) {
|
|
1107
1191
|
return { ok: false, reason: errText(e) };
|
|
@@ -1162,11 +1246,8 @@ const main = async () => {
|
|
|
1162
1246
|
return "";
|
|
1163
1247
|
// 计划节点 / 停放放行: 那一笔在 plan 发起 / 停放那一刻就定了; 其余看本轮服务谁。
|
|
1164
1248
|
const f = p.serves ?? servesOfTurn(p.self);
|
|
1165
|
-
if (p.op === "transfer")
|
|
1166
|
-
if (f && router.get(f)?.owner === p.self)
|
|
1167
|
-
router.commit(ops.move({ event: f, owner: p.target }));
|
|
1249
|
+
if (p.op === "transfer")
|
|
1168
1250
|
return f ?? "";
|
|
1169
|
-
}
|
|
1170
1251
|
const parent = p.op === "self" ? f : undefined;
|
|
1171
1252
|
return openRow({
|
|
1172
1253
|
...(parent ? { event: `${parent}.${(router.get(parent)?.seq ?? 0) + 1}` } : {}),
|
|
@@ -1176,6 +1257,29 @@ const main = async () => {
|
|
|
1176
1257
|
...(p.job ? { job: p.job } : {}), ...(p.asker ? { asker: p.asker } : {}), ...(p.after?.length ? { after: p.after } : {}),
|
|
1177
1258
|
});
|
|
1178
1259
|
};
|
|
1260
|
+
// Acceptance projects ownership only after the input is durable. A rejected
|
|
1261
|
+
// acceptance retains the human's route and bubble for the original owner.
|
|
1262
|
+
const acceptPeerDispatch = (input, transfer = false) => {
|
|
1263
|
+
if (!transfer)
|
|
1264
|
+
return events.accept(input);
|
|
1265
|
+
const row = input.serves ? router.get(input.serves) : undefined;
|
|
1266
|
+
if (!row || row.owner !== input.speaker || row.status === "answered" || row.status === "handed")
|
|
1267
|
+
throw new Error("transfer ownership changed before acceptance");
|
|
1268
|
+
try {
|
|
1269
|
+
const accepted = events.accept(input);
|
|
1270
|
+
m.transferReply(events.get(input.id));
|
|
1271
|
+
return accepted;
|
|
1272
|
+
}
|
|
1273
|
+
catch (error) {
|
|
1274
|
+
const accepted = events.get(input.id);
|
|
1275
|
+
if (!accepted || accepted.exposure === "none") {
|
|
1276
|
+
events.cancel((e) => e.id === input.id, "transfer acceptance failed before exposure");
|
|
1277
|
+
if (router.get(row.event)?.owner === input.target)
|
|
1278
|
+
router.commit(ops.move({ event: row.event, owner: input.speaker }));
|
|
1279
|
+
}
|
|
1280
|
+
throw error;
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1179
1283
|
/** 那一笔行上的接收快照 (挂起账投影用): 结论收给谁。没有行 = 不知道 (还是记 nobody)。 */
|
|
1180
1284
|
const receiverOfEvent = (event) => (event ? router.get(event)?.receiver : undefined);
|
|
1181
1285
|
/** 挂起账里那一行的 owner = 派活方 = 这一笔的接收方。收给群的根行 (人话 / 定时) 不进账 ——
|
|
@@ -1244,28 +1348,35 @@ const main = async () => {
|
|
|
1244
1348
|
return;
|
|
1245
1349
|
// 挂起账 / 工单账本跟着定论走 (§9.5 / §9.6): 每一份定论都从这儿过 (onFinal 的 answer、
|
|
1246
1350
|
// 失败级联的 canceled、boot 的重投), 所以放这里而不是 commitRoute。收给群的根行不进账。
|
|
1247
|
-
|
|
1351
|
+
const noticeOnly = (out.status === "need" || out.status === "error") && !!row.outcome;
|
|
1352
|
+
if (row.receiver.kind === "wizard" && !noticeOnly) {
|
|
1248
1353
|
ledger.outcome(row.receiver.to, event, out.status, parseClosing(out.body).text || out.body);
|
|
1249
1354
|
// 带 workflow 追踪的那件活: 执行态/正文/摘要跟着定论走 (计划节点据此推进) ——
|
|
1250
1355
|
// 这一份原来只挂在旧回执 watcher 的 onOutcome 上, T10b 随它一起在这里重建。
|
|
1251
1356
|
workflow.outcome(row.receiver.to, event, out.status, out.body);
|
|
1252
1357
|
advancePlans();
|
|
1253
|
-
//
|
|
1254
|
-
if (row.job && isTerminal(out.status)
|
|
1358
|
+
// 追述更新最新结果, 首次落定计数由工单保持。
|
|
1359
|
+
if (row.job && isTerminal(out.status))
|
|
1255
1360
|
jobs.settle(row.job, row.owner, out.status, parseClosing(out.body).artifacts, { turn: event });
|
|
1256
1361
|
}
|
|
1257
1362
|
postRoute(row, out, router.escalate(event, (k) => !!wizards.get(k)), `rcpt:${event}:${out.rev}`);
|
|
1258
1363
|
};
|
|
1259
1364
|
/** 一份定论的投递 (账本已记过): 投给升级后的收方 `esc` (没有 = 行上的 receiver)。群收方只落
|
|
1260
1365
|
* `hand`; wizard 收方 (含 Dead Letter 退到的群默认 wizard) 进收件箱, id = `inbox`。 */
|
|
1261
|
-
const postRoute = (row, out, esc, inbox) => {
|
|
1366
|
+
const postRoute = (row, out, esc, inbox, retire) => {
|
|
1262
1367
|
const event = row.event;
|
|
1263
1368
|
const receiver = esc?.receiver ?? row.receiver;
|
|
1264
1369
|
const via = (esc?.via ?? []).map(displayName);
|
|
1265
1370
|
// 升级落在 chat 根 = Dead Letter: 退到那个群, 由它的默认 wizard 收下这份结论。
|
|
1266
1371
|
const dead = receiver.kind === "chat" && !!esc;
|
|
1267
1372
|
if (receiver.kind !== "wizard" && !dead) {
|
|
1268
|
-
|
|
1373
|
+
// A transferred chat reply has no upstream wizard to report failures to.
|
|
1374
|
+
// Persist it through the same final outbox before handing the route off.
|
|
1375
|
+
const transferred = events.latestTransfer(event);
|
|
1376
|
+
if (out.status !== "done" && transferred?.target === row.owner)
|
|
1377
|
+
m.deliverTransferredFinal(transferred, out.body, out.status);
|
|
1378
|
+
if (isTerminal(out.status) && row.outcome?.rev === out.rev)
|
|
1379
|
+
router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
|
|
1269
1380
|
return;
|
|
1270
1381
|
}
|
|
1271
1382
|
// 回执那一轮服务的就是收方自己欠的那一笔 (`receiver.event`) —— 它的终句又一次落到那一笔上,
|
|
@@ -1285,6 +1396,13 @@ const main = async () => {
|
|
|
1285
1396
|
: undefined;
|
|
1286
1397
|
const job = row.job ? jobs.get(row.job) : undefined;
|
|
1287
1398
|
const tally = row.job ? jobs.tally(row.job) : undefined;
|
|
1399
|
+
const envelope = renderReceiptEnvelope(displayName(row.owner), channel ? chatNameOf(cfg, channel) : undefined, row.job ? { job: row.job, done: tally?.done ?? 0, total: tally?.total ?? 0, closed: job?.status !== "open", ...(job?.kind === "req" ? { req: true } : {}) } : undefined, out.status, event, { ...(row.asker ? { asker: row.asker } : {}),
|
|
1400
|
+
...(served?.kind === "wizard" ? { up: true } : {}),
|
|
1401
|
+
...(audienceName(channel, row.asker) ? { audience: audienceName(channel, row.asker) } : {}),
|
|
1402
|
+
...(via.length ? { via: via.join(", ") } : {}),
|
|
1403
|
+
...(late ? { late: true } : {}),
|
|
1404
|
+
...(retellTo ? { retellTo } : {}),
|
|
1405
|
+
...(row.late ? { retold: true } : {}) });
|
|
1288
1406
|
events.accept({
|
|
1289
1407
|
id: inbox, identity: JSON.stringify([to, event, out.status, out.rev]),
|
|
1290
1408
|
target: to, speaker: row.owner, channel, serves,
|
|
@@ -1292,26 +1410,35 @@ const main = async () => {
|
|
|
1292
1410
|
priority: "normal", deadline: Date.now() + 12 * 3600_000,
|
|
1293
1411
|
// 行首 RESULT:/NEED: 只是判定记号, 语义已由信封的 status 携带 —— 投递时剥掉
|
|
1294
1412
|
// (ARTIFACT 行保留: 路径对收方 wizard 有实际用途)。
|
|
1295
|
-
payload: { text: clipMiddle(stripClosingMark(out.body)),
|
|
1413
|
+
payload: { text: clipMiddle(stripClosingMark(out.body)), receipts: [{ event, version: out.rev, status: out.status,
|
|
1414
|
+
receiver, body: out.body, speaker: row.owner, envelope,
|
|
1415
|
+
source: row.reports?.[out.rev]?.source
|
|
1416
|
+
?? (row.outcome?.rev === out.rev ? row.outcome.report?.source : row.notice?.rev === out.rev ? row.notice.report?.source : undefined) }], peer: {
|
|
1296
1417
|
// 回执不是一件新活: `job` 留空 (否则 jobs.account 会把它当成工单的第二份派活数一遍)。
|
|
1297
1418
|
from: { kind: "peer", from: row.owner, receipt: true, status: out.status, event },
|
|
1298
1419
|
job: "", kind: "fyi",
|
|
1299
|
-
envelope
|
|
1300
|
-
...(served?.kind === "wizard" ? { up: true } : {}),
|
|
1301
|
-
...(audienceName(channel, row.asker) ? { audience: audienceName(channel, row.asker) } : {}),
|
|
1302
|
-
...(via.length ? { via: via.join(", ") } : {}),
|
|
1303
|
-
...(late ? { late: true } : {}),
|
|
1304
|
-
...(retellTo ? { retellTo } : {}),
|
|
1305
|
-
...(row.late ? { retold: true } : {}) }),
|
|
1420
|
+
envelope,
|
|
1306
1421
|
} },
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1422
|
+
}, retire);
|
|
1423
|
+
if (isTerminal(out.status) && row.outcome?.rev === out.rev)
|
|
1424
|
+
router.commit(ops.hand({ event, inbox }));
|
|
1309
1425
|
};
|
|
1310
1426
|
/** 收件箱里那份回执投不到了 (`gone`: 收方没有绑定, 按 store 也恢复不了) —— 投进去时它还在
|
|
1311
1427
|
* 注册表上, 升级没跳过它。现在把它当不在, 沿 receiver 边重走一次升级: 能收的上层 wizard /
|
|
1312
1428
|
* 群根 (Dead Letter) 收下。链上无处可升 (根行的收方自己没了): canceled 是收掉它的那一方自己
|
|
1313
1429
|
* 发起的, 没人在等, 不再投; 别的定论退到收方的家群 (Dead Letter)。账本不再记第二遍。 */
|
|
1314
1430
|
const rerouteGone = (e) => {
|
|
1431
|
+
const facts = receiptFacts(e);
|
|
1432
|
+
if (facts.length <= 1) {
|
|
1433
|
+
rerouteReceipt(e, e.id);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
facts.forEach((f, i) => rerouteReceipt({ ...e, id: `rcpt:${f.event}:${f.version}`,
|
|
1437
|
+
speaker: f.speaker, payload: { ...e.payload, text: f.body, receipts: [f],
|
|
1438
|
+
peer: { ...e.payload.peer, from: { kind: "peer", from: f.speaker, event: f.event,
|
|
1439
|
+
receipt: true, status: f.status }, envelope: f.envelope } } }, i === facts.length - 1 ? e.id : undefined));
|
|
1440
|
+
};
|
|
1441
|
+
const rerouteReceipt = (e, retire) => {
|
|
1315
1442
|
const from = e.payload.peer?.from;
|
|
1316
1443
|
const event = from?.receipt ? from.event : undefined;
|
|
1317
1444
|
const row = event ? router.get(event) : undefined;
|
|
@@ -1330,10 +1457,10 @@ const main = async () => {
|
|
|
1330
1457
|
return;
|
|
1331
1458
|
}
|
|
1332
1459
|
const rev = e.id.slice(`rcpt:${event}:`.length).split("~")[0];
|
|
1333
|
-
|
|
1334
|
-
const inbox = `${
|
|
1460
|
+
// Replaying a torn reroute must target the same durable inbox entry.
|
|
1461
|
+
const inbox = `${e.id}~${finalId(e.id, JSON.stringify(esc?.receiver ?? row.receiver), rev)}`;
|
|
1335
1462
|
log.info({ id: e.id, target: e.target, event, to: esc?.receiver ?? row.receiver, inbox }, "receipt reroute: 收方已不在, 改投");
|
|
1336
|
-
postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox);
|
|
1463
|
+
postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox, retire);
|
|
1337
1464
|
};
|
|
1338
1465
|
/** 一条路由 op: 先落盘, 再投该投的 (need / error 带正文入箱; answer 按 receiver 投递)。这里
|
|
1339
1466
|
* 只管 `re` (`mark open`) 把那一行拉回在飞 —— 账本与工单跟着 deliverRoute 走。 */
|
|
@@ -1356,6 +1483,8 @@ const main = async () => {
|
|
|
1356
1483
|
* 也最多再多打回一次)。**这是「轮终 evidence → onFinal」那条主触发链的落点**: 没有它,
|
|
1357
1484
|
* answer 就只能靠 sweeper / 级联取消 / boot 重放合成, 谁答完都不会有回执。 */
|
|
1358
1485
|
deliverFinal = (e, verdict) => {
|
|
1486
|
+
if (handoffFinalSuppressed(e) || !ownsReplyEvent(e))
|
|
1487
|
+
return;
|
|
1359
1488
|
// 同一轮的终句会递进来两次: result() 当场递一次, 泵见 complete 未 finalized 再补判一次
|
|
1360
1489
|
// (message-events)。被打回那一轮的重放不是补交 —— 丢掉; 否则 bounced 已记着这一笔,
|
|
1361
1490
|
// 第二次就把被打回的原文照收成 done (打回的续问还没注入, 定论先落了)。
|
|
@@ -1364,16 +1493,16 @@ const main = async () => {
|
|
|
1364
1493
|
// 自我交接切断的那一轮: 收尾那句不是欠着的那几笔的答案 —— 行留 open, 交给新会话的「继续 <event>」。
|
|
1365
1494
|
// 扣下记在这场交接上: 交接没做成 (新会话没起来), 它就是那几笔仅有的交代, 由 uncut 补投。
|
|
1366
1495
|
if (cuttingTurn(e.target)) {
|
|
1496
|
+
handoffs.cut(e.target, e.id);
|
|
1497
|
+
events.suppressHandoffFinal(e.id);
|
|
1367
1498
|
log.info({ event: e.id, target: e.target, serves: e.serves }, "handoff: 交接切断的那一轮终句不落定");
|
|
1368
|
-
if (!verdict.error)
|
|
1369
|
-
handoffs.cut(e.target, e.id);
|
|
1370
1499
|
return;
|
|
1371
1500
|
}
|
|
1372
1501
|
settleFinal(e, verdict);
|
|
1373
1502
|
};
|
|
1374
1503
|
/** 一轮的终句落到它服务的那一行上 (定论 / 中途态), 验收不合规格就打回续问。 */
|
|
1375
1504
|
const settleFinal = (e, verdict) => {
|
|
1376
|
-
const plans = router.onFinal(e.target, e.serves, verdict);
|
|
1505
|
+
const plans = router.onFinal(e.target, e.serves, { ...verdict, source: reportSource(e) });
|
|
1377
1506
|
const answer = plans.flatMap((op) => (op.op === "answer" && op.outcome.status === "done" ? [op] : []))[0];
|
|
1378
1507
|
const row = answer ? router.get(answer.event) : undefined;
|
|
1379
1508
|
const why = answer && row ? acceptanceOf(row, answer.outcome.body) : undefined;
|
|
@@ -1383,6 +1512,11 @@ const main = async () => {
|
|
|
1383
1512
|
rebound(row, why, e.id);
|
|
1384
1513
|
return;
|
|
1385
1514
|
}
|
|
1515
|
+
// Error evidence has no durable event result. Queue the chat reply before
|
|
1516
|
+
// mark:error suppresses replay, so a reload between the two cannot lose it.
|
|
1517
|
+
if (verdict.error && plans.some((op) => op.op === "mark" && op.status === "error")
|
|
1518
|
+
&& e.serves && router.get(e.serves)?.receiver.kind === "chat" && e.payload.peer?.transferred)
|
|
1519
|
+
m.deliverTransferredFinal(e, verdict.text);
|
|
1386
1520
|
plans.forEach(commitRoute);
|
|
1387
1521
|
};
|
|
1388
1522
|
// ── 新路由表 (T6): 失败级联 ─────────────────────────────────────────────
|
|
@@ -1434,9 +1568,12 @@ const main = async () => {
|
|
|
1434
1568
|
* 先留这个调用位。 */
|
|
1435
1569
|
const releaseHeld = (_row) => { };
|
|
1436
1570
|
/** §4.5 boot: fold 之后 `answered` 还没 `hand` 的行重投一次 —— rcpt 的 id 幂等, 重放不双投。 */
|
|
1437
|
-
const replayAnswered = () => router.rows()
|
|
1438
|
-
|
|
1439
|
-
|
|
1571
|
+
const replayAnswered = () => router.rows().forEach((r) => {
|
|
1572
|
+
if (r.status === "answered" && r.outcome)
|
|
1573
|
+
deliverRoute(r.event, r.outcome);
|
|
1574
|
+
if (r.notice && r.outcome?.status !== "canceled")
|
|
1575
|
+
deliverRoute(r.event, r.notice);
|
|
1576
|
+
});
|
|
1440
1577
|
const currentAsk = (target) => {
|
|
1441
1578
|
const env = openingAsk(target);
|
|
1442
1579
|
if (!env)
|
|
@@ -1554,57 +1691,98 @@ const main = async () => {
|
|
|
1554
1691
|
* - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
|
|
1555
1692
|
* - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
|
|
1556
1693
|
* 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
|
|
1557
|
-
const
|
|
1694
|
+
const namingClaims = new Set();
|
|
1695
|
+
const ownsSlot = (s) => wizards.get(s.target)?.birthToken === s.token;
|
|
1696
|
+
const releaseSlot = (s) => {
|
|
1697
|
+
if (!ownsSlot(s))
|
|
1698
|
+
return;
|
|
1699
|
+
if (s.existed)
|
|
1700
|
+
wizards.upsert(s.target, { spawning: undefined, spawnReused: undefined });
|
|
1701
|
+
else
|
|
1702
|
+
wizards.drop(s.target);
|
|
1703
|
+
};
|
|
1704
|
+
const finishSlot = (s) => {
|
|
1705
|
+
if (ownsSlot(s))
|
|
1706
|
+
wizards.upsert(s.target, { spawning: undefined, spawnReused: undefined });
|
|
1707
|
+
};
|
|
1708
|
+
const claimSlot = async (base, want, keep = [], allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, target ? wizards.get(target)?.name : undefined), internalName) => {
|
|
1558
1709
|
const asked = normalizeTag(want);
|
|
1559
1710
|
const denied = { ok: false, status: 409, body: { ok: false, reason: "workflow plan reserves this executor" } };
|
|
1560
|
-
const
|
|
1561
|
-
if (
|
|
1562
|
-
return
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
}
|
|
1575
|
-
if (clash && !(await displace(asked, keep))) {
|
|
1576
|
-
const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
|
|
1577
|
-
const alive = info?.paneAlive ?? false;
|
|
1578
|
-
const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
|
|
1579
|
-
const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
|
|
1580
|
-
const advice = alive
|
|
1581
|
-
? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
|
|
1582
|
-
: bound
|
|
1583
|
-
? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
|
|
1584
|
-
: "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
|
|
1585
|
-
return {
|
|
1586
|
-
ok: false,
|
|
1587
|
-
status: 409,
|
|
1588
|
-
body: {
|
|
1589
|
-
ok: false,
|
|
1590
|
-
reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
|
|
1591
|
-
name: clash.name,
|
|
1592
|
-
address: clash.name,
|
|
1593
|
-
alive,
|
|
1594
|
-
busy: info?.busy ?? false,
|
|
1595
|
-
idleForMs,
|
|
1596
|
-
},
|
|
1711
|
+
const claimKey = asked.toLowerCase();
|
|
1712
|
+
if (asked && namingClaims.has(claimKey))
|
|
1713
|
+
return { ok: false, status: 409, body: { ok: false, reason: "wizard name is being claimed" } };
|
|
1714
|
+
if (asked)
|
|
1715
|
+
namingClaims.add(claimKey);
|
|
1716
|
+
try {
|
|
1717
|
+
const candidate = asked || (internalName ? pickName(internalName, wizards.all().map((w) => w.name)) : wizards.nextName());
|
|
1718
|
+
if (!allowed(keyOf(base, candidate), candidate))
|
|
1719
|
+
return denied;
|
|
1720
|
+
const reserve = (target, existed) => {
|
|
1721
|
+
// No await between uniqueness check and durable registration.
|
|
1722
|
+
const token = randomUUID();
|
|
1723
|
+
wizards.upsert(target, { name: candidate, spawning: BOOT_ID, birthToken: token, spawnReused: existed || undefined });
|
|
1724
|
+
return { ok: true, target, name: candidate, existed, token };
|
|
1597
1725
|
};
|
|
1726
|
+
const clash = asked ? wizards.byName(asked) : undefined;
|
|
1727
|
+
if (clash?.spawning)
|
|
1728
|
+
return { ok: false, status: 409, body: { ok: false, reason: "wizard is being spawned", name: clash.name } };
|
|
1729
|
+
// 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
|
|
1730
|
+
// 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
|
|
1731
|
+
if (clash && !sameBot(base, clash.target)) {
|
|
1732
|
+
return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
|
|
1733
|
+
}
|
|
1734
|
+
if (clash && !allowed(clash.target, clash.name))
|
|
1735
|
+
return denied;
|
|
1736
|
+
const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
|
|
1737
|
+
if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
|
|
1738
|
+
return reserve(clash.target, true);
|
|
1739
|
+
}
|
|
1740
|
+
if (clash && !(await displace(asked, keep))) {
|
|
1741
|
+
const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
|
|
1742
|
+
const alive = info?.paneAlive ?? false;
|
|
1743
|
+
const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
|
|
1744
|
+
const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
|
|
1745
|
+
const advice = alive
|
|
1746
|
+
? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
|
|
1747
|
+
: bound
|
|
1748
|
+
? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
|
|
1749
|
+
: "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
|
|
1750
|
+
return {
|
|
1751
|
+
ok: false,
|
|
1752
|
+
status: 409,
|
|
1753
|
+
body: {
|
|
1754
|
+
ok: false,
|
|
1755
|
+
reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
|
|
1756
|
+
name: clash.name,
|
|
1757
|
+
address: clash.name,
|
|
1758
|
+
alive,
|
|
1759
|
+
busy: info?.busy ?? false,
|
|
1760
|
+
idleForMs,
|
|
1761
|
+
},
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
|
|
1765
|
+
return denied;
|
|
1766
|
+
const taken = new Set([
|
|
1767
|
+
...m.chatTargets(base).map(tagOfKey),
|
|
1768
|
+
...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
|
|
1769
|
+
].filter(Boolean));
|
|
1770
|
+
const slot = uniqueTag(candidate, taken);
|
|
1771
|
+
const target = keyOf(base, slot);
|
|
1772
|
+
// An identity rename may have claimed the address while stale eviction awaited IO.
|
|
1773
|
+
if (wizards.byName(candidate))
|
|
1774
|
+
return { ok: false, status: 409, body: { ok: false, reason: "wizard name was claimed during spawn preparation" } };
|
|
1775
|
+
return allowed(target, candidate) ? reserve(target, false) : denied;
|
|
1776
|
+
}
|
|
1777
|
+
catch (e) {
|
|
1778
|
+
if (e instanceof AutoNameExhausted)
|
|
1779
|
+
return { ok: false, status: 409, body: { ok: false, reason: e.message } };
|
|
1780
|
+
throw e;
|
|
1781
|
+
}
|
|
1782
|
+
finally {
|
|
1783
|
+
if (asked)
|
|
1784
|
+
namingClaims.delete(claimKey);
|
|
1598
1785
|
}
|
|
1599
|
-
if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
|
|
1600
|
-
return denied;
|
|
1601
|
-
const taken = new Set([
|
|
1602
|
-
...m.chatTargets(base).map(tagOfKey),
|
|
1603
|
-
...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
|
|
1604
|
-
].filter(Boolean));
|
|
1605
|
-
const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
|
|
1606
|
-
const target = keyOf(base, slot);
|
|
1607
|
-
return allowed(target, asked || slot) ? { ok: true, target, slot } : denied;
|
|
1608
1786
|
};
|
|
1609
1787
|
// ── 给人看的消息 ──────────────────────────────────────────────────
|
|
1610
1788
|
// send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
|
|
@@ -1657,8 +1835,43 @@ const main = async () => {
|
|
|
1657
1835
|
return;
|
|
1658
1836
|
}
|
|
1659
1837
|
const asker = askerOf(self);
|
|
1838
|
+
const activeId = openingOfSelf(self)?.env?.eventId;
|
|
1839
|
+
const active = activeId ? events.get(activeId) : undefined;
|
|
1840
|
+
const receipt = !!active?.payload.peer?.from.receipt;
|
|
1841
|
+
const leading = receipt ? /^(RESULT|Result|result|结论|NEED)[ \t]*[::]/.exec(content) : null;
|
|
1842
|
+
const closing = receipt ? parseClosing(content).kind : "none";
|
|
1843
|
+
if (receipt && active) {
|
|
1844
|
+
const row = active.serves ? router.get(active.serves) : undefined;
|
|
1845
|
+
if (active.canceled || active.interjected || handoffFinalSuppressed(active) || !ownsReplyEvent(active) || row?.outcome?.status === "canceled") {
|
|
1846
|
+
json(res, 409, { ok: false, reason: "这笔任务已取消或归属已变更,回执报告不再公开。" });
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (closing === "none" && (!row || !["open", "need", "error"].includes(row.status))) {
|
|
1850
|
+
json(res, 409, { ok: false, reason: "这笔任务已收口或无在途任务,回执确认不公开;新结果用 RESULT:,新问题用 NEED:。" });
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
const shown = receipt && (leading || closing !== "none") ? stripForChat(content) : content;
|
|
1855
|
+
if (!shown.trim()) {
|
|
1856
|
+
json(res, 400, { ok: false, reason: "markdown body required" });
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
const deliveries = [];
|
|
1660
1860
|
for (const dest of dests) {
|
|
1661
|
-
|
|
1861
|
+
if (active?.payload.peer?.from.receipt) {
|
|
1862
|
+
const purpose = closing === "none" ? "progress" : "report";
|
|
1863
|
+
const reportKind = closing === "need" ? "need" : "done";
|
|
1864
|
+
const coverage = purpose === "report" ? receiptCoverage(active, dest, closing === "need", content) : undefined;
|
|
1865
|
+
const revision = finalId(active.id, dest, content);
|
|
1866
|
+
const id = events.delivery({ id: finalId(active.id, dest, revision), eventId: active.id, target: self,
|
|
1867
|
+
turn: active.turn ?? active.id, channel: dest, revision, body: shown, purpose, reportKind,
|
|
1868
|
+
chunks: m.formatFinal(self, active.turn ?? active.id, dest, shown), ...(coverage ? { coverage } : {}) });
|
|
1869
|
+
const claims = events.deliveryClaims(coverage);
|
|
1870
|
+
deliveries.push({ channel: dest, id, states: claims.length ? claims.flatMap((c) => c.states) : events.deliveryStatus(id),
|
|
1871
|
+
...(claims.length > 1 ? { claims } : {}) });
|
|
1872
|
+
}
|
|
1873
|
+
else
|
|
1874
|
+
postAs(self, dest, content);
|
|
1662
1875
|
recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
|
|
1663
1876
|
}
|
|
1664
1877
|
// 微信群聊: 人超过 ctxTtlSec 没说话时微信会静默丢弃, 消息只能压着等他下次开口 —— 如实告诉调用方, 别回「已发送」。
|
|
@@ -1667,7 +1880,8 @@ const main = async () => {
|
|
|
1667
1880
|
const name = (d) => chatNameOf(cfg, d) || d;
|
|
1668
1881
|
json(res, 200, {
|
|
1669
1882
|
ok: true,
|
|
1670
|
-
sent: dests.filter((d) => !held.includes(d)).map(name),
|
|
1883
|
+
sent: dests.filter((d) => !held.includes(d) && !deliveries.some((x) => x.channel === d)).map(name),
|
|
1884
|
+
...(deliveries.length ? { deliveries, note: "已持久化;相同事实复用既有投递,实际状态见 states,不确定 ACK 不自动重发。" } : {}),
|
|
1671
1885
|
...(held.length ? { held: held.map(name), note: `微信群聊 ${held.map(name).join("、")} 那头超过 ${cfg.weixin.ctxTtlSec}s 没说话: 微信会静默丢弃主动推送, 这条已压着, 对方下次在微信里开口时送达 (定时提醒做不到准点)` } : {}),
|
|
1672
1886
|
});
|
|
1673
1887
|
});
|
|
@@ -1934,6 +2148,7 @@ const main = async () => {
|
|
|
1934
2148
|
const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.event ?? "") : undefined;
|
|
1935
2149
|
// 单已收但这一笔的行还 open = 收工前就派出去的活, 照投 (见 close_job)。
|
|
1936
2150
|
return planStore.healthy() && !e.canceled && (!p.from.receipt || planContinuationAllowed(e.target))
|
|
2151
|
+
&& (!p.transferred || !e.serves || events.latestTransfer(e.serves)?.id === e.id)
|
|
1937
2152
|
&& (!p.job || jobs.get(p.job)?.status === "open" || (!!e.serves && router.get(e.serves)?.status === "open")) && !attempt?.canceled
|
|
1938
2153
|
&& (!p.after?.length || prereqsOf(e.speaker, p.after).every((r) => r.state === "done" || r.state === "pending"));
|
|
1939
2154
|
},
|
|
@@ -1941,6 +2156,15 @@ const main = async () => {
|
|
|
1941
2156
|
if (e.execution !== "queued")
|
|
1942
2157
|
return;
|
|
1943
2158
|
const p = e.payload.peer;
|
|
2159
|
+
if (p.transferred && e.serves) {
|
|
2160
|
+
const row = router.get(e.serves);
|
|
2161
|
+
if (!row || row.owner !== e.speaker && row.owner !== e.target || events.latestTransfer(e.serves)?.id !== e.id)
|
|
2162
|
+
throw new Error("transfer intent no longer owns this route");
|
|
2163
|
+
// The input is durable before move. A crash here replays this projection
|
|
2164
|
+
// in the injector, without dropping the original human route.
|
|
2165
|
+
if (row.owner !== e.target)
|
|
2166
|
+
router.commit(ops.move({ event: row.event, owner: e.target }));
|
|
2167
|
+
}
|
|
1944
2168
|
// 旧 watcher 的 continuation 代数已退役 (§9.4): 收口由事件自己的 serves 决定。
|
|
1945
2169
|
if (p.attemptId)
|
|
1946
2170
|
accountWorkflow(workflow.byTurn(e.speaker, p.from.event ?? ""));
|
|
@@ -1999,7 +2223,7 @@ const main = async () => {
|
|
|
1999
2223
|
if (p.attemptId && p.tell)
|
|
2000
2224
|
workflow.intent(p.attemptId, p.tell);
|
|
2001
2225
|
const raw = await m.injectText(e.target, e.payload.text, undefined, {
|
|
2002
|
-
from: p.from, channel: e.channel, requireIdle: e.priority === "normal",
|
|
2226
|
+
from: p.from, channel: e.channel, requireIdle: e.priority === "normal", noRespawn: e.id.startsWith("route:") && e.id.includes(":resume:"),
|
|
2003
2227
|
envelope: withMsg(p.envelope + (p.after?.length ? afterNote(p.after, Object.fromEntries(p.after.flatMap((t) => { const x = seenOf(t); return x ? [[t, x]] : []; })), e.speaker) : ""), e.id),
|
|
2004
2228
|
guard: (phase, identity) => peerEvents.valid(e) && prereqsOf(e.speaker, p.after ?? []).every((r) => r.state === "done") && (!phase || ((!p.attemptId || workflow.boundary(p.attemptId)) && boundary(phase, identity))),
|
|
2005
2229
|
eventId: e.id,
|
|
@@ -2015,12 +2239,23 @@ const main = async () => {
|
|
|
2015
2239
|
// 没有行 (老事件 / 不带 serves 的注入) → 照旧放行。
|
|
2016
2240
|
// 已落定的那一笔只放追述 (§4.3): 判据与 onFinal 的 retellOps 同源 (router.finalReleased)。
|
|
2017
2241
|
// 调用方可能拿着 result 落定前的快照, 正文按事件库现读。
|
|
2242
|
+
m.setEventOwnerGate(ownsReplyEvent);
|
|
2243
|
+
m.setEventProgressGate((e) => {
|
|
2244
|
+
if (handoffFinalSuppressed(e) || e.quiet || e.canceled || e.interjected || !ownsReplyEvent(e))
|
|
2245
|
+
return false;
|
|
2246
|
+
const row = e.serves ? router.get(e.serves) : undefined;
|
|
2247
|
+
if (!row || row.owner !== e.target || row.status !== "open")
|
|
2248
|
+
return false;
|
|
2249
|
+
const kind = finalKind(router.table(), e.serves, e.result?.body ?? "");
|
|
2250
|
+
return !e.result || kind === "progress" || kind === "wait";
|
|
2251
|
+
});
|
|
2018
2252
|
m.setEventFinalGate((e) => {
|
|
2019
|
-
if (e
|
|
2253
|
+
if (handoffFinalSuppressed(e))
|
|
2254
|
+
return false;
|
|
2255
|
+
if (e.quiet || e.canceled || e.interjected || !ownsReplyEvent(e))
|
|
2020
2256
|
return false;
|
|
2021
|
-
return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "");
|
|
2257
|
+
return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "", reportSource(events.get(e.id) ?? e));
|
|
2022
2258
|
});
|
|
2023
|
-
m.setEventHandedAway((e) => { const row = e.serves ? router.get(e.serves) : undefined; return !!row && row.owner !== e.target; });
|
|
2024
2259
|
// boot 的路由表侧恢复 (T8): held 重查前置、answered 重投一次; 二者幂等, 与 events.resume
|
|
2025
2260
|
// 重放的注入/回执撞上无害。
|
|
2026
2261
|
void m.restored.then(() => { events.resume(); settleHeld(); replayAnswered(); });
|
|
@@ -2036,7 +2271,8 @@ const main = async () => {
|
|
|
2036
2271
|
// 自己就是计数器, 不另落一份。一次 tick 扫一遍收件箱就够 (这一轮不会再产新事件)。
|
|
2037
2272
|
const fruitless = new Map();
|
|
2038
2273
|
events.status().events.forEach((e) => {
|
|
2039
|
-
if (!e.serves || e.result || e.canceled || e.
|
|
2274
|
+
if (!e.serves || e.result || e.canceled || e.payload.peer?.from.receipt
|
|
2275
|
+
|| !(e.execution === "complete" || e.execution === "uncertain" && e.exposure === "consumed" && e.turnClosedAt))
|
|
2040
2276
|
return;
|
|
2041
2277
|
fruitless.set(e.serves, (fruitless.get(e.serves) ?? 0) + 1);
|
|
2042
2278
|
});
|
|
@@ -2282,11 +2518,11 @@ const main = async () => {
|
|
|
2282
2518
|
...(afters.length ? { after: afters } : {}), ...(plan.serves ? { serves: plan.serves } : {}) });
|
|
2283
2519
|
/** 建完行才发现不该派 (计划许可 / 预算在等待期间变了): 把刚开的那一行收掉 —— 不然它一直
|
|
2284
2520
|
* 挂在 owedBy 上, 名册里也永远「欠着」一件没人干的活。 */
|
|
2285
|
-
const dropRow = () => { if (newRow && serves)
|
|
2521
|
+
const dropRow = () => { if (newRow && serves && plan.op !== "transfer")
|
|
2286
2522
|
router.commit(ops.gc({ event: serves })); };
|
|
2287
2523
|
// 注入事件的 id 就是对那一笔的引用 (§9.3): 派活 = `route:<event>`, 续问 = `route:<event>:r<legs>`;
|
|
2288
2524
|
// 不开行 (none) 时没有行可指, 退回一句一号的合成 id。
|
|
2289
|
-
const eventId = serves ? `route:${serves}${legs > 1 ? `:r${legs}` : ""}` : `p:${self}:${target}:${at}:${legs}`;
|
|
2525
|
+
const eventId = serves ? plan.op === "transfer" ? `route:${serves}:transfer:${at}:${target}` : `route:${serves}${legs > 1 ? `:r${legs}` : ""}` : `p:${self}:${target}:${at}:${legs}`;
|
|
2290
2526
|
let attemptId;
|
|
2291
2527
|
const beginNode = (queued) => {
|
|
2292
2528
|
if (!planAllowed() || (planned && !planned.alive()))
|
|
@@ -2306,7 +2542,8 @@ const main = async () => {
|
|
|
2306
2542
|
// urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
|
|
2307
2543
|
// 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
|
|
2308
2544
|
const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
|
|
2309
|
-
|
|
2545
|
+
// Transfers need a separate input turn, including when the receiver becomes busy.
|
|
2546
|
+
const policy = others || plan.op === "transfer" ? "normal" : asked;
|
|
2310
2547
|
if (canceledBeforeDispatch() || !actorStillValid()) {
|
|
2311
2548
|
dropRow();
|
|
2312
2549
|
return { status: 409, body: { ok: false, reason: "caller authority or dispatch permit changed before acceptance" } };
|
|
@@ -2325,7 +2562,7 @@ const main = async () => {
|
|
|
2325
2562
|
}
|
|
2326
2563
|
let accepted;
|
|
2327
2564
|
try {
|
|
2328
|
-
accepted =
|
|
2565
|
+
accepted = acceptPeerDispatch({ id: eventId, target, speaker: self, channel, serves, asker, priority: policy, deadline: deadlineAt,
|
|
2329
2566
|
payload: { text, peer: {
|
|
2330
2567
|
from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
|
|
2331
2568
|
envelope: envelopeFor(self, channel, { asker, legs, ...(legs > 1 ? { re: true } : {}),
|
|
@@ -2333,14 +2570,12 @@ const main = async () => {
|
|
|
2333
2570
|
...(wantReceipt ? { tell } : {}), job: jobId, kind, attemptId, after: afters,
|
|
2334
2571
|
role: roleParam(body.role), free: freed,
|
|
2335
2572
|
...(plan.op === "transfer" ? { transferred: true } : {}),
|
|
2336
|
-
} } });
|
|
2573
|
+
} } }, plan.op === "transfer");
|
|
2337
2574
|
}
|
|
2338
2575
|
catch (error) {
|
|
2576
|
+
dropRow();
|
|
2339
2577
|
return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
|
|
2340
2578
|
}
|
|
2341
|
-
// 交出的是人问的那一笔: 那句话的气泡跟着行走, 接手方开轮时接过去续写 (不另开新气泡)。
|
|
2342
|
-
if (handedChat && serves)
|
|
2343
|
-
m.handBubble(self, serves, eventId, target);
|
|
2344
2579
|
// `re` 把那一笔从 need / error 拉回 open (§3.2 的 need→open / error→open): 行状态是「这份中途态
|
|
2345
2580
|
// 已经报过了」的去重记号 —— 不拉回去, owner 下一轮交同一个中途态就被静默吞掉 (往返断在这里)。
|
|
2346
2581
|
if (reTarget && (reTarget.status === "need" || reTarget.status === "error"))
|
|
@@ -2490,22 +2725,29 @@ const main = async () => {
|
|
|
2490
2725
|
const results = uniq.map((h, i) => {
|
|
2491
2726
|
const wr = wrs[i];
|
|
2492
2727
|
// 同一对并发多笔是允许的 (§4.1): 先取**还在排队**的那份 (那份还没进过你的会话); 都取过了就
|
|
2493
|
-
//
|
|
2728
|
+
// 报最新那件的投递状态 —— 绝不把已经 paste 过的那份当成新答案交回来。
|
|
2494
2729
|
const mine = router.settledTo(self).filter((r) => r.owner === h.target).reverse() // byAt → 新在前
|
|
2495
|
-
.map((row) => {
|
|
2496
|
-
|
|
2730
|
+
.map((row) => {
|
|
2731
|
+
const box = receiptOf(row);
|
|
2732
|
+
return { row, box,
|
|
2733
|
+
execution: events.executionOf(box.id), exposure: events.exposureOf(box.id) };
|
|
2734
|
+
});
|
|
2735
|
+
const pick = mine.find((x) => x.execution === "queued" && x.exposure === "none") ?? mine[0];
|
|
2497
2736
|
if (!pick)
|
|
2498
2737
|
return { name: h.address, idle: wr.idle, ...(wr.reason ? { reason: wr.reason } : {}),
|
|
2499
2738
|
...(wr.idle ? { stale: true, reason: "它停下了, 但路由表上没有交回给你的定论 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" } : {}) };
|
|
2500
|
-
//
|
|
2501
|
-
|
|
2739
|
+
// Only an unexposed queued input can be claimed. Execution state alone
|
|
2740
|
+
// proves neither consumption nor non-delivery, including old tombstones.
|
|
2741
|
+
if (pick.execution !== "queued" || pick.exposure !== "none")
|
|
2502
2742
|
return { name: h.address, idle: wr.idle,
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
:
|
|
2743
|
+
eventId: pick.box.id, delivered: pick.exposure === "consumed", exposure: pick.exposure ?? "unknown",
|
|
2744
|
+
delivery: pick.exposure === "consumed" ? "consumed"
|
|
2745
|
+
: pick.execution === "uncertain" || !pick.exposure || pick.exposure === "possible" ? "uncertain" : pick.execution,
|
|
2746
|
+
reason: pick.exposure === "consumed" ? "回执已由会话消费,不重复领取。"
|
|
2747
|
+
: "结果已保存,尚未确认回执消费;按 eventId 对账,不自动重投或再次领取。" };
|
|
2506
2748
|
// 取走就撤单: 这一笔在收件箱里排着的**全部**回执一起作废 (need 之后又答完时排着两份, 取走
|
|
2507
2749
|
// 的那一份就是新结论, 旧的那份不再 paste 第二遍); `hand` 确认已交付 (已 handed 的是 no-op)。
|
|
2508
|
-
if (pick.
|
|
2750
|
+
if (pick.execution === "queued") {
|
|
2509
2751
|
events.cancel((x) => x.id.startsWith(`rcpt:${pick.row.event}:`) && x.execution === "queued", "taken by wait_peer");
|
|
2510
2752
|
router.commit(ops.hand({ event: pick.row.event, inbox: pick.box.id }));
|
|
2511
2753
|
}
|
|
@@ -2530,30 +2772,19 @@ const main = async () => {
|
|
|
2530
2772
|
? { ok: true, ...results[0] }
|
|
2531
2773
|
: { ok: true, need, done, satisfied: done >= need, results });
|
|
2532
2774
|
});
|
|
2533
|
-
//
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
const restartFresh = (target, p) => {
|
|
2541
|
-
const info = m.sessionInfo(target);
|
|
2542
|
-
const cli = cfg.wrc.defaultCli ?? primary.name;
|
|
2543
|
-
const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
|
|
2544
|
-
if (!planContinuationAllowed(target))
|
|
2545
|
-
return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn" });
|
|
2546
|
-
return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: !p?.announce, guard: () => planContinuationAllowed(target) });
|
|
2775
|
+
// handoff 只清空原 pane 的原生上下文; 不更新 CLI 进程、工作目录或出生时的宪章。
|
|
2776
|
+
const clearForHandoff = (target, p, submitted) => {
|
|
2777
|
+
if (!handoffAllowed(target))
|
|
2778
|
+
return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn or executor reserved" });
|
|
2779
|
+
if (!p.clear && m.sessionInfo(target)?.sessionId !== p.sid)
|
|
2780
|
+
return Promise.resolve({ ok: false, reason: "native session changed before the clear attempt; refusing another clear" });
|
|
2781
|
+
return m.clearContext(target, p.clear, submitted, () => handoffAllowed(target));
|
|
2547
2782
|
};
|
|
2548
|
-
// POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
|
|
2549
|
-
// 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
|
|
2550
|
-
// 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
|
|
2551
|
-
// 自身操作(会 deadlock,同 /peers/send)。
|
|
2552
2783
|
/** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
|
|
2553
2784
|
* 自己与老路由 /wizard/handoff-self 共用。 */
|
|
2554
2785
|
const handoffSelf = (self, brief) => {
|
|
2555
|
-
if (!
|
|
2556
|
-
return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn" } };
|
|
2786
|
+
if (!handoffAllowed(self))
|
|
2787
|
+
return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn or executor reserved" } };
|
|
2557
2788
|
if (brief.length < 40)
|
|
2558
2789
|
return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
|
|
2559
2790
|
const info = m.sessionInfo(self);
|
|
@@ -3431,7 +3662,7 @@ const main = async () => {
|
|
|
3431
3662
|
}
|
|
3432
3663
|
const name = (b.name ?? "").toString().trim();
|
|
3433
3664
|
const description = (b.description ?? "").toString().trim() || clipLine(task, 120);
|
|
3434
|
-
// 空 name 交给 bearAs / claimSlot
|
|
3665
|
+
// 空 name 交给 bearAs / claimSlot 按持久序号自动取名避重, 不当作显式点名复用旧记录。
|
|
3435
3666
|
const tier = taskTier;
|
|
3436
3667
|
const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
|
|
3437
3668
|
if (born.status !== 200) {
|
|
@@ -3555,7 +3786,7 @@ const main = async () => {
|
|
|
3555
3786
|
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
3556
3787
|
}
|
|
3557
3788
|
}
|
|
3558
|
-
const planBirthAllowed = (target, name = normalizeTag(b.name ?? b.tag)
|
|
3789
|
+
const planBirthAllowed = (target, name = normalizeTag(b.name ?? b.tag)) => planStore.allowSpawn(self, jobId, planned, name, target) && (!planned || planned.alive());
|
|
3559
3790
|
if (!planBirthAllowed() || (planned && !!b.task))
|
|
3560
3791
|
return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
|
|
3561
3792
|
// 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
|
|
@@ -3639,20 +3870,20 @@ const main = async () => {
|
|
|
3639
3870
|
} };
|
|
3640
3871
|
}
|
|
3641
3872
|
const askedName = String(b.name ?? b.tag ?? "");
|
|
3642
|
-
|
|
3643
|
-
const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
|
|
3644
|
-
const slotR = await claimSlot(base, askedName, fallback, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
|
|
3873
|
+
const slotR = await claimSlot(base, askedName, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
|
|
3645
3874
|
if (!slotR.ok) {
|
|
3646
3875
|
return { status: slotR.status, body: slotR.body };
|
|
3647
3876
|
}
|
|
3648
|
-
const { target,
|
|
3877
|
+
const { target, name } = slotR;
|
|
3878
|
+
const releaseBirth = () => releaseSlot(slotR);
|
|
3649
3879
|
// claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
|
|
3650
3880
|
// 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
|
|
3651
|
-
const existed = !!wizards.get(target);
|
|
3652
3881
|
const forkOf = inherit && source !== self ? source : undefined;
|
|
3653
3882
|
// 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
|
|
3654
|
-
if (!planBirthAllowed(target,
|
|
3883
|
+
if (!ownsSlot(slotR) || !planBirthAllowed(target, name) || (jobId && jobs.get(jobId)?.status !== "open")) {
|
|
3884
|
+
releaseBirth();
|
|
3655
3885
|
return { status: 409, body: { ok: false, reason: "plan canceled/job reserved during spawn preparation" } };
|
|
3886
|
+
}
|
|
3656
3887
|
wizards.upsert(target, {
|
|
3657
3888
|
description: (b.description ?? "").toString().trim(),
|
|
3658
3889
|
// 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
|
|
@@ -3669,15 +3900,16 @@ const main = async () => {
|
|
|
3669
3900
|
// 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
|
|
3670
3901
|
spawning: BOOT_ID,
|
|
3671
3902
|
});
|
|
3672
|
-
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
3673
3903
|
const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
|
|
3674
3904
|
const task = (b.task ?? "").toString().trim();
|
|
3675
3905
|
// 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
|
|
3676
3906
|
// wait_peer 才不会把它们当成分身对这件活的答复。
|
|
3677
3907
|
const taskAt = Date.now();
|
|
3678
|
-
const birthGuard = () => (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
|
|
3679
|
-
if (!birthGuard())
|
|
3908
|
+
const birthGuard = () => ownsSlot(slotR) && (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
|
|
3909
|
+
if (!birthGuard()) {
|
|
3910
|
+
releaseBirth();
|
|
3680
3911
|
return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
|
|
3912
|
+
}
|
|
3681
3913
|
const r = await m.cloneSession({
|
|
3682
3914
|
guard: birthGuard,
|
|
3683
3915
|
parent: source,
|
|
@@ -3693,13 +3925,13 @@ const main = async () => {
|
|
|
3693
3925
|
}).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
|
|
3694
3926
|
// 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
|
|
3695
3927
|
if (!r.ok) {
|
|
3696
|
-
|
|
3697
|
-
wizards.upsert(target, { spawning: undefined });
|
|
3698
|
-
else
|
|
3699
|
-
wizards.drop(target);
|
|
3928
|
+
releaseBirth();
|
|
3700
3929
|
return { status: 500, body: { ok: false, reason: r.reason } };
|
|
3701
3930
|
}
|
|
3702
|
-
|
|
3931
|
+
if (!ownsSlot(slotR))
|
|
3932
|
+
return { status: 409, body: { ok: false, reason: "birth identity replaced during spawn" } };
|
|
3933
|
+
finishSlot(slotR);
|
|
3934
|
+
wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
3703
3935
|
const kid = briefOf(self, target);
|
|
3704
3936
|
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
3705
3937
|
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
@@ -3726,18 +3958,21 @@ const main = async () => {
|
|
|
3726
3958
|
? openDispatchRow({ self, target, op: plan.op, deadlineAt, job: jobId, ...(asker ? { asker } : {}), ...(plan.serves ? { serves: plan.serves } : {}) })
|
|
3727
3959
|
: "";
|
|
3728
3960
|
dispatchedEvent = serves;
|
|
3729
|
-
const eventId = serves ? `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
|
|
3961
|
+
const eventId = serves ? plan?.ok && plan.op === "transfer" ? `route:${serves}:transfer:${taskAt}:${target}` : `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
|
|
3730
3962
|
const birthTell = { from: self, to: target, channel: handedChat, job: jobId, at: taskAt, event: serves, legs: 1, deadlineAt, asker, dispatchKey: eventId };
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3963
|
+
try {
|
|
3964
|
+
acceptPeerDispatch({ id: eventId, target, speaker: self, channel: handedChat, serves, asker, priority: "normal", deadline: deadlineAt,
|
|
3965
|
+
payload: { text: task, peer: {
|
|
3966
|
+
from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
|
|
3967
|
+
envelope: envelopeFor(self, handedChat, { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
|
|
3968
|
+
...(quiet ? {} : { tell: birthTell }),
|
|
3969
|
+
job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
|
|
3970
|
+
...(plan?.ok && plan.op === "transfer" ? { transferred: true } : {}),
|
|
3971
|
+
} } }, !!plan?.ok && plan.op === "transfer");
|
|
3972
|
+
}
|
|
3973
|
+
catch (error) {
|
|
3974
|
+
return { status: 409, body: { ok: false, target, name, dispatched: false, reason: errText(error) } };
|
|
3975
|
+
}
|
|
3741
3976
|
}
|
|
3742
3977
|
else if (jobId && birthGuard()) {
|
|
3743
3978
|
jobs.attach(jobId, { target, task: "", spawned: true, ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}) });
|
|
@@ -3912,6 +4147,10 @@ const main = async () => {
|
|
|
3912
4147
|
json(res, 502, { ok: false, target, reason: done.reason });
|
|
3913
4148
|
return;
|
|
3914
4149
|
}
|
|
4150
|
+
if (wizards.get(target)?.birthToken !== victimRec?.birthToken) {
|
|
4151
|
+
json(res, 409, { ok: false, target, reason: "wizard identity changed during stop; identity cleanup withheld" });
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
3915
4154
|
const detached = end && b.forget ? forgetWizard(target) : [];
|
|
3916
4155
|
// 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
|
|
3917
4156
|
if (end)
|
|
@@ -4340,7 +4579,7 @@ const main = async () => {
|
|
|
4340
4579
|
];
|
|
4341
4580
|
// ── 改配置会不会动到宪章 ─────────────────────────────────────────
|
|
4342
4581
|
// 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
|
|
4343
|
-
// 看不见,
|
|
4582
|
+
// 看不见, 需要显式 /new 才重新渲染宪章; handoff /clear 保留启动时的系统提示。不按字段清单判 —— 宪章读哪些配置随代码变,
|
|
4344
4583
|
// 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
|
|
4345
4584
|
const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
|
|
4346
4585
|
// 「出生时同群的 wizard」是出生快照 (宪章自己声明名册随时可查), 且渲染途中的懒定居
|
|
@@ -4377,49 +4616,22 @@ const main = async () => {
|
|
|
4377
4616
|
}
|
|
4378
4617
|
};
|
|
4379
4618
|
const charterImpact = (before, after) => [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
|
|
4380
|
-
/**
|
|
4381
|
-
const CHARTER_HANDOFF_MS = 600_000;
|
|
4382
|
-
/** 受影响的 wizard 的处理分两档:
|
|
4383
|
-
* - hot: 自动交接 —— 守护进程请它写简报、原地重开 (宪章在重开时重渲染)、简报贴回;
|
|
4384
|
-
* 交接不成 (一直忙 / 已在交接中 / 重开失败) 才退回老规矩, 挂一条提醒让它自己 handoff。
|
|
4385
|
-
* - reload: 此刻重开渲染的还是旧宪章, 自动交接无意义 —— 维持挂提醒, reload 之后它自己交。
|
|
4386
|
-
* 给调用方的那段话照旧回; 调用方自己受影响就直说。人发起的改动多一句「交接之前还是旧规矩」:
|
|
4387
|
-
* 自动交接也要等它这一轮说完才动手, 这句话仍成立。 */
|
|
4619
|
+
/** /clear 不重新渲染进程启动时的宪章; 只通知, 不暗中重启会话。 */
|
|
4388
4620
|
const announceCharterImpact = (c, hits) => {
|
|
4389
4621
|
if (!hits.length)
|
|
4390
4622
|
return "";
|
|
4391
|
-
const ask = `${c.reload ? "
|
|
4392
|
-
|
|
4393
|
-
? " —— 这是人发起的改动: 交接之前你仍按旧规矩做事, handoff 之后才按新的"
|
|
4394
|
-
: " —— 这是人发起的改动: 交接之前你仍按旧规矩向人汇报, handoff 之后才会按新规矩汇报; 回人时说一句「交接后生效」";
|
|
4395
|
-
const mine = hits.find((h) => h.target === c.self);
|
|
4396
|
-
// 提醒照旧全机发 (各收各的); 回给调用方的名单与人数只含它看得见的 (sameBot) —— 别的机器人的 wizard
|
|
4397
|
-
// 连名字带个数都不报。没有调用方 (人 /name、自动补名) = 主机器人视角, 单机器人时就是全部。
|
|
4623
|
+
const ask = `${c.reload ? "守护进程 reload 之后, " : ""}需要显式 /new 才能载入新宪章;handoff /clear 保留原进程的系统提示,不会使这次改动生效`;
|
|
4624
|
+
hits.filter((h) => h.target !== c.self).forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); ${ask}`));
|
|
4398
4625
|
const shown = hits.filter((h) => sameBot(c.self ?? "", h.target));
|
|
4399
|
-
const
|
|
4400
|
-
if (!c.reload) {
|
|
4401
|
-
hits.forEach((h) => void handoffs.other(h.target, `${c.what} 改动了你的宪章 (变了的段: ${h.sections.join(" / ")}); 简报里点一句手头活在新规矩下怎么接着干`, CHARTER_HANDOFF_MS).then((r) => {
|
|
4402
|
-
if (!r.ok)
|
|
4403
|
-
notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 自动交接没做成 (${r.reason}) —— 已在跑的你看不见新规矩, 手上这件告一段落后自己${ask}${lag(h.target)}`);
|
|
4404
|
-
}));
|
|
4405
|
-
return [
|
|
4406
|
-
"",
|
|
4407
|
-
`⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
|
|
4408
|
-
...(shown.length ? ["已自动安排它们交接 (各自写简报 → 原地重开 → 接回简报); 交接不成的会收到提醒, 再自己 handoff"] : []),
|
|
4409
|
-
...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮说完就会自动交接${lag(mine.target)}`] : []),
|
|
4410
|
-
].join("\n");
|
|
4411
|
-
}
|
|
4412
|
-
const others = hits.filter((h) => h.target !== c.self);
|
|
4413
|
-
others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
|
|
4626
|
+
const mine = hits.find((h) => h.target === c.self);
|
|
4414
4627
|
return [
|
|
4415
4628
|
"",
|
|
4416
|
-
`⚠ 这次改动进了宪章 (系统提示)
|
|
4417
|
-
...(
|
|
4418
|
-
...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
|
|
4629
|
+
`⚠ 这次改动进了宪章 (系统提示)。${ask}。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
|
|
4630
|
+
...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")})`] : []),
|
|
4419
4631
|
].join("\n");
|
|
4420
4632
|
};
|
|
4421
4633
|
// 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
|
|
4422
|
-
// 挂在 pane 上的 wizard 的宪章, 变了的、pane
|
|
4634
|
+
// 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒 (见 announceCharterImpact)。
|
|
4423
4635
|
// 快照到写完之间不能有 await —— 别人的写入插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
|
|
4424
4636
|
guardCharter = (write, change) => {
|
|
4425
4637
|
const targets = attachedWizards();
|
|
@@ -4430,7 +4642,7 @@ const main = async () => {
|
|
|
4430
4642
|
return { r, note: Promise.resolve("") };
|
|
4431
4643
|
const fail = (e) => {
|
|
4432
4644
|
log.warn({ err: errText(e), what: c.what }, "宪章影响比对失败");
|
|
4433
|
-
return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard
|
|
4645
|
+
return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 需要显式 /new 才载入;handoff /clear 不刷新系统提示)`;
|
|
4434
4646
|
};
|
|
4435
4647
|
try {
|
|
4436
4648
|
const hits = charterImpact(before, (c.after ?? charterSnapshot)(targets));
|
|
@@ -4738,7 +4950,7 @@ const main = async () => {
|
|
|
4738
4950
|
const eventId = `quiet:${t}:${turn}`;
|
|
4739
4951
|
const revision = finalId(eventId, base, text);
|
|
4740
4952
|
events.delivery({ id: finalId(eventId, base, revision), eventId, target: t, turn, channel: base,
|
|
4741
|
-
revision, body: text, chunks: m.formatFinal(t, turn, base, text) });
|
|
4953
|
+
revision, body: text, purpose: "task", chunks: m.formatFinal(t, turn, base, text) });
|
|
4742
4954
|
}
|
|
4743
4955
|
log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
|
|
4744
4956
|
quietRelays.drop(turn);
|
|
@@ -4762,15 +4974,20 @@ const main = async () => {
|
|
|
4762
4974
|
const rec = tasks.get(taskId);
|
|
4763
4975
|
const owner = rec ? ownerOf(rec) : target;
|
|
4764
4976
|
const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
|
|
4765
|
-
const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
4766
|
-
if (!guard())
|
|
4977
|
+
const slotR = await claimSlot(base, "", [], undefined, `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
4978
|
+
if (!guard()) {
|
|
4979
|
+
if (slotR.ok)
|
|
4980
|
+
releaseSlot(slotR);
|
|
4767
4981
|
return { ok: false, reason: "scheduled runner withdrawn during slot selection" };
|
|
4982
|
+
}
|
|
4768
4983
|
if (!slotR.ok)
|
|
4769
4984
|
return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
|
|
4770
4985
|
const runner = slotR.target;
|
|
4771
|
-
const allowed = () => guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
|
|
4772
|
-
if (!allowed())
|
|
4986
|
+
const allowed = () => ownsSlot(slotR) && guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
|
|
4987
|
+
if (!allowed()) {
|
|
4988
|
+
releaseSlot(slotR);
|
|
4773
4989
|
return { ok: false, reason: "workflow plan reserves task runner" };
|
|
4990
|
+
}
|
|
4774
4991
|
const info = m.sessionInfo(target);
|
|
4775
4992
|
const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
|
|
4776
4993
|
wizards.upsert(runner, {
|
|
@@ -4779,16 +4996,20 @@ const main = async () => {
|
|
|
4779
4996
|
voiceOf: owner,
|
|
4780
4997
|
bornAt: Date.now(),
|
|
4781
4998
|
});
|
|
4782
|
-
const runnerName =
|
|
4783
|
-
const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed })
|
|
4999
|
+
const runnerName = slotR.name;
|
|
5000
|
+
const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed })
|
|
5001
|
+
.catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}` }));
|
|
4784
5002
|
if (!spawned.ok) {
|
|
4785
|
-
|
|
5003
|
+
releaseSlot(slotR);
|
|
4786
5004
|
return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
|
|
4787
5005
|
}
|
|
5006
|
+
if (!ownsSlot(slotR))
|
|
5007
|
+
return { ok: false, reason: "task runner identity replaced during spawn" };
|
|
5008
|
+
finishSlot(slotR);
|
|
4788
5009
|
if (!allowed()) {
|
|
4789
|
-
if (m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
|
|
5010
|
+
if (ownsSlot(slotR) && m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
|
|
4790
5011
|
await m.killPane(runner);
|
|
4791
|
-
|
|
5012
|
+
releaseSlot(slotR);
|
|
4792
5013
|
}
|
|
4793
5014
|
return { ok: false, reason: "scheduled runner withdrawn after spawn" };
|
|
4794
5015
|
}
|
|
@@ -4806,7 +5027,7 @@ const main = async () => {
|
|
|
4806
5027
|
? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { event: turn }) }
|
|
4807
5028
|
: { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
4808
5029
|
if (!inj.ok) {
|
|
4809
|
-
|
|
5030
|
+
releaseSlot(slotR);
|
|
4810
5031
|
return inj;
|
|
4811
5032
|
}
|
|
4812
5033
|
// 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
|
|
@@ -4821,8 +5042,10 @@ const main = async () => {
|
|
|
4821
5042
|
log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
|
|
4822
5043
|
return;
|
|
4823
5044
|
}
|
|
5045
|
+
if (!ownsSlot(slotR) || m.sessionInfo(runner)?.sessionId !== spawned.sessionId)
|
|
5046
|
+
return;
|
|
4824
5047
|
await m.killPane(runner);
|
|
4825
|
-
|
|
5048
|
+
releaseSlot(slotR);
|
|
4826
5049
|
})();
|
|
4827
5050
|
return inj;
|
|
4828
5051
|
};
|
|
@@ -4894,6 +5117,7 @@ const main = async () => {
|
|
|
4894
5117
|
registry: tasks,
|
|
4895
5118
|
log: log.child({ mod: "tasks" }),
|
|
4896
5119
|
inject: scheduledTaskInject,
|
|
5120
|
+
ready: bridge.restored,
|
|
4897
5121
|
// gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
|
|
4898
5122
|
cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
|
|
4899
5123
|
// 没写 target 的日程兜底到排班者那个机器人的默认聊天 (副机器人没配就不放枪, 不冒用主机器人)。
|