@yeaft/webchat-agent 0.1.494 → 0.1.495
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 +25 -1
- package/context.js +5 -0
- package/package.json +1 -1
- package/unify/config-api.js +89 -0
- package/unify/config.js +61 -0
- package/unify/session.js +14 -1
- package/unify/threads/engine-registry.js +69 -1
- package/unify/threads/store.js +32 -1
- package/unify/web-bridge.js +44 -0
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
25
25
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
26
26
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
27
|
-
import { getLlmConfig, updateLlmConfig } from '../unify/config-api.js';
|
|
27
|
+
import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
|
|
28
28
|
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread } from '../unify/web-bridge.js';
|
|
29
29
|
|
|
30
30
|
export async function handleMessage(msg) {
|
|
@@ -320,6 +320,30 @@ export async function handleMessage(msg) {
|
|
|
320
320
|
break;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
// task-318: Unify runtime settings (thread concurrency + auto-archive).
|
|
324
|
+
// Read/write the nested `unify` section of config.json — LLM fields
|
|
325
|
+
// untouched. On update we broadcast a `unify_settings_updated` event
|
|
326
|
+
// so the UI reflects the new values and in-process consumers
|
|
327
|
+
// (ThreadEngineRegistry, ThreadStore) can reload their caps.
|
|
328
|
+
case 'get_unify_settings': {
|
|
329
|
+
const settings = getUnifySettings(ctx.CONFIG?.yeaftDir);
|
|
330
|
+
sendToServer({ type: 'unify_settings', ...settings });
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case 'update_unify_settings': {
|
|
335
|
+
const result = updateUnifySettings(msg.settings || msg.config || {}, ctx.CONFIG?.yeaftDir);
|
|
336
|
+
// Let live consumers pick up the new caps without a session restart.
|
|
337
|
+
// The registry/store are created per-session; we update the exported
|
|
338
|
+
// accessors so subsequent dispatches see the new values.
|
|
339
|
+
if (!result.error && ctx.unifyRuntimeSettings) {
|
|
340
|
+
ctx.unifyRuntimeSettings.maxConcurrentThreads = result.maxConcurrentThreads;
|
|
341
|
+
ctx.unifyRuntimeSettings.autoArchiveIdleDays = result.autoArchiveIdleDays;
|
|
342
|
+
}
|
|
343
|
+
sendToServer({ type: 'unify_settings_updated', ...result });
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
323
347
|
// Unify — independent chat via Engine
|
|
324
348
|
case 'unify_chat':
|
|
325
349
|
await handleUnifyChat(msg);
|
package/context.js
CHANGED
|
@@ -30,4 +30,9 @@ export default {
|
|
|
30
30
|
sendToServer: null,
|
|
31
31
|
// 由 index.js 注册的配置保存函数
|
|
32
32
|
saveConfig: null,
|
|
33
|
+
// task-318: live Unify runtime caps. Mutated in-place by
|
|
34
|
+
// `update_unify_settings` so in-process consumers (web-bridge) can
|
|
35
|
+
// pick up the new values without a session restart. `null` until the
|
|
36
|
+
// Unify session has finished loading.
|
|
37
|
+
unifyRuntimeSettings: null,
|
|
33
38
|
};
|
package/package.json
CHANGED
package/unify/config-api.js
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import { DEFAULT_YEAFT_DIR } from './init.js';
|
|
14
14
|
import { normalizeProviderModels, serializeModelForPersistence } from './models.js';
|
|
15
|
+
import { normaliseUnifySection } from './config.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Read the LLM-relevant portion of config.json.
|
|
@@ -126,3 +127,91 @@ export function updateLlmConfig(update, dir) {
|
|
|
126
127
|
language: existing.language || 'en',
|
|
127
128
|
};
|
|
128
129
|
}
|
|
130
|
+
|
|
131
|
+
// ─── Unify runtime settings (task-318) ────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the Unify-section of config.json. Returns defaults when the file
|
|
135
|
+
* or section is absent. Callers (UI, registry, ThreadStore) rely on a
|
|
136
|
+
* stable shape — `normaliseUnifySection` guarantees that.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} [dir] — Yeaft data directory
|
|
139
|
+
* @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number } | { error: string }}
|
|
140
|
+
*/
|
|
141
|
+
export function getUnifySettings(dir) {
|
|
142
|
+
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
143
|
+
const configPath = join(root, 'config.json');
|
|
144
|
+
if (!existsSync(configPath)) return normaliseUnifySection(null);
|
|
145
|
+
try {
|
|
146
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
147
|
+
const json = JSON.parse(raw);
|
|
148
|
+
return normaliseUnifySection(json.unify);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return { error: `Failed to read config.json: ${e.message}` };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Update the Unify-section of config.json. Merges into existing config
|
|
156
|
+
* (LLM provider / model fields are untouched) and validates each field:
|
|
157
|
+
* `maxConcurrentThreads` must be 1..50, `autoArchiveIdleDays` must be
|
|
158
|
+
* 1..3650. Invalid values are rejected outright so the UI sees an error
|
|
159
|
+
* rather than silently reverting — a silent revert would make "I set it
|
|
160
|
+
* to 100 and nothing happened" impossible to debug.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ maxConcurrentThreads?: number, autoArchiveIdleDays?: number }} update
|
|
163
|
+
* @param {string} [dir]
|
|
164
|
+
* @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number } | { error: string }}
|
|
165
|
+
*/
|
|
166
|
+
export function updateUnifySettings(update, dir) {
|
|
167
|
+
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
168
|
+
const configPath = join(root, 'config.json');
|
|
169
|
+
|
|
170
|
+
if (!update || typeof update !== 'object') {
|
|
171
|
+
return { error: 'update payload required' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate before touching the file. We enforce the same clamp bounds
|
|
175
|
+
// that `normaliseUnifySection` uses for reads so round-trip is stable.
|
|
176
|
+
if (update.maxConcurrentThreads !== undefined) {
|
|
177
|
+
const n = Number(update.maxConcurrentThreads);
|
|
178
|
+
if (!Number.isFinite(n) || n < 1 || n > 50) {
|
|
179
|
+
return { error: 'maxConcurrentThreads must be between 1 and 50' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (update.autoArchiveIdleDays !== undefined) {
|
|
183
|
+
const n = Number(update.autoArchiveIdleDays);
|
|
184
|
+
if (!Number.isFinite(n) || n < 1 || n > 3650) {
|
|
185
|
+
return { error: 'autoArchiveIdleDays must be between 1 and 3650' };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Read existing config (preserve LLM and other top-level fields).
|
|
190
|
+
let existing = {};
|
|
191
|
+
if (existsSync(configPath)) {
|
|
192
|
+
try {
|
|
193
|
+
existing = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
194
|
+
} catch {
|
|
195
|
+
existing = {};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const prev = normaliseUnifySection(existing.unify);
|
|
200
|
+
const merged = {
|
|
201
|
+
maxConcurrentThreads: update.maxConcurrentThreads !== undefined
|
|
202
|
+
? Math.floor(Number(update.maxConcurrentThreads))
|
|
203
|
+
: prev.maxConcurrentThreads,
|
|
204
|
+
autoArchiveIdleDays: update.autoArchiveIdleDays !== undefined
|
|
205
|
+
? Math.floor(Number(update.autoArchiveIdleDays))
|
|
206
|
+
: prev.autoArchiveIdleDays,
|
|
207
|
+
};
|
|
208
|
+
existing.unify = merged;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return { error: `Failed to write config.json: ${e.message}` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return merged;
|
|
217
|
+
}
|
package/unify/config.js
CHANGED
|
@@ -33,6 +33,15 @@ const DEFAULTS = {
|
|
|
33
33
|
maxOutputTokens: 16384,
|
|
34
34
|
messageTokenBudget: 8192,
|
|
35
35
|
maxContinueTurns: 3,
|
|
36
|
+
// task-318: Unify-specific runtime caps. `maxConcurrentThreads` gates
|
|
37
|
+
// how many ThreadEngineRegistry instances may be live at once; dispatch
|
|
38
|
+
// refuses new threads beyond this. The count INCLUDES the always-on
|
|
39
|
+
// `main` thread, so the default 6 gives users 5 user-initiated threads
|
|
40
|
+
// on top of main. `autoArchiveIdleDays` is consumed by ThreadStore's
|
|
41
|
+
// idle-archive pass (task-317 will wire the pass itself; here we just
|
|
42
|
+
// plumb the knob through so the UI can set it).
|
|
43
|
+
unifyMaxConcurrentThreads: 6,
|
|
44
|
+
unifyAutoArchiveIdleDays: 30,
|
|
36
45
|
};
|
|
37
46
|
|
|
38
47
|
// ─── config.json reader ─────────────────────────────────────────
|
|
@@ -134,6 +143,52 @@ function isTruthy(val) {
|
|
|
134
143
|
return val === '1' || val === 'true' || val === 'yes';
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Clamp and normalise the `unify` config section. Missing / non-numeric
|
|
148
|
+
* inputs fall back to the DEFAULTS; numeric-but-out-of-range inputs are
|
|
149
|
+
* *clamped* to the valid range rather than silently reverting to the
|
|
150
|
+
* default. This keeps the read path aligned with `updateUnifySettings`,
|
|
151
|
+
* which rejects out-of-range writes up front — a hand-edited
|
|
152
|
+
* `config.json` with `maxConcurrentThreads: 100` now loads as 50 (the
|
|
153
|
+
* nearest valid value) instead of collapsing back to 5.
|
|
154
|
+
*
|
|
155
|
+
* Exported for tests and for `config-api.js` to share the same clamp
|
|
156
|
+
* bounds via `clampUnifyField`.
|
|
157
|
+
*
|
|
158
|
+
* @param {any} raw — jsonConfig.unify (may be undefined / malformed)
|
|
159
|
+
* @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number }}
|
|
160
|
+
*/
|
|
161
|
+
export function normaliseUnifySection(raw) {
|
|
162
|
+
const out = {
|
|
163
|
+
maxConcurrentThreads: DEFAULTS.unifyMaxConcurrentThreads,
|
|
164
|
+
autoArchiveIdleDays: DEFAULTS.unifyAutoArchiveIdleDays,
|
|
165
|
+
};
|
|
166
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
167
|
+
const mc = clampUnifyField(raw.maxConcurrentThreads, 'maxConcurrentThreads');
|
|
168
|
+
if (mc !== null) out.maxConcurrentThreads = mc;
|
|
169
|
+
const ad = clampUnifyField(raw.autoArchiveIdleDays, 'autoArchiveIdleDays');
|
|
170
|
+
if (ad !== null) out.autoArchiveIdleDays = ad;
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Clamp a single unify field to its valid range. Returns `null` if the
|
|
176
|
+
* input is not a finite number (so callers can treat it as "not set" and
|
|
177
|
+
* keep the previous value). Centralises the bounds used on both read
|
|
178
|
+
* (`normaliseUnifySection`) and write (`updateUnifySettings` validation).
|
|
179
|
+
*
|
|
180
|
+
* @param {unknown} v
|
|
181
|
+
* @param {'maxConcurrentThreads'|'autoArchiveIdleDays'} field
|
|
182
|
+
* @returns {number | null}
|
|
183
|
+
*/
|
|
184
|
+
export function clampUnifyField(v, field) {
|
|
185
|
+
if (v === null || v === undefined) return null;
|
|
186
|
+
const n = Number(v);
|
|
187
|
+
if (!Number.isFinite(n)) return null;
|
|
188
|
+
const [lo, hi] = field === 'maxConcurrentThreads' ? [1, 50] : [1, 3650];
|
|
189
|
+
return Math.min(hi, Math.max(lo, Math.floor(n)));
|
|
190
|
+
}
|
|
191
|
+
|
|
137
192
|
/**
|
|
138
193
|
* Build config from legacy config.md + .env + env vars.
|
|
139
194
|
* @deprecated — used only when config.json doesn't exist.
|
|
@@ -160,6 +215,8 @@ function loadLegacyConfig(dir, overrides) {
|
|
|
160
215
|
maxOutputTokens: overrides.maxOutputTokens ?? fileConfig.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
|
|
161
216
|
messageTokenBudget: overrides.messageTokenBudget ?? (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ?? fileConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
|
|
162
217
|
maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
|
|
218
|
+
// task-318: legacy path never had the `unify` section — defaults.
|
|
219
|
+
unify: normaliseUnifySection(null),
|
|
163
220
|
providers: null,
|
|
164
221
|
primaryModel: null,
|
|
165
222
|
fastModel: null,
|
|
@@ -252,6 +309,10 @@ export function loadConfig(overrides = {}) {
|
|
|
252
309
|
messageTokenBudget: overrides.messageTokenBudget ?? jsonConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
|
|
253
310
|
maxContinueTurns: overrides.maxContinueTurns ?? jsonConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
|
|
254
311
|
|
|
312
|
+
// task-318: Unify runtime caps. `unify` is a nested section so we
|
|
313
|
+
// don't pollute the flat config namespace used by chat/crew code.
|
|
314
|
+
unify: normaliseUnifySection(jsonConfig.unify),
|
|
315
|
+
|
|
255
316
|
// Legacy fields (null when using config.json)
|
|
256
317
|
apiKey: overrides.apiKey || null,
|
|
257
318
|
openaiApiKey: null,
|
package/unify/session.js
CHANGED
|
@@ -131,7 +131,13 @@ export async function loadSession(options = {}) {
|
|
|
131
131
|
// ─── 5b. Initialize thread store (task-299 Phase 1) ────
|
|
132
132
|
// task-307a: now file-backed under ~/.yeaft/threads/. Passing the
|
|
133
133
|
// yeaftDir switches on disk persistence; read-only mode is honoured.
|
|
134
|
-
|
|
134
|
+
// task-318: forward the Unify autoArchiveIdleDays knob so the
|
|
135
|
+
// archive pass (owned by task-317) can read it off the store.
|
|
136
|
+
initThreadStore(yeaftDir, {
|
|
137
|
+
readOnly: config._readOnly || false,
|
|
138
|
+
force: true,
|
|
139
|
+
idleArchiveDays: config.unify?.autoArchiveIdleDays ?? 0,
|
|
140
|
+
});
|
|
135
141
|
|
|
136
142
|
// ─── 6. Load skills ────────────────────────────────────
|
|
137
143
|
let skillManager;
|
|
@@ -187,6 +193,8 @@ export async function loadSession(options = {}) {
|
|
|
187
193
|
skillManager,
|
|
188
194
|
mcpManager,
|
|
189
195
|
yeaftDir,
|
|
196
|
+
// task-318: concurrent-thread cap (UI-adjustable via Settings).
|
|
197
|
+
maxConcurrent: config.unify?.maxConcurrentThreads ?? null,
|
|
190
198
|
});
|
|
191
199
|
// Seed the main-thread instance so listActive() is non-empty from T=0.
|
|
192
200
|
engineRegistry.ensure(MAIN_THREAD_ID);
|
|
@@ -254,6 +262,11 @@ export async function loadSession(options = {}) {
|
|
|
254
262
|
toolRegistry,
|
|
255
263
|
trace,
|
|
256
264
|
yeaftDir,
|
|
265
|
+
// task-318 rev-1 fix: expose the live ThreadStore handle so callers
|
|
266
|
+
// (web-bridge, message-router via ctx) can invoke setIdleArchiveDays()
|
|
267
|
+
// on the exact instance that's wired into the dispatcher. Without this
|
|
268
|
+
// export the setter was effectively dead code.
|
|
269
|
+
threadStore: getThreadStore(),
|
|
257
270
|
status,
|
|
258
271
|
shutdown,
|
|
259
272
|
};
|
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
import { MAIN_THREAD_ID } from './store.js';
|
|
30
30
|
import { createEngineInstance } from './engine-instance.js';
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Coerce a maxConcurrent input to either a positive integer or null
|
|
34
|
+
* (meaning "no cap"). Keeps the cap logic branch-free elsewhere.
|
|
35
|
+
*/
|
|
36
|
+
function normaliseMaxConcurrent(n) {
|
|
37
|
+
if (n === null || n === undefined) return null;
|
|
38
|
+
const v = Number(n);
|
|
39
|
+
if (!Number.isFinite(v) || v <= 0) return null;
|
|
40
|
+
return Math.floor(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
export class ThreadEngineRegistry {
|
|
33
44
|
/** @type {Map<string, import('./engine-instance.js').EngineInstance>} */
|
|
34
45
|
#instances;
|
|
@@ -39,18 +50,33 @@ export class ThreadEngineRegistry {
|
|
|
39
50
|
/** @type {string} */
|
|
40
51
|
#currentThreadId;
|
|
41
52
|
|
|
53
|
+
/**
|
|
54
|
+
* task-318: soft cap on concurrent live engine instances. When set,
|
|
55
|
+
* `ensure()` refuses to spawn a new instance beyond this many live
|
|
56
|
+
* entries. Already-live instances continue to serve query()s — the
|
|
57
|
+
* cap only gates net-new thread creation. Can be mutated at runtime
|
|
58
|
+
* by `setMaxConcurrent()` so the Settings UI takes effect without a
|
|
59
|
+
* session restart.
|
|
60
|
+
*
|
|
61
|
+
* Null / 0 / negative → unlimited (treat as "no cap").
|
|
62
|
+
* @type {number | null}
|
|
63
|
+
*/
|
|
64
|
+
#maxConcurrent;
|
|
65
|
+
|
|
42
66
|
/**
|
|
43
67
|
* @param {{
|
|
44
68
|
* factory: (threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance,
|
|
69
|
+
* maxConcurrent?: number | null,
|
|
45
70
|
* }} params
|
|
46
71
|
*/
|
|
47
|
-
constructor({ factory } = {}) {
|
|
72
|
+
constructor({ factory, maxConcurrent } = {}) {
|
|
48
73
|
if (typeof factory !== 'function') {
|
|
49
74
|
throw new Error('ThreadEngineRegistry: factory function is required');
|
|
50
75
|
}
|
|
51
76
|
this.#instances = new Map();
|
|
52
77
|
this.#factory = factory;
|
|
53
78
|
this.#currentThreadId = MAIN_THREAD_ID;
|
|
79
|
+
this.#maxConcurrent = normaliseMaxConcurrent(maxConcurrent);
|
|
54
80
|
}
|
|
55
81
|
|
|
56
82
|
/** @returns {string} */
|
|
@@ -83,6 +109,22 @@ export class ThreadEngineRegistry {
|
|
|
83
109
|
}
|
|
84
110
|
const existing = this.#instances.get(threadId);
|
|
85
111
|
if (existing && !existing.terminated) return existing;
|
|
112
|
+
// task-318: enforce concurrency cap before spawning a net-new
|
|
113
|
+
// instance. Replacing a terminated slot for the same threadId does
|
|
114
|
+
// NOT count against the cap — it's the same thread resuming.
|
|
115
|
+
if (!existing && this.#maxConcurrent !== null) {
|
|
116
|
+
const live = this.#countLive();
|
|
117
|
+
if (live >= this.#maxConcurrent) {
|
|
118
|
+
const err = new Error(
|
|
119
|
+
`ThreadEngineRegistry: concurrent thread limit reached (${live}/${this.#maxConcurrent}). ` +
|
|
120
|
+
`Archive or terminate an existing thread before starting a new one.`
|
|
121
|
+
);
|
|
122
|
+
err.code = 'ERR_MAX_CONCURRENT_THREADS';
|
|
123
|
+
err.limit = this.#maxConcurrent;
|
|
124
|
+
err.live = live;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
86
128
|
const instance = this.#factory(threadId, opts);
|
|
87
129
|
if (!instance || typeof instance.query !== 'function') {
|
|
88
130
|
throw new Error(`ThreadEngineRegistry.ensure: factory did not return an EngineInstance for ${threadId}`);
|
|
@@ -91,6 +133,31 @@ export class ThreadEngineRegistry {
|
|
|
91
133
|
return instance;
|
|
92
134
|
}
|
|
93
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Count currently non-terminated instances — the denominator for the
|
|
138
|
+
* concurrency cap. O(n) scan, n is small (≤ 50 in practice).
|
|
139
|
+
* @returns {number}
|
|
140
|
+
*/
|
|
141
|
+
#countLive() {
|
|
142
|
+
let n = 0;
|
|
143
|
+
for (const inst of this.#instances.values()) {
|
|
144
|
+
if (!inst.terminated) n += 1;
|
|
145
|
+
}
|
|
146
|
+
return n;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* task-318: read or update the concurrency cap. `setMaxConcurrent(null)`
|
|
151
|
+
* disables the cap entirely. Existing live instances are NOT terminated
|
|
152
|
+
* if the cap is lowered below their count — the cap only gates new
|
|
153
|
+
* `ensure()` calls going forward.
|
|
154
|
+
* @returns {number | null}
|
|
155
|
+
*/
|
|
156
|
+
get maxConcurrent() { return this.#maxConcurrent; }
|
|
157
|
+
setMaxConcurrent(n) {
|
|
158
|
+
this.#maxConcurrent = normaliseMaxConcurrent(n);
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
/**
|
|
95
162
|
* Set the current thread marker. Does not lazy-create — caller must
|
|
96
163
|
* ensure() if they want an instance for an unseen thread.
|
|
@@ -188,5 +255,6 @@ export function createThreadEngineRegistry(deps) {
|
|
|
188
255
|
...opts,
|
|
189
256
|
threadId,
|
|
190
257
|
}),
|
|
258
|
+
maxConcurrent: deps?.maxConcurrent ?? null,
|
|
191
259
|
});
|
|
192
260
|
}
|
package/unify/threads/store.js
CHANGED
|
@@ -52,6 +52,18 @@ export const MAIN_THREAD_ID = 'main';
|
|
|
52
52
|
/** Valid thread status values. Mirrors design doc §5. */
|
|
53
53
|
export const THREAD_STATUSES = ['active', 'idle', 'archived'];
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* task-318: Coerce an idle-archive days input to a non-negative integer
|
|
57
|
+
* (0 disables the feature). Anything invalid falls through to 0 so a
|
|
58
|
+
* malformed config can never silently enable auto-archive.
|
|
59
|
+
*/
|
|
60
|
+
function normaliseIdleDays(n) {
|
|
61
|
+
if (n === null || n === undefined) return 0;
|
|
62
|
+
const v = Number(n);
|
|
63
|
+
if (!Number.isFinite(v) || v <= 0) return 0;
|
|
64
|
+
return Math.floor(v);
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
/** Debounce window for grouped disk writes. Kept short so tests don't hang. */
|
|
56
68
|
const FLUSH_DEBOUNCE_MS = 8;
|
|
57
69
|
|
|
@@ -275,10 +287,12 @@ export class ThreadStore {
|
|
|
275
287
|
#dirtyAttachments;
|
|
276
288
|
/** @type {any} NodeJS.Timeout */
|
|
277
289
|
#flushTimer;
|
|
290
|
+
/** @type {number} task-318: days of inactivity before auto-archive (0 = disabled) */
|
|
291
|
+
#idleArchiveDays;
|
|
278
292
|
|
|
279
293
|
/**
|
|
280
294
|
* @param {string} [yeaftDir] — Base ~/.yeaft directory. Omit for in-memory mode.
|
|
281
|
-
* @param {{ readOnly?: boolean }} [opts]
|
|
295
|
+
* @param {{ readOnly?: boolean, idleArchiveDays?: number }} [opts]
|
|
282
296
|
*/
|
|
283
297
|
constructor(yeaftDir, opts = {}) {
|
|
284
298
|
this.#threads = new Map();
|
|
@@ -290,6 +304,11 @@ export class ThreadStore {
|
|
|
290
304
|
this.#flushTimer = null;
|
|
291
305
|
|
|
292
306
|
this.#readOnly = !!opts.readOnly;
|
|
307
|
+
// task-318: idle-archive threshold (days). Consumed by the archive
|
|
308
|
+
// pass in task-317; here we just accept + expose the knob so the
|
|
309
|
+
// config plumbing is testable end-to-end today. A value ≤ 0 means
|
|
310
|
+
// "no auto-archive" (feature disabled).
|
|
311
|
+
this.#idleArchiveDays = normaliseIdleDays(opts.idleArchiveDays);
|
|
293
312
|
if (yeaftDir) {
|
|
294
313
|
this.#dir = join(yeaftDir, 'threads');
|
|
295
314
|
this.#indexPath = join(this.#dir, 'index.md');
|
|
@@ -487,6 +506,18 @@ export class ThreadStore {
|
|
|
487
506
|
get currentId() { return this.#currentId; }
|
|
488
507
|
get size() { return this.#threads.size; }
|
|
489
508
|
|
|
509
|
+
/**
|
|
510
|
+
* task-318: idle-archive threshold (days). 0 disables auto-archive.
|
|
511
|
+
* The actual archive pass is owned by task-317 — this accessor is the
|
|
512
|
+
* contract between config and that future consumer, plus a write path
|
|
513
|
+
* so the Settings UI takes effect without restarting the session.
|
|
514
|
+
* @returns {number}
|
|
515
|
+
*/
|
|
516
|
+
get idleArchiveDays() { return this.#idleArchiveDays; }
|
|
517
|
+
setIdleArchiveDays(days) {
|
|
518
|
+
this.#idleArchiveDays = normaliseIdleDays(days);
|
|
519
|
+
}
|
|
520
|
+
|
|
490
521
|
get(id) { return this.#threads.get(id) || null; }
|
|
491
522
|
list() { return [...this.#threads.values()]; }
|
|
492
523
|
has(id) { return this.#threads.has(id); }
|
package/unify/web-bridge.js
CHANGED
|
@@ -73,6 +73,38 @@ function sendUnifyEvent(event) {
|
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* task-318 rev-1 fix: install live-setter bridge between the session's
|
|
78
|
+
* runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
|
|
79
|
+
* which message-router's `update_unify_settings` branch reads. Previously
|
|
80
|
+
* that object was null and the setters were dead code. Now every
|
|
81
|
+
* `update_unify_settings` mutation pushes the new caps into the live
|
|
82
|
+
* session within the same tick — no reload required.
|
|
83
|
+
*
|
|
84
|
+
* Exported so tests can drive the same wiring with a mock session.
|
|
85
|
+
*
|
|
86
|
+
* @param {import('./session.js').Session} s
|
|
87
|
+
*/
|
|
88
|
+
export function installUnifyRuntimeBridge(s) {
|
|
89
|
+
if (!s) return;
|
|
90
|
+
const initialMax = s.engineRegistry?.maxConcurrent ?? null;
|
|
91
|
+
const initialIdle = s.threadStore?.idleArchiveDays ?? 0;
|
|
92
|
+
ctx.unifyRuntimeSettings = {
|
|
93
|
+
get maxConcurrentThreads() { return s.engineRegistry?.maxConcurrent ?? initialMax; },
|
|
94
|
+
set maxConcurrentThreads(v) {
|
|
95
|
+
if (typeof s.engineRegistry?.setMaxConcurrent === 'function') {
|
|
96
|
+
s.engineRegistry.setMaxConcurrent(v);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
get autoArchiveIdleDays() { return s.threadStore?.idleArchiveDays ?? initialIdle; },
|
|
100
|
+
set autoArchiveIdleDays(v) {
|
|
101
|
+
if (typeof s.threadStore?.setIdleArchiveDays === 'function') {
|
|
102
|
+
s.threadStore.setIdleArchiveDays(v);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
76
108
|
/**
|
|
77
109
|
* task-301 Part 2: push the full thread list snapshot to the web client.
|
|
78
110
|
* Called after any ThreadStore-mutating tool completes and at turn_end so
|
|
@@ -391,6 +423,14 @@ export async function handleUnifyChat(msg) {
|
|
|
391
423
|
skipSkills: false,
|
|
392
424
|
});
|
|
393
425
|
|
|
426
|
+
// task-318 rev-1 fix: expose live setters on ctx so message-router's
|
|
427
|
+
// update_unify_settings branch can push the new caps into the
|
|
428
|
+
// registry + thread store without a session reload. Previously this
|
|
429
|
+
// object was null and setMaxConcurrent/setIdleArchiveDays were dead
|
|
430
|
+
// code — the config file was updated on disk but the running
|
|
431
|
+
// session continued with the old caps until next restart.
|
|
432
|
+
installUnifyRuntimeBridge(session);
|
|
433
|
+
|
|
394
434
|
// Create a stable conversationId for the Unify session
|
|
395
435
|
unifyConversationId = `unify-${Date.now()}`;
|
|
396
436
|
|
|
@@ -737,6 +777,8 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
737
777
|
skipMCP: false,
|
|
738
778
|
skipSkills: false,
|
|
739
779
|
});
|
|
780
|
+
// task-318 rev-1 fix: wire live setters; see handleUnifyChat.
|
|
781
|
+
installUnifyRuntimeBridge(session);
|
|
740
782
|
|
|
741
783
|
unifyConversationId = `unify-${Date.now()}`;
|
|
742
784
|
|
|
@@ -811,6 +853,8 @@ export async function resetUnifySession() {
|
|
|
811
853
|
skipMCP: false,
|
|
812
854
|
skipSkills: false,
|
|
813
855
|
});
|
|
856
|
+
// task-318 rev-1 fix: wire live setters; see handleUnifyChat.
|
|
857
|
+
installUnifyRuntimeBridge(session);
|
|
814
858
|
|
|
815
859
|
unifyConversationId = `unify-${Date.now()}`;
|
|
816
860
|
|