wezard 2.6.1 → 2.6.3
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/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 +34 -0
- package/dist/daemon/image-input.js.map +1 -0
- package/dist/daemon/inbound.js +4 -11
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +880 -280
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +32 -13
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +16 -6
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/message-format.js +40 -0
- package/dist/daemon/message-format.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +673 -295
- 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 +15 -14
- 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 +346 -120
- 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/spawn-tmux.js +7 -2
- package/dist/daemon/spawn-tmux.js.map +1 -1
- 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 +44 -9
- 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/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/config.js +12 -10
- package/dist/shared/config.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 +3 -0
- 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
|
@@ -28,7 +28,7 @@ import { loadMirrorStore } from "./mirror-store.js";
|
|
|
28
28
|
import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
|
|
29
29
|
import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
30
30
|
import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
|
|
31
|
-
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
|
|
31
|
+
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, buildChatUrl, getDetail, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
|
|
32
32
|
import { EMPTY_FACTS } from "../shared/world.js";
|
|
33
33
|
import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
|
|
34
34
|
import { makeMessageHandler } from "./outbound.js";
|
|
@@ -37,9 +37,11 @@ import { drainForReload, listPending } from "./pending.js";
|
|
|
37
37
|
import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
|
|
38
38
|
import { makeWedocBridge } from "./wedoc.js";
|
|
39
39
|
import { errText, installResponseTracker } from "./last-response.js";
|
|
40
|
+
import { footerTarget, responseFormatPolicy } from "./message-format.js";
|
|
40
41
|
import { scanClaudeSessions } from "./session-scan.js";
|
|
41
42
|
import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, TASK_POSTPONE_MS, TASK_QUIET_WAIT_MS, } from "./tasks.js";
|
|
42
43
|
import { openTaskRegistry } from "./task-registry.js";
|
|
44
|
+
import { deliverScheduled } from "./scheduled-delivery.js";
|
|
43
45
|
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
44
46
|
import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
|
|
45
47
|
import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
@@ -56,7 +58,11 @@ import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseC
|
|
|
56
58
|
import { keepalivePingSigs } from "../shared/keepalive.js";
|
|
57
59
|
import { expandHome } from "../shared/paths.js";
|
|
58
60
|
import { loadJsonMap } from "../shared/json-map-store.js";
|
|
59
|
-
import {
|
|
61
|
+
import { answerRecoveryAction, answerSourceLost, answerTargetOccupied, answerTransferOrigin, createAnswerTransfers } from "./answer-transfer.js";
|
|
62
|
+
import { createWorkflowNodes } from "./workflow-nodes.js";
|
|
63
|
+
import { loadWorkflowPlans, createWorkflowPlanRunner } from "./workflow-plans.js";
|
|
64
|
+
import { registerWorkflowPlanRoutes } from "./workflow-plan-http.js";
|
|
65
|
+
import { createReceipts, canonicalTurn, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
|
|
60
66
|
import { renderInFlight } from "../shared/turn-state.js";
|
|
61
67
|
import { startIdleNudge } from "./idle-nudge.js";
|
|
62
68
|
import { askerFromEnvelope, renderReminder } from "../shared/reminder.js";
|
|
@@ -84,6 +90,7 @@ const main = async () => {
|
|
|
84
90
|
// 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
|
|
85
91
|
// wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
|
|
86
92
|
// 记忆整理者的执行体, 同样只有 mirror 模式给得出 —— 赋值见 scheduledTaskInject 旁边。
|
|
93
|
+
let stopReceipts = () => { };
|
|
87
94
|
let stewardRun;
|
|
88
95
|
let stewardWiring = {};
|
|
89
96
|
let scheduledTaskInject;
|
|
@@ -149,7 +156,7 @@ const main = async () => {
|
|
|
149
156
|
// last-response tracker enables inbound's `quote` dedup of bot self-replies,
|
|
150
157
|
// and its chat-gate drops header-only pushes (empty messages) daemon-wide.
|
|
151
158
|
// 微信出站不走企微长连接, 不必等它连上。
|
|
152
|
-
installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c));
|
|
159
|
+
installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c), (context) => responseFormatPolicy(cfg, context, (target) => chatUrlFor(cfg.daemon, target)));
|
|
153
160
|
const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
|
|
154
161
|
// 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
|
|
155
162
|
// (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
|
|
@@ -170,6 +177,7 @@ const main = async () => {
|
|
|
170
177
|
const known = (t) => bridge.chatTargets(t).includes(t);
|
|
171
178
|
// 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
|
|
172
179
|
bindTagLinker({
|
|
180
|
+
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
181
|
urlOf: (t) => chatUrlFor(cfg.daemon, t),
|
|
174
182
|
resolve: (name, from) => {
|
|
175
183
|
const t = wizardStore()?.byName(name)?.target;
|
|
@@ -338,12 +346,22 @@ const main = async () => {
|
|
|
338
346
|
json(res, 400, { ok: false, reason: "target required (and cfg.defaultChat empty)" });
|
|
339
347
|
return;
|
|
340
348
|
}
|
|
349
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
350
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
341
353
|
const spawnLog = log.child({ mod: "mirror", sub: "spawn-init", target });
|
|
342
354
|
const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, { cli: backendFor(cfg).name }) });
|
|
343
355
|
if (!r.ok) {
|
|
344
356
|
json(res, 500, { ok: false, reason: r.reason });
|
|
345
357
|
return;
|
|
346
358
|
}
|
|
359
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
360
|
+
if (r.tmuxPane)
|
|
361
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane]);
|
|
362
|
+
json(res, 409, { ok: false, reason: "executor reserved during spawn; binding withheld" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
347
365
|
const att = m.attach({ sessionId: r.sessionId, jsonlPath: r.jsonlPath, target, tmuxPane: r.tmuxPane, tmuxSession: r.tmuxSession });
|
|
348
366
|
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
367
|
});
|
|
@@ -394,6 +412,10 @@ const main = async () => {
|
|
|
394
412
|
json(res, 400, { ok: false, reason: "target required (and cfg.defaultChat empty)" });
|
|
395
413
|
return;
|
|
396
414
|
}
|
|
415
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
416
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
397
419
|
const sessions = await scanClaudeSessions();
|
|
398
420
|
// 已绑在别的机器人聊天上的会话与不存在同形。
|
|
399
421
|
const hit = sessions.find((s) => s.sessionId === sessionId && ((t) => !t || sameBot(target, t))(m.targetForSession(s.sessionId)));
|
|
@@ -401,6 +423,10 @@ const main = async () => {
|
|
|
401
423
|
json(res, 404, { ok: false, reason: `session ${sessionId} not found among live tmux sessions` });
|
|
402
424
|
return;
|
|
403
425
|
}
|
|
426
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name) || !planStore.allowExternal(m.targetForSession(hit.sessionId))) {
|
|
427
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
404
430
|
const att = m.attach({ sessionId: hit.sessionId, jsonlPath: hit.jsonlPath, target, tmuxPane: hit.tmuxPane, tmuxSession: hit.tmuxSession, cwd: hit.cwd });
|
|
405
431
|
json(res, att.ok ? 200 : 500, att.ok
|
|
406
432
|
? { ok: true, sessionId: hit.sessionId, label: hit.label, target, cwd: hit.cwd, tmuxSession: hit.tmuxSession }
|
|
@@ -458,6 +484,10 @@ const main = async () => {
|
|
|
458
484
|
return;
|
|
459
485
|
}
|
|
460
486
|
const { target, slot: tag } = slotR;
|
|
487
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name ?? tag)) {
|
|
488
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
461
491
|
const existed = !!wizards.get(target);
|
|
462
492
|
const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
|
|
463
493
|
const model = (body.model ?? "").toString().trim();
|
|
@@ -465,7 +495,7 @@ const main = async () => {
|
|
|
465
495
|
const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
466
496
|
log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
|
|
467
497
|
// silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
|
|
468
|
-
const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
|
|
498
|
+
const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) });
|
|
469
499
|
if (!r.ok && !existed)
|
|
470
500
|
wizards.drop(target);
|
|
471
501
|
if (r.ok)
|
|
@@ -503,7 +533,12 @@ const main = async () => {
|
|
|
503
533
|
json(res, 400, { ok: false, reason: "text required" });
|
|
504
534
|
return;
|
|
505
535
|
}
|
|
506
|
-
const
|
|
536
|
+
const guard = () => planStore.allowExternal(target, wizards.get(target)?.name);
|
|
537
|
+
if (!guard()) {
|
|
538
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const r = await m.injectText(target, text, undefined, { guard });
|
|
507
542
|
json(res, r.ok ? 200 : 500, r);
|
|
508
543
|
});
|
|
509
544
|
// One-shot project switch: bind the chat-wide cwd AND apply it right here
|
|
@@ -521,6 +556,10 @@ const main = async () => {
|
|
|
521
556
|
json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
|
|
522
557
|
return;
|
|
523
558
|
}
|
|
559
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
560
|
+
json(res, 409, { ok: false, reason: "workflow plan fixes executor cwd" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
524
563
|
// keep = 人回答了「不用换, 就用现在这个」。只落一笔确认, 不碰 pane —— 把
|
|
525
564
|
// 「保持现状」也走成 setPendingCwd + newSession 的话, 代价是白杀一个会话。
|
|
526
565
|
if (body.keep === true) {
|
|
@@ -540,7 +579,7 @@ const main = async () => {
|
|
|
540
579
|
// Same shape as inbound's /new: windowName = tag for tagged sessions,
|
|
541
580
|
// principal for the default one. No explicit cwd — newSession reads the
|
|
542
581
|
// pendingCwd just queued and clears it once applied.
|
|
543
|
-
const r = await m.newSession(target, tagOfKey(target) || target, undefined, {});
|
|
582
|
+
const r = await m.newSession(target, tagOfKey(target) || target, undefined, { guard: () => planStore.allowExternal(target, wizards.get(target)?.name) });
|
|
544
583
|
json(res, r.ok ? 200 : 500, r.ok
|
|
545
584
|
? { ok: true, target, sessionId: r.sessionId, cwd: r.cwd }
|
|
546
585
|
: { ok: false, reason: r.reason, target, runningCwd: set.runningCwd, pendingCwd: set.pendingCwd });
|
|
@@ -716,15 +755,74 @@ const main = async () => {
|
|
|
716
755
|
return id.startsWith("human:") ? id.slice("human:".length) : "";
|
|
717
756
|
};
|
|
718
757
|
// 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
|
|
719
|
-
const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
|
|
758
|
+
const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc, { strict: true }));
|
|
759
|
+
const workflow = createWorkflowNodes(`${cfg.wrc.mirror.receiptsFile}.nodes.json`);
|
|
760
|
+
const planStore = loadWorkflowPlans(`${cfg.wrc.mirror.receiptsFile}.plans.json`, workflow.prereq);
|
|
761
|
+
const pendingBirthJobs = new Map();
|
|
762
|
+
let advancePlans = () => { };
|
|
763
|
+
// Lifecycle continuations carry the same live attempt, never a new task.
|
|
764
|
+
// Human inbound remains an explicit intervention; agent handoff/receipts
|
|
765
|
+
// must not revive canceled or unconfirmed planned executions.
|
|
766
|
+
const answers = createAnswerTransfers(loadJsonMap(join(cfg.daemon.stateDir, "answer-transfers.json"), undefined, { strict: true }));
|
|
767
|
+
m.setAnswerSilencer(answers.source);
|
|
768
|
+
const answerSends = new Map();
|
|
769
|
+
const deliverTransferredAnswer = (id, out) => {
|
|
770
|
+
const pending = answerSends.get(id);
|
|
771
|
+
if (pending)
|
|
772
|
+
return pending;
|
|
773
|
+
const p = (async () => {
|
|
774
|
+
m.stopAnswerProgress(id);
|
|
775
|
+
const r = answers.outcome(id, out);
|
|
776
|
+
if (!r)
|
|
777
|
+
return true;
|
|
778
|
+
if (!answers.delivering(id))
|
|
779
|
+
return false;
|
|
780
|
+
try {
|
|
781
|
+
await m.deliverAnswer(id, r.target, r.sourceTurn, r.channel, r.prefix, r.outcome.body, !["done", "need"].includes(r.outcome.status));
|
|
782
|
+
answers.delivered(id);
|
|
783
|
+
recordPost({ target: r.target, channel: r.channel, body: r.outcome.body, ...(r.tell.asker ? { asker: r.tell.asker } : {}) });
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
catch (e) {
|
|
787
|
+
answers.uncertain(id);
|
|
788
|
+
log.error({ id, err: e.message }, "transferred answer delivery acknowledgement uncertain; automatic retry withheld to prevent duplicate answers");
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
})().finally(() => answerSends.delete(id));
|
|
792
|
+
answerSends.set(id, p);
|
|
793
|
+
return p;
|
|
794
|
+
};
|
|
795
|
+
const planContinuationAllowed = (target) => {
|
|
796
|
+
const r = planStore.reservation(target, wizards.get(target)?.name);
|
|
797
|
+
if (!r)
|
|
798
|
+
return planStore.healthy();
|
|
799
|
+
const n = r.nodes.find((n) => n.to === target);
|
|
800
|
+
const a = n && workflow.byTurn(r.owner, n.turn);
|
|
801
|
+
return !!n && !!a && planStore.healthy() && r.state === "active" && n.phase === "observing"
|
|
802
|
+
&& !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
|
|
803
|
+
&& (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");
|
|
804
|
+
};
|
|
720
805
|
const receipts = createReceipts({
|
|
806
|
+
deliverAnswer: deliverTransferredAnswer,
|
|
807
|
+
workflowPrereq: workflow.prereq,
|
|
721
808
|
idleNow: m.idleNow,
|
|
722
|
-
onOutcome: (s, status, bodyText, bySender) =>
|
|
809
|
+
onOutcome: (s, status, bodyText, bySender) => {
|
|
810
|
+
// Pin transferred-answer terminal state synchronously with the receipt
|
|
811
|
+
// outcome; delivery may still be waiting for the target to become idle.
|
|
812
|
+
if (s.answerTransfer) {
|
|
813
|
+
answers.outcome(s.answerTransfer, { status, body: bodyText });
|
|
814
|
+
m.stopAnswerProgress(s.answerTransfer);
|
|
815
|
+
}
|
|
816
|
+
workflow.outcome(s.from, s.turn ?? "", status, bodyText);
|
|
817
|
+
advancePlans();
|
|
818
|
+
ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender);
|
|
819
|
+
},
|
|
723
820
|
jobTally: (id) => jobs.tally(id),
|
|
724
821
|
parkedNow: m.parkedNow,
|
|
725
822
|
settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
|
|
726
823
|
accept: (id, body) => { const j = jobs.get(id); return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined; },
|
|
727
|
-
rebound: async (x, why) => (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
|
|
824
|
+
rebound: async (x, why) => workflow.byTurn(x.from, x.turn) || !planStore.allowExternal(x.to, wizards.get(x.to)?.name) ? false : (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
|
|
825
|
+
guard: () => planStore.allowExternal(x.to, wizards.get(x.to)?.name),
|
|
728
826
|
from: { kind: "peer", from: x.from, turn: x.turn, ...(x.asker ? { asker: x.asker } : {}) },
|
|
729
827
|
channel: x.channel,
|
|
730
828
|
envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true, asker: x.asker }),
|
|
@@ -733,18 +831,20 @@ const main = async () => {
|
|
|
733
831
|
paneLive: m.paneLive,
|
|
734
832
|
nameOf: displayName,
|
|
735
833
|
log,
|
|
736
|
-
store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
|
|
737
|
-
holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile),
|
|
834
|
+
store: loadJsonMap(cfg.wrc.mirror.receiptsFile, undefined, { strict: true }),
|
|
835
|
+
holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile, undefined, { strict: true }),
|
|
738
836
|
// 放行停放着的 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 } : {}) });
|
|
837
|
+
release: async (h, seen, alive) => {
|
|
838
|
+
const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen, h.from), { turn: h.turn, channel: h.channel, after: h.after, seen: h.seen, alive, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
|
|
741
839
|
return r.status === 200 ? { ok: true } : { ok: false, reason: String(r.body.reason ?? `HTTP ${r.status}`) };
|
|
742
840
|
},
|
|
743
841
|
handingOff,
|
|
744
842
|
handedOff,
|
|
745
843
|
replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
|
|
746
844
|
lastWords: m.lastReply,
|
|
747
|
-
deliver: (to, bodyText, meta) => m.injectText(to, clipMiddle(bodyText), undefined, {
|
|
845
|
+
deliver: (to, bodyText, meta, alive) => m.injectText(to, clipMiddle(bodyText), undefined, {
|
|
846
|
+
requireIdle: true,
|
|
847
|
+
guard: () => (!alive || alive()) && planContinuationAllowed(to),
|
|
748
848
|
// receipt:true 是「这一轮不该再生回执」的记录 —— 回环在结构上就不成立:
|
|
749
849
|
// 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
|
|
750
850
|
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 } : {}) },
|
|
@@ -755,9 +855,10 @@ const main = async () => {
|
|
|
755
855
|
...(meta.asker ? { asker: meta.asker } : {}),
|
|
756
856
|
...(meta.pending ? { pending: meta.pending } : {}),
|
|
757
857
|
...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
|
|
758
|
-
}),
|
|
858
|
+
}) + (meta.eventId ? renderReminder({ wezard: "receipt-delivery", event: meta.eventId }, []) : ""),
|
|
759
859
|
}),
|
|
760
860
|
});
|
|
861
|
+
stopReceipts = receipts.stop;
|
|
761
862
|
const turnsLine = (xs, target) => renderInFlight(xs, target, displayName);
|
|
762
863
|
// reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
|
|
763
864
|
// transcript, 退回按时刻取就可能把上一件事的结论当回执。
|
|
@@ -775,9 +876,13 @@ const main = async () => {
|
|
|
775
876
|
// 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
|
|
776
877
|
inject: async (t, text, owe, channel) => {
|
|
777
878
|
const [first, ...rest] = owe;
|
|
879
|
+
if (!planContinuationAllowed(t))
|
|
880
|
+
return { ok: false, reason: "workflow continuation withdrawn" };
|
|
881
|
+
if (!first && planStore.reservation(t, wizards.get(t)?.name))
|
|
882
|
+
return { ok: false, reason: "workflow handoff requires the original attempt" };
|
|
778
883
|
if (!first)
|
|
779
|
-
return m.injectText(t, text, undefined, { channel });
|
|
780
|
-
const r = await m.injectText(t, text, undefined, carryOpts(first));
|
|
884
|
+
return m.injectText(t, text, undefined, { channel, guard: () => planContinuationAllowed(t) });
|
|
885
|
+
const r = await m.injectText(t, text, undefined, { ...carryOpts(first), guard: () => planContinuationAllowed(t) });
|
|
781
886
|
if (r.ok && rest.length)
|
|
782
887
|
void carryRest(t, rest);
|
|
783
888
|
return r;
|
|
@@ -817,7 +922,7 @@ const main = async () => {
|
|
|
817
922
|
for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
|
|
818
923
|
await sleep(1000);
|
|
819
924
|
await m.untilIdle(t, 3600_000);
|
|
820
|
-
const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, carryOpts(o));
|
|
925
|
+
const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, { ...carryOpts(o), guard: () => planContinuationAllowed(t) });
|
|
821
926
|
log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
|
|
822
927
|
}
|
|
823
928
|
}
|
|
@@ -854,6 +959,17 @@ const main = async () => {
|
|
|
854
959
|
const p = m.sessionInfo(self)?.jsonlPath;
|
|
855
960
|
return p ? openingTurn(expandHome(p), pingSigs) : undefined;
|
|
856
961
|
};
|
|
962
|
+
const lostAnswer = (self) => {
|
|
963
|
+
return answerSourceLost(answers.all(), self, m.answerContext(self)?.turn, openingOfSelf(self)?.ms);
|
|
964
|
+
};
|
|
965
|
+
const terminalAnswerTurn = (self) => {
|
|
966
|
+
const turn = openingOfSelf(self)?.env?.turn;
|
|
967
|
+
return turn ? answers.terminalTarget(self, turn) : undefined;
|
|
968
|
+
};
|
|
969
|
+
const controlSnapshot = (self) => ({ opening: openingOfSelf(self)?.ms, turn: m.answerContext(self)?.turn });
|
|
970
|
+
const controlValid = (self, snapshot) => openingOfSelf(self)?.ms === snapshot.opening && m.answerContext(self)?.turn === snapshot.turn && !lostAnswer(self) && !terminalAnswerTurn(self);
|
|
971
|
+
const hasAnswerChildren = (self, k) => !!k && receipts.states().some((s) => s.from === self && !!s.k && kAttr(s.k) === kAttr(k));
|
|
972
|
+
m.setAnswerSilencer((target, turn) => answers.source(target, turn) || lostAnswer(target));
|
|
857
973
|
const parentKOf = (self) => {
|
|
858
974
|
const opening = openingOfSelf(self);
|
|
859
975
|
// 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
|
|
@@ -873,7 +989,34 @@ const main = async () => {
|
|
|
873
989
|
// A private dispatch keeps its human origin even though its output channel is empty.
|
|
874
990
|
bindCardChannel((key) => askerOf(key)?.chat || m.currentChannel(key));
|
|
875
991
|
// 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
|
|
876
|
-
void m.restored.then(() => {
|
|
992
|
+
void m.restored.then(() => {
|
|
993
|
+
handoffs.resume();
|
|
994
|
+
answers.all().filter((r) => r.state !== "delivered").forEach((r) => {
|
|
995
|
+
const action = answerRecoveryAction(r);
|
|
996
|
+
if (action === "cancel-before-send") {
|
|
997
|
+
answers.activate(r.id);
|
|
998
|
+
void deliverTransferredAnswer(r.id, { status: "canceled", body: "daemon 重启时回答移交仍在投递,结果不确定;本轮已收止,请重新发送问题。" });
|
|
999
|
+
}
|
|
1000
|
+
else if (action === "ambiguous") {
|
|
1001
|
+
answers.uncertain(r.id);
|
|
1002
|
+
log.error({ id: r.id }, "answer delivery may have reached the chat before restart; automatic retry withheld, user may resend if absent");
|
|
1003
|
+
}
|
|
1004
|
+
else if (action === "deliver-outcome" && r.outcome)
|
|
1005
|
+
void deliverTransferredAnswer(r.id, r.outcome);
|
|
1006
|
+
else if (action === "watch" && !receipts.watching(r.tell))
|
|
1007
|
+
receipts.register(r.tell);
|
|
1008
|
+
});
|
|
1009
|
+
// Confirmed injections may outlive the legacy receipt-store write. Rebuild watching,
|
|
1010
|
+
// using the original turn and intent; uncertain injections remain manual reconciliation.
|
|
1011
|
+
workflow.all().filter((a) => a.tell && ["running", "needs-input", "errored"].includes(a.execution))
|
|
1012
|
+
.forEach((a) => { try {
|
|
1013
|
+
recoverWorkflow(a);
|
|
1014
|
+
}
|
|
1015
|
+
catch (e) {
|
|
1016
|
+
log.error({ err: e.message, attemptId: a.id }, "workflow recovery accounting failed; watcher not restored");
|
|
1017
|
+
} });
|
|
1018
|
+
receipts.resume();
|
|
1019
|
+
});
|
|
877
1020
|
// 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
|
|
878
1021
|
// 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
|
|
879
1022
|
void m.restored.then(() => startIdleNudge({
|
|
@@ -883,11 +1026,14 @@ const main = async () => {
|
|
|
883
1026
|
jobOpen: (id) => jobs.get(id)?.status === "open",
|
|
884
1027
|
policy: () => ({ afterMs: cfg.wrc.mirror.idleNudge.afterMin * 60_000, max: cfg.wrc.mirror.idleNudge.max }),
|
|
885
1028
|
ask: async ({ from, to, turn, text }) => {
|
|
1029
|
+
if (workflow.byTurn(from, turn) || !planStore.allowExternal(to, wizards.get(to)?.name))
|
|
1030
|
+
return false;
|
|
886
1031
|
const x = receipts.reask(from, to, turn);
|
|
887
1032
|
if (!x)
|
|
888
1033
|
return false;
|
|
889
1034
|
ledger.touch(from, turn);
|
|
890
1035
|
return (await m.injectText(to, text, undefined, {
|
|
1036
|
+
guard: () => planStore.allowExternal(to, wizards.get(to)?.name),
|
|
891
1037
|
from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
|
|
892
1038
|
channel: x.channel,
|
|
893
1039
|
envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true, asker: x.asker }),
|
|
@@ -902,7 +1048,13 @@ const main = async () => {
|
|
|
902
1048
|
/** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
|
|
903
1049
|
* wizard 收到一行名册变动; 返回是否腾出了名字。 */
|
|
904
1050
|
const displace = async (name, keep) => {
|
|
905
|
-
|
|
1051
|
+
if (!planStore.allowExternal(undefined, name))
|
|
1052
|
+
return false;
|
|
1053
|
+
const gone = await evictStaleName(wizards, name, keep, { lastActivity: m.lastActivity, retire: async (target) => {
|
|
1054
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name))
|
|
1055
|
+
throw new Error("workflow plan reserves this identity; eviction withheld");
|
|
1056
|
+
return m.killPane(target);
|
|
1057
|
+
} });
|
|
906
1058
|
if (!gone)
|
|
907
1059
|
return false;
|
|
908
1060
|
const slot = !!tagOfKey(gone.target);
|
|
@@ -916,14 +1068,20 @@ const main = async () => {
|
|
|
916
1068
|
* - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
|
|
917
1069
|
* - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
|
|
918
1070
|
* 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
|
|
919
|
-
const claimSlot = async (base, want, fallback, keep = []) => {
|
|
1071
|
+
const claimSlot = async (base, want, fallback, keep = [], allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, target ? wizards.get(target)?.name : undefined)) => {
|
|
920
1072
|
const asked = normalizeTag(want);
|
|
1073
|
+
const denied = { ok: false, status: 409, body: { ok: false, reason: "workflow plan reserves this executor" } };
|
|
1074
|
+
const candidate = asked || normalizeTag(fallback) || "wizard";
|
|
1075
|
+
if (!allowed(keyOf(base, candidate), candidate))
|
|
1076
|
+
return denied;
|
|
921
1077
|
const clash = asked ? wizards.byName(asked) : undefined;
|
|
922
1078
|
// 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
|
|
923
1079
|
// 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
|
|
924
1080
|
if (clash && !sameBot(base, clash.target)) {
|
|
925
1081
|
return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
|
|
926
1082
|
}
|
|
1083
|
+
if (clash && !allowed(clash.target, clash.name))
|
|
1084
|
+
return denied;
|
|
927
1085
|
const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
|
|
928
1086
|
if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
|
|
929
1087
|
return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
|
|
@@ -952,12 +1110,15 @@ const main = async () => {
|
|
|
952
1110
|
},
|
|
953
1111
|
};
|
|
954
1112
|
}
|
|
1113
|
+
if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
|
|
1114
|
+
return denied;
|
|
955
1115
|
const taken = new Set([
|
|
956
1116
|
...m.chatTargets(base).map(tagOfKey),
|
|
957
1117
|
...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
|
|
958
1118
|
].filter(Boolean));
|
|
959
1119
|
const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
|
|
960
|
-
|
|
1120
|
+
const target = keyOf(base, slot);
|
|
1121
|
+
return allowed(target, asked || slot) ? { ok: true, target, slot } : denied;
|
|
961
1122
|
};
|
|
962
1123
|
// ── 给人看的消息 ──────────────────────────────────────────────────
|
|
963
1124
|
// send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
|
|
@@ -968,7 +1129,12 @@ const main = async () => {
|
|
|
968
1129
|
// 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
|
|
969
1130
|
// 的回执轮都不拦; 管家只在自己的群里豁免。notify 与 send_file 共用这一道闸。
|
|
970
1131
|
const topOnlyMuted = (self, dests) => {
|
|
1132
|
+
if (lostAnswer(self))
|
|
1133
|
+
return { ok: false, reason: "本轮回答权和控制权已移交;由目标 wizard 对人交付" };
|
|
971
1134
|
const env = openingOfSelf(self)?.env;
|
|
1135
|
+
const transferred = env?.turn ? answers.target(self, env.turn) : undefined;
|
|
1136
|
+
if (transferred)
|
|
1137
|
+
return !transferred.outcome && transferred.state !== "delivered" ? undefined : { ok: false, reason: "本轮移交回答已经落定;迟到产出不能再次对人发送" };
|
|
972
1138
|
const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
|
|
973
1139
|
const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
|
|
974
1140
|
return muted.length && belowTop
|
|
@@ -998,7 +1164,8 @@ const main = async () => {
|
|
|
998
1164
|
json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
999
1165
|
return;
|
|
1000
1166
|
}
|
|
1001
|
-
const
|
|
1167
|
+
const activeTransfer = (() => { const t = openingOfSelf(self)?.env?.turn; return t ? answers.target(self, t) : undefined; })();
|
|
1168
|
+
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [activeTransfer?.channel ?? channelOf(self)])];
|
|
1002
1169
|
const muted = topOnlyMuted(self, dests);
|
|
1003
1170
|
if (muted) {
|
|
1004
1171
|
json(res, 409, muted);
|
|
@@ -1036,7 +1203,9 @@ const main = async () => {
|
|
|
1036
1203
|
}
|
|
1037
1204
|
ensureChatNames(self);
|
|
1038
1205
|
const ref = (b.to ?? "").trim();
|
|
1039
|
-
const
|
|
1206
|
+
const transferTurn = openingOfSelf(self)?.env?.turn;
|
|
1207
|
+
const transfer = transferTurn ? answers.target(self, transferTurn) : undefined;
|
|
1208
|
+
const dest = ref ? chatBaseOf(cfg, ref) : transfer?.channel ?? channelOf(self);
|
|
1040
1209
|
if (!dest || !sameBot(self, dest)) {
|
|
1041
1210
|
json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
1042
1211
|
return;
|
|
@@ -1200,23 +1369,65 @@ const main = async () => {
|
|
|
1200
1369
|
return [...new Set((Array.isArray(raw) ? raw : []).map((x) => String(x).replace(/[`\s]/g, "")).filter(Boolean))];
|
|
1201
1370
|
};
|
|
1202
1371
|
/** 放行时挂在信封后面的前置结论: 谁交的、件号、RESULT 与 ARTIFACT 原样 (只进对方输入框)。 */
|
|
1203
|
-
const afterNote = (turns, seen) => renderReminder({ wezard: "after" }, [
|
|
1372
|
+
const afterNote = (turns, seen, owner) => renderReminder({ wezard: "after" }, [
|
|
1204
1373
|
"这件活排在下面这些前置之后, 它们都已落定; 结论原样附上 (谁交的 · 件号):",
|
|
1205
1374
|
...turns.flatMap((t) => {
|
|
1206
1375
|
const x = seen[t];
|
|
1207
1376
|
if (!x)
|
|
1208
1377
|
return [];
|
|
1209
1378
|
const c = parseClosing(x.body, 1500);
|
|
1379
|
+
const versions = workflow.list(owner).find((a) => a.turn === t && a.acceptance === "accepted")?.files ?? [];
|
|
1210
1380
|
const clean = (v) => v.replace(/<\/?system-reminder[^>]*>/g, "");
|
|
1211
1381
|
return [
|
|
1212
1382
|
`- ${displayName(x.to)} · ${t} · ${x.status}: ${c.kind === "result" ? `RESULT: ${clean(c.text)}` : clipLine(clean(x.body), 600) || "(没有正文)"}`,
|
|
1213
|
-
...c.artifacts.map((a) =>
|
|
1383
|
+
...c.artifacts.map((a) => {
|
|
1384
|
+
const f = versions.find((f) => f.path === a.path);
|
|
1385
|
+
return f ? ` ARTIFACT: ${f.snapshot} — accepted version sha256 ${f.sha256}; original source ${f.path}` : ` ARTIFACT: ${a.path}${a.note ? ` — ${a.note}` : ""}`;
|
|
1386
|
+
}),
|
|
1387
|
+
...versions.map((f) => ` VERIFIED ARTIFACT VERSION: ${f.snapshot} · sha256 ${f.sha256} · source ${f.path}; consume this readonly snapshot, not mutable source`),
|
|
1214
1388
|
];
|
|
1215
1389
|
}),
|
|
1216
1390
|
]);
|
|
1391
|
+
// One durable key per tracked physical dispatch leg (held release reuses its reservation).
|
|
1392
|
+
const accountWorkflow = (a, recovery = false) => {
|
|
1393
|
+
if (!a.dispatches?.length)
|
|
1394
|
+
throw new Error("historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry (no blind budget import)");
|
|
1395
|
+
for (const d of a.dispatches) {
|
|
1396
|
+
jobs.account(a.job, d.key, { target: a.to, task: d.continuation ? "" : d.text, spawned: false, ...(d.role ? { role: d.role } : {}) }, d.kind, d.free, recovery, !!d.free && !!d.continuation);
|
|
1397
|
+
const t = d.tell ?? a.tell;
|
|
1398
|
+
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 } : {}) });
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
const recoverWorkflow = (a) => {
|
|
1402
|
+
if (a.canceled || jobs.get(a.job)?.status !== "open")
|
|
1403
|
+
return;
|
|
1404
|
+
accountWorkflow(a, true);
|
|
1405
|
+
if (a.tell && !receipts.watching(a.tell))
|
|
1406
|
+
receipts.register(a.tell, true, a.status && a.body ? { status: a.status, body: a.body } : undefined);
|
|
1407
|
+
};
|
|
1408
|
+
const cancelPeerWork = (target, self, only = () => true) => {
|
|
1409
|
+
const keys = new Set([
|
|
1410
|
+
...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}`),
|
|
1411
|
+
...receipts.states().filter((s) => s.to === target && only(s)).map((s) => `${s.from}\0${s.turn}`),
|
|
1412
|
+
]);
|
|
1413
|
+
workflow.cancel(target, undefined, undefined, undefined, only);
|
|
1414
|
+
receipts.cancel(target, self, only);
|
|
1415
|
+
return keys.size;
|
|
1416
|
+
};
|
|
1217
1417
|
/** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
|
|
1218
1418
|
* 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
|
|
1219
|
-
const
|
|
1419
|
+
const peerDispatching = new Map();
|
|
1420
|
+
const tellPeerAsInner = async (self, body, extra = "", held, planned, actor) => {
|
|
1421
|
+
const authority = actor?.authority ?? (!held && !planned ? controlSnapshot(self) : undefined);
|
|
1422
|
+
if (!held && !planned && lostAnswer(self))
|
|
1423
|
+
return { status: 409, body: { ok: false, reason: "this turn transferred answer and control; stop dispatching and conclude silently" } };
|
|
1424
|
+
if (!held && !planned && terminalAnswerTurn(self))
|
|
1425
|
+
return { status: 409, body: { ok: false, reason: "transferred answer is terminal; late control actions refused" } };
|
|
1426
|
+
const answer = body.answer;
|
|
1427
|
+
if (answer !== undefined && answer !== "transfer")
|
|
1428
|
+
return { status: 400, body: { ok: false, reason: "answer must be transfer when supplied" } };
|
|
1429
|
+
if (held?.alive && !held.alive())
|
|
1430
|
+
return { status: 409, body: { ok: false, reason: "held dispatch withdrawn" } };
|
|
1220
1431
|
const text = (body.text ?? "").toString();
|
|
1221
1432
|
if (!text.trim()) {
|
|
1222
1433
|
return { status: 400, body: { ok: false, reason: "text required" } };
|
|
@@ -1231,12 +1442,12 @@ const main = async () => {
|
|
|
1231
1442
|
}
|
|
1232
1443
|
const { target, foreign } = r;
|
|
1233
1444
|
// 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
|
|
1234
|
-
const re = (body.re ?? "").toString()
|
|
1445
|
+
const re = canonicalTurn((body.re ?? "").toString());
|
|
1235
1446
|
// 放行一件停放着的 (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);
|
|
1447
|
+
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
1448
|
// 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);
|
|
1449
|
+
const asker = planned ? planned.origin.asker : held ? held.asker : actor?.asker ?? (turn.legs > 1 ? turn.asker : askerOf(self));
|
|
1450
|
+
const k = planned ? planned.origin.k : held ? held.k : actor?.k ?? (body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self));
|
|
1240
1451
|
// 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
|
|
1241
1452
|
// 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
|
|
1242
1453
|
// 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
|
|
@@ -1249,14 +1460,56 @@ const main = async () => {
|
|
|
1249
1460
|
// 没带 re 的普通 task / ask / dispatch 一律不并进根单。
|
|
1250
1461
|
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
1462
|
const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
|
|
1463
|
+
const answerContext = answer === "transfer" ? actor?.answerContext ?? m.answerContext(self) : undefined;
|
|
1464
|
+
const answerSourceOpening = answerContext ? actor?.authority.opening ?? openingOfSelf(self)?.ms ?? 0 : 0;
|
|
1465
|
+
const actorStillValid = () => {
|
|
1466
|
+
if (!authority)
|
|
1467
|
+
return true; // held/planned work carries its captured permit.
|
|
1468
|
+
if (openingOfSelf(self)?.ms !== authority.opening || m.answerContext(self)?.turn !== authority.turn || terminalAnswerTurn(self))
|
|
1469
|
+
return false;
|
|
1470
|
+
if (answer === "transfer")
|
|
1471
|
+
return !answers.all().some((r) => r.source === self && r.sourceTurn === authority.turn && r.id !== turn.turn);
|
|
1472
|
+
return !lostAnswer(self);
|
|
1473
|
+
};
|
|
1474
|
+
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))) {
|
|
1475
|
+
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" } };
|
|
1476
|
+
}
|
|
1252
1477
|
if (jobId) {
|
|
1253
1478
|
const jc = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
|
|
1254
1479
|
if (!jc.ok) {
|
|
1255
1480
|
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
1256
1481
|
}
|
|
1257
1482
|
}
|
|
1483
|
+
const trackedRe = workflow.byTurn(self, turn.turn) ?? (re ? workflow.byTurn(self, re) : undefined);
|
|
1484
|
+
if (trackedRe && !trackedRe.accountingVersion && trackedRe.execution !== "terminal")
|
|
1485
|
+
return { status: 409, body: { ok: false, attemptId: trackedRe.id, reason: "historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry" } };
|
|
1486
|
+
const rawNode = body.node;
|
|
1487
|
+
if (rawNode !== undefined && (typeof rawNode !== "string" || !rawNode.trim()))
|
|
1488
|
+
return { status: 400, body: { ok: false, reason: "node must not be blank" } };
|
|
1489
|
+
if (trackedRe && rawNode !== undefined && rawNode.trim() !== trackedRe.node)
|
|
1490
|
+
return { status: 409, body: { ok: false, reason: "tracked turn cannot change node" } };
|
|
1491
|
+
const node = rawNode?.trim() ?? trackedRe?.node ?? "";
|
|
1492
|
+
const planAllowed = () => planStore.allowTell(self, jobId, node, target, re, planned, wizards.get(target)?.name);
|
|
1493
|
+
if (!planAllowed())
|
|
1494
|
+
return { status: 409, body: { ok: false, reason: "dedicated plan job/executor only allows runner dispatch or exact owner re" } };
|
|
1495
|
+
if (node && jobId && workflow.list(self, jobId).some((a) => !a.accountingVersion))
|
|
1496
|
+
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)" } };
|
|
1497
|
+
if (node && (!jobId || jobs.get(jobId)?.owner !== self || kind !== "task" || body.receipt === false)) {
|
|
1498
|
+
return { status: 400, body: { ok: false, reason: "node requires owner job, task, and receipt" } };
|
|
1499
|
+
}
|
|
1500
|
+
if (re && trackedRe && (turn.reUnknown || trackedRe.to !== target))
|
|
1501
|
+
return { status: 409, body: { ok: false, reason: "tracked re cannot start a new attempt; use node after reconciliation" } };
|
|
1502
|
+
// A released hold can survive a crash after confirmed injection but before dropHold.
|
|
1503
|
+
// Recover the watcher from the durable intent; never paste that attempt again.
|
|
1504
|
+
const heldAttempt = held && node ? workflow.byTurn(self, held.turn) : undefined;
|
|
1505
|
+
if (heldAttempt?.tell && ["running", "needs-input", "errored"].includes(heldAttempt.execution)) {
|
|
1506
|
+
recoverWorkflow(heldAttempt);
|
|
1507
|
+
return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id, turn: held.turn } };
|
|
1508
|
+
}
|
|
1509
|
+
if (heldAttempt?.execution === "terminal" && heldAttempt.status === "done")
|
|
1510
|
+
return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id } };
|
|
1258
1511
|
// 派活方向闸 (见 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 });
|
|
1512
|
+
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
1513
|
if (dr) {
|
|
1261
1514
|
return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
|
|
1262
1515
|
}
|
|
@@ -1283,7 +1536,7 @@ const main = async () => {
|
|
|
1283
1536
|
const parkIt = prereqs.some((p) => p.state === "pending");
|
|
1284
1537
|
// 前置都已落定: 不停放, 结论附在信封后面照常投。
|
|
1285
1538
|
if (afters.length && !parkIt)
|
|
1286
|
-
extra += afterNote(afters, Object.fromEntries(afters.map((t, i) => [t, prereqs[i].seen])));
|
|
1539
|
+
extra += afterNote(afters, Object.fromEntries(afters.map((t, i) => [t, prereqs[i].seen])), self);
|
|
1287
1540
|
// 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
|
|
1288
1541
|
// 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
|
|
1289
1542
|
// 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
|
|
@@ -1303,7 +1556,11 @@ const main = async () => {
|
|
|
1303
1556
|
}
|
|
1304
1557
|
// 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
|
|
1305
1558
|
// 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
|
|
1559
|
+
if (answer === "transfer" && handingOff(target))
|
|
1560
|
+
return { status: 409, body: { ok: false, reason: "answer target is handing off; source retains answer and control" } };
|
|
1306
1561
|
await handedOff(target);
|
|
1562
|
+
if (!actorStillValid())
|
|
1563
|
+
return { status: 409, body: { ok: false, reason: "caller turn/control changed while waiting; dispatch refused" } };
|
|
1307
1564
|
// 投递策略。对方闲着三者一样 —— 立刻投; 只在它正忙时分岔:
|
|
1308
1565
|
// normal (默认) 等它这一轮结束再投 —— 派新活的常态: 两个 wizard 同时找第三个时,
|
|
1309
1566
|
// 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里不是边角情况而是常态;
|
|
@@ -1337,23 +1594,53 @@ const main = async () => {
|
|
|
1337
1594
|
}
|
|
1338
1595
|
// 前置还没落定: 不注入, 登记一份停放 (见 receipts.Hold) —— 件号、频道、父 k、链头定在这一刻,
|
|
1339
1596
|
// 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
|
|
1597
|
+
let attemptId;
|
|
1598
|
+
const beginNode = (queued) => {
|
|
1599
|
+
if (!planAllowed() || (planned && !planned.alive()))
|
|
1600
|
+
throw new Error("plan dispatch canceled or job reserved");
|
|
1601
|
+
return node ? workflow.begin(self, jobId, node, turn.turn, target, queued).id : undefined;
|
|
1602
|
+
};
|
|
1340
1603
|
if (parkIt) {
|
|
1604
|
+
try {
|
|
1605
|
+
attemptId = beginNode(true);
|
|
1606
|
+
}
|
|
1607
|
+
catch (e) {
|
|
1608
|
+
return { status: 409, body: { ok: false, reason: e.message } };
|
|
1609
|
+
}
|
|
1341
1610
|
const bp = body;
|
|
1611
|
+
if (attemptId) {
|
|
1612
|
+
try {
|
|
1613
|
+
workflow.recordLeg(attemptId, { text, at: Date.now(), tell: { from: self, to: target, channel, job: jobId, turn: turn.turn, at: Date.now(), legs: 1, ...(k ? { k } : {}), ...(asker ? { asker } : {}) }, kind: "task", ...(roleParam(bp.role) ? { role: roleParam(bp.role) } : {}) });
|
|
1614
|
+
accountWorkflow(workflow.byTurn(self, turn.turn));
|
|
1615
|
+
}
|
|
1616
|
+
catch (e) {
|
|
1617
|
+
return { status: 409, body: { ok: false, attemptId, reason: e.message } };
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1342
1620
|
receipts.hold({
|
|
1343
1621
|
from: self, to: target, turn: turn.turn, at: Date.now(), after: afters, channel, job: jobId,
|
|
1344
1622
|
...(k ? { k } : {}), ...(asker ? { asker } : {}),
|
|
1345
1623
|
// force: 冷门控上面已过一遍, 放行时缓存又凉了也不再退回。
|
|
1346
|
-
body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
|
|
1624
|
+
body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(node ? { node } : {}), ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
|
|
1347
1625
|
});
|
|
1348
|
-
if (jobId) {
|
|
1626
|
+
if (jobId && !attemptId) {
|
|
1349
1627
|
const role = roleParam(bp.role);
|
|
1350
1628
|
jobs.attach(jobId, { target, task: text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
|
|
1351
1629
|
jobs.spend(jobId);
|
|
1352
1630
|
}
|
|
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(" ")} 之后:
|
|
1631
|
+
return { status: 200, body: { ok: true, parked: true, after: afters, turn: turn.turn, ...(attemptId ? { node, attemptId } : {}), name: peerAddress(cfg, self, target), ...(jobId ? { job: jobId } : {}), receipt: `排在 ${afters.join(" ")} 之后: 前置满足放行条件才投(tracked 要 accepted,legacy 要 done;带上结论), 任一失败这件不投、按 canceled 回给你; 不要挂在 wait_peer 上等, 接着干你自己的事` } };
|
|
1632
|
+
}
|
|
1633
|
+
try {
|
|
1634
|
+
attemptId = beginNode(true);
|
|
1354
1635
|
}
|
|
1636
|
+
catch (e) {
|
|
1637
|
+
return { status: 409, body: { ok: false, reason: e.message } };
|
|
1638
|
+
}
|
|
1639
|
+
const canceledBeforeDispatch = () => !planAllowed() || (!!planned && !planned.alive()) || (!!held?.alive && !held.alive()) || !!workflow.byTurn(self, turn.turn)?.canceled || (!!jobId && jobs.get(jobId)?.status !== "open");
|
|
1355
1640
|
// 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
|
|
1356
1641
|
const wasBusy = !(await m.idleNow(target));
|
|
1642
|
+
if (answer === "transfer" && (wasBusy || receipts.states().some((s) => s.to === target)))
|
|
1643
|
+
return { status: 409, body: { ok: false, wasBusy: true, reason: "answer target is busy or has pending work; source retains answer and control" } };
|
|
1357
1644
|
// urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
|
|
1358
1645
|
// 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
|
|
1359
1646
|
const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
|
|
@@ -1363,12 +1650,20 @@ const main = async () => {
|
|
|
1363
1650
|
if (policy === "normal" && wasBusy) {
|
|
1364
1651
|
const t0 = Date.now();
|
|
1365
1652
|
// 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
|
|
1366
|
-
const wr = await m.untilIdle(target, waitSec * 1000);
|
|
1653
|
+
const wr = await m.untilIdle(target, waitSec * 1000, { aborted: canceledBeforeDispatch });
|
|
1367
1654
|
waitedMs = Date.now() - t0;
|
|
1655
|
+
if (!actorStillValid())
|
|
1656
|
+
return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed while waiting for idle" } };
|
|
1368
1657
|
if (!wr.idle) {
|
|
1658
|
+
if (canceledBeforeDispatch())
|
|
1659
|
+
return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
|
|
1369
1660
|
return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
|
|
1370
1661
|
}
|
|
1371
1662
|
}
|
|
1663
|
+
if (canceledBeforeDispatch())
|
|
1664
|
+
return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
|
|
1665
|
+
if (!actorStillValid())
|
|
1666
|
+
return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed before target control" } };
|
|
1372
1667
|
if (policy === "urgent" && wasBusy) {
|
|
1373
1668
|
// 只按 Esc, 不走 /stop 的 teardown: 那会清掉它的注入队列 (别人排着的话一起丢)
|
|
1374
1669
|
// 并暂停 keepalive —— 这里打断它是为了马上接着干, 不是让它安静。
|
|
@@ -1377,6 +1672,8 @@ const main = async () => {
|
|
|
1377
1672
|
// 等 Esc 生效、这一轮真收住再投; 收不住也照投 —— 落进输入框排在后面, 退化成 now。
|
|
1378
1673
|
interrupted = esc.ok && (await m.untilIdle(target, 15_000)).idle;
|
|
1379
1674
|
waitedMs = Date.now() - t0;
|
|
1675
|
+
if (!actorStillValid())
|
|
1676
|
+
return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed during interrupt wait" } };
|
|
1380
1677
|
}
|
|
1381
1678
|
// 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
|
|
1382
1679
|
const at = Date.now();
|
|
@@ -1390,7 +1687,64 @@ const main = async () => {
|
|
|
1390
1687
|
// 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
|
|
1391
1688
|
const wantReceipt = kind !== "fyi" && body.receipt !== false;
|
|
1392
1689
|
const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
|
|
1690
|
+
const blocking = workflow.list(self).find((a) => a.to === target && a.turn !== turn.turn && !["queued", "terminal"].includes(a.execution));
|
|
1691
|
+
if (blocking && kind !== "fyi")
|
|
1692
|
+
return { status: 409, body: { ok: false, reason: `tracked attempt ${blocking.id} still in flight` } };
|
|
1693
|
+
if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
|
|
1694
|
+
return { status: 409, body: { ok: false, reason: "prerequisite acceptance/version changed before injection" } };
|
|
1695
|
+
let physicalExposure = false;
|
|
1696
|
+
const dispatchGuard = (phase) => {
|
|
1697
|
+
if (!actorStillValid())
|
|
1698
|
+
return false;
|
|
1699
|
+
if (answers.all().some((r) => r.target === target && answerTargetOccupied(r) && r.id !== turn.turn))
|
|
1700
|
+
return false;
|
|
1701
|
+
if (answerContext && m.answerContext(self)?.turn !== answerContext.turn)
|
|
1702
|
+
return false;
|
|
1703
|
+
if (!planAllowed() || (planned && !planned.alive()) || (held?.alive && !held.alive()))
|
|
1704
|
+
return false;
|
|
1705
|
+
if (jobId && jobs.get(jobId)?.status !== "open") {
|
|
1706
|
+
if (attemptId)
|
|
1707
|
+
workflow.cancel(target, self, jobId, turn.turn);
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
const a = workflow.byTurn(self, turn.turn);
|
|
1711
|
+
if (a?.canceled || a?.execution === "dispatch-unknown" || a?.execution === "terminal")
|
|
1712
|
+
return false;
|
|
1713
|
+
if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
|
|
1714
|
+
return false;
|
|
1715
|
+
const allowed = !phase || !attemptId || workflow.boundary(attemptId);
|
|
1716
|
+
if (allowed && phase)
|
|
1717
|
+
physicalExposure = true;
|
|
1718
|
+
return allowed;
|
|
1719
|
+
};
|
|
1720
|
+
if (!dispatchGuard())
|
|
1721
|
+
return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled or prerequisites invalidated" } };
|
|
1722
|
+
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 } : {}) };
|
|
1723
|
+
if (node) {
|
|
1724
|
+
try {
|
|
1725
|
+
attemptId = beginNode(false);
|
|
1726
|
+
workflow.intent(attemptId, trackedTell);
|
|
1727
|
+
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);
|
|
1728
|
+
accountWorkflow(workflow.byTurn(self, turn.turn));
|
|
1729
|
+
}
|
|
1730
|
+
catch (e) {
|
|
1731
|
+
return { status: 409, body: { ok: false, reason: e.message } };
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
if (answerContext && k?.kind === "chat") {
|
|
1735
|
+
try {
|
|
1736
|
+
answers.reserve({ id: turn.turn, source: self, target, sourceTurn: answerContext.turn, sourceOpening: answerSourceOpening, origin: answerTransferOrigin(self, answerContext.turn), channel: k.channel, prefix: answerContext.prefix, tell: trackedTell, state: "reserved" });
|
|
1737
|
+
}
|
|
1738
|
+
catch (e) {
|
|
1739
|
+
return { status: 409, body: { ok: false, reason: e.message } };
|
|
1740
|
+
}
|
|
1741
|
+
if (!m.prepareAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel)) {
|
|
1742
|
+
answers.rollback(turn.turn);
|
|
1743
|
+
return { status: 409, body: { ok: false, reason: "answer stream is no longer writable; source retains answer and control" } };
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1393
1746
|
const inj = await m.injectText(target, text, undefined, {
|
|
1747
|
+
guard: dispatchGuard,
|
|
1394
1748
|
from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
|
|
1395
1749
|
channel,
|
|
1396
1750
|
envelope: envelopeFor(self, channel, {
|
|
@@ -1402,15 +1756,39 @@ const main = async () => {
|
|
|
1402
1756
|
...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
|
|
1403
1757
|
...(kind !== "task" ? { act: kind } : {}),
|
|
1404
1758
|
...(wantReceipt ? {} : { quiet: true }),
|
|
1405
|
-
}) + extra,
|
|
1759
|
+
}) + extra + (answerContext ? renderReminder({ wezard: "answer-transfer" }, ["你已接手这轮人的回答权与控制权。判断为简单问题,直接答给人;原气泡内容会清空,只显示你的进度,终句覆盖气泡并由 daemon 唯一交付,不回给派活方。不要另行 notify 相同终稿;需要追问可直接以自然语言收口。派活方这轮后续产出不再对人发送。"]) : ""),
|
|
1760
|
+
}).catch((e) => {
|
|
1761
|
+
if (attemptId)
|
|
1762
|
+
workflow.dispatched(attemptId, false);
|
|
1763
|
+
if (answerContext)
|
|
1764
|
+
return { ok: false, uncertain: true, reason: e.message };
|
|
1765
|
+
throw e;
|
|
1406
1766
|
});
|
|
1767
|
+
if (answerContext) {
|
|
1768
|
+
if (inj.ok || inj.uncertain || physicalExposure) {
|
|
1769
|
+
answers.activate(turn.turn);
|
|
1770
|
+
const bubble = await m.takeAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel);
|
|
1771
|
+
receipts.register(trackedTell);
|
|
1772
|
+
if (!inj.ok)
|
|
1773
|
+
void deliverTransferredAnswer(turn.turn, { status: "canceled", body: "回答移交的投递结果不确定,本轮已收止;若未收到回答,请重新发送问题。" });
|
|
1774
|
+
if (!bubble.cleared)
|
|
1775
|
+
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 失败);不能重试移交。目标仍负责最终答复,必要时另发终稿;本轮请停止派活与公开回答。" } };
|
|
1776
|
+
return { status: 200, body: { ...inj, ok: true, answer: "transfer", control: "transfer", name: peerAddress(cfg, self, target), turn: turn.turn, bubbleCleared: bubble.cleared, receipt: "回答权已转交;可用气泡只显示目标进度与终稿;本轮请停止派活与公开回答,静默收口" } };
|
|
1777
|
+
}
|
|
1778
|
+
m.cancelAnswerPreparation(turn.turn);
|
|
1779
|
+
answers.rollback(turn.turn);
|
|
1780
|
+
}
|
|
1781
|
+
if (attemptId)
|
|
1782
|
+
workflow.dispatched(attemptId, inj.ok && !inj.uncertain);
|
|
1783
|
+
if (!dispatchGuard())
|
|
1784
|
+
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
1785
|
// 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
|
|
1408
1786
|
// 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
|
|
1409
1787
|
// 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
|
|
1410
1788
|
// 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
|
|
1411
1789
|
if (inj.ok && wantReceipt)
|
|
1412
|
-
receipts.register(
|
|
1413
|
-
if (inj.ok && wantReceipt) {
|
|
1790
|
+
receipts.register(attemptId ? workflow.byTurn(self, turn.turn).tell : trackedTell);
|
|
1791
|
+
if (inj.ok && wantReceipt && !attemptId) {
|
|
1414
1792
|
if (turn.legs > 1)
|
|
1415
1793
|
ledger.touch(self, turn.turn);
|
|
1416
1794
|
else
|
|
@@ -1418,7 +1796,7 @@ const main = async () => {
|
|
|
1418
1796
|
}
|
|
1419
1797
|
// 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
|
|
1420
1798
|
// 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
|
|
1421
|
-
if (inj.ok && jobId) {
|
|
1799
|
+
if (inj.ok && jobId && !attemptId) {
|
|
1422
1800
|
const role = roleParam(body.role);
|
|
1423
1801
|
// G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
|
|
1424
1802
|
const rj = jobs.get(jobId);
|
|
@@ -1427,7 +1805,8 @@ const main = async () => {
|
|
|
1427
1805
|
jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
|
|
1428
1806
|
}
|
|
1429
1807
|
// 停放时已记过预算, 放行不再算一次。
|
|
1430
|
-
const
|
|
1808
|
+
const trackedJob = inj.ok && attemptId ? jobs.get(jobId) : undefined;
|
|
1809
|
+
const spent = trackedJob ? { turns: trackedJob.turns ?? 0, maxTurns: trackedJob.maxTurns } : inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
|
|
1431
1810
|
if (inj.ok && isPublic)
|
|
1432
1811
|
relayPeer(self, target, text, channel);
|
|
1433
1812
|
// `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
|
|
@@ -1446,6 +1825,7 @@ const main = async () => {
|
|
|
1446
1825
|
...(waitedMs ? { waitedMs } : {}),
|
|
1447
1826
|
...(jobId ? { job: jobId } : {}),
|
|
1448
1827
|
...(inj.ok ? { turn: turn.turn } : {}),
|
|
1828
|
+
...(attemptId ? { node, attemptId, acceptance: "pending" } : {}),
|
|
1449
1829
|
...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
|
|
1450
1830
|
...(inj.ok && wake ? { wakeCost: wake } : {}),
|
|
1451
1831
|
...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
|
|
@@ -1458,6 +1838,24 @@ const main = async () => {
|
|
|
1458
1838
|
: {}),
|
|
1459
1839
|
} };
|
|
1460
1840
|
};
|
|
1841
|
+
const tellPeerAs = async (...args) => {
|
|
1842
|
+
const [self, body] = args;
|
|
1843
|
+
const transfer = body.answer === "transfer";
|
|
1844
|
+
const current = peerDispatching.get(self);
|
|
1845
|
+
if (current?.transfer || (transfer && current?.count))
|
|
1846
|
+
return { status: 409, body: { ok: false, reason: "another dispatch is in flight for this caller; source retains its current answer ownership" } };
|
|
1847
|
+
peerDispatching.set(self, { count: (current?.count ?? 0) + 1, transfer });
|
|
1848
|
+
try {
|
|
1849
|
+
return await tellPeerAsInner(...args);
|
|
1850
|
+
}
|
|
1851
|
+
finally {
|
|
1852
|
+
const n = peerDispatching.get(self);
|
|
1853
|
+
if (n && n.count > 1)
|
|
1854
|
+
peerDispatching.set(self, { count: n.count - 1, transfer: n.transfer });
|
|
1855
|
+
else
|
|
1856
|
+
peerDispatching.delete(self);
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1461
1859
|
const tellPeer = async (req, res) => {
|
|
1462
1860
|
const { self, body } = await readPeerBody(req);
|
|
1463
1861
|
if (!self) {
|
|
@@ -1467,6 +1865,58 @@ const main = async () => {
|
|
|
1467
1865
|
const r = await tellPeerAs(self, body);
|
|
1468
1866
|
json(res, r.status, r.body);
|
|
1469
1867
|
};
|
|
1868
|
+
http.register("POST /peers/inbox", async (req, res) => {
|
|
1869
|
+
const { self } = await readPeerBody(req);
|
|
1870
|
+
if (!self) {
|
|
1871
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller" });
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
json(res, 200, { ok: true, events: receipts.inbox(self) });
|
|
1875
|
+
});
|
|
1876
|
+
http.register("POST /workflow/nodes", async (req, res) => {
|
|
1877
|
+
const { self, body } = await readPeerBody(req);
|
|
1878
|
+
if (!self) {
|
|
1879
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller" });
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
const attempts = workflow.list(self, body.job);
|
|
1883
|
+
json(res, 200, { ok: true, nodes: attempts, tally: {
|
|
1884
|
+
attempts: attempts.length, nodes: new Set(attempts.map((a) => `${a.job}\0${a.node}`)).size,
|
|
1885
|
+
accepted: attempts.filter((a) => workflow.prereq(self, a.turn)?.state === "done").length,
|
|
1886
|
+
awaitingAcceptance: attempts.filter((a) => a.execution === "terminal" && a.status === "done" && a.acceptance === "pending").length,
|
|
1887
|
+
} });
|
|
1888
|
+
});
|
|
1889
|
+
http.register("POST /workflow/verdict", async (req, res) => {
|
|
1890
|
+
const { self, body } = await readPeerBody(req);
|
|
1891
|
+
if (!self) {
|
|
1892
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller" });
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const b = body;
|
|
1896
|
+
if (!["accepted", "rejected", "sent", "stopped"].includes(b.as ?? "")) {
|
|
1897
|
+
json(res, 400, { ok: false, reason: "invalid verdict" });
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
try {
|
|
1901
|
+
const old = workflow.list(self).find((a) => a.id === b.attemptId);
|
|
1902
|
+
if (!old)
|
|
1903
|
+
throw new Error("attempt not found for owner");
|
|
1904
|
+
if (b.as !== "stopped" && (jobs.get(old.job)?.status !== "open" || jobs.get(old.job)?.owner !== self))
|
|
1905
|
+
throw new Error("owner job must be open for acceptance or sent watching");
|
|
1906
|
+
if (b.as === "sent" && !old.dispatches?.length)
|
|
1907
|
+
throw new Error("historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry");
|
|
1908
|
+
const a = workflow.verdict(self, b.attemptId ?? "", b.as, b.digest, b.reason ?? "", b.files);
|
|
1909
|
+
if (b.as === "sent" && a.tell)
|
|
1910
|
+
recoverWorkflow(a);
|
|
1911
|
+
if (b.as === "stopped")
|
|
1912
|
+
receipts.cancel(a.to, self, (x) => x.from === self && x.turn === a.turn);
|
|
1913
|
+
advancePlans();
|
|
1914
|
+
json(res, 200, { ok: true, attempt: workflow.byTurn(self, a.turn) });
|
|
1915
|
+
}
|
|
1916
|
+
catch (e) {
|
|
1917
|
+
json(res, 409, { ok: false, reason: e.message });
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1470
1920
|
http.register("POST /peers/tell", tellPeer);
|
|
1471
1921
|
http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
|
|
1472
1922
|
// 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
|
|
@@ -1515,19 +1965,20 @@ const main = async () => {
|
|
|
1515
1965
|
// 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
|
|
1516
1966
|
const full = wr.idle ? m.lastReply(h.target, receipts.sentAt(self, h.target)) : "";
|
|
1517
1967
|
const already = full ? receipts.claim(self, h.target) : false;
|
|
1518
|
-
const closing = parseClosing(full);
|
|
1968
|
+
const closing = parseClosing(already === "uncertain" ? "" : full);
|
|
1519
1969
|
const result = closing.kind === "result" ? closing.text : "";
|
|
1520
1970
|
return {
|
|
1521
1971
|
name: h.address,
|
|
1522
1972
|
idle: wr.idle,
|
|
1523
1973
|
// 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
|
|
1524
|
-
...(already ? { delivered: true } : {}),
|
|
1974
|
+
...(already === true ? { delivered: true } : {}),
|
|
1975
|
+
...(already === "uncertain" ? { delivery: "uncertain", reason: "回执可能已递交,需按 receipt eventId 对账;没有自动重投或再次领取。" } : {}),
|
|
1525
1976
|
...(wr.reason ? { reason: wr.reason } : {}),
|
|
1526
1977
|
...(closing.kind === "need" ? { need: closing.text } : {}),
|
|
1527
1978
|
...(closing.artifacts.length ? { artifacts: closing.artifacts } : {}),
|
|
1528
1979
|
...(result
|
|
1529
1980
|
? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
|
|
1530
|
-
: full
|
|
1981
|
+
: already === "uncertain" ? {} : full
|
|
1531
1982
|
? { lastText: clipMiddle(full) }
|
|
1532
1983
|
: wr.idle
|
|
1533
1984
|
? { stale: true, reason: "它停下了, 但自你上次 send_peer 以来没有新回复 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" }
|
|
@@ -1544,12 +1995,14 @@ const main = async () => {
|
|
|
1544
1995
|
// /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
|
|
1545
1996
|
// 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd / keepalive 照旧沿用。
|
|
1546
1997
|
// 同 backend 时模型也沿用, 管家除外 ——
|
|
1547
|
-
//
|
|
1998
|
+
// 它换上此刻的 router 档 (newSession 里定), 改了 models.router 后交接一次就是让它生效的那一下。
|
|
1548
1999
|
const restartFresh = (target) => {
|
|
1549
2000
|
const info = m.sessionInfo(target);
|
|
1550
2001
|
const cli = cfg.wrc.defaultCli ?? primary.name;
|
|
1551
2002
|
const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
|
|
1552
|
-
|
|
2003
|
+
if (!planContinuationAllowed(target))
|
|
2004
|
+
return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn" });
|
|
2005
|
+
return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: true, warm: true, guard: () => planContinuationAllowed(target) });
|
|
1553
2006
|
};
|
|
1554
2007
|
// POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
|
|
1555
2008
|
// 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
|
|
@@ -1558,6 +2011,8 @@ const main = async () => {
|
|
|
1558
2011
|
/** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
|
|
1559
2012
|
* 自己与老路由 /wizard/handoff-self 共用。 */
|
|
1560
2013
|
const handoffSelf = (self, brief) => {
|
|
2014
|
+
if (!planContinuationAllowed(self))
|
|
2015
|
+
return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn" } };
|
|
1561
2016
|
if (brief.length < 40)
|
|
1562
2017
|
return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
|
|
1563
2018
|
const info = m.sessionInfo(self);
|
|
@@ -1608,6 +2063,10 @@ const main = async () => {
|
|
|
1608
2063
|
json(res, r.status, r.body);
|
|
1609
2064
|
return;
|
|
1610
2065
|
}
|
|
2066
|
+
if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
|
|
2067
|
+
json(res, 409, { ok: false, reason: "workflow executor may only hand off its own live attempt" });
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
1611
2070
|
const focus = (body.focus ?? "").toString().trim();
|
|
1612
2071
|
const timeoutMs = Math.min(Math.max(Number(body.timeoutSec ?? 600) || 600, 30), 7200) * 1000;
|
|
1613
2072
|
const r = await handoffs.other(target, focus, timeoutMs);
|
|
@@ -2196,6 +2655,10 @@ const main = async () => {
|
|
|
2196
2655
|
json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
|
|
2197
2656
|
return;
|
|
2198
2657
|
}
|
|
2658
|
+
if (asked && (!planStore.allowExternal(self, wizards.get(self)?.name) || !planStore.allowExternal(undefined, asked))) {
|
|
2659
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves this identity" });
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2199
2662
|
const before = settleName(wizards, chatNameOf(cfg, self), self);
|
|
2200
2663
|
const name = asked ? wizards.rename(self, asked) : before;
|
|
2201
2664
|
if (description)
|
|
@@ -2327,7 +2790,23 @@ const main = async () => {
|
|
|
2327
2790
|
json(res, 400, { ok: false, reason: "task is required" });
|
|
2328
2791
|
return;
|
|
2329
2792
|
}
|
|
2793
|
+
const dispatchOrigin = {
|
|
2794
|
+
authority: controlSnapshot(self), answerContext: m.answerContext(self), asker: askerOf(self), k: parentKOf(self),
|
|
2795
|
+
};
|
|
2330
2796
|
const lead = b.lead === true;
|
|
2797
|
+
if (b.answer !== undefined && b.answer !== "transfer") {
|
|
2798
|
+
json(res, 400, { ok: false, reason: "answer must be transfer when supplied" });
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
if (b.answer === "transfer" && lead) {
|
|
2802
|
+
json(res, 409, { ok: false, reason: "answer transfer is for simple direct answers; lead dispatch cannot transfer" });
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
const answerParent = dispatchOrigin?.k;
|
|
2806
|
+
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))) {
|
|
2807
|
+
json(res, 409, { ok: false, reason: "no exclusive active human topOnly answer available to transfer" });
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2331
2810
|
const force = b.force === true;
|
|
2332
2811
|
const to = (b.to ?? "").toString().trim();
|
|
2333
2812
|
const now = Date.now();
|
|
@@ -2335,6 +2814,10 @@ const main = async () => {
|
|
|
2335
2814
|
// 占用保护由 decide 统一判断, 留在候选表里说明为什么不能自动复用。
|
|
2336
2815
|
// 显式 to 保留人为选定; force 只影响成本门, 不绕 lead/工单/连续性保护。
|
|
2337
2816
|
const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target));
|
|
2817
|
+
if (!controlValid(self, dispatchOrigin.authority)) {
|
|
2818
|
+
json(res, 409, { ok: false, reason: "dispatch source turn/control changed while routing; original task remains with source" });
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2338
2821
|
const tierArg = (b.tier ?? "").toString().trim();
|
|
2339
2822
|
if (tierArg && !TIERS.includes(tierArg)) {
|
|
2340
2823
|
json(res, 400, { ok: false, reason: `unknown tier: ${tierArg}` });
|
|
@@ -2352,10 +2835,11 @@ const main = async () => {
|
|
|
2352
2835
|
let rootId = "";
|
|
2353
2836
|
const tell = (name, text, extra = "") => tellPeerAs(self, {
|
|
2354
2837
|
name, text, public: b.public !== false, force,
|
|
2838
|
+
...(b.answer ? { answer: b.answer } : {}),
|
|
2355
2839
|
// lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
|
|
2356
2840
|
...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
|
|
2357
2841
|
...(rootId ? { job: rootId } : {}),
|
|
2358
|
-
}, extra);
|
|
2842
|
+
}, extra, undefined, undefined, dispatchOrigin);
|
|
2359
2843
|
const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
|
|
2360
2844
|
if (b.dryRun) {
|
|
2361
2845
|
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 +2889,17 @@ const main = async () => {
|
|
|
2405
2889
|
return;
|
|
2406
2890
|
}
|
|
2407
2891
|
const tier = taskTier;
|
|
2408
|
-
const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
|
|
2892
|
+
const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
|
|
2409
2893
|
if (born.status !== 200) {
|
|
2410
2894
|
settleRoot(false);
|
|
2411
2895
|
json(res, born.status, { ...born.body, decision: "spawn", reason: d.why });
|
|
2412
2896
|
return;
|
|
2413
2897
|
}
|
|
2898
|
+
if (!controlValid(self, dispatchOrigin.authority)) {
|
|
2899
|
+
settleRoot(false);
|
|
2900
|
+
json(res, 409, { ok: false, decision: "spawn", reason: "dispatch source turn changed during spawn; task remains with original source turn" });
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2414
2903
|
// 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
|
|
2415
2904
|
const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
|
|
2416
2905
|
settleRoot(t.status === 200);
|
|
@@ -2492,193 +2981,249 @@ const main = async () => {
|
|
|
2492
2981
|
// 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
|
|
2493
2982
|
// 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
|
|
2494
2983
|
/** 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
|
-
|
|
2984
|
+
const bearAs = async (self, body, planned, capturedAuthority) => {
|
|
2985
|
+
const authority = planned ? undefined : capturedAuthority ?? controlSnapshot(self);
|
|
2986
|
+
if (authority && !controlValid(self, authority))
|
|
2987
|
+
return { status: 409, body: { ok: false, reason: "this turn no longer owns control; cannot spawn more work" } };
|
|
2988
|
+
// Legacy bootstrap can dispatch before attaching its member. An empty job
|
|
2989
|
+
// is not available for plan ownership while that birth is still in flight.
|
|
2990
|
+
const birthJob = (body.job ?? "").trim();
|
|
2991
|
+
if (birthJob)
|
|
2992
|
+
pendingBirthJobs.set(birthJob, (pendingBirthJobs.get(birthJob) ?? 0) + 1);
|
|
2993
|
+
try {
|
|
2994
|
+
const b = body;
|
|
2995
|
+
const asker = b.task?.trim() ? askerOf(self) : undefined;
|
|
2996
|
+
const k = body.chain === false ? undefined : parentKOf(self);
|
|
2997
|
+
const askedEffort = parseEffort(b.effort);
|
|
2998
|
+
if (b.effort !== undefined && !askedEffort) {
|
|
2999
|
+
return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
|
|
2509
3000
|
}
|
|
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
|
-
|
|
3001
|
+
// 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
|
|
3002
|
+
const jobId = (b.job ?? "").trim();
|
|
3003
|
+
if (jobId) {
|
|
3004
|
+
const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
|
|
3005
|
+
if (!jc.ok) {
|
|
3006
|
+
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
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());
|
|
3010
|
+
if (!planBirthAllowed() || (planned && !!b.task))
|
|
3011
|
+
return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
|
|
3012
|
+
// 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
|
|
3013
|
+
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;
|
|
3014
|
+
if (dr) {
|
|
3015
|
+
return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
|
|
3016
|
+
}
|
|
3017
|
+
// `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
|
|
3018
|
+
// 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
|
|
3019
|
+
// 逼调用方每次自己说。
|
|
3020
|
+
if (typeof b.inherit !== "boolean") {
|
|
3021
|
+
return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
|
|
3022
|
+
}
|
|
3023
|
+
const inherit = b.inherit;
|
|
3024
|
+
// detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
|
|
3025
|
+
// 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
|
|
3026
|
+
const detached = b.detached === true;
|
|
3027
|
+
if (detached && (inherit || (b.job ?? "").trim())) {
|
|
3028
|
+
return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
|
|
3029
|
+
}
|
|
3030
|
+
// 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
|
|
3031
|
+
// 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
|
|
3032
|
+
// 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
|
|
3033
|
+
const fromAddr = (b.from ?? "").toString().trim();
|
|
3034
|
+
if (fromAddr && !inherit) {
|
|
3035
|
+
return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
|
|
3036
|
+
}
|
|
3037
|
+
const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
|
|
3038
|
+
if (!fromR.ok) {
|
|
3039
|
+
return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
|
|
3040
|
+
}
|
|
3041
|
+
const source = fromR.target;
|
|
3042
|
+
const sourceInfo = m.sessionInfo(source);
|
|
3043
|
+
const forkBackend = inherit && sourceInfo && existsSync(sourceInfo.jsonlPath)
|
|
3044
|
+
&& (!b.cwd?.trim() || expandHome(b.cwd.trim()) === sourceInfo.cwd) ? sourceInfo.cli : undefined;
|
|
3045
|
+
const pick = pickTier(cfg, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, inherit, forkBackend);
|
|
3046
|
+
if (!pick.ok)
|
|
3047
|
+
return { status: 400, body: pick };
|
|
3048
|
+
const effort = pick.effort;
|
|
3049
|
+
// 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
|
|
3050
|
+
// 要的恰恰是它的上下文, 给一张白纸等于答非所问。
|
|
3051
|
+
if (source !== self && !sourceInfo?.sessionId) {
|
|
3052
|
+
return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
|
|
3053
|
+
}
|
|
3054
|
+
const alivePeers = detached ? [] : await m.peers(self);
|
|
3055
|
+
if (authority && !controlValid(self, authority))
|
|
3056
|
+
return { status: 409, body: { ok: false, reason: "caller turn/control changed while preparing spawn" } };
|
|
3057
|
+
// 预算。撞到上限由守护进程腾位, 不叫调用方去收: 最久没动的闲分身只关 pane、绑定留着
|
|
3058
|
+
// (同 maxPanes 的 reap), 再找它 `--resume` 带全上下文复活。忙的、停在审批上的、手上
|
|
3059
|
+
// 还有没落定的活的不动; 全都动不了才拒。只数**本聊天里**活着的 ——
|
|
3060
|
+
// 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
|
|
3061
|
+
const aliveKids = alivePeers.filter((pp) => pp.paneAlive && childrenOf(wizards.all(), self).some((k) => k.target === pp.target));
|
|
3062
|
+
const over = detached ? 0 : aliveKids.length - cfg.wrc.mirror.cloneMax + 1;
|
|
3063
|
+
if (over > 0) {
|
|
3064
|
+
const parked = new Set(listPending().map((p) => p.meta.sessionId).filter(Boolean));
|
|
3065
|
+
const owing = new Set(receipts.states().map((x) => x.to));
|
|
3066
|
+
const idle = aliveKids
|
|
3067
|
+
.filter((pp) => planStore.allowExternal(pp.target, wizards.get(pp.target)?.name) && !pp.busy && !owing.has(pp.target) && !parked.has(m.sessionInfo(pp.target)?.sessionId ?? ""))
|
|
3068
|
+
.sort((x, y) => x.lastActivity - y.lastActivity)
|
|
3069
|
+
.slice(0, over);
|
|
3070
|
+
const slept = (await Promise.all(idle.map((pp) => m.sleepPane(pp.target)))).filter((r) => r.ok).length;
|
|
3071
|
+
if (slept < over) {
|
|
3072
|
+
return { status: 429, body: {
|
|
3073
|
+
ok: false,
|
|
3074
|
+
reason: `你名下 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) 都在干活, 腾不出位置 —— 等其中一个交回结论再分`,
|
|
3075
|
+
clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
|
|
3076
|
+
} };
|
|
3077
|
+
}
|
|
3078
|
+
log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
|
|
3079
|
+
}
|
|
3080
|
+
const wantChat = (b.chat ?? "").toString().trim();
|
|
3081
|
+
const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
|
|
3082
|
+
if (!base) {
|
|
3083
|
+
return { status: 404, body: {
|
|
2570
3084
|
ok: false,
|
|
2571
|
-
reason:
|
|
2572
|
-
|
|
3085
|
+
reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
|
|
3086
|
+
candidates: chatNamesFor(self),
|
|
2573
3087
|
} };
|
|
2574
3088
|
}
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
3089
|
+
const askedName = String(b.name ?? b.tag ?? "");
|
|
3090
|
+
// 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
|
|
3091
|
+
const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
|
|
3092
|
+
const slotR = await claimSlot(base, askedName, fallback, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
|
|
3093
|
+
if (!slotR.ok) {
|
|
3094
|
+
return { status: slotR.status, body: slotR.body };
|
|
3095
|
+
}
|
|
3096
|
+
const { target, slot: tag } = slotR;
|
|
3097
|
+
// claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
|
|
3098
|
+
// 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
|
|
3099
|
+
const existed = !!wizards.get(target);
|
|
3100
|
+
const forkOf = inherit && source !== self ? source : undefined;
|
|
3101
|
+
// 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
|
|
3102
|
+
if (!planBirthAllowed(target, normalizeTag(askedName) || tag) || (jobId && jobs.get(jobId)?.status !== "open"))
|
|
3103
|
+
return { status: 409, body: { ok: false, reason: "plan canceled/job reserved during spawn preparation" } };
|
|
3104
|
+
wizards.upsert(target, {
|
|
3105
|
+
description: (b.description ?? "").toString().trim(),
|
|
3106
|
+
// 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
|
|
3107
|
+
parent: detached ? undefined : self,
|
|
3108
|
+
bornAt: Date.now(),
|
|
3109
|
+
clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
|
|
3110
|
+
...(forkOf ? { forkOf } : {}),
|
|
3111
|
+
// fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
|
|
3112
|
+
// 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
|
|
3113
|
+
...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
|
|
3114
|
+
tier: pick.tier,
|
|
3115
|
+
// 显式写 undefined: 复用的冷记录可能当过 lead。
|
|
3116
|
+
lead: b.lead === true ? true : undefined,
|
|
3117
|
+
// 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
|
|
3118
|
+
spawning: BOOT_ID,
|
|
3119
|
+
});
|
|
3120
|
+
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
3121
|
+
const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
|
|
3122
|
+
const task = (b.task ?? "").toString().trim();
|
|
3123
|
+
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
3124
|
+
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
3125
|
+
// 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
|
|
3126
|
+
// wait_peer 才不会把它们当成分身对这件活的答复。
|
|
3127
|
+
const taskAt = Date.now();
|
|
3128
|
+
const taskTurn = newTurn();
|
|
3129
|
+
const birthGuard = () => (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
|
|
3130
|
+
if (!birthGuard())
|
|
3131
|
+
return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
|
|
3132
|
+
const r = await m.cloneSession({
|
|
3133
|
+
guard: birthGuard,
|
|
3134
|
+
parent: source,
|
|
3135
|
+
target,
|
|
3136
|
+
windowName: name,
|
|
3137
|
+
cli: pick.cli,
|
|
3138
|
+
model: pick.model,
|
|
3139
|
+
effort,
|
|
3140
|
+
cwd: b.cwd,
|
|
3141
|
+
systemPrompt: charter,
|
|
3142
|
+
inherit,
|
|
3143
|
+
// 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
|
|
3144
|
+
// 也少一次"就位了但没事干"的空转。
|
|
3145
|
+
// 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
|
|
3146
|
+
bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
|
|
3147
|
+
...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
|
|
3148
|
+
keepalive,
|
|
3149
|
+
}).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
|
|
3150
|
+
// 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
|
|
3151
|
+
if (!r.ok) {
|
|
3152
|
+
if (existed)
|
|
3153
|
+
wizards.upsert(target, { spawning: undefined });
|
|
3154
|
+
else
|
|
3155
|
+
wizards.drop(target);
|
|
3156
|
+
return { status: 500, body: { ok: false, reason: r.reason } };
|
|
3157
|
+
}
|
|
3158
|
+
wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
3159
|
+
const kid = briefOf(self, target);
|
|
3160
|
+
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
3161
|
+
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
3162
|
+
if (!jobId) {
|
|
3163
|
+
// 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
|
|
3164
|
+
postRoster(base, [target, self], {
|
|
3165
|
+
op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
|
|
3166
|
+
...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
|
|
3167
|
+
...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
|
|
3168
|
+
}, subjectOf(wizards.get(target)));
|
|
3169
|
+
}
|
|
3170
|
+
// 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
|
|
3171
|
+
let dispatched = r.inherited && !!task;
|
|
3172
|
+
if (task && !r.inherited && birthGuard()) {
|
|
3173
|
+
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 }), guard: birthGuard });
|
|
3174
|
+
dispatched = inj.ok;
|
|
3175
|
+
}
|
|
3176
|
+
if (jobId && birthGuard()) {
|
|
3177
|
+
const role = roleParam(body.role);
|
|
3178
|
+
const from = inherit ? source : undefined;
|
|
3179
|
+
jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
|
|
3180
|
+
}
|
|
3181
|
+
if (jobId && dispatched)
|
|
3182
|
+
jobs.spend(jobId);
|
|
3183
|
+
// 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
|
|
3184
|
+
// 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
|
|
3185
|
+
if (dispatched)
|
|
3186
|
+
receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
|
|
3187
|
+
if (dispatched)
|
|
3188
|
+
ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
|
|
3189
|
+
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 } : {}) } };
|
|
2592
3190
|
}
|
|
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;
|
|
3191
|
+
finally {
|
|
3192
|
+
if (birthJob) {
|
|
3193
|
+
const remaining = (pendingBirthJobs.get(birthJob) ?? 1) - 1;
|
|
3194
|
+
if (remaining)
|
|
3195
|
+
pendingBirthJobs.set(birthJob, remaining);
|
|
3196
|
+
else
|
|
3197
|
+
pendingBirthJobs.delete(birthJob);
|
|
3198
|
+
}
|
|
2666
3199
|
}
|
|
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
3200
|
};
|
|
3201
|
+
const planRunner = createWorkflowPlanRunner(planStore, {
|
|
3202
|
+
job: jobs.get, attempt: workflow.byTurn, prereq: workflow.prereq,
|
|
3203
|
+
spawn: (run, node, state, permit) => bearAs(run.owner, {
|
|
3204
|
+
inherit: false, name: state.name, description: node.criteria || `workflow node ${node.id}`,
|
|
3205
|
+
tier: node.tier, cwd: run.plan.cwd, job: run.plan.job, keepalive: false,
|
|
3206
|
+
}, permit),
|
|
3207
|
+
tell: (run, node, state, after, permit) => tellPeerAs(run.owner, {
|
|
3208
|
+
name: state.name, text: node.task + (node.criteria ? `\n\n验收标准: ${node.criteria}` : ""),
|
|
3209
|
+
node: node.id, job: run.plan.job, ...(after.length ? { after } : {}),
|
|
3210
|
+
}, "", undefined, permit),
|
|
3211
|
+
cancel: (run) => {
|
|
3212
|
+
workflow.cancel(undefined, run.owner, run.plan.job);
|
|
3213
|
+
run.nodes.filter((n) => n.to).forEach((n) => receipts.cancel(n.to, run.owner, (x) => x.from === run.owner && x.turn === n.turn));
|
|
3214
|
+
receipts.cancelHeld(run.plan.job, run.owner);
|
|
3215
|
+
},
|
|
3216
|
+
error: (e) => log.error({ err: errText(e) }, "workflow plan advancement paused"),
|
|
3217
|
+
});
|
|
3218
|
+
advancePlans = planRunner.kick;
|
|
3219
|
+
registerWorkflowPlanRoutes(http, {
|
|
3220
|
+
read: readPeerBody, store: planStore, runner: planRunner,
|
|
3221
|
+
job: jobs.get, attempts: workflow.list,
|
|
3222
|
+
occupied: (self, job) => pendingBirthJobs.has(job) || receipts.states().some((s) => s.from === self && s.job === job),
|
|
3223
|
+
origin: (self) => { const k = parentKOf(self); return { ...(k ? { k } : {}), ...(askerOf(self) ? { asker: askerOf(self) } : {}), chain: receipts.ancestorsOf(self, k) }; },
|
|
3224
|
+
direction: (self, jobId, origin) => checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: origin.chain, nameOf: displayName }),
|
|
3225
|
+
});
|
|
3226
|
+
void m.restored.then(planRunner.start);
|
|
2682
3227
|
http.register("POST /wizard/clone", async (req, res) => {
|
|
2683
3228
|
const { self, body } = await readPeerBody(req);
|
|
2684
3229
|
if (!self) {
|
|
@@ -2702,6 +3247,11 @@ const main = async () => {
|
|
|
2702
3247
|
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
2703
3248
|
return;
|
|
2704
3249
|
}
|
|
3250
|
+
const authority = controlSnapshot(self);
|
|
3251
|
+
if (!controlValid(self, authority)) {
|
|
3252
|
+
json(res, 409, { ok: false, reason: "本轮回答权和控制权已移交或失效;不能打断或结束目标 wizard" });
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
2705
3255
|
const b = body;
|
|
2706
3256
|
const r = resolvePeer(self, addrOf(b));
|
|
2707
3257
|
// 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
|
|
@@ -2709,6 +3259,11 @@ const main = async () => {
|
|
|
2709
3259
|
const found = r.ok || r.candidates?.length ? undefined : ((w) => (w && sameBot(self, w.target) ? w : undefined))(wizards.byName(addrOf(b)));
|
|
2710
3260
|
const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
|
|
2711
3261
|
if (cold && (b.mode ?? "end") === "end") {
|
|
3262
|
+
if (!controlValid(self, authority)) {
|
|
3263
|
+
json(res, 409, { ok: false, reason: "本轮控制权已变化;拒绝结束目标 wizard" });
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
workflow.cancel(cold.target);
|
|
2712
3267
|
const detached = b.forget ? forgetWizard(cold.target) : [];
|
|
2713
3268
|
json(res, 200, { ok: true, target: cold.target, name: cold.name, mode: "end", forgotten: !!b.forget, note: "它本来就没有会话在跑", ...(detached.length ? { canceledIn: detached } : {}) });
|
|
2714
3269
|
return;
|
|
@@ -2731,19 +3286,27 @@ const main = async () => {
|
|
|
2731
3286
|
const turn = (b.turn ?? "").replace(/[`\s]/g, "");
|
|
2732
3287
|
if (!end && turn) {
|
|
2733
3288
|
const mine = receipts.pending(self, target);
|
|
2734
|
-
if (mine?.turn !== turn) {
|
|
3289
|
+
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
3290
|
json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、件号写错, 或不是你派的)` });
|
|
2736
3291
|
return;
|
|
2737
3292
|
}
|
|
2738
3293
|
// 先落 canceled 再按 Esc: 被打断那一轮的半截话不该被 watcher 当成答案投回来。
|
|
2739
3294
|
const busy = !(await m.idleNow(target));
|
|
2740
|
-
|
|
3295
|
+
if (!controlValid(self, authority)) {
|
|
3296
|
+
json(res, 409, { ok: false, reason: "等待期间本轮控制权已变化;未撤回或打断目标" });
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
const canceled = cancelPeerWork(target, self, (x) => x.from === self && x.turn === turn);
|
|
2741
3300
|
// 按 Esc 之前再认一次手上这一轮: 上面那次 await 期间它可能已经做完这件、接了别人的下一件。
|
|
2742
3301
|
const current = busy && openingAsk(target)?.turn === turn;
|
|
2743
3302
|
// 还没轮到 / 排在后面: 捎一句给它, 轮到那句话时别再做了 (notices 挂在下一条注入的尾巴上)。
|
|
2744
3303
|
if (!current)
|
|
2745
3304
|
notices.post([target], `${displayName(self)} 撤回了它派给你的 ${turn} —— 那件活不用做了; 读到它时直接略过, 不必回复。`);
|
|
2746
3305
|
// 只按 Esc, 不 teardown: teardown 会清掉它的注入队列, 别人排着的话一起丢。
|
|
3306
|
+
if (current && !controlValid(self, authority)) {
|
|
3307
|
+
json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;已登记撤回但未发送 Esc" });
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
2747
3310
|
const esc = current ? await m.interruptPane(target) : undefined;
|
|
2748
3311
|
// Esc 没按成也不回滚撤回: 这件已经不等了, 只是它手上那一轮还在跑 —— 如实说。
|
|
2749
3312
|
json(res, 200, {
|
|
@@ -2755,13 +3318,26 @@ const main = async () => {
|
|
|
2755
3318
|
}
|
|
2756
3319
|
// 不带件号的打断会停掉它手上整轮: 那一轮若是别的 wizard 派的, 先拒 —— 误伤的是别人的活。
|
|
2757
3320
|
const others = !end && !b.force ? await othersTurn(target, self) : undefined;
|
|
3321
|
+
if (!controlValid(self, authority)) {
|
|
3322
|
+
json(res, 409, { ok: false, reason: "等待期间本轮控制权已变化;未取消或打断目标" });
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
2758
3325
|
if (others) {
|
|
2759
3326
|
json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.turn ? ` (${others.turn})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (件号); 确要打断它这一轮, 加 force:true` });
|
|
2760
3327
|
return;
|
|
2761
3328
|
}
|
|
2762
3329
|
// 先落 canceled 再动 pane: pane 一死, 守着的 watcher 会把同一份报成 dead。
|
|
2763
3330
|
// 打断只停它手上这一轮: 只掐这一轮开头那句派来的活 (人 / 定时 / 回执轮不掐任何一份)。
|
|
2764
|
-
const
|
|
3331
|
+
const only = end ? undefined : currentAsk(target);
|
|
3332
|
+
if (!controlValid(self, authority)) {
|
|
3333
|
+
json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;未取消或打断目标" });
|
|
3334
|
+
return;
|
|
3335
|
+
}
|
|
3336
|
+
const canceled = cancelPeerWork(target, self, only);
|
|
3337
|
+
if (!controlValid(self, authority)) {
|
|
3338
|
+
json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;未打断或结束目标" });
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
2765
3341
|
const done = end ? await m.killPane(target) : await m.interruptPane(target, { teardown: true });
|
|
2766
3342
|
if (!done.ok) {
|
|
2767
3343
|
json(res, 502, { ok: false, target, reason: done.reason });
|
|
@@ -2933,6 +3509,8 @@ const main = async () => {
|
|
|
2933
3509
|
const closeOne = async (j, summary, mark) => {
|
|
2934
3510
|
const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
|
|
2935
3511
|
// 取消: 还没落定的成员手上这张单派的活记成 canceled, 发起者不会再收到一串回执。
|
|
3512
|
+
planRunner.cancelJob(j.owner, j.id);
|
|
3513
|
+
workflow.cancel(undefined, j.owner, j.id);
|
|
2936
3514
|
if (how === "cancel")
|
|
2937
3515
|
j.members.filter((mm) => !mm.outcome).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
|
|
2938
3516
|
// 这张单名下还排着没投的 (after 停放) 也不投了。
|
|
@@ -3390,6 +3968,11 @@ const main = async () => {
|
|
|
3390
3968
|
json(res, 400, { ok: false, reason: `node.tag \`${xnode.tag}\` 不是一个合法的 tag` });
|
|
3391
3969
|
return;
|
|
3392
3970
|
}
|
|
3971
|
+
const allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, wizards.get(target)?.name);
|
|
3972
|
+
if (spec.nodes.some((n) => !allowed(keyOf(spec.base, n.tag), n.tag))) {
|
|
3973
|
+
json(res, 409, { ok: false, reason: "workflow plan reserves a graph executor" });
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3393
3976
|
const graphLog = log.child({ mod: "graph", base: spec.base });
|
|
3394
3977
|
// 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
|
|
3395
3978
|
// 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
|
|
@@ -3399,16 +3982,27 @@ const main = async () => {
|
|
|
3399
3982
|
// (or its pane died). Re-spawning a healthy node would throw away the
|
|
3400
3983
|
// context that makes a multi-round loop worth running.
|
|
3401
3984
|
ensureNode: async (target, node) => {
|
|
3985
|
+
if (!allowed(target, node.tag))
|
|
3986
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
3402
3987
|
const existing = (await m.peers(target)).find((p) => p.target === target);
|
|
3988
|
+
if (!allowed(target, node.tag))
|
|
3989
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
3403
3990
|
if (existing?.paneAlive)
|
|
3404
3991
|
return { ok: true };
|
|
3405
3992
|
// No broadcast plumbing needed: a `base#tag` target strips down to the
|
|
3406
3993
|
// same WeCom chatid as `base`, so the node's own pushes already land
|
|
3407
3994
|
// 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 });
|
|
3995
|
+
const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true, guard: () => allowed(target, node.tag) });
|
|
3409
3996
|
return { ok: r.ok, reason: r.reason };
|
|
3410
3997
|
},
|
|
3411
|
-
send: async (target, text, origin) =>
|
|
3998
|
+
send: async (target, text, origin) => {
|
|
3999
|
+
if (!allowed(target))
|
|
4000
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
4001
|
+
await handedOff(target);
|
|
4002
|
+
if (!allowed(target))
|
|
4003
|
+
return { ok: false, reason: "workflow plan reserves this executor" };
|
|
4004
|
+
return m.injectText(target, text, origin, { channel, guard: () => allowed(target) });
|
|
4005
|
+
},
|
|
3412
4006
|
isBusy: m.isBusy,
|
|
3413
4007
|
lastText: async (target) => m.lastText(target),
|
|
3414
4008
|
notify: (_base, markdown) => { if (channel)
|
|
@@ -3473,50 +4067,37 @@ const main = async () => {
|
|
|
3473
4067
|
// 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
|
|
3474
4068
|
// 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
|
|
3475
4069
|
// 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
|
|
3476
|
-
scheduledTaskInject = async (target, text,
|
|
3477
|
-
|
|
4070
|
+
scheduledTaskInject = async (target, text, opts) => {
|
|
4071
|
+
const { taskId, fresh, quiet } = opts;
|
|
3478
4072
|
const owner = ((r) => (r ? ownerOf(r) : target))(tasks.get(taskId));
|
|
3479
4073
|
if (!sameBot(owner, target))
|
|
3480
4074
|
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
|
-
const wr = await m.untilIdle(target, TASK_POSTPONE_MS);
|
|
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);
|
|
3517
|
-
};
|
|
3518
|
-
/** 顺延中的任务 (内存即可: reload 丢了只是少放一枪, 下一次到点照常)。 */
|
|
3519
|
-
const postponed = new Set();
|
|
4075
|
+
const allowed = () => opts.guard() && planStore.allowExternal(target, wizards.get(target)?.name);
|
|
4076
|
+
return deliverScheduled({ ...opts, guard: () => opts.guard() && (fresh || allowed()) }, {
|
|
4077
|
+
postponeMs: TASK_POSTPONE_MS,
|
|
4078
|
+
handedOff: () => handedOff(target),
|
|
4079
|
+
idleNow: () => m.idleNow(target),
|
|
4080
|
+
untilIdle: (timeoutMs, aborted) => m.untilIdle(target, timeoutMs, { aborted }),
|
|
4081
|
+
named: async (live) => {
|
|
4082
|
+
const guard = () => live() && allowed();
|
|
4083
|
+
if (!guard())
|
|
4084
|
+
return { ok: false, reason: "scheduled target permission withdrawn" };
|
|
4085
|
+
const at = Date.now();
|
|
4086
|
+
const turn = newTurn();
|
|
4087
|
+
// Confirm idle inside the physical pane transaction; a race is unsent
|
|
4088
|
+
// and returns to the target consumer, without releasing task dedup.
|
|
4089
|
+
const inj = await m.injectText(target, text, undefined, quiet
|
|
4090
|
+
? { guard, requireIdle: true, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
|
|
4091
|
+
: { guard, requireIdle: true, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
4092
|
+
if (inj.ok && quiet)
|
|
4093
|
+
void relayUnlessQuiet(target, at, turn, baseOfKey(target));
|
|
4094
|
+
return inj;
|
|
4095
|
+
},
|
|
4096
|
+
runner: (busy, live) => !fresh && !allowed()
|
|
4097
|
+
? Promise.resolve({ ok: false, reason: "scheduled target permission withdrawn" })
|
|
4098
|
+
: runTaskInRunner(target, text, taskId, quiet, busy, live),
|
|
4099
|
+
});
|
|
4100
|
+
};
|
|
3520
4101
|
/** 安静任务那一轮的终句: 它私下跑, 镜像不推; 等它这一轮跑完, 终句不是 `QUIET` 就替它
|
|
3521
4102
|
* 发进群 (头写它的名字, 人知道是谁说的)。按信封件号定位答句 (同回执): 按时刻取会把紧随
|
|
3522
4103
|
* 其后的私聊 / 人话那一轮的答案当成它的, 把私下内容发进群。 */
|
|
@@ -3544,7 +4125,9 @@ const main = async () => {
|
|
|
3544
4125
|
const quietRelays = loadJsonMap(join(cfg.daemon.stateDir, "quiet-relays.json"), (rows) => Object.fromEntries(Object.entries(rows).filter(([, r]) => Date.now() - r.at < TASK_QUIET_WAIT_MS)));
|
|
3545
4126
|
void m.restored.then(() => Object.entries(quietRelays.all()).forEach(([turn, r]) => void relayUnlessQuiet(r.target, r.at, turn, r.base)));
|
|
3546
4127
|
/** 到点起一个白板 wizard 执行这一枪, 跑完自动收掉。 */
|
|
3547
|
-
const runTaskInRunner = async (target, text, taskId, quiet, busy) => {
|
|
4128
|
+
const runTaskInRunner = async (target, text, taskId, quiet, busy, guard) => {
|
|
4129
|
+
if (!guard())
|
|
4130
|
+
return { ok: false, reason: "scheduled runner withdrawn" };
|
|
3548
4131
|
// 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
|
|
3549
4132
|
// rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
|
|
3550
4133
|
const base = baseOfKey(target);
|
|
@@ -3552,9 +4135,14 @@ const main = async () => {
|
|
|
3552
4135
|
const owner = rec ? ownerOf(rec) : target;
|
|
3553
4136
|
const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
|
|
3554
4137
|
const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
4138
|
+
if (!guard())
|
|
4139
|
+
return { ok: false, reason: "scheduled runner withdrawn during slot selection" };
|
|
3555
4140
|
if (!slotR.ok)
|
|
3556
4141
|
return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
|
|
3557
4142
|
const runner = slotR.target;
|
|
4143
|
+
const allowed = () => guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
|
|
4144
|
+
if (!allowed())
|
|
4145
|
+
return { ok: false, reason: "workflow plan reserves task runner" };
|
|
3558
4146
|
const info = m.sessionInfo(target);
|
|
3559
4147
|
const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
|
|
3560
4148
|
wizards.upsert(runner, {
|
|
@@ -3564,11 +4152,18 @@ const main = async () => {
|
|
|
3564
4152
|
bornAt: Date.now(),
|
|
3565
4153
|
});
|
|
3566
4154
|
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 });
|
|
4155
|
+
const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed });
|
|
3568
4156
|
if (!spawned.ok) {
|
|
3569
4157
|
wizards.drop(runner);
|
|
3570
4158
|
return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
|
|
3571
4159
|
}
|
|
4160
|
+
if (!allowed()) {
|
|
4161
|
+
if (m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
|
|
4162
|
+
await m.killPane(runner);
|
|
4163
|
+
wizards.drop(runner);
|
|
4164
|
+
}
|
|
4165
|
+
return { ok: false, reason: "scheduled runner withdrawn after spawn" };
|
|
4166
|
+
}
|
|
3572
4167
|
if (!quiet) {
|
|
3573
4168
|
notifyChat(base, withTagHeader(target, busy
|
|
3574
4169
|
? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
|
|
@@ -3577,8 +4172,8 @@ const main = async () => {
|
|
|
3577
4172
|
const at = Date.now();
|
|
3578
4173
|
const turn = newTurn();
|
|
3579
4174
|
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) });
|
|
4175
|
+
? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
|
|
4176
|
+
: { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
3582
4177
|
if (!inj.ok) {
|
|
3583
4178
|
wizards.drop(runner);
|
|
3584
4179
|
return inj;
|
|
@@ -3687,7 +4282,7 @@ const main = async () => {
|
|
|
3687
4282
|
const sweep = async () => {
|
|
3688
4283
|
const taskOwners = new Set(tasks.list()
|
|
3689
4284
|
.filter((t) => t.enabled)
|
|
3690
|
-
.map((t) => wizardStore()?.byName(t.owner)?.target)
|
|
4285
|
+
.map((t) => wizardStore()?.get(t.owner)?.target ?? wizardStore()?.byName(t.owner)?.target)
|
|
3691
4286
|
.filter((t) => !!t));
|
|
3692
4287
|
const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
|
|
3693
4288
|
const r = await bridge.reapOverflow(max, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
|
|
@@ -3709,6 +4304,7 @@ const main = async () => {
|
|
|
3709
4304
|
}
|
|
3710
4305
|
const shutdown = async (signal) => {
|
|
3711
4306
|
log.info({ signal }, "shutdown signal");
|
|
4307
|
+
stopReceipts();
|
|
3712
4308
|
scheduler.stop();
|
|
3713
4309
|
steward?.stop();
|
|
3714
4310
|
tasks.stop();
|
|
@@ -3724,6 +4320,10 @@ const main = async () => {
|
|
|
3724
4320
|
await Promise.allSettled([ws.shutdown(), http.close()]);
|
|
3725
4321
|
process.exit(0);
|
|
3726
4322
|
};
|
|
4323
|
+
http.register("POST /shutdown", (_req, res) => {
|
|
4324
|
+
json(res, 200, { ok: true });
|
|
4325
|
+
return shutdown("HTTP");
|
|
4326
|
+
});
|
|
3727
4327
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3728
4328
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
3729
4329
|
try {
|