@yeaft/webchat-agent 0.1.654 → 0.1.656
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 +4 -12
- package/package.json +1 -1
- package/unify/engine.js +8 -29
- package/unify/session.js +11 -80
- package/unify/web-bridge.js +15 -93
- package/unify/input-queue/store.js +0 -322
- package/unify/pipeline/dispatcher.js +0 -196
- package/unify/threads/engine-instance.js +0 -312
- package/unify/threads/engine-registry.js +0 -260
- package/unify/threads/store.js +0 -886
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* input-queue/store.js — Persistent FIFO input queue for Yeaft Unify.
|
|
3
|
-
*
|
|
4
|
-
* task-307b (Phase 2): a disk-backed queue of user inputs waiting to be
|
|
5
|
-
* routed to a thread/engine. Each entry is one JSON file at
|
|
6
|
-
* <yeaftDir>/input-queue/<id>.json
|
|
7
|
-
* so that crashing during a routing decision never loses a queued input.
|
|
8
|
-
*
|
|
9
|
-
* Schema follows design doc §5:
|
|
10
|
-
* {
|
|
11
|
-
* id: 'iq-xxxxxxxx',
|
|
12
|
-
* text: '<the user-typed input>',
|
|
13
|
-
* createdAt: 1234567890123, // epoch ms
|
|
14
|
-
* status: 'pending' | 'routing' | 'dispatched',
|
|
15
|
-
* routedTo: '<threadId>' | null, // set when status === 'dispatched'
|
|
16
|
-
* routedAt: 1234567890456 | null,
|
|
17
|
-
* error: '<message>' | null, // optional — populated when a
|
|
18
|
-
* // routing attempt fails and
|
|
19
|
-
* // the entry is put back to
|
|
20
|
-
* // 'pending' for retry.
|
|
21
|
-
* }
|
|
22
|
-
*
|
|
23
|
-
* State machine:
|
|
24
|
-
* enqueue(text)
|
|
25
|
-
* │
|
|
26
|
-
* ▼
|
|
27
|
-
* pending ─────claim()─────► routing ─────markRouted()────► dispatched
|
|
28
|
-
* ▲ │
|
|
29
|
-
* └──────markFailed()──────┘ (status back to pending; error field recorded)
|
|
30
|
-
*
|
|
31
|
-
* - pending : waiting for a dispatcher
|
|
32
|
-
* - routing : a dispatcher has taken responsibility; crash here means
|
|
33
|
-
* boot-time recovery will still see the entry and can
|
|
34
|
-
* re-claim it (the row is still on disk).
|
|
35
|
-
* - dispatched : routed to a thread — the file is removed because the
|
|
36
|
-
* durable audit trail now lives in the messages.
|
|
37
|
-
*
|
|
38
|
-
* API (task-307b per PM):
|
|
39
|
-
* - enqueue(text) → entry Create + persist pending.
|
|
40
|
-
* - dequeue() → entry | null Peek at oldest pending
|
|
41
|
-
* (non-mutating — see
|
|
42
|
-
* claim() for the mutating
|
|
43
|
-
* transition).
|
|
44
|
-
* - claim() → entry | null Oldest pending → 'routing'
|
|
45
|
-
* (atomic w.r.t. disk).
|
|
46
|
-
* - list(status?) → entry[] Snapshot filtered by status.
|
|
47
|
-
* - markRouted(id, routedTo) → entry | null routing → dispatched;
|
|
48
|
-
* file deleted.
|
|
49
|
-
* - markFailed(id, err) → entry | null routing → pending; error
|
|
50
|
-
* recorded; kept on disk.
|
|
51
|
-
* - peek() / get(id) / remove(id) / size() / pendingCount()
|
|
52
|
-
*
|
|
53
|
-
* Persistence:
|
|
54
|
-
* - Writes are synchronous. The queue is a durability boundary, not a
|
|
55
|
-
* hot path (one write per state transition).
|
|
56
|
-
* - Permission/FS failures don't throw: they are swallowed and logged once
|
|
57
|
-
* per process (matches ConversationStore's philosophy).
|
|
58
|
-
* - If `yeaftDir` is omitted, the store operates purely in memory.
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
|
-
import { randomUUID } from 'crypto';
|
|
62
|
-
import {
|
|
63
|
-
existsSync,
|
|
64
|
-
mkdirSync,
|
|
65
|
-
readdirSync,
|
|
66
|
-
readFileSync,
|
|
67
|
-
writeFileSync,
|
|
68
|
-
unlinkSync,
|
|
69
|
-
} from 'fs';
|
|
70
|
-
import { join } from 'path';
|
|
71
|
-
|
|
72
|
-
/** All valid status values, per design §5. */
|
|
73
|
-
export const INPUT_QUEUE_STATUSES = ['pending', 'routing', 'dispatched'];
|
|
74
|
-
|
|
75
|
-
export class InputQueueStore {
|
|
76
|
-
/** @type {Map<string, object>} */
|
|
77
|
-
#entries;
|
|
78
|
-
/** @type {string|null} */
|
|
79
|
-
#dir;
|
|
80
|
-
/** @type {boolean} */
|
|
81
|
-
#persistent;
|
|
82
|
-
/** @type {boolean} */
|
|
83
|
-
#warned;
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* @param {string|null} [yeaftDir]
|
|
87
|
-
*/
|
|
88
|
-
constructor(yeaftDir = null) {
|
|
89
|
-
this.#entries = new Map();
|
|
90
|
-
this.#warned = false;
|
|
91
|
-
|
|
92
|
-
if (yeaftDir) {
|
|
93
|
-
this.#dir = join(yeaftDir, 'input-queue');
|
|
94
|
-
this.#persistent = true;
|
|
95
|
-
this.#ensureDir();
|
|
96
|
-
this.#loadAll();
|
|
97
|
-
} else {
|
|
98
|
-
this.#dir = null;
|
|
99
|
-
this.#persistent = false;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
get persistent() { return this.#persistent; }
|
|
104
|
-
size() { return this.#entries.size; }
|
|
105
|
-
|
|
106
|
-
pendingCount() {
|
|
107
|
-
let n = 0;
|
|
108
|
-
for (const e of this.#entries.values()) if (e.status === 'pending') n += 1;
|
|
109
|
-
return n;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Append a new pending entry and persist it.
|
|
114
|
-
* @param {string} text
|
|
115
|
-
* @returns {object} the entry
|
|
116
|
-
*/
|
|
117
|
-
enqueue(text) {
|
|
118
|
-
if (typeof text !== 'string') throw new Error('text must be a string');
|
|
119
|
-
const id = `iq-${randomUUID().slice(0, 8)}`;
|
|
120
|
-
const entry = {
|
|
121
|
-
id,
|
|
122
|
-
text,
|
|
123
|
-
createdAt: Date.now(),
|
|
124
|
-
status: 'pending',
|
|
125
|
-
routedTo: null,
|
|
126
|
-
routedAt: null,
|
|
127
|
-
error: null,
|
|
128
|
-
};
|
|
129
|
-
this.#entries.set(id, entry);
|
|
130
|
-
this.#writeEntry(entry);
|
|
131
|
-
return entry;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Oldest pending entry (no state change). null if empty. */
|
|
135
|
-
peek() {
|
|
136
|
-
let oldest = null;
|
|
137
|
-
for (const e of this.#entries.values()) {
|
|
138
|
-
if (e.status !== 'pending') continue;
|
|
139
|
-
if (!oldest || e.createdAt < oldest.createdAt) oldest = e;
|
|
140
|
-
}
|
|
141
|
-
return oldest;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Return the oldest pending entry without mutating state. Kept as a
|
|
146
|
-
* separate method from claim() because some consumers only want to
|
|
147
|
-
* observe the head of the queue (e.g. UI preview).
|
|
148
|
-
*/
|
|
149
|
-
dequeue() {
|
|
150
|
-
return this.peek();
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Transition the oldest pending entry → 'routing' and persist. This is
|
|
155
|
-
* the real consumer-facing take: after claim() the caller must eventually
|
|
156
|
-
* call markRouted() (success) or markFailed() (→ put back as pending).
|
|
157
|
-
*
|
|
158
|
-
* @returns {object|null} the claimed entry, or null if nothing pending
|
|
159
|
-
*/
|
|
160
|
-
claim() {
|
|
161
|
-
const e = this.peek();
|
|
162
|
-
if (!e) return null;
|
|
163
|
-
e.status = 'routing';
|
|
164
|
-
this.#writeEntry(e);
|
|
165
|
-
return e;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Mark an entry as successfully dispatched to a thread. Persists the
|
|
170
|
-
* updated state then removes the file — the authoritative record now
|
|
171
|
-
* lives in the conversation/messages log.
|
|
172
|
-
*
|
|
173
|
-
* @param {string} id
|
|
174
|
-
* @param {string} routedTo — thread id (e.g. 'main' or 'thr-xxxxxxxx')
|
|
175
|
-
* @returns {object|null}
|
|
176
|
-
*/
|
|
177
|
-
markRouted(id, routedTo) {
|
|
178
|
-
const e = this.#entries.get(id);
|
|
179
|
-
if (!e) return null;
|
|
180
|
-
if (!routedTo || typeof routedTo !== 'string') throw new Error('routedTo required');
|
|
181
|
-
e.status = 'dispatched';
|
|
182
|
-
e.routedTo = routedTo;
|
|
183
|
-
e.routedAt = Date.now();
|
|
184
|
-
// Persist the transition before removing (crash-safe ordering).
|
|
185
|
-
this.#writeEntry(e);
|
|
186
|
-
this.#removeFile(id);
|
|
187
|
-
this.#entries.delete(id);
|
|
188
|
-
return e;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Mark a routing attempt failed. The entry returns to 'pending' so the
|
|
193
|
-
* next claim() can retry it; the error string is retained for diagnostics.
|
|
194
|
-
*
|
|
195
|
-
* @param {string} id
|
|
196
|
-
* @param {string|Error} err
|
|
197
|
-
*/
|
|
198
|
-
markFailed(id, err) {
|
|
199
|
-
const e = this.#entries.get(id);
|
|
200
|
-
if (!e) return null;
|
|
201
|
-
e.status = 'pending';
|
|
202
|
-
e.error = typeof err === 'string' ? err : (err?.message || String(err));
|
|
203
|
-
this.#writeEntry(e);
|
|
204
|
-
return e;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Remove an entry entirely (memory + disk). */
|
|
208
|
-
remove(id) {
|
|
209
|
-
if (!this.#entries.has(id)) return false;
|
|
210
|
-
this.#entries.delete(id);
|
|
211
|
-
this.#removeFile(id);
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Snapshot of all entries, optionally filtered by status. Chronological.
|
|
217
|
-
* @param {'pending'|'routing'|'dispatched'} [status]
|
|
218
|
-
*/
|
|
219
|
-
list(status) {
|
|
220
|
-
let arr = [...this.#entries.values()];
|
|
221
|
-
if (status) arr = arr.filter(e => e.status === status);
|
|
222
|
-
arr.sort((a, b) => a.createdAt - b.createdAt);
|
|
223
|
-
return arr;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
get(id) { return this.#entries.get(id) || null; }
|
|
227
|
-
|
|
228
|
-
// ─── Persistence internals ────────────────────────────
|
|
229
|
-
|
|
230
|
-
#ensureDir() {
|
|
231
|
-
try {
|
|
232
|
-
if (!existsSync(this.#dir)) mkdirSync(this.#dir, { recursive: true, mode: 0o755 });
|
|
233
|
-
} catch (err) {
|
|
234
|
-
this.#warn(`Cannot create input-queue dir: ${err?.code || err?.message}`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
#loadAll() {
|
|
239
|
-
if (!existsSync(this.#dir)) return;
|
|
240
|
-
let files;
|
|
241
|
-
try {
|
|
242
|
-
files = readdirSync(this.#dir);
|
|
243
|
-
} catch (err) {
|
|
244
|
-
this.#warn(`Cannot read input-queue dir: ${err?.code || err?.message}`);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
for (const f of files) {
|
|
248
|
-
if (!f.endsWith('.json')) continue;
|
|
249
|
-
const path = join(this.#dir, f);
|
|
250
|
-
try {
|
|
251
|
-
const raw = readFileSync(path, 'utf8');
|
|
252
|
-
const parsed = JSON.parse(raw);
|
|
253
|
-
if (!parsed || typeof parsed !== 'object' || !parsed.id) continue;
|
|
254
|
-
if (!INPUT_QUEUE_STATUSES.includes(parsed.status)) continue;
|
|
255
|
-
// Crash recovery: an entry left in 'routing' at startup had a
|
|
256
|
-
// dispatcher claim it right before the crash. Put it back to
|
|
257
|
-
// 'pending' so the next claim() can retry it.
|
|
258
|
-
const status = parsed.status === 'routing' ? 'pending' : parsed.status;
|
|
259
|
-
this.#entries.set(parsed.id, {
|
|
260
|
-
id: parsed.id,
|
|
261
|
-
text: typeof parsed.text === 'string' ? parsed.text : '',
|
|
262
|
-
createdAt: Number(parsed.createdAt) || Date.now(),
|
|
263
|
-
status,
|
|
264
|
-
routedTo: parsed.routedTo || null,
|
|
265
|
-
routedAt: parsed.routedAt || null,
|
|
266
|
-
error: parsed.error || null,
|
|
267
|
-
});
|
|
268
|
-
} catch {
|
|
269
|
-
// Skip corrupt file.
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
#writeEntry(entry) {
|
|
275
|
-
if (!this.#persistent) return;
|
|
276
|
-
const path = join(this.#dir, `${entry.id}.json`);
|
|
277
|
-
try {
|
|
278
|
-
writeFileSync(path, JSON.stringify(entry, null, 2), { encoding: 'utf8', mode: 0o644 });
|
|
279
|
-
} catch (err) {
|
|
280
|
-
this.#warn(`Cannot write ${entry.id}: ${err?.code || err?.message}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
#removeFile(id) {
|
|
285
|
-
if (!this.#persistent) return;
|
|
286
|
-
const path = join(this.#dir, `${id}.json`);
|
|
287
|
-
if (!existsSync(path)) return;
|
|
288
|
-
try {
|
|
289
|
-
unlinkSync(path);
|
|
290
|
-
} catch (err) {
|
|
291
|
-
this.#warn(`Cannot remove ${id}: ${err?.code || err?.message}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
#warn(msg) {
|
|
296
|
-
if (this.#warned) return;
|
|
297
|
-
this.#warned = true;
|
|
298
|
-
// eslint-disable-next-line no-console
|
|
299
|
-
console.warn(`[Yeaft InputQueue] ${msg}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ─── Singleton helpers ────────────────────────────────────
|
|
304
|
-
|
|
305
|
-
/** @type {InputQueueStore|null} */
|
|
306
|
-
let inputQueueStore = null;
|
|
307
|
-
|
|
308
|
-
export function initInputQueueStore(opts = {}) {
|
|
309
|
-
if (!inputQueueStore || opts.force) {
|
|
310
|
-
inputQueueStore = new InputQueueStore(opts.yeaftDir || null);
|
|
311
|
-
}
|
|
312
|
-
return inputQueueStore;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function getInputQueueStore() {
|
|
316
|
-
if (!inputQueueStore) inputQueueStore = new InputQueueStore(null);
|
|
317
|
-
return inputQueueStore;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
export function _resetInputQueueStoreForTests() {
|
|
321
|
-
inputQueueStore = null;
|
|
322
|
-
}
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pipeline/dispatcher.js — H2.f.1 single-thread dispatcher.
|
|
3
|
-
*
|
|
4
|
-
* H2 retires the multi-thread routing model. The new memory architecture
|
|
5
|
-
* (pre-flow FTS + adjustMemory) does not need an LLM router to pick a
|
|
6
|
-
* thread per turn; one engine, one conversation. This module preserves
|
|
7
|
-
* the historic `submit()` / `drain()` API surface so web-bridge does not
|
|
8
|
-
* change in this PR — it just always routes to MAIN_THREAD_ID and never
|
|
9
|
-
* calls a classifier.
|
|
10
|
-
*
|
|
11
|
-
* The pipeline now collapses to:
|
|
12
|
-
*
|
|
13
|
-
* unify_chat input
|
|
14
|
-
* │
|
|
15
|
-
* ▼
|
|
16
|
-
* ┌──────────────┐
|
|
17
|
-
* │ InputQueue │ persistent FIFO of pending user inputs
|
|
18
|
-
* └──────┬───────┘
|
|
19
|
-
* │ claim()
|
|
20
|
-
* ▼
|
|
21
|
-
* ┌──────────────┐
|
|
22
|
-
* │ EngineReg. │ ensure(MAIN_THREAD_ID) → EngineInstance
|
|
23
|
-
* └──────┬───────┘
|
|
24
|
-
* │ inst.query({ prompt })
|
|
25
|
-
* ▼
|
|
26
|
-
* web-bridge forwards engine events as `unify_output`.
|
|
27
|
-
*
|
|
28
|
-
* `routing_decision` is still yielded so the wire protocol is unchanged
|
|
29
|
-
* (action='continue', source='single-thread'). Frontend treats it as a
|
|
30
|
-
* no-op marker.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import { MAIN_THREAD_ID } from '../threads/store.js';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Per-entry transient metadata (messageId, queryOpts) lives in a WeakMap
|
|
37
|
-
* keyed by the queue entry object so it is NEVER persisted to disk by
|
|
38
|
-
* InputQueueStore. Entries are GC'd together with the entry once the
|
|
39
|
-
* queue drops the strong reference.
|
|
40
|
-
* @type {WeakMap<object, {messageId?: string, queryOpts?: object}>}
|
|
41
|
-
*/
|
|
42
|
-
const transientMeta = new WeakMap();
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* @typedef {Object} SubmitOptions
|
|
46
|
-
* @property {string} [messageId]
|
|
47
|
-
* @property {object} [queryOpts]
|
|
48
|
-
* @property {object} [override] — accepted for back-compat, ignored.
|
|
49
|
-
*
|
|
50
|
-
* @typedef {Object} DispatcherDeps
|
|
51
|
-
* @property {import('../input-queue/store.js').InputQueueStore} inputQueue
|
|
52
|
-
* @property {import('../threads/engine-registry.js').ThreadEngineRegistry} engineRegistry
|
|
53
|
-
* @property {object} [trace]
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
|
-
export class Dispatcher {
|
|
57
|
-
/** @type {DispatcherDeps} */
|
|
58
|
-
#deps;
|
|
59
|
-
|
|
60
|
-
constructor(deps) {
|
|
61
|
-
const { inputQueue, engineRegistry } = deps || {};
|
|
62
|
-
if (!inputQueue || typeof inputQueue.enqueue !== 'function') {
|
|
63
|
-
throw new Error('Dispatcher: inputQueue is required');
|
|
64
|
-
}
|
|
65
|
-
if (!engineRegistry || typeof engineRegistry.ensure !== 'function') {
|
|
66
|
-
throw new Error('Dispatcher: engineRegistry is required');
|
|
67
|
-
}
|
|
68
|
-
this.#deps = deps;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Snapshot of queue counters for the UI, post-mutation. */
|
|
72
|
-
#queueSnapshot() {
|
|
73
|
-
const { inputQueue } = this.#deps;
|
|
74
|
-
const entries = inputQueue.list();
|
|
75
|
-
const counts = { pending: 0, routing: 0, dispatched: 0 };
|
|
76
|
-
for (const e of entries) {
|
|
77
|
-
if (counts[e.status] !== undefined) counts[e.status] += 1;
|
|
78
|
-
}
|
|
79
|
-
return {
|
|
80
|
-
type: 'input_queue_updated',
|
|
81
|
-
total: entries.length,
|
|
82
|
-
pending: counts.pending,
|
|
83
|
-
routing: counts.routing,
|
|
84
|
-
dispatched: counts.dispatched,
|
|
85
|
-
head: entries[0] ? { id: entries[0].id, status: entries[0].status, text: entries[0].text.slice(0, 80) } : null,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Enqueue a user input. Does NOT dispatch — caller invokes `drain()` or
|
|
91
|
-
* `dispatch(entry)` next.
|
|
92
|
-
*
|
|
93
|
-
* @param {string} text
|
|
94
|
-
* @param {SubmitOptions} [opts]
|
|
95
|
-
* @returns {{ entry: object, snapshot: object }}
|
|
96
|
-
*/
|
|
97
|
-
submit(text, opts = {}) {
|
|
98
|
-
if (typeof text !== 'string' || !text.trim()) {
|
|
99
|
-
throw new Error('Dispatcher.submit: text required');
|
|
100
|
-
}
|
|
101
|
-
const { inputQueue } = this.#deps;
|
|
102
|
-
const entry = inputQueue.enqueue(text);
|
|
103
|
-
transientMeta.set(entry, {
|
|
104
|
-
messageId: opts.messageId || undefined,
|
|
105
|
-
queryOpts: opts.queryOpts || undefined,
|
|
106
|
-
});
|
|
107
|
-
const snapshot = this.#queueSnapshot();
|
|
108
|
-
return { entry, snapshot };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Drain the queue: claim → dispatch in a loop until empty.
|
|
113
|
-
*
|
|
114
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
115
|
-
* @yields {object} bridge events
|
|
116
|
-
*/
|
|
117
|
-
async *drain(opts = {}) {
|
|
118
|
-
const { inputQueue } = this.#deps;
|
|
119
|
-
while (true) {
|
|
120
|
-
const head = inputQueue.peek();
|
|
121
|
-
if (!head) return;
|
|
122
|
-
if (head.status !== 'pending') return; // another dispatcher holds it
|
|
123
|
-
for await (const ev of this.dispatch(head, opts)) yield ev;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Dispatch one queue entry to the single thread engine. Always routes
|
|
129
|
-
* to MAIN_THREAD_ID — no LLM classification, no fork/switch/interrupt.
|
|
130
|
-
*
|
|
131
|
-
* @param {object} entry — from inputQueue.peek() or inputQueue.enqueue()
|
|
132
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
133
|
-
* @yields {object} bridge events
|
|
134
|
-
*/
|
|
135
|
-
async *dispatch(entry, opts = {}) {
|
|
136
|
-
const { inputQueue, engineRegistry } = this.#deps;
|
|
137
|
-
const { signal } = opts;
|
|
138
|
-
|
|
139
|
-
// ── Step 1: claim (pending → routing) ──
|
|
140
|
-
let claimed = entry;
|
|
141
|
-
if (entry.status === 'pending') {
|
|
142
|
-
claimed = inputQueue.claim();
|
|
143
|
-
if (!claimed || claimed.id !== entry.id) {
|
|
144
|
-
// Another worker took it. Treat as a no-op success.
|
|
145
|
-
yield this.#queueSnapshot();
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
yield this.#queueSnapshot();
|
|
150
|
-
|
|
151
|
-
// ── Step 2: synthesize a "continue to main" routing decision so the
|
|
152
|
-
// wire protocol stays compatible with old clients. ──
|
|
153
|
-
const targetThreadId = MAIN_THREAD_ID;
|
|
154
|
-
yield {
|
|
155
|
-
type: 'routing_decision',
|
|
156
|
-
entryId: claimed.id,
|
|
157
|
-
action: 'continue',
|
|
158
|
-
targetThreadId,
|
|
159
|
-
source: 'single-thread',
|
|
160
|
-
reason: 'single-thread-dispatcher',
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
// ── Step 3: dispatch to the single EngineInstance ──
|
|
164
|
-
let instance;
|
|
165
|
-
try {
|
|
166
|
-
instance = engineRegistry.ensure(targetThreadId);
|
|
167
|
-
} catch (err) {
|
|
168
|
-
inputQueue.markFailed(claimed.id, err);
|
|
169
|
-
yield this.#queueSnapshot();
|
|
170
|
-
yield { type: 'error', error: err, retryable: false };
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
const queryOpts = (transientMeta.get(claimed) || {}).queryOpts || {};
|
|
176
|
-
for await (const event of instance.query({ prompt: claimed.text, signal, ...queryOpts })) {
|
|
177
|
-
yield { type: 'engine_event', threadId: targetThreadId, event };
|
|
178
|
-
}
|
|
179
|
-
inputQueue.markRouted(claimed.id, targetThreadId);
|
|
180
|
-
yield this.#queueSnapshot();
|
|
181
|
-
} catch (err) {
|
|
182
|
-
inputQueue.markFailed(claimed.id, err);
|
|
183
|
-
yield this.#queueSnapshot();
|
|
184
|
-
yield { type: 'error', error: err, retryable: true };
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Build a single-thread Dispatcher from session-level deps.
|
|
191
|
-
* @param {DispatcherDeps} deps
|
|
192
|
-
* @returns {Dispatcher}
|
|
193
|
-
*/
|
|
194
|
-
export function createDispatcher(deps) {
|
|
195
|
-
return new Dispatcher(deps);
|
|
196
|
-
}
|