@yeaft/webchat-agent 0.1.874 → 0.1.875
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/connection/message-router.js +20 -13
- package/package.json +1 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/cli.js +13 -13
- package/yeaft/compact/compactor.js +20 -20
- package/yeaft/conversation/persist.js +95 -95
- package/yeaft/debug-trace.js +12 -12
- package/yeaft/dream-v2/apply.js +15 -15
- package/yeaft/dream-v2/merge.js +12 -12
- package/yeaft/dream-v2/prompts/{extract-group.md → extract-session.md} +12 -12
- package/yeaft/dream-v2/prompts/index.js +3 -3
- package/yeaft/dream-v2/prompts/triage-pass1.md +1 -1
- package/yeaft/dream-v2/runner.js +37 -37
- package/yeaft/dream-v2/segment.js +3 -3
- package/yeaft/dream-v2/session-wiring.js +22 -22
- package/yeaft/dream-v2/state.js +7 -7
- package/yeaft/dream-v2/triage.js +22 -22
- package/yeaft/engine.js +65 -65
- package/yeaft/memory/ams-registry.js +22 -22
- package/yeaft/memory/seed-backfill.js +9 -9
- package/yeaft/memory/store-v2.js +27 -27
- package/yeaft/prompts.js +9 -9
- package/yeaft/routing/loop-guard.js +14 -14
- package/yeaft/routing/router.js +5 -5
- package/yeaft/session.js +5 -5
- package/yeaft/sessions/coordinator.js +96 -20
- package/yeaft/{groups → sessions}/ids.js +3 -3
- package/yeaft/{groups → sessions}/index.js +28 -28
- package/yeaft/sessions/pre-flow.js +178 -42
- package/yeaft/{groups → sessions}/seed-default.js +19 -19
- package/yeaft/{groups/group-config.js → sessions/session-config.js} +29 -29
- package/yeaft/{groups/group-crud.js → sessions/session-crud.js} +113 -113
- package/yeaft/sessions/session-store.js +85 -154
- package/yeaft/stop-hooks.js +4 -4
- package/yeaft/tools/todo-write.js +1 -1
- package/yeaft/tools/types.js +2 -2
- package/yeaft/vp/registry.js +1 -1
- package/yeaft/vp/vp-crud.js +1 -1
- package/yeaft/vp-status-broker.js +28 -28
- package/yeaft/web-bridge.js +411 -398
- package/yeaft/groups/coordinator.js +0 -221
- package/yeaft/groups/group-store.js +0 -212
- package/yeaft/groups/pre-flow.js +0 -329
- /package/yeaft/{groups → sessions}/feature-flag.js +0 -0
- /package/yeaft/{groups → sessions}/project-doc.js +0 -0
- /package/yeaft/{groups → sessions}/roster.js +0 -0
package/yeaft/web-bridge.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* web-bridge.js — Bridge between web UI and Yeaft Yeaft Engine.
|
|
3
3
|
*
|
|
4
4
|
* PR #797: group VP runtime is threaded again. Each group VP can own multiple
|
|
5
|
-
* classified threads, keyed by (
|
|
5
|
+
* classified threads, keyed by (sessionId, vpId, threadId), with separate engine,
|
|
6
6
|
* inbox, abort, todo, persistence, and frontend timeline boundaries. Legacy
|
|
7
7
|
* 1:1 chat paths still use the default `main` thread.
|
|
8
8
|
*
|
|
@@ -30,25 +30,25 @@ import { createVp, updateVp, deleteVp, readVp, VpCrudError } from './vp/vp-crud.
|
|
|
30
30
|
import { scanVpLibrary } from './vp/vp-store.js';
|
|
31
31
|
import { createRouter } from './routing/router.js';
|
|
32
32
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
SessionCrudError,
|
|
34
|
+
createSessionFromSpec,
|
|
35
|
+
renameSession,
|
|
36
|
+
updateSessionAnnouncement,
|
|
37
|
+
archiveSession,
|
|
38
|
+
deleteSession,
|
|
39
|
+
purgeArchivedSessions,
|
|
40
40
|
addMember,
|
|
41
41
|
removeMember,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
} from './
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import { createCoordinator } from './
|
|
51
|
-
import {
|
|
42
|
+
setSessionDefaultVp,
|
|
43
|
+
snapshotSessions,
|
|
44
|
+
resolveSessionYeaftDir,
|
|
45
|
+
sessionsRoot,
|
|
46
|
+
} from './sessions/session-crud.js';
|
|
47
|
+
import { openSession, loadSessionMeta } from './sessions/session-store.js';
|
|
48
|
+
import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
|
|
49
|
+
import { updateSessionConfig } from './sessions/session-crud.js';
|
|
50
|
+
import { createCoordinator } from './sessions/coordinator.js';
|
|
51
|
+
import { seedDefaultSession } from './sessions/seed-default.js';
|
|
52
52
|
import {
|
|
53
53
|
trimSnapshotForBudget,
|
|
54
54
|
} from './history-compact.js';
|
|
@@ -70,7 +70,7 @@ export function __testSetThreadClassifier(fn) {
|
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
72
|
* Tracks scoped-dream triggers that are currently inflight, keyed by
|
|
73
|
-
*
|
|
73
|
+
* sessionId. Used by `handleYeaftDreamTrigger` to reject any overlapping
|
|
74
74
|
* scoped trigger rather than racing the sink-wrapping logic against
|
|
75
75
|
* itself.
|
|
76
76
|
*
|
|
@@ -79,7 +79,7 @@ export function __testSetThreadClassifier(fn) {
|
|
|
79
79
|
* the first's inflight promise and dropped its own scope filter. So
|
|
80
80
|
* "B during A's run" doesn't actually produce a separate scoped pass
|
|
81
81
|
* for B — letting B install a second sink wrapper would only mis-stamp
|
|
82
|
-
* A's events with B's
|
|
82
|
+
* A's events with B's sessionId. Reporting B as an explicit skipped
|
|
83
83
|
* result is the honest answer; the user can re-click after A settles.
|
|
84
84
|
* @type {Set<string>}
|
|
85
85
|
*/
|
|
@@ -90,7 +90,7 @@ const inflightScopedDreamGroups = new Set();
|
|
|
90
90
|
* cancels the prior round (if any).
|
|
91
91
|
*
|
|
92
92
|
* Group VP turns do not flow through this slot. They each get their own
|
|
93
|
-
* controller in `vpAborts` keyed by `${
|
|
93
|
+
* controller in `vpAborts` keyed by `${sessionId}::${vpId}::${threadId}`.
|
|
94
94
|
* The `currentAbortCtrl` here is only mutated by 1:1 chat paths, the test
|
|
95
95
|
* seeder, and the session-reset cleanup. Don't reach for it from group-flow
|
|
96
96
|
* code; selective abort, abort-all, and abort-turn already operate against
|
|
@@ -112,7 +112,7 @@ const turnAbortCtrls = new Map();
|
|
|
112
112
|
/**
|
|
113
113
|
* Per-turn runtime ownership. Targeted thread aborts use this instead of
|
|
114
114
|
* blindly aborting every turn controller in the process.
|
|
115
|
-
* @type {Map<string, {
|
|
115
|
+
* @type {Map<string, { sessionId: string, vpId: string, threadId: string, key: string }>}
|
|
116
116
|
*/
|
|
117
117
|
const turnAbortMeta = new Map();
|
|
118
118
|
|
|
@@ -136,13 +136,13 @@ function getVpStatusBroker() {
|
|
|
136
136
|
// The broker emits both `vp_status_changed` and
|
|
137
137
|
// `vp_status_snapshot`. Both ride the standard sendYeaftEvent
|
|
138
138
|
// envelope so the frontend's existing yeaft_output dispatcher
|
|
139
|
-
// sees them. We stamp
|
|
139
|
+
// sees them. We stamp sessionId/vpId on the envelope for
|
|
140
140
|
// events that target a specific VP so the server's per-client
|
|
141
|
-
// routing (
|
|
141
|
+
// routing (sessionId scoping, etc.) works the same way as
|
|
142
142
|
// typing events.
|
|
143
143
|
const env = {};
|
|
144
144
|
if (event && typeof event === 'object') {
|
|
145
|
-
if (event.
|
|
145
|
+
if (event.sessionId) env.sessionId = event.sessionId;
|
|
146
146
|
if (event.vpId) env.vpId = event.vpId;
|
|
147
147
|
if (event.turnId) env.turnId = event.turnId;
|
|
148
148
|
}
|
|
@@ -168,7 +168,7 @@ function getVpStatusBroker() {
|
|
|
168
168
|
* (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
|
|
169
169
|
* `#abortReason`, `#adjustRanByGroup`, `#execLog`, `#currentThreadId`)
|
|
170
170
|
* does not collide across concurrent VP turns. Engines are keyed by
|
|
171
|
-
* `${
|
|
171
|
+
* `${sessionId}::${vpId}::${threadId}` rather than vpId alone because
|
|
172
172
|
* Engine cannot serve two concurrent queries safely — even if AMS state
|
|
173
173
|
* partitions correctly by groupKey, the non-group-keyed private state
|
|
174
174
|
* would collide if the same VP ran turns in two groups or two threads
|
|
@@ -183,7 +183,7 @@ const vpEngines = new Map();
|
|
|
183
183
|
/** @type {Map<string, AbortController>} */
|
|
184
184
|
const vpAborts = new Map();
|
|
185
185
|
/**
|
|
186
|
-
* Per-(
|
|
186
|
+
* Per-(sessionId, vpId) current TodoWrite list. Each VP in a group keeps
|
|
187
187
|
* its own todo state so two VPs in the same group can independently
|
|
188
188
|
* track multi-step tasks without overwriting each other. Threaded into
|
|
189
189
|
* the engine's tool ctx via buildVpQueryOpts → getCurrentTodos /
|
|
@@ -193,7 +193,7 @@ const vpAborts = new Map();
|
|
|
193
193
|
* loses the "what was the most recent list?" peek without breaking the
|
|
194
194
|
* UI replay.
|
|
195
195
|
*
|
|
196
|
-
* Key: `${
|
|
196
|
+
* Key: `${sessionId}::${vpId}` (matches vpEngines/vpAborts convention).
|
|
197
197
|
* Value: `Array<{content, status, activeForm}>` — the last full list
|
|
198
198
|
* the VP wrote with TodoWrite.
|
|
199
199
|
*
|
|
@@ -202,33 +202,33 @@ const vpAborts = new Map();
|
|
|
202
202
|
const vpCurrentTodos = new Map();
|
|
203
203
|
/**
|
|
204
204
|
* Per-group cached coordinator + router. Created on first
|
|
205
|
-
* `
|
|
205
|
+
* `handleYeaftSessionSend` for a given sessionId; reused across user messages
|
|
206
206
|
* AND across `route_forward` deliveries inside running VP turns (the
|
|
207
207
|
* router is wired into engine ctx; if we recreated coord per turn the
|
|
208
208
|
* route_forward path would deliver into a freshly-created `captured[]`
|
|
209
209
|
* that nobody consumes — exactly the pre-707 bug).
|
|
210
210
|
*
|
|
211
211
|
* Purge sites:
|
|
212
|
-
* - `invalidateGroupContext(
|
|
212
|
+
* - `invalidateGroupContext(sessionId)` — called from every group CRUD
|
|
213
213
|
* handler that mutates roster / meta / lifecycle state on disk
|
|
214
214
|
* (rename, update announcement, archive, delete, add/remove member,
|
|
215
215
|
* set default VP).
|
|
216
|
-
* - `
|
|
216
|
+
* - `handleYeaftSessionSend` — invalidates inline when its own
|
|
217
217
|
* auto-add / default-VP-heal pass mutated the roster.
|
|
218
218
|
* - `resetYeaftSession` and `__testResetVpState` clear the whole map.
|
|
219
219
|
*
|
|
220
220
|
* @type {Map<string, { coord: ReturnType<typeof createCoordinator>,
|
|
221
221
|
* router: ReturnType<typeof createRouter>,
|
|
222
|
-
*
|
|
222
|
+
* sessionHandle: object }>}
|
|
223
223
|
*/
|
|
224
224
|
const groupContexts = new Map();
|
|
225
225
|
|
|
226
|
-
function vpKey(
|
|
227
|
-
return `${
|
|
226
|
+
function vpKey(sessionId, vpId) {
|
|
227
|
+
return `${sessionId}::${vpId}`;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
function threadKey(
|
|
231
|
-
return `${
|
|
230
|
+
function threadKey(sessionId, vpId, threadId) {
|
|
231
|
+
return `${sessionId}::${vpId}::${threadId || 'main'}`;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
function createThreadId() {
|
|
@@ -241,8 +241,8 @@ const vpThreads = new Map();
|
|
|
241
241
|
/** @type {Map<string, Set<Promise<string|null>>>} */
|
|
242
242
|
const routePromisesByMsgId = new Map();
|
|
243
243
|
|
|
244
|
-
function getVpThreadMap(
|
|
245
|
-
const key = vpKey(
|
|
244
|
+
function getVpThreadMap(sessionId, vpId) {
|
|
245
|
+
const key = vpKey(sessionId, vpId);
|
|
246
246
|
let map = vpThreads.get(key);
|
|
247
247
|
if (!map) {
|
|
248
248
|
map = new Map();
|
|
@@ -251,20 +251,20 @@ function getVpThreadMap(groupId, vpId) {
|
|
|
251
251
|
return map;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
function getRunningThreads(
|
|
255
|
-
return Array.from(getVpThreadMap(
|
|
254
|
+
function getRunningThreads(sessionId, vpId) {
|
|
255
|
+
return Array.from(getVpThreadMap(sessionId, vpId).values())
|
|
256
256
|
.filter(t => t && RUNNING_THREAD_STATES.has(t.status));
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
function getOrCreateVpThread({
|
|
260
|
-
const map = getVpThreadMap(
|
|
259
|
+
function getOrCreateVpThread({ sessionId, vpId, threadId, title }) {
|
|
260
|
+
const map = getVpThreadMap(sessionId, vpId);
|
|
261
261
|
const id = threadId || createThreadId();
|
|
262
262
|
let thread = map.get(id);
|
|
263
263
|
const now = Date.now();
|
|
264
264
|
if (!thread) {
|
|
265
265
|
thread = {
|
|
266
266
|
threadId: id,
|
|
267
|
-
|
|
267
|
+
sessionId,
|
|
268
268
|
vpId,
|
|
269
269
|
status: 'queued',
|
|
270
270
|
title: title || '新任务',
|
|
@@ -348,23 +348,23 @@ async function waitForRoutePromises(msgId) {
|
|
|
348
348
|
* Drop the cached coordinator + router for a group AND abort/clear any
|
|
349
349
|
* in-flight VP turns belonging to it. Call this from every CRUD handler
|
|
350
350
|
* that mutates the group's roster, meta, or lifecycle state on disk —
|
|
351
|
-
* the cached coord holds a closed `
|
|
351
|
+
* the cached coord holds a closed `sessionHandle`, so without invalidation
|
|
352
352
|
* later route_forward / ingest calls would read zombie meta (stale
|
|
353
353
|
* roster, pre-rename announcement, kicked members still routable).
|
|
354
354
|
*
|
|
355
355
|
* Idempotent — safe to call when no entry exists.
|
|
356
356
|
*/
|
|
357
|
-
function invalidateGroupContext(
|
|
358
|
-
if (!
|
|
359
|
-
groupContexts.delete(
|
|
360
|
-
const prefix = `${
|
|
357
|
+
function invalidateGroupContext(sessionId) {
|
|
358
|
+
if (!sessionId) return;
|
|
359
|
+
groupContexts.delete(sessionId);
|
|
360
|
+
const prefix = `${sessionId}::`;
|
|
361
361
|
for (const [k, ctrl] of vpAborts) {
|
|
362
362
|
if (!k.startsWith(prefix)) continue;
|
|
363
363
|
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
|
|
364
364
|
vpAborts.delete(k);
|
|
365
365
|
}
|
|
366
366
|
for (const [turnId, meta] of Array.from(turnAbortMeta.entries())) {
|
|
367
|
-
if (meta?.
|
|
367
|
+
if (meta?.sessionId !== sessionId) continue;
|
|
368
368
|
turnAbortCtrls.delete(turnId);
|
|
369
369
|
turnAbortMeta.delete(turnId);
|
|
370
370
|
}
|
|
@@ -382,7 +382,7 @@ function invalidateGroupContext(groupId) {
|
|
|
382
382
|
}
|
|
383
383
|
// Engines are NOT torn down here on purpose. They hold subordinate
|
|
384
384
|
// state (AMS adjustments) that should survive a meta change and a
|
|
385
|
-
// closed
|
|
385
|
+
// closed sessionHandle — they don't reach the on-disk group meta
|
|
386
386
|
// directly. They *are* dropped on `resetYeaftSession`.
|
|
387
387
|
}
|
|
388
388
|
|
|
@@ -458,7 +458,7 @@ let _vpUnsubscribe = null;
|
|
|
458
458
|
|
|
459
459
|
/**
|
|
460
460
|
* Per-group conversation history lives on the GroupContext entry
|
|
461
|
-
* (`groupContexts.get(
|
|
461
|
+
* (`groupContexts.get(sessionId).history`). The pre-refactor module-level
|
|
462
462
|
* `conversationMessages` was a single array shared across every group —
|
|
463
463
|
* a user prompt in group-A would leak into group-B's next-turn snapshot
|
|
464
464
|
* because the bridge appended every turn to the same array regardless
|
|
@@ -466,7 +466,7 @@ let _vpUnsubscribe = null;
|
|
|
466
466
|
* the in-memory tape was unified.
|
|
467
467
|
*
|
|
468
468
|
* Post-refactor: each GroupContext owns its own `history`, lazily
|
|
469
|
-
* hydrated from `conversationStore.loadRecentByGroup(
|
|
469
|
+
* hydrated from `conversationStore.loadRecentByGroup(sessionId)` on first
|
|
470
470
|
* access. Group-A and group-B are isolated.
|
|
471
471
|
*
|
|
472
472
|
* @typedef {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>} GroupHistory
|
|
@@ -474,9 +474,9 @@ let _vpUnsubscribe = null;
|
|
|
474
474
|
|
|
475
475
|
/**
|
|
476
476
|
* @typedef {Object} GroupContextEntry
|
|
477
|
-
* @property {object|null} coord — group coordinator (lazily built by
|
|
478
|
-
* @property {object|null} router — message router (lazily built by
|
|
479
|
-
* @property {object|null}
|
|
477
|
+
* @property {object|null} coord — group coordinator (lazily built by getOrCreateSessionContext)
|
|
478
|
+
* @property {object|null} router — message router (lazily built by getOrCreateSessionContext)
|
|
479
|
+
* @property {object|null} sessionHandle — opened group handle (lazily built by getOrCreateSessionContext)
|
|
480
480
|
* @property {GroupHistory} history — per-group conversation tape
|
|
481
481
|
* @property {boolean} historyHydrated — true once history has been loaded
|
|
482
482
|
* from disk (or explicitly assigned). The flag is required because an
|
|
@@ -491,7 +491,7 @@ function makeGroupContextStub() {
|
|
|
491
491
|
return {
|
|
492
492
|
coord: null,
|
|
493
493
|
router: null,
|
|
494
|
-
|
|
494
|
+
sessionHandle: null,
|
|
495
495
|
history: [],
|
|
496
496
|
historyHydrated: false,
|
|
497
497
|
};
|
|
@@ -564,7 +564,7 @@ function projectPersistedToHistoryEntry(m) {
|
|
|
564
564
|
if (m.id) entry.id = m.id;
|
|
565
565
|
entry.threadId = m.threadId || m.turnId || 'main';
|
|
566
566
|
entry.turnId = m.turnId || entry.threadId;
|
|
567
|
-
if (m.
|
|
567
|
+
if (m.sessionId) entry.sessionId = m.sessionId;
|
|
568
568
|
if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
|
|
569
569
|
if (m.toolCallId) entry.toolCallId = m.toolCallId;
|
|
570
570
|
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
@@ -597,13 +597,13 @@ function hydrateHistoryAttachmentPreviews(attachments) {
|
|
|
597
597
|
});
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
-
function loadVisibleGroupHistoryPage(store,
|
|
601
|
-
if (!store || !
|
|
600
|
+
function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null) {
|
|
601
|
+
if (!store || !sessionId || !(limit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
|
|
602
602
|
|
|
603
603
|
let rows = [];
|
|
604
604
|
try {
|
|
605
605
|
if (typeof store.loadVisibleByGroup === 'function') {
|
|
606
|
-
const page = store.loadVisibleByGroup(
|
|
606
|
+
const page = store.loadVisibleByGroup(sessionId, beforeSeq, limit);
|
|
607
607
|
return {
|
|
608
608
|
messages: (page.messages || []).map(projectPersistedToVisibleHistoryEntry).filter(Boolean),
|
|
609
609
|
oldestSeq: (typeof page.oldestSeq === 'number') ? page.oldestSeq : null,
|
|
@@ -612,16 +612,16 @@ function loadVisibleGroupHistoryPage(store, groupId, limit, beforeSeq = null) {
|
|
|
612
612
|
} else if (typeof store.loadOlderByGroup === 'function') {
|
|
613
613
|
// Compatibility fallback for older test doubles: use an unbounded raw
|
|
614
614
|
// prefix, then project/slice visible rows below.
|
|
615
|
-
rows = store.loadOlderByGroup(
|
|
615
|
+
rows = store.loadOlderByGroup(sessionId, beforeSeq, Infinity).messages || [];
|
|
616
616
|
} else if (Number.isFinite(beforeSeq)) {
|
|
617
617
|
const all = typeof store.loadAllByGroup === 'function'
|
|
618
|
-
? store.loadAllByGroup(
|
|
619
|
-
: store.loadRecentByGroup(
|
|
618
|
+
? store.loadAllByGroup(sessionId)
|
|
619
|
+
: store.loadRecentByGroup(sessionId, Infinity);
|
|
620
620
|
rows = all.filter(m => parseSeqFromId(m?.id) < beforeSeq);
|
|
621
621
|
} else if (typeof store.loadAllByGroup === 'function') {
|
|
622
|
-
rows = store.loadAllByGroup(
|
|
622
|
+
rows = store.loadAllByGroup(sessionId);
|
|
623
623
|
} else {
|
|
624
|
-
rows = store.loadRecentByGroup(
|
|
624
|
+
rows = store.loadRecentByGroup(sessionId, Infinity);
|
|
625
625
|
}
|
|
626
626
|
} catch (err) {
|
|
627
627
|
console.error('[Yeaft] visible history page load failed:', err?.message || err);
|
|
@@ -651,16 +651,16 @@ function loadVisibleGroupHistoryPage(store, groupId, limit, beforeSeq = null) {
|
|
|
651
651
|
* conversation store. Returns an empty array if the session isn't
|
|
652
652
|
* loaded yet (sub-agent / test paths) or if the load throws.
|
|
653
653
|
*
|
|
654
|
-
* @param {string}
|
|
654
|
+
* @param {string} sessionId
|
|
655
655
|
* @returns {GroupHistory}
|
|
656
656
|
*/
|
|
657
|
-
function hydrateGroupHistory(
|
|
658
|
-
if (!session?.conversationStore || !
|
|
657
|
+
function hydrateGroupHistory(sessionId) {
|
|
658
|
+
if (!session?.conversationStore || !sessionId) return [];
|
|
659
659
|
let recent;
|
|
660
660
|
try {
|
|
661
|
-
recent = session.conversationStore.loadRecentByGroup(
|
|
661
|
+
recent = session.conversationStore.loadRecentByGroup(sessionId);
|
|
662
662
|
} catch (err) {
|
|
663
|
-
console.warn('[Yeaft] hydrateGroupHistory failed (
|
|
663
|
+
console.warn('[Yeaft] hydrateGroupHistory failed (sessionId=%s):', sessionId, err?.message || err);
|
|
664
664
|
return [];
|
|
665
665
|
}
|
|
666
666
|
const out = [];
|
|
@@ -675,7 +675,7 @@ function hydrateGroupHistory(groupId) {
|
|
|
675
675
|
* Get-or-create the per-group history array. Used everywhere the bridge
|
|
676
676
|
* needs to read/append/snapshot a group's conversation tape. Lazily
|
|
677
677
|
* inserts an entry into `groupContexts` on first access — no
|
|
678
|
-
* `
|
|
678
|
+
* `sessionHandle` required (history is independent of coord/router
|
|
679
679
|
* lifecycle, so a sub-agent / route_forward path that hasn't yet
|
|
680
680
|
* opened the group can still read history).
|
|
681
681
|
*
|
|
@@ -684,24 +684,24 @@ function hydrateGroupHistory(groupId) {
|
|
|
684
684
|
* compact (race guard checks reference equality), `consolidate`
|
|
685
685
|
* events, and session reset.
|
|
686
686
|
*
|
|
687
|
-
* @param {string}
|
|
687
|
+
* @param {string} sessionId
|
|
688
688
|
* @returns {GroupHistory}
|
|
689
689
|
*/
|
|
690
|
-
function getOrCreateGroupHistory(
|
|
691
|
-
if (!
|
|
692
|
-
let entry = groupContexts.get(
|
|
690
|
+
function getOrCreateGroupHistory(sessionId) {
|
|
691
|
+
if (!sessionId) return [];
|
|
692
|
+
let entry = groupContexts.get(sessionId);
|
|
693
693
|
// Use `historyHydrated` rather than truthiness on `history` itself —
|
|
694
694
|
// an empty array (post-consolidate, post-clear, or a partial entry
|
|
695
|
-
// seeded by an early `
|
|
695
|
+
// seeded by an early `getOrCreateSessionContext` call before data was
|
|
696
696
|
// loaded) is legitimate state that does NOT mean "needs hydration"...
|
|
697
697
|
// unless we never loaded from disk in the first place. The flag
|
|
698
698
|
// separates the two cases.
|
|
699
699
|
if (entry && entry.historyHydrated) return entry.history;
|
|
700
700
|
if (!entry) {
|
|
701
701
|
entry = makeGroupContextStub();
|
|
702
|
-
groupContexts.set(
|
|
702
|
+
groupContexts.set(sessionId, entry);
|
|
703
703
|
}
|
|
704
|
-
entry.history = hydrateGroupHistory(
|
|
704
|
+
entry.history = hydrateGroupHistory(sessionId);
|
|
705
705
|
entry.historyHydrated = true;
|
|
706
706
|
return entry.history;
|
|
707
707
|
}
|
|
@@ -715,15 +715,15 @@ function getOrCreateGroupHistory(groupId) {
|
|
|
715
715
|
* a hydration — even setting `[]` after `consolidate` means "this is the
|
|
716
716
|
* canonical state right now, don't re-load from disk".
|
|
717
717
|
*
|
|
718
|
-
* @param {string}
|
|
718
|
+
* @param {string} sessionId
|
|
719
719
|
* @param {GroupHistory} next
|
|
720
720
|
*/
|
|
721
|
-
function setGroupHistory(
|
|
722
|
-
if (!
|
|
723
|
-
let entry = groupContexts.get(
|
|
721
|
+
function setGroupHistory(sessionId, next) {
|
|
722
|
+
if (!sessionId) return;
|
|
723
|
+
let entry = groupContexts.get(sessionId);
|
|
724
724
|
if (!entry) {
|
|
725
725
|
entry = makeGroupContextStub();
|
|
726
|
-
groupContexts.set(
|
|
726
|
+
groupContexts.set(sessionId, entry);
|
|
727
727
|
}
|
|
728
728
|
entry.history = next;
|
|
729
729
|
entry.historyHydrated = true;
|
|
@@ -734,10 +734,10 @@ function setGroupHistory(groupId, next) {
|
|
|
734
734
|
* `__testGroupHistory`. Lets tests pin the per-group isolation contract
|
|
735
735
|
* without booting a full session.
|
|
736
736
|
*
|
|
737
|
-
* @param {string}
|
|
737
|
+
* @param {string} sessionId
|
|
738
738
|
*/
|
|
739
|
-
export function __testGroupHistory(
|
|
740
|
-
return getOrCreateGroupHistory(
|
|
739
|
+
export function __testGroupHistory(sessionId) {
|
|
740
|
+
return getOrCreateGroupHistory(sessionId);
|
|
741
741
|
}
|
|
742
742
|
|
|
743
743
|
/**
|
|
@@ -746,8 +746,8 @@ export function __testGroupHistory(groupId) {
|
|
|
746
746
|
*
|
|
747
747
|
* Tests that need to verify the hydrate-from-disk path can construct a
|
|
748
748
|
* `ConversationStore` against a tmp dir, write per-group records via
|
|
749
|
-
* `store.append({
|
|
750
|
-
* store into the bridge before calling `__testGroupHistory(
|
|
749
|
+
* `store.append({sessionId, ...})`, then call this helper to wire the
|
|
750
|
+
* store into the bridge before calling `__testGroupHistory(sessionId)`.
|
|
751
751
|
*
|
|
752
752
|
* @param {{ conversationStore: object } | null} sessionLike
|
|
753
753
|
*/
|
|
@@ -760,10 +760,10 @@ export function __testSetSession(sessionLike) {
|
|
|
760
760
|
* if never seeded). Lets tests assert the `historyHydrated` flag without
|
|
761
761
|
* exporting the entire `groupContexts` Map.
|
|
762
762
|
*
|
|
763
|
-
* @param {string}
|
|
763
|
+
* @param {string} sessionId
|
|
764
764
|
*/
|
|
765
|
-
export function __testGroupContextEntry(
|
|
766
|
-
return groupContexts.get(
|
|
765
|
+
export function __testGroupContextEntry(sessionId) {
|
|
766
|
+
return groupContexts.get(sessionId);
|
|
767
767
|
}
|
|
768
768
|
|
|
769
769
|
/**
|
|
@@ -772,20 +772,20 @@ export function __testGroupContextEntry(groupId) {
|
|
|
772
772
|
* dependencies (notably `toolStats`) come from the session reference —
|
|
773
773
|
* see `test/agent/web-bridge-vp-engine-tool-stats.test.js`.
|
|
774
774
|
*
|
|
775
|
-
* @param {string}
|
|
775
|
+
* @param {string} sessionId
|
|
776
776
|
* @param {string} vpId
|
|
777
777
|
*/
|
|
778
|
-
export function __testGetOrCreateVpEngine(
|
|
779
|
-
return getOrCreateVpEngine(
|
|
778
|
+
export function __testGetOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
|
|
779
|
+
return getOrCreateVpEngine(sessionId, vpId, threadId);
|
|
780
780
|
}
|
|
781
781
|
|
|
782
782
|
|
|
783
783
|
/** Test-only: inspect runtime thread rows for a VP. */
|
|
784
|
-
export function __testGetVpThreads(
|
|
785
|
-
const map = vpThreads.get(vpKey(
|
|
784
|
+
export function __testGetVpThreads(sessionId, vpId) {
|
|
785
|
+
const map = vpThreads.get(vpKey(sessionId, vpId));
|
|
786
786
|
return Array.from((map || new Map()).values()).map((thread) => ({
|
|
787
787
|
threadId: thread.threadId,
|
|
788
|
-
|
|
788
|
+
sessionId: thread.sessionId,
|
|
789
789
|
vpId: thread.vpId,
|
|
790
790
|
status: thread.status,
|
|
791
791
|
title: thread.title,
|
|
@@ -800,8 +800,8 @@ export async function __testWaitForRoutePromises(msgId) {
|
|
|
800
800
|
}
|
|
801
801
|
|
|
802
802
|
/** Test-only: route one coordinator envelope into the VP thread runtime. */
|
|
803
|
-
export function __testEnqueueForVp(
|
|
804
|
-
return enqueueForVp(
|
|
803
|
+
export function __testEnqueueForVp(sessionId, vpId, envelope) {
|
|
804
|
+
return enqueueForVp(sessionId, vpId, envelope);
|
|
805
805
|
}
|
|
806
806
|
|
|
807
807
|
/** Whether we've already sent a permission warning to the UI */
|
|
@@ -825,12 +825,12 @@ function isPermissionErrorMsg(msg) {
|
|
|
825
825
|
* adapter / trace / config / stores so memory recall, conversation
|
|
826
826
|
* persistence, and tool registry remain consistent.
|
|
827
827
|
*
|
|
828
|
-
* @param {string}
|
|
828
|
+
* @param {string} sessionId
|
|
829
829
|
* @param {string} vpId
|
|
830
830
|
* @returns {import('./engine.js').Engine}
|
|
831
831
|
*/
|
|
832
|
-
function getOrCreateVpEngine(
|
|
833
|
-
const key = threadKey(
|
|
832
|
+
function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
|
|
833
|
+
const key = threadKey(sessionId, vpId, threadId);
|
|
834
834
|
let eng = vpEngines.get(key);
|
|
835
835
|
if (eng) return eng;
|
|
836
836
|
if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
|
|
@@ -838,8 +838,8 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
|
|
|
838
838
|
// session's user-level config when no override is set. The resolver
|
|
839
839
|
// never mutates session.config — it returns a new object.
|
|
840
840
|
const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir;
|
|
841
|
-
const groupCfg =
|
|
842
|
-
const effectiveConfig =
|
|
841
|
+
const groupCfg = loadSessionConfig(yeaftDir, sessionId);
|
|
842
|
+
const effectiveConfig = resolveSessionConfig(session.config, groupCfg);
|
|
843
843
|
eng = new Engine({
|
|
844
844
|
adapter: session.adapter,
|
|
845
845
|
trace: session.trace,
|
|
@@ -857,11 +857,11 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
|
|
|
857
857
|
// (`if (this.#toolStats && ...)`) is false and group VP tool calls
|
|
858
858
|
// are silently dropped.
|
|
859
859
|
toolStats: session.toolStats || null,
|
|
860
|
-
// Per-VP fan-out: bind the engine to its (
|
|
860
|
+
// Per-VP fan-out: bind the engine to its (sessionId, vpId) so post-turn
|
|
861
861
|
// compact reads/writes a scoped summary instead of the legacy global
|
|
862
862
|
// compact.md (which every VP would otherwise share, producing
|
|
863
863
|
// identical, ever-growing summaries across groups).
|
|
864
|
-
|
|
864
|
+
sessionId,
|
|
865
865
|
vpId,
|
|
866
866
|
});
|
|
867
867
|
vpEngines.set(key, eng);
|
|
@@ -873,42 +873,42 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
|
|
|
873
873
|
*
|
|
874
874
|
* The coordinator MUST be reused across user turns AND across in-flight
|
|
875
875
|
* tool calls (route_forward) — its `deliver` callback is the only way
|
|
876
|
-
* envelopes reach `vpInboxes`. If we recreated it per `
|
|
876
|
+
* envelopes reach `vpInboxes`. If we recreated it per `handleYeaftSessionSend`
|
|
877
877
|
* call (the pre-707 design), `route_forward` running mid-turn would
|
|
878
878
|
* deliver into a doomed `captured[]` while the new dispatch ran against
|
|
879
879
|
* a fresh coordinator. The persistent coordinator + module-level inboxes
|
|
880
880
|
* close that gap.
|
|
881
881
|
*
|
|
882
|
-
* Caller is responsible for passing in a freshly-opened
|
|
882
|
+
* Caller is responsible for passing in a freshly-opened sessionHandle on
|
|
883
883
|
* first creation; subsequent calls reuse the cached coord.
|
|
884
884
|
*
|
|
885
|
-
* @param {string}
|
|
886
|
-
* @param {object}
|
|
887
|
-
* @returns {{ coord: object, router: object,
|
|
885
|
+
* @param {string} sessionId
|
|
886
|
+
* @param {object} sessionHandle — only used on first creation
|
|
887
|
+
* @returns {{ coord: object, router: object, sessionHandle: object }}
|
|
888
888
|
*/
|
|
889
|
-
function
|
|
890
|
-
let entry = groupContexts.get(
|
|
889
|
+
function getOrCreateSessionContext(sessionId, sessionHandle) {
|
|
890
|
+
let entry = groupContexts.get(sessionId);
|
|
891
891
|
if (entry && entry.coord && entry.router) return entry;
|
|
892
892
|
// Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
|
|
893
893
|
// (no coord/router yet). Build the coord/router and merge into the
|
|
894
894
|
// existing record so the per-group history reference and hydration
|
|
895
895
|
// flag are preserved.
|
|
896
|
-
const coord = createCoordinator(
|
|
897
|
-
deliver: (vpId, envelope) => enqueueForVp(
|
|
896
|
+
const coord = createCoordinator(sessionHandle, {
|
|
897
|
+
deliver: (vpId, envelope) => enqueueForVp(sessionId, vpId, envelope),
|
|
898
898
|
});
|
|
899
899
|
const router = createRouter({ coordinator: coord });
|
|
900
900
|
if (!entry) {
|
|
901
901
|
entry = makeGroupContextStub();
|
|
902
|
-
groupContexts.set(
|
|
902
|
+
groupContexts.set(sessionId, entry);
|
|
903
903
|
}
|
|
904
904
|
entry.coord = coord;
|
|
905
905
|
entry.router = router;
|
|
906
|
-
entry.
|
|
906
|
+
entry.sessionHandle = sessionHandle;
|
|
907
907
|
// Defend against a future caller that builds a coord/router without
|
|
908
908
|
// having gone through `getOrCreateGroupHistory` first: a partial entry
|
|
909
909
|
// could exist with `historyHydrated:false`, so do the load now.
|
|
910
910
|
if (!entry.historyHydrated) {
|
|
911
|
-
entry.history = hydrateGroupHistory(
|
|
911
|
+
entry.history = hydrateGroupHistory(sessionId);
|
|
912
912
|
entry.historyHydrated = true;
|
|
913
913
|
}
|
|
914
914
|
return entry;
|
|
@@ -923,23 +923,23 @@ function getOrCreateGroupContext(groupId, groupHandle) {
|
|
|
923
923
|
* `route_forward` makes the target VP's typing dot light up before the
|
|
924
924
|
* engine even starts that turn.
|
|
925
925
|
*
|
|
926
|
-
* @param {string}
|
|
926
|
+
* @param {string} sessionId
|
|
927
927
|
* @param {string} vpId
|
|
928
|
-
* @param {object} envelope — coordinator envelope `{
|
|
928
|
+
* @param {object} envelope — coordinator envelope `{sessionId, taskId, msg, trigger}`
|
|
929
929
|
*/
|
|
930
|
-
function enqueueForVp(
|
|
931
|
-
const routePromise = routeEnvelopeToVpThread(
|
|
930
|
+
function enqueueForVp(sessionId, vpId, envelope) {
|
|
931
|
+
const routePromise = routeEnvelopeToVpThread(sessionId, vpId, envelope);
|
|
932
932
|
registerRoutePromise(envelope?.msg?.id, routePromise);
|
|
933
933
|
}
|
|
934
934
|
|
|
935
|
-
async function routeEnvelopeToVpThread(
|
|
935
|
+
async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
|
|
936
936
|
const { text, prompt, promptParts } = buildVpPromptPayload(vpId, envelope);
|
|
937
|
-
const runningThreads = getRunningThreads(
|
|
937
|
+
const runningThreads = getRunningThreads(sessionId, vpId);
|
|
938
938
|
let thread = null;
|
|
939
939
|
let related = false;
|
|
940
940
|
|
|
941
941
|
if (runningThreads.length === 0) {
|
|
942
|
-
thread = getOrCreateVpThread({
|
|
942
|
+
thread = getOrCreateVpThread({ sessionId, vpId, title: fallbackTitle(text) });
|
|
943
943
|
} else {
|
|
944
944
|
const decision = await threadClassifier({
|
|
945
945
|
adapter: session?.adapter,
|
|
@@ -951,7 +951,7 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
|
|
|
951
951
|
const targetIsRunning = runningThreads.some((t) => t.threadId === decision.targetThreadId);
|
|
952
952
|
if (decision.decision === 'related' && decision.targetThreadId && targetIsRunning) {
|
|
953
953
|
thread = getOrCreateVpThread({
|
|
954
|
-
|
|
954
|
+
sessionId,
|
|
955
955
|
vpId,
|
|
956
956
|
threadId: decision.targetThreadId,
|
|
957
957
|
title: decision.title,
|
|
@@ -959,7 +959,7 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
|
|
|
959
959
|
related = true;
|
|
960
960
|
} else {
|
|
961
961
|
thread = getOrCreateVpThread({
|
|
962
|
-
|
|
962
|
+
sessionId,
|
|
963
963
|
vpId,
|
|
964
964
|
title: decision.title || fallbackTitle(text),
|
|
965
965
|
});
|
|
@@ -976,7 +976,7 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
|
|
|
976
976
|
persistInboundMessageOnceByMsgId({
|
|
977
977
|
msgId: envelope?.msg?.id,
|
|
978
978
|
text,
|
|
979
|
-
|
|
979
|
+
sessionId,
|
|
980
980
|
threadId: thread.threadId,
|
|
981
981
|
role: envelope?.msg?.meta?.injectedBy === 'route_forward' ? 'assistant' : 'user',
|
|
982
982
|
speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
|
|
@@ -986,18 +986,18 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
|
|
|
986
986
|
try {
|
|
987
987
|
sendYeaftEvent({
|
|
988
988
|
type: 'vp_thread_user_appended',
|
|
989
|
-
|
|
989
|
+
sessionId,
|
|
990
990
|
vpId,
|
|
991
991
|
threadId: thread.threadId,
|
|
992
992
|
title: thread.title,
|
|
993
993
|
turnId,
|
|
994
994
|
ts: Date.now(),
|
|
995
|
-
}, {
|
|
995
|
+
}, { sessionId, vpId, threadId: thread.threadId, turnId });
|
|
996
996
|
} catch { /* never crash WS pipeline */ }
|
|
997
997
|
return thread.threadId;
|
|
998
998
|
}
|
|
999
999
|
|
|
1000
|
-
const key = threadKey(
|
|
1000
|
+
const key = threadKey(sessionId, vpId, thread.threadId);
|
|
1001
1001
|
let inbox = vpInboxes.get(key);
|
|
1002
1002
|
if (!inbox) {
|
|
1003
1003
|
inbox = [];
|
|
@@ -1008,18 +1008,18 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
|
|
|
1008
1008
|
try {
|
|
1009
1009
|
sendYeaftEvent({
|
|
1010
1010
|
type: 'vp_typing_start',
|
|
1011
|
-
|
|
1011
|
+
sessionId,
|
|
1012
1012
|
vpId,
|
|
1013
1013
|
threadId: thread.threadId,
|
|
1014
1014
|
turnId,
|
|
1015
1015
|
ts: Date.now(),
|
|
1016
|
-
}, {
|
|
1016
|
+
}, { sessionId, vpId, threadId: thread.threadId, turnId });
|
|
1017
1017
|
} catch { /* never crash WS pipeline */ }
|
|
1018
1018
|
|
|
1019
1019
|
try {
|
|
1020
1020
|
thread.status = 'typing';
|
|
1021
1021
|
getVpStatusBroker().transition({
|
|
1022
|
-
|
|
1022
|
+
sessionId,
|
|
1023
1023
|
vpId,
|
|
1024
1024
|
threadId: thread.threadId,
|
|
1025
1025
|
title: thread.title,
|
|
@@ -1031,24 +1031,24 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
|
|
|
1031
1031
|
console.warn('[Yeaft] vp-status typing transition failed:', err?.message || err);
|
|
1032
1032
|
}
|
|
1033
1033
|
|
|
1034
|
-
ensureDriverRunning(
|
|
1034
|
+
ensureDriverRunning(sessionId, vpId, thread.threadId);
|
|
1035
1035
|
return thread.threadId;
|
|
1036
1036
|
}
|
|
1037
1037
|
|
|
1038
|
-
function ensureDriverRunning(
|
|
1039
|
-
const key = threadKey(
|
|
1038
|
+
function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
|
|
1039
|
+
const key = threadKey(sessionId, vpId, threadId);
|
|
1040
1040
|
if (vpDrivers.has(key)) return;
|
|
1041
1041
|
const promise = (async () => {
|
|
1042
1042
|
while (true) {
|
|
1043
1043
|
const inbox = vpInboxes.get(key);
|
|
1044
1044
|
if (!inbox || inbox.length === 0) break;
|
|
1045
1045
|
const { envelope, turnId, thread: queuedThread } = inbox.shift();
|
|
1046
|
-
const thread = queuedThread || getOrCreateVpThread({
|
|
1046
|
+
const thread = queuedThread || getOrCreateVpThread({ sessionId, vpId, threadId });
|
|
1047
1047
|
const vpAbort = new AbortController();
|
|
1048
1048
|
vpAborts.set(key, vpAbort);
|
|
1049
1049
|
turnAbortCtrls.set(turnId, vpAbort);
|
|
1050
|
-
turnAbortMeta.set(turnId, {
|
|
1051
|
-
const baseSnapshot = getOrCreateGroupHistory(
|
|
1050
|
+
turnAbortMeta.set(turnId, { sessionId, vpId, threadId: thread.threadId, key });
|
|
1051
|
+
const baseSnapshot = getOrCreateGroupHistory(sessionId)
|
|
1052
1052
|
.filter((m) => !m.threadId || m.threadId === 'main' || m.threadId === thread.threadId);
|
|
1053
1053
|
const trigger = envelope?.trigger || 'fallback';
|
|
1054
1054
|
const { text, prompt, promptParts } = buildVpPromptPayload(vpId, envelope);
|
|
@@ -1062,7 +1062,7 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
1062
1062
|
persistInboundMessageOnceByMsgId({
|
|
1063
1063
|
msgId: envMsgId,
|
|
1064
1064
|
text,
|
|
1065
|
-
|
|
1065
|
+
sessionId,
|
|
1066
1066
|
threadId: thread.threadId,
|
|
1067
1067
|
role: isForward ? 'assistant' : 'user',
|
|
1068
1068
|
speakerVpId: senderVpId,
|
|
@@ -1075,7 +1075,7 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
1075
1075
|
await runVpTurnWithEscalation({
|
|
1076
1076
|
prompt,
|
|
1077
1077
|
promptParts,
|
|
1078
|
-
|
|
1078
|
+
sessionId,
|
|
1079
1079
|
vpId,
|
|
1080
1080
|
threadId: thread.threadId,
|
|
1081
1081
|
thread,
|
|
@@ -1093,19 +1093,19 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
1093
1093
|
try {
|
|
1094
1094
|
sendYeaftEvent({
|
|
1095
1095
|
type: 'vp_typing_end',
|
|
1096
|
-
|
|
1096
|
+
sessionId,
|
|
1097
1097
|
vpId,
|
|
1098
1098
|
threadId: thread.threadId,
|
|
1099
1099
|
turnId,
|
|
1100
1100
|
ts: Date.now(),
|
|
1101
|
-
}, {
|
|
1101
|
+
}, { sessionId, vpId, threadId: thread.threadId, turnId });
|
|
1102
1102
|
} catch { /* never crash WS pipeline */ }
|
|
1103
1103
|
}
|
|
1104
1104
|
try {
|
|
1105
1105
|
if (text && envelope?.msg) {
|
|
1106
1106
|
sendYeaftEvent({
|
|
1107
1107
|
type: 'group_message',
|
|
1108
|
-
|
|
1108
|
+
sessionId,
|
|
1109
1109
|
vpId,
|
|
1110
1110
|
threadId: thread.threadId,
|
|
1111
1111
|
speakerVpId: vpId,
|
|
@@ -1113,7 +1113,7 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
1113
1113
|
mentions: Array.isArray(envelope?.msg?.mentions) ? envelope.msg.mentions : [],
|
|
1114
1114
|
trigger,
|
|
1115
1115
|
ts: Date.now(),
|
|
1116
|
-
}, {
|
|
1116
|
+
}, { sessionId, vpId, threadId: thread.threadId, turnId });
|
|
1117
1117
|
}
|
|
1118
1118
|
} catch { /* never crash WS pipeline */ }
|
|
1119
1119
|
|
|
@@ -1142,7 +1142,7 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
1142
1142
|
if (!replayText && !replayParts) continue;
|
|
1143
1143
|
const followUpId = `followup_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
1144
1144
|
const followUpEnvelope = {
|
|
1145
|
-
|
|
1145
|
+
sessionId,
|
|
1146
1146
|
taskId: envelope?.taskId || null,
|
|
1147
1147
|
trigger: 'pending_rescue',
|
|
1148
1148
|
msg: {
|
|
@@ -1159,7 +1159,7 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
1159
1159
|
thread.status = 'typing';
|
|
1160
1160
|
thread.updatedAt = Date.now();
|
|
1161
1161
|
getVpStatusBroker().transition({
|
|
1162
|
-
|
|
1162
|
+
sessionId,
|
|
1163
1163
|
vpId,
|
|
1164
1164
|
threadId: thread.threadId,
|
|
1165
1165
|
title: thread.title || '',
|
|
@@ -1173,19 +1173,19 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
1173
1173
|
try {
|
|
1174
1174
|
sendYeaftEvent({
|
|
1175
1175
|
type: 'vp_typing_start',
|
|
1176
|
-
|
|
1176
|
+
sessionId,
|
|
1177
1177
|
vpId,
|
|
1178
1178
|
threadId: thread.threadId,
|
|
1179
1179
|
turnId: followUpTurnId,
|
|
1180
1180
|
ts: Date.now(),
|
|
1181
|
-
}, {
|
|
1181
|
+
}, { sessionId, vpId, threadId: thread.threadId, turnId: followUpTurnId });
|
|
1182
1182
|
} catch { /* never crash WS pipeline */ }
|
|
1183
1183
|
}
|
|
1184
1184
|
}
|
|
1185
1185
|
}
|
|
1186
1186
|
vpDrivers.delete(key);
|
|
1187
1187
|
const tail = vpInboxes.get(key);
|
|
1188
|
-
if (tail && tail.length > 0) ensureDriverRunning(
|
|
1188
|
+
if (tail && tail.length > 0) ensureDriverRunning(sessionId, vpId, threadId);
|
|
1189
1189
|
})();
|
|
1190
1190
|
vpDrivers.set(key, promise);
|
|
1191
1191
|
}
|
|
@@ -1240,13 +1240,13 @@ export async function __testResetVpState() {
|
|
|
1240
1240
|
|
|
1241
1241
|
/**
|
|
1242
1242
|
* Send a yeaft_output message carrying claude_output-format data.
|
|
1243
|
-
* Envelope fields: conversationId,
|
|
1243
|
+
* Envelope fields: conversationId, sessionId, vpId, turnId, threadId let the
|
|
1244
1244
|
* frontend route incremental deltas to the correct per-VP/thread block.
|
|
1245
1245
|
*/
|
|
1246
|
-
function resolveGroupDefaultVpId(
|
|
1247
|
-
if (!
|
|
1246
|
+
function resolveGroupDefaultVpId(sessionId) {
|
|
1247
|
+
if (!sessionId) return null;
|
|
1248
1248
|
try {
|
|
1249
|
-
const meta = ensureGroupCoordinator(
|
|
1249
|
+
const meta = ensureGroupCoordinator(sessionId)?.group?.getMeta?.();
|
|
1250
1250
|
const vpId = typeof meta?.defaultVpId === 'string' ? meta.defaultVpId.trim() : '';
|
|
1251
1251
|
return vpId || null;
|
|
1252
1252
|
} catch {
|
|
@@ -1254,12 +1254,12 @@ function resolveGroupDefaultVpId(groupId) {
|
|
|
1254
1254
|
}
|
|
1255
1255
|
}
|
|
1256
1256
|
|
|
1257
|
-
function sendYeaftOutput(data, {
|
|
1258
|
-
const resolvedVpId = vpId || (
|
|
1257
|
+
function sendYeaftOutput(data, { sessionId, chatId, vpId, turnId, threadId } = {}) {
|
|
1258
|
+
const resolvedVpId = vpId || (sessionId ? resolveGroupDefaultVpId(sessionId) : null);
|
|
1259
1259
|
sendToServer({
|
|
1260
1260
|
type: 'yeaft_output',
|
|
1261
1261
|
conversationId: yeaftConversationId,
|
|
1262
|
-
...(
|
|
1262
|
+
...(sessionId ? { sessionId } : {}),
|
|
1263
1263
|
...(chatId ? { chatId } : {}),
|
|
1264
1264
|
...(resolvedVpId ? { vpId: resolvedVpId } : {}),
|
|
1265
1265
|
...(turnId ? { turnId } : {}),
|
|
@@ -1269,11 +1269,11 @@ function sendYeaftOutput(data, { groupId, chatId, vpId, turnId, threadId } = {})
|
|
|
1269
1269
|
}
|
|
1270
1270
|
|
|
1271
1271
|
/** Send a yeaft_output event (non-claude_output metadata). */
|
|
1272
|
-
function sendYeaftEvent(event, {
|
|
1272
|
+
function sendYeaftEvent(event, { sessionId, chatId, vpId, turnId, threadId } = {}) {
|
|
1273
1273
|
sendToServer({
|
|
1274
1274
|
type: 'yeaft_output',
|
|
1275
1275
|
conversationId: yeaftConversationId,
|
|
1276
|
-
...(
|
|
1276
|
+
...(sessionId ? { sessionId } : {}),
|
|
1277
1277
|
...(chatId ? { chatId } : {}),
|
|
1278
1278
|
...(vpId ? { vpId } : {}),
|
|
1279
1279
|
...(turnId ? { turnId } : {}),
|
|
@@ -1375,7 +1375,7 @@ export function handleYeaftVpDelete(msg) {
|
|
|
1375
1375
|
try {
|
|
1376
1376
|
const broker = getVpStatusBroker();
|
|
1377
1377
|
for (const row of broker.snapshot()) {
|
|
1378
|
-
if (row.vpId === vpId) broker.forget({
|
|
1378
|
+
if (row.vpId === vpId) broker.forget({ sessionId: row.sessionId, vpId });
|
|
1379
1379
|
}
|
|
1380
1380
|
} catch (err) {
|
|
1381
1381
|
console.warn('[Yeaft] vp-status forget on delete failed:', err?.message || err);
|
|
@@ -1416,60 +1416,73 @@ export function handleYeaftVpRead(msg) {
|
|
|
1416
1416
|
* Group CRUD wired to WS events.
|
|
1417
1417
|
*/
|
|
1418
1418
|
function sendGroupCrudResult(payload) {
|
|
1419
|
-
|
|
1419
|
+
// Wire-compat: emit BOTH legacy `group_crud_result` (old web bundles)
|
|
1420
|
+
// and the new `session_crud_result` alias. Payload includes both
|
|
1421
|
+
// `groupId` and `sessionId` for the same reason — see below.
|
|
1422
|
+
const out = { ...payload };
|
|
1423
|
+
if (out.sessionId != null && out.groupId == null) out.groupId = out.sessionId;
|
|
1424
|
+
if (out.groupId != null && out.sessionId == null) out.sessionId = out.groupId;
|
|
1425
|
+
sendYeaftEvent({ type: 'group_crud_result', ...out });
|
|
1426
|
+
sendYeaftEvent({ type: 'session_crud_result', ...out });
|
|
1420
1427
|
}
|
|
1421
1428
|
|
|
1422
1429
|
function sendGroupSnapshotBroadcast() {
|
|
1423
1430
|
try {
|
|
1424
1431
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1425
1432
|
if (!yeaftDir) return;
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1433
|
+
const sessions = snapshotSessions(yeaftDir);
|
|
1434
|
+
// Wire-compat: emit both legacy `group_list_updated` (groups field)
|
|
1435
|
+
// and new `session_list_updated` (sessions field). Old web bundles
|
|
1436
|
+
// listen on the former.
|
|
1437
|
+
sendYeaftEvent({ type: 'group_list_updated', groups: sessions });
|
|
1438
|
+
sendYeaftEvent({ type: 'session_list_updated', sessions });
|
|
1428
1439
|
} catch (err) {
|
|
1429
1440
|
console.warn('[Yeaft] sendGroupSnapshotBroadcast failed:', err?.message || err);
|
|
1430
1441
|
}
|
|
1431
1442
|
}
|
|
1432
1443
|
|
|
1433
|
-
function sendGroupRosterChanged(
|
|
1434
|
-
if (!
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
groupId:
|
|
1438
|
-
name:
|
|
1439
|
-
roster:
|
|
1440
|
-
defaultVpId:
|
|
1441
|
-
workDir:
|
|
1442
|
-
}
|
|
1444
|
+
function sendGroupRosterChanged(session) {
|
|
1445
|
+
if (!session) return;
|
|
1446
|
+
const payload = {
|
|
1447
|
+
sessionId: session.id,
|
|
1448
|
+
groupId: session.id, // wire-compat for old web bundles
|
|
1449
|
+
name: session.name,
|
|
1450
|
+
roster: session.roster,
|
|
1451
|
+
defaultVpId: session.defaultVpId,
|
|
1452
|
+
workDir: session.workDir || '',
|
|
1453
|
+
};
|
|
1454
|
+
sendYeaftEvent({ type: 'group_roster_changed', ...payload });
|
|
1455
|
+
sendYeaftEvent({ type: 'session_roster_changed', ...payload });
|
|
1443
1456
|
}
|
|
1444
1457
|
|
|
1445
1458
|
function groupErrorPayload(err) {
|
|
1446
1459
|
let code = 'unknown';
|
|
1447
|
-
if (err instanceof
|
|
1448
|
-
else if (err instanceof
|
|
1460
|
+
if (err instanceof SessionCrudError) code = err.code;
|
|
1461
|
+
else if (err instanceof SessionConfigError) code = err.code;
|
|
1449
1462
|
return {
|
|
1450
1463
|
code,
|
|
1451
|
-
|
|
1464
|
+
sessionId: err && err.sessionId,
|
|
1452
1465
|
message: err && err.message,
|
|
1453
1466
|
};
|
|
1454
1467
|
}
|
|
1455
1468
|
|
|
1456
|
-
export function
|
|
1469
|
+
export function handleYeaftListSessions(msg) {
|
|
1457
1470
|
const requestId = msg && msg.requestId;
|
|
1458
1471
|
try {
|
|
1459
1472
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1460
|
-
const groups =
|
|
1473
|
+
const groups = snapshotSessions(yeaftDir);
|
|
1461
1474
|
sendGroupCrudResult({ op: 'list', requestId, ok: true, groups });
|
|
1462
1475
|
} catch (err) {
|
|
1463
1476
|
sendGroupCrudResult({ op: 'list', requestId, ok: false, error: groupErrorPayload(err) });
|
|
1464
1477
|
}
|
|
1465
1478
|
}
|
|
1466
1479
|
|
|
1467
|
-
export function
|
|
1480
|
+
export function handleYeaftCreateSession(msg) {
|
|
1468
1481
|
const requestId = msg && msg.requestId;
|
|
1469
1482
|
const payload = (msg && msg.payload) || {};
|
|
1470
1483
|
try {
|
|
1471
1484
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1472
|
-
const group =
|
|
1485
|
+
const group = createSessionFromSpec(yeaftDir, payload);
|
|
1473
1486
|
sendGroupCrudResult({ op: 'create', requestId, ok: true, group });
|
|
1474
1487
|
sendGroupSnapshotBroadcast();
|
|
1475
1488
|
} catch (err) {
|
|
@@ -1477,14 +1490,14 @@ export function handleYeaftCreateGroup(msg) {
|
|
|
1477
1490
|
}
|
|
1478
1491
|
}
|
|
1479
1492
|
|
|
1480
|
-
export function
|
|
1493
|
+
export function handleYeaftRenameSession(msg) {
|
|
1481
1494
|
const requestId = msg && msg.requestId;
|
|
1482
|
-
const
|
|
1495
|
+
const sessionId = msg && msg.sessionId;
|
|
1483
1496
|
const name = msg && msg.name;
|
|
1484
1497
|
try {
|
|
1485
1498
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1486
|
-
const group =
|
|
1487
|
-
invalidateGroupContext(
|
|
1499
|
+
const group = renameSession(yeaftDir, sessionId, name);
|
|
1500
|
+
invalidateGroupContext(sessionId);
|
|
1488
1501
|
sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
|
|
1489
1502
|
sendGroupSnapshotBroadcast();
|
|
1490
1503
|
} catch (err) {
|
|
@@ -1496,7 +1509,7 @@ export function handleYeaftRenameGroup(msg) {
|
|
|
1496
1509
|
* `yeaft_update_group` — generalised group meta patch. Currently accepts
|
|
1497
1510
|
* `name` and `announcement` keys. Empty patch is rejected; an empty/
|
|
1498
1511
|
* whitespace-only `name` is also rejected up front rather than letting
|
|
1499
|
-
* `
|
|
1512
|
+
* `renameSession` raise a less-specific error deeper in the call stack.
|
|
1500
1513
|
*
|
|
1501
1514
|
* Partial-success contract: when a single patch contains BOTH `name` and
|
|
1502
1515
|
* `announcement`, the rename is committed first; if the announcement
|
|
@@ -1506,25 +1519,25 @@ export function handleYeaftRenameGroup(msg) {
|
|
|
1506
1519
|
* so this is theoretical; readers extending the patch shape should know
|
|
1507
1520
|
* the contract permits half-commits.
|
|
1508
1521
|
*/
|
|
1509
|
-
export function
|
|
1522
|
+
export function handleYeaftUpdateSession(msg) {
|
|
1510
1523
|
const requestId = msg && msg.requestId;
|
|
1511
|
-
const
|
|
1524
|
+
const sessionId = msg && msg.sessionId;
|
|
1512
1525
|
const patch = (msg && msg.patch && typeof msg.patch === 'object') ? msg.patch : null;
|
|
1513
1526
|
try {
|
|
1514
1527
|
const hasName = patch && typeof patch.name === 'string' && patch.name.trim().length > 0;
|
|
1515
1528
|
const hasAnnouncement = patch && typeof patch.announcement === 'string';
|
|
1516
1529
|
if (!patch || (!hasName && !hasAnnouncement)) {
|
|
1517
|
-
throw new
|
|
1530
|
+
throw new SessionCrudError('invalid_patch', sessionId);
|
|
1518
1531
|
}
|
|
1519
1532
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1520
1533
|
let group = null;
|
|
1521
1534
|
if (hasName) {
|
|
1522
|
-
group =
|
|
1535
|
+
group = renameSession(yeaftDir, sessionId, patch.name);
|
|
1523
1536
|
}
|
|
1524
1537
|
if (hasAnnouncement) {
|
|
1525
|
-
group =
|
|
1538
|
+
group = updateSessionAnnouncement(yeaftDir, sessionId, patch.announcement);
|
|
1526
1539
|
}
|
|
1527
|
-
invalidateGroupContext(
|
|
1540
|
+
invalidateGroupContext(sessionId);
|
|
1528
1541
|
sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
|
|
1529
1542
|
sendGroupSnapshotBroadcast();
|
|
1530
1543
|
} catch (err) {
|
|
@@ -1534,54 +1547,54 @@ export function handleYeaftUpdateGroup(msg) {
|
|
|
1534
1547
|
|
|
1535
1548
|
/**
|
|
1536
1549
|
* Persist the model selected in the group conversation header. Cache invalidation:
|
|
1537
|
-
* drop every cached Engine whose key starts with `${
|
|
1550
|
+
* drop every cached Engine whose key starts with `${sessionId}::` so the
|
|
1538
1551
|
* next turn picks up the new model. The group meta itself is untouched.
|
|
1539
1552
|
*
|
|
1540
|
-
* Payload: {
|
|
1553
|
+
* Payload: { sessionId, requestId, config: { model?: string|null } }
|
|
1541
1554
|
* - `model: ''` or `null` clears the selected group model (falls back to user default).
|
|
1542
1555
|
*/
|
|
1543
|
-
export function
|
|
1556
|
+
export function handleYeaftUpdateSessionConfig(msg) {
|
|
1544
1557
|
const requestId = msg && msg.requestId;
|
|
1545
|
-
const
|
|
1558
|
+
const sessionId = msg && msg.sessionId;
|
|
1546
1559
|
const partial = (msg && msg.config && typeof msg.config === 'object') ? msg.config : null;
|
|
1547
1560
|
try {
|
|
1548
|
-
if (!
|
|
1549
|
-
if (!partial) throw new
|
|
1561
|
+
if (!sessionId) throw new SessionConfigError('missing_group_id', 'sessionId required');
|
|
1562
|
+
if (!partial) throw new SessionConfigError('invalid_patch', 'config object required');
|
|
1550
1563
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1551
|
-
const savedConfig =
|
|
1564
|
+
const savedConfig = updateSessionConfig(yeaftDir, sessionId, partial);
|
|
1552
1565
|
// Drop cached engines so the next VP turn rebuilds with the new model.
|
|
1553
|
-
const prefix = `${
|
|
1566
|
+
const prefix = `${sessionId}::`;
|
|
1554
1567
|
for (const k of Array.from(vpEngines.keys())) {
|
|
1555
1568
|
if (k.startsWith(prefix)) vpEngines.delete(k);
|
|
1556
1569
|
}
|
|
1557
|
-
invalidateGroupContext(
|
|
1558
|
-
sendGroupCrudResult({ op: 'update_config', requestId, ok: true,
|
|
1570
|
+
invalidateGroupContext(sessionId);
|
|
1571
|
+
sendGroupCrudResult({ op: 'update_config', requestId, ok: true, sessionId, config: savedConfig });
|
|
1559
1572
|
sendGroupSnapshotBroadcast();
|
|
1560
1573
|
} catch (err) {
|
|
1561
1574
|
sendGroupCrudResult({ op: 'update_config', requestId, ok: false, error: groupErrorPayload(err) });
|
|
1562
1575
|
}
|
|
1563
1576
|
}
|
|
1564
1577
|
|
|
1565
|
-
export function
|
|
1578
|
+
export function handleYeaftArchiveSession(msg) {
|
|
1566
1579
|
const requestId = msg && msg.requestId;
|
|
1567
|
-
const
|
|
1580
|
+
const sessionId = msg && msg.sessionId;
|
|
1568
1581
|
try {
|
|
1569
1582
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1570
|
-
const result =
|
|
1571
|
-
invalidateGroupContext(
|
|
1572
|
-
sendGroupCrudResult({ op: 'archive', requestId, ok: true,
|
|
1583
|
+
const result = archiveSession(yeaftDir, sessionId);
|
|
1584
|
+
invalidateGroupContext(sessionId);
|
|
1585
|
+
sendGroupCrudResult({ op: 'archive', requestId, ok: true, sessionId: result.sessionId });
|
|
1573
1586
|
sendGroupSnapshotBroadcast();
|
|
1574
1587
|
} catch (err) {
|
|
1575
1588
|
sendGroupCrudResult({ op: 'archive', requestId, ok: false, error: groupErrorPayload(err) });
|
|
1576
1589
|
}
|
|
1577
1590
|
}
|
|
1578
1591
|
|
|
1579
|
-
export function
|
|
1592
|
+
export function handleYeaftDeleteSession(msg) {
|
|
1580
1593
|
const requestId = msg && msg.requestId;
|
|
1581
|
-
const
|
|
1594
|
+
const sessionId = msg && msg.sessionId;
|
|
1582
1595
|
try {
|
|
1583
1596
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1584
|
-
const result =
|
|
1597
|
+
const result = deleteSession(yeaftDir, sessionId);
|
|
1585
1598
|
// Cascade: remove every persisted message stamped with this group id.
|
|
1586
1599
|
// Hard delete (per user spec): no soft-archive, the bytes are gone.
|
|
1587
1600
|
// Skipped silently if the session/store isn't initialized — the next
|
|
@@ -1589,17 +1602,17 @@ export function handleYeaftDeleteGroup(msg) {
|
|
|
1589
1602
|
let messagesRemoved = 0;
|
|
1590
1603
|
try {
|
|
1591
1604
|
if (session && session.conversationStore) {
|
|
1592
|
-
messagesRemoved = session.conversationStore.deleteByGroup(
|
|
1605
|
+
messagesRemoved = session.conversationStore.deleteByGroup(sessionId);
|
|
1593
1606
|
}
|
|
1594
1607
|
} catch (cascadeErr) {
|
|
1595
|
-
console.warn(`[Yeaft] cascade delete for group ${
|
|
1608
|
+
console.warn(`[Yeaft] cascade delete for group ${sessionId} failed: ${cascadeErr.message}`);
|
|
1596
1609
|
}
|
|
1597
1610
|
// Drop the cached coord/router and abort/clear any in-flight VP
|
|
1598
1611
|
// turns for the deleted group. Engines for the deleted group are
|
|
1599
1612
|
// also dropped — unlike rename/announcement updates, the group is
|
|
1600
1613
|
// gone for good and there's nothing to preserve.
|
|
1601
|
-
invalidateGroupContext(
|
|
1602
|
-
const prefix = `${
|
|
1614
|
+
invalidateGroupContext(sessionId);
|
|
1615
|
+
const prefix = `${sessionId}::`;
|
|
1603
1616
|
for (const k of Array.from(vpEngines.keys())) {
|
|
1604
1617
|
if (k.startsWith(prefix)) vpEngines.delete(k);
|
|
1605
1618
|
}
|
|
@@ -1607,7 +1620,7 @@ export function handleYeaftDeleteGroup(msg) {
|
|
|
1607
1620
|
op: 'delete',
|
|
1608
1621
|
requestId,
|
|
1609
1622
|
ok: true,
|
|
1610
|
-
|
|
1623
|
+
sessionId: result.sessionId,
|
|
1611
1624
|
messagesRemoved,
|
|
1612
1625
|
});
|
|
1613
1626
|
sendGroupSnapshotBroadcast();
|
|
@@ -1616,14 +1629,14 @@ export function handleYeaftDeleteGroup(msg) {
|
|
|
1616
1629
|
}
|
|
1617
1630
|
}
|
|
1618
1631
|
|
|
1619
|
-
export function
|
|
1632
|
+
export function handleYeaftSessionAddMember(msg) {
|
|
1620
1633
|
const requestId = msg && msg.requestId;
|
|
1621
|
-
const
|
|
1634
|
+
const sessionId = msg && msg.sessionId;
|
|
1622
1635
|
const vpId = msg && msg.vpId;
|
|
1623
1636
|
try {
|
|
1624
1637
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1625
|
-
const group = addMember(yeaftDir,
|
|
1626
|
-
invalidateGroupContext(
|
|
1638
|
+
const group = addMember(yeaftDir, sessionId, vpId);
|
|
1639
|
+
invalidateGroupContext(sessionId);
|
|
1627
1640
|
sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
|
|
1628
1641
|
sendGroupRosterChanged(group);
|
|
1629
1642
|
} catch (err) {
|
|
@@ -1631,17 +1644,17 @@ export function handleYeaftAddMember(msg) {
|
|
|
1631
1644
|
}
|
|
1632
1645
|
}
|
|
1633
1646
|
|
|
1634
|
-
export function
|
|
1647
|
+
export function handleYeaftSessionRemoveMember(msg) {
|
|
1635
1648
|
const requestId = msg && msg.requestId;
|
|
1636
|
-
const
|
|
1649
|
+
const sessionId = msg && msg.sessionId;
|
|
1637
1650
|
const vpId = msg && msg.vpId;
|
|
1638
1651
|
try {
|
|
1639
1652
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1640
|
-
const group = removeMember(yeaftDir,
|
|
1641
|
-
invalidateGroupContext(
|
|
1653
|
+
const group = removeMember(yeaftDir, sessionId, vpId);
|
|
1654
|
+
invalidateGroupContext(sessionId);
|
|
1642
1655
|
// Also drop the kicked VP's thread engines — the next time they're
|
|
1643
1656
|
// added back they should start with fresh per-thread state.
|
|
1644
|
-
const removedPrefix = `${
|
|
1657
|
+
const removedPrefix = `${sessionId}::${vpId}::`;
|
|
1645
1658
|
for (const key of Array.from(vpEngines.keys())) {
|
|
1646
1659
|
if (key.startsWith(removedPrefix)) vpEngines.delete(key);
|
|
1647
1660
|
}
|
|
@@ -1652,14 +1665,14 @@ export function handleYeaftRemoveMember(msg) {
|
|
|
1652
1665
|
}
|
|
1653
1666
|
}
|
|
1654
1667
|
|
|
1655
|
-
export function
|
|
1668
|
+
export function handleYeaftSessionSetDefaultVp(msg) {
|
|
1656
1669
|
const requestId = msg && msg.requestId;
|
|
1657
|
-
const
|
|
1670
|
+
const sessionId = msg && msg.sessionId;
|
|
1658
1671
|
const vpId = msg && msg.vpId;
|
|
1659
1672
|
try {
|
|
1660
1673
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1661
|
-
const group =
|
|
1662
|
-
invalidateGroupContext(
|
|
1674
|
+
const group = setSessionDefaultVp(yeaftDir, sessionId, vpId);
|
|
1675
|
+
invalidateGroupContext(sessionId);
|
|
1663
1676
|
sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
|
|
1664
1677
|
sendGroupRosterChanged(group);
|
|
1665
1678
|
} catch (err) {
|
|
@@ -1709,14 +1722,14 @@ export function installYeaftRuntimeBridge(s) {
|
|
|
1709
1722
|
//
|
|
1710
1723
|
// Group-id stamping is NO LONGER done here. It used to be: this sink
|
|
1711
1724
|
// read a module-level `activeScopedDreamGroupId` that
|
|
1712
|
-
// `handleYeaftDreamTrigger({
|
|
1725
|
+
// `handleYeaftDreamTrigger({sessionId})` parked before awaiting the
|
|
1713
1726
|
// scope-filtered pass. That created a race when two scoped triggers
|
|
1714
1727
|
// overlapped (auto-tick during a manual click; or two manual clicks
|
|
1715
1728
|
// for different groups): the second handler's `finally` could clear
|
|
1716
1729
|
// the module slot while the first run was still emitting events,
|
|
1717
1730
|
// dropping the stamp from the tail of the first pass. The new design:
|
|
1718
1731
|
// `handleYeaftDreamTrigger` wraps THIS sink for the lifetime of the
|
|
1719
|
-
// trigger to inject `
|
|
1732
|
+
// trigger to inject `sessionId` per-call (see that function below). The
|
|
1720
1733
|
// base sink is intentionally a pure passthrough.
|
|
1721
1734
|
//
|
|
1722
1735
|
// Bug 2: also forward turn_open / turn_close / loop events emitted by
|
|
@@ -1724,11 +1737,11 @@ export function installYeaftRuntimeBridge(s) {
|
|
|
1724
1737
|
s._dreamProgressSink = (evt) => {
|
|
1725
1738
|
try {
|
|
1726
1739
|
if (evt.type === 'turn_open' || evt.type === 'turn_close' || evt.type === 'loop') {
|
|
1727
|
-
const tag = evt && evt.
|
|
1740
|
+
const tag = evt && evt.sessionId ? { sessionId: evt.sessionId } : {};
|
|
1728
1741
|
sendYeaftEvent(evt, tag);
|
|
1729
1742
|
} else {
|
|
1730
1743
|
const out = { type: 'dream_progress', ...evt };
|
|
1731
|
-
const tag = evt && evt.
|
|
1744
|
+
const tag = evt && evt.sessionId ? { sessionId: evt.sessionId } : {};
|
|
1732
1745
|
sendYeaftEvent(out, tag);
|
|
1733
1746
|
}
|
|
1734
1747
|
} catch { /* never let event delivery throw */ }
|
|
@@ -1740,7 +1753,7 @@ export function installYeaftRuntimeBridge(s) {
|
|
|
1740
1753
|
// present. Per-group `yeaft_history_compacted` events fire after a
|
|
1741
1754
|
// successful summarize+swap.
|
|
1742
1755
|
if (s.compactor && typeof s.compactor.setOnCompacted === 'function') {
|
|
1743
|
-
s.compactor.setOnCompacted((
|
|
1756
|
+
s.compactor.setOnCompacted((sessionId, result) => {
|
|
1744
1757
|
try {
|
|
1745
1758
|
sendYeaftEvent({
|
|
1746
1759
|
type: 'yeaft_history_compacted',
|
|
@@ -1751,7 +1764,7 @@ export function installYeaftRuntimeBridge(s) {
|
|
|
1751
1764
|
afterTokens: result?.afterTokens,
|
|
1752
1765
|
archivedCount: result?.archivedCount,
|
|
1753
1766
|
ts: Date.now(),
|
|
1754
|
-
}, {
|
|
1767
|
+
}, { sessionId });
|
|
1755
1768
|
} catch { /* WS pipeline failure must not crash compact */ }
|
|
1756
1769
|
});
|
|
1757
1770
|
}
|
|
@@ -1769,7 +1782,7 @@ export function installYeaftRuntimeBridge(s) {
|
|
|
1769
1782
|
|
|
1770
1783
|
/**
|
|
1771
1784
|
* Mid-turn vp-status transitions (text_delta / tool_call / tool_end).
|
|
1772
|
-
* Tolerates `hctx` missing
|
|
1785
|
+
* Tolerates `hctx` missing sessionId/vpId — pre-707 1:1 chat paths don't
|
|
1773
1786
|
* have either; they're tracked as the default broker key but the
|
|
1774
1787
|
* frontend ignores rows it doesn't recognize.
|
|
1775
1788
|
*
|
|
@@ -1784,7 +1797,7 @@ function maybeTransitionVpStatus(hctx, state) {
|
|
|
1784
1797
|
hctx.thread.updatedAt = Date.now();
|
|
1785
1798
|
}
|
|
1786
1799
|
getVpStatusBroker().transition({
|
|
1787
|
-
|
|
1800
|
+
sessionId: hctx.sessionId || null,
|
|
1788
1801
|
vpId: hctx.vpId,
|
|
1789
1802
|
state,
|
|
1790
1803
|
turnId: hctx.turnId || null,
|
|
@@ -1803,7 +1816,7 @@ function maybeTransitionVpStatus(hctx, state) {
|
|
|
1803
1816
|
* todos, debug cards, and persistence all share the same boundary.
|
|
1804
1817
|
*
|
|
1805
1818
|
* @param {object} event — engine event (text_delta / tool_call / …)
|
|
1806
|
-
* @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, thinkingBlocksAccum?:Array, resetQueryTimer:Function,
|
|
1819
|
+
* @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, thinkingBlocksAccum?:Array, resetQueryTimer:Function, sessionId?:string, vpId?:string, turnId?:string}} hctx
|
|
1807
1820
|
*/
|
|
1808
1821
|
export function __testHandleEngineEvent(event, hctx) {
|
|
1809
1822
|
return handleEngineEvent(event, hctx);
|
|
@@ -1812,7 +1825,7 @@ export function __testHandleEngineEvent(event, hctx) {
|
|
|
1812
1825
|
function handleEngineEvent(event, hctx) {
|
|
1813
1826
|
hctx.resetQueryTimer();
|
|
1814
1827
|
const envelope = {
|
|
1815
|
-
|
|
1828
|
+
sessionId: hctx.sessionId,
|
|
1816
1829
|
vpId: hctx.vpId,
|
|
1817
1830
|
turnId: hctx.turnId,
|
|
1818
1831
|
threadId: hctx.threadId || event.threadId,
|
|
@@ -1942,7 +1955,7 @@ function handleEngineEvent(event, hctx) {
|
|
|
1942
1955
|
hctx.thread.updatedAt = Date.now();
|
|
1943
1956
|
}
|
|
1944
1957
|
getVpStatusBroker().settleIdle({
|
|
1945
|
-
|
|
1958
|
+
sessionId: hctx.sessionId || null,
|
|
1946
1959
|
vpId: hctx.vpId,
|
|
1947
1960
|
threadId: hctx.threadId || 'main',
|
|
1948
1961
|
title: hctx.thread?.title || '',
|
|
@@ -1953,7 +1966,7 @@ function handleEngineEvent(event, hctx) {
|
|
|
1953
1966
|
}
|
|
1954
1967
|
sendYeaftEvent({
|
|
1955
1968
|
type: 'vp_turn_end',
|
|
1956
|
-
|
|
1969
|
+
sessionId: hctx.sessionId,
|
|
1957
1970
|
vpId: hctx.vpId,
|
|
1958
1971
|
threadId: hctx.threadId || event.threadId || 'main',
|
|
1959
1972
|
turnId: hctx.turnId,
|
|
@@ -1983,7 +1996,7 @@ function handleEngineEvent(event, hctx) {
|
|
|
1983
1996
|
case 'consolidate':
|
|
1984
1997
|
// Engine compressed the context — clear THIS group's accumulated
|
|
1985
1998
|
// history. Other groups' histories stay intact.
|
|
1986
|
-
if (hctx.
|
|
1999
|
+
if (hctx.sessionId) setGroupHistory(hctx.sessionId, []);
|
|
1987
2000
|
sendYeaftEvent({
|
|
1988
2001
|
type: 'consolidate',
|
|
1989
2002
|
archivedCount: event.archivedCount,
|
|
@@ -2023,7 +2036,7 @@ function handleEngineEvent(event, hctx) {
|
|
|
2023
2036
|
turnId: event.turnId,
|
|
2024
2037
|
userPrompt: event.userPrompt,
|
|
2025
2038
|
vpId: event.vpId,
|
|
2026
|
-
|
|
2039
|
+
sessionId: event.sessionId,
|
|
2027
2040
|
at: event.at,
|
|
2028
2041
|
}, envelope);
|
|
2029
2042
|
break;
|
|
@@ -2142,19 +2155,19 @@ function handleEngineEvent(event, hctx) {
|
|
|
2142
2155
|
* conversation entry point.
|
|
2143
2156
|
*
|
|
2144
2157
|
* Contract (post-consolidation, was previously split between handleYeaftChat
|
|
2145
|
-
* and
|
|
2158
|
+
* and handleYeaftSessionSend):
|
|
2146
2159
|
* - Frontend ALWAYS sends `yeaft_group_chat`. There is no `yeaft_chat`.
|
|
2147
|
-
* - `
|
|
2160
|
+
* - `sessionId` defaults to `'grp_default'` if missing — Yeaft is a single
|
|
2148
2161
|
* conversation backed by the default group; the user is never "outside"
|
|
2149
2162
|
* a group.
|
|
2150
2163
|
* - If the group dir doesn't exist and the resolved id is `'grp_default'`,
|
|
2151
|
-
* it is seeded on the fly. Any other unknown
|
|
2164
|
+
* it is seeded on the fly. Any other unknown sessionId surfaces an error.
|
|
2152
2165
|
* - Coordinator is MANDATORY (this is what guarantees ctx.router is wired
|
|
2153
2166
|
* so the `route_forward` tool can never trip `router_unavailable`).
|
|
2154
2167
|
* - No legacy "no-group" fallback paths — they were the source of the
|
|
2155
2168
|
* router_unavailable bug fixed in v0.1.671.
|
|
2156
2169
|
*/
|
|
2157
|
-
export async function
|
|
2170
|
+
export async function handleYeaftSessionSend(msg) {
|
|
2158
2171
|
if (!msg || typeof msg !== 'object') return;
|
|
2159
2172
|
const { text } = msg;
|
|
2160
2173
|
// PR #721: image-only send is allowed — text may be empty when the
|
|
@@ -2165,8 +2178,8 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2165
2178
|
const hasFiles = Array.isArray(msg.files) && msg.files.length > 0;
|
|
2166
2179
|
if (!text?.trim() && !hasFiles) return;
|
|
2167
2180
|
const mentions = Array.isArray(msg.mentions) ? msg.mentions : [];
|
|
2168
|
-
const
|
|
2169
|
-
? msg.
|
|
2181
|
+
const sessionId = (typeof msg.sessionId === 'string' && msg.sessionId.trim())
|
|
2182
|
+
? msg.sessionId.trim()
|
|
2170
2183
|
: 'grp_default';
|
|
2171
2184
|
|
|
2172
2185
|
// Entry gate: if a compact is in flight from the previous turn IN
|
|
@@ -2178,7 +2191,7 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2178
2191
|
// a session has loaded (or in test paths that never call
|
|
2179
2192
|
// `ensureSessionLoaded`) it may be unavailable; skip gracefully.
|
|
2180
2193
|
if (session?.compactor) {
|
|
2181
|
-
await session.compactor.awaitInFlight(
|
|
2194
|
+
await session.compactor.awaitInFlight(sessionId);
|
|
2182
2195
|
}
|
|
2183
2196
|
|
|
2184
2197
|
// yeaftDir is a hard prerequisite for both session boot and group seeding;
|
|
@@ -2189,8 +2202,8 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2189
2202
|
sendYeaftOutput({
|
|
2190
2203
|
type: 'assistant',
|
|
2191
2204
|
message: { content: [{ type: 'text', text: '⚠️ Yeaft session error: no yeaft directory configured.' }] },
|
|
2192
|
-
}, {
|
|
2193
|
-
sendYeaftOutput({ type: 'result', result_text: '' }, {
|
|
2205
|
+
}, { sessionId });
|
|
2206
|
+
sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
|
|
2194
2207
|
return;
|
|
2195
2208
|
}
|
|
2196
2209
|
|
|
@@ -2199,46 +2212,46 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2199
2212
|
// Open the group; seed grp_default on the fly if absent. Track
|
|
2200
2213
|
// seedFailed separately so a seed crash surfaces a different message
|
|
2201
2214
|
// than a genuinely-missing group.
|
|
2202
|
-
let
|
|
2203
|
-
let
|
|
2215
|
+
let sessionHandle = null;
|
|
2216
|
+
let sessionRoot = null;
|
|
2204
2217
|
let seedFailed = false;
|
|
2205
2218
|
try {
|
|
2206
|
-
const groupYeaftDir =
|
|
2207
|
-
|
|
2208
|
-
const dir = join(
|
|
2209
|
-
if (existsSync(dir) &&
|
|
2210
|
-
|
|
2211
|
-
} else if (
|
|
2219
|
+
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
2220
|
+
sessionRoot = sessionsRoot(groupYeaftDir);
|
|
2221
|
+
const dir = join(sessionRoot, sessionId);
|
|
2222
|
+
if (existsSync(dir) && loadSessionMeta(dir)) {
|
|
2223
|
+
sessionHandle = openSession(sessionRoot, sessionId);
|
|
2224
|
+
} else if (sessionId === 'grp_default') {
|
|
2212
2225
|
try {
|
|
2213
|
-
const seeded =
|
|
2214
|
-
|
|
2226
|
+
const seeded = seedDefaultSession(groupYeaftDir, { memoryRoot: join(groupYeaftDir, 'memory') });
|
|
2227
|
+
sessionHandle = seeded.group;
|
|
2215
2228
|
} catch (seedErr) {
|
|
2216
2229
|
seedFailed = true;
|
|
2217
|
-
console.warn('[Yeaft] yeaft_group_chat:
|
|
2230
|
+
console.warn('[Yeaft] yeaft_group_chat: seedDefaultSession failed', seedErr?.message || seedErr);
|
|
2218
2231
|
}
|
|
2219
2232
|
} else {
|
|
2220
|
-
console.warn('[Yeaft] yeaft_group_chat:
|
|
2233
|
+
console.warn('[Yeaft] yeaft_group_chat: sessionId %s not found', sessionId);
|
|
2221
2234
|
}
|
|
2222
2235
|
} catch (err) {
|
|
2223
2236
|
console.warn('[Yeaft] yeaft_group_chat: group open failed', err?.message || err);
|
|
2224
2237
|
}
|
|
2225
2238
|
|
|
2226
|
-
if (!
|
|
2239
|
+
if (!sessionHandle) {
|
|
2227
2240
|
const errText = seedFailed
|
|
2228
|
-
? `⚠️ Failed to seed default group ${
|
|
2229
|
-
: `⚠️ Group ${
|
|
2241
|
+
? `⚠️ Failed to seed default group ${sessionId} — check group .yeaft permissions.`
|
|
2242
|
+
: `⚠️ Group ${sessionId} not found.`;
|
|
2230
2243
|
sendYeaftOutput({
|
|
2231
2244
|
type: 'assistant',
|
|
2232
2245
|
message: { content: [{ type: 'text', text: errText }] },
|
|
2233
|
-
}, {
|
|
2234
|
-
sendYeaftOutput({ type: 'result', result_text: '' }, {
|
|
2246
|
+
}, { sessionId });
|
|
2247
|
+
sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
|
|
2235
2248
|
return;
|
|
2236
2249
|
}
|
|
2237
2250
|
|
|
2238
2251
|
// Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
|
|
2239
2252
|
let rosterMutated = false;
|
|
2240
2253
|
try {
|
|
2241
|
-
const meta =
|
|
2254
|
+
const meta = sessionHandle.getMeta();
|
|
2242
2255
|
const wantsAdd = mentions.filter(
|
|
2243
2256
|
(m) => m && m !== 'all' && !meta.roster.includes(m)
|
|
2244
2257
|
);
|
|
@@ -2247,23 +2260,23 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2247
2260
|
try {
|
|
2248
2261
|
const vp = readVp(vpId);
|
|
2249
2262
|
if (!vp) continue;
|
|
2250
|
-
addMember(yeaftDir,
|
|
2263
|
+
addMember(yeaftDir, sessionId, vpId);
|
|
2251
2264
|
rosterMutated = true;
|
|
2252
2265
|
} catch { /* skip strangers */ }
|
|
2253
2266
|
}
|
|
2254
2267
|
if (rosterMutated) {
|
|
2255
|
-
try {
|
|
2256
|
-
|
|
2257
|
-
sendGroupRosterChanged(
|
|
2268
|
+
try { sessionHandle.close && sessionHandle.close(); } catch { /* best-effort */ }
|
|
2269
|
+
sessionHandle = openSession(sessionRoot, sessionId);
|
|
2270
|
+
sendGroupRosterChanged(sessionHandle.getMeta());
|
|
2258
2271
|
}
|
|
2259
2272
|
}
|
|
2260
|
-
const meta2 =
|
|
2273
|
+
const meta2 = sessionHandle.getMeta();
|
|
2261
2274
|
if (!meta2.defaultVpId && meta2.roster.length) {
|
|
2262
2275
|
try {
|
|
2263
|
-
|
|
2264
|
-
try {
|
|
2265
|
-
|
|
2266
|
-
sendGroupRosterChanged(
|
|
2276
|
+
setSessionDefaultVp(yeaftDir, sessionId, meta2.roster[0]);
|
|
2277
|
+
try { sessionHandle.close && sessionHandle.close(); } catch { /* best-effort */ }
|
|
2278
|
+
sessionHandle = openSession(sessionRoot, sessionId);
|
|
2279
|
+
sendGroupRosterChanged(sessionHandle.getMeta());
|
|
2267
2280
|
rosterMutated = true;
|
|
2268
2281
|
} catch { /* best-effort */ }
|
|
2269
2282
|
}
|
|
@@ -2272,15 +2285,15 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2272
2285
|
}
|
|
2273
2286
|
|
|
2274
2287
|
// task-707: per-group persistent coordinator/router. Created once per
|
|
2275
|
-
//
|
|
2288
|
+
// sessionId; reused across user messages AND across in-flight tool calls
|
|
2276
2289
|
// (route_forward delivers via this same coord). If the roster mutated
|
|
2277
2290
|
// we replace the cached coord so it points at the freshly-opened
|
|
2278
|
-
//
|
|
2291
|
+
// sessionHandle.
|
|
2279
2292
|
if (rosterMutated) {
|
|
2280
|
-
groupContexts.delete(
|
|
2293
|
+
groupContexts.delete(sessionId);
|
|
2281
2294
|
}
|
|
2282
|
-
const
|
|
2283
|
-
const coord =
|
|
2295
|
+
const sessionCtx = getOrCreateSessionContext(sessionId, sessionHandle);
|
|
2296
|
+
const coord = sessionCtx.coord;
|
|
2284
2297
|
|
|
2285
2298
|
// Multi-thread routing owns active-VP decisions. Do not abort an active
|
|
2286
2299
|
// VP before classification: a new query may append to the running thread
|
|
@@ -2299,7 +2312,7 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2299
2312
|
let attachmentBundle = { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
|
|
2300
2313
|
if (inboundFiles.length > 0) {
|
|
2301
2314
|
try {
|
|
2302
|
-
attachmentBundle = persistYeaftAttachments(inboundFiles, { subdir:
|
|
2315
|
+
attachmentBundle = persistYeaftAttachments(inboundFiles, { subdir: sessionId });
|
|
2303
2316
|
} catch (err) {
|
|
2304
2317
|
console.warn('[Yeaft] yeaft_group_chat: attachment persist failed', err?.message || err);
|
|
2305
2318
|
}
|
|
@@ -2314,7 +2327,7 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2314
2327
|
sendYeaftOutput({
|
|
2315
2328
|
type: 'assistant',
|
|
2316
2329
|
message: { content: [{ type: 'text', text: `⚠️ ${attachmentBundle.failed.length} file(s) could not be attached:\n${detail}` }] },
|
|
2317
|
-
}, {
|
|
2330
|
+
}, { sessionId });
|
|
2318
2331
|
}
|
|
2319
2332
|
const persistedAttachments = attachmentsForPersistence(attachmentBundle.promptAttachments);
|
|
2320
2333
|
|
|
@@ -2344,8 +2357,8 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2344
2357
|
sendYeaftOutput({
|
|
2345
2358
|
type: 'assistant',
|
|
2346
2359
|
message: { content: [{ type: 'text', text: `⚠️ Group dispatch error: ${err?.message || err}` }] },
|
|
2347
|
-
}, {
|
|
2348
|
-
sendYeaftOutput({ type: 'result', result_text: '' }, {
|
|
2360
|
+
}, { sessionId });
|
|
2361
|
+
sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
|
|
2349
2362
|
return;
|
|
2350
2363
|
}
|
|
2351
2364
|
|
|
@@ -2365,8 +2378,8 @@ export async function handleYeaftGroupChat(msg) {
|
|
|
2365
2378
|
sendYeaftOutput({
|
|
2366
2379
|
type: 'assistant',
|
|
2367
2380
|
message: { content: [{ type: 'text', text: '⚠️ No VP available to respond — check the group roster.' }] },
|
|
2368
|
-
}, {
|
|
2369
|
-
sendYeaftOutput({ type: 'result', result_text: '' }, {
|
|
2381
|
+
}, { sessionId });
|
|
2382
|
+
sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
|
|
2370
2383
|
return;
|
|
2371
2384
|
}
|
|
2372
2385
|
|
|
@@ -2404,33 +2417,33 @@ async function waitForVpDrivers(_groupId, driverKeys = []) {
|
|
|
2404
2417
|
*
|
|
2405
2418
|
* @param {object} args
|
|
2406
2419
|
* @param {string} args.vpId
|
|
2407
|
-
* @param {object} args.
|
|
2420
|
+
* @param {object} args.sessionCoordinator — the persistent coordinator for the
|
|
2408
2421
|
* group; used here for `group.getMeta()` (defaultVpId, announcement) and
|
|
2409
2422
|
* to bind the per-group router into toolCtx.
|
|
2410
|
-
* @param {string} [args.
|
|
2423
|
+
* @param {string} [args.sessionId]
|
|
2411
2424
|
* @param {object} [args.envelope] — the inbound coordinator envelope that
|
|
2412
2425
|
* triggered this turn. Threaded into toolCtx as `inboundEnvelope` so
|
|
2413
2426
|
* `route_forward` can extend `causedBy` chains correctly. Optional only
|
|
2414
2427
|
* for pre-707 callers that no longer exist in production.
|
|
2415
2428
|
*/
|
|
2416
|
-
export function buildVpQueryOpts({ vpId,
|
|
2429
|
+
export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope, threadId = 'main' }) {
|
|
2417
2430
|
// Read the group meta once and reuse for both defaultVpId fallback and
|
|
2418
2431
|
// announcement injection. Each .getMeta() reload reads + parses the
|
|
2419
2432
|
// group.json file, so calling it twice per turn is wasteful — and
|
|
2420
2433
|
// (more importantly) opens a window where a concurrent group edit
|
|
2421
2434
|
// could land between the two reads, giving the engine a defaultVpId
|
|
2422
2435
|
// from one snapshot and an announcement from a newer one.
|
|
2423
|
-
let
|
|
2436
|
+
let sessionMeta = null;
|
|
2424
2437
|
try {
|
|
2425
|
-
|
|
2426
|
-
&& typeof
|
|
2427
|
-
?
|
|
2438
|
+
sessionMeta = sessionCoordinator && sessionCoordinator.group
|
|
2439
|
+
&& typeof sessionCoordinator.group.getMeta === 'function'
|
|
2440
|
+
? sessionCoordinator.group.getMeta() : null;
|
|
2428
2441
|
} catch { /* coordinator inspection is best-effort */ }
|
|
2429
2442
|
|
|
2430
2443
|
let resolvedVpId = vpId;
|
|
2431
2444
|
if (!resolvedVpId) {
|
|
2432
|
-
if (
|
|
2433
|
-
resolvedVpId =
|
|
2445
|
+
if (sessionMeta && typeof sessionMeta.defaultVpId === 'string' && sessionMeta.defaultVpId) {
|
|
2446
|
+
resolvedVpId = sessionMeta.defaultVpId;
|
|
2434
2447
|
}
|
|
2435
2448
|
}
|
|
2436
2449
|
if (!resolvedVpId) {
|
|
@@ -2450,27 +2463,27 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope, th
|
|
|
2450
2463
|
if (!resolvedVpId) return undefined;
|
|
2451
2464
|
|
|
2452
2465
|
const out = { senderVpId: resolvedVpId, threadId: threadId || 'main' };
|
|
2453
|
-
if (typeof
|
|
2454
|
-
out.
|
|
2466
|
+
if (typeof sessionId === 'string' && sessionId.trim()) {
|
|
2467
|
+
out.sessionId = sessionId.trim();
|
|
2455
2468
|
}
|
|
2456
2469
|
// task-334-group-editor: surface the group announcement to the engine so
|
|
2457
2470
|
// buildWorkerPrompt can inject it as a CLAUDE.md-style shared prefix.
|
|
2458
2471
|
// Empty/missing reads as '' and prompts.js skips the section.
|
|
2459
|
-
if (
|
|
2460
|
-
out.
|
|
2472
|
+
if (sessionMeta && typeof sessionMeta.announcement === 'string') {
|
|
2473
|
+
out.sessionAnnouncement = sessionMeta.announcement;
|
|
2461
2474
|
}
|
|
2462
2475
|
// Surface the group's configured working directory so the engine can
|
|
2463
2476
|
// resolve CLAUDE.md / AGENTS.md at that path and inject it as a
|
|
2464
2477
|
// [Project Doc] block above the announcement. Groups with no workDir
|
|
2465
2478
|
// skip the block silently (matches the announcement contract).
|
|
2466
|
-
if (
|
|
2467
|
-
out.workDir =
|
|
2479
|
+
if (sessionMeta && typeof sessionMeta.workDir === 'string' && sessionMeta.workDir.trim()) {
|
|
2480
|
+
out.workDir = sessionMeta.workDir.trim();
|
|
2468
2481
|
}
|
|
2469
2482
|
const persona = buildVpPersona(resolvedVpId);
|
|
2470
2483
|
if (persona) out.vpPersona = persona;
|
|
2471
|
-
if (
|
|
2484
|
+
if (sessionCoordinator && typeof sessionCoordinator.ingest === 'function') {
|
|
2472
2485
|
try {
|
|
2473
|
-
out.router = createRouter({ coordinator:
|
|
2486
|
+
out.router = createRouter({ coordinator: sessionCoordinator });
|
|
2474
2487
|
} catch {
|
|
2475
2488
|
// Router build failure is non-fatal.
|
|
2476
2489
|
}
|
|
@@ -2484,10 +2497,10 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope, th
|
|
|
2484
2497
|
out.inboundEnvelope = envelope;
|
|
2485
2498
|
}
|
|
2486
2499
|
// TodoWrite per-thread isolation. Bind closures that read/write a slot
|
|
2487
|
-
// keyed by `${
|
|
2500
|
+
// keyed by `${sessionId}::${vpId}::${threadId}` so concurrent threads for
|
|
2488
2501
|
// the same VP cannot overwrite each other's lists, and the TodoWrite tool
|
|
2489
2502
|
// can stay ignorant of routing details (it just calls ctx.setCurrentTodos).
|
|
2490
|
-
const todosKey = threadKey(out.
|
|
2503
|
+
const todosKey = threadKey(out.sessionId || '', resolvedVpId, out.threadId || 'main');
|
|
2491
2504
|
out.getCurrentTodos = () => {
|
|
2492
2505
|
const cached = vpCurrentTodos.get(todosKey);
|
|
2493
2506
|
return Array.isArray(cached) ? cached.slice() : null;
|
|
@@ -2533,13 +2546,13 @@ async function ensureSessionLoaded() {
|
|
|
2533
2546
|
// Bug 8: clean up legacy `.archived-*` group dirs at boot.
|
|
2534
2547
|
try {
|
|
2535
2548
|
if (yeaftDir) {
|
|
2536
|
-
const removed =
|
|
2549
|
+
const removed = purgeArchivedSessions(yeaftDir);
|
|
2537
2550
|
if (removed && removed.length > 0) {
|
|
2538
2551
|
console.log(`[Yeaft] purged ${removed.length} legacy .archived group dir(s)`);
|
|
2539
2552
|
}
|
|
2540
2553
|
}
|
|
2541
2554
|
} catch (err) {
|
|
2542
|
-
console.warn('[Yeaft]
|
|
2555
|
+
console.warn('[Yeaft] purgeArchivedSessions failed:', err?.message || err);
|
|
2543
2556
|
}
|
|
2544
2557
|
|
|
2545
2558
|
yeaftConversationId = `yeaft-${Date.now()}`;
|
|
@@ -2597,7 +2610,7 @@ async function ensureSessionLoaded() {
|
|
|
2597
2610
|
* helpers) or for adapter implementations that ignore signal.
|
|
2598
2611
|
*/
|
|
2599
2612
|
async function runVpTurnWithEscalation(args) {
|
|
2600
|
-
const {
|
|
2613
|
+
const { sessionId, vpId, turnId, threadId, thread } = args;
|
|
2601
2614
|
const deadlineMs = QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS;
|
|
2602
2615
|
await raceWithEscalation(runVpTurn(args), {
|
|
2603
2616
|
deadlineMs,
|
|
@@ -2608,7 +2621,7 @@ async function runVpTurnWithEscalation(args) {
|
|
|
2608
2621
|
try {
|
|
2609
2622
|
sendYeaftOutput(
|
|
2610
2623
|
{ type: 'result', result_text: '', stopped: true },
|
|
2611
|
-
{
|
|
2624
|
+
{ sessionId, vpId, turnId, threadId },
|
|
2612
2625
|
);
|
|
2613
2626
|
} catch { /* never crash WS pipeline */ }
|
|
2614
2627
|
// vp-status: when the watchdog escalates, `runVpTurn`'s inner
|
|
@@ -2620,7 +2633,7 @@ async function runVpTurnWithEscalation(args) {
|
|
|
2620
2633
|
// settling here would re-introduce the "stuck on streaming" bug
|
|
2621
2634
|
// the whole PR is meant to fix.
|
|
2622
2635
|
try {
|
|
2623
|
-
getVpStatusBroker().settleIdle({
|
|
2636
|
+
getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
|
|
2624
2637
|
} catch (err) {
|
|
2625
2638
|
console.warn('[Yeaft] vp-status settleIdle (escalation) failed:', err?.message || err);
|
|
2626
2639
|
}
|
|
@@ -2683,18 +2696,18 @@ async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
|
|
|
2683
2696
|
* appended to `conversationMessages`.
|
|
2684
2697
|
*
|
|
2685
2698
|
* task-707: takes a coordinator `envelope` rather than the coordinator
|
|
2686
|
-
* itself; the persistent coord lives in `groupContexts[
|
|
2687
|
-
* `getOrCreateVpEngine(
|
|
2699
|
+
* itself; the persistent coord lives in `groupContexts[sessionId]`. Uses
|
|
2700
|
+
* `getOrCreateVpEngine(sessionId, vpId)` so each VP runs against its own
|
|
2688
2701
|
* Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
|
|
2689
2702
|
* `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
|
|
2690
2703
|
* collide when VP-A and VP-B run concurrent turns.
|
|
2691
2704
|
*
|
|
2692
|
-
* @param {{ prompt: string,
|
|
2705
|
+
* @param {{ prompt: string, sessionId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
|
|
2693
2706
|
*/
|
|
2694
|
-
async function runVpTurn({ prompt, promptParts = null,
|
|
2707
|
+
async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId = 'main', thread = null, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
|
|
2695
2708
|
if (!prompt?.trim()) return;
|
|
2696
2709
|
|
|
2697
|
-
const envelope = {
|
|
2710
|
+
const envelope = { sessionId, vpId, threadId, turnId };
|
|
2698
2711
|
|
|
2699
2712
|
try {
|
|
2700
2713
|
if (session?.dreamScheduler) {
|
|
@@ -2714,10 +2727,10 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2714
2727
|
resetQueryTimer();
|
|
2715
2728
|
|
|
2716
2729
|
// Emit turn_start so frontend can create the message block.
|
|
2717
|
-
sendYeaftEvent({ type: 'vp_turn_start', vpId, threadId, turnId,
|
|
2730
|
+
sendYeaftEvent({ type: 'vp_turn_start', vpId, threadId, turnId, sessionId, title: thread?.title || '' }, envelope);
|
|
2718
2731
|
// vp-status: LLM call about to start, no text/tool yet → 'thinking'.
|
|
2719
2732
|
try {
|
|
2720
|
-
getVpStatusBroker().transition({
|
|
2733
|
+
getVpStatusBroker().transition({ sessionId, vpId, threadId, title: thread?.title || '', state: 'thinking', turnId, messageCount: thread?.messageIds?.length || 0 });
|
|
2721
2734
|
} catch (err) {
|
|
2722
2735
|
console.warn('[Yeaft] vp-status thinking transition failed:', err?.message || err);
|
|
2723
2736
|
}
|
|
@@ -2731,21 +2744,21 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2731
2744
|
let vpEngine = null;
|
|
2732
2745
|
|
|
2733
2746
|
// task-707: per-VP engine + persistent group coord. The coord is
|
|
2734
|
-
// created in
|
|
2747
|
+
// created in handleYeaftSessionSend via getOrCreateSessionContext and
|
|
2735
2748
|
// cached on `groupContexts`; we pull it here so route_forward
|
|
2736
2749
|
// (router built from this same coord) lands envelopes back on the
|
|
2737
2750
|
// right inbox set.
|
|
2738
|
-
const
|
|
2739
|
-
const
|
|
2751
|
+
const sessionCtx = groupContexts.get(sessionId);
|
|
2752
|
+
const sessionCoordinator = sessionCtx?.coord || null;
|
|
2740
2753
|
const queryOpts = buildVpQueryOpts({
|
|
2741
2754
|
vpId,
|
|
2742
|
-
|
|
2743
|
-
|
|
2755
|
+
sessionCoordinator,
|
|
2756
|
+
sessionId,
|
|
2744
2757
|
envelope: inboundEnvelope,
|
|
2745
2758
|
threadId,
|
|
2746
2759
|
});
|
|
2747
2760
|
|
|
2748
|
-
vpEngine = getOrCreateVpEngine(
|
|
2761
|
+
vpEngine = getOrCreateVpEngine(sessionId, vpId, threadId);
|
|
2749
2762
|
if (thread) thread.engine = vpEngine;
|
|
2750
2763
|
|
|
2751
2764
|
const handlerCtx = {
|
|
@@ -2754,7 +2767,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2754
2767
|
toolResultsAccum,
|
|
2755
2768
|
thinkingBlocksAccum,
|
|
2756
2769
|
resetQueryTimer,
|
|
2757
|
-
|
|
2770
|
+
sessionId,
|
|
2758
2771
|
vpId,
|
|
2759
2772
|
turnId,
|
|
2760
2773
|
threadId,
|
|
@@ -2774,7 +2787,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2774
2787
|
messages: trimmedMessages,
|
|
2775
2788
|
signal: vpAbort.signal,
|
|
2776
2789
|
// Multi-VP fan-out (history-dedup): the user row was persisted
|
|
2777
|
-
// ONCE by
|
|
2790
|
+
// ONCE by handleYeaftSessionSend → persistUserMessageOnce before
|
|
2778
2791
|
// fan-out. Tell the engine's stop-hook to skip the user-row
|
|
2779
2792
|
// append for THIS VP's turn (it still writes assistant + tool
|
|
2780
2793
|
// rows for this VP). Without this the magnet of N engines would
|
|
@@ -2793,7 +2806,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2793
2806
|
}
|
|
2794
2807
|
|
|
2795
2808
|
// Turn completed — atomically append this VP's output to shared history.
|
|
2796
|
-
appendTurnToGroupHistory(
|
|
2809
|
+
appendTurnToGroupHistory(sessionId, threadId, [prompt, ...appendedUserPrompts], assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
|
|
2797
2810
|
|
|
2798
2811
|
sendYeaftOutput({
|
|
2799
2812
|
type: 'assistant',
|
|
@@ -2825,7 +2838,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2825
2838
|
// identical to a normal turn end in the timeline — the user has
|
|
2826
2839
|
// no way to tell from the row that something went wrong.
|
|
2827
2840
|
try {
|
|
2828
|
-
getVpStatusBroker().transition({
|
|
2841
|
+
getVpStatusBroker().transition({ sessionId, vpId, threadId, title: thread?.title || '', state: 'error', turnId, messageCount: thread?.messageIds?.length || 0 });
|
|
2829
2842
|
} catch (brokerErr) {
|
|
2830
2843
|
console.warn('[Yeaft] vp-status error transition failed:', brokerErr?.message || brokerErr);
|
|
2831
2844
|
}
|
|
@@ -2864,7 +2877,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2864
2877
|
// the row must drop back to 'idle'. Wrapped in its own try so a
|
|
2865
2878
|
// broker bug can't mask the original error.
|
|
2866
2879
|
try {
|
|
2867
|
-
getVpStatusBroker().settleIdle({
|
|
2880
|
+
getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
|
|
2868
2881
|
} catch (err) {
|
|
2869
2882
|
console.warn('[Yeaft] vp-status settleIdle failed:', err?.message || err);
|
|
2870
2883
|
}
|
|
@@ -2899,9 +2912,9 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
|
|
|
2899
2912
|
* a session, this in-memory tape carries the un-collapsed form — which
|
|
2900
2913
|
* is fine because each VP turn's `engine.query` re-collapses on the fly.
|
|
2901
2914
|
*/
|
|
2902
|
-
function appendTurnToGroupHistory(
|
|
2903
|
-
if (!
|
|
2904
|
-
const history = getOrCreateGroupHistory(
|
|
2915
|
+
function appendTurnToGroupHistory(sessionId, threadId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum) {
|
|
2916
|
+
if (!sessionId) return;
|
|
2917
|
+
const history = getOrCreateGroupHistory(sessionId);
|
|
2905
2918
|
const promptList = Array.isArray(prompts) ? prompts : [prompts];
|
|
2906
2919
|
for (const prompt of promptList) {
|
|
2907
2920
|
if (typeof prompt === 'string' && prompt.trim()) {
|
|
@@ -2948,7 +2961,7 @@ function appendTurnToGroupHistory(groupId, threadId, prompts, assistantTextParts
|
|
|
2948
2961
|
/**
|
|
2949
2962
|
* Persist an inbound message row to disk EXACTLY ONCE per
|
|
2950
2963
|
* coordinator-ingest call, keyed by the coordinator-assigned `msgId`.
|
|
2951
|
-
* Both `
|
|
2964
|
+
* Both `handleYeaftSessionSend` (real user input, persists as
|
|
2952
2965
|
* role='user') and `enqueueForVp`'s driver loop (route_forward
|
|
2953
2966
|
* synthetic injections, persists as role='assistant' attributed via
|
|
2954
2967
|
* `speakerVpId`) call this — the Set guard makes either path the
|
|
@@ -2965,16 +2978,16 @@ function appendTurnToGroupHistory(groupId, threadId, prompts, assistantTextParts
|
|
|
2965
2978
|
* still run, and the next user message will trigger another append.
|
|
2966
2979
|
*
|
|
2967
2980
|
* Note: we mirror `engine.#persistMessages`'s core user-row fields
|
|
2968
|
-
* (role/content/threadId/
|
|
2981
|
+
* (role/content/threadId/sessionId) so existing parsers keep working.
|
|
2969
2982
|
* Attachment UI metadata is persisted separately (without base64) so
|
|
2970
2983
|
* refresh replay can render chips without leaking image source data into
|
|
2971
2984
|
* the message body.
|
|
2972
2985
|
*
|
|
2973
|
-
* @param {{ msgId:string, text:string,
|
|
2986
|
+
* @param {{ msgId:string, text:string, sessionId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object> }} args
|
|
2974
2987
|
* @returns {boolean} true if this call wrote the row, false if a prior
|
|
2975
2988
|
* call already wrote it (dedup hit).
|
|
2976
2989
|
*/
|
|
2977
|
-
function persistInboundMessageOnceByMsgId({ msgId, text,
|
|
2990
|
+
function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments }) {
|
|
2978
2991
|
if (!session?.conversationStore) return false;
|
|
2979
2992
|
// No msgId means no dedup key — caller is responsible for guarding.
|
|
2980
2993
|
// Both call sites already do (`if (envMsgId && text)` and
|
|
@@ -3006,7 +3019,7 @@ function persistInboundMessageOnceByMsgId({ msgId, text, groupId, threadId = 'ma
|
|
|
3006
3019
|
}
|
|
3007
3020
|
}
|
|
3008
3021
|
try {
|
|
3009
|
-
// role defaults to 'user' for back-compat:
|
|
3022
|
+
// role defaults to 'user' for back-compat: handleYeaftSessionSend's
|
|
3010
3023
|
// real-user call site passes no role and gets a user row. The driver
|
|
3011
3024
|
// loop passes role='assistant' + speakerVpId for route_forward
|
|
3012
3025
|
// injections so the on-disk record correctly attributes the text to
|
|
@@ -3017,7 +3030,7 @@ function persistInboundMessageOnceByMsgId({ msgId, text, groupId, threadId = 'ma
|
|
|
3017
3030
|
content: text,
|
|
3018
3031
|
threadId: threadId || 'main',
|
|
3019
3032
|
};
|
|
3020
|
-
if (
|
|
3033
|
+
if (sessionId) record.sessionId = sessionId;
|
|
3021
3034
|
// Stamp speakerVpId so the UI's loadHistory replay can route the row
|
|
3022
3035
|
// to the correct VP block. Only meaningful when role='assistant'; for
|
|
3023
3036
|
// a real user message we leave it unset (the UI's user track is
|
|
@@ -3182,28 +3195,28 @@ export function abortYeaftSession(opts = {}) {
|
|
|
3182
3195
|
return { aborted: [], all: false };
|
|
3183
3196
|
}
|
|
3184
3197
|
|
|
3185
|
-
function seedAbortController(threadId, ctrl,
|
|
3198
|
+
function seedAbortController(threadId, ctrl, sessionId = 'test', vpId = 'vp', turnId = null) {
|
|
3186
3199
|
const tid = threadId || 'main';
|
|
3187
|
-
const key = threadKey(
|
|
3200
|
+
const key = threadKey(sessionId, vpId, tid);
|
|
3188
3201
|
if (ctrl) vpAborts.set(key, ctrl);
|
|
3189
3202
|
if (turnId && ctrl) {
|
|
3190
3203
|
turnAbortCtrls.set(turnId, ctrl);
|
|
3191
|
-
turnAbortMeta.set(turnId, {
|
|
3204
|
+
turnAbortMeta.set(turnId, { sessionId, vpId, threadId: tid, key });
|
|
3192
3205
|
}
|
|
3193
3206
|
}
|
|
3194
3207
|
|
|
3195
3208
|
/** Test-only: seed an in-flight VP runtime controller. */
|
|
3196
|
-
export function __testSeedAbortController(threadId, ctrl,
|
|
3197
|
-
seedAbortController(threadId, ctrl,
|
|
3209
|
+
export function __testSeedAbortController(threadId, ctrl, sessionId = 'test', vpId = 'vp') {
|
|
3210
|
+
seedAbortController(threadId, ctrl, sessionId, vpId);
|
|
3198
3211
|
}
|
|
3199
3212
|
|
|
3200
3213
|
/** Test-only: seed an in-flight VP turn controller. */
|
|
3201
|
-
export function __testSeedTurnAbortController(turnId, threadId, ctrl,
|
|
3214
|
+
export function __testSeedTurnAbortController(turnId, threadId, ctrl, sessionId = 'test', vpId = 'vp') {
|
|
3202
3215
|
if (!turnId || !ctrl) return;
|
|
3203
3216
|
const tid = threadId || 'main';
|
|
3204
|
-
const key = threadKey(
|
|
3217
|
+
const key = threadKey(sessionId, vpId, tid);
|
|
3205
3218
|
turnAbortCtrls.set(turnId, ctrl);
|
|
3206
|
-
turnAbortMeta.set(turnId, {
|
|
3219
|
+
turnAbortMeta.set(turnId, { sessionId, vpId, threadId: tid, key });
|
|
3207
3220
|
}
|
|
3208
3221
|
|
|
3209
3222
|
/** Test-only: returns registered VP runtime thread ids. */
|
|
@@ -3233,12 +3246,12 @@ export const __testRaceWithEscalation = raceWithEscalation;
|
|
|
3233
3246
|
* VP-detail page button). Fires an unscoped dream pass; the result
|
|
3234
3247
|
* event is tagged with `vpId` so the per-VP store row updates.
|
|
3235
3248
|
*
|
|
3236
|
-
* { type: 'yeaft_dream_trigger',
|
|
3249
|
+
* { type: 'yeaft_dream_trigger', sessionId } — per-GROUP trigger (new
|
|
3237
3250
|
* in v0.1.754 — added so users can manually kick dream for a group
|
|
3238
3251
|
* after seeing the Resident layer stuck on the bootstrap seed).
|
|
3239
3252
|
* Fires a scope-filtered pass via `triggerDreamForScopes(['group/X'])`
|
|
3240
3253
|
* so unrelated groups don't get processed; the result event is
|
|
3241
|
-
* tagged with `
|
|
3254
|
+
* tagged with `sessionId` for the per-group UI row.
|
|
3242
3255
|
*
|
|
3243
3256
|
* Backwards-compat: when neither field is set, defaults to `vpId='default'`
|
|
3244
3257
|
* which matches the pre-v0.1.754 behavior.
|
|
@@ -3286,14 +3299,14 @@ export function normalizeDreamResult(result) {
|
|
|
3286
3299
|
|
|
3287
3300
|
export async function handleYeaftDreamTrigger(msg = {}) {
|
|
3288
3301
|
// Resolve tag up-front so EVERY outbound envelope (including the
|
|
3289
|
-
// scheduler-uninitialised early-return below) carries `
|
|
3302
|
+
// scheduler-uninitialised early-return below) carries `sessionId` /
|
|
3290
3303
|
// `vpId`. Without this the frontend's `applyDreamResult` couldn't
|
|
3291
3304
|
// route the error event back to the right row and the per-group
|
|
3292
3305
|
// "Run dream now" button would stay stuck on "Running…" forever
|
|
3293
3306
|
// (review feedback from PR #757).
|
|
3294
|
-
const
|
|
3295
|
-
const vpId = !
|
|
3296
|
-
const tag =
|
|
3307
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId ? msg.sessionId : null;
|
|
3308
|
+
const vpId = !sessionId ? (msg.vpId || 'default') : null;
|
|
3309
|
+
const tag = sessionId ? { sessionId } : { vpId };
|
|
3297
3310
|
|
|
3298
3311
|
if (!session?.dreamScheduler) {
|
|
3299
3312
|
const error = 'Dream scheduler not initialized — session not loaded.';
|
|
@@ -3307,7 +3320,7 @@ export async function handleYeaftDreamTrigger(msg = {}) {
|
|
|
3307
3320
|
|
|
3308
3321
|
// Concurrent-trigger guard for scoped runs. Two scoped clicks (same
|
|
3309
3322
|
// group or different) overlapping the same inflight pass used to set
|
|
3310
|
-
// the module-level
|
|
3323
|
+
// the module-level sessionId slot, race the sink wrapping, and let the
|
|
3311
3324
|
// second `finally` restore the original sink while the first run was
|
|
3312
3325
|
// still emitting events. We now refuse scoped triggers while ANY dream
|
|
3313
3326
|
// pass is already running: a scoped manual click during an unscoped
|
|
@@ -3317,7 +3330,7 @@ export async function handleYeaftDreamTrigger(msg = {}) {
|
|
|
3317
3330
|
// and a different group's filter would have been silently dropped
|
|
3318
3331
|
// anyway (see dream-v2/schedule.js inflight reuse), so the user-facing
|
|
3319
3332
|
// semantics are unchanged ("you already asked").
|
|
3320
|
-
if (
|
|
3333
|
+
if (sessionId && (inflightScopedDreamGroups.size > 0 || session.dreamScheduler.isRunning)) {
|
|
3321
3334
|
const skippedResult = {
|
|
3322
3335
|
skipped: true,
|
|
3323
3336
|
skippedReason: 'already-running',
|
|
@@ -3333,21 +3346,21 @@ export async function handleYeaftDreamTrigger(msg = {}) {
|
|
|
3333
3346
|
}
|
|
3334
3347
|
|
|
3335
3348
|
// Per-call sink wrapper. For scoped runs we install a closure that
|
|
3336
|
-
// injects this trigger's
|
|
3349
|
+
// injects this trigger's sessionId onto top-level events the runner
|
|
3337
3350
|
// emits without one (start/merge/done), then delegates to the
|
|
3338
3351
|
// original passthrough sink. The wrapper lives only for the lifetime
|
|
3339
3352
|
// of this trigger and is restored in `finally`; concurrent calls for
|
|
3340
|
-
// OTHER
|
|
3353
|
+
// OTHER sessionIds chain (last-installed wins) but each restoration
|
|
3341
3354
|
// unwinds back to its predecessor.
|
|
3342
3355
|
const originalSink = session?._dreamProgressSink;
|
|
3343
|
-
if (
|
|
3344
|
-
if (
|
|
3345
|
-
inflightScopedDreamGroups.add(
|
|
3356
|
+
if (sessionId) session._dreamActiveGroupId = sessionId;
|
|
3357
|
+
if (sessionId && typeof originalSink === 'function') {
|
|
3358
|
+
inflightScopedDreamGroups.add(sessionId);
|
|
3346
3359
|
session._dreamProgressSink = (evt) => {
|
|
3347
3360
|
try {
|
|
3348
|
-
const stamped = evt && evt.
|
|
3361
|
+
const stamped = evt && evt.sessionId
|
|
3349
3362
|
? evt
|
|
3350
|
-
: { ...evt,
|
|
3363
|
+
: { ...evt, sessionId };
|
|
3351
3364
|
originalSink(stamped);
|
|
3352
3365
|
} catch { /* never let event delivery throw */ }
|
|
3353
3366
|
};
|
|
@@ -3360,8 +3373,8 @@ export async function handleYeaftDreamTrigger(msg = {}) {
|
|
|
3360
3373
|
status: 'running',
|
|
3361
3374
|
});
|
|
3362
3375
|
|
|
3363
|
-
const result =
|
|
3364
|
-
? await session.dreamScheduler.triggerDreamForScopes([`group/${
|
|
3376
|
+
const result = sessionId
|
|
3377
|
+
? await session.dreamScheduler.triggerDreamForScopes([`group/${sessionId}`])
|
|
3365
3378
|
: await session.dreamScheduler.triggerDreamNow();
|
|
3366
3379
|
|
|
3367
3380
|
const normalized = normalizeDreamResult(result);
|
|
@@ -3397,10 +3410,10 @@ export async function handleYeaftDreamTrigger(msg = {}) {
|
|
|
3397
3410
|
});
|
|
3398
3411
|
} finally {
|
|
3399
3412
|
// Restore the original sink and release the per-group inflight lock.
|
|
3400
|
-
if (
|
|
3401
|
-
if (
|
|
3413
|
+
if (sessionId && session?._dreamActiveGroupId === sessionId) session._dreamActiveGroupId = null;
|
|
3414
|
+
if (sessionId && typeof originalSink === 'function') {
|
|
3402
3415
|
session._dreamProgressSink = originalSink;
|
|
3403
|
-
inflightScopedDreamGroups.delete(
|
|
3416
|
+
inflightScopedDreamGroups.delete(sessionId);
|
|
3404
3417
|
}
|
|
3405
3418
|
}
|
|
3406
3419
|
}
|
|
@@ -3467,7 +3480,7 @@ export async function handleYeaftFetchToolStats(_msg = {}) {
|
|
|
3467
3480
|
*
|
|
3468
3481
|
* Inputs (all optional):
|
|
3469
3482
|
* - `limit` — max number of loops to return (1..500, default 100)
|
|
3470
|
-
* - `
|
|
3483
|
+
* - `sessionId` — narrow by group
|
|
3471
3484
|
* - `threadId` — narrow by thread
|
|
3472
3485
|
*
|
|
3473
3486
|
* Sends:
|
|
@@ -3479,14 +3492,14 @@ export async function handleYeaftFetchToolStats(_msg = {}) {
|
|
|
3479
3492
|
export async function handleYeaftFetchDebugHistory(msg = {}) {
|
|
3480
3493
|
const limit = Number.isFinite(msg?.limit) ? Number(msg.limit) : 100;
|
|
3481
3494
|
const dreamLimit = Number.isFinite(msg?.dreamLimit) ? Number(msg.dreamLimit) : 5;
|
|
3482
|
-
const
|
|
3495
|
+
const sessionId = typeof msg?.sessionId === 'string' && msg.sessionId ? msg.sessionId : null;
|
|
3483
3496
|
const threadId = typeof msg?.threadId === 'string' && msg.threadId ? msg.threadId : null;
|
|
3484
3497
|
let loops = [];
|
|
3485
3498
|
let turns = [];
|
|
3486
3499
|
let dreamEvents = [];
|
|
3487
3500
|
try {
|
|
3488
3501
|
if (session?.trace && typeof session.trace.fetchRecentDebugHistory === 'function') {
|
|
3489
|
-
const out = session.trace.fetchRecentDebugHistory({ limit, dreamLimit,
|
|
3502
|
+
const out = session.trace.fetchRecentDebugHistory({ limit, dreamLimit, sessionId, threadId });
|
|
3490
3503
|
loops = Array.isArray(out?.loops) ? out.loops : [];
|
|
3491
3504
|
turns = Array.isArray(out?.turns) ? out.turns : [];
|
|
3492
3505
|
dreamEvents = Array.isArray(out?.dreamEvents) ? out.dreamEvents : [];
|
|
@@ -3506,7 +3519,7 @@ export async function handleYeaftFetchDebugHistory(msg = {}) {
|
|
|
3506
3519
|
loops,
|
|
3507
3520
|
turns,
|
|
3508
3521
|
dreamEvents,
|
|
3509
|
-
|
|
3522
|
+
sessionId,
|
|
3510
3523
|
threadId,
|
|
3511
3524
|
});
|
|
3512
3525
|
}
|
|
@@ -3540,20 +3553,20 @@ export function handleYeaftModelSwitch(msg) {
|
|
|
3540
3553
|
* Handle history load request. Loads recent messages from ConversationStore
|
|
3541
3554
|
* and replays them through the standard claude_output pipeline.
|
|
3542
3555
|
*
|
|
3543
|
-
* Group-history-isolation (Bug 7): when `msg.
|
|
3556
|
+
* Group-history-isolation (Bug 7): when `msg.sessionId` is provided the
|
|
3544
3557
|
* replay AND the engine's bootstrap context are filtered to that group.
|
|
3545
|
-
* Messages tagged with another
|
|
3546
|
-
*
|
|
3558
|
+
* Messages tagged with another sessionId — and legacy messages with no
|
|
3559
|
+
* sessionId at all — are excluded so a stale `grp_default` (or any other
|
|
3547
3560
|
* group) never bleeds into the active group's pane.
|
|
3548
3561
|
*/
|
|
3549
3562
|
export async function handleYeaftLoadHistory(msg) {
|
|
3550
|
-
const
|
|
3563
|
+
const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
|
|
3551
3564
|
// `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
|
|
3552
3565
|
// `loadRecentByGroup` use turn-based slicing so the cut never lands
|
|
3553
3566
|
// mid-tool-arc. Pass `undefined` to use the persistence-layer default
|
|
3554
3567
|
// (DEFAULT_RECENT_TURNS = 20 turns).
|
|
3555
3568
|
const pickRecent = (store, lim) =>
|
|
3556
|
-
|
|
3569
|
+
sessionId ? store.loadRecentByGroup(sessionId, lim) : store.loadRecent(lim);
|
|
3557
3570
|
|
|
3558
3571
|
if (!session) {
|
|
3559
3572
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
@@ -3568,16 +3581,16 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3568
3581
|
yeaftConversationId = `yeaft-${Date.now()}`;
|
|
3569
3582
|
|
|
3570
3583
|
// Per-group history hydrates lazily via getOrCreateGroupHistory.
|
|
3571
|
-
// When the load-history call carries a
|
|
3584
|
+
// When the load-history call carries a sessionId, force-refresh THAT
|
|
3572
3585
|
// group's tape so the next user message sees on-disk state. When
|
|
3573
3586
|
// it doesn't (legacy callers), do nothing — the per-group lazy
|
|
3574
3587
|
// hydration handles it.
|
|
3575
|
-
if (
|
|
3576
|
-
} else if (
|
|
3588
|
+
if (sessionId) setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
|
|
3589
|
+
} else if (sessionId) {
|
|
3577
3590
|
// Re-entering an existing session with a (possibly new) group filter:
|
|
3578
3591
|
// re-seed THIS group's history from disk so it doesn't carry stale
|
|
3579
3592
|
// in-memory state into the next turn's context.
|
|
3580
|
-
setGroupHistory(
|
|
3593
|
+
setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
|
|
3581
3594
|
}
|
|
3582
3595
|
|
|
3583
3596
|
// Always replay session_ready so refresh / reconnect rebuilds UI state.
|
|
@@ -3606,14 +3619,14 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3606
3619
|
// opening a group can paint the latest messages quickly; older rows are
|
|
3607
3620
|
// paged via `yeaft_load_more_history` when the user scrolls upward.
|
|
3608
3621
|
const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
|
|
3609
|
-
const visiblePage =
|
|
3610
|
-
? loadVisibleGroupHistoryPage(session.conversationStore,
|
|
3622
|
+
const visiblePage = sessionId
|
|
3623
|
+
? loadVisibleGroupHistoryPage(session.conversationStore, sessionId, limit)
|
|
3611
3624
|
: { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
|
|
3612
3625
|
// Legacy compact.md is a non-group fallback only. For group replay, reading
|
|
3613
3626
|
// it makes every group show "has compact" once any legacy/non-scoped compact
|
|
3614
3627
|
// exists, even when this group has no scoped summary.
|
|
3615
|
-
const compactSummary =
|
|
3616
|
-
const replayEntries =
|
|
3628
|
+
const compactSummary = sessionId ? '' : session.conversationStore.readCompactSummary();
|
|
3629
|
+
const replayEntries = sessionId
|
|
3617
3630
|
? visiblePage.messages
|
|
3618
3631
|
: visiblePage.messages
|
|
3619
3632
|
.map(projectPersistedToVisibleHistoryEntry)
|
|
@@ -3629,14 +3642,14 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3629
3642
|
...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
|
|
3630
3643
|
},
|
|
3631
3644
|
ts: entry.ts || null,
|
|
3632
|
-
}, {
|
|
3645
|
+
}, { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' });
|
|
3633
3646
|
} else if (entry.role === 'assistant') {
|
|
3634
3647
|
// speakerVpId rides on the envelope so the frontend can route this
|
|
3635
3648
|
// replayed assistant text to the correct VP track. Without it, the
|
|
3636
3649
|
// history replay would merge replies from different VPs onto one
|
|
3637
3650
|
// anonymous assistant turn.
|
|
3638
3651
|
const envelopeOpts = {
|
|
3639
|
-
|
|
3652
|
+
sessionId: entry.sessionId || null,
|
|
3640
3653
|
threadId: entry.threadId || 'main',
|
|
3641
3654
|
turnId: entry.turnId || entry.threadId || 'main',
|
|
3642
3655
|
};
|
|
@@ -3657,7 +3670,7 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3657
3670
|
// tail rows cannot consume the bootstrap window or create false hasMore.
|
|
3658
3671
|
let hasMore = false;
|
|
3659
3672
|
let oldestSeq = null;
|
|
3660
|
-
if (
|
|
3673
|
+
if (sessionId) {
|
|
3661
3674
|
hasMore = visiblePage.hasMore;
|
|
3662
3675
|
oldestSeq = visiblePage.oldestSeq;
|
|
3663
3676
|
}
|
|
@@ -3667,8 +3680,8 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3667
3680
|
// replay, only scoped per-(group, vp) summaries count; legacy compact.md is
|
|
3668
3681
|
// reserved for non-group / pre-scoped 1:1 callers.
|
|
3669
3682
|
let hasCompactSummaryFlag = !!compactSummary;
|
|
3670
|
-
if (
|
|
3671
|
-
hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(
|
|
3683
|
+
if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForGroup === 'function') {
|
|
3684
|
+
hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(sessionId);
|
|
3672
3685
|
}
|
|
3673
3686
|
|
|
3674
3687
|
sendYeaftEvent({
|
|
@@ -3677,7 +3690,7 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3677
3690
|
hasCompactSummary: hasCompactSummaryFlag,
|
|
3678
3691
|
totalHot: session.conversationStore.countHot(),
|
|
3679
3692
|
totalCold: session.conversationStore.countCold(),
|
|
3680
|
-
|
|
3693
|
+
sessionId,
|
|
3681
3694
|
hasMore,
|
|
3682
3695
|
oldestSeq,
|
|
3683
3696
|
});
|
|
@@ -3685,7 +3698,7 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3685
3698
|
|
|
3686
3699
|
/**
|
|
3687
3700
|
* Handle a "load older messages" pagination request. Reads `turns` more
|
|
3688
|
-
* turns of history strictly older than `beforeSeq` for `
|
|
3701
|
+
* turns of history strictly older than `beforeSeq` for `sessionId`, and
|
|
3689
3702
|
* emits them in a single `yeaft_history_chunk` envelope (NOT a
|
|
3690
3703
|
* `yeaft_output` — that pipeline appends, but the frontend needs to
|
|
3691
3704
|
* PREPEND these older messages above what it already has).
|
|
@@ -3694,18 +3707,18 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
3694
3707
|
* `handleYeaftLoadHistory` (user / assistant text only). On any internal
|
|
3695
3708
|
* failure we still emit an empty chunk so the spinner clears.
|
|
3696
3709
|
*
|
|
3697
|
-
* @param {object} msg — {
|
|
3710
|
+
* @param {object} msg — { sessionId, beforeSeq, turns }
|
|
3698
3711
|
*/
|
|
3699
3712
|
export async function handleYeaftLoadMoreHistory(msg) {
|
|
3700
|
-
const
|
|
3713
|
+
const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
|
|
3701
3714
|
const emit = (payload) => sendToServer({
|
|
3702
3715
|
type: 'yeaft_history_chunk',
|
|
3703
3716
|
conversationId: yeaftConversationId,
|
|
3704
|
-
|
|
3717
|
+
sessionId,
|
|
3705
3718
|
...payload,
|
|
3706
3719
|
});
|
|
3707
3720
|
|
|
3708
|
-
if (!session || !
|
|
3721
|
+
if (!session || !sessionId) {
|
|
3709
3722
|
emit({ messages: [], oldestSeq: null, hasMore: false });
|
|
3710
3723
|
return;
|
|
3711
3724
|
}
|
|
@@ -3715,7 +3728,7 @@ export async function handleYeaftLoadMoreHistory(msg) {
|
|
|
3715
3728
|
|
|
3716
3729
|
let result;
|
|
3717
3730
|
try {
|
|
3718
|
-
result = loadVisibleGroupHistoryPage(session.conversationStore,
|
|
3731
|
+
result = loadVisibleGroupHistoryPage(session.conversationStore, sessionId, turns, beforeSeq);
|
|
3719
3732
|
} catch (err) {
|
|
3720
3733
|
console.error('[Yeaft] loadOlderByGroup failed:', err.message);
|
|
3721
3734
|
result = { messages: [], oldestSeq: null, hasMore: false };
|
|
@@ -3731,7 +3744,7 @@ export async function handleYeaftLoadMoreHistory(msg) {
|
|
|
3731
3744
|
role: m.role,
|
|
3732
3745
|
content: m.content,
|
|
3733
3746
|
ts: m.ts || m.time || null,
|
|
3734
|
-
|
|
3747
|
+
sessionId: m.sessionId || null,
|
|
3735
3748
|
threadId: m.threadId || m.turnId || 'main',
|
|
3736
3749
|
turnId: m.turnId || m.threadId || 'main',
|
|
3737
3750
|
...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
|