@yeaft/webchat-agent 0.1.770 → 0.1.772
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/unify/vp-status-broker.js +216 -0
- package/unify/web-bridge.js +256 -2
package/package.json
CHANGED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vp-status-broker.js — single source of truth for per-VP status,
|
|
3
|
+
* emitted to the frontend on every transition.
|
|
4
|
+
*
|
|
5
|
+
* Why: the browser used to reverse-infer VP status from assistant
|
|
6
|
+
* messages' `isStreaming` flag (web/stores/helpers/vp-timeline.js).
|
|
7
|
+
* That flag is a UI artifact, not a state machine — whenever the
|
|
8
|
+
* `result` event lands without flipping the flag (reconnect mid-turn,
|
|
9
|
+
* tool window where the flag is cosmetically dropped, persisted
|
|
10
|
+
* history that re-hydrates with the flag absent), the inferred
|
|
11
|
+
* status drifts and can stay stuck on "streaming" forever.
|
|
12
|
+
*
|
|
13
|
+
* This broker owns a tiny in-memory table keyed by `(groupId, vpId)`,
|
|
14
|
+
* and emits a `vp_status_changed` event each time the state really
|
|
15
|
+
* changes. `vp_status_snapshot` rebuilds the table on a fresh
|
|
16
|
+
* frontend (reconnect, refresh). The broker is in-memory only —
|
|
17
|
+
* agent restart starts everyone at `idle`, which is the right default
|
|
18
|
+
* because no turn is in flight on a fresh process either.
|
|
19
|
+
*
|
|
20
|
+
* State machine: see docs/notes/2026-05-15-vp-status-from-agent.md.
|
|
21
|
+
*
|
|
22
|
+
* Sink injection: the broker is constructed with a `send` callback so
|
|
23
|
+
* it doesn't depend on the WebSocket layer directly (and so tests can
|
|
24
|
+
* collect emitted events into an array without spinning up WS).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Valid status values. `error` is a terminal/sticky state that the
|
|
29
|
+
* caller is expected to transition out of (the broker doesn't
|
|
30
|
+
* auto-decay — that policy belongs to the caller / web bridge).
|
|
31
|
+
* @type {ReadonlySet<string>}
|
|
32
|
+
*/
|
|
33
|
+
export const VALID_STATES = new Set([
|
|
34
|
+
'idle',
|
|
35
|
+
'typing',
|
|
36
|
+
'thinking',
|
|
37
|
+
'streaming',
|
|
38
|
+
'tool',
|
|
39
|
+
'error',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} VpStatusEntry
|
|
44
|
+
* @property {string} state — one of VALID_STATES
|
|
45
|
+
* @property {number} since — ms timestamp the state was entered
|
|
46
|
+
* @property {string|null} turnId
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a broker instance.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} opts
|
|
53
|
+
* @param {(event: object) => void} opts.send — emits events to the
|
|
54
|
+
* wire (web bridge wraps `sendUnifyEvent`). Must be synchronous; the
|
|
55
|
+
* broker calls it inline so transitions are flushed in order.
|
|
56
|
+
* @param {() => number} [opts.now] — clock, defaults to
|
|
57
|
+
* `Date.now`. Injected so tests can pin timestamps.
|
|
58
|
+
*/
|
|
59
|
+
export function createVpStatusBroker({ send, now = Date.now } = {}) {
|
|
60
|
+
if (typeof send !== 'function') {
|
|
61
|
+
throw new TypeError('createVpStatusBroker: `send` callback is required');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `${groupId}::${vpId}` → VpStatusEntry. Composite key because the
|
|
66
|
+
* same vpId can appear in multiple groups; we track them separately
|
|
67
|
+
* so the frontend's per-group timeline reads only its slice.
|
|
68
|
+
* @type {Map<string, VpStatusEntry & {groupId: string, vpId: string}>}
|
|
69
|
+
*/
|
|
70
|
+
const table = new Map();
|
|
71
|
+
|
|
72
|
+
const keyOf = (groupId, vpId) => `${groupId || ''}::${vpId}`;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Apply a state transition. Idempotent: a no-op if the requested
|
|
76
|
+
* state is already current (no event emitted, no `since` rewrite).
|
|
77
|
+
* Validates the input state — unknown values throw, so a typo
|
|
78
|
+
* fails loudly in the agent log instead of silently breaking the
|
|
79
|
+
* UI's render.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} args
|
|
82
|
+
* @param {string} args.groupId
|
|
83
|
+
* @param {string} args.vpId
|
|
84
|
+
* @param {string} args.state — must be in VALID_STATES
|
|
85
|
+
* @param {string|null} [args.turnId] — optional, for non-idle states
|
|
86
|
+
* @returns {boolean} — true if a change was emitted
|
|
87
|
+
*/
|
|
88
|
+
function transition({ groupId, vpId, state, turnId = null }) {
|
|
89
|
+
if (!vpId) return false;
|
|
90
|
+
if (!VALID_STATES.has(state)) {
|
|
91
|
+
throw new RangeError(`vp-status-broker: invalid state '${state}'`);
|
|
92
|
+
}
|
|
93
|
+
const key = keyOf(groupId, vpId);
|
|
94
|
+
const prev = table.get(key);
|
|
95
|
+
// Dedup: same state AND same turnId means no real transition.
|
|
96
|
+
// Allowing turnId to differ even at the same state lets a fresh
|
|
97
|
+
// turn re-stamp `since` without spamming events.
|
|
98
|
+
if (prev && prev.state === state && prev.turnId === turnId) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const since = now();
|
|
102
|
+
const entry = { state, since, turnId, groupId: groupId || null, vpId };
|
|
103
|
+
table.set(key, entry);
|
|
104
|
+
send({
|
|
105
|
+
type: 'vp_status_changed',
|
|
106
|
+
groupId: entry.groupId,
|
|
107
|
+
vpId,
|
|
108
|
+
state,
|
|
109
|
+
since,
|
|
110
|
+
turnId,
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convenience: force `idle` regardless of current state. Used by
|
|
117
|
+
* the run-vp-turn `finally` block so a turn that ended via *any*
|
|
118
|
+
* path (normal, abort, error, watchdog escalation) always settles
|
|
119
|
+
* back to idle.
|
|
120
|
+
*/
|
|
121
|
+
function settleIdle({ groupId, vpId }) {
|
|
122
|
+
return transition({ groupId, vpId, state: 'idle', turnId: null });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the snapshot payload. Optionally filtered by groupId.
|
|
127
|
+
*
|
|
128
|
+
* Filtering semantics:
|
|
129
|
+
* - `groupId === undefined` → return every row across every group.
|
|
130
|
+
* This is the all-groups broadcast used on `session_ready`.
|
|
131
|
+
* - `groupId === null` → same as undefined. Treated as "no
|
|
132
|
+
* scope" because the wire envelope serializes `undefined` as
|
|
133
|
+
* `null` over JSON, and we want both forms to behave the same.
|
|
134
|
+
* - `groupId === '<id>'` → only rows for that group.
|
|
135
|
+
*
|
|
136
|
+
* The store mirrors this semantic on the frontend (see
|
|
137
|
+
* `vp_status_snapshot` handler in chat.js): scoped snapshots
|
|
138
|
+
* replace just that group's slice, while null/undefined snapshots
|
|
139
|
+
* replace the whole table.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} [groupId]
|
|
142
|
+
* @returns {Array<{vpId:string, state:string, since:number, turnId:string|null, groupId:string|null}>}
|
|
143
|
+
*/
|
|
144
|
+
function snapshot(groupId) {
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const entry of table.values()) {
|
|
147
|
+
if (groupId !== undefined && groupId !== null && entry.groupId !== groupId) continue;
|
|
148
|
+
out.push({
|
|
149
|
+
vpId: entry.vpId,
|
|
150
|
+
state: entry.state,
|
|
151
|
+
since: entry.since,
|
|
152
|
+
turnId: entry.turnId,
|
|
153
|
+
groupId: entry.groupId,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Emit a `vp_status_snapshot` to the wire. Pulls from `snapshot()`
|
|
161
|
+
* so the wire shape stays consistent with the in-memory table.
|
|
162
|
+
*
|
|
163
|
+
* Wire envelope: `{ type, groupId, statuses }` where `groupId` is
|
|
164
|
+
* `null` when unscoped (frontend uses null to mean "replace the
|
|
165
|
+
* whole table"). See `snapshot()` JSDoc for the scoping contract.
|
|
166
|
+
*/
|
|
167
|
+
function broadcastSnapshot({ groupId } = {}) {
|
|
168
|
+
send({
|
|
169
|
+
type: 'vp_status_snapshot',
|
|
170
|
+
groupId: groupId === undefined ? null : groupId,
|
|
171
|
+
statuses: snapshot(groupId),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Drop an entry — e.g. when a VP is removed from a group. Without
|
|
177
|
+
* this, a deleted VP would stay in the snapshot forever and
|
|
178
|
+
* re-appear on the frontend at every reconnect.
|
|
179
|
+
*/
|
|
180
|
+
function forget({ groupId, vpId }) {
|
|
181
|
+
table.delete(keyOf(groupId, vpId));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Wipe the in-memory table. Called from `resetUnifySession` on the
|
|
186
|
+
* agent side so a forced session reset doesn't leave the broker
|
|
187
|
+
* holding rows for VPs whose engines/inboxes have been cleared. The
|
|
188
|
+
* post-reset `broadcastSnapshot` then emits an empty table, and the
|
|
189
|
+
* frontend's mirror clears in lockstep.
|
|
190
|
+
*
|
|
191
|
+
* Distinct from `__testReset`: this is production code path, named
|
|
192
|
+
* accordingly. `__testReset` stays for tests that mutate broker
|
|
193
|
+
* state across describe-blocks.
|
|
194
|
+
*/
|
|
195
|
+
function reset() {
|
|
196
|
+
table.clear();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Wipe everything (only used by tests; agent runtime keeps the
|
|
201
|
+
* broker alive for the whole process).
|
|
202
|
+
*/
|
|
203
|
+
function __testReset() {
|
|
204
|
+
table.clear();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
transition,
|
|
209
|
+
settleIdle,
|
|
210
|
+
snapshot,
|
|
211
|
+
broadcastSnapshot,
|
|
212
|
+
forget,
|
|
213
|
+
reset,
|
|
214
|
+
__testReset,
|
|
215
|
+
};
|
|
216
|
+
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -50,10 +50,28 @@ import {
|
|
|
50
50
|
} from './history-compact.js';
|
|
51
51
|
import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
|
|
52
52
|
import { parseSeqFromId } from './conversation/persist.js';
|
|
53
|
+
import { createVpStatusBroker } from './vp-status-broker.js';
|
|
53
54
|
|
|
54
55
|
/** @type {import('./session.js').Session | null} */
|
|
55
56
|
let session = null;
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Tracks scoped-dream triggers that are currently inflight, keyed by
|
|
60
|
+
* groupId. Used by `handleUnifyDreamTrigger` to reject any overlapping
|
|
61
|
+
* scoped trigger rather than racing the sink-wrapping logic against
|
|
62
|
+
* itself.
|
|
63
|
+
*
|
|
64
|
+
* Cross-group overlap is rejected (not just same-group): under the
|
|
65
|
+
* existing dream scheduler a second concurrent trigger silently shares
|
|
66
|
+
* the first's inflight promise and dropped its own scope filter. So
|
|
67
|
+
* "B during A's run" doesn't actually produce a separate scoped pass
|
|
68
|
+
* for B — letting B install a second sink wrapper would only mis-stamp
|
|
69
|
+
* A's events with B's groupId. Rejecting B with an explicit error is
|
|
70
|
+
* the honest answer; the user can re-click after A settles.
|
|
71
|
+
* @type {Set<string>}
|
|
72
|
+
*/
|
|
73
|
+
const inflightScopedDreamGroups = new Set();
|
|
74
|
+
|
|
57
75
|
/**
|
|
58
76
|
* Single in-flight AbortController. A new user message cancels the prior
|
|
59
77
|
* round (if any). H2.f.2: replaces the per-thread Map.
|
|
@@ -78,6 +96,43 @@ let currentAbortCtrl = null;
|
|
|
78
96
|
*/
|
|
79
97
|
const turnAbortCtrls = new Map();
|
|
80
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Per-VP status broker — the agent-side authority for VP timeline
|
|
101
|
+
* status. Lazy-initialized on first use because `sendUnifyEvent` is
|
|
102
|
+
* declared below; trying to call it at module top-level would crash
|
|
103
|
+
* with a TDZ error during agent boot.
|
|
104
|
+
*
|
|
105
|
+
* Every transition (typing → thinking → streaming → tool → idle) is
|
|
106
|
+
* pushed through `vpStatusBroker.transition(...)`. The broker also
|
|
107
|
+
* owns the `vp_status_snapshot` payload for reconnect.
|
|
108
|
+
*
|
|
109
|
+
* @type {ReturnType<typeof createVpStatusBroker> | null}
|
|
110
|
+
*/
|
|
111
|
+
let vpStatusBroker = null;
|
|
112
|
+
function getVpStatusBroker() {
|
|
113
|
+
if (!vpStatusBroker) {
|
|
114
|
+
vpStatusBroker = createVpStatusBroker({
|
|
115
|
+
send: (event) => {
|
|
116
|
+
// The broker emits both `vp_status_changed` and
|
|
117
|
+
// `vp_status_snapshot`. Both ride the standard sendUnifyEvent
|
|
118
|
+
// envelope so the frontend's existing unify_output dispatcher
|
|
119
|
+
// sees them. We stamp groupId/vpId on the envelope for
|
|
120
|
+
// events that target a specific VP so the server's per-client
|
|
121
|
+
// routing (groupId scoping, etc.) works the same way as
|
|
122
|
+
// typing events.
|
|
123
|
+
const env = {};
|
|
124
|
+
if (event && typeof event === 'object') {
|
|
125
|
+
if (event.groupId) env.groupId = event.groupId;
|
|
126
|
+
if (event.vpId) env.vpId = event.vpId;
|
|
127
|
+
if (event.turnId) env.turnId = event.turnId;
|
|
128
|
+
}
|
|
129
|
+
sendUnifyEvent(event, env);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return vpStatusBroker;
|
|
134
|
+
}
|
|
135
|
+
|
|
81
136
|
/**
|
|
82
137
|
* Per-VP inbox + driver + engine pool (group multi-VP delivery).
|
|
83
138
|
*
|
|
@@ -573,6 +628,15 @@ function enqueueForVp(groupId, vpId, envelope) {
|
|
|
573
628
|
}, { groupId, vpId, turnId });
|
|
574
629
|
} catch { /* never crash WS pipeline */ }
|
|
575
630
|
|
|
631
|
+
// vp-status: queued, driver not running yet → 'typing'. Emitted
|
|
632
|
+
// alongside `vp_typing_start` so the timeline pane lights up on the
|
|
633
|
+
// same edge as the per-message typing dot.
|
|
634
|
+
try {
|
|
635
|
+
getVpStatusBroker().transition({ groupId, vpId, state: 'typing', turnId });
|
|
636
|
+
} catch (err) {
|
|
637
|
+
console.warn('[Unify] vp-status typing transition failed:', err?.message || err);
|
|
638
|
+
}
|
|
639
|
+
|
|
576
640
|
ensureDriverRunning(groupId, vpId);
|
|
577
641
|
}
|
|
578
642
|
|
|
@@ -864,6 +928,18 @@ export function handleUnifyVpDelete(msg) {
|
|
|
864
928
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
865
929
|
const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
|
|
866
930
|
deleteVp(vpId, memoryRoot ? { memoryRoot } : {});
|
|
931
|
+
// vp-status: a deleted VP must not haunt the snapshot. We don't
|
|
932
|
+
// know up front which groups the VP appeared in (the registry's
|
|
933
|
+
// delete already detached it from every group), so sweep every
|
|
934
|
+
// matching entry from the broker table.
|
|
935
|
+
try {
|
|
936
|
+
const broker = getVpStatusBroker();
|
|
937
|
+
for (const row of broker.snapshot()) {
|
|
938
|
+
if (row.vpId === vpId) broker.forget({ groupId: row.groupId, vpId });
|
|
939
|
+
}
|
|
940
|
+
} catch (err) {
|
|
941
|
+
console.warn('[Unify] vp-status forget on delete failed:', err?.message || err);
|
|
942
|
+
}
|
|
867
943
|
sendVpCrudResult({ op: 'delete', requestId, ok: true, vpId });
|
|
868
944
|
} catch (err) {
|
|
869
945
|
sendVpCrudResult({
|
|
@@ -1126,9 +1202,23 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
1126
1202
|
if (!s) return;
|
|
1127
1203
|
|
|
1128
1204
|
// Forward dream pipeline progress events to the web debug panel.
|
|
1205
|
+
//
|
|
1206
|
+
// Group-id stamping is NO LONGER done here. It used to be: this sink
|
|
1207
|
+
// read a module-level `activeScopedDreamGroupId` that
|
|
1208
|
+
// `handleUnifyDreamTrigger({groupId})` parked before awaiting the
|
|
1209
|
+
// scope-filtered pass. That created a race when two scoped triggers
|
|
1210
|
+
// overlapped (auto-tick during a manual click; or two manual clicks
|
|
1211
|
+
// for different groups): the second handler's `finally` could clear
|
|
1212
|
+
// the module slot while the first run was still emitting events,
|
|
1213
|
+
// dropping the stamp from the tail of the first pass. The new design:
|
|
1214
|
+
// `handleUnifyDreamTrigger` wraps THIS sink for the lifetime of the
|
|
1215
|
+
// trigger to inject `groupId` per-call (see that function below). The
|
|
1216
|
+
// base sink is intentionally a pure passthrough.
|
|
1129
1217
|
s._dreamProgressSink = (evt) => {
|
|
1130
1218
|
try {
|
|
1131
|
-
|
|
1219
|
+
const out = { type: 'dream_progress', ...evt };
|
|
1220
|
+
const tag = evt && evt.groupId ? { groupId: evt.groupId } : {};
|
|
1221
|
+
sendUnifyEvent(out, tag);
|
|
1132
1222
|
} catch { /* never let event delivery throw */ }
|
|
1133
1223
|
};
|
|
1134
1224
|
|
|
@@ -1165,6 +1255,29 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
1165
1255
|
};
|
|
1166
1256
|
}
|
|
1167
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* Mid-turn vp-status transitions (text_delta / tool_call / tool_end).
|
|
1260
|
+
* Tolerates `hctx` missing groupId/vpId — pre-707 1:1 chat paths don't
|
|
1261
|
+
* have either; they're tracked as the default broker key but the
|
|
1262
|
+
* frontend ignores rows it doesn't recognize.
|
|
1263
|
+
*
|
|
1264
|
+
* @param {object} hctx
|
|
1265
|
+
* @param {string} state
|
|
1266
|
+
*/
|
|
1267
|
+
function maybeTransitionVpStatus(hctx, state) {
|
|
1268
|
+
if (!hctx || !hctx.vpId) return;
|
|
1269
|
+
try {
|
|
1270
|
+
getVpStatusBroker().transition({
|
|
1271
|
+
groupId: hctx.groupId || null,
|
|
1272
|
+
vpId: hctx.vpId,
|
|
1273
|
+
state,
|
|
1274
|
+
turnId: hctx.turnId || null,
|
|
1275
|
+
});
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
console.warn(`[Unify] vp-status ${state} transition failed:`, err?.message || err);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1168
1281
|
/**
|
|
1169
1282
|
* Handle a single engine event unwrapped from an `engine_event` envelope.
|
|
1170
1283
|
* H2.f.2: no longer stamps a threadId on outgoing claude_output frames.
|
|
@@ -1187,6 +1300,10 @@ function handleEngineEvent(event, hctx) {
|
|
|
1187
1300
|
type: 'assistant',
|
|
1188
1301
|
message: { content: [{ type: 'text', text: event.text }] },
|
|
1189
1302
|
}, envelope);
|
|
1303
|
+
// vp-status: first text-delta of a (thinking|tool) phase flips
|
|
1304
|
+
// the row to 'streaming'. transition() is a no-op when already
|
|
1305
|
+
// streaming, so subsequent deltas are cheap.
|
|
1306
|
+
maybeTransitionVpStatus(hctx, 'streaming');
|
|
1190
1307
|
break;
|
|
1191
1308
|
|
|
1192
1309
|
case 'thinking_delta':
|
|
@@ -1220,6 +1337,7 @@ function handleEngineEvent(event, hctx) {
|
|
|
1220
1337
|
}],
|
|
1221
1338
|
},
|
|
1222
1339
|
}, envelope);
|
|
1340
|
+
maybeTransitionVpStatus(hctx, 'tool');
|
|
1223
1341
|
break;
|
|
1224
1342
|
|
|
1225
1343
|
case 'tool_start':
|
|
@@ -1248,6 +1366,12 @@ function handleEngineEvent(event, hctx) {
|
|
|
1248
1366
|
is_error: event.isError || false,
|
|
1249
1367
|
}],
|
|
1250
1368
|
}, envelope);
|
|
1369
|
+
// Tool finished. The engine may either (a) emit more text-deltas
|
|
1370
|
+
// before end_turn, or (b) go straight to end_turn. Settle the
|
|
1371
|
+
// row back to 'thinking' — if (a), the next text_delta will flip
|
|
1372
|
+
// it to 'streaming'; if (b), runVpTurn's finally will flip it to
|
|
1373
|
+
// 'idle'. Either way we never strand the row in 'tool'.
|
|
1374
|
+
maybeTransitionVpStatus(hctx, 'thinking');
|
|
1251
1375
|
break;
|
|
1252
1376
|
|
|
1253
1377
|
case 'turn_start':
|
|
@@ -1964,6 +2088,15 @@ async function ensureSessionLoaded() {
|
|
|
1964
2088
|
tools: session.status.tools,
|
|
1965
2089
|
});
|
|
1966
2090
|
sendGroupSnapshotBroadcast();
|
|
2091
|
+
// vp-status: rebuild frontend status table from authoritative agent
|
|
2092
|
+
// memory. Sent unconditionally so reconnect/refresh paths get the same
|
|
2093
|
+
// bootstrap as first-load (the broker dedup logic makes a redundant
|
|
2094
|
+
// snapshot harmless).
|
|
2095
|
+
try {
|
|
2096
|
+
getVpStatusBroker().broadcastSnapshot();
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
console.warn('[Unify] vp-status snapshot broadcast failed:', err?.message || err);
|
|
2099
|
+
}
|
|
1967
2100
|
}
|
|
1968
2101
|
|
|
1969
2102
|
/**
|
|
@@ -2008,6 +2141,19 @@ async function runVpTurnWithEscalation(args) {
|
|
|
2008
2141
|
{ groupId, vpId, turnId },
|
|
2009
2142
|
);
|
|
2010
2143
|
} catch { /* never crash WS pipeline */ }
|
|
2144
|
+
// vp-status: when the watchdog escalates, `runVpTurn`'s inner
|
|
2145
|
+
// promise is still dangling (the adapter is ignoring `signal`)
|
|
2146
|
+
// and its outer `finally` won't run until the adapter eventually
|
|
2147
|
+
// returns — which may be never. Settle the broker here so the
|
|
2148
|
+
// row drops to idle in lockstep with the synthetic stop frame.
|
|
2149
|
+
// This is the exact failure mode the watchdog exists for; not
|
|
2150
|
+
// settling here would re-introduce the "stuck on streaming" bug
|
|
2151
|
+
// the whole PR is meant to fix.
|
|
2152
|
+
try {
|
|
2153
|
+
getVpStatusBroker().settleIdle({ groupId, vpId });
|
|
2154
|
+
} catch (err) {
|
|
2155
|
+
console.warn('[Unify] vp-status settleIdle (escalation) failed:', err?.message || err);
|
|
2156
|
+
}
|
|
2011
2157
|
},
|
|
2012
2158
|
});
|
|
2013
2159
|
}
|
|
@@ -2099,6 +2245,12 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
2099
2245
|
|
|
2100
2246
|
// Emit turn_start so frontend can create the message block.
|
|
2101
2247
|
sendUnifyEvent({ type: 'vp_turn_start', vpId, turnId, groupId }, envelope);
|
|
2248
|
+
// vp-status: LLM call about to start, no text/tool yet → 'thinking'.
|
|
2249
|
+
try {
|
|
2250
|
+
getVpStatusBroker().transition({ groupId, vpId, state: 'thinking', turnId });
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
console.warn('[Unify] vp-status thinking transition failed:', err?.message || err);
|
|
2253
|
+
}
|
|
2102
2254
|
|
|
2103
2255
|
try {
|
|
2104
2256
|
const assistantTextParts = [];
|
|
@@ -2184,6 +2336,17 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
2184
2336
|
|
|
2185
2337
|
console.error('[Unify] query error:', err);
|
|
2186
2338
|
|
|
2339
|
+
// vp-status: surface a transient `error` state so the row's status
|
|
2340
|
+
// label flips red for the brief window before the outer finally
|
|
2341
|
+
// settles it to idle. Without this, an LLM/tool failure would look
|
|
2342
|
+
// identical to a normal turn end in the timeline — the user has
|
|
2343
|
+
// no way to tell from the row that something went wrong.
|
|
2344
|
+
try {
|
|
2345
|
+
getVpStatusBroker().transition({ groupId, vpId, state: 'error', turnId });
|
|
2346
|
+
} catch (brokerErr) {
|
|
2347
|
+
console.warn('[Unify] vp-status error transition failed:', brokerErr?.message || brokerErr);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2187
2350
|
if (isPermissionErrorMsg(err.message)) {
|
|
2188
2351
|
if (!_permissionDiagnosticSent) {
|
|
2189
2352
|
_permissionDiagnosticSent = true;
|
|
@@ -2212,6 +2375,16 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
2212
2375
|
type: 'result',
|
|
2213
2376
|
result_text: '',
|
|
2214
2377
|
}, envelope);
|
|
2378
|
+
} finally {
|
|
2379
|
+
// vp-status: guaranteed-settle. Regardless of how the turn exited
|
|
2380
|
+
// (normal completion, AbortError early-return, caught exception),
|
|
2381
|
+
// the row must drop back to 'idle'. Wrapped in its own try so a
|
|
2382
|
+
// broker bug can't mask the original error.
|
|
2383
|
+
try {
|
|
2384
|
+
getVpStatusBroker().settleIdle({ groupId, vpId });
|
|
2385
|
+
} catch (err) {
|
|
2386
|
+
console.warn('[Unify] vp-status settleIdle failed:', err?.message || err);
|
|
2387
|
+
}
|
|
2215
2388
|
}
|
|
2216
2389
|
}
|
|
2217
2390
|
|
|
@@ -2518,6 +2691,46 @@ export async function handleUnifyDreamTrigger(msg = {}) {
|
|
|
2518
2691
|
return;
|
|
2519
2692
|
}
|
|
2520
2693
|
|
|
2694
|
+
// Concurrent-trigger guard for scoped runs. Two scoped clicks (same
|
|
2695
|
+
// group or different) overlapping the same inflight pass used to set
|
|
2696
|
+
// the module-level groupId slot, race the sink wrapping, and let the
|
|
2697
|
+
// second `finally` restore the original sink while the first run was
|
|
2698
|
+
// still emitting events. We now refuse any second scoped trigger
|
|
2699
|
+
// while ANY scoped pass is inflight — the scheduler already
|
|
2700
|
+
// short-circuits the underlying run for same-group, and a different
|
|
2701
|
+
// group's filter would have been silently dropped anyway (see
|
|
2702
|
+
// dream-v2/schedule.js inflight reuse), so the user-facing semantics
|
|
2703
|
+
// are unchanged ("you already asked").
|
|
2704
|
+
if (groupId && inflightScopedDreamGroups.size > 0) {
|
|
2705
|
+
sendToServer({
|
|
2706
|
+
type: 'unify_dream_result',
|
|
2707
|
+
...tag,
|
|
2708
|
+
success: false,
|
|
2709
|
+
error: 'A dream pass is already running.',
|
|
2710
|
+
});
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// Per-call sink wrapper. For scoped runs we install a closure that
|
|
2715
|
+
// injects this trigger's groupId onto top-level events the runner
|
|
2716
|
+
// emits without one (start/merge/done), then delegates to the
|
|
2717
|
+
// original passthrough sink. The wrapper lives only for the lifetime
|
|
2718
|
+
// of this trigger and is restored in `finally`; concurrent calls for
|
|
2719
|
+
// OTHER groupIds chain (last-installed wins) but each restoration
|
|
2720
|
+
// unwinds back to its predecessor.
|
|
2721
|
+
const originalSink = session?._dreamProgressSink;
|
|
2722
|
+
if (groupId && typeof originalSink === 'function') {
|
|
2723
|
+
inflightScopedDreamGroups.add(groupId);
|
|
2724
|
+
session._dreamProgressSink = (evt) => {
|
|
2725
|
+
try {
|
|
2726
|
+
const stamped = evt && evt.groupId
|
|
2727
|
+
? evt
|
|
2728
|
+
: { ...evt, groupId };
|
|
2729
|
+
originalSink(stamped);
|
|
2730
|
+
} catch { /* never let event delivery throw */ }
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2521
2734
|
try {
|
|
2522
2735
|
sendToServer({
|
|
2523
2736
|
type: 'unify_dream_status',
|
|
@@ -2536,6 +2749,7 @@ export async function handleUnifyDreamTrigger(msg = {}) {
|
|
|
2536
2749
|
const targets = Array.isArray(result?.targets) ? result.targets : [];
|
|
2537
2750
|
const entriesCreated = targets.filter(t => t && t.status === 'done').length;
|
|
2538
2751
|
const lastDreamAt = result?.startedAt || new Date().toISOString();
|
|
2752
|
+
const success = !result.error && !result.skipped;
|
|
2539
2753
|
|
|
2540
2754
|
// Spread `result` FIRST so derived fields (success, entriesCreated,
|
|
2541
2755
|
// lastDreamAt) authoritatively shadow anything the runner might grow
|
|
@@ -2543,11 +2757,19 @@ export async function handleUnifyDreamTrigger(msg = {}) {
|
|
|
2543
2757
|
// { groups, targets, startedAt, error?, skipped? }) but the failure
|
|
2544
2758
|
// mode of the alternative ordering is silent — review feedback from
|
|
2545
2759
|
// PR #743.
|
|
2760
|
+
//
|
|
2761
|
+
// This `unify_dream_result` envelope is the SOLE terminal signal for
|
|
2762
|
+
// a dream pass. The chat-store projects it into BOTH `unifyDreamLatest`
|
|
2763
|
+
// (final tally row) AND `unifyDreamEvents` (ring-buffer terminal
|
|
2764
|
+
// marker), so we no longer mirror a synthetic `phase:'result'`
|
|
2765
|
+
// dream_progress event — that mirror used to race the
|
|
2766
|
+
// `unifyDreamLatest` writer and flip the success row back to
|
|
2767
|
+
// 'running' (Critical reviewer finding pre-merge).
|
|
2546
2768
|
sendToServer({
|
|
2547
2769
|
type: 'unify_dream_result',
|
|
2548
2770
|
...tag,
|
|
2549
2771
|
...result,
|
|
2550
|
-
success
|
|
2772
|
+
success,
|
|
2551
2773
|
entriesCreated,
|
|
2552
2774
|
lastDreamAt,
|
|
2553
2775
|
});
|
|
@@ -2558,6 +2780,12 @@ export async function handleUnifyDreamTrigger(msg = {}) {
|
|
|
2558
2780
|
success: false,
|
|
2559
2781
|
error: err?.message || String(err),
|
|
2560
2782
|
});
|
|
2783
|
+
} finally {
|
|
2784
|
+
// Restore the original sink and release the per-group inflight lock.
|
|
2785
|
+
if (groupId && typeof originalSink === 'function') {
|
|
2786
|
+
session._dreamProgressSink = originalSink;
|
|
2787
|
+
inflightScopedDreamGroups.delete(groupId);
|
|
2788
|
+
}
|
|
2561
2789
|
}
|
|
2562
2790
|
}
|
|
2563
2791
|
|
|
@@ -2693,6 +2921,14 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2693
2921
|
tools: session.status.tools,
|
|
2694
2922
|
});
|
|
2695
2923
|
sendGroupSnapshotBroadcast();
|
|
2924
|
+
// vp-status: replay the authoritative table on reconnect so a refreshed
|
|
2925
|
+
// frontend doesn't have to wait for the next transition to learn each
|
|
2926
|
+
// VP's current state.
|
|
2927
|
+
try {
|
|
2928
|
+
getVpStatusBroker().broadcastSnapshot();
|
|
2929
|
+
} catch (err) {
|
|
2930
|
+
console.warn('[Unify] vp-status snapshot broadcast (replay) failed:', err?.message || err);
|
|
2931
|
+
}
|
|
2696
2932
|
|
|
2697
2933
|
// `msg.limit` is the replay-scrollback request from the frontend (UI
|
|
2698
2934
|
// history pane, not engine context). Semantics changed (2026-05-01):
|
|
@@ -2865,6 +3101,16 @@ export async function resetUnifySession() {
|
|
|
2865
3101
|
// History-dedup cache is keyed by per-session coordinator msg ids;
|
|
2866
3102
|
// a fresh session resets the id space, so clear the cache too.
|
|
2867
3103
|
_persistedUserMsgIds.clear();
|
|
3104
|
+
// vp-status: nuke the broker table too. Drivers above have just
|
|
3105
|
+
// been aborted, so any in-flight `settleIdle` from their outer
|
|
3106
|
+
// `finally` blocks is racing this reset. Clearing here makes the
|
|
3107
|
+
// post-reset `broadcastSnapshot` (further down) emit an empty
|
|
3108
|
+
// table, and the frontend mirror clears in lockstep.
|
|
3109
|
+
try {
|
|
3110
|
+
getVpStatusBroker().reset();
|
|
3111
|
+
} catch (err) {
|
|
3112
|
+
console.warn('[Unify] vp-status broker reset failed:', err?.message || err);
|
|
3113
|
+
}
|
|
2868
3114
|
|
|
2869
3115
|
try {
|
|
2870
3116
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
@@ -2890,6 +3136,14 @@ export async function resetUnifySession() {
|
|
|
2890
3136
|
mcpServers: session.status.mcpServers,
|
|
2891
3137
|
tools: session.status.tools,
|
|
2892
3138
|
});
|
|
3139
|
+
// vp-status: after a forced reset the broker table is still live in
|
|
3140
|
+
// memory; broadcast so the frontend can rebuild its mirror without
|
|
3141
|
+
// waiting for the first per-VP transition.
|
|
3142
|
+
try {
|
|
3143
|
+
getVpStatusBroker().broadcastSnapshot();
|
|
3144
|
+
} catch (err) {
|
|
3145
|
+
console.warn('[Unify] vp-status snapshot broadcast (reset) failed:', err?.message || err);
|
|
3146
|
+
}
|
|
2893
3147
|
} catch (err) {
|
|
2894
3148
|
console.error('[Unify] Failed to re-initialize session after reset:', err.message);
|
|
2895
3149
|
}
|