ai_co_discuss 0.2.85 → 0.2.87
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/backend/server.py +3 -3
- package/package.json +1 -1
- package/runner/discussion_launch.mjs +42 -4
- package/runner/free_runner.mjs +463 -452
- package/web/app.js +38 -8
- package/web/modern.css +1 -1
- package/web/modern.js +1380 -1299
package/runner/free_runner.mjs
CHANGED
|
@@ -1,452 +1,463 @@
|
|
|
1
|
-
/** Free-room delivery: one live execution per member, carrying all of that member's open requests. */
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
-
import { basename, join, resolve } from 'node:path';
|
|
6
|
-
import { agy_command_rule, atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
7
|
-
import { api } from './discussion_runner.mjs';
|
|
8
|
-
import { with_expert_prompt } from './expert_prompts.mjs';
|
|
9
|
-
import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, route_free_message } from './scheduler.mjs';
|
|
10
|
-
|
|
11
|
-
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
|
-
const RENEW_MS = 30_000;
|
|
13
|
-
/** A changed phase is reported early, but no more often than this, since each report refreshes open pages. */
|
|
14
|
-
const ACTIVITY_REPORT_MS = 10_000;
|
|
15
|
-
/** Progress notes kept per execution, each cut to this length, and how many one renewal carries; the website enforces the same. */
|
|
16
|
-
const MAX_EXECUTION_NOTES = 50;
|
|
17
|
-
const MAX_NOTE_LENGTH = 500;
|
|
18
|
-
const MAX_NOTES_PER_RENEW = 10;
|
|
19
|
-
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
20
|
-
const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running'];
|
|
21
|
-
const MAX_HASHED_BYTES = 5 * 1024 * 1024;
|
|
22
|
-
const GIT_TIMEOUT_MS = 15_000;
|
|
23
|
-
|
|
24
|
-
/** Identify a working directory consistently, so executions in the same cwd can be compared. */
|
|
25
|
-
export function cwd_key(directory) {
|
|
26
|
-
if (!directory) return '';
|
|
27
|
-
const path = resolve(directory).replaceAll('\\', '/');
|
|
28
|
-
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Record each changed or untracked file's Git status and content hash in a Git working tree.
|
|
33
|
-
* Returns null outside Git: free rooms then provide no automatic overlap detection.
|
|
34
|
-
*/
|
|
35
|
-
export function workspace_snapshot(directory) {
|
|
36
|
-
if (!directory) return null;
|
|
37
|
-
const result = spawnSync('git', ['-C', directory, 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
38
|
-
{ encoding: 'utf8', windowsHide: true, timeout: GIT_TIMEOUT_MS });
|
|
39
|
-
if (result.status !== 0) return null;
|
|
40
|
-
const entries = new Map();
|
|
41
|
-
const fields = result.stdout.split('\0').filter(Boolean);
|
|
42
|
-
for (let index = 0; index < fields.length; index++) {
|
|
43
|
-
const status = fields[index].slice(0, 2), path = fields[index].slice(3);
|
|
44
|
-
if (status[0] === 'R' || status[0] === 'C') index++; // The original path of a rename follows.
|
|
45
|
-
let content = 'missing';
|
|
46
|
-
try {
|
|
47
|
-
const file = join(directory, path);
|
|
48
|
-
const size = statSync(file).size;
|
|
49
|
-
content = size > MAX_HASHED_BYTES ? `size:${size}` : createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
50
|
-
} catch { /* Deleted files keep the marker. */ }
|
|
51
|
-
entries.set(path, `${status}:${content}`);
|
|
52
|
-
}
|
|
53
|
-
return entries;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Paths whose status or content differ between two snapshots of one working tree. */
|
|
57
|
-
export function changed_between(before, after) {
|
|
58
|
-
const paths = new Set([...before.keys(), ...after.keys()]);
|
|
59
|
-
return [...paths].filter(path => before.get(path) !== after.get(path)).sort();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Write a non-secret task and a member-only credential file for one execution. */
|
|
63
|
-
export function free_task_files(config, member, execution, binding) {
|
|
64
|
-
const id = `free-${execution.id}`;
|
|
65
|
-
const draft_directory = config.draft_directory || `${config.directory}-drafts`;
|
|
66
|
-
mkdirSync(draft_directory, { recursive: true });
|
|
67
|
-
const access_path = join(config.directory, `${id}.access.json`);
|
|
68
|
-
const task_path = join(config.directory, `${id}.task.json`);
|
|
69
|
-
const profile = config.participants[member.name];
|
|
70
|
-
atomic_json(access_path, { site: config.site, room: config.room, token: config.members[member.name].token,
|
|
71
|
-
session_id: binding.session_id || '' });
|
|
72
|
-
const cli = process.platform === 'win32' && profile.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
|
|
73
|
-
const bound_access = ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
|
|
74
|
-
const access_arg = bound_access ? '' : ' --access-file 凭据路径';
|
|
75
|
-
const access_rule = bound_access ? '本次凭据文件已通过进程环境 DISCUSS_ACCESS_FILE 绑定,codiscuss 自动读取,命令无需 --access-file。' : '';
|
|
76
|
-
const project_rule = !profile.cwd ? '未指定项目目录。'
|
|
77
|
-
: profile.project_access === 'write' ? '用户允许按请求修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
|
|
78
|
-
const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
|
|
79
|
-
// The host routes unnamed messages and receives hand-backs; it may name members in its answer.
|
|
80
|
-
const discussion = config.mode !== 'free';
|
|
81
|
-
const host_rule = member.role === 'host' && !discussion
|
|
82
|
-
? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束,all 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 --to)。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),只点名一位成员时,其回答直接面向用户,你不会再被唤醒;同时点名多位时,全部结束后你会再被唤醒一次汇总。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
|
|
83
|
-
: '';
|
|
84
|
-
atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
|
|
85
|
-
execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
|
|
86
|
-
request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
|
|
87
|
-
read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
|
|
88
|
-
skills: [join(config.project, '.agents/skills/discuss-participant/SKILL.md')], cli, subcommands: TASK_SUBCOMMANDS,
|
|
89
|
-
...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }),
|
|
90
|
-
instructions: `${agy_command_rule(profile.adapter, cli)}${discussion ? '讨论房间:用户直接向你提问(' + messages + ')。这不是你的发言安排,不要领取发言权、不要安排他人,也不改变讨论进度,只回答这个提问。' : '自由协作房间:用户点名了你(' + messages + ')。'}${access_rule}先用 ${cli} message list ${config.room} --after ${execution.read_until}${access_arg} 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID${access_arg} 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
|
|
91
|
-
const prompt = `用户在${discussion ? '讨论' : '自由协作'}房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
|
|
92
|
-
return { id, participant: member.name, directory: config.directory, draft_directory,
|
|
93
|
-
skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile) };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
|
|
97
|
-
antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
|
|
98
|
-
|
|
99
|
-
/** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
|
|
100
|
-
function member_description(name, profile, work) {
|
|
101
|
-
const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
|
|
102
|
-
const access = profile.adapter === 'api' ? '' : !profile.cwd ? ',未指定项目目录'
|
|
103
|
-
: profile.project_access === 'write' ? `,可修改项目 ${project} 的文件` : `,项目 ${project} 只读`;
|
|
104
|
-
return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access}${work})`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
|
|
108
|
-
function member_work(requests, name, message_id) {
|
|
109
|
-
const open = requests.filter(item => item.member === name && item.message_id !== message_id);
|
|
110
|
-
const ids = status => [...new Set(open.filter(item => item.status === status).map(item => `#${item.message_id}`))].join('、');
|
|
111
|
-
return [ids('delivering') && `;正在处理 ${ids('delivering')}`, ids('pending') && `;待处理 ${ids('pending')}`].filter(Boolean).join('');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Why a message stayed with the host: the scheduler chose the host, or leaned elsewhere without enough certainty. */
|
|
115
|
-
function route_host_label(decision, host, members) {
|
|
116
|
-
if (decision.choice === '__host__' || decision.confidence === null) return `交给主持人 ${host}`;
|
|
117
|
-
const lean = decision.choice === '__all__' ? '全体各自回应' : decision.choice === '__none__' ? '无需回应'
|
|
118
|
-
: members.some(member => member.name === decision.choice) ? `请 ${decision.choice} 回答` : '';
|
|
119
|
-
return lean ? `倾向${lean},把握不足,交给主持人 ${host}` : `交给主持人 ${host}`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Delivers free-room requests through the same software adapters as discussions. */
|
|
123
|
-
export class FreeRoomRunner {
|
|
124
|
-
constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch) {
|
|
125
|
-
this.config = config;
|
|
126
|
-
this.store = store;
|
|
127
|
-
this.deliver = deliver;
|
|
128
|
-
this.log = log;
|
|
129
|
-
this.fetch = fetch_impl;
|
|
130
|
-
this.routed = new Set();
|
|
131
|
-
// Group requests (@全体成员) being arranged; a failed arrangement leaves the set so the next tick retries.
|
|
132
|
-
this.grouped = new Set();
|
|
133
|
-
this.decisions = new Set();
|
|
134
|
-
// Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
|
|
135
|
-
this.routing = new Set();
|
|
136
|
-
this.children = new Set();
|
|
137
|
-
this.members = new Map();
|
|
138
|
-
// Execution id -> why its session ended on its own, such as a command refused in unattended mode ('' when unknown).
|
|
139
|
-
this.exited = new Map();
|
|
140
|
-
this.snapshots = new Map();
|
|
141
|
-
// Execution id -> { phase, at, sent_phase, sent_at, notes, next_seq }: what the member's software last showed it doing,
|
|
142
|
-
// and the progress notes not yet on the website.
|
|
143
|
-
this.activity = new Map();
|
|
144
|
-
this.context_errors = new Map();
|
|
145
|
-
this.adopt(config);
|
|
146
|
-
this.store.db.exec(`CREATE TABLE IF NOT EXISTS free_deliveries(member TEXT PRIMARY KEY, client_id TEXT NOT NULL,
|
|
147
|
-
execution_id INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
|
148
|
-
started INTEGER NOT NULL DEFAULT 0, last_renew INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '')`);
|
|
149
|
-
}
|
|
150
|
-
/** Read this member's current local delivery record. */
|
|
151
|
-
row(name) { return this.store.db.prepare('SELECT * FROM free_deliveries WHERE member=?').get(name); }
|
|
152
|
-
/** Persist one member's delivery state; the client id makes a lost reserve response recoverable. */
|
|
153
|
-
save(name, fields) {
|
|
154
|
-
const current = this.row(name) || { member: name, client_id: '', execution_id: 0, status: 'idle', attempts: 0, started: 0, last_renew: 0, error: '' };
|
|
155
|
-
const next = { ...current, ...fields };
|
|
156
|
-
this.store.db.prepare(`INSERT INTO free_deliveries VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(member) DO UPDATE SET
|
|
157
|
-
client_id=excluded.client_id,execution_id=excluded.execution_id,status=excluded.status,attempts=excluded.attempts,
|
|
158
|
-
started=excluded.started,last_renew=excluded.last_renew,error=excluded.error`)
|
|
159
|
-
.run(name, next.client_id, next.execution_id, next.status, next.attempts, next.started, next.last_renew, next.error);
|
|
160
|
-
return next;
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Take a changed configuration while running: new members are added, but a member whose
|
|
164
|
-
* project, cwd or bound session changed is blocked instead of silently reusing its conversation.
|
|
165
|
-
*/
|
|
166
|
-
adopt(config) {
|
|
167
|
-
for (const [name, item] of Object.entries(config.participants)) {
|
|
168
|
-
const context = JSON.stringify([item.adapter, item.project_id || '', item.project_name || '', cwd_key(item.cwd), cwd_key(item.workdir), item.session_id || '']);
|
|
169
|
-
const key = `member-context:${name}`;
|
|
170
|
-
const previous = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
|
|
171
|
-
if (previous && previous
|
|
172
|
-
else this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, context);
|
|
173
|
-
}
|
|
174
|
-
this.config = { ...this.config, ...config, participants: config.participants, members: config.members };
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
await this.
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
.finally(() => this.
|
|
229
|
-
|
|
230
|
-
this.decisions.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return this.save(member.name, { status: 'idle' });
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
this.save(member.name, {
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1
|
+
/** Free-room delivery: one live execution per member, carrying all of that member's open requests. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { agy_command_rule, atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
7
|
+
import { api, CODEX_ADAPTERS } from './discussion_runner.mjs';
|
|
8
|
+
import { with_expert_prompt } from './expert_prompts.mjs';
|
|
9
|
+
import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, route_free_message } from './scheduler.mjs';
|
|
10
|
+
|
|
11
|
+
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
|
+
const RENEW_MS = 30_000;
|
|
13
|
+
/** A changed phase is reported early, but no more often than this, since each report refreshes open pages. */
|
|
14
|
+
const ACTIVITY_REPORT_MS = 10_000;
|
|
15
|
+
/** Progress notes kept per execution, each cut to this length, and how many one renewal carries; the website enforces the same. */
|
|
16
|
+
const MAX_EXECUTION_NOTES = 50;
|
|
17
|
+
const MAX_NOTE_LENGTH = 500;
|
|
18
|
+
const MAX_NOTES_PER_RENEW = 10;
|
|
19
|
+
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
20
|
+
const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running'];
|
|
21
|
+
const MAX_HASHED_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
23
|
+
|
|
24
|
+
/** Identify a working directory consistently, so executions in the same cwd can be compared. */
|
|
25
|
+
export function cwd_key(directory) {
|
|
26
|
+
if (!directory) return '';
|
|
27
|
+
const path = resolve(directory).replaceAll('\\', '/');
|
|
28
|
+
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record each changed or untracked file's Git status and content hash in a Git working tree.
|
|
33
|
+
* Returns null outside Git: free rooms then provide no automatic overlap detection.
|
|
34
|
+
*/
|
|
35
|
+
export function workspace_snapshot(directory) {
|
|
36
|
+
if (!directory) return null;
|
|
37
|
+
const result = spawnSync('git', ['-C', directory, 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
38
|
+
{ encoding: 'utf8', windowsHide: true, timeout: GIT_TIMEOUT_MS });
|
|
39
|
+
if (result.status !== 0) return null;
|
|
40
|
+
const entries = new Map();
|
|
41
|
+
const fields = result.stdout.split('\0').filter(Boolean);
|
|
42
|
+
for (let index = 0; index < fields.length; index++) {
|
|
43
|
+
const status = fields[index].slice(0, 2), path = fields[index].slice(3);
|
|
44
|
+
if (status[0] === 'R' || status[0] === 'C') index++; // The original path of a rename follows.
|
|
45
|
+
let content = 'missing';
|
|
46
|
+
try {
|
|
47
|
+
const file = join(directory, path);
|
|
48
|
+
const size = statSync(file).size;
|
|
49
|
+
content = size > MAX_HASHED_BYTES ? `size:${size}` : createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
50
|
+
} catch { /* Deleted files keep the marker. */ }
|
|
51
|
+
entries.set(path, `${status}:${content}`);
|
|
52
|
+
}
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Paths whose status or content differ between two snapshots of one working tree. */
|
|
57
|
+
export function changed_between(before, after) {
|
|
58
|
+
const paths = new Set([...before.keys(), ...after.keys()]);
|
|
59
|
+
return [...paths].filter(path => before.get(path) !== after.get(path)).sort();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Write a non-secret task and a member-only credential file for one execution. */
|
|
63
|
+
export function free_task_files(config, member, execution, binding) {
|
|
64
|
+
const id = `free-${execution.id}`;
|
|
65
|
+
const draft_directory = config.draft_directory || `${config.directory}-drafts`;
|
|
66
|
+
mkdirSync(draft_directory, { recursive: true });
|
|
67
|
+
const access_path = join(config.directory, `${id}.access.json`);
|
|
68
|
+
const task_path = join(config.directory, `${id}.task.json`);
|
|
69
|
+
const profile = config.participants[member.name];
|
|
70
|
+
atomic_json(access_path, { site: config.site, room: config.room, token: config.members[member.name].token,
|
|
71
|
+
session_id: binding.session_id || '' });
|
|
72
|
+
const cli = process.platform === 'win32' && profile.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
|
|
73
|
+
const bound_access = ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
|
|
74
|
+
const access_arg = bound_access ? '' : ' --access-file 凭据路径';
|
|
75
|
+
const access_rule = bound_access ? '本次凭据文件已通过进程环境 DISCUSS_ACCESS_FILE 绑定,codiscuss 自动读取,命令无需 --access-file。' : '';
|
|
76
|
+
const project_rule = !profile.cwd ? '未指定项目目录。'
|
|
77
|
+
: profile.project_access === 'write' ? '用户允许按请求修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
|
|
78
|
+
const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
|
|
79
|
+
// The host routes unnamed messages and receives hand-backs; it may name members in its answer.
|
|
80
|
+
const discussion = config.mode !== 'free';
|
|
81
|
+
const host_rule = member.role === 'host' && !discussion
|
|
82
|
+
? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束,all 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 --to)。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),只点名一位成员时,其回答直接面向用户,你不会再被唤醒;同时点名多位时,全部结束后你会再被唤醒一次汇总。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
|
|
83
|
+
: '';
|
|
84
|
+
atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
|
|
85
|
+
execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
|
|
86
|
+
request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
|
|
87
|
+
read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
|
|
88
|
+
skills: [join(config.project, '.agents/skills/discuss-participant/SKILL.md')], cli, subcommands: TASK_SUBCOMMANDS,
|
|
89
|
+
...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }),
|
|
90
|
+
instructions: `${agy_command_rule(profile.adapter, cli)}${discussion ? '讨论房间:用户直接向你提问(' + messages + ')。这不是你的发言安排,不要领取发言权、不要安排他人,也不改变讨论进度,只回答这个提问。' : '自由协作房间:用户点名了你(' + messages + ')。'}${access_rule}先用 ${cli} message list ${config.room} --after ${execution.read_until}${access_arg} 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID${access_arg} 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
|
|
91
|
+
const prompt = `用户在${discussion ? '讨论' : '自由协作'}房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
|
|
92
|
+
return { id, participant: member.name, directory: config.directory, draft_directory,
|
|
93
|
+
skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
|
|
97
|
+
antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
|
|
98
|
+
|
|
99
|
+
/** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
|
|
100
|
+
function member_description(name, profile, work) {
|
|
101
|
+
const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
|
|
102
|
+
const access = profile.adapter === 'api' ? '' : !profile.cwd ? ',未指定项目目录'
|
|
103
|
+
: profile.project_access === 'write' ? `,可修改项目 ${project} 的文件` : `,项目 ${project} 只读`;
|
|
104
|
+
return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access}${work})`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
|
|
108
|
+
function member_work(requests, name, message_id) {
|
|
109
|
+
const open = requests.filter(item => item.member === name && item.message_id !== message_id);
|
|
110
|
+
const ids = status => [...new Set(open.filter(item => item.status === status).map(item => `#${item.message_id}`))].join('、');
|
|
111
|
+
return [ids('delivering') && `;正在处理 ${ids('delivering')}`, ids('pending') && `;待处理 ${ids('pending')}`].filter(Boolean).join('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Why a message stayed with the host: the scheduler chose the host, or leaned elsewhere without enough certainty. */
|
|
115
|
+
function route_host_label(decision, host, members) {
|
|
116
|
+
if (decision.choice === '__host__' || decision.confidence === null) return `交给主持人 ${host}`;
|
|
117
|
+
const lean = decision.choice === '__all__' ? '全体各自回应' : decision.choice === '__none__' ? '无需回应'
|
|
118
|
+
: members.some(member => member.name === decision.choice) ? `请 ${decision.choice} 回答` : '';
|
|
119
|
+
return lean ? `倾向${lean},把握不足,交给主持人 ${host}` : `交给主持人 ${host}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Delivers free-room requests through the same software adapters as discussions. */
|
|
123
|
+
export class FreeRoomRunner {
|
|
124
|
+
constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch) {
|
|
125
|
+
this.config = config;
|
|
126
|
+
this.store = store;
|
|
127
|
+
this.deliver = deliver;
|
|
128
|
+
this.log = log;
|
|
129
|
+
this.fetch = fetch_impl;
|
|
130
|
+
this.routed = new Set();
|
|
131
|
+
// Group requests (@全体成员) being arranged; a failed arrangement leaves the set so the next tick retries.
|
|
132
|
+
this.grouped = new Set();
|
|
133
|
+
this.decisions = new Set();
|
|
134
|
+
// Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
|
|
135
|
+
this.routing = new Set();
|
|
136
|
+
this.children = new Set();
|
|
137
|
+
this.members = new Map();
|
|
138
|
+
// Execution id -> why its session ended on its own, such as a command refused in unattended mode ('' when unknown).
|
|
139
|
+
this.exited = new Map();
|
|
140
|
+
this.snapshots = new Map();
|
|
141
|
+
// Execution id -> { phase, at, sent_phase, sent_at, notes, next_seq }: what the member's software last showed it doing,
|
|
142
|
+
// and the progress notes not yet on the website.
|
|
143
|
+
this.activity = new Map();
|
|
144
|
+
this.context_errors = new Map();
|
|
145
|
+
this.adopt(config);
|
|
146
|
+
this.store.db.exec(`CREATE TABLE IF NOT EXISTS free_deliveries(member TEXT PRIMARY KEY, client_id TEXT NOT NULL,
|
|
147
|
+
execution_id INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
|
148
|
+
started INTEGER NOT NULL DEFAULT 0, last_renew INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '')`);
|
|
149
|
+
}
|
|
150
|
+
/** Read this member's current local delivery record. */
|
|
151
|
+
row(name) { return this.store.db.prepare('SELECT * FROM free_deliveries WHERE member=?').get(name); }
|
|
152
|
+
/** Persist one member's delivery state; the client id makes a lost reserve response recoverable. */
|
|
153
|
+
save(name, fields) {
|
|
154
|
+
const current = this.row(name) || { member: name, client_id: '', execution_id: 0, status: 'idle', attempts: 0, started: 0, last_renew: 0, error: '' };
|
|
155
|
+
const next = { ...current, ...fields };
|
|
156
|
+
this.store.db.prepare(`INSERT INTO free_deliveries VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(member) DO UPDATE SET
|
|
157
|
+
client_id=excluded.client_id,execution_id=excluded.execution_id,status=excluded.status,attempts=excluded.attempts,
|
|
158
|
+
started=excluded.started,last_renew=excluded.last_renew,error=excluded.error`)
|
|
159
|
+
.run(name, next.client_id, next.execution_id, next.status, next.attempts, next.started, next.last_renew, next.error);
|
|
160
|
+
return next;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Take a changed configuration while running: new members are added, but a member whose
|
|
164
|
+
* project, cwd or bound session changed is blocked instead of silently reusing its conversation.
|
|
165
|
+
*/
|
|
166
|
+
adopt(config) {
|
|
167
|
+
for (const [name, item] of Object.entries(config.participants)) {
|
|
168
|
+
const context = JSON.stringify([item.adapter, item.project_id || '', item.project_name || '', cwd_key(item.cwd), cwd_key(item.workdir), item.session_id || '']);
|
|
169
|
+
const key = `member-context:${name}`;
|
|
170
|
+
const previous = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
|
|
171
|
+
if (previous && !this.same_context(name, previous, context)) this.context_errors.set(name, `${name} 已绑定会话的项目、cwd 或接入方式发生变化;请移除后重新邀请`);
|
|
172
|
+
else this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, context);
|
|
173
|
+
}
|
|
174
|
+
this.config = { ...this.config, ...config, participants: config.participants, members: config.members };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A Codex member switched between App and CLI keeps its conversation: the two entries count as one, and the App's
|
|
178
|
+
* session may be filled in from the conversation this member is already bound to.
|
|
179
|
+
*/
|
|
180
|
+
same_context(name, previous, current) {
|
|
181
|
+
const [before, after] = [JSON.parse(previous), JSON.parse(current)];
|
|
182
|
+
for (const context of [before, after]) if (CODEX_ADAPTERS.includes(context[0])) context[0] = 'codex';
|
|
183
|
+
const bound = this.store.binding(name).session_id;
|
|
184
|
+
if (before[5] !== after[5] && [before[5], after[5]].includes('') && [before[5], after[5]].includes(bound)) after[5] = before[5];
|
|
185
|
+
return JSON.stringify(before) === JSON.stringify(after);
|
|
186
|
+
}
|
|
187
|
+
/** One bounded iteration: reconcile live executions, then deliver idle members with open requests. */
|
|
188
|
+
async tick() {
|
|
189
|
+
const inbox = await api(this.config, '/inbox');
|
|
190
|
+
const room = await api(this.config, '');
|
|
191
|
+
// Smart scheduling runs beside delivery: the host waits for its answer (or failure) before starting, see `routing`.
|
|
192
|
+
if (this.config.scheduler && !inbox.archived) this.recommend_unnamed(inbox, room);
|
|
193
|
+
if (!inbox.archived) this.arrange_groups(inbox, room);
|
|
194
|
+
this.room_title = room.title;
|
|
195
|
+
const active = new Map(inbox.executions.map(item => [item.id, item]));
|
|
196
|
+
const present = new Set(inbox.members.map(member => member.name));
|
|
197
|
+
// A member sent away or removed no longer appears; its live work was already voided on the website.
|
|
198
|
+
for (const row of this.store.db.prepare('SELECT * FROM free_deliveries').all()) {
|
|
199
|
+
if (LIVE.includes(row.status) && !present.has(row.member)) { this.stop_member(row.member); this.save(row.member, { status: 'idle' }); }
|
|
200
|
+
}
|
|
201
|
+
await this.report_finished(active);
|
|
202
|
+
// Start in the room's speaking order: execution ids follow it, and the website lays out working cards by them.
|
|
203
|
+
const rank = (room.speaking_order?.items || []).map(item => item.name);
|
|
204
|
+
const place = name => rank.includes(name) ? rank.indexOf(name) : rank.length;
|
|
205
|
+
for (const member of [...inbox.members].sort((left, right) => place(left.name) - place(right.name))) {
|
|
206
|
+
const row = this.row(member.name);
|
|
207
|
+
if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active, inbox.execution_minutes);
|
|
208
|
+
else if (!inbox.archived && member.pending.some(request => request.purpose !== 'group') && !this.members.get(member.name)?.size
|
|
209
|
+
&& !member.pending.some(request => this.routing.has(request.id))) await this.start(member, row);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Smart scheduling: for each new unnamed message routed to the host, ask the provider whether another woken
|
|
214
|
+
* member suits it better and apply a confident pick. The host waits for the answer (or its failure) before starting.
|
|
215
|
+
*/
|
|
216
|
+
recommend_unnamed(inbox, room) {
|
|
217
|
+
// Manual and online members are listed apart; they are never delivered to but may host or be recommended.
|
|
218
|
+
const everyone = [...inbox.members, ...(inbox.observers || [])];
|
|
219
|
+
const host = everyone.find(member => member.role === 'host');
|
|
220
|
+
const waiting = host?.pending.filter(request => request.purpose === 'route' && !this.routed.has(request.id)) || [];
|
|
221
|
+
for (const request of waiting) {
|
|
222
|
+
this.routed.add(request.id);
|
|
223
|
+
if (everyone.length < 2) continue;
|
|
224
|
+
this.routing.add(request.id);
|
|
225
|
+
const decision = this.mark_routing(request.message_id, true)
|
|
226
|
+
.then(() => this.recommend(request, room, everyone))
|
|
227
|
+
.catch(error => this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`))
|
|
228
|
+
.finally(() => { this.routing.delete(request.id); return this.mark_routing(request.message_id, false); });
|
|
229
|
+
this.decisions.add(decision);
|
|
230
|
+
decision.finally(() => this.decisions.delete(decision));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** For each message naming everyone, have them answer at once, or one after another when smart scheduling says so. */
|
|
234
|
+
arrange_groups(inbox, room) {
|
|
235
|
+
const everyone = [...inbox.members, ...(inbox.observers || [])];
|
|
236
|
+
for (const request of everyone.flatMap(member => member.pending).filter(item => item.purpose === 'group' && !this.grouped.has(item.id))) {
|
|
237
|
+
this.grouped.add(request.id);
|
|
238
|
+
const arranged = (this.config.scheduler ? this.mark_routing(request.message_id, true).then(() => this.order_group(request, room, everyone))
|
|
239
|
+
.finally(() => this.mark_routing(request.message_id, false)) : this.apply_group(request.message_id, { action: 'all' }))
|
|
240
|
+
.catch(error => { this.grouped.delete(request.id); this.log(`全体成员 #${request.message_id} 未能安排:${error.message}`); });
|
|
241
|
+
this.decisions.add(arranged);
|
|
242
|
+
arranged.finally(() => this.decisions.delete(arranged));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Ask the scheduler how everyone answers; a failed call still has them answer at once. */
|
|
246
|
+
async order_group(request, room, everyone) {
|
|
247
|
+
const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
|
|
248
|
+
const message = page.messages.find(item => item.id === request.message_id);
|
|
249
|
+
const members = (room.speaking_order?.items || []).map(item => item.name).filter(name => everyone.some(member => member.name === name));
|
|
250
|
+
const decision = await decide_group_order(this.config.api_home, this.config.scheduler,
|
|
251
|
+
{ title: room.title, goal: room.goal, context: page.messages, message, members: members.length ? members : everyone.map(member => member.name), profiles: room.descriptions }, this.fetch)
|
|
252
|
+
.catch(error => { this.log(`智能调度 #${request.message_id} 未判断全体回答方式:${error.message}`); return { action: 'all' }; });
|
|
253
|
+
await this.apply_group(request.message_id, decision);
|
|
254
|
+
if (decision.detail) {
|
|
255
|
+
const label = decision.action === 'sequential' ? '请全体按发言顺序依次回答' : '请全体同时回答';
|
|
256
|
+
await this.record_decision(request.message_id, decision, label, true);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Replace the host's group request with everyone's requests; a message already arranged elsewhere is left as is. */
|
|
260
|
+
async apply_group(message_id, decision) {
|
|
261
|
+
this.log(`全体成员 #${message_id}:${decision.action === 'sequential' ? '依次回答' : '同时回答'}`);
|
|
262
|
+
await api(this.config, `/recommendations/${message_id}`, { action: decision.action }).catch(error => {
|
|
263
|
+
if (!/HTTP (404|409)/.test(error.message)) throw error;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/** Tell the website a message is being analysed so it can show the card; a failed mark never blocks routing. */
|
|
267
|
+
async mark_routing(message_id, active) {
|
|
268
|
+
try { await api(this.config, '/scheduler-routing', { message_id, active }); }
|
|
269
|
+
catch (error) { this.log(`智能调度 #${message_id} 分析状态未上报:${error.message}`); }
|
|
270
|
+
}
|
|
271
|
+
/** Describe each member as the scheduler sees it: software, project access and the other messages it is on. */
|
|
272
|
+
route_members(everyone, room, message_id) {
|
|
273
|
+
const requests = (room.free?.requests || []).filter(item => ['pending', 'delivering'].includes(item.status));
|
|
274
|
+
return everyone.map(member => {
|
|
275
|
+
const work = member_work(requests, member.name, message_id);
|
|
276
|
+
const profile = this.config.participants[member.name];
|
|
277
|
+
const described = room.descriptions?.[member.name] ? `;用户描述:${room.descriptions[member.name]}` : '';
|
|
278
|
+
return { id: member.id, name: member.name, role: member.role, description: profile ? member_description(member.name, profile, `${work}${described}`)
|
|
279
|
+
: `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发${work}${described})` };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/** Decide one message and apply a confident pick: one member, everyone, or no one; otherwise the host keeps it. */
|
|
283
|
+
async recommend(request, room, everyone) {
|
|
284
|
+
const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
|
|
285
|
+
const message = page.messages.find(item => item.id === request.message_id);
|
|
286
|
+
if (!message) return;
|
|
287
|
+
const described = this.route_members(everyone, room, message.id);
|
|
288
|
+
const host = described.find(member => member.role === 'host');
|
|
289
|
+
const members = described.filter(member => member !== host);
|
|
290
|
+
const decision = await route_free_message(this.config.api_home, this.config.scheduler,
|
|
291
|
+
{ title: room.title, goal: room.goal, context: page.messages, message, host, members }, this.fetch);
|
|
292
|
+
this.log(`智能调度 #${message.id}:${decision.action}${decision.member ? ` ${decision.member}` : ''}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
|
|
293
|
+
if (decision.action === 'host') return this.record_decision(message.id, decision, route_host_label(decision, host.name, members), false);
|
|
294
|
+
if (decision.action !== 'member') {
|
|
295
|
+
// Everyone or no one replaces the host's request in one step; a host that already started keeps the message.
|
|
296
|
+
const applied = await api(this.config, `/recommendations/${message.id}`, { action: decision.action })
|
|
297
|
+
.then(() => true, error => { this.log(`智能调度 #${message.id} 未能改派:${error.message}`); return false; });
|
|
298
|
+
const names = described.map(member => member.name).join('、');
|
|
299
|
+
const label = decision.action === 'all' ? `请全体各自回应(${names})` : '无需回应,未唤醒任何成员';
|
|
300
|
+
return this.record_decision(message.id, decision, applied ? label : `${label}(未生效,仍由主持人处理)`, applied);
|
|
301
|
+
}
|
|
302
|
+
await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
|
|
303
|
+
provider: this.config.scheduler.provider, confidence: decision.confidence });
|
|
304
|
+
// A confident pick is applied at once, interrupting the host; if that fails the recommendation stays for the user to adopt.
|
|
305
|
+
const adopted = await api(this.config, `/recommendations/${message.id}`, { action: 'adopt', mode: 'interrupt' })
|
|
306
|
+
.then(() => true, error => { this.log(`智能调度 #${message.id} 自动改派失败,保留推荐:${error.message}`); return false; });
|
|
307
|
+
await this.record_decision(message.id, decision, adopted ? `改由 ${decision.member} 回答` : `建议让 ${decision.member} 回答(未能自动改派)`, adopted);
|
|
308
|
+
}
|
|
309
|
+
/** Show each judgment under its message so the user can rate it; a failed record never affects delivery. */
|
|
310
|
+
async record_decision(message_id, decision, label, applied) {
|
|
311
|
+
try {
|
|
312
|
+
await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
|
|
313
|
+
applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
|
|
314
|
+
context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
|
|
315
|
+
prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION } });
|
|
316
|
+
} catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
|
|
317
|
+
}
|
|
318
|
+
/** Reserve with a persisted client id before invoking any software. */
|
|
319
|
+
async start(member, row) {
|
|
320
|
+
const record = this.save(member.name, { client_id: randomUUID(), execution_id: 0, status: 'reserving', error: '',
|
|
321
|
+
attempts: row?.attempts || 0 });
|
|
322
|
+
await this.reserve(member, record);
|
|
323
|
+
}
|
|
324
|
+
/** A retried client id returns the original execution; a finished or refused one just goes idle. */
|
|
325
|
+
async reserve(member, record) {
|
|
326
|
+
let execution;
|
|
327
|
+
const profile = this.config.participants[member.name];
|
|
328
|
+
const cwd = profile?.adapter === 'api' ? '' : cwd_key(profile?.cwd || this.config.cwd);
|
|
329
|
+
try { execution = await api(this.config, '/executions', { member_id: member.id, client_id: record.client_id, cwd }); }
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (/HTTP (404|409)/.test(error.message)) return this.save(member.name, { status: 'idle', error: '' });
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
if (!['dispatched', 'started'].includes(execution.status)) return this.save(member.name, { status: 'idle' });
|
|
335
|
+
this.save(member.name, { execution_id: execution.id, status: 'delivering', started: Date.now(), last_renew: Date.now() });
|
|
336
|
+
await this.invoke(member, execution);
|
|
337
|
+
}
|
|
338
|
+
/** Hand the execution to the member's software; only proven non-delivery is released for retry. */
|
|
339
|
+
async invoke(member, execution) {
|
|
340
|
+
const adapter = this.config.participants[member.name];
|
|
341
|
+
if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
|
|
342
|
+
if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
|
|
343
|
+
const directory = adapter.adapter === 'api' ? undefined : adapter.cwd || this.config.cwd;
|
|
344
|
+
const before = workspace_snapshot(directory);
|
|
345
|
+
if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
|
|
346
|
+
const binding = this.store.binding(member.name);
|
|
347
|
+
if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
|
|
348
|
+
let invoked = false;
|
|
349
|
+
try {
|
|
350
|
+
const task = free_task_files(this.config, member, execution, binding);
|
|
351
|
+
invoked = true;
|
|
352
|
+
const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
|
|
353
|
+
api_context: { site: this.config.site, room: this.config.room, token: this.config.members[member.name].token,
|
|
354
|
+
home: this.config.api_home, mode: 'free', role: member.role || 'member', execution_id: execution.id,
|
|
355
|
+
requests: execution.requests, read_after: execution.read_until },
|
|
356
|
+
on_binding: session => this.store.bind(member.name, session),
|
|
357
|
+
on_process: child => this.track(member.name, child),
|
|
358
|
+
on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.touch(execution.id); },
|
|
359
|
+
on_activity: phase => this.touch(execution.id, phase),
|
|
360
|
+
on_note: text => this.note(execution.id, text),
|
|
361
|
+
// A stopped process may report after the next execution started; only its own execution takes the result.
|
|
362
|
+
on_failure: error => { try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ } },
|
|
363
|
+
on_exit: denial => this.exited.set(execution.id, denial || ''),
|
|
364
|
+
});
|
|
365
|
+
if (result.session_id) this.store.bind(member.name, result.session_id);
|
|
366
|
+
this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
|
|
367
|
+
} catch (error) {
|
|
368
|
+
const attempts = (this.row(member.name)?.attempts || 0) + 1;
|
|
369
|
+
if ((invoked && error.delivery_status !== 'not_sent') || attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
370
|
+
return this.fail(member.name, execution.id, `投递失败:${error.message}`);
|
|
371
|
+
}
|
|
372
|
+
await api(this.config, `/executions/${execution.id}`, { action: 'release', reason: `确认未送达:${error.message}` });
|
|
373
|
+
this.save(member.name, { status: 'idle', attempts, error: error.message });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** Follow the website's verdict; stop only this member's processes when its execution ends. */
|
|
377
|
+
async reconcile(member, row, active, minutes) {
|
|
378
|
+
if (row.status === 'reserving') return this.reserve(member, row);
|
|
379
|
+
if (row.status === 'sidecar_pending') {
|
|
380
|
+
const receipt = sidecar_receipt(this.config.participants[member.name], `free-${row.execution_id}`);
|
|
381
|
+
if (receipt?.status === 'accepted' && receipt.session_id) {
|
|
382
|
+
this.store.bind(member.name, receipt.session_id);
|
|
383
|
+
this.save(member.name, { status: 'running' });
|
|
384
|
+
} else if (receipt && active.has(row.execution_id)) return this.fail(member.name, row.execution_id, receipt.error || 'Sidecar 投递结果不明,请核对原会话');
|
|
385
|
+
}
|
|
386
|
+
const execution = active.get(row.execution_id);
|
|
387
|
+
if (!execution) {
|
|
388
|
+
this.activity.delete(row.execution_id);
|
|
389
|
+
this.stop_member(member.name);
|
|
390
|
+
return this.save(member.name, { status: 'idle', error: '' });
|
|
391
|
+
}
|
|
392
|
+
// A restart between persisting and invoking cannot prove non-delivery, so it never resends.
|
|
393
|
+
if (row.status === 'delivering') return this.fail(member.name, execution.id, '上次投递结果不明,未自动重发;请核对原会话后重新发送');
|
|
394
|
+
if (row.error) return this.fail(member.name, execution.id, row.error);
|
|
395
|
+
if (this.exited.has(execution.id)) {
|
|
396
|
+
const latest = await api(this.config, `/executions/${execution.id}`);
|
|
397
|
+
const denial = this.exited.get(execution.id);
|
|
398
|
+
if (['dispatched', 'started'].includes(latest.status)) return this.fail(member.name, execution.id, denial
|
|
399
|
+
? `${member.name} ${denial},未提交本次回答`
|
|
400
|
+
: `${member.name} 会话已结束,但未提交本次回答;请核对原会话确认是否已有内容`);
|
|
401
|
+
return this.save(member.name, { status: 'idle' });
|
|
402
|
+
}
|
|
403
|
+
if (Date.now() >= execution.created_at * 1000 + minutes * 60_000) return this.fail(member.name, execution.id, `超过单次执行时限 ${minutes} 分钟;请核对原会话确认是否已有内容`);
|
|
404
|
+
const activity = this.activity.get(execution.id);
|
|
405
|
+
const changed = activity && (activity.phase !== activity.sent_phase || activity.at !== activity.sent_at || activity.notes.length);
|
|
406
|
+
const elapsed = Date.now() - row.last_renew;
|
|
407
|
+
if (elapsed >= RENEW_MS || (changed && elapsed >= ACTIVITY_REPORT_MS)) {
|
|
408
|
+
const notes = activity?.notes.slice(0, MAX_NOTES_PER_RENEW) || [];
|
|
409
|
+
await api(this.config, `/executions/${execution.id}`, { action: 'renew',
|
|
410
|
+
...(activity ? { activity: activity.phase, activity_at: Math.floor(activity.at / 1000) } : {}), ...(notes.length ? { notes } : {}) });
|
|
411
|
+
if (activity) Object.assign(activity, { sent_phase: activity.phase, sent_at: activity.at, notes: activity.notes.slice(notes.length) });
|
|
412
|
+
this.save(member.name, { last_renew: Date.now() });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** Note that the software produced output now, and its phase when the output names one. */
|
|
416
|
+
touch(execution_id, phase) {
|
|
417
|
+
// Updated in place: a renewal in flight marks what it sent on this same record.
|
|
418
|
+
if (!this.activity.has(execution_id)) this.activity.set(execution_id, { phase: '', notes: [], next_seq: 0 });
|
|
419
|
+
const current = this.activity.get(execution_id);
|
|
420
|
+
if (phase) current.phase = phase;
|
|
421
|
+
current.at = Date.now();
|
|
422
|
+
}
|
|
423
|
+
/** Queue one progress note for the next renewal; numbering makes a retried renewal harmless, and notes past the cap are dropped. */
|
|
424
|
+
note(execution_id, text) {
|
|
425
|
+
this.touch(execution_id);
|
|
426
|
+
const current = this.activity.get(execution_id);
|
|
427
|
+
if (current.next_seq >= MAX_EXECUTION_NOTES) return;
|
|
428
|
+
current.next_seq += 1;
|
|
429
|
+
// Cut by UTF-16 units like the website's check, without leaving half of a surrogate pair.
|
|
430
|
+
current.notes.push({ seq: current.next_seq, text: text.slice(0, MAX_NOTE_LENGTH).replace(/[\uD800-\uDBFF]$/, '') });
|
|
431
|
+
}
|
|
432
|
+
/** Report observed file changes once an execution has ended and its member's processes have exited. */
|
|
433
|
+
async report_finished(active) {
|
|
434
|
+
for (const [execution_id, snapshot] of this.snapshots) {
|
|
435
|
+
if (active.has(execution_id) || this.members.get(snapshot.member)?.size) continue;
|
|
436
|
+
this.snapshots.delete(execution_id);
|
|
437
|
+
const after = workspace_snapshot(snapshot.directory);
|
|
438
|
+
if (!after) continue;
|
|
439
|
+
const changes = changed_between(snapshot.before, after);
|
|
440
|
+
try { await api(this.config, `/executions/${execution_id}`, { action: 'report', changes: changes.slice(0, 200) }); }
|
|
441
|
+
catch (error) { if (!/HTTP (404|409)/.test(error.message)) throw error; }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** Record the cause on the website, then stop this member's own processes. */
|
|
445
|
+
async fail(name, execution_id, reason) {
|
|
446
|
+
this.activity.delete(execution_id);
|
|
447
|
+
try { await api(this.config, `/executions/${execution_id}`, { action: 'fail', reason }); }
|
|
448
|
+
catch (error) { if (!/HTTP 409/.test(error.message)) throw error; }
|
|
449
|
+
this.save(name, { status: 'idle', error: reason });
|
|
450
|
+
this.stop_member(name);
|
|
451
|
+
}
|
|
452
|
+
/** Count live processes per member so one session never has two writers. */
|
|
453
|
+
track(name, child) {
|
|
454
|
+
this.children.add(child);
|
|
455
|
+
if (!this.members.has(name)) this.members.set(name, new Set());
|
|
456
|
+
this.members.get(name).add(child);
|
|
457
|
+
child.once('close', () => { this.children.delete(child); this.members.get(name).delete(child); });
|
|
458
|
+
}
|
|
459
|
+
/** Stop one member's processes without touching other members or desktop apps. */
|
|
460
|
+
stop_member(name) { for (const child of this.members.get(name) || []) child.kill(); }
|
|
461
|
+
/** Stop every process this runner created. */
|
|
462
|
+
stop_children() { for (const child of this.children) child.kill(); }
|
|
463
|
+
}
|