@yeaft/webchat-agent 0.1.769 → 0.1.771
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 +168 -0
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,6 +50,7 @@ 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;
|
|
@@ -78,6 +79,43 @@ let currentAbortCtrl = null;
|
|
|
78
79
|
*/
|
|
79
80
|
const turnAbortCtrls = new Map();
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Per-VP status broker — the agent-side authority for VP timeline
|
|
84
|
+
* status. Lazy-initialized on first use because `sendUnifyEvent` is
|
|
85
|
+
* declared below; trying to call it at module top-level would crash
|
|
86
|
+
* with a TDZ error during agent boot.
|
|
87
|
+
*
|
|
88
|
+
* Every transition (typing → thinking → streaming → tool → idle) is
|
|
89
|
+
* pushed through `vpStatusBroker.transition(...)`. The broker also
|
|
90
|
+
* owns the `vp_status_snapshot` payload for reconnect.
|
|
91
|
+
*
|
|
92
|
+
* @type {ReturnType<typeof createVpStatusBroker> | null}
|
|
93
|
+
*/
|
|
94
|
+
let vpStatusBroker = null;
|
|
95
|
+
function getVpStatusBroker() {
|
|
96
|
+
if (!vpStatusBroker) {
|
|
97
|
+
vpStatusBroker = createVpStatusBroker({
|
|
98
|
+
send: (event) => {
|
|
99
|
+
// The broker emits both `vp_status_changed` and
|
|
100
|
+
// `vp_status_snapshot`. Both ride the standard sendUnifyEvent
|
|
101
|
+
// envelope so the frontend's existing unify_output dispatcher
|
|
102
|
+
// sees them. We stamp groupId/vpId on the envelope for
|
|
103
|
+
// events that target a specific VP so the server's per-client
|
|
104
|
+
// routing (groupId scoping, etc.) works the same way as
|
|
105
|
+
// typing events.
|
|
106
|
+
const env = {};
|
|
107
|
+
if (event && typeof event === 'object') {
|
|
108
|
+
if (event.groupId) env.groupId = event.groupId;
|
|
109
|
+
if (event.vpId) env.vpId = event.vpId;
|
|
110
|
+
if (event.turnId) env.turnId = event.turnId;
|
|
111
|
+
}
|
|
112
|
+
sendUnifyEvent(event, env);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return vpStatusBroker;
|
|
117
|
+
}
|
|
118
|
+
|
|
81
119
|
/**
|
|
82
120
|
* Per-VP inbox + driver + engine pool (group multi-VP delivery).
|
|
83
121
|
*
|
|
@@ -573,6 +611,15 @@ function enqueueForVp(groupId, vpId, envelope) {
|
|
|
573
611
|
}, { groupId, vpId, turnId });
|
|
574
612
|
} catch { /* never crash WS pipeline */ }
|
|
575
613
|
|
|
614
|
+
// vp-status: queued, driver not running yet → 'typing'. Emitted
|
|
615
|
+
// alongside `vp_typing_start` so the timeline pane lights up on the
|
|
616
|
+
// same edge as the per-message typing dot.
|
|
617
|
+
try {
|
|
618
|
+
getVpStatusBroker().transition({ groupId, vpId, state: 'typing', turnId });
|
|
619
|
+
} catch (err) {
|
|
620
|
+
console.warn('[Unify] vp-status typing transition failed:', err?.message || err);
|
|
621
|
+
}
|
|
622
|
+
|
|
576
623
|
ensureDriverRunning(groupId, vpId);
|
|
577
624
|
}
|
|
578
625
|
|
|
@@ -864,6 +911,18 @@ export function handleUnifyVpDelete(msg) {
|
|
|
864
911
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
865
912
|
const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
|
|
866
913
|
deleteVp(vpId, memoryRoot ? { memoryRoot } : {});
|
|
914
|
+
// vp-status: a deleted VP must not haunt the snapshot. We don't
|
|
915
|
+
// know up front which groups the VP appeared in (the registry's
|
|
916
|
+
// delete already detached it from every group), so sweep every
|
|
917
|
+
// matching entry from the broker table.
|
|
918
|
+
try {
|
|
919
|
+
const broker = getVpStatusBroker();
|
|
920
|
+
for (const row of broker.snapshot()) {
|
|
921
|
+
if (row.vpId === vpId) broker.forget({ groupId: row.groupId, vpId });
|
|
922
|
+
}
|
|
923
|
+
} catch (err) {
|
|
924
|
+
console.warn('[Unify] vp-status forget on delete failed:', err?.message || err);
|
|
925
|
+
}
|
|
867
926
|
sendVpCrudResult({ op: 'delete', requestId, ok: true, vpId });
|
|
868
927
|
} catch (err) {
|
|
869
928
|
sendVpCrudResult({
|
|
@@ -1165,6 +1224,29 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
1165
1224
|
};
|
|
1166
1225
|
}
|
|
1167
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* Mid-turn vp-status transitions (text_delta / tool_call / tool_end).
|
|
1229
|
+
* Tolerates `hctx` missing groupId/vpId — pre-707 1:1 chat paths don't
|
|
1230
|
+
* have either; they're tracked as the default broker key but the
|
|
1231
|
+
* frontend ignores rows it doesn't recognize.
|
|
1232
|
+
*
|
|
1233
|
+
* @param {object} hctx
|
|
1234
|
+
* @param {string} state
|
|
1235
|
+
*/
|
|
1236
|
+
function maybeTransitionVpStatus(hctx, state) {
|
|
1237
|
+
if (!hctx || !hctx.vpId) return;
|
|
1238
|
+
try {
|
|
1239
|
+
getVpStatusBroker().transition({
|
|
1240
|
+
groupId: hctx.groupId || null,
|
|
1241
|
+
vpId: hctx.vpId,
|
|
1242
|
+
state,
|
|
1243
|
+
turnId: hctx.turnId || null,
|
|
1244
|
+
});
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
console.warn(`[Unify] vp-status ${state} transition failed:`, err?.message || err);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1168
1250
|
/**
|
|
1169
1251
|
* Handle a single engine event unwrapped from an `engine_event` envelope.
|
|
1170
1252
|
* H2.f.2: no longer stamps a threadId on outgoing claude_output frames.
|
|
@@ -1187,6 +1269,10 @@ function handleEngineEvent(event, hctx) {
|
|
|
1187
1269
|
type: 'assistant',
|
|
1188
1270
|
message: { content: [{ type: 'text', text: event.text }] },
|
|
1189
1271
|
}, envelope);
|
|
1272
|
+
// vp-status: first text-delta of a (thinking|tool) phase flips
|
|
1273
|
+
// the row to 'streaming'. transition() is a no-op when already
|
|
1274
|
+
// streaming, so subsequent deltas are cheap.
|
|
1275
|
+
maybeTransitionVpStatus(hctx, 'streaming');
|
|
1190
1276
|
break;
|
|
1191
1277
|
|
|
1192
1278
|
case 'thinking_delta':
|
|
@@ -1220,6 +1306,7 @@ function handleEngineEvent(event, hctx) {
|
|
|
1220
1306
|
}],
|
|
1221
1307
|
},
|
|
1222
1308
|
}, envelope);
|
|
1309
|
+
maybeTransitionVpStatus(hctx, 'tool');
|
|
1223
1310
|
break;
|
|
1224
1311
|
|
|
1225
1312
|
case 'tool_start':
|
|
@@ -1248,6 +1335,12 @@ function handleEngineEvent(event, hctx) {
|
|
|
1248
1335
|
is_error: event.isError || false,
|
|
1249
1336
|
}],
|
|
1250
1337
|
}, envelope);
|
|
1338
|
+
// Tool finished. The engine may either (a) emit more text-deltas
|
|
1339
|
+
// before end_turn, or (b) go straight to end_turn. Settle the
|
|
1340
|
+
// row back to 'thinking' — if (a), the next text_delta will flip
|
|
1341
|
+
// it to 'streaming'; if (b), runVpTurn's finally will flip it to
|
|
1342
|
+
// 'idle'. Either way we never strand the row in 'tool'.
|
|
1343
|
+
maybeTransitionVpStatus(hctx, 'thinking');
|
|
1251
1344
|
break;
|
|
1252
1345
|
|
|
1253
1346
|
case 'turn_start':
|
|
@@ -1964,6 +2057,15 @@ async function ensureSessionLoaded() {
|
|
|
1964
2057
|
tools: session.status.tools,
|
|
1965
2058
|
});
|
|
1966
2059
|
sendGroupSnapshotBroadcast();
|
|
2060
|
+
// vp-status: rebuild frontend status table from authoritative agent
|
|
2061
|
+
// memory. Sent unconditionally so reconnect/refresh paths get the same
|
|
2062
|
+
// bootstrap as first-load (the broker dedup logic makes a redundant
|
|
2063
|
+
// snapshot harmless).
|
|
2064
|
+
try {
|
|
2065
|
+
getVpStatusBroker().broadcastSnapshot();
|
|
2066
|
+
} catch (err) {
|
|
2067
|
+
console.warn('[Unify] vp-status snapshot broadcast failed:', err?.message || err);
|
|
2068
|
+
}
|
|
1967
2069
|
}
|
|
1968
2070
|
|
|
1969
2071
|
/**
|
|
@@ -2008,6 +2110,19 @@ async function runVpTurnWithEscalation(args) {
|
|
|
2008
2110
|
{ groupId, vpId, turnId },
|
|
2009
2111
|
);
|
|
2010
2112
|
} catch { /* never crash WS pipeline */ }
|
|
2113
|
+
// vp-status: when the watchdog escalates, `runVpTurn`'s inner
|
|
2114
|
+
// promise is still dangling (the adapter is ignoring `signal`)
|
|
2115
|
+
// and its outer `finally` won't run until the adapter eventually
|
|
2116
|
+
// returns — which may be never. Settle the broker here so the
|
|
2117
|
+
// row drops to idle in lockstep with the synthetic stop frame.
|
|
2118
|
+
// This is the exact failure mode the watchdog exists for; not
|
|
2119
|
+
// settling here would re-introduce the "stuck on streaming" bug
|
|
2120
|
+
// the whole PR is meant to fix.
|
|
2121
|
+
try {
|
|
2122
|
+
getVpStatusBroker().settleIdle({ groupId, vpId });
|
|
2123
|
+
} catch (err) {
|
|
2124
|
+
console.warn('[Unify] vp-status settleIdle (escalation) failed:', err?.message || err);
|
|
2125
|
+
}
|
|
2011
2126
|
},
|
|
2012
2127
|
});
|
|
2013
2128
|
}
|
|
@@ -2099,6 +2214,12 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
2099
2214
|
|
|
2100
2215
|
// Emit turn_start so frontend can create the message block.
|
|
2101
2216
|
sendUnifyEvent({ type: 'vp_turn_start', vpId, turnId, groupId }, envelope);
|
|
2217
|
+
// vp-status: LLM call about to start, no text/tool yet → 'thinking'.
|
|
2218
|
+
try {
|
|
2219
|
+
getVpStatusBroker().transition({ groupId, vpId, state: 'thinking', turnId });
|
|
2220
|
+
} catch (err) {
|
|
2221
|
+
console.warn('[Unify] vp-status thinking transition failed:', err?.message || err);
|
|
2222
|
+
}
|
|
2102
2223
|
|
|
2103
2224
|
try {
|
|
2104
2225
|
const assistantTextParts = [];
|
|
@@ -2184,6 +2305,17 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
2184
2305
|
|
|
2185
2306
|
console.error('[Unify] query error:', err);
|
|
2186
2307
|
|
|
2308
|
+
// vp-status: surface a transient `error` state so the row's status
|
|
2309
|
+
// label flips red for the brief window before the outer finally
|
|
2310
|
+
// settles it to idle. Without this, an LLM/tool failure would look
|
|
2311
|
+
// identical to a normal turn end in the timeline — the user has
|
|
2312
|
+
// no way to tell from the row that something went wrong.
|
|
2313
|
+
try {
|
|
2314
|
+
getVpStatusBroker().transition({ groupId, vpId, state: 'error', turnId });
|
|
2315
|
+
} catch (brokerErr) {
|
|
2316
|
+
console.warn('[Unify] vp-status error transition failed:', brokerErr?.message || brokerErr);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2187
2319
|
if (isPermissionErrorMsg(err.message)) {
|
|
2188
2320
|
if (!_permissionDiagnosticSent) {
|
|
2189
2321
|
_permissionDiagnosticSent = true;
|
|
@@ -2212,6 +2344,16 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
2212
2344
|
type: 'result',
|
|
2213
2345
|
result_text: '',
|
|
2214
2346
|
}, envelope);
|
|
2347
|
+
} finally {
|
|
2348
|
+
// vp-status: guaranteed-settle. Regardless of how the turn exited
|
|
2349
|
+
// (normal completion, AbortError early-return, caught exception),
|
|
2350
|
+
// the row must drop back to 'idle'. Wrapped in its own try so a
|
|
2351
|
+
// broker bug can't mask the original error.
|
|
2352
|
+
try {
|
|
2353
|
+
getVpStatusBroker().settleIdle({ groupId, vpId });
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
console.warn('[Unify] vp-status settleIdle failed:', err?.message || err);
|
|
2356
|
+
}
|
|
2215
2357
|
}
|
|
2216
2358
|
}
|
|
2217
2359
|
|
|
@@ -2693,6 +2835,14 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2693
2835
|
tools: session.status.tools,
|
|
2694
2836
|
});
|
|
2695
2837
|
sendGroupSnapshotBroadcast();
|
|
2838
|
+
// vp-status: replay the authoritative table on reconnect so a refreshed
|
|
2839
|
+
// frontend doesn't have to wait for the next transition to learn each
|
|
2840
|
+
// VP's current state.
|
|
2841
|
+
try {
|
|
2842
|
+
getVpStatusBroker().broadcastSnapshot();
|
|
2843
|
+
} catch (err) {
|
|
2844
|
+
console.warn('[Unify] vp-status snapshot broadcast (replay) failed:', err?.message || err);
|
|
2845
|
+
}
|
|
2696
2846
|
|
|
2697
2847
|
// `msg.limit` is the replay-scrollback request from the frontend (UI
|
|
2698
2848
|
// history pane, not engine context). Semantics changed (2026-05-01):
|
|
@@ -2865,6 +3015,16 @@ export async function resetUnifySession() {
|
|
|
2865
3015
|
// History-dedup cache is keyed by per-session coordinator msg ids;
|
|
2866
3016
|
// a fresh session resets the id space, so clear the cache too.
|
|
2867
3017
|
_persistedUserMsgIds.clear();
|
|
3018
|
+
// vp-status: nuke the broker table too. Drivers above have just
|
|
3019
|
+
// been aborted, so any in-flight `settleIdle` from their outer
|
|
3020
|
+
// `finally` blocks is racing this reset. Clearing here makes the
|
|
3021
|
+
// post-reset `broadcastSnapshot` (further down) emit an empty
|
|
3022
|
+
// table, and the frontend mirror clears in lockstep.
|
|
3023
|
+
try {
|
|
3024
|
+
getVpStatusBroker().reset();
|
|
3025
|
+
} catch (err) {
|
|
3026
|
+
console.warn('[Unify] vp-status broker reset failed:', err?.message || err);
|
|
3027
|
+
}
|
|
2868
3028
|
|
|
2869
3029
|
try {
|
|
2870
3030
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
@@ -2890,6 +3050,14 @@ export async function resetUnifySession() {
|
|
|
2890
3050
|
mcpServers: session.status.mcpServers,
|
|
2891
3051
|
tools: session.status.tools,
|
|
2892
3052
|
});
|
|
3053
|
+
// vp-status: after a forced reset the broker table is still live in
|
|
3054
|
+
// memory; broadcast so the frontend can rebuild its mirror without
|
|
3055
|
+
// waiting for the first per-VP transition.
|
|
3056
|
+
try {
|
|
3057
|
+
getVpStatusBroker().broadcastSnapshot();
|
|
3058
|
+
} catch (err) {
|
|
3059
|
+
console.warn('[Unify] vp-status snapshot broadcast (reset) failed:', err?.message || err);
|
|
3060
|
+
}
|
|
2893
3061
|
} catch (err) {
|
|
2894
3062
|
console.error('[Unify] Failed to re-initialize session after reset:', err.message);
|
|
2895
3063
|
}
|