wezard 2.6.2 → 2.6.4
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-plugin/plugin.json +1 -1
- package/config.example.jsonc +3 -1
- package/dist/daemon/answer-transfer.js +56 -0
- package/dist/daemon/answer-transfer.js.map +1 -0
- package/dist/daemon/approval.js +1 -1
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/bot-mux.js +4 -0
- package/dist/daemon/bot-mux.js.map +1 -1
- package/dist/daemon/cfg-sync.js +1 -1
- package/dist/daemon/cfg-sync.js.map +1 -1
- package/dist/daemon/chat-name.js +4 -4
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +2 -2
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/image-input.js +54 -0
- package/dist/daemon/image-input.js.map +1 -0
- package/dist/daemon/inbound.js +7 -13
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +1134 -331
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +39 -15
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +37 -7
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/message-events.js +361 -0
- package/dist/daemon/message-events.js.map +1 -0
- package/dist/daemon/message-format.js +40 -0
- package/dist/daemon/message-format.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +847 -343
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/pane-input.js +48 -0
- package/dist/daemon/pane-input.js.map +1 -0
- package/dist/daemon/peers.js +17 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +8 -0
- package/dist/daemon/pending-items.js.map +1 -1
- package/dist/daemon/receipts.js +427 -139
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/scheduled-delivery.js +52 -0
- package/dist/daemon/scheduled-delivery.js.map +1 -0
- package/dist/daemon/steward-model.js +10 -10
- package/dist/daemon/steward-model.js.map +1 -1
- package/dist/daemon/tasks.js +83 -27
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/tmux-buffer.js +16 -6
- package/dist/daemon/tmux-buffer.js.map +1 -1
- package/dist/daemon/weixin-cmd.js +2 -4
- package/dist/daemon/weixin-cmd.js.map +1 -1
- package/dist/daemon/weixin.js +111 -7
- package/dist/daemon/weixin.js.map +1 -1
- package/dist/daemon/wizard.js +2 -1
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/daemon/workflow-nodes.js +217 -0
- package/dist/daemon/workflow-nodes.js.map +1 -0
- package/dist/daemon/workflow-plan-http.js +83 -0
- package/dist/daemon/workflow-plan-http.js.map +1 -0
- package/dist/daemon/workflow-plans.js +274 -0
- package/dist/daemon/workflow-plans.js.map +1 -0
- package/dist/mcp/server.js +27 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/codex.js +1 -1
- package/dist/shared/codex.js.map +1 -1
- package/dist/shared/config.js +12 -10
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/json-map-store.js +46 -9
- package/dist/shared/json-map-store.js.map +1 -1
- package/dist/shared/keyed-scheduler.js +51 -0
- package/dist/shared/keyed-scheduler.js.map +1 -0
- package/dist/shared/message-footer.js +34 -0
- package/dist/shared/message-footer.js.map +1 -0
- package/dist/shared/model-tiers.js +1 -2
- package/dist/shared/model-tiers.js.map +1 -1
- package/dist/shared/reminder.js +9 -1
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/session-label.js +19 -1
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/workflow-plan.js +36 -0
- package/dist/shared/workflow-plan.js.map +1 -0
- package/package.json +1 -1
package/dist/daemon/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wxMarkdown } from "../shared/wx-text.js";
|
|
1
2
|
// Daemon entry. Resident process — exits only on signal or fatal WS auth failure.
|
|
2
3
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -12,7 +13,7 @@ import { patchJsonc } from "../shared/config-writer.js";
|
|
|
12
13
|
import { EFFORTS, parseEffort } from "../shared/effort.js";
|
|
13
14
|
import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
|
|
14
15
|
import { bindCodexStateDir } from "../shared/codex.js";
|
|
15
|
-
import { startMux } from "./bot-mux.js";
|
|
16
|
+
import { startMux, pinFrameBot } from "./bot-mux.js";
|
|
16
17
|
import { startWeixin } from "./weixin.js";
|
|
17
18
|
import { installWeixinPort } from "./weixin-port.js";
|
|
18
19
|
import { bindWxCommands, makeOnBound, makeWxCommands, routeSendMedia, wecomBindSink, weixinSendMedia, wxAdmins, wxRoutes } from "./weixin-cmd.js";
|
|
@@ -25,10 +26,11 @@ import { applyHot, cfgSeenBy, patchedTop, pinChatKeys, configGet, fromLegacy, le
|
|
|
25
26
|
import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
|
|
26
27
|
import { installInboundRouter } from "./inbound.js";
|
|
27
28
|
import { loadMirrorStore } from "./mirror-store.js";
|
|
29
|
+
import { createMessageEvents, messageId, finalId } from "./message-events.js";
|
|
28
30
|
import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
|
|
29
31
|
import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
30
32
|
import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
|
|
31
|
-
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
|
|
33
|
+
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, buildChatUrl, getDetail, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
|
|
32
34
|
import { EMPTY_FACTS } from "../shared/world.js";
|
|
33
35
|
import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
|
|
34
36
|
import { makeMessageHandler } from "./outbound.js";
|
|
@@ -37,9 +39,11 @@ import { drainForReload, listPending } from "./pending.js";
|
|
|
37
39
|
import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
|
|
38
40
|
import { makeWedocBridge } from "./wedoc.js";
|
|
39
41
|
import { errText, installResponseTracker } from "./last-response.js";
|
|
42
|
+
import { footerTarget, responseFormatPolicy } from "./message-format.js";
|
|
40
43
|
import { scanClaudeSessions } from "./session-scan.js";
|
|
41
44
|
import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, TASK_POSTPONE_MS, TASK_QUIET_WAIT_MS, } from "./tasks.js";
|
|
42
45
|
import { openTaskRegistry } from "./task-registry.js";
|
|
46
|
+
import { deliverScheduled } from "./scheduled-delivery.js";
|
|
43
47
|
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
44
48
|
import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
|
|
45
49
|
import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
@@ -52,11 +56,15 @@ import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, h
|
|
|
52
56
|
import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
|
|
53
57
|
import { checkDirection } from "./direction.js";
|
|
54
58
|
import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
|
|
55
|
-
import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
|
|
59
|
+
import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, turnsSince, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
|
|
56
60
|
import { keepalivePingSigs } from "../shared/keepalive.js";
|
|
57
61
|
import { expandHome } from "../shared/paths.js";
|
|
58
62
|
import { loadJsonMap } from "../shared/json-map-store.js";
|
|
59
|
-
import {
|
|
63
|
+
import { answerRecoveryAction, answerSourceLost, answerTargetOccupied, answerTransferOrigin, createAnswerTransfers } from "./answer-transfer.js";
|
|
64
|
+
import { createWorkflowNodes } from "./workflow-nodes.js";
|
|
65
|
+
import { loadWorkflowPlans, createWorkflowPlanRunner } from "./workflow-plans.js";
|
|
66
|
+
import { registerWorkflowPlanRoutes } from "./workflow-plan-http.js";
|
|
67
|
+
import { createReceipts, canonicalTurn, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
|
|
60
68
|
import { renderInFlight } from "../shared/turn-state.js";
|
|
61
69
|
import { startIdleNudge } from "./idle-nudge.js";
|
|
62
70
|
import { askerFromEnvelope, renderReminder } from "../shared/reminder.js";
|
|
@@ -84,6 +92,7 @@ const main = async () => {
|
|
|
84
92
|
// 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
|
|
85
93
|
// wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
|
|
86
94
|
// 记忆整理者的执行体, 同样只有 mirror 模式给得出 —— 赋值见 scheduledTaskInject 旁边。
|
|
95
|
+
let stopReceipts = () => { };
|
|
87
96
|
let stewardRun;
|
|
88
97
|
let stewardWiring = {};
|
|
89
98
|
let scheduledTaskInject;
|
|
@@ -149,7 +158,7 @@ const main = async () => {
|
|
|
149
158
|
// last-response tracker enables inbound's `quote` dedup of bot self-replies,
|
|
150
159
|
// and its chat-gate drops header-only pushes (empty messages) daemon-wide.
|
|
151
160
|
// 微信出站不走企微长连接, 不必等它连上。
|
|
152
|
-
installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c));
|
|
161
|
+
const responseTracker = installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c), (context) => responseFormatPolicy(cfg, context, (target) => chatUrlFor(cfg.daemon, target)));
|
|
153
162
|
const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
|
|
154
163
|
// 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
|
|
155
164
|
// (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
|
|
@@ -164,12 +173,87 @@ const main = async () => {
|
|
|
164
173
|
log.warn({ err: e.message }, "memory steward task not retired");
|
|
165
174
|
}
|
|
166
175
|
const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
|
|
167
|
-
|
|
176
|
+
let peerEvents;
|
|
177
|
+
const events = createMessageEvents(join(cfg.daemon.stateDir, "message-events.json"), {
|
|
178
|
+
resource: (target) => bridge.sessionInfo(target)?.tmuxPane || target,
|
|
179
|
+
transcript: (target) => bridge.sessionInfo(target)?.jsonlPath,
|
|
180
|
+
valid: (e) => e.payload.peer ? !!peerEvents?.valid(e) : true,
|
|
181
|
+
accepted: (e) => { if (e.payload.peer)
|
|
182
|
+
peerEvents?.accepted(e); },
|
|
183
|
+
committed: (e) => { if (e.payload.peer)
|
|
184
|
+
peerEvents?.committed(e); },
|
|
185
|
+
canceled: (e) => {
|
|
186
|
+
if (e.payload.peer)
|
|
187
|
+
peerEvents?.canceled(e);
|
|
188
|
+
else if (e.payload.human && e.exposure === "none") {
|
|
189
|
+
const body = `这条消息未执行:${e.reason ?? "已取消"}。`;
|
|
190
|
+
events.result(e.id, body, finalId(e.id, e.channel, body));
|
|
191
|
+
// A canceled, unexposed input has a terminal response owned by its original channel.
|
|
192
|
+
events.delivery({ id: finalId(e.id, e.channel, body), eventId: e.id, target: e.target, turn: e.turn || e.id,
|
|
193
|
+
channel: e.channel, revision: finalId(e.id, e.channel, body), body, chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
ready: async (e) => !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : !bridge.hasMirrorTarget(e.target) || await bridge.idleNow(e.target) || !(await bridge.paneLive(e.target))),
|
|
197
|
+
inject: async (e, boundary) => {
|
|
198
|
+
if (e.payload.peer)
|
|
199
|
+
return peerEvents.inject(e, boundary);
|
|
200
|
+
if (!bridge.hasMirrorTarget(e.target)) {
|
|
201
|
+
const spawned = await bridge.newSession(e.target, wizardStore()?.get(e.target)?.name || e.target, undefined, { silent: true });
|
|
202
|
+
if (!spawned.ok)
|
|
203
|
+
return { ok: false, retryable: true, reason: spawned.reason };
|
|
204
|
+
}
|
|
205
|
+
let outcome;
|
|
206
|
+
pinFrameBot(e.payload.human.frame, botOfKey(e.channel));
|
|
207
|
+
await bridge.dispatch({ ...e.payload.human, eventId: e.id,
|
|
208
|
+
text: e.payload.text + renderReminder({ wezard: "message-event", event: e.id }, []),
|
|
209
|
+
guard: boundary, onSubmitted: (r) => { outcome = r; } });
|
|
210
|
+
return outcome ?? { ok: false, retryable: events.get(e.id)?.exposure === "none", uncertain: events.get(e.id)?.exposure !== "none", reason: "dispatch ended without a submission acknowledgement" };
|
|
211
|
+
},
|
|
212
|
+
evidence: async (e) => {
|
|
213
|
+
if (e.canceled && await bridge.idleNow(e.target))
|
|
214
|
+
events.released(e.id);
|
|
215
|
+
const paths = [...new Set([bridge.sessionInfo(e.target)?.jsonlPath, e.transcript].filter((p) => !!p))];
|
|
216
|
+
for (const path of paths) {
|
|
217
|
+
const turns = turnsSince(expandHome(path), e.at - 60_000);
|
|
218
|
+
const start = turns.findIndex((t) => t.role === "user" && t.env?.eventId === e.id);
|
|
219
|
+
if (start < 0)
|
|
220
|
+
continue;
|
|
221
|
+
const next = turns.findIndex((t, i) => i > start && t.role === "user" && !t.queued);
|
|
222
|
+
const replies = turns.slice(start + 1, next < 0 ? undefined : next).filter((t) => t.role === "assistant");
|
|
223
|
+
const last = replies.at(-1);
|
|
224
|
+
const ended = next >= 0 || await bridge.idleNow(e.target);
|
|
225
|
+
return { consumed: true, interjected: !!turns[start].queued, ...(ended && last && !last.mid && !last.apiError ? { body: last.text, revision: finalId(e.id, e.channel, last.text) } : {}) };
|
|
226
|
+
}
|
|
227
|
+
return { consumed: false };
|
|
228
|
+
},
|
|
229
|
+
recovered: (e) => bridge.recoverEventFinal(e),
|
|
230
|
+
format: (d) => ({ ...d, chunks: d.chunks.map((c) => responseTracker.freeze(d.channel.replace(/^(user|chat|group):/, ""), c)) }),
|
|
231
|
+
send: async (d, n) => {
|
|
232
|
+
await bridge.settleFinalProgress(d.id);
|
|
233
|
+
const chat = d.channel.replace(/^(user|chat|group):/, "");
|
|
234
|
+
if (wx.owns(chat)) {
|
|
235
|
+
const result = wx.sendFinal(chat, wxMarkdown(d.chunks[n]), `${d.id}:${n}`);
|
|
236
|
+
return { ack: result.ack, queued: result.state === "queued" || result.state === "sending",
|
|
237
|
+
uncertain: result.state === "uncertain" ? result.reason ?? "Weixin send ACK unknown" : undefined,
|
|
238
|
+
failed: result.state === "failed" ? result.reason ?? "Weixin final rejected" : undefined };
|
|
239
|
+
}
|
|
240
|
+
const stream = d.stream && n === 0 && d.chunks.length === 1 && Date.now() < d.stream.expires ? d.stream : undefined;
|
|
241
|
+
const ack = d.transfer
|
|
242
|
+
? await bridge.deliverAnswer(d.transfer.id, d.target, d.turn, d.channel, "", d.chunks[n], d.transfer.standalone || n > 0 || d.chunks.length > 1, true)
|
|
243
|
+
: await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
|
|
244
|
+
return ack && typeof ack === "object" && "errcode" in ack && ack.errcode === 0 ? { ack: JSON.stringify(ack) }
|
|
245
|
+
: { ack: JSON.stringify(ack), uncertain: "final transport returned no explicit success ACK" };
|
|
246
|
+
},
|
|
247
|
+
deliveryEvidence: (d, n) => wx.finalStatus(`${d.id}:${n}`),
|
|
248
|
+
error: (e) => log.error({ err: errText(e) }, "message event scheduler"),
|
|
249
|
+
});
|
|
250
|
+
const bridge = startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore, events, sendFrozen: (channel, content, stream) => responseTracker.sendFrozen(channel.replace(/^(user|chat|group):/, ""), content, stream, events.running) });
|
|
168
251
|
// 群里每个 `.name` 都挂它的 rolepage (见 session-label 的 TagLinker)。正文提及
|
|
169
252
|
// 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接。
|
|
170
253
|
const known = (t) => bridge.chatTargets(t).includes(t);
|
|
171
254
|
// 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
|
|
172
255
|
bindTagLinker({
|
|
256
|
+
targetOfUrl: (url) => footerTarget(url, new URL(".", buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, "")).href, (id) => { const r = getDetail(id); return r && "target" in r ? r.target : undefined; }, known),
|
|
173
257
|
urlOf: (t) => chatUrlFor(cfg.daemon, t),
|
|
174
258
|
resolve: (name, from) => {
|
|
175
259
|
const t = wizardStore()?.byName(name)?.target;
|
|
@@ -182,7 +266,18 @@ const main = async () => {
|
|
|
182
266
|
// 重开的空档里 resume 回旧 sid。
|
|
183
267
|
// 宪章守卫 (见「改配置会不会动到宪章」): 要等下面宪章渲染那一块立起来才有; 之前 (启动时那次自动补名) 只写不比对。
|
|
184
268
|
let guardCharter = (write) => ({ r: write(), note: Promise.resolve("") });
|
|
185
|
-
installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) =>
|
|
269
|
+
installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => {
|
|
270
|
+
if (a.text.trimStart().startsWith("/")) {
|
|
271
|
+
await handedOff(a.principal);
|
|
272
|
+
await bridge.dispatch(a);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const id = a.eventId ?? messageId(botOfKey(a.principal), a.channel, a.streamId);
|
|
276
|
+
events.accept({ id, identity: a.identity, originalAt: a.originalAt, target: a.principal, speaker: a.speaker, channel: a.channel,
|
|
277
|
+
asker: { who: a.speaker, chat: a.channel }, priority: "normal", deadline: Date.now() + 12 * 3600_000,
|
|
278
|
+
payload: { text: a.text, human: a } });
|
|
279
|
+
await ws.client.replyStream(a.frame, a.streamId, withTagHeader(a.principal, "已排队"), false).catch(() => undefined);
|
|
280
|
+
} }, sourcePath, (w, c) => guardCharter(w, c));
|
|
186
281
|
// approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
|
|
187
282
|
// 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
|
|
188
283
|
// 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
|
|
@@ -325,6 +420,11 @@ const main = async () => {
|
|
|
325
420
|
{
|
|
326
421
|
const m = bridge;
|
|
327
422
|
installMirrorEventListener(ws.client, m, log.child({ mod: "mirror" }));
|
|
423
|
+
http.register("GET /message-events/status", (_req, res) => json(res, 200, events.status()));
|
|
424
|
+
http.register("POST /message-events/reconcile", async (req, res) => {
|
|
425
|
+
const body = await readBody(req);
|
|
426
|
+
json(res, 200, await events.reconcile(String(body.id ?? "")) ?? { error: "unknown event" });
|
|
427
|
+
});
|
|
328
428
|
http.register("GET /mirror/status", (_req, res) => json(res, 200, m.status()));
|
|
329
429
|
// Manual auto-spawn trigger — used by `wezard init` to materialize a
|
|
330
430
|
// tmux+claude pane immediately after claim, instead of waiting for the
|
|
@@ -338,12 +438,22 @@ const main = async () => {
|
|
|
338
438
|
json(res, 400, { ok: false, reason: "target required (and cfg.defaultChat empty)" });
|
|
339
439
|
return;
|
|
340
440
|
}
|
|
441
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
442
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
341
445
|
const spawnLog = log.child({ mod: "mirror", sub: "spawn-init", target });
|
|
342
446
|
const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, { cli: backendFor(cfg).name }) });
|
|
343
447
|
if (!r.ok) {
|
|
344
448
|
json(res, 500, { ok: false, reason: r.reason });
|
|
345
449
|
return;
|
|
346
450
|
}
|
|
451
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
452
|
+
if (r.tmuxPane)
|
|
453
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane]);
|
|
454
|
+
json(res, 409, { ok: false, reason: "executor reserved during spawn; binding withheld" });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
347
457
|
const att = m.attach({ sessionId: r.sessionId, jsonlPath: r.jsonlPath, target, tmuxPane: r.tmuxPane, tmuxSession: r.tmuxSession });
|
|
348
458
|
json(res, att.ok ? 200 : 500, att.ok ? { ok: true, sessionId: r.sessionId, tmuxSession: r.tmuxSession, tmuxPane: r.tmuxPane, target } : { ok: false, reason: att.reason });
|
|
349
459
|
});
|
|
@@ -394,6 +504,10 @@ const main = async () => {
|
|
|
394
504
|
json(res, 400, { ok: false, reason: "target required (and cfg.defaultChat empty)" });
|
|
395
505
|
return;
|
|
396
506
|
}
|
|
507
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
508
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
397
511
|
const sessions = await scanClaudeSessions();
|
|
398
512
|
// 已绑在别的机器人聊天上的会话与不存在同形。
|
|
399
513
|
const hit = sessions.find((s) => s.sessionId === sessionId && ((t) => !t || sameBot(target, t))(m.targetForSession(s.sessionId)));
|
|
@@ -401,6 +515,10 @@ const main = async () => {
|
|
|
401
515
|
json(res, 404, { ok: false, reason: `session ${sessionId} not found among live tmux sessions` });
|
|
402
516
|
return;
|
|
403
517
|
}
|
|
518
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name) || !planStore.allowExternal(m.targetForSession(hit.sessionId))) {
|
|
519
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
404
522
|
const att = m.attach({ sessionId: hit.sessionId, jsonlPath: hit.jsonlPath, target, tmuxPane: hit.tmuxPane, tmuxSession: hit.tmuxSession, cwd: hit.cwd });
|
|
405
523
|
json(res, att.ok ? 200 : 500, att.ok
|
|
406
524
|
? { ok: true, sessionId: hit.sessionId, label: hit.label, target, cwd: hit.cwd, tmuxSession: hit.tmuxSession }
|
|
@@ -458,6 +576,10 @@ const main = async () => {
|
|
|
458
576
|
return;
|
|
459
577
|
}
|
|
460
578
|
const { target, slot: tag } = slotR;
|
|
579
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name ?? tag)) {
|
|
580
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
461
583
|
const existed = !!wizards.get(target);
|
|
462
584
|
const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
|
|
463
585
|
const model = (body.model ?? "").toString().trim();
|
|
@@ -465,7 +587,7 @@ const main = async () => {
|
|
|
465
587
|
const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
466
588
|
log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
|
|
467
589
|
// silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
|
|
468
|
-
const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
|
|
590
|
+
const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) });
|
|
469
591
|
if (!r.ok && !existed)
|
|
470
592
|
wizards.drop(target);
|
|
471
593
|
if (r.ok)
|
|
@@ -503,7 +625,12 @@ const main = async () => {
|
|
|
503
625
|
json(res, 400, { ok: false, reason: "text required" });
|
|
504
626
|
return;
|
|
505
627
|
}
|
|
506
|
-
const
|
|
628
|
+
const guard = () => planStore.allowExternal(target, wizards.get(target)?.name);
|
|
629
|
+
if (!guard()) {
|
|
630
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const r = await m.injectText(target, text, undefined, { guard });
|
|
507
634
|
json(res, r.ok ? 200 : 500, r);
|
|
508
635
|
});
|
|
509
636
|
// One-shot project switch: bind the chat-wide cwd AND apply it right here
|
|
@@ -521,6 +648,10 @@ const main = async () => {
|
|
|
521
648
|
json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
|
|
522
649
|
return;
|
|
523
650
|
}
|
|
651
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
652
|
+
json(res, 409, { ok: false, reason: "workflow plan fixes executor cwd" });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
524
655
|
// keep = 人回答了「不用换, 就用现在这个」。只落一笔确认, 不碰 pane —— 把
|
|
525
656
|
// 「保持现状」也走成 setPendingCwd + newSession 的话, 代价是白杀一个会话。
|
|
526
657
|
if (body.keep === true) {
|
|
@@ -540,7 +671,7 @@ const main = async () => {
|
|
|
540
671
|
// Same shape as inbound's /new: windowName = tag for tagged sessions,
|
|
541
672
|
// principal for the default one. No explicit cwd — newSession reads the
|
|
542
673
|
// pendingCwd just queued and clears it once applied.
|
|
543
|
-
const r = await m.newSession(target, tagOfKey(target) || target, undefined, {});
|
|
674
|
+
const r = await m.newSession(target, tagOfKey(target) || target, undefined, { guard: () => planStore.allowExternal(target, wizards.get(target)?.name) });
|
|
544
675
|
json(res, r.ok ? 200 : 500, r.ok
|
|
545
676
|
? { ok: true, target, sessionId: r.sessionId, cwd: r.cwd }
|
|
546
677
|
: { ok: false, reason: r.reason, target, runningCwd: set.runningCwd, pendingCwd: set.pendingCwd });
|
|
@@ -716,48 +847,135 @@ const main = async () => {
|
|
|
716
847
|
return id.startsWith("human:") ? id.slice("human:".length) : "";
|
|
717
848
|
};
|
|
718
849
|
// 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
|
|
719
|
-
const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
|
|
850
|
+
const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc, { strict: true }));
|
|
851
|
+
const workflow = createWorkflowNodes(`${cfg.wrc.mirror.receiptsFile}.nodes.json`);
|
|
852
|
+
const planStore = loadWorkflowPlans(`${cfg.wrc.mirror.receiptsFile}.plans.json`, workflow.prereq);
|
|
853
|
+
const pendingBirthJobs = new Map();
|
|
854
|
+
let advancePlans = () => { };
|
|
855
|
+
// Lifecycle continuations carry the same live attempt, never a new task.
|
|
856
|
+
// Human inbound remains an explicit intervention; agent handoff/receipts
|
|
857
|
+
// must not revive canceled or unconfirmed planned executions.
|
|
858
|
+
const answers = createAnswerTransfers(loadJsonMap(join(cfg.daemon.stateDir, "answer-transfers.json"), undefined, { strict: true }));
|
|
859
|
+
m.setAnswerSilencer(answers.source);
|
|
860
|
+
const answerSends = new Map();
|
|
861
|
+
const deliverTransferredAnswer = (id, out) => {
|
|
862
|
+
const pending = answerSends.get(id);
|
|
863
|
+
if (pending)
|
|
864
|
+
return pending;
|
|
865
|
+
const p = (async () => {
|
|
866
|
+
m.stopAnswerProgress(id);
|
|
867
|
+
const r = answers.outcome(id, out);
|
|
868
|
+
if (!r)
|
|
869
|
+
return true;
|
|
870
|
+
if (!answers.delivering(id))
|
|
871
|
+
return false;
|
|
872
|
+
try {
|
|
873
|
+
const eventId = `answer:${id}`;
|
|
874
|
+
const revision = finalId(eventId, r.channel, r.outcome.body);
|
|
875
|
+
const deliveryId = events.delivery({ id: finalId(eventId, r.channel, revision), eventId, target: r.target,
|
|
876
|
+
turn: r.sourceTurn, channel: r.channel, revision, body: r.outcome.body,
|
|
877
|
+
chunks: m.formatFinal(r.target, r.sourceTurn, r.channel, r.outcome.body),
|
|
878
|
+
transfer: { id, standalone: !["done", "need"].includes(r.outcome.status) } });
|
|
879
|
+
for (;;) {
|
|
880
|
+
const states = events.deliveryStatus(deliveryId);
|
|
881
|
+
if (states.every((s) => s === "delivered"))
|
|
882
|
+
break;
|
|
883
|
+
if (states.some((s) => s === "uncertain" || s === "failed"))
|
|
884
|
+
throw new Error(`final delivery uncertain: ${deliveryId}`);
|
|
885
|
+
await sleep(250);
|
|
886
|
+
}
|
|
887
|
+
answers.delivered(id);
|
|
888
|
+
recordPost({ target: r.target, channel: r.channel, body: r.outcome.body, ...(r.tell.asker ? { asker: r.tell.asker } : {}) });
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
catch (e) {
|
|
892
|
+
answers.uncertain(id);
|
|
893
|
+
log.error({ id, err: e.message }, "transferred answer delivery acknowledgement uncertain; automatic retry withheld to prevent duplicate answers");
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
})().finally(() => answerSends.delete(id));
|
|
897
|
+
answerSends.set(id, p);
|
|
898
|
+
return p;
|
|
899
|
+
};
|
|
900
|
+
const planContinuationAllowed = (target) => {
|
|
901
|
+
const r = planStore.reservation(target, wizards.get(target)?.name);
|
|
902
|
+
if (!r)
|
|
903
|
+
return planStore.healthy();
|
|
904
|
+
const n = r.nodes.find((n) => n.to === target);
|
|
905
|
+
const a = n && workflow.byTurn(r.owner, n.turn);
|
|
906
|
+
return !!n && !!a && planStore.healthy() && r.state === "active" && n.phase === "observing"
|
|
907
|
+
&& !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
|
|
908
|
+
&& (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");
|
|
909
|
+
};
|
|
910
|
+
const acceptContinuation = (tell, text) => {
|
|
911
|
+
if (workflow.byTurn(tell.from, tell.turn) || !planStore.allowExternal(tell.to, wizards.get(tell.to)?.name))
|
|
912
|
+
return false;
|
|
913
|
+
const id = `p:${tell.from}:${tell.to}:${tell.turn}:${tell.legs}`;
|
|
914
|
+
events.accept({ id, target: tell.to, speaker: tell.from, channel: tell.channel, asker: tell.asker, k: tell.k,
|
|
915
|
+
priority: "normal", deadline: tell.deadlineAt ?? deadlineOf(tell.at ?? Date.now()), payload: { text, peer: {
|
|
916
|
+
from: { kind: "peer", from: tell.from, turn: tell.turn, ...(tell.asker ? { asker: tell.asker } : {}) },
|
|
917
|
+
envelope: envelopeFor(tell.from, tell.channel, { turn: tell.turn, legs: tell.legs, re: true, asker: tell.asker }),
|
|
918
|
+
tell: { ...tell, dispatchKey: id }, job: tell.job ?? "", kind: "task", held: true,
|
|
919
|
+
} } });
|
|
920
|
+
return true;
|
|
921
|
+
};
|
|
720
922
|
const receipts = createReceipts({
|
|
923
|
+
deliverAnswer: deliverTransferredAnswer,
|
|
924
|
+
workflowPrereq: workflow.prereq,
|
|
721
925
|
idleNow: m.idleNow,
|
|
722
|
-
onOutcome: (s, status, bodyText, bySender) =>
|
|
926
|
+
onOutcome: (s, status, bodyText, bySender) => {
|
|
927
|
+
// Pin transferred-answer terminal state synchronously with the receipt
|
|
928
|
+
// outcome; delivery may still be waiting for the target to become idle.
|
|
929
|
+
if (s.answerTransfer) {
|
|
930
|
+
answers.outcome(s.answerTransfer, { status, body: bodyText });
|
|
931
|
+
m.stopAnswerProgress(s.answerTransfer);
|
|
932
|
+
}
|
|
933
|
+
workflow.outcome(s.from, s.turn ?? "", status, bodyText);
|
|
934
|
+
advancePlans();
|
|
935
|
+
ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender);
|
|
936
|
+
},
|
|
723
937
|
jobTally: (id) => jobs.tally(id),
|
|
724
938
|
parkedNow: m.parkedNow,
|
|
725
|
-
settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
|
|
939
|
+
settleJob: (id, target, outcome, artifacts, identity) => { jobs.settle(id, target, outcome, artifacts, identity); },
|
|
726
940
|
accept: (id, body) => { const j = jobs.get(id); return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined; },
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
channel: x.channel,
|
|
730
|
-
envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true, asker: x.asker }),
|
|
731
|
-
})).ok,
|
|
941
|
+
durableRebound: true,
|
|
942
|
+
rebound: async (x, why, tell) => !!tell && acceptContinuation(tell, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口:RESULT: <结论>;交付文件逐项 ARTIFACT: <路径> — 一句话;真缺信息用 NEED: <问题>。)`),
|
|
732
943
|
untilIdle: m.untilIdle,
|
|
733
944
|
paneLive: m.paneLive,
|
|
734
945
|
nameOf: displayName,
|
|
735
946
|
log,
|
|
736
|
-
store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
|
|
737
|
-
holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile),
|
|
947
|
+
store: loadJsonMap(cfg.wrc.mirror.receiptsFile, undefined, { strict: true }),
|
|
948
|
+
holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile, undefined, { strict: true }),
|
|
738
949
|
// 放行停放着的 after 件: 走 tell_peer 同一条注入路径, 沿用停放时的件号 / 频道 / 父 k。
|
|
739
|
-
release: async (h, seen) => {
|
|
740
|
-
const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen), { turn: h.turn, channel: h.channel, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
|
|
950
|
+
release: async (h, seen, alive) => {
|
|
951
|
+
const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen, h.from), { at: h.at, turn: h.turn, channel: h.channel, after: h.after, seen: h.seen, alive, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
|
|
741
952
|
return r.status === 200 ? { ok: true } : { ok: false, reason: String(r.body.reason ?? `HTTP ${r.status}`) };
|
|
742
953
|
},
|
|
743
954
|
handingOff,
|
|
744
955
|
handedOff,
|
|
745
956
|
replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
|
|
746
957
|
lastWords: m.lastReply,
|
|
747
|
-
deliver: (to, bodyText, meta
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
958
|
+
deliver: async (to, bodyText, meta, alive) => {
|
|
959
|
+
if ((alive && !alive()) || !planContinuationAllowed(to))
|
|
960
|
+
return { ok: false, retryable: true, reason: "receipt permission withdrawn" };
|
|
961
|
+
const id = meta.eventId;
|
|
962
|
+
const text = clipMiddle(bodyText);
|
|
963
|
+
events.accept({ id, identity: JSON.stringify([to, text, meta]), target: to, speaker: meta.from, channel: meta.channel,
|
|
964
|
+
asker: meta.asker, k: meta.k, replyTo: meta.replyTo, priority: "normal", deadline: Date.now() + 12 * 3600_000,
|
|
965
|
+
payload: { text, peer: { job: "", kind: "fyi",
|
|
966
|
+
from: { kind: "peer", from: meta.from, receipt: true, status: meta.status, ...(meta.turn ? { turn: meta.turn } : {}), ...(meta.job ? { job: meta.job, done: meta.done, total: meta.total } : {}), ...(meta.asker ? { asker: meta.asker } : {}) },
|
|
967
|
+
envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open", ...(jobs.get(meta.job)?.kind === "req" ? { req: true } : {}) } : undefined, meta.status, meta.turn || undefined, {
|
|
968
|
+
...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
|
|
969
|
+
...(meta.k ? { k: kAttr(meta.k) } : {}),
|
|
970
|
+
...(meta.asker ? { asker: meta.asker } : {}),
|
|
971
|
+
...(meta.pending ? { pending: meta.pending } : {}),
|
|
972
|
+
...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
|
|
973
|
+
}) + (meta.eventId ? renderReminder({ wezard: "receipt-delivery", event: meta.eventId }, []) : "")
|
|
974
|
+
} } });
|
|
975
|
+
return events.submission(id);
|
|
976
|
+
},
|
|
760
977
|
});
|
|
978
|
+
stopReceipts = receipts.stop;
|
|
761
979
|
const turnsLine = (xs, target) => renderInFlight(xs, target, displayName);
|
|
762
980
|
// reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
|
|
763
981
|
// transcript, 退回按时刻取就可能把上一件事的结论当回执。
|
|
@@ -775,9 +993,13 @@ const main = async () => {
|
|
|
775
993
|
// 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
|
|
776
994
|
inject: async (t, text, owe, channel) => {
|
|
777
995
|
const [first, ...rest] = owe;
|
|
996
|
+
if (!planContinuationAllowed(t))
|
|
997
|
+
return { ok: false, reason: "workflow continuation withdrawn" };
|
|
998
|
+
if (!first && planStore.reservation(t, wizards.get(t)?.name))
|
|
999
|
+
return { ok: false, reason: "workflow handoff requires the original attempt" };
|
|
778
1000
|
if (!first)
|
|
779
|
-
return m.injectText(t, text, undefined, { channel });
|
|
780
|
-
const r = await m.injectText(t, text, undefined, carryOpts(first));
|
|
1001
|
+
return m.injectText(t, text, undefined, { channel, guard: () => planContinuationAllowed(t) });
|
|
1002
|
+
const r = await m.injectText(t, text, undefined, { ...carryOpts(first), guard: () => planContinuationAllowed(t) });
|
|
781
1003
|
if (r.ok && rest.length)
|
|
782
1004
|
void carryRest(t, rest);
|
|
783
1005
|
return r;
|
|
@@ -817,7 +1039,7 @@ const main = async () => {
|
|
|
817
1039
|
for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
|
|
818
1040
|
await sleep(1000);
|
|
819
1041
|
await m.untilIdle(t, 3600_000);
|
|
820
|
-
const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, carryOpts(o));
|
|
1042
|
+
const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, { ...carryOpts(o), guard: () => planContinuationAllowed(t) });
|
|
821
1043
|
log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
|
|
822
1044
|
}
|
|
823
1045
|
}
|
|
@@ -854,6 +1076,19 @@ const main = async () => {
|
|
|
854
1076
|
const p = m.sessionInfo(self)?.jsonlPath;
|
|
855
1077
|
return p ? openingTurn(expandHome(p), pingSigs) : undefined;
|
|
856
1078
|
};
|
|
1079
|
+
const lostAnswer = (self) => {
|
|
1080
|
+
return answerSourceLost(answers.all(), self, m.answerContext(self)?.turn, openingOfSelf(self)?.ms);
|
|
1081
|
+
};
|
|
1082
|
+
const terminalAnswerTurn = (self) => {
|
|
1083
|
+
const turn = openingOfSelf(self)?.env?.turn;
|
|
1084
|
+
return turn ? answers.terminalTarget(self, turn) : undefined;
|
|
1085
|
+
};
|
|
1086
|
+
const controlSnapshot = (self) => ({ opening: openingOfSelf(self)?.ms, turn: m.answerContext(self)?.turn });
|
|
1087
|
+
const controlValid = (self, snapshot) => openingOfSelf(self)?.ms === snapshot.opening && m.answerContext(self)?.turn === snapshot.turn && !lostAnswer(self) && !terminalAnswerTurn(self);
|
|
1088
|
+
const hasAnswerChildren = (self, k) => !!k && (receipts.states().some((s) => s.from === self && !!s.k && kAttr(s.k) === kAttr(k))
|
|
1089
|
+
|| events.status().events.some((e) => e.speaker === self && e.k && kAttr(e.k) === kAttr(k) && e.payload.peer?.tell && !["complete", "canceled"].includes(e.execution))
|
|
1090
|
+
|| workflow.all().some((a) => a.owner === self && a.tell?.k && kAttr(a.tell.k) === kAttr(k) && !a.canceled && a.execution !== "terminal"));
|
|
1091
|
+
m.setAnswerSilencer((target, turn) => answers.source(target, turn) || lostAnswer(target));
|
|
857
1092
|
const parentKOf = (self) => {
|
|
858
1093
|
const opening = openingOfSelf(self);
|
|
859
1094
|
// 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
|
|
@@ -873,7 +1108,39 @@ const main = async () => {
|
|
|
873
1108
|
// A private dispatch keeps its human origin even though its output channel is empty.
|
|
874
1109
|
bindCardChannel((key) => askerOf(key)?.chat || m.currentChannel(key));
|
|
875
1110
|
// 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
|
|
876
|
-
void m.restored.then(() => {
|
|
1111
|
+
void m.restored.then(() => {
|
|
1112
|
+
handoffs.resume();
|
|
1113
|
+
answers.all().filter((r) => r.state !== "delivered").forEach((r) => {
|
|
1114
|
+
const savedFinal = events.status().deliveries.find((d) => d.transfer?.id === r.id);
|
|
1115
|
+
if (savedFinal?.parts.every((p) => p.state === "delivered")) {
|
|
1116
|
+
answers.delivered(r.id);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
const action = answerRecoveryAction(r);
|
|
1120
|
+
if (action === "cancel-before-send") {
|
|
1121
|
+
answers.activate(r.id);
|
|
1122
|
+
void deliverTransferredAnswer(r.id, { status: "canceled", body: "daemon 重启时回答移交仍在投递,结果不确定;本轮已收止,请重新发送问题。" });
|
|
1123
|
+
}
|
|
1124
|
+
else if (action === "ambiguous") {
|
|
1125
|
+
answers.uncertain(r.id);
|
|
1126
|
+
log.error({ id: r.id }, "answer delivery may have reached the chat before restart; automatic retry withheld, user may resend if absent");
|
|
1127
|
+
}
|
|
1128
|
+
else if (action === "deliver-outcome" && r.outcome)
|
|
1129
|
+
void deliverTransferredAnswer(r.id, r.outcome);
|
|
1130
|
+
else if (action === "watch" && !receipts.watching(r.tell))
|
|
1131
|
+
receipts.register(r.tell);
|
|
1132
|
+
});
|
|
1133
|
+
// Confirmed injections may outlive the legacy receipt-store write. Rebuild watching,
|
|
1134
|
+
// using the original turn and intent; uncertain injections remain manual reconciliation.
|
|
1135
|
+
workflow.all().filter((a) => a.tell && ["running", "needs-input", "errored"].includes(a.execution))
|
|
1136
|
+
.forEach((a) => { try {
|
|
1137
|
+
recoverWorkflow(a);
|
|
1138
|
+
}
|
|
1139
|
+
catch (e) {
|
|
1140
|
+
log.error({ err: e.message, attemptId: a.id }, "workflow recovery accounting failed; watcher not restored");
|
|
1141
|
+
} });
|
|
1142
|
+
receipts.resume();
|
|
1143
|
+
});
|
|
877
1144
|
// 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
|
|
878
1145
|
// 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
|
|
879
1146
|
void m.restored.then(() => startIdleNudge({
|
|
@@ -883,15 +1150,10 @@ const main = async () => {
|
|
|
883
1150
|
jobOpen: (id) => jobs.get(id)?.status === "open",
|
|
884
1151
|
policy: () => ({ afterMs: cfg.wrc.mirror.idleNudge.afterMin * 60_000, max: cfg.wrc.mirror.idleNudge.max }),
|
|
885
1152
|
ask: async ({ from, to, turn, text }) => {
|
|
886
|
-
|
|
887
|
-
if (!x)
|
|
1153
|
+
if (workflow.byTurn(from, turn) || !planStore.allowExternal(to, wizards.get(to)?.name))
|
|
888
1154
|
return false;
|
|
889
|
-
|
|
890
|
-
return
|
|
891
|
-
from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
|
|
892
|
-
channel: x.channel,
|
|
893
|
-
envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true, asker: x.asker }),
|
|
894
|
-
})).ok;
|
|
1155
|
+
const tell = receipts.continuation(from, to, turn);
|
|
1156
|
+
return !!tell && acceptContinuation(tell, text);
|
|
895
1157
|
},
|
|
896
1158
|
nameOf: displayName,
|
|
897
1159
|
store: loadJsonMap(join(cfg.daemon.stateDir, "idle-nudges.json")),
|
|
@@ -902,7 +1164,13 @@ const main = async () => {
|
|
|
902
1164
|
/** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
|
|
903
1165
|
* wizard 收到一行名册变动; 返回是否腾出了名字。 */
|
|
904
1166
|
const displace = async (name, keep) => {
|
|
905
|
-
|
|
1167
|
+
if (!planStore.allowExternal(undefined, name))
|
|
1168
|
+
return false;
|
|
1169
|
+
const gone = await evictStaleName(wizards, name, keep, { lastActivity: m.lastActivity, retire: async (target) => {
|
|
1170
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name))
|
|
1171
|
+
throw new Error("workflow plan reserves this identity; eviction withheld");
|
|
1172
|
+
return m.killPane(target);
|
|
1173
|
+
} });
|
|
906
1174
|
if (!gone)
|
|
907
1175
|
return false;
|
|
908
1176
|
const slot = !!tagOfKey(gone.target);
|
|
@@ -916,14 +1184,20 @@ const main = async () => {
|
|
|
916
1184
|
* - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
|
|
917
1185
|
* - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
|
|
918
1186
|
* 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
|
|
919
|
-
const claimSlot = async (base, want, fallback, keep = []) => {
|
|
1187
|
+
const claimSlot = async (base, want, fallback, keep = [], allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, target ? wizards.get(target)?.name : undefined)) => {
|
|
920
1188
|
const asked = normalizeTag(want);
|
|
1189
|
+
const denied = { ok: false, status: 409, body: { ok: false, reason: "workflow plan reserves this executor" } };
|
|
1190
|
+
const candidate = asked || normalizeTag(fallback) || "wizard";
|
|
1191
|
+
if (!allowed(keyOf(base, candidate), candidate))
|
|
1192
|
+
return denied;
|
|
921
1193
|
const clash = asked ? wizards.byName(asked) : undefined;
|
|
922
1194
|
// 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
|
|
923
1195
|
// 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
|
|
924
1196
|
if (clash && !sameBot(base, clash.target)) {
|
|
925
1197
|
return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
|
|
926
1198
|
}
|
|
1199
|
+
if (clash && !allowed(clash.target, clash.name))
|
|
1200
|
+
return denied;
|
|
927
1201
|
const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
|
|
928
1202
|
if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
|
|
929
1203
|
return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
|
|
@@ -952,12 +1226,15 @@ const main = async () => {
|
|
|
952
1226
|
},
|
|
953
1227
|
};
|
|
954
1228
|
}
|
|
1229
|
+
if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
|
|
1230
|
+
return denied;
|
|
955
1231
|
const taken = new Set([
|
|
956
1232
|
...m.chatTargets(base).map(tagOfKey),
|
|
957
1233
|
...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
|
|
958
1234
|
].filter(Boolean));
|
|
959
1235
|
const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
|
|
960
|
-
|
|
1236
|
+
const target = keyOf(base, slot);
|
|
1237
|
+
return allowed(target, asked || slot) ? { ok: true, target, slot } : denied;
|
|
961
1238
|
};
|
|
962
1239
|
// ── 给人看的消息 ──────────────────────────────────────────────────
|
|
963
1240
|
// send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
|
|
@@ -968,7 +1245,12 @@ const main = async () => {
|
|
|
968
1245
|
// 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
|
|
969
1246
|
// 的回执轮都不拦; 管家只在自己的群里豁免。notify 与 send_file 共用这一道闸。
|
|
970
1247
|
const topOnlyMuted = (self, dests) => {
|
|
1248
|
+
if (lostAnswer(self))
|
|
1249
|
+
return { ok: false, reason: "本轮回答权和控制权已移交;由目标 wizard 对人交付" };
|
|
971
1250
|
const env = openingOfSelf(self)?.env;
|
|
1251
|
+
const transferred = env?.turn ? answers.target(self, env.turn) : undefined;
|
|
1252
|
+
if (transferred)
|
|
1253
|
+
return !transferred.outcome && transferred.state !== "delivered" ? undefined : { ok: false, reason: "本轮移交回答已经落定;迟到产出不能再次对人发送" };
|
|
972
1254
|
const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
|
|
973
1255
|
const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
|
|
974
1256
|
return muted.length && belowTop
|
|
@@ -998,7 +1280,8 @@ const main = async () => {
|
|
|
998
1280
|
json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
999
1281
|
return;
|
|
1000
1282
|
}
|
|
1001
|
-
const
|
|
1283
|
+
const activeTransfer = (() => { const t = openingOfSelf(self)?.env?.turn; return t ? answers.target(self, t) : undefined; })();
|
|
1284
|
+
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [activeTransfer?.channel ?? channelOf(self)])];
|
|
1002
1285
|
const muted = topOnlyMuted(self, dests);
|
|
1003
1286
|
if (muted) {
|
|
1004
1287
|
json(res, 409, muted);
|
|
@@ -1036,7 +1319,9 @@ const main = async () => {
|
|
|
1036
1319
|
}
|
|
1037
1320
|
ensureChatNames(self);
|
|
1038
1321
|
const ref = (b.to ?? "").trim();
|
|
1039
|
-
const
|
|
1322
|
+
const transferTurn = openingOfSelf(self)?.env?.turn;
|
|
1323
|
+
const transfer = transferTurn ? answers.target(self, transferTurn) : undefined;
|
|
1324
|
+
const dest = ref ? chatBaseOf(cfg, ref) : transfer?.channel ?? channelOf(self);
|
|
1040
1325
|
if (!dest || !sameBot(self, dest)) {
|
|
1041
1326
|
json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
1042
1327
|
return;
|
|
@@ -1200,23 +1485,141 @@ const main = async () => {
|
|
|
1200
1485
|
return [...new Set((Array.isArray(raw) ? raw : []).map((x) => String(x).replace(/[`\s]/g, "")).filter(Boolean))];
|
|
1201
1486
|
};
|
|
1202
1487
|
/** 放行时挂在信封后面的前置结论: 谁交的、件号、RESULT 与 ARTIFACT 原样 (只进对方输入框)。 */
|
|
1203
|
-
const afterNote = (turns, seen) => renderReminder({ wezard: "after" }, [
|
|
1488
|
+
const afterNote = (turns, seen, owner) => renderReminder({ wezard: "after" }, [
|
|
1204
1489
|
"这件活排在下面这些前置之后, 它们都已落定; 结论原样附上 (谁交的 · 件号):",
|
|
1205
1490
|
...turns.flatMap((t) => {
|
|
1206
1491
|
const x = seen[t];
|
|
1207
1492
|
if (!x)
|
|
1208
1493
|
return [];
|
|
1209
1494
|
const c = parseClosing(x.body, 1500);
|
|
1495
|
+
const versions = workflow.list(owner).find((a) => a.turn === t && a.acceptance === "accepted")?.files ?? [];
|
|
1210
1496
|
const clean = (v) => v.replace(/<\/?system-reminder[^>]*>/g, "");
|
|
1211
1497
|
return [
|
|
1212
1498
|
`- ${displayName(x.to)} · ${t} · ${x.status}: ${c.kind === "result" ? `RESULT: ${clean(c.text)}` : clipLine(clean(x.body), 600) || "(没有正文)"}`,
|
|
1213
|
-
...c.artifacts.map((a) =>
|
|
1499
|
+
...c.artifacts.map((a) => {
|
|
1500
|
+
const f = versions.find((f) => f.path === a.path);
|
|
1501
|
+
return f ? ` ARTIFACT: ${f.snapshot} — accepted version sha256 ${f.sha256}; original source ${f.path}` : ` ARTIFACT: ${a.path}${a.note ? ` — ${a.note}` : ""}`;
|
|
1502
|
+
}),
|
|
1503
|
+
...versions.map((f) => ` VERIFIED ARTIFACT VERSION: ${f.snapshot} · sha256 ${f.sha256} · source ${f.path}; consume this readonly snapshot, not mutable source`),
|
|
1214
1504
|
];
|
|
1215
1505
|
}),
|
|
1216
1506
|
]);
|
|
1507
|
+
// One durable key per tracked physical dispatch leg (held release reuses its reservation).
|
|
1508
|
+
const accountWorkflow = (a, recovery = false) => {
|
|
1509
|
+
if (!a.dispatches?.length)
|
|
1510
|
+
throw new Error("historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry (no blind budget import)");
|
|
1511
|
+
for (const d of a.dispatches) {
|
|
1512
|
+
jobs.account(a.job, d.key, { target: a.to, task: d.continuation ? "" : d.text, spawned: false, turn: (d.tell ?? a.tell)?.turn, ...(d.role ? { role: d.role } : {}) }, d.kind, d.free, recovery, !!d.free && !!d.continuation);
|
|
1513
|
+
const t = d.tell ?? a.tell;
|
|
1514
|
+
ledger.dispatch(d.key, { turn: a.turn, owner: a.owner, to: a.to, text: d.text, at: d.at, job: a.job, ...(t?.k ? { for: t.k } : {}), ...(t?.asker ? { asker: t.asker } : {}) });
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
const recoverWorkflow = (a) => {
|
|
1518
|
+
if (a.canceled || jobs.get(a.job)?.status !== "open")
|
|
1519
|
+
return;
|
|
1520
|
+
accountWorkflow(a, true);
|
|
1521
|
+
if (a.tell && !receipts.watching(a.tell))
|
|
1522
|
+
receipts.register(a.tell, true, a.status && a.body ? { status: a.status, body: a.body } : undefined);
|
|
1523
|
+
};
|
|
1524
|
+
const cancelPeerWork = (target, self, only = () => true) => {
|
|
1525
|
+
const keys = new Set([
|
|
1526
|
+
...workflow.all().filter((a) => a.to === target && a.execution !== "terminal" && !a.canceled && only({ from: a.owner, turn: a.turn })).map((a) => `${a.owner}\0${a.turn}`),
|
|
1527
|
+
...receipts.states().filter((s) => s.to === target && only(s)).map((s) => `${s.from}\0${s.turn}`),
|
|
1528
|
+
]);
|
|
1529
|
+
events.cancel((e) => e.target === target && !!e.payload.peer && only({ from: e.speaker, turn: e.payload.peer.from.turn ?? "" }));
|
|
1530
|
+
workflow.cancel(target, undefined, undefined, undefined, only);
|
|
1531
|
+
receipts.cancel(target, self, only);
|
|
1532
|
+
return keys.size;
|
|
1533
|
+
};
|
|
1534
|
+
peerEvents = {
|
|
1535
|
+
valid: (e) => {
|
|
1536
|
+
const p = e.payload.peer;
|
|
1537
|
+
const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.turn ?? "") : undefined;
|
|
1538
|
+
return planStore.healthy() && !e.canceled && (!p.controlTransfer || !answers.get(p.controlTransfer)?.outcome) && (!p.from.receipt || planContinuationAllowed(e.target)) && (!p.job || jobs.get(p.job)?.status === "open") && !attempt?.canceled
|
|
1539
|
+
&& (!p.after?.length || receipts.prereqs(e.speaker, p.after, p.afterSeen).every((r) => r.state === "done" || r.state === "pending"))
|
|
1540
|
+
&& !answers.all().some((r) => r.target === e.target && answerTargetOccupied(r));
|
|
1541
|
+
},
|
|
1542
|
+
accepted: (e) => {
|
|
1543
|
+
if (e.execution !== "queued")
|
|
1544
|
+
return;
|
|
1545
|
+
const p = e.payload.peer;
|
|
1546
|
+
if (p.tell && e.k?.kind === "chat")
|
|
1547
|
+
events.defer(e.k.turn, true);
|
|
1548
|
+
if (p.attemptId)
|
|
1549
|
+
accountWorkflow(workflow.byTurn(e.speaker, p.from.turn ?? ""));
|
|
1550
|
+
else if (p.job && !p.held)
|
|
1551
|
+
jobs.account(p.job, e.id, { target: e.target, turn: p.from.turn, task: (p.tell?.legs ?? 1) > 1 ? "" : e.payload.text, spawned: !!p.spawned, ...(p.role ? { role: p.role } : {}) }, p.kind === "ask" ? "ask" : "task", !!p.free || !!p.held, false, !!p.free);
|
|
1552
|
+
if (p.tell) {
|
|
1553
|
+
if (!p.attemptId)
|
|
1554
|
+
ledger.dispatch(e.id, { turn: p.tell.turn, owner: e.speaker, to: e.target, text: e.payload.text, at: e.at, ...(p.job ? { job: p.job } : {}), ...(e.k ? { for: e.k } : {}), ...(e.asker ? { asker: e.asker } : {}) });
|
|
1555
|
+
if (!receipts.watching(p.tell))
|
|
1556
|
+
receipts.register({ ...p.tell, queued: true });
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
committed: (e) => {
|
|
1560
|
+
const p = e.payload.peer;
|
|
1561
|
+
if (p.attemptId) {
|
|
1562
|
+
const a = workflow.byTurn(e.speaker, p.from.turn ?? "");
|
|
1563
|
+
if (a?.execution === "dispatch-unknown" && !a.canceled && !a.status && e.exposure === "consumed")
|
|
1564
|
+
workflow.verdict(e.speaker, p.attemptId, "sent", undefined, `transcript confirms message event ${e.id}`);
|
|
1565
|
+
else
|
|
1566
|
+
workflow.dispatched(p.attemptId, true);
|
|
1567
|
+
}
|
|
1568
|
+
if (p.tell)
|
|
1569
|
+
receipts.commit(p.tell);
|
|
1570
|
+
if (p.from.receipt)
|
|
1571
|
+
receipts.confirmDelivery(e.id);
|
|
1572
|
+
},
|
|
1573
|
+
canceled: (e) => {
|
|
1574
|
+
const p = e.payload.peer;
|
|
1575
|
+
if (p.attemptId)
|
|
1576
|
+
workflow.cancel(e.target, e.speaker, p.job, p.from.turn);
|
|
1577
|
+
if (p.tell)
|
|
1578
|
+
receipts.cancel(e.target, "scheduler", (s) => s.from === e.speaker && s.turn === p.tell.turn);
|
|
1579
|
+
},
|
|
1580
|
+
ready: async (e) => receipts.prereqs(e.speaker, e.payload.peer.after ?? [], e.payload.peer.afterSeen).every((r) => r.state === "done")
|
|
1581
|
+
&& (e.priority === "now" || await m.idleNow(e.target) || !(await m.paneLive(e.target)) || (e.priority === "urgent" && !(await othersTurn(e.target, e.speaker)))),
|
|
1582
|
+
inject: async (e, boundary) => {
|
|
1583
|
+
const p = e.payload.peer;
|
|
1584
|
+
if (e.priority === "urgent" && !(await m.idleNow(e.target))) {
|
|
1585
|
+
if (await othersTurn(e.target, e.speaker))
|
|
1586
|
+
return { ok: false, retryable: true, reason: "urgent target now belongs to another actor" };
|
|
1587
|
+
const path = m.sessionInfo(e.target)?.jsonlPath;
|
|
1588
|
+
const opening = path ? openingTurn(expandHome(path), pingSigs)?.ms : undefined;
|
|
1589
|
+
const r = await m.interruptPane(e.target, { guard: () => peerEvents.valid(e)
|
|
1590
|
+
&& m.sessionInfo(e.target)?.jsonlPath === path && (!path || openingTurn(expandHome(path), pingSigs)?.ms === opening) });
|
|
1591
|
+
if (!r.ok)
|
|
1592
|
+
return { ok: false, retryable: true, reason: r.reason };
|
|
1593
|
+
}
|
|
1594
|
+
if (p.attemptId && p.tell)
|
|
1595
|
+
workflow.intent(p.attemptId, p.tell);
|
|
1596
|
+
const raw = await m.injectText(e.target, e.payload.text, undefined, {
|
|
1597
|
+
from: p.from, channel: e.channel, requireIdle: e.priority === "normal",
|
|
1598
|
+
envelope: p.envelope + (p.after?.length ? afterNote(p.after, Object.fromEntries(p.after.map((t, n) => [t, receipts.prereqs(e.speaker, p.after, p.afterSeen)[n].seen])), e.speaker) : "") + renderReminder({ wezard: "message-event", event: e.id }, []),
|
|
1599
|
+
guard: (phase) => peerEvents.valid(e) && receipts.prereqs(e.speaker, p.after ?? [], p.afterSeen).every((r) => r.state === "done") && (!phase || ((!p.attemptId || workflow.boundary(p.attemptId)) && boundary(phase))),
|
|
1600
|
+
});
|
|
1601
|
+
const r = !raw.ok && !raw.uncertain && !raw.retryable && events.get(e.id)?.exposure === "none" ? { ...raw, retryable: true } : raw;
|
|
1602
|
+
if (p.attemptId && (r.uncertain || (!r.ok && !r.retryable)))
|
|
1603
|
+
workflow.dispatched(p.attemptId, false);
|
|
1604
|
+
return r;
|
|
1605
|
+
},
|
|
1606
|
+
};
|
|
1607
|
+
m.setEventFinalGate((e) => !e.awaitingContinuation && !e.canceled && !e.interjected && !receipts.pendingDelivery(e.target, { kind: "chat", channel: e.channel, turn: e.id }, e.currentContinuation) && !hasAnswerChildren(e.target, { kind: "chat", channel: e.channel, turn: e.id }) && !answers.all().some((r) => r.source === e.target && (r.sourceTurn === e.turn || r.origin === e.id)));
|
|
1608
|
+
void m.restored.then(() => events.resume());
|
|
1217
1609
|
/** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
|
|
1218
1610
|
* 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
|
|
1219
|
-
const
|
|
1611
|
+
const peerDispatching = new Map();
|
|
1612
|
+
const tellPeerAsInner = async (self, body, extra = "", held, planned, actor) => {
|
|
1613
|
+
const authority = actor?.authority ?? (!held && !planned ? controlSnapshot(self) : undefined);
|
|
1614
|
+
if (!held && !planned && lostAnswer(self))
|
|
1615
|
+
return { status: 409, body: { ok: false, reason: "this turn transferred answer and control; stop dispatching and conclude silently" } };
|
|
1616
|
+
if (!held && !planned && terminalAnswerTurn(self))
|
|
1617
|
+
return { status: 409, body: { ok: false, reason: "transferred answer is terminal; late control actions refused" } };
|
|
1618
|
+
const answer = body.answer;
|
|
1619
|
+
if (answer !== undefined && answer !== "transfer")
|
|
1620
|
+
return { status: 400, body: { ok: false, reason: "answer must be transfer when supplied" } };
|
|
1621
|
+
if (held?.alive && !held.alive())
|
|
1622
|
+
return { status: 409, body: { ok: false, reason: "held dispatch withdrawn" } };
|
|
1220
1623
|
const text = (body.text ?? "").toString();
|
|
1221
1624
|
if (!text.trim()) {
|
|
1222
1625
|
return { status: 400, body: { ok: false, reason: "text required" } };
|
|
@@ -1231,12 +1634,12 @@ const main = async () => {
|
|
|
1231
1634
|
}
|
|
1232
1635
|
const { target, foreign } = r;
|
|
1233
1636
|
// 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
|
|
1234
|
-
const re = (body.re ?? "").toString()
|
|
1637
|
+
const re = canonicalTurn((body.re ?? "").toString());
|
|
1235
1638
|
// 放行一件停放着的 (after): 件号 / 频道 / 父 k / 链头都沿用停放那一刻定的 —— 依赖边不改 k。
|
|
1236
|
-
const turn = held ? { turn: held.turn, legs: 1, channel: held.channel, asker: held.asker, k: held.k } : receipts.prepare(self, target, re || undefined);
|
|
1639
|
+
const turn = planned ? { turn: planned.turn, legs: 1, channel: "", asker: planned.origin.asker, k: planned.origin.k } : held ? { turn: held.turn, legs: 1, channel: held.channel, asker: held.asker, k: held.k } : receipts.prepare(self, target, re || undefined);
|
|
1237
1640
|
// Capture the origin before waiting for another pane; our own turn may change meanwhile.
|
|
1238
|
-
const asker = held ? held.asker : turn.legs > 1 ? turn.asker : askerOf(self);
|
|
1239
|
-
const k = held ? held.k : body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self);
|
|
1641
|
+
const asker = planned ? planned.origin.asker : held ? held.asker : actor?.asker ?? (turn.legs > 1 ? turn.asker : askerOf(self));
|
|
1642
|
+
const k = planned ? planned.origin.k : held ? held.k : actor?.k ?? (body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self));
|
|
1240
1643
|
// 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
|
|
1241
1644
|
// 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
|
|
1242
1645
|
// 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
|
|
@@ -1249,14 +1652,56 @@ const main = async () => {
|
|
|
1249
1652
|
// 没带 re 的普通 task / ask / dispatch 一律不并进根单。
|
|
1250
1653
|
const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
|
|
1251
1654
|
const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
|
|
1655
|
+
const answerContext = answer === "transfer" ? actor?.answerContext ?? m.answerContext(self) : undefined;
|
|
1656
|
+
const answerSourceOpening = answerContext ? actor?.authority.opening ?? openingOfSelf(self)?.ms ?? 0 : 0;
|
|
1657
|
+
const actorStillValid = () => {
|
|
1658
|
+
if (!authority)
|
|
1659
|
+
return true; // held/planned work carries its captured permit.
|
|
1660
|
+
if (openingOfSelf(self)?.ms !== authority.opening || m.answerContext(self)?.turn !== authority.turn || terminalAnswerTurn(self))
|
|
1661
|
+
return false;
|
|
1662
|
+
if (answer === "transfer")
|
|
1663
|
+
return !answers.all().some((r) => r.source === self && r.sourceTurn === authority.turn && r.id !== turn.turn);
|
|
1664
|
+
return !lostAnswer(self);
|
|
1665
|
+
};
|
|
1666
|
+
if (answer === "transfer" && (!answerContext || !asker?.who.startsWith("user:") || k?.kind !== "chat" || !chatPolicyOf(cfg, k.channel).topOnly || kind === "fyi" || re || jobId || held || planned || body.node || body.after?.length || body.receipt === false || body.chain === false || hasAnswerChildren(self, k))) {
|
|
1667
|
+
return { status: 409, body: { ok: false, reason: "answer transfer requires an active human topOnly turn with no child work, job, re, node, after, chain:false or receipt:false" } };
|
|
1668
|
+
}
|
|
1252
1669
|
if (jobId) {
|
|
1253
1670
|
const jc = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
|
|
1254
1671
|
if (!jc.ok) {
|
|
1255
1672
|
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
1256
1673
|
}
|
|
1257
1674
|
}
|
|
1675
|
+
const trackedRe = workflow.byTurn(self, turn.turn) ?? (re ? workflow.byTurn(self, re) : undefined);
|
|
1676
|
+
if (trackedRe && !trackedRe.accountingVersion && trackedRe.execution !== "terminal")
|
|
1677
|
+
return { status: 409, body: { ok: false, attemptId: trackedRe.id, reason: "historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry" } };
|
|
1678
|
+
const rawNode = body.node;
|
|
1679
|
+
if (rawNode !== undefined && (typeof rawNode !== "string" || !rawNode.trim()))
|
|
1680
|
+
return { status: 400, body: { ok: false, reason: "node must not be blank" } };
|
|
1681
|
+
if (trackedRe && rawNode !== undefined && rawNode.trim() !== trackedRe.node)
|
|
1682
|
+
return { status: 409, body: { ok: false, reason: "tracked turn cannot change node" } };
|
|
1683
|
+
const node = rawNode?.trim() ?? trackedRe?.node ?? "";
|
|
1684
|
+
const planAllowed = () => planStore.allowTell(self, jobId, node, target, re, planned, wizards.get(target)?.name);
|
|
1685
|
+
if (!planAllowed())
|
|
1686
|
+
return { status: 409, body: { ok: false, reason: "dedicated plan job/executor only allows runner dispatch or exact owner re" } };
|
|
1687
|
+
if (node && jobId && workflow.list(self, jobId).some((a) => !a.accountingVersion))
|
|
1688
|
+
return { status: 409, body: { ok: false, reason: "historical job has incomplete dispatch accounting evidence; reconcile stopped, close the old job, and open a new job (no guessed budget migration)" } };
|
|
1689
|
+
if (node && (!jobId || jobs.get(jobId)?.owner !== self || kind !== "task" || body.receipt === false)) {
|
|
1690
|
+
return { status: 400, body: { ok: false, reason: "node requires owner job, task, and receipt" } };
|
|
1691
|
+
}
|
|
1692
|
+
if (re && trackedRe && (turn.reUnknown || trackedRe.to !== target))
|
|
1693
|
+
return { status: 409, body: { ok: false, reason: "tracked re cannot start a new attempt; use node after reconciliation" } };
|
|
1694
|
+
// A released hold can survive a crash after confirmed injection but before dropHold.
|
|
1695
|
+
// Recover the watcher from the durable intent; never paste that attempt again.
|
|
1696
|
+
const heldAttempt = held && node ? workflow.byTurn(self, held.turn) : undefined;
|
|
1697
|
+
if (heldAttempt?.tell && ["running", "needs-input", "errored"].includes(heldAttempt.execution)) {
|
|
1698
|
+
recoverWorkflow(heldAttempt);
|
|
1699
|
+
return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id, turn: held.turn } };
|
|
1700
|
+
}
|
|
1701
|
+
if (heldAttempt?.execution === "terminal" && heldAttempt.status === "done")
|
|
1702
|
+
return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id } };
|
|
1258
1703
|
// 派活方向闸 (见 direction.ts): 只拒不改投递路径, 不动回执 k。
|
|
1259
|
-
const dr = held ? undefined : checkDirection({ self, target, kind, re: turn.legs > 1, jobId, jobs: jobs.all(), chain: receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName });
|
|
1704
|
+
const dr = held ? undefined : checkDirection({ self, target, kind, re: turn.legs > 1, jobId, jobs: jobs.all(), chain: planned?.origin.chain ?? receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName });
|
|
1260
1705
|
if (dr) {
|
|
1261
1706
|
return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
|
|
1262
1707
|
}
|
|
@@ -1280,10 +1725,6 @@ const main = async () => {
|
|
|
1280
1725
|
return { status: 409, body: { ok: false, reason: `你已有一件活 (${cur.turn}) 在 ${displayName(target)} 手上没落定, 同一对只有一个回执槽 —— 等它交差, 或把它也列进 after` } };
|
|
1281
1726
|
}
|
|
1282
1727
|
}
|
|
1283
|
-
const parkIt = prereqs.some((p) => p.state === "pending");
|
|
1284
|
-
// 前置都已落定: 不停放, 结论附在信封后面照常投。
|
|
1285
|
-
if (afters.length && !parkIt)
|
|
1286
|
-
extra += afterNote(afters, Object.fromEntries(afters.map((t, i) => [t, prereqs[i].seen])));
|
|
1287
1728
|
// 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
|
|
1288
1729
|
// 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
|
|
1289
1730
|
// 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
|
|
@@ -1303,7 +1744,12 @@ const main = async () => {
|
|
|
1303
1744
|
}
|
|
1304
1745
|
// 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
|
|
1305
1746
|
// 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
|
|
1306
|
-
|
|
1747
|
+
if (answer === "transfer" && handingOff(target))
|
|
1748
|
+
return { status: 409, body: { ok: false, reason: "answer target is handing off; source retains answer and control" } };
|
|
1749
|
+
if (answer === "transfer")
|
|
1750
|
+
await handedOff(target);
|
|
1751
|
+
if (!actorStillValid())
|
|
1752
|
+
return { status: 409, body: { ok: false, reason: "caller turn/control changed while waiting; dispatch refused" } };
|
|
1307
1753
|
// 投递策略。对方闲着三者一样 —— 立刻投; 只在它正忙时分岔:
|
|
1308
1754
|
// normal (默认) 等它这一轮结束再投 —— 派新活的常态: 两个 wizard 同时找第三个时,
|
|
1309
1755
|
// 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里不是边角情况而是常态;
|
|
@@ -1337,38 +1783,84 @@ const main = async () => {
|
|
|
1337
1783
|
}
|
|
1338
1784
|
// 前置还没落定: 不注入, 登记一份停放 (见 receipts.Hold) —— 件号、频道、父 k、链头定在这一刻,
|
|
1339
1785
|
// 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
jobs.spend(jobId);
|
|
1352
|
-
}
|
|
1353
|
-
return { status: 200, body: { ok: true, parked: true, after: afters, turn: turn.turn, name: peerAddress(cfg, self, target), ...(jobId ? { job: jobId } : {}), receipt: `排在 ${afters.join(" ")} 之后: 前置都 done 才投 (带上它们的结论), 任一失败这件不投、按 canceled 回给你; 不要挂在 wait_peer 上等, 接着干你自己的事` } };
|
|
1786
|
+
let attemptId;
|
|
1787
|
+
const beginNode = (queued) => {
|
|
1788
|
+
if (!planAllowed() || (planned && !planned.alive()))
|
|
1789
|
+
throw new Error("plan dispatch canceled or job reserved");
|
|
1790
|
+
return node ? workflow.begin(self, jobId, node, turn.turn, target, queued).id : undefined;
|
|
1791
|
+
};
|
|
1792
|
+
try {
|
|
1793
|
+
attemptId = beginNode(true);
|
|
1794
|
+
}
|
|
1795
|
+
catch (e) {
|
|
1796
|
+
return { status: 409, body: { ok: false, reason: e.message } };
|
|
1354
1797
|
}
|
|
1798
|
+
const canceledBeforeDispatch = () => !planAllowed() || (!!planned && !planned.alive()) || (!!held?.alive && !held.alive()) || !!workflow.byTurn(self, turn.turn)?.canceled || (!!jobId && jobs.get(jobId)?.status !== "open");
|
|
1355
1799
|
// 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
|
|
1356
1800
|
const wasBusy = !(await m.idleNow(target));
|
|
1801
|
+
if (answer === "transfer" && (wasBusy || receipts.states().some((s) => s.to === target)))
|
|
1802
|
+
return { status: 409, body: { ok: false, wasBusy: true, reason: "answer target is busy or has pending work; source retains answer and control" } };
|
|
1357
1803
|
// urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
|
|
1358
1804
|
// 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
|
|
1359
1805
|
const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
|
|
1360
1806
|
const policy = others ? "normal" : asked;
|
|
1807
|
+
if (answer !== "transfer") {
|
|
1808
|
+
if (canceledBeforeDispatch() || !actorStillValid())
|
|
1809
|
+
return { status: 409, body: { ok: false, reason: "caller authority or dispatch permit changed before acceptance" } };
|
|
1810
|
+
if (jobId) {
|
|
1811
|
+
const budget = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
|
|
1812
|
+
if (!budget.ok)
|
|
1813
|
+
return { status: budget.status, body: { ok: false, reason: budget.reason } };
|
|
1814
|
+
}
|
|
1815
|
+
// All caller-dependent facts are frozen here; workers never reread the caller's current turn.
|
|
1816
|
+
const at = held?.at ?? Date.now();
|
|
1817
|
+
const deadlineAt = deadlineOf(at, body.deadline);
|
|
1818
|
+
const wantReceipt = kind !== "fyi" && body.receipt !== false;
|
|
1819
|
+
const eventId = `p:${self}:${target}:${turn.turn}:${turn.legs}`;
|
|
1820
|
+
let tell = { from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, k, asker, dispatchKey: eventId };
|
|
1821
|
+
if (attemptId) {
|
|
1822
|
+
const leg = workflow.recordLeg(attemptId, { text, tell, continuation: turn.legs > 1, at, kind: "task", role: roleParam(body.role), free: !!open?.need && open.turn === turn.turn }, !!held);
|
|
1823
|
+
tell = { ...tell, dispatchKey: leg.key };
|
|
1824
|
+
}
|
|
1825
|
+
let accepted;
|
|
1826
|
+
try {
|
|
1827
|
+
accepted = events.accept({ id: eventId, target, speaker: self, channel, asker, k, priority: policy, deadline: deadlineAt,
|
|
1828
|
+
payload: { text, peer: {
|
|
1829
|
+
from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
|
|
1830
|
+
envelope: envelopeFor(self, channel, { turn: turn.turn, asker, legs: turn.legs, ...(turn.legs > 1 ? { re: true } : {}),
|
|
1831
|
+
...(body.deadline !== undefined ? { deadline: deadlineAt } : {}), ...(kind !== "task" ? { act: kind } : {}), ...(wantReceipt ? {} : { quiet: true }) }) + extra,
|
|
1832
|
+
...(wantReceipt ? { tell } : {}), job: jobId, kind, attemptId, after: held?.after ?? afters, afterSeen: held?.seen,
|
|
1833
|
+
controlTransfer: answers.all().find((r) => r.target === self && r.tell.turn === openingOfSelf(self)?.env?.turn && !r.outcome)?.id,
|
|
1834
|
+
role: roleParam(body.role), free: !!open?.need && open.turn === turn.turn, held: !!held,
|
|
1835
|
+
} } });
|
|
1836
|
+
}
|
|
1837
|
+
catch (error) {
|
|
1838
|
+
return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
|
|
1839
|
+
}
|
|
1840
|
+
return { status: 200, body: { ...accepted, queued: true, ...(prereqs.some((p) => p.state === "pending") ? { parked: true, after: afters } : {}), turn: turn.turn, name: peerAddress(cfg, self, target), priority: policy, wasBusy,
|
|
1841
|
+
...(jobId ? { job: jobId } : {}), ...(attemptId ? { node, attemptId, acceptance: "pending" } : {}),
|
|
1842
|
+
...(wantReceipt ? { receipt: "已持久化排队;提交后观察原件回执,结论自动返回" } : {}),
|
|
1843
|
+
...(others ? { urgentDowngraded: "目标正在处理他人的轮次,按 normal 排队" } : {}) } };
|
|
1844
|
+
}
|
|
1361
1845
|
let waitedMs = 0;
|
|
1362
1846
|
let interrupted;
|
|
1363
1847
|
if (policy === "normal" && wasBusy) {
|
|
1364
1848
|
const t0 = Date.now();
|
|
1365
1849
|
// 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
|
|
1366
|
-
const wr = await m.untilIdle(target, waitSec * 1000);
|
|
1850
|
+
const wr = await m.untilIdle(target, waitSec * 1000, { aborted: canceledBeforeDispatch });
|
|
1367
1851
|
waitedMs = Date.now() - t0;
|
|
1852
|
+
if (!actorStillValid())
|
|
1853
|
+
return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed while waiting for idle" } };
|
|
1368
1854
|
if (!wr.idle) {
|
|
1855
|
+
if (canceledBeforeDispatch())
|
|
1856
|
+
return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
|
|
1369
1857
|
return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
|
|
1370
1858
|
}
|
|
1371
1859
|
}
|
|
1860
|
+
if (canceledBeforeDispatch())
|
|
1861
|
+
return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
|
|
1862
|
+
if (!actorStillValid())
|
|
1863
|
+
return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed before target control" } };
|
|
1372
1864
|
if (policy === "urgent" && wasBusy) {
|
|
1373
1865
|
// 只按 Esc, 不走 /stop 的 teardown: 那会清掉它的注入队列 (别人排着的话一起丢)
|
|
1374
1866
|
// 并暂停 keepalive —— 这里打断它是为了马上接着干, 不是让它安静。
|
|
@@ -1377,6 +1869,8 @@ const main = async () => {
|
|
|
1377
1869
|
// 等 Esc 生效、这一轮真收住再投; 收不住也照投 —— 落进输入框排在后面, 退化成 now。
|
|
1378
1870
|
interrupted = esc.ok && (await m.untilIdle(target, 15_000)).idle;
|
|
1379
1871
|
waitedMs = Date.now() - t0;
|
|
1872
|
+
if (!actorStillValid())
|
|
1873
|
+
return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed during interrupt wait" } };
|
|
1380
1874
|
}
|
|
1381
1875
|
// 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
|
|
1382
1876
|
const at = Date.now();
|
|
@@ -1390,7 +1884,64 @@ const main = async () => {
|
|
|
1390
1884
|
// 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
|
|
1391
1885
|
const wantReceipt = kind !== "fyi" && body.receipt !== false;
|
|
1392
1886
|
const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
|
|
1887
|
+
const blocking = workflow.list(self).find((a) => a.to === target && a.turn !== turn.turn && !["queued", "terminal"].includes(a.execution));
|
|
1888
|
+
if (blocking && kind !== "fyi")
|
|
1889
|
+
return { status: 409, body: { ok: false, reason: `tracked attempt ${blocking.id} still in flight` } };
|
|
1890
|
+
if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
|
|
1891
|
+
return { status: 409, body: { ok: false, reason: "prerequisite acceptance/version changed before injection" } };
|
|
1892
|
+
let physicalExposure = false;
|
|
1893
|
+
const dispatchGuard = (phase) => {
|
|
1894
|
+
if (!actorStillValid())
|
|
1895
|
+
return false;
|
|
1896
|
+
if (answers.all().some((r) => r.target === target && answerTargetOccupied(r) && r.id !== turn.turn))
|
|
1897
|
+
return false;
|
|
1898
|
+
if (answerContext && m.answerContext(self)?.turn !== answerContext.turn)
|
|
1899
|
+
return false;
|
|
1900
|
+
if (!planAllowed() || (planned && !planned.alive()) || (held?.alive && !held.alive()))
|
|
1901
|
+
return false;
|
|
1902
|
+
if (jobId && jobs.get(jobId)?.status !== "open") {
|
|
1903
|
+
if (attemptId)
|
|
1904
|
+
workflow.cancel(target, self, jobId, turn.turn);
|
|
1905
|
+
return false;
|
|
1906
|
+
}
|
|
1907
|
+
const a = workflow.byTurn(self, turn.turn);
|
|
1908
|
+
if (a?.canceled || a?.execution === "dispatch-unknown" || a?.execution === "terminal")
|
|
1909
|
+
return false;
|
|
1910
|
+
if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
|
|
1911
|
+
return false;
|
|
1912
|
+
const allowed = !phase || !attemptId || workflow.boundary(attemptId);
|
|
1913
|
+
if (allowed && phase)
|
|
1914
|
+
physicalExposure = true;
|
|
1915
|
+
return allowed;
|
|
1916
|
+
};
|
|
1917
|
+
if (!dispatchGuard())
|
|
1918
|
+
return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled or prerequisites invalidated" } };
|
|
1919
|
+
const trackedTell = { from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(answer === "transfer" ? { answerTransfer: turn.turn } : {}), ...(k ? { k } : {}), ...(asker ? { asker } : {}) };
|
|
1920
|
+
if (node) {
|
|
1921
|
+
try {
|
|
1922
|
+
attemptId = beginNode(false);
|
|
1923
|
+
workflow.intent(attemptId, trackedTell);
|
|
1924
|
+
workflow.recordLeg(attemptId, { text, tell: trackedTell, continuation: turn.legs > 1, at, kind: "task", ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}), free: !!open?.need && open.turn === turn.turn }, !!held);
|
|
1925
|
+
accountWorkflow(workflow.byTurn(self, turn.turn));
|
|
1926
|
+
}
|
|
1927
|
+
catch (e) {
|
|
1928
|
+
return { status: 409, body: { ok: false, reason: e.message } };
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
if (answerContext && k?.kind === "chat") {
|
|
1932
|
+
try {
|
|
1933
|
+
answers.reserve({ id: turn.turn, source: self, target, sourceTurn: answerContext.turn, sourceOpening: answerSourceOpening, origin: openingOfSelf(self)?.env?.eventId ?? answerTransferOrigin(self, answerContext.turn), channel: k.channel, prefix: answerContext.prefix, tell: trackedTell, state: "reserved" });
|
|
1934
|
+
}
|
|
1935
|
+
catch (e) {
|
|
1936
|
+
return { status: 409, body: { ok: false, reason: e.message } };
|
|
1937
|
+
}
|
|
1938
|
+
if (!m.prepareAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel)) {
|
|
1939
|
+
answers.rollback(turn.turn);
|
|
1940
|
+
return { status: 409, body: { ok: false, reason: "answer stream is no longer writable; source retains answer and control" } };
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1393
1943
|
const inj = await m.injectText(target, text, undefined, {
|
|
1944
|
+
guard: dispatchGuard,
|
|
1394
1945
|
from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
|
|
1395
1946
|
channel,
|
|
1396
1947
|
envelope: envelopeFor(self, channel, {
|
|
@@ -1402,15 +1953,39 @@ const main = async () => {
|
|
|
1402
1953
|
...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
|
|
1403
1954
|
...(kind !== "task" ? { act: kind } : {}),
|
|
1404
1955
|
...(wantReceipt ? {} : { quiet: true }),
|
|
1405
|
-
}) + extra,
|
|
1956
|
+
}) + extra + (answerContext ? renderReminder({ wezard: "answer-transfer" }, ["你已接手这轮人的回答权与控制权。判断为简单问题,直接答给人;原气泡内容会清空,只显示你的进度,终句覆盖气泡并由 daemon 唯一交付,不回给派活方。不要另行 notify 相同终稿;需要追问可直接以自然语言收口。派活方这轮后续产出不再对人发送。"]) : ""),
|
|
1957
|
+
}).catch((e) => {
|
|
1958
|
+
if (attemptId)
|
|
1959
|
+
workflow.dispatched(attemptId, false);
|
|
1960
|
+
if (answerContext)
|
|
1961
|
+
return { ok: false, uncertain: true, reason: e.message };
|
|
1962
|
+
throw e;
|
|
1406
1963
|
});
|
|
1964
|
+
if (answerContext) {
|
|
1965
|
+
if (inj.ok || inj.uncertain || physicalExposure) {
|
|
1966
|
+
answers.activate(turn.turn);
|
|
1967
|
+
const bubble = await m.takeAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel);
|
|
1968
|
+
receipts.register(trackedTell);
|
|
1969
|
+
if (!inj.ok)
|
|
1970
|
+
void deliverTransferredAnswer(turn.turn, { status: "canceled", body: "回答移交的投递结果不确定,本轮已收止;若未收到回答,请重新发送问题。" });
|
|
1971
|
+
if (!bubble.cleared)
|
|
1972
|
+
return { status: 202, body: { ...inj, ok: false, answer: "transfer", control: "transfer", ownershipTransferred: true, bubbleCleared: false, retryable: false, name: peerAddress(cfg, self, target), turn: turn.turn, reason: "回答权已转交,但原气泡清空未确认(已结束、不可用或 ACK 失败);不能重试移交。目标仍负责最终答复,必要时另发终稿;本轮请停止派活与公开回答。" } };
|
|
1973
|
+
return { status: 200, body: { ...inj, ok: true, answer: "transfer", control: "transfer", name: peerAddress(cfg, self, target), turn: turn.turn, bubbleCleared: bubble.cleared, receipt: "回答权已转交;可用气泡只显示目标进度与终稿;本轮请停止派活与公开回答,静默收口" } };
|
|
1974
|
+
}
|
|
1975
|
+
m.cancelAnswerPreparation(turn.turn);
|
|
1976
|
+
answers.rollback(turn.turn);
|
|
1977
|
+
}
|
|
1978
|
+
if (attemptId)
|
|
1979
|
+
workflow.dispatched(attemptId, inj.ok && !inj.uncertain);
|
|
1980
|
+
if (!dispatchGuard())
|
|
1981
|
+
return { status: 409, body: { ok: false, attemptId, reason: `canceled/invalidated during transport; reconcile possible delivery${inj.reason ? ": " + inj.reason : ""}`, uncertain: !!inj.uncertain || physicalExposure || workflow.byTurn(self, turn.turn)?.execution === "dispatch-unknown" } };
|
|
1407
1982
|
// 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
|
|
1408
1983
|
// 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
|
|
1409
1984
|
// 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
|
|
1410
1985
|
// 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
|
|
1411
1986
|
if (inj.ok && wantReceipt)
|
|
1412
|
-
receipts.register(
|
|
1413
|
-
if (inj.ok && wantReceipt) {
|
|
1987
|
+
receipts.register(attemptId ? workflow.byTurn(self, turn.turn).tell : trackedTell);
|
|
1988
|
+
if (inj.ok && wantReceipt && !attemptId) {
|
|
1414
1989
|
if (turn.legs > 1)
|
|
1415
1990
|
ledger.touch(self, turn.turn);
|
|
1416
1991
|
else
|
|
@@ -1418,7 +1993,7 @@ const main = async () => {
|
|
|
1418
1993
|
}
|
|
1419
1994
|
// 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
|
|
1420
1995
|
// 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
|
|
1421
|
-
if (inj.ok && jobId) {
|
|
1996
|
+
if (inj.ok && jobId && !attemptId) {
|
|
1422
1997
|
const role = roleParam(body.role);
|
|
1423
1998
|
// G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
|
|
1424
1999
|
const rj = jobs.get(jobId);
|
|
@@ -1427,7 +2002,8 @@ const main = async () => {
|
|
|
1427
2002
|
jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
|
|
1428
2003
|
}
|
|
1429
2004
|
// 停放时已记过预算, 放行不再算一次。
|
|
1430
|
-
const
|
|
2005
|
+
const trackedJob = inj.ok && attemptId ? jobs.get(jobId) : undefined;
|
|
2006
|
+
const spent = trackedJob ? { turns: trackedJob.turns ?? 0, maxTurns: trackedJob.maxTurns } : inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
|
|
1431
2007
|
if (inj.ok && isPublic)
|
|
1432
2008
|
relayPeer(self, target, text, channel);
|
|
1433
2009
|
// `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
|
|
@@ -1446,6 +2022,7 @@ const main = async () => {
|
|
|
1446
2022
|
...(waitedMs ? { waitedMs } : {}),
|
|
1447
2023
|
...(jobId ? { job: jobId } : {}),
|
|
1448
2024
|
...(inj.ok ? { turn: turn.turn } : {}),
|
|
2025
|
+
...(attemptId ? { node, attemptId, acceptance: "pending" } : {}),
|
|
1449
2026
|
...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
|
|
1450
2027
|
...(inj.ok && wake ? { wakeCost: wake } : {}),
|
|
1451
2028
|
...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
|
|
@@ -1458,6 +2035,24 @@ const main = async () => {
|
|
|
1458
2035
|
: {}),
|
|
1459
2036
|
} };
|
|
1460
2037
|
};
|
|
2038
|
+
const tellPeerAs = async (...args) => {
|
|
2039
|
+
const [self, body] = args;
|
|
2040
|
+
const transfer = body.answer === "transfer";
|
|
2041
|
+
const current = peerDispatching.get(self);
|
|
2042
|
+
if (current?.transfer || (transfer && current?.count))
|
|
2043
|
+
return { status: 409, body: { ok: false, reason: "another dispatch is in flight for this caller; source retains its current answer ownership" } };
|
|
2044
|
+
peerDispatching.set(self, { count: (current?.count ?? 0) + 1, transfer });
|
|
2045
|
+
try {
|
|
2046
|
+
return await tellPeerAsInner(...args);
|
|
2047
|
+
}
|
|
2048
|
+
finally {
|
|
2049
|
+
const n = peerDispatching.get(self);
|
|
2050
|
+
if (n && n.count > 1)
|
|
2051
|
+
peerDispatching.set(self, { count: n.count - 1, transfer: n.transfer });
|
|
2052
|
+
else
|
|
2053
|
+
peerDispatching.delete(self);
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
1461
2056
|
const tellPeer = async (req, res) => {
|
|
1462
2057
|
const { self, body } = await readPeerBody(req);
|
|
1463
2058
|
if (!self) {
|
|
@@ -1467,6 +2062,58 @@ const main = async () => {
|
|
|
1467
2062
|
const r = await tellPeerAs(self, body);
|
|
1468
2063
|
json(res, r.status, r.body);
|
|
1469
2064
|
};
|
|
2065
|
+
http.register("POST /peers/inbox", async (req, res) => {
|
|
2066
|
+
const { self } = await readPeerBody(req);
|
|
2067
|
+
if (!self) {
|
|
2068
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller" });
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
json(res, 200, { ok: true, events: receipts.inbox(self) });
|
|
2072
|
+
});
|
|
2073
|
+
http.register("POST /workflow/nodes", async (req, res) => {
|
|
2074
|
+
const { self, body } = await readPeerBody(req);
|
|
2075
|
+
if (!self) {
|
|
2076
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller" });
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const attempts = workflow.list(self, body.job);
|
|
2080
|
+
json(res, 200, { ok: true, nodes: attempts, tally: {
|
|
2081
|
+
attempts: attempts.length, nodes: new Set(attempts.map((a) => `${a.job}\0${a.node}`)).size,
|
|
2082
|
+
accepted: attempts.filter((a) => workflow.prereq(self, a.turn)?.state === "done").length,
|
|
2083
|
+
awaitingAcceptance: attempts.filter((a) => a.execution === "terminal" && a.status === "done" && a.acceptance === "pending").length,
|
|
2084
|
+
} });
|
|
2085
|
+
});
|
|
2086
|
+
http.register("POST /workflow/verdict", async (req, res) => {
|
|
2087
|
+
const { self, body } = await readPeerBody(req);
|
|
2088
|
+
if (!self) {
|
|
2089
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller" });
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
const b = body;
|
|
2093
|
+
if (!["accepted", "rejected", "sent", "stopped"].includes(b.as ?? "")) {
|
|
2094
|
+
json(res, 400, { ok: false, reason: "invalid verdict" });
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
try {
|
|
2098
|
+
const old = workflow.list(self).find((a) => a.id === b.attemptId);
|
|
2099
|
+
if (!old)
|
|
2100
|
+
throw new Error("attempt not found for owner");
|
|
2101
|
+
if (b.as !== "stopped" && (jobs.get(old.job)?.status !== "open" || jobs.get(old.job)?.owner !== self))
|
|
2102
|
+
throw new Error("owner job must be open for acceptance or sent watching");
|
|
2103
|
+
if (b.as === "sent" && !old.dispatches?.length)
|
|
2104
|
+
throw new Error("historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry");
|
|
2105
|
+
const a = workflow.verdict(self, b.attemptId ?? "", b.as, b.digest, b.reason ?? "", b.files);
|
|
2106
|
+
if (b.as === "sent" && a.tell)
|
|
2107
|
+
recoverWorkflow(a);
|
|
2108
|
+
if (b.as === "stopped")
|
|
2109
|
+
receipts.cancel(a.to, self, (x) => x.from === self && x.turn === a.turn);
|
|
2110
|
+
advancePlans();
|
|
2111
|
+
json(res, 200, { ok: true, attempt: workflow.byTurn(self, a.turn) });
|
|
2112
|
+
}
|
|
2113
|
+
catch (e) {
|
|
2114
|
+
json(res, 409, { ok: false, reason: e.message });
|
|
2115
|
+
}
|
|
2116
|
+
});
|
|
1470
2117
|
http.register("POST /peers/tell", tellPeer);
|
|
1471
2118
|
http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
|
|
1472
2119
|
// 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
|
|
@@ -1498,7 +2145,7 @@ const main = async () => {
|
|
|
1498
2145
|
return;
|
|
1499
2146
|
}
|
|
1500
2147
|
// 同一个 wizard 写两遍不该顶掉两个名额。
|
|
1501
|
-
const uniq = hits.filter((h, i) => hits.findIndex((o) => o.target === h.target) === i);
|
|
2148
|
+
const uniq = hits.filter((h, i) => hits.findIndex((o) => o.target === h.target) === i).map((h) => ({ ...h, turn: receipts.sentTurn(self, h.target), at: receipts.sentAt(self, h.target) }));
|
|
1502
2149
|
const need = Math.min(Math.max(Number(b.need ?? uniq.length) || uniq.length, 1), uniq.length);
|
|
1503
2150
|
const timeoutMs = Math.min(Math.max(Number(b.timeoutSec ?? 900) || 900, 10), 7200) * 1000;
|
|
1504
2151
|
const wrs = await waitForQuorum(uniq.map((h) => h.target), m.isBusy, timeoutMs, need);
|
|
@@ -1513,21 +2160,22 @@ const main = async () => {
|
|
|
1513
2160
|
const results = uniq.map((h, i) => {
|
|
1514
2161
|
const wr = wrs[i];
|
|
1515
2162
|
// 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
|
|
1516
|
-
const full = wr.idle ? m.
|
|
1517
|
-
const already = full ? receipts.claim(self, h.target) : false;
|
|
1518
|
-
const closing = parseClosing(full);
|
|
2163
|
+
const full = wr.idle && h.turn ? m.replyToPeer(h.target, displayName(self), h.at, h.turn)?.text ?? "" : "";
|
|
2164
|
+
const already = full ? receipts.claim(self, h.target, h.turn) : false;
|
|
2165
|
+
const closing = parseClosing(already === "uncertain" ? "" : full);
|
|
1519
2166
|
const result = closing.kind === "result" ? closing.text : "";
|
|
1520
2167
|
return {
|
|
1521
2168
|
name: h.address,
|
|
1522
2169
|
idle: wr.idle,
|
|
1523
2170
|
// 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
|
|
1524
|
-
...(already ? { delivered: true } : {}),
|
|
2171
|
+
...(already === true ? { delivered: true } : {}),
|
|
2172
|
+
...(already === "uncertain" ? { delivery: "uncertain", reason: "回执可能已递交,需按 receipt eventId 对账;没有自动重投或再次领取。" } : {}),
|
|
1525
2173
|
...(wr.reason ? { reason: wr.reason } : {}),
|
|
1526
2174
|
...(closing.kind === "need" ? { need: closing.text } : {}),
|
|
1527
2175
|
...(closing.artifacts.length ? { artifacts: closing.artifacts } : {}),
|
|
1528
2176
|
...(result
|
|
1529
2177
|
? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
|
|
1530
|
-
: full
|
|
2178
|
+
: already === "uncertain" ? {} : full
|
|
1531
2179
|
? { lastText: clipMiddle(full) }
|
|
1532
2180
|
: wr.idle
|
|
1533
2181
|
? { stale: true, reason: "它停下了, 但自你上次 send_peer 以来没有新回复 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" }
|
|
@@ -1544,12 +2192,14 @@ const main = async () => {
|
|
|
1544
2192
|
// /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
|
|
1545
2193
|
// 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd / keepalive 照旧沿用。
|
|
1546
2194
|
// 同 backend 时模型也沿用, 管家除外 ——
|
|
1547
|
-
//
|
|
2195
|
+
// 它换上此刻的 router 档 (newSession 里定), 改了 models.router 后交接一次就是让它生效的那一下。
|
|
1548
2196
|
const restartFresh = (target) => {
|
|
1549
2197
|
const info = m.sessionInfo(target);
|
|
1550
2198
|
const cli = cfg.wrc.defaultCli ?? primary.name;
|
|
1551
2199
|
const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
|
|
1552
|
-
|
|
2200
|
+
if (!planContinuationAllowed(target))
|
|
2201
|
+
return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn" });
|
|
2202
|
+
return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: true, warm: true, guard: () => planContinuationAllowed(target) });
|
|
1553
2203
|
};
|
|
1554
2204
|
// POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
|
|
1555
2205
|
// 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
|
|
@@ -1558,6 +2208,8 @@ const main = async () => {
|
|
|
1558
2208
|
/** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
|
|
1559
2209
|
* 自己与老路由 /wizard/handoff-self 共用。 */
|
|
1560
2210
|
const handoffSelf = (self, brief) => {
|
|
2211
|
+
if (!planContinuationAllowed(self))
|
|
2212
|
+
return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn" } };
|
|
1561
2213
|
if (brief.length < 40)
|
|
1562
2214
|
return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
|
|
1563
2215
|
const info = m.sessionInfo(self);
|
|
@@ -1608,6 +2260,10 @@ const main = async () => {
|
|
|
1608
2260
|
json(res, r.status, r.body);
|
|
1609
2261
|
return;
|
|
1610
2262
|
}
|
|
2263
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
2264
|
+
json(res, 409, { ok: false, reason: "workflow executor may only hand off its own live attempt" });
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
1611
2267
|
const focus = (body.focus ?? "").toString().trim();
|
|
1612
2268
|
const timeoutMs = Math.min(Math.max(Number(body.timeoutSec ?? 600) || 600, 30), 7200) * 1000;
|
|
1613
2269
|
const r = await handoffs.other(target, focus, timeoutMs);
|
|
@@ -2196,6 +2852,10 @@ const main = async () => {
|
|
|
2196
2852
|
json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
|
|
2197
2853
|
return;
|
|
2198
2854
|
}
|
|
2855
|
+
if (asked && (!planStore.allowExternal(self, wizards.get(self)?.name) || !planStore.allowExternal(undefined, asked))) {
|
|
2856
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this identity" });
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2199
2859
|
const before = settleName(wizards, chatNameOf(cfg, self), self);
|
|
2200
2860
|
const name = asked ? wizards.rename(self, asked) : before;
|
|
2201
2861
|
if (description)
|
|
@@ -2327,7 +2987,23 @@ const main = async () => {
|
|
|
2327
2987
|
json(res, 400, { ok: false, reason: "task is required" });
|
|
2328
2988
|
return;
|
|
2329
2989
|
}
|
|
2990
|
+
const dispatchOrigin = {
|
|
2991
|
+
authority: controlSnapshot(self), answerContext: m.answerContext(self), asker: askerOf(self), k: parentKOf(self),
|
|
2992
|
+
};
|
|
2330
2993
|
const lead = b.lead === true;
|
|
2994
|
+
if (b.answer !== undefined && b.answer !== "transfer") {
|
|
2995
|
+
json(res, 400, { ok: false, reason: "answer must be transfer when supplied" });
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
if (b.answer === "transfer" && lead) {
|
|
2999
|
+
json(res, 409, { ok: false, reason: "answer transfer is for simple direct answers; lead dispatch cannot transfer" });
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
const answerParent = dispatchOrigin?.k;
|
|
3003
|
+
if (b.answer === "transfer" && (lostAnswer(self) || !dispatchOrigin?.answerContext || answerParent?.kind !== "chat" || !dispatchOrigin.asker?.who.startsWith("user:") || !chatPolicyOf(cfg, answerParent.channel).topOnly || hasAnswerChildren(self, answerParent) || peerDispatching.has(self))) {
|
|
3004
|
+
json(res, 409, { ok: false, reason: "no exclusive active human topOnly answer available to transfer" });
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
2331
3007
|
const force = b.force === true;
|
|
2332
3008
|
const to = (b.to ?? "").toString().trim();
|
|
2333
3009
|
const now = Date.now();
|
|
@@ -2335,6 +3011,10 @@ const main = async () => {
|
|
|
2335
3011
|
// 占用保护由 decide 统一判断, 留在候选表里说明为什么不能自动复用。
|
|
2336
3012
|
// 显式 to 保留人为选定; force 只影响成本门, 不绕 lead/工单/连续性保护。
|
|
2337
3013
|
const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target));
|
|
3014
|
+
if (!controlValid(self, dispatchOrigin.authority)) {
|
|
3015
|
+
json(res, 409, { ok: false, reason: "dispatch source turn/control changed while routing; original task remains with source" });
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
2338
3018
|
const tierArg = (b.tier ?? "").toString().trim();
|
|
2339
3019
|
if (tierArg && !TIERS.includes(tierArg)) {
|
|
2340
3020
|
json(res, 400, { ok: false, reason: `unknown tier: ${tierArg}` });
|
|
@@ -2352,10 +3032,11 @@ const main = async () => {
|
|
|
2352
3032
|
let rootId = "";
|
|
2353
3033
|
const tell = (name, text, extra = "") => tellPeerAs(self, {
|
|
2354
3034
|
name, text, public: b.public !== false, force,
|
|
3035
|
+
...(b.answer ? { answer: b.answer } : {}),
|
|
2355
3036
|
// lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
|
|
2356
3037
|
...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
|
|
2357
3038
|
...(rootId ? { job: rootId } : {}),
|
|
2358
|
-
}, extra);
|
|
3039
|
+
}, extra, undefined, undefined, dispatchOrigin);
|
|
2359
3040
|
const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
|
|
2360
3041
|
if (b.dryRun) {
|
|
2361
3042
|
json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: taskTier }), ...(shown.length ? { candidates: shown } : {}) });
|
|
@@ -2405,12 +3086,17 @@ const main = async () => {
|
|
|
2405
3086
|
return;
|
|
2406
3087
|
}
|
|
2407
3088
|
const tier = taskTier;
|
|
2408
|
-
const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
|
|
3089
|
+
const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
|
|
2409
3090
|
if (born.status !== 200) {
|
|
2410
3091
|
settleRoot(false);
|
|
2411
3092
|
json(res, born.status, { ...born.body, decision: "spawn", reason: d.why });
|
|
2412
3093
|
return;
|
|
2413
3094
|
}
|
|
3095
|
+
if (!controlValid(self, dispatchOrigin.authority)) {
|
|
3096
|
+
settleRoot(false);
|
|
3097
|
+
json(res, 409, { ok: false, decision: "spawn", reason: "dispatch source turn changed during spawn; task remains with original source turn" });
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
2414
3100
|
// 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
|
|
2415
3101
|
const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
|
|
2416
3102
|
settleRoot(t.status === 200);
|
|
@@ -2492,193 +3178,242 @@ const main = async () => {
|
|
|
2492
3178
|
// 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
|
|
2493
3179
|
// 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
|
|
2494
3180
|
/** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
|
|
2495
|
-
const bearAs = async (self, body) => {
|
|
2496
|
-
const
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
const
|
|
2507
|
-
|
|
2508
|
-
|
|
3181
|
+
const bearAs = async (self, body, planned, capturedAuthority) => {
|
|
3182
|
+
const authority = planned ? undefined : capturedAuthority ?? controlSnapshot(self);
|
|
3183
|
+
if (authority && !controlValid(self, authority))
|
|
3184
|
+
return { status: 409, body: { ok: false, reason: "this turn no longer owns control; cannot spawn more work" } };
|
|
3185
|
+
// Legacy bootstrap can dispatch before attaching its member. An empty job
|
|
3186
|
+
// is not available for plan ownership while that birth is still in flight.
|
|
3187
|
+
const birthJob = (body.job ?? "").trim();
|
|
3188
|
+
if (birthJob)
|
|
3189
|
+
pendingBirthJobs.set(birthJob, (pendingBirthJobs.get(birthJob) ?? 0) + 1);
|
|
3190
|
+
try {
|
|
3191
|
+
const b = body;
|
|
3192
|
+
const asker = b.task?.trim() ? askerOf(self) : undefined;
|
|
3193
|
+
const k = body.chain === false ? undefined : parentKOf(self);
|
|
3194
|
+
const askedEffort = parseEffort(b.effort);
|
|
3195
|
+
if (b.effort !== undefined && !askedEffort) {
|
|
3196
|
+
return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
|
|
2509
3197
|
}
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
const
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
3198
|
+
// 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
|
|
3199
|
+
const jobId = (b.job ?? "").trim();
|
|
3200
|
+
if (jobId) {
|
|
3201
|
+
const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
|
|
3202
|
+
if (!jc.ok) {
|
|
3203
|
+
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
const planBirthAllowed = (target, name = normalizeTag(b.name ?? b.tag) || (b.detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone") => planStore.allowSpawn(self, jobId, planned, name, target) && (!planned || planned.alive());
|
|
3207
|
+
if (!planBirthAllowed() || (planned && !!b.task))
|
|
3208
|
+
return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
|
|
3209
|
+
// 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
|
|
3210
|
+
const dr = planned || (b.task ?? "").toString().trim() ? checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: planned?.origin.chain ?? receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName }) : undefined;
|
|
3211
|
+
if (dr) {
|
|
3212
|
+
return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
|
|
3213
|
+
}
|
|
3214
|
+
// `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
|
|
3215
|
+
// 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
|
|
3216
|
+
// 逼调用方每次自己说。
|
|
3217
|
+
if (typeof b.inherit !== "boolean") {
|
|
3218
|
+
return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
|
|
3219
|
+
}
|
|
3220
|
+
const inherit = b.inherit;
|
|
3221
|
+
// detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
|
|
3222
|
+
// 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
|
|
3223
|
+
const detached = b.detached === true;
|
|
3224
|
+
if (detached && (inherit || (b.job ?? "").trim())) {
|
|
3225
|
+
return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
|
|
3226
|
+
}
|
|
3227
|
+
// 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
|
|
3228
|
+
// 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
|
|
3229
|
+
// 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
|
|
3230
|
+
const fromAddr = (b.from ?? "").toString().trim();
|
|
3231
|
+
if (fromAddr && !inherit) {
|
|
3232
|
+
return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
|
|
3233
|
+
}
|
|
3234
|
+
const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
|
|
3235
|
+
if (!fromR.ok) {
|
|
3236
|
+
return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
|
|
3237
|
+
}
|
|
3238
|
+
const source = fromR.target;
|
|
3239
|
+
const sourceInfo = m.sessionInfo(source);
|
|
3240
|
+
const forkBackend = inherit && sourceInfo && existsSync(sourceInfo.jsonlPath)
|
|
3241
|
+
&& (!b.cwd?.trim() || expandHome(b.cwd.trim()) === sourceInfo.cwd) ? sourceInfo.cli : undefined;
|
|
3242
|
+
const pick = pickTier(cfg, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, inherit, forkBackend);
|
|
3243
|
+
if (!pick.ok)
|
|
3244
|
+
return { status: 400, body: pick };
|
|
3245
|
+
const effort = pick.effort;
|
|
3246
|
+
// 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
|
|
3247
|
+
// 要的恰恰是它的上下文, 给一张白纸等于答非所问。
|
|
3248
|
+
if (source !== self && !sourceInfo?.sessionId) {
|
|
3249
|
+
return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
|
|
3250
|
+
}
|
|
3251
|
+
const alivePeers = detached ? [] : await m.peers(self);
|
|
3252
|
+
if (authority && !controlValid(self, authority))
|
|
3253
|
+
return { status: 409, body: { ok: false, reason: "caller turn/control changed while preparing spawn" } };
|
|
3254
|
+
// 预算。撞到上限由守护进程腾位, 不叫调用方去收: 最久没动的闲分身只关 pane、绑定留着
|
|
3255
|
+
// (同 maxPanes 的 reap), 再找它 `--resume` 带全上下文复活。忙的、停在审批上的、手上
|
|
3256
|
+
// 还有没落定的活的不动; 全都动不了才拒。只数**本聊天里**活着的 ——
|
|
3257
|
+
// 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
|
|
3258
|
+
const aliveKids = alivePeers.filter((pp) => pp.paneAlive && childrenOf(wizards.all(), self).some((k) => k.target === pp.target));
|
|
3259
|
+
const over = detached ? 0 : aliveKids.length - cfg.wrc.mirror.cloneMax + 1;
|
|
3260
|
+
if (over > 0) {
|
|
3261
|
+
const parked = new Set(listPending().map((p) => p.meta.sessionId).filter(Boolean));
|
|
3262
|
+
const owing = new Set(receipts.states().map((x) => x.to));
|
|
3263
|
+
const idle = aliveKids
|
|
3264
|
+
.filter((pp) => planStore.allowExternal(pp.target, wizards.get(pp.target)?.name) && !pp.busy && !owing.has(pp.target) && !parked.has(m.sessionInfo(pp.target)?.sessionId ?? ""))
|
|
3265
|
+
.sort((x, y) => x.lastActivity - y.lastActivity)
|
|
3266
|
+
.slice(0, over);
|
|
3267
|
+
const slept = (await Promise.all(idle.map((pp) => m.sleepPane(pp.target)))).filter((r) => r.ok).length;
|
|
3268
|
+
if (slept < over) {
|
|
3269
|
+
return { status: 429, body: {
|
|
3270
|
+
ok: false,
|
|
3271
|
+
reason: `你名下 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) 都在干活, 腾不出位置 —— 等其中一个交回结论再分`,
|
|
3272
|
+
clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
|
|
3273
|
+
} };
|
|
3274
|
+
}
|
|
3275
|
+
log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
|
|
3276
|
+
}
|
|
3277
|
+
const wantChat = (b.chat ?? "").toString().trim();
|
|
3278
|
+
const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
|
|
3279
|
+
if (!base) {
|
|
3280
|
+
return { status: 404, body: {
|
|
2570
3281
|
ok: false,
|
|
2571
|
-
reason:
|
|
2572
|
-
|
|
3282
|
+
reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
|
|
3283
|
+
candidates: chatNamesFor(self),
|
|
2573
3284
|
} };
|
|
2574
3285
|
}
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
3286
|
+
const askedName = String(b.name ?? b.tag ?? "");
|
|
3287
|
+
// 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
|
|
3288
|
+
const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
|
|
3289
|
+
const slotR = await claimSlot(base, askedName, fallback, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
|
|
3290
|
+
if (!slotR.ok) {
|
|
3291
|
+
return { status: slotR.status, body: slotR.body };
|
|
3292
|
+
}
|
|
3293
|
+
const { target, slot: tag } = slotR;
|
|
3294
|
+
// claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
|
|
3295
|
+
// 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
|
|
3296
|
+
const existed = !!wizards.get(target);
|
|
3297
|
+
const forkOf = inherit && source !== self ? source : undefined;
|
|
3298
|
+
// 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
|
|
3299
|
+
if (!planBirthAllowed(target, normalizeTag(askedName) || tag) || (jobId && jobs.get(jobId)?.status !== "open"))
|
|
3300
|
+
return { status: 409, body: { ok: false, reason: "plan canceled/job reserved during spawn preparation" } };
|
|
3301
|
+
wizards.upsert(target, {
|
|
3302
|
+
description: (b.description ?? "").toString().trim(),
|
|
3303
|
+
// 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
|
|
3304
|
+
parent: detached ? undefined : self,
|
|
3305
|
+
bornAt: Date.now(),
|
|
3306
|
+
clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
|
|
3307
|
+
...(forkOf ? { forkOf } : {}),
|
|
3308
|
+
// fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
|
|
3309
|
+
// 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
|
|
3310
|
+
...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
|
|
3311
|
+
tier: pick.tier,
|
|
3312
|
+
// 显式写 undefined: 复用的冷记录可能当过 lead。
|
|
3313
|
+
lead: b.lead === true ? true : undefined,
|
|
3314
|
+
// 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
|
|
3315
|
+
spawning: BOOT_ID,
|
|
3316
|
+
});
|
|
3317
|
+
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
3318
|
+
const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
|
|
3319
|
+
const task = (b.task ?? "").toString().trim();
|
|
3320
|
+
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
3321
|
+
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
3322
|
+
// 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
|
|
3323
|
+
// wait_peer 才不会把它们当成分身对这件活的答复。
|
|
3324
|
+
const taskAt = Date.now();
|
|
3325
|
+
const taskTurn = newTurn();
|
|
3326
|
+
const birthGuard = () => (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
|
|
3327
|
+
if (!birthGuard())
|
|
3328
|
+
return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
|
|
3329
|
+
const r = await m.cloneSession({
|
|
3330
|
+
guard: birthGuard,
|
|
3331
|
+
parent: source,
|
|
3332
|
+
target,
|
|
3333
|
+
windowName: name,
|
|
3334
|
+
cli: pick.cli,
|
|
3335
|
+
model: pick.model,
|
|
3336
|
+
effort,
|
|
3337
|
+
cwd: b.cwd,
|
|
3338
|
+
systemPrompt: charter,
|
|
3339
|
+
inherit,
|
|
3340
|
+
// Fork initialization carries no task; actual work is durably accepted below.
|
|
3341
|
+
keepalive,
|
|
3342
|
+
}).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
|
|
3343
|
+
// 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
|
|
3344
|
+
if (!r.ok) {
|
|
3345
|
+
if (existed)
|
|
3346
|
+
wizards.upsert(target, { spawning: undefined });
|
|
3347
|
+
else
|
|
3348
|
+
wizards.drop(target);
|
|
3349
|
+
return { status: 500, body: { ok: false, reason: r.reason } };
|
|
3350
|
+
}
|
|
3351
|
+
wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
3352
|
+
const kid = briefOf(self, target);
|
|
3353
|
+
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
3354
|
+
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
3355
|
+
if (!jobId) {
|
|
3356
|
+
// 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
|
|
3357
|
+
postRoster(base, [target, self], {
|
|
3358
|
+
op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
|
|
3359
|
+
...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
|
|
3360
|
+
...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
|
|
3361
|
+
}, subjectOf(wizards.get(target)));
|
|
3362
|
+
}
|
|
3363
|
+
const dispatched = !!task && birthGuard();
|
|
3364
|
+
if (dispatched) {
|
|
3365
|
+
const eventId = `p:${self}:${target}:${taskTurn}:1`;
|
|
3366
|
+
const deadlineAt = deadlineOf(taskAt);
|
|
3367
|
+
events.accept({ id: eventId, target, speaker: self, channel: "", asker, k, priority: "normal", deadline: deadlineAt,
|
|
3368
|
+
payload: { text: task, peer: {
|
|
3369
|
+
from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) },
|
|
3370
|
+
envelope: envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
|
|
3371
|
+
tell: { from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, legs: 1, deadlineAt, k, asker, dispatchKey: eventId },
|
|
3372
|
+
job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
|
|
3373
|
+
} } });
|
|
3374
|
+
}
|
|
3375
|
+
else if (jobId && birthGuard()) {
|
|
3376
|
+
jobs.attach(jobId, { target, task: "", spawned: true, ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}) });
|
|
3377
|
+
}
|
|
3378
|
+
return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, queued: dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
|
|
2592
3379
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
// 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
|
|
2602
|
-
parent: detached ? undefined : self,
|
|
2603
|
-
bornAt: Date.now(),
|
|
2604
|
-
clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
|
|
2605
|
-
...(forkOf ? { forkOf } : {}),
|
|
2606
|
-
// fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
|
|
2607
|
-
// 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
|
|
2608
|
-
...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
|
|
2609
|
-
tier: pick.tier,
|
|
2610
|
-
// 显式写 undefined: 复用的冷记录可能当过 lead。
|
|
2611
|
-
lead: b.lead === true ? true : undefined,
|
|
2612
|
-
// 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
|
|
2613
|
-
spawning: BOOT_ID,
|
|
2614
|
-
});
|
|
2615
|
-
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
2616
|
-
const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
|
|
2617
|
-
const task = (b.task ?? "").toString().trim();
|
|
2618
|
-
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
2619
|
-
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
2620
|
-
// 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
|
|
2621
|
-
// wait_peer 才不会把它们当成分身对这件活的答复。
|
|
2622
|
-
const taskAt = Date.now();
|
|
2623
|
-
const taskTurn = newTurn();
|
|
2624
|
-
const r = await m.cloneSession({
|
|
2625
|
-
parent: source,
|
|
2626
|
-
target,
|
|
2627
|
-
windowName: name,
|
|
2628
|
-
cli: pick.cli,
|
|
2629
|
-
model: pick.model,
|
|
2630
|
-
effort,
|
|
2631
|
-
cwd: b.cwd,
|
|
2632
|
-
systemPrompt: charter,
|
|
2633
|
-
inherit,
|
|
2634
|
-
// 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
|
|
2635
|
-
// 也少一次"就位了但没事干"的空转。
|
|
2636
|
-
// 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
|
|
2637
|
-
bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
|
|
2638
|
-
...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
|
|
2639
|
-
keepalive,
|
|
2640
|
-
}).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
|
|
2641
|
-
// 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
|
|
2642
|
-
if (!r.ok) {
|
|
2643
|
-
if (existed)
|
|
2644
|
-
wizards.upsert(target, { spawning: undefined });
|
|
2645
|
-
else
|
|
2646
|
-
wizards.drop(target);
|
|
2647
|
-
return { status: 500, body: { ok: false, reason: r.reason } };
|
|
2648
|
-
}
|
|
2649
|
-
wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
2650
|
-
const kid = briefOf(self, target);
|
|
2651
|
-
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
2652
|
-
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
2653
|
-
if (!jobId) {
|
|
2654
|
-
// 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
|
|
2655
|
-
postRoster(base, [target, self], {
|
|
2656
|
-
op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
|
|
2657
|
-
...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
|
|
2658
|
-
...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
|
|
2659
|
-
}, subjectOf(wizards.get(target)));
|
|
2660
|
-
}
|
|
2661
|
-
// 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
|
|
2662
|
-
let dispatched = r.inherited && !!task;
|
|
2663
|
-
if (task && !r.inherited) {
|
|
2664
|
-
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn, asker }) });
|
|
2665
|
-
dispatched = inj.ok;
|
|
3380
|
+
finally {
|
|
3381
|
+
if (birthJob) {
|
|
3382
|
+
const remaining = (pendingBirthJobs.get(birthJob) ?? 1) - 1;
|
|
3383
|
+
if (remaining)
|
|
3384
|
+
pendingBirthJobs.set(birthJob, remaining);
|
|
3385
|
+
else
|
|
3386
|
+
pendingBirthJobs.delete(birthJob);
|
|
3387
|
+
}
|
|
2666
3388
|
}
|
|
2667
|
-
if (jobId) {
|
|
2668
|
-
const role = roleParam(body.role);
|
|
2669
|
-
const from = inherit ? source : undefined;
|
|
2670
|
-
jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
|
|
2671
|
-
}
|
|
2672
|
-
if (jobId && dispatched)
|
|
2673
|
-
jobs.spend(jobId);
|
|
2674
|
-
// 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
|
|
2675
|
-
// 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
|
|
2676
|
-
if (dispatched)
|
|
2677
|
-
receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
|
|
2678
|
-
if (dispatched)
|
|
2679
|
-
ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
|
|
2680
|
-
return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
|
|
2681
3389
|
};
|
|
3390
|
+
const planRunner = createWorkflowPlanRunner(planStore, {
|
|
3391
|
+
job: jobs.get, attempt: workflow.byTurn, prereq: workflow.prereq,
|
|
3392
|
+
spawn: (run, node, state, permit) => bearAs(run.owner, {
|
|
3393
|
+
inherit: false, name: state.name, description: node.criteria || `workflow node ${node.id}`,
|
|
3394
|
+
tier: node.tier, cwd: run.plan.cwd, job: run.plan.job, keepalive: false,
|
|
3395
|
+
}, permit),
|
|
3396
|
+
tell: (run, node, state, after, permit) => tellPeerAs(run.owner, {
|
|
3397
|
+
name: state.name, text: node.task + (node.criteria ? `\n\n验收标准: ${node.criteria}` : ""),
|
|
3398
|
+
node: node.id, job: run.plan.job, ...(after.length ? { after } : {}),
|
|
3399
|
+
}, "", undefined, permit),
|
|
3400
|
+
cancel: (run) => {
|
|
3401
|
+
workflow.cancel(undefined, run.owner, run.plan.job);
|
|
3402
|
+
run.nodes.filter((n) => n.to).forEach((n) => receipts.cancel(n.to, run.owner, (x) => x.from === run.owner && x.turn === n.turn));
|
|
3403
|
+
events.cancel((e) => e.speaker === run.owner && e.payload.peer?.job === run.plan.job);
|
|
3404
|
+
receipts.cancelHeld(run.plan.job, run.owner);
|
|
3405
|
+
},
|
|
3406
|
+
error: (e) => log.error({ err: errText(e) }, "workflow plan advancement paused"),
|
|
3407
|
+
});
|
|
3408
|
+
advancePlans = planRunner.kick;
|
|
3409
|
+
registerWorkflowPlanRoutes(http, {
|
|
3410
|
+
read: readPeerBody, store: planStore, runner: planRunner,
|
|
3411
|
+
job: jobs.get, attempts: workflow.list,
|
|
3412
|
+
occupied: (self, job) => pendingBirthJobs.has(job) || receipts.states().some((s) => s.from === self && s.job === job),
|
|
3413
|
+
origin: (self) => { const k = parentKOf(self); return { ...(k ? { k } : {}), ...(askerOf(self) ? { asker: askerOf(self) } : {}), chain: receipts.ancestorsOf(self, k) }; },
|
|
3414
|
+
direction: (self, jobId, origin) => checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: origin.chain, nameOf: displayName }),
|
|
3415
|
+
});
|
|
3416
|
+
void m.restored.then(planRunner.start);
|
|
2682
3417
|
http.register("POST /wizard/clone", async (req, res) => {
|
|
2683
3418
|
const { self, body } = await readPeerBody(req);
|
|
2684
3419
|
if (!self) {
|
|
@@ -2702,6 +3437,11 @@ const main = async () => {
|
|
|
2702
3437
|
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
2703
3438
|
return;
|
|
2704
3439
|
}
|
|
3440
|
+
const authority = controlSnapshot(self);
|
|
3441
|
+
if (!controlValid(self, authority)) {
|
|
3442
|
+
json(res, 409, { ok: false, reason: "本轮回答权和控制权已移交或失效;不能打断或结束目标 wizard" });
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
2705
3445
|
const b = body;
|
|
2706
3446
|
const r = resolvePeer(self, addrOf(b));
|
|
2707
3447
|
// 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
|
|
@@ -2709,6 +3449,11 @@ const main = async () => {
|
|
|
2709
3449
|
const found = r.ok || r.candidates?.length ? undefined : ((w) => (w && sameBot(self, w.target) ? w : undefined))(wizards.byName(addrOf(b)));
|
|
2710
3450
|
const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
|
|
2711
3451
|
if (cold && (b.mode ?? "end") === "end") {
|
|
3452
|
+
if (!controlValid(self, authority)) {
|
|
3453
|
+
json(res, 409, { ok: false, reason: "本轮控制权已变化;拒绝结束目标 wizard" });
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
workflow.cancel(cold.target);
|
|
2712
3457
|
const detached = b.forget ? forgetWizard(cold.target) : [];
|
|
2713
3458
|
json(res, 200, { ok: true, target: cold.target, name: cold.name, mode: "end", forgotten: !!b.forget, note: "它本来就没有会话在跑", ...(detached.length ? { canceledIn: detached } : {}) });
|
|
2714
3459
|
return;
|
|
@@ -2731,19 +3476,27 @@ const main = async () => {
|
|
|
2731
3476
|
const turn = (b.turn ?? "").replace(/[`\s]/g, "");
|
|
2732
3477
|
if (!end && turn) {
|
|
2733
3478
|
const mine = receipts.pending(self, target);
|
|
2734
|
-
if (mine?.turn !== turn) {
|
|
3479
|
+
if (mine?.turn !== turn && workflow.byTurn(self, turn)?.to !== target && !receipts.states().some((s) => s.from === self && s.to === target && s.turn === turn && s.state === "queued")) {
|
|
2735
3480
|
json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、件号写错, 或不是你派的)` });
|
|
2736
3481
|
return;
|
|
2737
3482
|
}
|
|
2738
3483
|
// 先落 canceled 再按 Esc: 被打断那一轮的半截话不该被 watcher 当成答案投回来。
|
|
2739
3484
|
const busy = !(await m.idleNow(target));
|
|
2740
|
-
|
|
3485
|
+
if (!controlValid(self, authority)) {
|
|
3486
|
+
json(res, 409, { ok: false, reason: "等待期间本轮控制权已变化;未撤回或打断目标" });
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
const canceled = cancelPeerWork(target, self, (x) => x.from === self && x.turn === turn);
|
|
2741
3490
|
// 按 Esc 之前再认一次手上这一轮: 上面那次 await 期间它可能已经做完这件、接了别人的下一件。
|
|
2742
3491
|
const current = busy && openingAsk(target)?.turn === turn;
|
|
2743
3492
|
// 还没轮到 / 排在后面: 捎一句给它, 轮到那句话时别再做了 (notices 挂在下一条注入的尾巴上)。
|
|
2744
3493
|
if (!current)
|
|
2745
3494
|
notices.post([target], `${displayName(self)} 撤回了它派给你的 ${turn} —— 那件活不用做了; 读到它时直接略过, 不必回复。`);
|
|
2746
3495
|
// 只按 Esc, 不 teardown: teardown 会清掉它的注入队列, 别人排着的话一起丢。
|
|
3496
|
+
if (current && !controlValid(self, authority)) {
|
|
3497
|
+
json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;已登记撤回但未发送 Esc" });
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
2747
3500
|
const esc = current ? await m.interruptPane(target) : undefined;
|
|
2748
3501
|
// Esc 没按成也不回滚撤回: 这件已经不等了, 只是它手上那一轮还在跑 —— 如实说。
|
|
2749
3502
|
json(res, 200, {
|
|
@@ -2755,13 +3508,26 @@ const main = async () => {
|
|
|
2755
3508
|
}
|
|
2756
3509
|
// 不带件号的打断会停掉它手上整轮: 那一轮若是别的 wizard 派的, 先拒 —— 误伤的是别人的活。
|
|
2757
3510
|
const others = !end && !b.force ? await othersTurn(target, self) : undefined;
|
|
3511
|
+
if (!controlValid(self, authority)) {
|
|
3512
|
+
json(res, 409, { ok: false, reason: "等待期间本轮控制权已变化;未取消或打断目标" });
|
|
3513
|
+
return;
|
|
3514
|
+
}
|
|
2758
3515
|
if (others) {
|
|
2759
3516
|
json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.turn ? ` (${others.turn})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (件号); 确要打断它这一轮, 加 force:true` });
|
|
2760
3517
|
return;
|
|
2761
3518
|
}
|
|
2762
3519
|
// 先落 canceled 再动 pane: pane 一死, 守着的 watcher 会把同一份报成 dead。
|
|
2763
3520
|
// 打断只停它手上这一轮: 只掐这一轮开头那句派来的活 (人 / 定时 / 回执轮不掐任何一份)。
|
|
2764
|
-
const
|
|
3521
|
+
const only = end ? undefined : currentAsk(target);
|
|
3522
|
+
if (!controlValid(self, authority)) {
|
|
3523
|
+
json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;未取消或打断目标" });
|
|
3524
|
+
return;
|
|
3525
|
+
}
|
|
3526
|
+
const canceled = cancelPeerWork(target, self, only);
|
|
3527
|
+
if (!controlValid(self, authority)) {
|
|
3528
|
+
json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;未打断或结束目标" });
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
2765
3531
|
const done = end ? await m.killPane(target) : await m.interruptPane(target, { teardown: true });
|
|
2766
3532
|
if (!done.ok) {
|
|
2767
3533
|
json(res, 502, { ok: false, target, reason: done.reason });
|
|
@@ -2933,9 +3699,12 @@ const main = async () => {
|
|
|
2933
3699
|
const closeOne = async (j, summary, mark) => {
|
|
2934
3700
|
const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
|
|
2935
3701
|
// 取消: 还没落定的成员手上这张单派的活记成 canceled, 发起者不会再收到一串回执。
|
|
3702
|
+
planRunner.cancelJob(j.owner, j.id);
|
|
3703
|
+
workflow.cancel(undefined, j.owner, j.id);
|
|
2936
3704
|
if (how === "cancel")
|
|
2937
3705
|
j.members.filter((mm) => !mm.outcome).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
|
|
2938
3706
|
// 这张单名下还排着没投的 (after 停放) 也不投了。
|
|
3707
|
+
events.cancel((e) => e.speaker === j.owner && e.payload.peer?.job === j.id);
|
|
2939
3708
|
receipts.cancelHeld(j.id, j.owner);
|
|
2940
3709
|
const closed = jobs.close(j.id, summary, mark);
|
|
2941
3710
|
ledger.closeJob(j.id);
|
|
@@ -3390,6 +4159,11 @@ const main = async () => {
|
|
|
3390
4159
|
json(res, 400, { ok: false, reason: `node.tag \`${xnode.tag}\` 不是一个合法的 tag` });
|
|
3391
4160
|
return;
|
|
3392
4161
|
}
|
|
4162
|
+
const allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, wizards.get(target)?.name);
|
|
4163
|
+
if (spec.nodes.some((n) => !allowed(keyOf(spec.base, n.tag), n.tag))) {
|
|
4164
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves a graph executor" });
|
|
4165
|
+
return;
|
|
4166
|
+
}
|
|
3393
4167
|
const graphLog = log.child({ mod: "graph", base: spec.base });
|
|
3394
4168
|
// 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
|
|
3395
4169
|
// 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
|
|
@@ -3399,16 +4173,27 @@ const main = async () => {
|
|
|
3399
4173
|
// (or its pane died). Re-spawning a healthy node would throw away the
|
|
3400
4174
|
// context that makes a multi-round loop worth running.
|
|
3401
4175
|
ensureNode: async (target, node) => {
|
|
4176
|
+
if (!allowed(target, node.tag))
|
|
4177
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
3402
4178
|
const existing = (await m.peers(target)).find((p) => p.target === target);
|
|
4179
|
+
if (!allowed(target, node.tag))
|
|
4180
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
3403
4181
|
if (existing?.paneAlive)
|
|
3404
4182
|
return { ok: true };
|
|
3405
4183
|
// No broadcast plumbing needed: a `base#tag` target strips down to the
|
|
3406
4184
|
// same WeCom chatid as `base`, so the node's own pushes already land
|
|
3407
4185
|
// in this chat — mirroring them again was pure duplication.
|
|
3408
|
-
const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true });
|
|
4186
|
+
const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true, guard: () => allowed(target, node.tag) });
|
|
3409
4187
|
return { ok: r.ok, reason: r.reason };
|
|
3410
4188
|
},
|
|
3411
|
-
send: async (target, text, origin) =>
|
|
4189
|
+
send: async (target, text, origin) => {
|
|
4190
|
+
if (!allowed(target))
|
|
4191
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
4192
|
+
await handedOff(target);
|
|
4193
|
+
if (!allowed(target))
|
|
4194
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
4195
|
+
return m.injectText(target, text, origin, { channel, guard: () => allowed(target) });
|
|
4196
|
+
},
|
|
3412
4197
|
isBusy: m.isBusy,
|
|
3413
4198
|
lastText: async (target) => m.lastText(target),
|
|
3414
4199
|
notify: (_base, markdown) => { if (channel)
|
|
@@ -3473,50 +4258,39 @@ const main = async () => {
|
|
|
3473
4258
|
// 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
|
|
3474
4259
|
// 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
|
|
3475
4260
|
// 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
|
|
3476
|
-
scheduledTaskInject = async (target, text,
|
|
3477
|
-
|
|
4261
|
+
scheduledTaskInject = async (target, text, opts) => {
|
|
4262
|
+
const { taskId, fresh, quiet } = opts;
|
|
3478
4263
|
const owner = ((r) => (r ? ownerOf(r) : target))(tasks.get(taskId));
|
|
3479
4264
|
if (!sameBot(owner, target))
|
|
3480
4265
|
return { ok: false, reason: `日程 ${taskId} 的目标与排班者归不同的企微机器人, 不跨机器人放枪` };
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
postponed.delete(taskId);
|
|
3509
|
-
const r = wr.idle ? await inNamed() : await inRunner(true);
|
|
3510
|
-
log.info({ taskId, target, postponed: true, idle: wr.idle, ok: r.ok, reason: r.reason }, "scheduled task: 目标正忙, 顺延");
|
|
3511
|
-
if (!r.ok)
|
|
3512
|
-
notifyChat(baseOfKey(target), withTagHeader(target, `⏰ 定时任务 \`${taskId}\` 顺延后注入失败: ${r.reason ?? "unknown"}`));
|
|
3513
|
-
})();
|
|
3514
|
-
return { ok: true, reason: "目标正忙, 顺延到它闲下来" };
|
|
3515
|
-
}
|
|
3516
|
-
return inRunner(busy);
|
|
4266
|
+
const allowed = () => opts.guard() && planStore.allowExternal(target, wizards.get(target)?.name);
|
|
4267
|
+
return deliverScheduled({ ...opts, guard: () => opts.guard() && (fresh || allowed()) }, {
|
|
4268
|
+
postponeMs: TASK_POSTPONE_MS,
|
|
4269
|
+
handedOff: () => handedOff(target),
|
|
4270
|
+
idleNow: () => m.idleNow(target),
|
|
4271
|
+
untilIdle: (timeoutMs, aborted) => m.untilIdle(target, timeoutMs, { aborted }),
|
|
4272
|
+
named: async (live) => {
|
|
4273
|
+
const guard = () => live() && allowed();
|
|
4274
|
+
if (!guard())
|
|
4275
|
+
return { ok: false, reason: "scheduled target permission withdrawn" };
|
|
4276
|
+
const at = Date.now();
|
|
4277
|
+
const turn = newTurn();
|
|
4278
|
+
// Confirm idle inside the physical pane transaction; a race is unsent
|
|
4279
|
+
// and returns to the target consumer, without releasing task dedup.
|
|
4280
|
+
if (quiet)
|
|
4281
|
+
quietRelays.set(turn, { target, at, base: baseOfKey(target) });
|
|
4282
|
+
const inj = await m.injectText(target, text, undefined, quiet
|
|
4283
|
+
? { guard, requireIdle: true, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
|
|
4284
|
+
: { guard, requireIdle: true, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
4285
|
+
if (inj.ok && quiet)
|
|
4286
|
+
void relayUnlessQuiet(target, at, turn, baseOfKey(target));
|
|
4287
|
+
return inj;
|
|
4288
|
+
},
|
|
4289
|
+
runner: (busy, live) => !fresh && !allowed()
|
|
4290
|
+
? Promise.resolve({ ok: false, reason: "scheduled target permission withdrawn" })
|
|
4291
|
+
: runTaskInRunner(target, text, taskId, quiet, busy, live),
|
|
4292
|
+
});
|
|
3517
4293
|
};
|
|
3518
|
-
/** 顺延中的任务 (内存即可: reload 丢了只是少放一枪, 下一次到点照常)。 */
|
|
3519
|
-
const postponed = new Set();
|
|
3520
4294
|
/** 安静任务那一轮的终句: 它私下跑, 镜像不推; 等它这一轮跑完, 终句不是 `QUIET` 就替它
|
|
3521
4295
|
* 发进群 (头写它的名字, 人知道是谁说的)。按信封件号定位答句 (同回执): 按时刻取会把紧随
|
|
3522
4296
|
* 其后的私聊 / 人话那一轮的答案当成它的, 把私下内容发进群。 */
|
|
@@ -3525,12 +4299,19 @@ const main = async () => {
|
|
|
3525
4299
|
const end = at + TASK_QUIET_WAIT_MS;
|
|
3526
4300
|
// 先等它动起来: 刚注入时注册表还报 idle。
|
|
3527
4301
|
await sleep(5000);
|
|
3528
|
-
|
|
3529
|
-
|
|
4302
|
+
let firstObservation = true;
|
|
4303
|
+
while (firstObservation || Date.now() < end) {
|
|
4304
|
+
firstObservation = false;
|
|
4305
|
+
if (Date.now() < end)
|
|
4306
|
+
await m.untilIdle(t, end - Date.now());
|
|
3530
4307
|
const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
|
|
3531
4308
|
if (reply && (await m.idleNow(t))) {
|
|
3532
|
-
if (!isQuiet(reply))
|
|
3533
|
-
|
|
4309
|
+
if (!isQuiet(reply)) {
|
|
4310
|
+
const eventId = `quiet:${t}:${turn}`;
|
|
4311
|
+
const revision = finalId(eventId, base, reply);
|
|
4312
|
+
events.delivery({ id: finalId(eventId, base, revision), eventId, target: t, turn, channel: base,
|
|
4313
|
+
revision, body: reply, chunks: m.formatFinal(t, turn, base, reply) });
|
|
4314
|
+
}
|
|
3534
4315
|
log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
|
|
3535
4316
|
quietRelays.drop(turn);
|
|
3536
4317
|
return;
|
|
@@ -3538,13 +4319,15 @@ const main = async () => {
|
|
|
3538
4319
|
await sleep(5000);
|
|
3539
4320
|
}
|
|
3540
4321
|
log.warn({ target: t }, "quiet task: 等满仍没收口, 不转发");
|
|
3541
|
-
quietRelays.
|
|
4322
|
+
quietRelays.set(turn, { target: t, at, base, reason: "deadline passed; final not observed, retained for reconciliation" });
|
|
3542
4323
|
};
|
|
3543
4324
|
// 落盘: 这个仓库里 reload 是常态, 守在内存里的那一轮一 reload 就既不转发也不说 QUIET 了。
|
|
3544
|
-
const quietRelays = loadJsonMap(join(cfg.daemon.stateDir, "quiet-relays.json"),
|
|
4325
|
+
const quietRelays = loadJsonMap(join(cfg.daemon.stateDir, "quiet-relays.json"), undefined, { strict: true });
|
|
3545
4326
|
void m.restored.then(() => Object.entries(quietRelays.all()).forEach(([turn, r]) => void relayUnlessQuiet(r.target, r.at, turn, r.base)));
|
|
3546
4327
|
/** 到点起一个白板 wizard 执行这一枪, 跑完自动收掉。 */
|
|
3547
|
-
const runTaskInRunner = async (target, text, taskId, quiet, busy) => {
|
|
4328
|
+
const runTaskInRunner = async (target, text, taskId, quiet, busy, guard) => {
|
|
4329
|
+
if (!guard())
|
|
4330
|
+
return { ok: false, reason: "scheduled runner withdrawn" };
|
|
3548
4331
|
// 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
|
|
3549
4332
|
// rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
|
|
3550
4333
|
const base = baseOfKey(target);
|
|
@@ -3552,9 +4335,14 @@ const main = async () => {
|
|
|
3552
4335
|
const owner = rec ? ownerOf(rec) : target;
|
|
3553
4336
|
const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
|
|
3554
4337
|
const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
4338
|
+
if (!guard())
|
|
4339
|
+
return { ok: false, reason: "scheduled runner withdrawn during slot selection" };
|
|
3555
4340
|
if (!slotR.ok)
|
|
3556
4341
|
return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
|
|
3557
4342
|
const runner = slotR.target;
|
|
4343
|
+
const allowed = () => guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
|
|
4344
|
+
if (!allowed())
|
|
4345
|
+
return { ok: false, reason: "workflow plan reserves task runner" };
|
|
3558
4346
|
const info = m.sessionInfo(target);
|
|
3559
4347
|
const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
|
|
3560
4348
|
wizards.upsert(runner, {
|
|
@@ -3564,11 +4352,18 @@ const main = async () => {
|
|
|
3564
4352
|
bornAt: Date.now(),
|
|
3565
4353
|
});
|
|
3566
4354
|
const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
3567
|
-
const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true });
|
|
4355
|
+
const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed });
|
|
3568
4356
|
if (!spawned.ok) {
|
|
3569
4357
|
wizards.drop(runner);
|
|
3570
4358
|
return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
|
|
3571
4359
|
}
|
|
4360
|
+
if (!allowed()) {
|
|
4361
|
+
if (m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
|
|
4362
|
+
await m.killPane(runner);
|
|
4363
|
+
wizards.drop(runner);
|
|
4364
|
+
}
|
|
4365
|
+
return { ok: false, reason: "scheduled runner withdrawn after spawn" };
|
|
4366
|
+
}
|
|
3572
4367
|
if (!quiet) {
|
|
3573
4368
|
notifyChat(base, withTagHeader(target, busy
|
|
3574
4369
|
? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
|
|
@@ -3576,9 +4371,11 @@ const main = async () => {
|
|
|
3576
4371
|
}
|
|
3577
4372
|
const at = Date.now();
|
|
3578
4373
|
const turn = newTurn();
|
|
4374
|
+
if (quiet)
|
|
4375
|
+
quietRelays.set(turn, { target: runner, at, base });
|
|
3579
4376
|
const inj = await m.injectText(runner, text, undefined, quiet
|
|
3580
|
-
? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
|
|
3581
|
-
: { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
4377
|
+
? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
|
|
4378
|
+
: { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
3582
4379
|
if (!inj.ok) {
|
|
3583
4380
|
wizards.drop(runner);
|
|
3584
4381
|
return inj;
|
|
@@ -3687,7 +4484,7 @@ const main = async () => {
|
|
|
3687
4484
|
const sweep = async () => {
|
|
3688
4485
|
const taskOwners = new Set(tasks.list()
|
|
3689
4486
|
.filter((t) => t.enabled)
|
|
3690
|
-
.map((t) => wizardStore()?.byName(t.owner)?.target)
|
|
4487
|
+
.map((t) => wizardStore()?.get(t.owner)?.target ?? wizardStore()?.byName(t.owner)?.target)
|
|
3691
4488
|
.filter((t) => !!t));
|
|
3692
4489
|
const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
|
|
3693
4490
|
const r = await bridge.reapOverflow(max, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
|
|
@@ -3709,6 +4506,8 @@ const main = async () => {
|
|
|
3709
4506
|
}
|
|
3710
4507
|
const shutdown = async (signal) => {
|
|
3711
4508
|
log.info({ signal }, "shutdown signal");
|
|
4509
|
+
events.stop();
|
|
4510
|
+
stopReceipts();
|
|
3712
4511
|
scheduler.stop();
|
|
3713
4512
|
steward?.stop();
|
|
3714
4513
|
tasks.stop();
|
|
@@ -3724,6 +4523,10 @@ const main = async () => {
|
|
|
3724
4523
|
await Promise.allSettled([ws.shutdown(), http.close()]);
|
|
3725
4524
|
process.exit(0);
|
|
3726
4525
|
};
|
|
4526
|
+
http.register("POST /shutdown", (_req, res) => {
|
|
4527
|
+
json(res, 200, { ok: true });
|
|
4528
|
+
return shutdown("HTTP");
|
|
4529
|
+
});
|
|
3727
4530
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3728
4531
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
3729
4532
|
try {
|