@yeaft/webchat-agent 0.1.741 → 0.1.746
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/dream-v2/schedule.js +15 -3
- package/unify/dream-v2/session-wiring.js +116 -1
- package/unify/engine.js +91 -22
- package/unify/session.js +21 -1
- package/unify/web-bridge.js +20 -1
package/package.json
CHANGED
|
@@ -26,13 +26,22 @@ export const DEFAULT_INTERVAL_MS = DREAM_INTERVAL_HOURS * 60 * 60 * 1000;
|
|
|
26
26
|
* everything `runDream` needs (memory root, llm, message-store hooks,
|
|
27
27
|
* onProgress sink); the scheduler only knows how to call it.
|
|
28
28
|
*
|
|
29
|
+
* `keepAlive` controls whether the interval timer holds the event loop
|
|
30
|
+
* open. Default `false` preserves the original behaviour (CLI / one-shot
|
|
31
|
+
* invocations should not be kept alive solely by the dream ticker). The
|
|
32
|
+
* web server passes `true` because (a) the HTTP listener already pins
|
|
33
|
+
* the loop, so unref'ing buys nothing, and (b) on Node 22 some platforms
|
|
34
|
+
* never schedule unref'd timers when nothing else wakes them — meaning
|
|
35
|
+
* the server saw zero ticks in production. See PR fix/dream-cadence.
|
|
36
|
+
*
|
|
29
37
|
* @param {{
|
|
30
38
|
* run: (opts: { manual: boolean, scopeFilter?: string[] }) => Promise<object>,
|
|
31
39
|
* intervalMs?: number,
|
|
40
|
+
* keepAlive?: boolean,
|
|
32
41
|
* logger?: { info?: (...a:any) => void, warn?: (...a:any) => void, error?: (...a:any) => void },
|
|
33
42
|
* }} args
|
|
34
43
|
*/
|
|
35
|
-
export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, logger }) {
|
|
44
|
+
export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, keepAlive = false, logger }) {
|
|
36
45
|
if (typeof run !== 'function') throw new Error('createDreamScheduler: run callable required');
|
|
37
46
|
const log = logger || {};
|
|
38
47
|
let timer = null;
|
|
@@ -60,8 +69,11 @@ export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, lo
|
|
|
60
69
|
start() {
|
|
61
70
|
if (timer) return;
|
|
62
71
|
timer = setInterval(() => { fire({ manual: false }).catch(() => {}); }, intervalMs);
|
|
63
|
-
//
|
|
64
|
-
|
|
72
|
+
// Server / long-lived hosts pass keepAlive=true so the interval
|
|
73
|
+
// participates in the loop normally. Short-lived hosts (CLI, tests)
|
|
74
|
+
// leave keepAlive=false to avoid pinning the loop open with the
|
|
75
|
+
// ticker alone.
|
|
76
|
+
if (!keepAlive && typeof timer.unref === 'function') timer.unref();
|
|
65
77
|
},
|
|
66
78
|
stop() {
|
|
67
79
|
if (timer) { clearInterval(timer); timer = null; }
|
|
@@ -13,7 +13,8 @@ import { join } from 'path';
|
|
|
13
13
|
import { runDream } from './runner.js';
|
|
14
14
|
import { createDreamScheduler } from './schedule.js';
|
|
15
15
|
import { listGroups, openGroup } from '../groups/group-store.js';
|
|
16
|
-
import {
|
|
16
|
+
import { readGroupState } from './state.js';
|
|
17
|
+
import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS } from './limits.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Build the per-call options for runDream. Pure: takes a session and returns
|
|
@@ -154,6 +155,12 @@ export function createV2DreamScheduler(session) {
|
|
|
154
155
|
|
|
155
156
|
const v2 = createDreamScheduler({
|
|
156
157
|
run,
|
|
158
|
+
// Server mode — the HTTP listener already pins the event loop, so the
|
|
159
|
+
// ticker should be allowed to participate normally. Without this, on
|
|
160
|
+
// some Node 22 builds the unref'd interval stops being scheduled and
|
|
161
|
+
// dream effectively never fires (observed in production: 12 days
|
|
162
|
+
// between ticks, see fix/dream-cadence-and-ui-trigger).
|
|
163
|
+
keepAlive: !!session?.config?.serverMode,
|
|
157
164
|
logger: session.config?.debug ? console : undefined,
|
|
158
165
|
});
|
|
159
166
|
// Auto-start the timer.
|
|
@@ -192,6 +199,12 @@ export function createV2DreamScheduler(session) {
|
|
|
192
199
|
noteUserMessage: nudgeOnUserMessage,
|
|
193
200
|
triggerDreamNow() { return v2.triggerNow(); },
|
|
194
201
|
triggerDreamForScopes(scopeFilter) { return v2.triggerNow(scopeFilter); },
|
|
202
|
+
/**
|
|
203
|
+
* Non-manual catch-up fire. MIN_NEW_PER_GROUP still applies, so groups
|
|
204
|
+
* below threshold are skipped — same shape as the interval timer
|
|
205
|
+
* tick. Used by `bootCatchUpStaleDream`.
|
|
206
|
+
*/
|
|
207
|
+
catchUpNudge() { return v2.nudge(); },
|
|
195
208
|
shutdown() { v2.stop(); },
|
|
196
209
|
get isRunning() { return v2.isRunning(); },
|
|
197
210
|
// Preserve direct access for tests.
|
|
@@ -246,3 +259,105 @@ export async function bootInitEmptyGroups(args) {
|
|
|
246
259
|
out.triggered = empty;
|
|
247
260
|
return out;
|
|
248
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* fix/dream-cadence-and-ui-trigger: stale-cadence catch-up.
|
|
265
|
+
*
|
|
266
|
+
* Walks every group on disk and reads the per-group `.dream-state`
|
|
267
|
+
* `lastDreamAt`. The newest of those is the effective "session was last
|
|
268
|
+
* cleanly Dream-processed at" timestamp. If that's older than
|
|
269
|
+
* `DREAM_INTERVAL_HOURS` (or there's no record at all and at least one
|
|
270
|
+
* group has user traffic), schedule a non-manual catch-up tick.
|
|
271
|
+
*
|
|
272
|
+
* Why this matters: the only thing previously triggering dream on a
|
|
273
|
+
* long-lived server was `setInterval(...).unref()` plus the user-traffic
|
|
274
|
+
* nudge. In practice (production: 12 days idle), neither fired reliably.
|
|
275
|
+
* This boot-time catch-up gives us a deterministic "if we were stale at
|
|
276
|
+
* boot, run once" guarantee that's independent of timer behaviour.
|
|
277
|
+
*
|
|
278
|
+
* MIN_NEW_PER_GROUP still gates per-group writes inside `runDream` —
|
|
279
|
+
* non-manual catch-up will not produce empty segment churn for groups
|
|
280
|
+
* that haven't crossed threshold.
|
|
281
|
+
*
|
|
282
|
+
* Pure side-effect, fire-and-forget.
|
|
283
|
+
*
|
|
284
|
+
* @param {{
|
|
285
|
+
* yeaftDir: string,
|
|
286
|
+
* dreamScheduler: { catchUpNudge?: () => Promise<any> },
|
|
287
|
+
* intervalHours?: number,
|
|
288
|
+
* now?: number,
|
|
289
|
+
* config?: { debug?: boolean },
|
|
290
|
+
* }} args
|
|
291
|
+
* @returns {Promise<{ stale: boolean, lastDreamAt: string|null, ageMs: number|null, fired: boolean }>}
|
|
292
|
+
*/
|
|
293
|
+
export async function bootCatchUpStaleDream(args) {
|
|
294
|
+
const out = { stale: false, lastDreamAt: null, ageMs: null, fired: false };
|
|
295
|
+
if (!args || !args.dreamScheduler) return out;
|
|
296
|
+
|
|
297
|
+
const memoryRoot = join(args.yeaftDir, 'memory');
|
|
298
|
+
const groupsRoot = join(args.yeaftDir, 'groups');
|
|
299
|
+
const intervalMs = (args.intervalHours ?? DREAM_INTERVAL_HOURS) * 60 * 60 * 1000;
|
|
300
|
+
const now = args.now ?? Date.now();
|
|
301
|
+
|
|
302
|
+
let groupIds;
|
|
303
|
+
try { groupIds = listGroups(groupsRoot).map(g => g.id); }
|
|
304
|
+
catch { return out; }
|
|
305
|
+
|
|
306
|
+
// Find the newest lastDreamAt across all groups.
|
|
307
|
+
let newestAt = null;
|
|
308
|
+
let anyTraffic = false;
|
|
309
|
+
for (const gid of groupIds) {
|
|
310
|
+
let st;
|
|
311
|
+
try { st = await readGroupState(memoryRoot, gid); }
|
|
312
|
+
catch { continue; }
|
|
313
|
+
if (st.lastDreamAt) {
|
|
314
|
+
const t = Date.parse(st.lastDreamAt);
|
|
315
|
+
if (Number.isFinite(t) && (newestAt === null || t > newestAt)) newestAt = t;
|
|
316
|
+
}
|
|
317
|
+
if (!anyTraffic) {
|
|
318
|
+
try {
|
|
319
|
+
const h = openGroup(groupsRoot, gid);
|
|
320
|
+
const first = h.streamMessages().next();
|
|
321
|
+
if (!first.done) anyTraffic = true;
|
|
322
|
+
} catch { /* keep going */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// No groups, no traffic, nothing to catch up.
|
|
327
|
+
if (!anyTraffic) return out;
|
|
328
|
+
|
|
329
|
+
out.lastDreamAt = newestAt === null ? null : new Date(newestAt).toISOString();
|
|
330
|
+
out.ageMs = newestAt === null ? null : (now - newestAt);
|
|
331
|
+
out.stale = (newestAt === null) || (now - newestAt > intervalMs);
|
|
332
|
+
if (!out.stale) return out;
|
|
333
|
+
|
|
334
|
+
if (args.config?.debug) {
|
|
335
|
+
// eslint-disable-next-line no-console
|
|
336
|
+
console.log(`[dream-v2] boot catch-up: stale (lastDreamAt=${out.lastDreamAt}, ageMs=${out.ageMs}); firing one non-manual tick.`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Non-manual: MIN_NEW_PER_GROUP still applies. Use the public
|
|
340
|
+
// `catchUpNudge()` adapter — same dedupe path as the interval timer
|
|
341
|
+
// (both go through `fire({ manual: false })`), so a concurrent timer
|
|
342
|
+
// tick will be coalesced by the existing in-flight guard.
|
|
343
|
+
//
|
|
344
|
+
// Fail closed if the scheduler shim doesn't expose `catchUpNudge`:
|
|
345
|
+
// the previous `triggerDreamForScopes()` fallback routed through
|
|
346
|
+
// `v2.triggerNow()` which sets `manual: true` and bypasses
|
|
347
|
+
// MIN_NEW_PER_GROUP — directly contradicting the contract documented
|
|
348
|
+
// above. Better to skip than to silently fire a different semantic.
|
|
349
|
+
// PR #743 review feedback (Martin).
|
|
350
|
+
try {
|
|
351
|
+
if (typeof args.dreamScheduler.catchUpNudge === 'function') {
|
|
352
|
+
Promise.resolve(args.dreamScheduler.catchUpNudge()).catch(() => {});
|
|
353
|
+
out.fired = true;
|
|
354
|
+
} else {
|
|
355
|
+
// Shim does not expose the non-manual catch-up path. Refuse to
|
|
356
|
+
// substitute a manual fire — the caller should upgrade the shim.
|
|
357
|
+
out.fired = false;
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
out.fired = false;
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
package/unify/engine.js
CHANGED
|
@@ -1278,10 +1278,30 @@ export class Engine {
|
|
|
1278
1278
|
|
|
1279
1279
|
// PR-L: track this query()'s tool-arc for reflection.
|
|
1280
1280
|
// `turnStartIdx` is where the current user message lives; the arc
|
|
1281
|
-
// we may collapse spans (
|
|
1281
|
+
// we may collapse spans (arcStartIdx .. last assistant/tool).
|
|
1282
|
+
//
|
|
1283
|
+
// Periodic-T1 fix: T1 must fire EVERY TOOL_BATCH_SIZE (13) tool
|
|
1284
|
+
// calls, not just the first 13. So instead of a one-shot boolean,
|
|
1285
|
+
// track:
|
|
1286
|
+
// • `lastT1AtToolCount` — toolCount snapshot at the last T1
|
|
1287
|
+
// ATTEMPT (success OR error). Trigger when
|
|
1288
|
+
// `queryToolCount - lastT1AtToolCount >= TOOL_BATCH_SIZE`.
|
|
1289
|
+
// • `arcStartIdx` — first index of the current (uncollapsed)
|
|
1290
|
+
// tool arc. Initialised to turnStartIdx + 1; reset after each
|
|
1291
|
+
// successful T1 collapse to `conversationMessages.length`
|
|
1292
|
+
// (i.e. the slot the next assistant message will land in).
|
|
1293
|
+
// • `t1CollapsesDone` — count of T1 firings that ACTUALLY
|
|
1294
|
+
// rewrote history. Distinct from `lastT1AtToolCount` because
|
|
1295
|
+
// the catch block bumps the latter to back off after a
|
|
1296
|
+
// transient reflector error WITHOUT having collapsed
|
|
1297
|
+
// anything. The T2 schedule check below is gated on this
|
|
1298
|
+
// counter (==0 means "no T1 ever rewrote the arc, T2 may
|
|
1299
|
+
// fall back at end_turn").
|
|
1282
1300
|
const turnStartIdx = conversationMessages.length - 1;
|
|
1283
1301
|
let queryToolCount = 0;
|
|
1284
|
-
let
|
|
1302
|
+
let lastT1AtToolCount = 0;
|
|
1303
|
+
let arcStartIdx = turnStartIdx + 1;
|
|
1304
|
+
let t1CollapsesDone = 0;
|
|
1285
1305
|
const queryNumber = (this.#__queryCounter = (this.#__queryCounter || 0) + 1);
|
|
1286
1306
|
|
|
1287
1307
|
// feat-6af5f9f1 PR B: a Turn = one user prompt + all AI responses.
|
|
@@ -1784,10 +1804,17 @@ export class Engine {
|
|
|
1784
1804
|
|
|
1785
1805
|
// PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
|
|
1786
1806
|
// total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
|
|
1787
|
-
// (5) AND T1
|
|
1807
|
+
// (5) AND no T1 has actually rewritten the arc yet. Kicks off the
|
|
1788
1808
|
// primary-model call without await; the next query()'s
|
|
1789
1809
|
// `#applyPendingT2Reflections` carries the result forward.
|
|
1790
|
-
|
|
1810
|
+
//
|
|
1811
|
+
// Periodic-T1 fix: gate on `t1CollapsesDone === 0`, NOT
|
|
1812
|
+
// `lastT1AtToolCount === 0`. The catch block of T1 bumps
|
|
1813
|
+
// `lastT1AtToolCount` after a reflector error to avoid
|
|
1814
|
+
// tight-loop retries — but no collapse happened, so T2 should
|
|
1815
|
+
// still be allowed to fall back at end_turn. Fowler-review
|
|
1816
|
+
// critical finding.
|
|
1817
|
+
if (queryToolCount > TURN_SUMMARY_THRESHOLD && t1CollapsesDone === 0) {
|
|
1791
1818
|
const arcStart = turnStartIdx + 1;
|
|
1792
1819
|
const arcEnd = conversationMessages.length - 1;
|
|
1793
1820
|
if (arcEnd > arcStart) {
|
|
@@ -2029,23 +2056,41 @@ export class Engine {
|
|
|
2029
2056
|
break;
|
|
2030
2057
|
}
|
|
2031
2058
|
|
|
2032
|
-
// PR-L: T1 in-turn (synchronous) reflection. Fires
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
// the
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2059
|
+
// PR-L: T1 in-turn (synchronous) reflection. Fires once per
|
|
2060
|
+
// adapter loop iteration where ≥ TOOL_BATCH_SIZE (13) tool
|
|
2061
|
+
// calls have accumulated since the last T1 firing — not just
|
|
2062
|
+
// the first 13 of the query(). Generates a markdown reflection
|
|
2063
|
+
// over the assistant+tool arc since the last T1 firing (or
|
|
2064
|
+
// since the user prompt for the first batch) and rewrites that
|
|
2065
|
+
// range to a SINGLE synthetic user message before the next
|
|
2066
|
+
// adapter.stream() runs.
|
|
2067
|
+
//
|
|
2068
|
+
// Loop semantics:
|
|
2069
|
+
// - First batch: arcStartIdx = turnStartIdx + 1, fires when
|
|
2070
|
+
// queryToolCount reaches 13.
|
|
2071
|
+
// - Each subsequent batch: arcStartIdx is updated to the slot
|
|
2072
|
+
// right after the just-inserted reflection message; fires
|
|
2073
|
+
// again whenever 13 more tools have run since
|
|
2074
|
+
// lastT1AtToolCount.
|
|
2075
|
+
// - The dedup Set key includes `lastT1AtToolCount` so each
|
|
2076
|
+
// batch within the same query gets a distinct entry — without
|
|
2077
|
+
// this the second batch would be silently skipped.
|
|
2078
|
+
const t1BatchDue = queryToolCount - lastT1AtToolCount >= TOOL_BATCH_SIZE;
|
|
2079
|
+
if (t1BatchDue && !abortedDuringTools && !signal?.aborted) {
|
|
2080
|
+
const t1DedupKey = `${queryNumber}:t1:${queryToolCount}`;
|
|
2081
|
+
if (this.#reflectedTurns.has(t1DedupKey)) {
|
|
2082
|
+
// Defensive: should never hit since t1BatchDue gates re-entry
|
|
2083
|
+
// and queryNumber namespaces queries. Kept as belt-and-
|
|
2084
|
+
// suspenders against any future external mutation of the
|
|
2085
|
+
// cursor (or a re-entrant query() that this code doesn't
|
|
2086
|
+
// anticipate).
|
|
2087
|
+
} else {
|
|
2088
|
+
this.#reflectedTurns.add(t1DedupKey);
|
|
2089
|
+
const batchStart = arcStartIdx;
|
|
2090
|
+
const batchEnd = conversationMessages.length - 1;
|
|
2044
2091
|
try {
|
|
2045
|
-
const arcStart = turnStartIdx + 1;
|
|
2046
|
-
const arcEnd = conversationMessages.length - 1;
|
|
2047
2092
|
const { pairs, assistantText } = extractToolPairsFromRange(
|
|
2048
|
-
conversationMessages,
|
|
2093
|
+
conversationMessages, batchStart, batchEnd,
|
|
2049
2094
|
);
|
|
2050
2095
|
yield {
|
|
2051
2096
|
type: 'reflection',
|
|
@@ -2053,7 +2098,7 @@ export class Engine {
|
|
|
2053
2098
|
loopNumber: turnNumber,
|
|
2054
2099
|
trigger: 't1',
|
|
2055
2100
|
status: 'pending',
|
|
2056
|
-
loopRange: [
|
|
2101
|
+
loopRange: [batchStart, batchEnd],
|
|
2057
2102
|
toolCount: pairs.length,
|
|
2058
2103
|
};
|
|
2059
2104
|
const { content, durationMs } = await runT1Reflection({
|
|
@@ -2065,10 +2110,21 @@ export class Engine {
|
|
|
2065
2110
|
signal,
|
|
2066
2111
|
});
|
|
2067
2112
|
const next = collapseRangeToReflection(
|
|
2068
|
-
conversationMessages,
|
|
2113
|
+
conversationMessages, batchStart, batchEnd, content,
|
|
2069
2114
|
);
|
|
2070
2115
|
conversationMessages.length = 0;
|
|
2071
2116
|
for (const m of next) conversationMessages.push(m);
|
|
2117
|
+
// After collapse: the just-inserted reflection lives at
|
|
2118
|
+
// index `batchStart`. The next tool arc therefore starts
|
|
2119
|
+
// immediately after it, i.e. at conversationMessages.length
|
|
2120
|
+
// (the next assistant message will land here).
|
|
2121
|
+
arcStartIdx = conversationMessages.length;
|
|
2122
|
+
lastT1AtToolCount = queryToolCount;
|
|
2123
|
+
// Bump the success counter — used by the T2 schedule check
|
|
2124
|
+
// to decide whether T2 still has work to do at end_turn.
|
|
2125
|
+
// Distinct from lastT1AtToolCount which the catch block
|
|
2126
|
+
// also bumps (but without rewriting history).
|
|
2127
|
+
t1CollapsesDone += 1;
|
|
2072
2128
|
yield {
|
|
2073
2129
|
type: 'reflection',
|
|
2074
2130
|
turnId: queryTurnId,
|
|
@@ -2078,7 +2134,7 @@ export class Engine {
|
|
|
2078
2134
|
// so the frontend key stays stable across pending → ready and
|
|
2079
2135
|
// the spinner card is replaced in place (no orphan).
|
|
2080
2136
|
status: 'ready',
|
|
2081
|
-
loopRange: [
|
|
2137
|
+
loopRange: [batchStart, batchEnd],
|
|
2082
2138
|
toolCount: pairs.length,
|
|
2083
2139
|
content,
|
|
2084
2140
|
durationMs,
|
|
@@ -2094,6 +2150,19 @@ export class Engine {
|
|
|
2094
2150
|
status: 'error',
|
|
2095
2151
|
error: err && err.message || String(err),
|
|
2096
2152
|
};
|
|
2153
|
+
// Advance lastT1AtToolCount past this batch so we don't
|
|
2154
|
+
// tight-loop on a hiccuping reflector. The next attempt is
|
|
2155
|
+
// 13 tools from now, not immediately. arcStartIdx is left
|
|
2156
|
+
// alone because history wasn't rewritten — the tail still
|
|
2157
|
+
// begins where it did. The trade-off: the next batch's
|
|
2158
|
+
// reflection will cover the tools that just failed too,
|
|
2159
|
+
// which is fine (they're still in conversationMessages).
|
|
2160
|
+
//
|
|
2161
|
+
// We do NOT bump t1CollapsesDone — see the variable's
|
|
2162
|
+
// declaration comment. This keeps the T2 fallback path live
|
|
2163
|
+
// when every T1 attempt has errored.
|
|
2164
|
+
lastT1AtToolCount = queryToolCount;
|
|
2165
|
+
}
|
|
2097
2166
|
}
|
|
2098
2167
|
}
|
|
2099
2168
|
|
package/unify/session.js
CHANGED
|
@@ -43,7 +43,7 @@ import { Engine } from './engine.js';
|
|
|
43
43
|
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
44
44
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
45
45
|
import { runSummaryBackfill } from './memory/seed-backfill.js';
|
|
46
|
-
import { createV2DreamScheduler, bootInitEmptyGroups } from './dream-v2/session-wiring.js';
|
|
46
|
+
import { createV2DreamScheduler, bootInitEmptyGroups, bootCatchUpStaleDream } from './dream-v2/session-wiring.js';
|
|
47
47
|
import { openSegmentIndex } from './memory/index-db.js';
|
|
48
48
|
import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
|
|
49
49
|
import { openAmsRegistry } from './memory/ams-registry.js';
|
|
@@ -97,6 +97,7 @@ export async function loadSession(options = {}) {
|
|
|
97
97
|
skipSkills = false,
|
|
98
98
|
extraTools = [],
|
|
99
99
|
configOverrides = {},
|
|
100
|
+
serverMode = false,
|
|
100
101
|
} = options;
|
|
101
102
|
|
|
102
103
|
// ─── 1. Determine yeaftDir + ensure directory structure ──
|
|
@@ -119,6 +120,10 @@ export async function loadSession(options = {}) {
|
|
|
119
120
|
|
|
120
121
|
// ─── 2. Load config ───────────────────────────────────
|
|
121
122
|
const config = loadConfig(overrides);
|
|
123
|
+
// fix/dream-cadence-and-ui-trigger: tag config so the dream scheduler
|
|
124
|
+
// can decide whether to keep its interval timer alive (server) or
|
|
125
|
+
// unref it (CLI / tests). Non-persisted — set per-session by caller.
|
|
126
|
+
if (serverMode) config.serverMode = true;
|
|
122
127
|
|
|
123
128
|
// ─── 2.1 Migration state check (task-334i) ────────────
|
|
124
129
|
// If the group-chat feature flag is on but migration has not
|
|
@@ -328,6 +333,21 @@ export async function loadSession(options = {}) {
|
|
|
328
333
|
}).catch(() => { /* best-effort boot init */ });
|
|
329
334
|
}
|
|
330
335
|
|
|
336
|
+
// fix/dream-cadence-and-ui-trigger: stale-cadence catch-up. If the
|
|
337
|
+
// newest per-group lastDreamAt across all groups is older than
|
|
338
|
+
// DREAM_INTERVAL_HOURS (or absent and there's user traffic), fire a
|
|
339
|
+
// single non-manual tick now. Independent of the interval timer —
|
|
340
|
+
// necessary because production observed 12 days between scheduled
|
|
341
|
+
// ticks (the unref'd interval did not fire reliably on long-lived
|
|
342
|
+
// server processes).
|
|
343
|
+
if (!config._readOnly) {
|
|
344
|
+
bootCatchUpStaleDream({
|
|
345
|
+
yeaftDir,
|
|
346
|
+
dreamScheduler,
|
|
347
|
+
config,
|
|
348
|
+
}).catch(() => { /* best-effort catch-up */ });
|
|
349
|
+
}
|
|
350
|
+
|
|
331
351
|
// H2.f.5: thread engine registry, input queue, and dispatcher retired.
|
|
332
352
|
// The session exposes a single `engine`; web-bridge calls engine.query()
|
|
333
353
|
// directly. Memory recall happens via memory/preflow.js (pre-turn) and
|
package/unify/web-bridge.js
CHANGED
|
@@ -1665,6 +1665,7 @@ async function ensureSessionLoaded() {
|
|
|
1665
1665
|
...(yeaftDir && { dir: yeaftDir }),
|
|
1666
1666
|
skipMCP: false,
|
|
1667
1667
|
skipSkills: false,
|
|
1668
|
+
serverMode: true,
|
|
1668
1669
|
});
|
|
1669
1670
|
|
|
1670
1671
|
installUnifyRuntimeBridge(session);
|
|
@@ -2367,11 +2368,27 @@ export async function handleUnifyDreamTrigger(msg = {}) {
|
|
|
2367
2368
|
|
|
2368
2369
|
const result = await session.dreamScheduler.triggerDreamNow();
|
|
2369
2370
|
|
|
2371
|
+
// fix/dream-cadence-and-ui-trigger: derive a single "entries
|
|
2372
|
+
// created" count for the UI bubble. The runner returns a richer
|
|
2373
|
+
// shape (groups[], targets[]); the front-end button only needs a
|
|
2374
|
+
// scalar plus the start timestamp.
|
|
2375
|
+
const targets = Array.isArray(result?.targets) ? result.targets : [];
|
|
2376
|
+
const entriesCreated = targets.filter(t => t && t.status === 'done').length;
|
|
2377
|
+
const lastDreamAt = result?.startedAt || new Date().toISOString();
|
|
2378
|
+
|
|
2379
|
+
// Spread `result` FIRST so derived fields (success, entriesCreated,
|
|
2380
|
+
// lastDreamAt) authoritatively shadow anything the runner might grow
|
|
2381
|
+
// with the same name. Today there is no collision (runner.js returns
|
|
2382
|
+
// { groups, targets, startedAt, error?, skipped? }) but the failure
|
|
2383
|
+
// mode of the alternative ordering is silent — review feedback from
|
|
2384
|
+
// PR #743.
|
|
2370
2385
|
sendToServer({
|
|
2371
2386
|
type: 'unify_dream_result',
|
|
2372
2387
|
vpId,
|
|
2373
|
-
success: !result.error && !result.skipped,
|
|
2374
2388
|
...result,
|
|
2389
|
+
success: !result.error && !result.skipped,
|
|
2390
|
+
entriesCreated,
|
|
2391
|
+
lastDreamAt,
|
|
2375
2392
|
});
|
|
2376
2393
|
} catch (err) {
|
|
2377
2394
|
sendToServer({
|
|
@@ -2570,6 +2587,7 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2570
2587
|
...(yeaftDir && { dir: yeaftDir }),
|
|
2571
2588
|
skipMCP: false,
|
|
2572
2589
|
skipSkills: false,
|
|
2590
|
+
serverMode: true,
|
|
2573
2591
|
});
|
|
2574
2592
|
installUnifyRuntimeBridge(session);
|
|
2575
2593
|
|
|
@@ -2762,6 +2780,7 @@ export async function resetUnifySession() {
|
|
|
2762
2780
|
...(yeaftDir && { dir: yeaftDir }),
|
|
2763
2781
|
skipMCP: false,
|
|
2764
2782
|
skipSkills: false,
|
|
2783
|
+
serverMode: true,
|
|
2765
2784
|
});
|
|
2766
2785
|
installUnifyRuntimeBridge(session);
|
|
2767
2786
|
|