@yeaft/webchat-agent 0.1.706 → 0.1.707
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/package.json +1 -1
- package/unify/engine.js +59 -1
- package/unify/tools/route-forward.js +18 -0
- package/unify/tools/types.js +17 -0
- package/unify/web-bridge.js +583 -99
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -682,6 +682,11 @@ export class Engine {
|
|
|
682
682
|
inboundEnvelope: vpCtx?.inboundEnvelope,
|
|
683
683
|
taskId: vpCtx?.taskId,
|
|
684
684
|
taskMembers: vpCtx?.taskMembers,
|
|
685
|
+
// task-707: tool-callable end-turn signal. The engine threads this
|
|
686
|
+
// setter when constructing toolCtx so a tool (e.g. route_forward)
|
|
687
|
+
// can mark "after this batch, end the turn — do NOT call adapter
|
|
688
|
+
// again". Honored at the top of the tool-loop continuation.
|
|
689
|
+
requestEndTurn: vpCtx?.requestEndTurn,
|
|
685
690
|
// Sub-agent plumbing — Agent tool needs these to spawn a child
|
|
686
691
|
// Engine that inherits the parent's adapter / stores / toolset.
|
|
687
692
|
parentEngineDeps: {
|
|
@@ -1180,6 +1185,13 @@ export class Engine {
|
|
|
1180
1185
|
let currentModel = this.#config.model;
|
|
1181
1186
|
let cumulativeInputTokens = 0;
|
|
1182
1187
|
let cumulativeOutputTokens = 0;
|
|
1188
|
+
// task-707: tool-callable end-turn signal. Tools (currently only
|
|
1189
|
+
// `route_forward`) can set this via toolCtx.requestEndTurn(reason)
|
|
1190
|
+
// to break out of the tool-loop after the current batch finishes
|
|
1191
|
+
// — without invoking another adapter.stream(). Used to hand off
|
|
1192
|
+
// control to other VPs cleanly. Reset to null at the top of every
|
|
1193
|
+
// outer-loop iteration so the flag never carries across turns.
|
|
1194
|
+
let endTurnRequested = null;
|
|
1183
1195
|
|
|
1184
1196
|
while (true) {
|
|
1185
1197
|
turnNumber++;
|
|
@@ -1678,7 +1690,28 @@ export class Engine {
|
|
|
1678
1690
|
}
|
|
1679
1691
|
|
|
1680
1692
|
// Execute tool calls and feed results back
|
|
1681
|
-
|
|
1693
|
+
// task-707: requestEndTurn is a per-batch closure that lets a tool
|
|
1694
|
+
// signal "end this turn after the current batch — no adapter retry".
|
|
1695
|
+
// We re-create the closure each iteration because endTurnRequested
|
|
1696
|
+
// is a per-query local (reset implicitly at the top of #runQuery).
|
|
1697
|
+
const toolCtx = this.#buildToolContext(signal, {
|
|
1698
|
+
router,
|
|
1699
|
+
senderVpId,
|
|
1700
|
+
inboundEnvelope,
|
|
1701
|
+
taskId,
|
|
1702
|
+
taskMembers,
|
|
1703
|
+
vpPersona,
|
|
1704
|
+
contextWindow: currentContextWindow,
|
|
1705
|
+
requestEndTurn: (reason) => {
|
|
1706
|
+
// First call wins — preserve the kind/reason of the first tool
|
|
1707
|
+
// that asked to end the turn. Late callers (a second
|
|
1708
|
+
// route_forward in the same batch) keep dispatching but don't
|
|
1709
|
+
// overwrite the recorded reason.
|
|
1710
|
+
if (endTurnRequested == null) {
|
|
1711
|
+
endTurnRequested = reason || { kind: 'tool_handoff' };
|
|
1712
|
+
}
|
|
1713
|
+
},
|
|
1714
|
+
});
|
|
1682
1715
|
|
|
1683
1716
|
// task-325a: track whether we aborted mid tool-loop so we can
|
|
1684
1717
|
// break out of the outer while-loop cleanly once the current
|
|
@@ -1822,6 +1855,31 @@ export class Engine {
|
|
|
1822
1855
|
conversationMessages.push({ role: 'user', content: reminder });
|
|
1823
1856
|
}
|
|
1824
1857
|
|
|
1858
|
+
// task-707: tool-callable end-turn signal. If a tool in this batch
|
|
1859
|
+
// called toolCtx.requestEndTurn(reason), break out of the outer
|
|
1860
|
+
// while-loop now — DON'T call adapter.stream() again. The
|
|
1861
|
+
// assistant(tool_use)+tool(tool_result) pairs are already in
|
|
1862
|
+
// conversationMessages, so the next user-initiated turn sees a
|
|
1863
|
+
// clean wire shape. Used by `route_forward` to hand off control
|
|
1864
|
+
// to other VPs without continuing to generate.
|
|
1865
|
+
//
|
|
1866
|
+
// Order matters: this runs BEFORE T1 reflection (which would
|
|
1867
|
+
// collapse the arc into a summary that's only valuable across
|
|
1868
|
+
// multi-iteration tool loops) and BEFORE the abortedDuringTools
|
|
1869
|
+
// check (so a clean handoff doesn't get reported as 'aborted').
|
|
1870
|
+
if (endTurnRequested) {
|
|
1871
|
+
const handoffDetail = typeof endTurnRequested === 'object'
|
|
1872
|
+
? endTurnRequested
|
|
1873
|
+
: { kind: 'tool_handoff', reason: String(endTurnRequested) };
|
|
1874
|
+
yield {
|
|
1875
|
+
type: 'turn_end',
|
|
1876
|
+
turnNumber,
|
|
1877
|
+
stopReason: 'tool_handoff',
|
|
1878
|
+
detail: handoffDetail,
|
|
1879
|
+
};
|
|
1880
|
+
break;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1825
1883
|
// PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
|
|
1826
1884
|
// query() lifetime, the moment queryToolCount crosses
|
|
1827
1885
|
// TOOL_BATCH_SIZE (13). Generates a markdown reflection over the
|
|
@@ -106,6 +106,24 @@ Returns JSON: { ok, dispatched?, error?, detail? }.`,
|
|
|
106
106
|
detail: result.detail || null,
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
|
+
// task-707: hand off control. Successful forward means the originating
|
|
110
|
+
// turn should NOT continue generating — the target VPs are now in
|
|
111
|
+
// charge. Signal the engine to break the tool-loop after this batch.
|
|
112
|
+
// The structured payload feeds web-bridge's `group_handoff` UX event
|
|
113
|
+
// so the frontend can render "↪ 已转交给 @vp-x、@vp-y" without
|
|
114
|
+
// re-parsing a string.
|
|
115
|
+
if (typeof ctx.requestEndTurn === 'function') {
|
|
116
|
+
try {
|
|
117
|
+
ctx.requestEndTurn({
|
|
118
|
+
kind: 'route_forward',
|
|
119
|
+
fromVpId: senderVpId,
|
|
120
|
+
dispatched: result.dispatched.slice(),
|
|
121
|
+
broadcast: Boolean(result.report?.broadcast),
|
|
122
|
+
text,
|
|
123
|
+
reason: reason || null,
|
|
124
|
+
});
|
|
125
|
+
} catch { /* never block the tool path on a UX hint */ }
|
|
126
|
+
}
|
|
109
127
|
return JSON.stringify({
|
|
110
128
|
ok: true,
|
|
111
129
|
dispatched: result.dispatched,
|
package/unify/tools/types.js
CHANGED
|
@@ -27,6 +27,23 @@
|
|
|
27
27
|
* @property {number} [contextWindow] — current model's context window in
|
|
28
28
|
* tokens (used by ToolRegistry.execute to cap a single tool result at a
|
|
29
29
|
* fraction of the window so one runaway grep can't blow the wire).
|
|
30
|
+
* @property {(reason?: string|object) => void} [requestEndTurn]
|
|
31
|
+
* — tool-callable signal that the current engine turn should end after
|
|
32
|
+
* this batch of tool calls completes (no follow-up adapter.stream call).
|
|
33
|
+
* Used by `route_forward` to hand off control to other VPs without
|
|
34
|
+
* continuing to generate. The engine wires this when it builds toolCtx
|
|
35
|
+
* and yields a `turn_end` event with `stopReason: 'tool_handoff'` and
|
|
36
|
+
* the supplied reason as `detail`. `reason` may be a structured object
|
|
37
|
+
* `{kind, ...}` so downstream observers (web-bridge) can render UI hints
|
|
38
|
+
* (e.g. "↪ 已转交给 @vp-b") without re-parsing strings.
|
|
39
|
+
* @property {string} [senderVpId] — id of the VP whose turn is currently
|
|
40
|
+
* running. Used by `route_forward` to stamp the forwarded message and
|
|
41
|
+
* by the loop guard to key per-sender throttling.
|
|
42
|
+
* @property {object} [inboundEnvelope] — the envelope that triggered this
|
|
43
|
+
* turn (groupId / msgId / causedBy chain). Threaded into route_forward
|
|
44
|
+
* so causedBy chains extend correctly.
|
|
45
|
+
* @property {object} [router] — per-group router (createRouter() output)
|
|
46
|
+
* for VP-to-VP forwarding. Set by the bridge when running inside a group.
|
|
30
47
|
*/
|
|
31
48
|
|
|
32
49
|
/**
|
package/unify/web-bridge.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { existsSync } from 'node:fs';
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { Engine } from './engine.js';
|
|
24
25
|
import { loadSession } from './session.js';
|
|
25
26
|
import { sendToServer } from '../connection/buffer.js';
|
|
26
27
|
import ctx from '../context.js';
|
|
@@ -57,6 +58,14 @@ let session = null;
|
|
|
57
58
|
/**
|
|
58
59
|
* Single in-flight AbortController. A new user message cancels the prior
|
|
59
60
|
* round (if any). H2.f.2: replaces the per-thread Map.
|
|
61
|
+
*
|
|
62
|
+
* Note: post-707, group fan-out turns no longer flow through this slot —
|
|
63
|
+
* they each get their own controller in `vpAborts` keyed by
|
|
64
|
+
* `${groupId}::${vpId}`. The `currentAbortCtrl` here is only mutated by
|
|
65
|
+
* 1:1 chat paths, the test seeder, and the session-reset cleanup. Don't
|
|
66
|
+
* reach for it from new group-flow code; selective abort, abort-all,
|
|
67
|
+
* and abort-turn already operate against `vpAborts` correctly.
|
|
68
|
+
*
|
|
60
69
|
* @type {AbortController | null}
|
|
61
70
|
*/
|
|
62
71
|
let currentAbortCtrl = null;
|
|
@@ -70,6 +79,91 @@ let currentAbortCtrl = null;
|
|
|
70
79
|
*/
|
|
71
80
|
const turnAbortCtrls = new Map();
|
|
72
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Per-VP inbox + driver + engine pool (group multi-VP delivery).
|
|
84
|
+
*
|
|
85
|
+
* Replaces the pre-707 one-shot `captured[]` array. The coordinator's
|
|
86
|
+
* `deliver(vpId, envelope)` callback now pushes into `vpInboxes`, and a
|
|
87
|
+
* per-VP driver (long-lived async function) drains the inbox one
|
|
88
|
+
* envelope at a time — exactly the shape used by sub-agent runner's
|
|
89
|
+
* `pendingPrompts` + `driveSubAgent`. With this in place:
|
|
90
|
+
* 1. `route_forward` pushes via the same `deliver` → enqueueForVp
|
|
91
|
+
* path the user dispatch uses, so VP-to-VP hand-offs actually run
|
|
92
|
+
* the target VP's driver instead of being dropped.
|
|
93
|
+
* 2. Each VP gets its own Engine (via `vpEngines`) so private state
|
|
94
|
+
* (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
|
|
95
|
+
* `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not collide
|
|
96
|
+
* across concurrent VP turns. Engines are keyed by
|
|
97
|
+
* `${groupId}::${vpId}` rather than vpId alone because Engine
|
|
98
|
+
* cannot serve two concurrent queries safely — even if AMS state
|
|
99
|
+
* partitions correctly by groupKey, the non-group-keyed private
|
|
100
|
+
* state would collide if the same VP ran turns in two groups in
|
|
101
|
+
* parallel.
|
|
102
|
+
*/
|
|
103
|
+
/** @type {Map<string, Array<{envelope: object, opts: object}>>} */
|
|
104
|
+
const vpInboxes = new Map();
|
|
105
|
+
/** @type {Map<string, Promise<void>>} */
|
|
106
|
+
const vpDrivers = new Map();
|
|
107
|
+
/** @type {Map<string, import('./engine.js').Engine>} */
|
|
108
|
+
const vpEngines = new Map();
|
|
109
|
+
/** @type {Map<string, AbortController>} */
|
|
110
|
+
const vpAborts = new Map();
|
|
111
|
+
/**
|
|
112
|
+
* Per-group cached coordinator + router. Created on first
|
|
113
|
+
* `handleUnifyGroupChat` for a given groupId; reused across user messages
|
|
114
|
+
* AND across `route_forward` deliveries inside running VP turns (the
|
|
115
|
+
* router is wired into engine ctx; if we recreated coord per turn the
|
|
116
|
+
* route_forward path would deliver into a freshly-created `captured[]`
|
|
117
|
+
* that nobody consumes — exactly the pre-707 bug).
|
|
118
|
+
*
|
|
119
|
+
* Purge sites:
|
|
120
|
+
* - `invalidateGroupContext(groupId)` — called from every group CRUD
|
|
121
|
+
* handler that mutates roster / meta / lifecycle state on disk
|
|
122
|
+
* (rename, update announcement, archive, delete, add/remove member,
|
|
123
|
+
* set default VP).
|
|
124
|
+
* - `handleUnifyGroupChat` — invalidates inline when its own
|
|
125
|
+
* auto-add / default-VP-heal pass mutated the roster.
|
|
126
|
+
* - `resetUnifySession` and `__testResetVpState` clear the whole map.
|
|
127
|
+
*
|
|
128
|
+
* @type {Map<string, { coord: ReturnType<typeof createCoordinator>,
|
|
129
|
+
* router: ReturnType<typeof createRouter>,
|
|
130
|
+
* groupHandle: object }>}
|
|
131
|
+
*/
|
|
132
|
+
const groupContexts = new Map();
|
|
133
|
+
|
|
134
|
+
function vpKey(groupId, vpId) {
|
|
135
|
+
return `${groupId}::${vpId}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Drop the cached coordinator + router for a group AND abort/clear any
|
|
140
|
+
* in-flight VP turns belonging to it. Call this from every CRUD handler
|
|
141
|
+
* that mutates the group's roster, meta, or lifecycle state on disk —
|
|
142
|
+
* the cached coord holds a closed `groupHandle`, so without invalidation
|
|
143
|
+
* later route_forward / ingest calls would read zombie meta (stale
|
|
144
|
+
* roster, pre-rename announcement, kicked members still routable).
|
|
145
|
+
*
|
|
146
|
+
* Idempotent — safe to call when no entry exists.
|
|
147
|
+
*/
|
|
148
|
+
function invalidateGroupContext(groupId) {
|
|
149
|
+
if (!groupId) return;
|
|
150
|
+
groupContexts.delete(groupId);
|
|
151
|
+
const prefix = `${groupId}::`;
|
|
152
|
+
for (const [k, ctrl] of vpAborts) {
|
|
153
|
+
if (!k.startsWith(prefix)) continue;
|
|
154
|
+
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
|
|
155
|
+
vpAborts.delete(k);
|
|
156
|
+
}
|
|
157
|
+
for (const [k, inbox] of vpInboxes) {
|
|
158
|
+
if (!k.startsWith(prefix)) continue;
|
|
159
|
+
if (Array.isArray(inbox)) inbox.length = 0;
|
|
160
|
+
}
|
|
161
|
+
// Engines are NOT torn down here on purpose. They hold subordinate
|
|
162
|
+
// state (AMS adjustments) that should survive a meta change and a
|
|
163
|
+
// closed groupHandle — they don't reach the on-disk group meta
|
|
164
|
+
// directly. They *are* dropped on `resetUnifySession`.
|
|
165
|
+
}
|
|
166
|
+
|
|
73
167
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
74
168
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
75
169
|
|
|
@@ -122,6 +216,253 @@ function isPermissionErrorMsg(msg) {
|
|
|
122
216
|
return lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied');
|
|
123
217
|
}
|
|
124
218
|
|
|
219
|
+
// ============================================================
|
|
220
|
+
// task-707: per-VP inbox + driver helpers (group multi-VP fix)
|
|
221
|
+
// ============================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get-or-create the per-VP Engine. Each VP owns its own Engine instance
|
|
225
|
+
* so private state (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
|
|
226
|
+
* `#abortReason`, `#adjustRanByGroup`, `#execLog`) doesn't collide when
|
|
227
|
+
* VP-A and VP-B run concurrent turns. All engines share the session's
|
|
228
|
+
* adapter / trace / config / stores so memory recall, conversation
|
|
229
|
+
* persistence, and tool registry remain consistent.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} groupId
|
|
232
|
+
* @param {string} vpId
|
|
233
|
+
* @returns {import('./engine.js').Engine}
|
|
234
|
+
*/
|
|
235
|
+
function getOrCreateVpEngine(groupId, vpId) {
|
|
236
|
+
const key = vpKey(groupId, vpId);
|
|
237
|
+
let eng = vpEngines.get(key);
|
|
238
|
+
if (eng) return eng;
|
|
239
|
+
if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
|
|
240
|
+
eng = new Engine({
|
|
241
|
+
adapter: session.adapter,
|
|
242
|
+
trace: session.trace,
|
|
243
|
+
config: session.config,
|
|
244
|
+
conversationStore: session.conversationStore,
|
|
245
|
+
memoryIndex: session.memoryIndex || null,
|
|
246
|
+
amsRegistry: session.amsRegistry,
|
|
247
|
+
toolRegistry: session.toolRegistry,
|
|
248
|
+
skillManager: session.skillManager,
|
|
249
|
+
mcpManager: session.mcpManager,
|
|
250
|
+
yeaftDir: session.yeaftDir,
|
|
251
|
+
});
|
|
252
|
+
vpEngines.set(key, eng);
|
|
253
|
+
return eng;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get-or-create the persistent per-group coordinator + router.
|
|
258
|
+
*
|
|
259
|
+
* The coordinator MUST be reused across user turns AND across in-flight
|
|
260
|
+
* tool calls (route_forward) — its `deliver` callback is the only way
|
|
261
|
+
* envelopes reach `vpInboxes`. If we recreated it per `handleUnifyGroupChat`
|
|
262
|
+
* call (the pre-707 design), `route_forward` running mid-turn would
|
|
263
|
+
* deliver into a doomed `captured[]` while the new dispatch ran against
|
|
264
|
+
* a fresh coordinator. The persistent coordinator + module-level inboxes
|
|
265
|
+
* close that gap.
|
|
266
|
+
*
|
|
267
|
+
* Caller is responsible for passing in a freshly-opened groupHandle on
|
|
268
|
+
* first creation; subsequent calls reuse the cached coord.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} groupId
|
|
271
|
+
* @param {object} groupHandle — only used on first creation
|
|
272
|
+
* @returns {{ coord: object, router: object, groupHandle: object }}
|
|
273
|
+
*/
|
|
274
|
+
function getOrCreateGroupContext(groupId, groupHandle) {
|
|
275
|
+
let entry = groupContexts.get(groupId);
|
|
276
|
+
if (entry) return entry;
|
|
277
|
+
const coord = createCoordinator(groupHandle, {
|
|
278
|
+
deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
|
|
279
|
+
});
|
|
280
|
+
const router = createRouter({ coordinator: coord });
|
|
281
|
+
entry = { coord, router, groupHandle };
|
|
282
|
+
groupContexts.set(groupId, entry);
|
|
283
|
+
return entry;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Push an envelope onto a VP's inbox and ensure its driver is running.
|
|
288
|
+
*
|
|
289
|
+
* Side effect: emits `vp_typing_start` immediately (NOT after the driver
|
|
290
|
+
* picks up the envelope). This makes the UX match the user's expectation
|
|
291
|
+
* — the typing indicator turns on the instant the message is queued, so
|
|
292
|
+
* `route_forward` makes the target VP's typing dot light up before the
|
|
293
|
+
* engine even starts that turn.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} groupId
|
|
296
|
+
* @param {string} vpId
|
|
297
|
+
* @param {object} envelope — coordinator envelope `{groupId, taskId, msg, trigger}`
|
|
298
|
+
*/
|
|
299
|
+
function enqueueForVp(groupId, vpId, envelope) {
|
|
300
|
+
const key = vpKey(groupId, vpId);
|
|
301
|
+
let inbox = vpInboxes.get(key);
|
|
302
|
+
if (!inbox) {
|
|
303
|
+
inbox = [];
|
|
304
|
+
vpInboxes.set(key, inbox);
|
|
305
|
+
}
|
|
306
|
+
// Mint a turnId now so the typing event the UI sees is paired with
|
|
307
|
+
// the same id the driver uses when it later runs the turn.
|
|
308
|
+
const turnId = `${randomUUID().slice(0, 8)}:${vpId}`;
|
|
309
|
+
inbox.push({ envelope, turnId });
|
|
310
|
+
|
|
311
|
+
// Typing fires on enqueue. The matching `vp_typing_end` is emitted by
|
|
312
|
+
// the driver's runVpTurn `finally` block — every enqueue eventually
|
|
313
|
+
// results in exactly one runVpTurn execution, so the per-VP counter
|
|
314
|
+
// (`web/stores/helpers/vp-typing.js`) stays balanced.
|
|
315
|
+
try {
|
|
316
|
+
sendUnifyEvent({
|
|
317
|
+
type: 'vp_typing_start',
|
|
318
|
+
groupId,
|
|
319
|
+
vpId,
|
|
320
|
+
turnId,
|
|
321
|
+
ts: Date.now(),
|
|
322
|
+
}, { groupId, vpId, turnId });
|
|
323
|
+
} catch { /* never crash WS pipeline */ }
|
|
324
|
+
|
|
325
|
+
ensureDriverRunning(groupId, vpId);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Spin up a driver loop for a VP if one isn't already running. Idempotent.
|
|
330
|
+
* The driver pulls envelopes from the inbox one at a time and runs each
|
|
331
|
+
* through `runVpTurn`. When the inbox empties, the driver exits and is
|
|
332
|
+
* removed from `vpDrivers`. The next `enqueueForVp` will spawn a fresh
|
|
333
|
+
* driver.
|
|
334
|
+
*
|
|
335
|
+
* Mirrors `sub-agent/runner.js`'s `driveSubAgent` shape: shift → run →
|
|
336
|
+
* loop until empty. No internal sleep — all "wakeups" are driven by
|
|
337
|
+
* external `enqueueForVp` calls.
|
|
338
|
+
*/
|
|
339
|
+
function ensureDriverRunning(groupId, vpId) {
|
|
340
|
+
const key = vpKey(groupId, vpId);
|
|
341
|
+
if (vpDrivers.has(key)) return;
|
|
342
|
+
const promise = (async () => {
|
|
343
|
+
while (true) {
|
|
344
|
+
const inbox = vpInboxes.get(key);
|
|
345
|
+
if (!inbox || inbox.length === 0) break;
|
|
346
|
+
const { envelope, turnId } = inbox.shift();
|
|
347
|
+
const vpAbort = new AbortController();
|
|
348
|
+
vpAborts.set(key, vpAbort);
|
|
349
|
+
// Mirror into turnAbortCtrls for the existing per-turn Stop button.
|
|
350
|
+
turnAbortCtrls.set(turnId, vpAbort);
|
|
351
|
+
// Snapshot history at the moment this turn starts. Later turns in
|
|
352
|
+
// the same driver loop see updated history (post-append from the
|
|
353
|
+
// previous turn).
|
|
354
|
+
const baseSnapshot = [...conversationMessages];
|
|
355
|
+
const trigger = envelope?.trigger || 'fallback';
|
|
356
|
+
// Synthesize the prompt. For coordinator-emitted envelopes the
|
|
357
|
+
// text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
|
|
358
|
+
// the legacy fan-out path so the model sees the same surface form
|
|
359
|
+
// it always has.
|
|
360
|
+
const text = envelope?.msg?.text || '';
|
|
361
|
+
const prompt = `@vp-${vpId} ${text}`;
|
|
362
|
+
try {
|
|
363
|
+
await runVpTurn({
|
|
364
|
+
prompt,
|
|
365
|
+
groupId,
|
|
366
|
+
vpId,
|
|
367
|
+
turnId,
|
|
368
|
+
envelope,
|
|
369
|
+
vpAbort,
|
|
370
|
+
baseSnapshot,
|
|
371
|
+
});
|
|
372
|
+
} catch (err) {
|
|
373
|
+
console.warn('[Unify] driveVp: runVpTurn failed', vpId, err?.message || err);
|
|
374
|
+
} finally {
|
|
375
|
+
turnAbortCtrls.delete(turnId);
|
|
376
|
+
// Only clear the entry if it's still ours. A fresh user message
|
|
377
|
+
// can install a new controller for this VP between our abort
|
|
378
|
+
// and our finally (selective-abort pre-pass aborts the OLD
|
|
379
|
+
// controller, then ingest enqueues a NEW envelope which mints
|
|
380
|
+
// a fresh controller). Without this guard we'd drop the new
|
|
381
|
+
// controller and a later abort-all wouldn't see it.
|
|
382
|
+
if (vpAborts.get(key) === vpAbort) vpAborts.delete(key);
|
|
383
|
+
try {
|
|
384
|
+
sendUnifyEvent({
|
|
385
|
+
type: 'vp_typing_end',
|
|
386
|
+
groupId,
|
|
387
|
+
vpId,
|
|
388
|
+
turnId,
|
|
389
|
+
ts: Date.now(),
|
|
390
|
+
}, { groupId, vpId, turnId });
|
|
391
|
+
} catch { /* never crash WS pipeline */ }
|
|
392
|
+
}
|
|
393
|
+
// Emit a group_message event so the persisted message shows up in
|
|
394
|
+
// the UI's group log AT THE POINT the turn actually ran (so a
|
|
395
|
+
// queued envelope doesn't "appear" before the prior turn finished
|
|
396
|
+
// its writes). Trigger reflects coord's classification. The emit
|
|
397
|
+
// happens regardless of whether the turn aborted — the envelope
|
|
398
|
+
// is real (coord persisted it before deliver), so the user log
|
|
399
|
+
// should show it even if the assistant reply was cut short.
|
|
400
|
+
try {
|
|
401
|
+
if (text && envelope?.msg) {
|
|
402
|
+
sendUnifyEvent({
|
|
403
|
+
type: 'group_message',
|
|
404
|
+
groupId,
|
|
405
|
+
vpId,
|
|
406
|
+
speakerVpId: vpId,
|
|
407
|
+
text,
|
|
408
|
+
mentions: Array.isArray(envelope?.msg?.mentions) ? envelope.msg.mentions : [],
|
|
409
|
+
trigger,
|
|
410
|
+
ts: Date.now(),
|
|
411
|
+
}, { groupId, vpId, turnId });
|
|
412
|
+
}
|
|
413
|
+
} catch { /* never crash WS pipeline */ }
|
|
414
|
+
}
|
|
415
|
+
vpDrivers.delete(key);
|
|
416
|
+
// Re-arm guard: the inbox could have been pushed into between the
|
|
417
|
+
// top-of-loop empty check and this delete (synchronous re-entry
|
|
418
|
+
// from a sendUnifyEvent listener, or a microtask scheduled while
|
|
419
|
+
// we were in `finally`). Without this, the new envelope would be
|
|
420
|
+
// stranded — `enqueueForVp` saw `vpDrivers.has(key)` return true
|
|
421
|
+
// because we hadn't deleted yet, then we delete and exit. Self-
|
|
422
|
+
// rearm if there is fresh work.
|
|
423
|
+
const tail = vpInboxes.get(key);
|
|
424
|
+
if (tail && tail.length > 0) ensureDriverRunning(groupId, vpId);
|
|
425
|
+
})();
|
|
426
|
+
vpDrivers.set(key, promise);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Test-only: drain all currently-queued VP work to completion. Tests use
|
|
431
|
+
* this as a barrier between "scenario triggered" and "now assert state".
|
|
432
|
+
* Production code never calls this — driver lifecycles are tied to
|
|
433
|
+
* inbox emptiness, not external signals.
|
|
434
|
+
*/
|
|
435
|
+
export async function __testDrainVpDrivers() {
|
|
436
|
+
// Snapshot the in-flight driver promises and wait. New drivers
|
|
437
|
+
// spawned during the wait (by route_forward inside a turn) get
|
|
438
|
+
// picked up on the next iteration.
|
|
439
|
+
while (vpDrivers.size > 0) {
|
|
440
|
+
const promises = Array.from(vpDrivers.values());
|
|
441
|
+
await Promise.all(promises.map((p) => p.catch(() => {})));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Test-only: reset all per-VP / per-group caches. Aborts in-flight
|
|
447
|
+
* drivers and drains them before clearing so the next test doesn't see
|
|
448
|
+
* a half-aborted controller writing to a now-cleared map.
|
|
449
|
+
*/
|
|
450
|
+
export async function __testResetVpState() {
|
|
451
|
+
for (const ctrl of vpAborts.values()) {
|
|
452
|
+
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* */ }
|
|
453
|
+
}
|
|
454
|
+
for (const inbox of vpInboxes.values()) {
|
|
455
|
+
if (Array.isArray(inbox)) inbox.length = 0;
|
|
456
|
+
}
|
|
457
|
+
await __testDrainVpDrivers();
|
|
458
|
+
vpInboxes.clear();
|
|
459
|
+
vpDrivers.clear();
|
|
460
|
+
vpEngines.clear();
|
|
461
|
+
vpAborts.clear();
|
|
462
|
+
groupContexts.clear();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
125
466
|
/**
|
|
126
467
|
* Send a unify_output message carrying claude_output-format data.
|
|
127
468
|
* Envelope fields: conversationId, groupId, vpId, turnId — the last two
|
|
@@ -318,6 +659,7 @@ export function handleUnifyRenameGroup(msg) {
|
|
|
318
659
|
try {
|
|
319
660
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
320
661
|
const group = renameGroup(yeaftDir, groupId, name);
|
|
662
|
+
invalidateGroupContext(groupId);
|
|
321
663
|
sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
|
|
322
664
|
sendGroupSnapshotBroadcast();
|
|
323
665
|
} catch (err) {
|
|
@@ -357,6 +699,7 @@ export function handleUnifyUpdateGroup(msg) {
|
|
|
357
699
|
if (hasAnnouncement) {
|
|
358
700
|
group = updateGroupAnnouncement(yeaftDir, groupId, patch.announcement);
|
|
359
701
|
}
|
|
702
|
+
invalidateGroupContext(groupId);
|
|
360
703
|
sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
|
|
361
704
|
sendGroupSnapshotBroadcast();
|
|
362
705
|
} catch (err) {
|
|
@@ -370,6 +713,7 @@ export function handleUnifyArchiveGroup(msg) {
|
|
|
370
713
|
try {
|
|
371
714
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
372
715
|
const result = archiveGroup(yeaftDir, groupId);
|
|
716
|
+
invalidateGroupContext(groupId);
|
|
373
717
|
sendGroupCrudResult({ op: 'archive', requestId, ok: true, groupId: result.groupId });
|
|
374
718
|
sendGroupSnapshotBroadcast();
|
|
375
719
|
} catch (err) {
|
|
@@ -396,6 +740,15 @@ export function handleUnifyDeleteGroup(msg) {
|
|
|
396
740
|
} catch (cascadeErr) {
|
|
397
741
|
console.warn(`[Yeaft] cascade delete for group ${groupId} failed: ${cascadeErr.message}`);
|
|
398
742
|
}
|
|
743
|
+
// Drop the cached coord/router and abort/clear any in-flight VP
|
|
744
|
+
// turns for the deleted group. Engines for the deleted group are
|
|
745
|
+
// also dropped — unlike rename/announcement updates, the group is
|
|
746
|
+
// gone for good and there's nothing to preserve.
|
|
747
|
+
invalidateGroupContext(groupId);
|
|
748
|
+
const prefix = `${groupId}::`;
|
|
749
|
+
for (const k of Array.from(vpEngines.keys())) {
|
|
750
|
+
if (k.startsWith(prefix)) vpEngines.delete(k);
|
|
751
|
+
}
|
|
399
752
|
sendGroupCrudResult({
|
|
400
753
|
op: 'delete',
|
|
401
754
|
requestId,
|
|
@@ -416,6 +769,7 @@ export function handleUnifyAddMember(msg) {
|
|
|
416
769
|
try {
|
|
417
770
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
418
771
|
const group = addMember(yeaftDir, groupId, vpId);
|
|
772
|
+
invalidateGroupContext(groupId);
|
|
419
773
|
sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
|
|
420
774
|
sendGroupRosterChanged(group);
|
|
421
775
|
} catch (err) {
|
|
@@ -430,6 +784,10 @@ export function handleUnifyRemoveMember(msg) {
|
|
|
430
784
|
try {
|
|
431
785
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
432
786
|
const group = removeMember(yeaftDir, groupId, vpId);
|
|
787
|
+
invalidateGroupContext(groupId);
|
|
788
|
+
// Also drop the kicked VP's engine — the next time they're added
|
|
789
|
+
// back they should start with fresh per-VP state.
|
|
790
|
+
vpEngines.delete(vpKey(groupId, vpId));
|
|
433
791
|
sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
|
|
434
792
|
sendGroupRosterChanged(group);
|
|
435
793
|
} catch (err) {
|
|
@@ -444,6 +802,7 @@ export function handleUnifySetDefaultVp(msg) {
|
|
|
444
802
|
try {
|
|
445
803
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
446
804
|
const group = setGroupDefaultVp(yeaftDir, groupId, vpId);
|
|
805
|
+
invalidateGroupContext(groupId);
|
|
447
806
|
sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
|
|
448
807
|
sendGroupRosterChanged(group);
|
|
449
808
|
} catch (err) {
|
|
@@ -561,11 +920,41 @@ function handleEngineEvent(event, hctx) {
|
|
|
561
920
|
break;
|
|
562
921
|
|
|
563
922
|
case 'turn_start':
|
|
564
|
-
case 'turn_end':
|
|
565
923
|
case 'stop':
|
|
566
924
|
// No UI action needed; outer loop sends the final result.
|
|
567
925
|
break;
|
|
568
926
|
|
|
927
|
+
case 'turn_end':
|
|
928
|
+
// When a tool (currently only `route_forward`) signals
|
|
929
|
+
// requestEndTurn, the engine emits turn_end with
|
|
930
|
+
// stopReason='tool_handoff' and a structured `detail` payload.
|
|
931
|
+
// Surface that to the frontend as a `group_handoff` event so the
|
|
932
|
+
// originating VP's bubble can render "↪ 已转交给 @vp-b、@vp-c".
|
|
933
|
+
// Other turn_end variants are ignored (the outer loop handles
|
|
934
|
+
// result/end_turn semantics already).
|
|
935
|
+
//
|
|
936
|
+
// The `version` field is the wire schema version. Today there is
|
|
937
|
+
// only one shape. Future variants (e.g. a second hand-off tool)
|
|
938
|
+
// can bump it without breaking older frontends — they ignore
|
|
939
|
+
// unknown versions.
|
|
940
|
+
if (event.stopReason === 'tool_handoff' && event.detail && typeof event.detail === 'object') {
|
|
941
|
+
const detail = event.detail;
|
|
942
|
+
if (detail.kind === 'route_forward') {
|
|
943
|
+
sendUnifyEvent({
|
|
944
|
+
type: 'group_handoff',
|
|
945
|
+
version: 1,
|
|
946
|
+
kind: 'route_forward',
|
|
947
|
+
fromVpId: detail.fromVpId || hctx.vpId,
|
|
948
|
+
toVpIds: Array.isArray(detail.dispatched) ? detail.dispatched.slice() : [],
|
|
949
|
+
broadcast: Boolean(detail.broadcast),
|
|
950
|
+
text: typeof detail.text === 'string' ? detail.text : '',
|
|
951
|
+
reason: detail.reason || null,
|
|
952
|
+
ts: Date.now(),
|
|
953
|
+
}, envelope);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
break;
|
|
957
|
+
|
|
569
958
|
case 'usage':
|
|
570
959
|
sendUnifyEvent({
|
|
571
960
|
type: 'context_usage',
|
|
@@ -774,19 +1163,6 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
774
1163
|
|
|
775
1164
|
await ensureSessionLoaded();
|
|
776
1165
|
|
|
777
|
-
// Cancel any prior in-flight dispatch BEFORE we fan out.
|
|
778
|
-
if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
|
|
779
|
-
try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
|
|
780
|
-
}
|
|
781
|
-
// Also abort any lingering per-VP controllers from the prior dispatch.
|
|
782
|
-
for (const ctrl of turnAbortCtrls.values()) {
|
|
783
|
-
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
|
|
784
|
-
}
|
|
785
|
-
turnAbortCtrls.clear();
|
|
786
|
-
|
|
787
|
-
const dispatchAbortCtrl = new AbortController();
|
|
788
|
-
currentAbortCtrl = dispatchAbortCtrl;
|
|
789
|
-
|
|
790
1166
|
// Open the group; seed grp_default on the fly if absent. Track
|
|
791
1167
|
// seedFailed separately so a seed crash surfaces a different message
|
|
792
1168
|
// than a genuinely-missing group.
|
|
@@ -825,22 +1201,22 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
825
1201
|
}
|
|
826
1202
|
|
|
827
1203
|
// Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
|
|
1204
|
+
let rosterMutated = false;
|
|
828
1205
|
try {
|
|
829
1206
|
const meta = groupHandle.getMeta();
|
|
830
1207
|
const wantsAdd = mentions.filter(
|
|
831
1208
|
(m) => m && m !== 'all' && !meta.roster.includes(m)
|
|
832
1209
|
);
|
|
833
1210
|
if (wantsAdd.length) {
|
|
834
|
-
let mutated = false;
|
|
835
1211
|
for (const vpId of wantsAdd) {
|
|
836
1212
|
try {
|
|
837
1213
|
const vp = readVp(vpId);
|
|
838
1214
|
if (!vp) continue;
|
|
839
1215
|
addMember(yeaftDir, groupId, vpId);
|
|
840
|
-
|
|
1216
|
+
rosterMutated = true;
|
|
841
1217
|
} catch { /* skip strangers */ }
|
|
842
1218
|
}
|
|
843
|
-
if (
|
|
1219
|
+
if (rosterMutated) {
|
|
844
1220
|
try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
|
|
845
1221
|
groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
|
|
846
1222
|
sendGroupRosterChanged(groupHandle.getMeta());
|
|
@@ -853,17 +1229,71 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
853
1229
|
try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
|
|
854
1230
|
groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
|
|
855
1231
|
sendGroupRosterChanged(groupHandle.getMeta());
|
|
1232
|
+
rosterMutated = true;
|
|
856
1233
|
} catch { /* best-effort */ }
|
|
857
1234
|
}
|
|
858
1235
|
} catch (err) {
|
|
859
1236
|
console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
|
|
860
1237
|
}
|
|
861
1238
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1239
|
+
// task-707: per-group persistent coordinator/router. Created once per
|
|
1240
|
+
// groupId; reused across user messages AND across in-flight tool calls
|
|
1241
|
+
// (route_forward delivers via this same coord). If the roster mutated
|
|
1242
|
+
// we replace the cached coord so it points at the freshly-opened
|
|
1243
|
+
// groupHandle.
|
|
1244
|
+
if (rosterMutated) {
|
|
1245
|
+
groupContexts.delete(groupId);
|
|
1246
|
+
}
|
|
1247
|
+
const groupCtx = getOrCreateGroupContext(groupId, groupHandle);
|
|
1248
|
+
const coord = groupCtx.coord;
|
|
1249
|
+
|
|
1250
|
+
// Selective abort — replace the pre-707 blanket abort that killed
|
|
1251
|
+
// every in-flight turn on every new user message (Bug 2). We peek at
|
|
1252
|
+
// which VPs THIS message will retrigger via the coordinator's
|
|
1253
|
+
// `parseMentions` and only abort those VP's current turns. VPs busy
|
|
1254
|
+
// on unrelated work keep running.
|
|
1255
|
+
//
|
|
1256
|
+
// Intentional limitation: we do NOT walk the route_forward causedBy
|
|
1257
|
+
// chain. If VP-A is mid-turn, route_forwarded to VP-B, and the user
|
|
1258
|
+
// now mentions only @vp-a, we abort VP-A but VP-B keeps generating
|
|
1259
|
+
// even though VP-B's inbound was caused by the now-overridden VP-A
|
|
1260
|
+
// turn. The argument for letting VP-B run: VP-B may already have
|
|
1261
|
+
// useful work in flight (a partial reply); aborting it on a chain
|
|
1262
|
+
// override discards that work for no user-visible benefit. If a
|
|
1263
|
+
// future product decision wants to cancel transitively, walk
|
|
1264
|
+
// `vpInboxes[*].envelope.causedBy` against the selectively-aborted
|
|
1265
|
+
// VP set and fan the abort out.
|
|
1266
|
+
//
|
|
1267
|
+
// Note: this is a best-effort pre-pass. The real dispatch list comes
|
|
1268
|
+
// from `coord.ingest(...).dispatched` below, but at that point we
|
|
1269
|
+
// would already have called deliver() and started new typing events
|
|
1270
|
+
// for the same VPs we're about to abort — racy. Using the pre-pass
|
|
1271
|
+
// result keeps the abort window before any deliver() side effects.
|
|
1272
|
+
try {
|
|
1273
|
+
const meta = groupHandle.getMeta();
|
|
1274
|
+
const willTarget = mentions.includes('all')
|
|
1275
|
+
? meta.roster.slice()
|
|
1276
|
+
: mentions.filter((m) => meta.roster.includes(m));
|
|
1277
|
+
for (const vpId of willTarget) {
|
|
1278
|
+
const k = vpKey(groupId, vpId);
|
|
1279
|
+
const ctrl = vpAborts.get(k);
|
|
1280
|
+
if (ctrl && !ctrl.signal.aborted) {
|
|
1281
|
+
try { ctrl.abort(); } catch { /* best-effort */ }
|
|
1282
|
+
}
|
|
1283
|
+
// Also drop any queued envelopes for VPs being replaced — the
|
|
1284
|
+
// user's new message supersedes them.
|
|
1285
|
+
const inbox = vpInboxes.get(k);
|
|
1286
|
+
if (inbox && inbox.length > 0) {
|
|
1287
|
+
inbox.length = 0;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
} catch (err) {
|
|
1291
|
+
console.warn('[Unify] unify_group_chat: selective abort pre-pass failed', err?.message || err);
|
|
1292
|
+
}
|
|
866
1293
|
|
|
1294
|
+
// Ingest user text. The coordinator persists, applies mention/fanout
|
|
1295
|
+
// rules, and calls deliver() (== enqueueForVp) for each chosen VP —
|
|
1296
|
+
// which both (a) emits vp_typing_start and (b) ensures a driver runs.
|
|
867
1297
|
let report;
|
|
868
1298
|
try {
|
|
869
1299
|
report = coord.ingest({
|
|
@@ -883,7 +1313,8 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
883
1313
|
}
|
|
884
1314
|
|
|
885
1315
|
const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
|
|
886
|
-
|
|
1316
|
+
const fallbackId = typeof report?.fallback === 'string' ? report.fallback : null;
|
|
1317
|
+
if (dispatchedIds.length === 0 && !fallbackId) {
|
|
887
1318
|
// Coordinator chose nobody and provided no fallback — should not happen
|
|
888
1319
|
// with a healthy roster. Surface the failure explicitly rather than
|
|
889
1320
|
// silently retrying as a single-VP turn (the legacy fallback masked
|
|
@@ -896,73 +1327,15 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
896
1327
|
return;
|
|
897
1328
|
}
|
|
898
1329
|
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
try {
|
|
909
|
-
sendUnifyEvent({
|
|
910
|
-
type: 'group_message',
|
|
911
|
-
groupId,
|
|
912
|
-
vpId,
|
|
913
|
-
speakerVpId: vpId,
|
|
914
|
-
text,
|
|
915
|
-
mentions,
|
|
916
|
-
trigger: envelope?.trigger || 'fallback',
|
|
917
|
-
ts: Date.now(),
|
|
918
|
-
}, { groupId, vpId, turnId });
|
|
919
|
-
} catch { /* never crash WS pipeline */ }
|
|
920
|
-
|
|
921
|
-
try {
|
|
922
|
-
sendUnifyEvent({
|
|
923
|
-
type: 'vp_typing_start',
|
|
924
|
-
groupId,
|
|
925
|
-
vpId,
|
|
926
|
-
turnId,
|
|
927
|
-
ts: Date.now(),
|
|
928
|
-
}, { groupId, vpId, turnId });
|
|
929
|
-
} catch { /* never crash WS pipeline */ }
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// Per-VP parallel fan-out. Each VP gets its own AbortController
|
|
933
|
-
// (stoppable individually via per-VP Stop button) and reads from
|
|
934
|
-
// the shared baseSnapshot. On completion (or abort), results are
|
|
935
|
-
// atomically appended to conversationMessages.
|
|
936
|
-
await Promise.all(captured.map(async ({ vpId }) => {
|
|
937
|
-
const turnId = `${dispatchId}:${vpId}`;
|
|
938
|
-
const vpAbort = new AbortController();
|
|
939
|
-
turnAbortCtrls.set(turnId, vpAbort);
|
|
940
|
-
|
|
941
|
-
try {
|
|
942
|
-
await runVpTurn({
|
|
943
|
-
prompt: `@vp-${vpId} ${text}`,
|
|
944
|
-
groupId,
|
|
945
|
-
vpId,
|
|
946
|
-
turnId,
|
|
947
|
-
groupCoordinator: coord,
|
|
948
|
-
vpAbort,
|
|
949
|
-
baseSnapshot,
|
|
950
|
-
});
|
|
951
|
-
} catch (err) {
|
|
952
|
-
console.warn('[Unify] unify_group_chat: per-vp dispatch failed', vpId, err?.message || err);
|
|
953
|
-
} finally {
|
|
954
|
-
turnAbortCtrls.delete(turnId);
|
|
955
|
-
try {
|
|
956
|
-
sendUnifyEvent({
|
|
957
|
-
type: 'vp_typing_end',
|
|
958
|
-
groupId,
|
|
959
|
-
vpId,
|
|
960
|
-
turnId,
|
|
961
|
-
ts: Date.now(),
|
|
962
|
-
}, { groupId, vpId, turnId });
|
|
963
|
-
} catch { /* never crash WS pipeline */ }
|
|
964
|
-
}
|
|
965
|
-
}));
|
|
1330
|
+
// Wait for the drivers initially scheduled by THIS user message to
|
|
1331
|
+
// drain. We pass the dispatched id list (plus any fallback) for
|
|
1332
|
+
// documentation; the wait function itself blocks on every driver in
|
|
1333
|
+
// the group, so route_forward fan-outs the user didn't mention still
|
|
1334
|
+
// get drained before we return.
|
|
1335
|
+
const primaryTargets = dispatchedIds.length > 0
|
|
1336
|
+
? dispatchedIds.slice()
|
|
1337
|
+
: (fallbackId ? [fallbackId] : []);
|
|
1338
|
+
await waitForVpDrivers(groupId, primaryTargets);
|
|
966
1339
|
|
|
967
1340
|
// Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
|
|
968
1341
|
// history past 20 turns / 80K tokens. Runs in the background — does
|
|
@@ -973,10 +1346,52 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
973
1346
|
scheduleCompactAfterTurn(groupId);
|
|
974
1347
|
}
|
|
975
1348
|
|
|
1349
|
+
/**
|
|
1350
|
+
* Wait for the drivers of `primaryTargets` AND any drivers spawned by
|
|
1351
|
+
* their downstream route_forward chains to all complete. We re-poll
|
|
1352
|
+
* because a route_forward inside VP-A's turn enqueues VP-B, which spawns
|
|
1353
|
+
* a new driver while we're waiting for VP-A. Loop terminates when every
|
|
1354
|
+
* group-local driver is idle.
|
|
1355
|
+
*
|
|
1356
|
+
* `primaryTargets` is informational — for filtering we wait on EVERY
|
|
1357
|
+
* driver in the group, since route_forward fan-out targets the user
|
|
1358
|
+
* never directly mentioned still need to drain before this user message
|
|
1359
|
+
* is considered handled.
|
|
1360
|
+
*
|
|
1361
|
+
* Bounded by the per-driver QUERY_TIMEOUT_MS that runVpTurn enforces,
|
|
1362
|
+
* so even a misbehaving model can't pin this forever.
|
|
1363
|
+
*/
|
|
1364
|
+
async function waitForVpDrivers(groupId, _primaryTargets) {
|
|
1365
|
+
while (true) {
|
|
1366
|
+
// Snapshot the current set of drivers belonging to this group.
|
|
1367
|
+
const promises = [];
|
|
1368
|
+
const prefix = `${groupId}::`;
|
|
1369
|
+
for (const [key, p] of vpDrivers.entries()) {
|
|
1370
|
+
if (key.startsWith(prefix)) promises.push(p);
|
|
1371
|
+
}
|
|
1372
|
+
if (promises.length === 0) return;
|
|
1373
|
+
await Promise.all(promises.map((p) => p.catch(() => {})));
|
|
1374
|
+
// Re-check: if a route_forward in one of the awaited turns
|
|
1375
|
+
// enqueued more work for another VP, a new driver may have been
|
|
1376
|
+
// started. Loop again. Otherwise exit.
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
976
1380
|
/**
|
|
977
1381
|
* Build the per-query VP context for the Engine.
|
|
1382
|
+
*
|
|
1383
|
+
* @param {object} args
|
|
1384
|
+
* @param {string} args.vpId
|
|
1385
|
+
* @param {object} args.groupCoordinator — the persistent coordinator for the
|
|
1386
|
+
* group; used here for `group.getMeta()` (defaultVpId, announcement) and
|
|
1387
|
+
* to bind the per-group router into toolCtx.
|
|
1388
|
+
* @param {string} [args.groupId]
|
|
1389
|
+
* @param {object} [args.envelope] — the inbound coordinator envelope that
|
|
1390
|
+
* triggered this turn. Threaded into toolCtx as `inboundEnvelope` so
|
|
1391
|
+
* `route_forward` can extend `causedBy` chains correctly. Optional only
|
|
1392
|
+
* for pre-707 callers that no longer exist in production.
|
|
978
1393
|
*/
|
|
979
|
-
export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
1394
|
+
export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope }) {
|
|
980
1395
|
// Read the group meta once and reuse for both defaultVpId fallback and
|
|
981
1396
|
// announcement injection. Each .getMeta() reload reads + parses the
|
|
982
1397
|
// group.json file, so calling it twice per turn is wasteful — and
|
|
@@ -1040,6 +1455,14 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
|
1040
1455
|
// Router build failure is non-fatal.
|
|
1041
1456
|
}
|
|
1042
1457
|
}
|
|
1458
|
+
// task-707: thread the inbound envelope into toolCtx so `route_forward`
|
|
1459
|
+
// can stamp `causedBy` chains and the loop guard can key per-sender
|
|
1460
|
+
// throttling against the originating envelope. Safe to omit on a
|
|
1461
|
+
// user-initiated turn — route_forward will fall back to a synthetic
|
|
1462
|
+
// envelope inside router.forward.
|
|
1463
|
+
if (envelope && typeof envelope === 'object') {
|
|
1464
|
+
out.inboundEnvelope = envelope;
|
|
1465
|
+
}
|
|
1043
1466
|
return out;
|
|
1044
1467
|
}
|
|
1045
1468
|
|
|
@@ -1105,15 +1528,23 @@ async function ensureSessionLoaded() {
|
|
|
1105
1528
|
* coordinator-bound router, stream events to the frontend, and append the
|
|
1106
1529
|
* result to the flat conversation history.
|
|
1107
1530
|
*
|
|
1108
|
-
* Private — only `
|
|
1109
|
-
* own AbortController (`vpAbort`) so it can be
|
|
1110
|
-
* shared `baseSnapshot` is the conversation
|
|
1111
|
-
* VP sees another VP's in-flight output.
|
|
1112
|
-
* aborted), the VP's output is atomically
|
|
1531
|
+
* Private — only the per-VP driver in `ensureDriverRunning` calls this.
|
|
1532
|
+
* Each VP-turn gets its own AbortController (`vpAbort`) so it can be
|
|
1533
|
+
* stopped individually. The shared `baseSnapshot` is the conversation
|
|
1534
|
+
* history at fan-out start — no VP sees another VP's in-flight output.
|
|
1535
|
+
* After the turn finishes (or is aborted), the VP's output is atomically
|
|
1536
|
+
* appended to `conversationMessages`.
|
|
1113
1537
|
*
|
|
1114
|
-
*
|
|
1538
|
+
* task-707: takes a coordinator `envelope` rather than the coordinator
|
|
1539
|
+
* itself; the persistent coord lives in `groupContexts[groupId]`. Uses
|
|
1540
|
+
* `getOrCreateVpEngine(groupId, vpId)` so each VP runs against its own
|
|
1541
|
+
* Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
|
|
1542
|
+
* `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
|
|
1543
|
+
* collide when VP-A and VP-B run concurrent turns.
|
|
1544
|
+
*
|
|
1545
|
+
* @param {{ prompt: string, groupId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
|
|
1115
1546
|
*/
|
|
1116
|
-
async function runVpTurn({ prompt, groupId, vpId, turnId,
|
|
1547
|
+
async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
|
|
1117
1548
|
if (!prompt?.trim()) return;
|
|
1118
1549
|
|
|
1119
1550
|
const envelope = { groupId, vpId, turnId };
|
|
@@ -1143,7 +1574,19 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
|
|
|
1143
1574
|
const toolCallsAccum = [];
|
|
1144
1575
|
const toolResultsAccum = [];
|
|
1145
1576
|
|
|
1146
|
-
|
|
1577
|
+
// task-707: per-VP engine + persistent group coord. The coord is
|
|
1578
|
+
// created in handleUnifyGroupChat via getOrCreateGroupContext and
|
|
1579
|
+
// cached on `groupContexts`; we pull it here so route_forward
|
|
1580
|
+
// (router built from this same coord) lands envelopes back on the
|
|
1581
|
+
// right inbox set.
|
|
1582
|
+
const groupCtx = groupContexts.get(groupId);
|
|
1583
|
+
const groupCoordinator = groupCtx?.coord || null;
|
|
1584
|
+
const queryOpts = buildVpQueryOpts({
|
|
1585
|
+
vpId,
|
|
1586
|
+
groupCoordinator,
|
|
1587
|
+
groupId,
|
|
1588
|
+
envelope: inboundEnvelope,
|
|
1589
|
+
});
|
|
1147
1590
|
const handlerCtx = {
|
|
1148
1591
|
assistantTextParts,
|
|
1149
1592
|
toolCallsAccum,
|
|
@@ -1160,7 +1603,8 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
|
|
|
1160
1603
|
const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
|
|
1161
1604
|
messageTokenBudget: session?.config?.messageTokenBudget,
|
|
1162
1605
|
});
|
|
1163
|
-
|
|
1606
|
+
const vpEngine = getOrCreateVpEngine(groupId, vpId);
|
|
1607
|
+
for await (const event of vpEngine.query({
|
|
1164
1608
|
prompt,
|
|
1165
1609
|
messages: trimmedMessages,
|
|
1166
1610
|
signal: vpAbort.signal,
|
|
@@ -1413,6 +1857,28 @@ async function runCompactNow(groupId) {
|
|
|
1413
1857
|
}
|
|
1414
1858
|
}
|
|
1415
1859
|
|
|
1860
|
+
/**
|
|
1861
|
+
* Abort every in-flight VP turn and clear all queued envelopes across
|
|
1862
|
+
* every group. Shared by `handleUnifyAbortThread` and
|
|
1863
|
+
* `handleUnifyAbortAll` — both have the same "stop everything" intent
|
|
1864
|
+
* after the H2.f.2 collapse to single-conversation. Pushes
|
|
1865
|
+
* `vp:<key>` strings into the supplied `aborted` array for the
|
|
1866
|
+
* unify_aborted event.
|
|
1867
|
+
*
|
|
1868
|
+
* @param {string[]} aborted — output array, mutated in place
|
|
1869
|
+
*/
|
|
1870
|
+
function abortAllVpRuntime(aborted) {
|
|
1871
|
+
for (const [key, ctrl] of vpAborts) {
|
|
1872
|
+
try {
|
|
1873
|
+
if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(`vp:${key}`); }
|
|
1874
|
+
} catch { /* best-effort */ }
|
|
1875
|
+
}
|
|
1876
|
+
vpAborts.clear();
|
|
1877
|
+
for (const inbox of vpInboxes.values()) {
|
|
1878
|
+
if (Array.isArray(inbox)) inbox.length = 0;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1416
1882
|
/**
|
|
1417
1883
|
* H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
|
|
1418
1884
|
* `threadId` parameter; the new version aborts the single in-flight
|
|
@@ -1433,6 +1899,7 @@ export function handleUnifyAbortThread(_msg = {}) {
|
|
|
1433
1899
|
try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
|
|
1434
1900
|
}
|
|
1435
1901
|
turnAbortCtrls.clear();
|
|
1902
|
+
abortAllVpRuntime(aborted);
|
|
1436
1903
|
sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
|
|
1437
1904
|
return { aborted, all: false };
|
|
1438
1905
|
}
|
|
@@ -1452,6 +1919,7 @@ export function handleUnifyAbortAll() {
|
|
|
1452
1919
|
try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
|
|
1453
1920
|
}
|
|
1454
1921
|
turnAbortCtrls.clear();
|
|
1922
|
+
abortAllVpRuntime(aborted);
|
|
1455
1923
|
sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
|
|
1456
1924
|
return { aborted, all: true };
|
|
1457
1925
|
}
|
|
@@ -1803,6 +2271,22 @@ export async function resetUnifySession() {
|
|
|
1803
2271
|
}
|
|
1804
2272
|
unifyConversationId = null;
|
|
1805
2273
|
conversationMessages = [];
|
|
2274
|
+
// Re-arm the permission warning. The user might have fixed the
|
|
2275
|
+
// ~/.yeaft/ permissions in the interim and is now restarting the
|
|
2276
|
+
// session — they should see the diagnostic again if it still fails.
|
|
2277
|
+
_permissionDiagnosticSent = false;
|
|
2278
|
+
// Drop all per-VP / per-group transient state when the session is
|
|
2279
|
+
// replaced. Drivers may still be running with a stale engine
|
|
2280
|
+
// reference; abort them so they exit cleanly. The new session gets
|
|
2281
|
+
// fresh inboxes / engines / coords on first dispatch.
|
|
2282
|
+
for (const [, ctrl] of vpAborts) {
|
|
2283
|
+
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
|
|
2284
|
+
}
|
|
2285
|
+
vpAborts.clear();
|
|
2286
|
+
vpInboxes.clear();
|
|
2287
|
+
vpDrivers.clear();
|
|
2288
|
+
vpEngines.clear();
|
|
2289
|
+
groupContexts.clear();
|
|
1806
2290
|
|
|
1807
2291
|
try {
|
|
1808
2292
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|