ai_co_discuss 0.3.39 → 0.3.40

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.
@@ -1,890 +1,907 @@
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 { adapter_of, BRANCHABLE_ADAPTERS, branch_session, capabilities_of, cleanup_room, deliver_task, delivery_receipt, session_exists, stored_delivery } from './adapters/index.mjs';
7
- import { api, read_rule } from './discussion_runner.mjs';
8
- import { SegmentLog } from './execution_progress.mjs';
9
- import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
10
- import { atomic_json } from './files.mjs';
11
- import { SessionDriver } from './session_driver.mjs';
12
- import { DEFAULT_SCHEDULER_MODE, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version, scheduler_thresholds, some_members } from './scheduler.mjs';
13
-
14
- const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
15
- const RENEW_MS = 30_000;
16
- /** A changed phase is reported early, but no more often than this, since each report refreshes open pages. */
17
- const ACTIVITY_REPORT_MS = 10_000;
18
- /** Segments and progress notes are reported quietly, waking no page, so they may go every tick. */
19
- const PROGRESS_REPORT_MS = 2_000;
20
- const MAX_DELIVERY_ATTEMPTS = 3;
21
- // A manual member's session that refuses a delivery is retried in the same execution, after a wait the owner can cut short.
22
- const MAX_DELIVERY_RETRIES = 3;
23
- const RETRY_DELAY_SECONDS = 60;
24
- const MAX_RETRY_REASON = 500;
25
- const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running', 'retry_wait'];
26
- const MAX_HASHED_BYTES = 5 * 1024 * 1024;
27
- const GIT_TIMEOUT_MS = 15_000;
28
-
29
- /** Fingerprint configurations whose adapter can enforce project read-only access; no local paths leave this function. */
30
- export function review_capability_version(profile, member, config) {
31
- if (!profile || profile.project_access !== 'read' || member.access !== 'runner' || member.session_mode !== 'new'
32
- || !capabilities_of(profile.adapter).enforces_read_only || profile.adapter !== member.adapter
33
- || !profile.cwd || profile.allowed_tools?.length) return '';
34
- const project = cwd_key(profile.cwd), drafts = cwd_key(config.draft_directory || `${config.directory}-drafts`);
35
- if (drafts === project || drafts.startsWith(`${project}/`)) return '';
36
- const facts = [1, profile.adapter, profile.executable || '', cwd_key(profile.cwd), cwd_key(profile.workdir),
37
- profile.project_id || '', profile.project_name || '', profile.session_id || '', drafts,
38
- cwd_key(config.directory)];
39
- return createHash('sha256').update(JSON.stringify(facts)).digest('hex');
40
- }
41
-
42
- /** Identify a working directory consistently, so executions in the same cwd can be compared. */
43
- export function cwd_key(directory) {
44
- if (!directory) return '';
45
- const path = resolve(directory).replaceAll('\\', '/');
46
- return process.platform === 'win32' ? path.toLowerCase() : path;
47
- }
48
-
49
- /**
50
- * Record each changed or untracked file's Git status and content hash in a Git working tree.
51
- * Returns null outside Git: free rooms then provide no automatic overlap detection.
52
- */
53
- export function workspace_snapshot(directory) {
54
- if (!directory) return null;
55
- const result = spawnSync('git', ['-C', directory, 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
56
- { encoding: 'utf8', windowsHide: true, timeout: GIT_TIMEOUT_MS });
57
- if (result.status !== 0) return null;
58
- const entries = new Map();
59
- const fields = result.stdout.split('\0').filter(Boolean);
60
- for (let index = 0; index < fields.length; index++) {
61
- const status = fields[index].slice(0, 2), path = fields[index].slice(3);
62
- if (status[0] === 'R' || status[0] === 'C') index++; // The original path of a rename follows.
63
- let content = 'missing';
64
- try {
65
- const file = join(directory, path);
66
- const size = statSync(file).size;
67
- content = size > MAX_HASHED_BYTES ? `size:${size}` : createHash('sha256').update(readFileSync(file)).digest('hex');
68
- } catch { /* Deleted files keep the marker. */ }
69
- entries.set(path, `${status}:${content}`);
70
- }
71
- return entries;
72
- }
73
-
74
- /** Paths whose status or content differ between two snapshots of one working tree. */
75
- export function changed_between(before, after) {
76
- const paths = new Set([...before.keys(), ...after.keys()]);
77
- return [...paths].filter(path => before.get(path) !== after.get(path)).sort();
78
- }
79
-
80
- /** Write a non-secret task and a member-only credential file for one execution. */
81
- export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], stopped = [], process_access = true } = {}) {
82
- const id = `free-${execution.id}`;
83
- const draft_directory = config.draft_directory || `${config.directory}-drafts`;
84
- mkdirSync(draft_directory, { recursive: true });
85
- const access_path = join(config.directory, `${id}.access.json`);
86
- const task_path = join(config.directory, `${id}.task.json`);
87
- const profile = config.participants[member.name];
88
- atomic_json(access_path, { site: config.site, room: config.room, member: member.name, token: config.members[member.name].token,
89
- session_id: binding.session_id || '', execution_id: execution.id });
90
- const adapter = adapter_of(profile.adapter);
91
- const cli = process.platform === 'win32' ? adapter?.windows_command || 'codiscuss.cmd' : 'codiscuss';
92
- const inbox = Boolean(adapter?.into_window?.(profile, binding));
93
- const bound_access = process_access && !inbox && Boolean(adapter?.capabilities.access_env);
94
- const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
95
- const requests = execution.requests || [];
96
- const discussion_request = requests.find(item => item.session_request_id && ['discuss', 'discuss_host'].includes(item.purpose)) || null;
97
- const direct_question = Boolean(session?.status === 'active' && requests.some(item => item.purpose === 'ask'));
98
- const { instructions, prompt } = free_task_text({ name: member.label || member.name, role: member.role, discussion: direct_question,
99
- discussion_request, profile, cli,
100
- room: config.room, messages, read_after: execution.read_until, execution_id: execution.id, task_path,
101
- access_file: bound_access ? '' : access_path, read_rule: read_rule(profile.adapter), inbox });
102
- const skills = discussion_request || direct_question
103
- ? ['discuss-participant/SKILL.md', 'discuss-participant/references/discussion.md',
104
- ...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/discussion.md'] : [],
105
- ...direct_question ? ['discuss-participant/references/free.md'] : []]
106
- : ['discuss-participant/SKILL.md', 'discuss-participant/references/free.md',
107
- ...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/free.md'] : []];
108
- atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
109
- execution_id: execution.id, requests, request_messages: execution.requests.map(item => item.message_id),
110
- request_purposes: execution.requests.map(item => item.purpose || 'ask'),
111
- ...(discussion_request ? { discussion_request } : {}),
112
- ...(session ? { session: { id: session.id, status: session.status, phase: session.phase,
113
- goal: session.goal, named_followup: session.named_followup } } : {}),
114
- ...(suggestions.length ? { scheduler_suggestions: suggestions } : {}),
115
- ...(reviews.length ? { review_handoffs: reviews } : {}),
116
- ...(stopped.length ? { stopped_executions: stopped } : {}),
117
- role: member.role || 'member',
118
- read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
119
- skills: skills.map(file => join(config.project, '.agents/skills', file)),
120
- cli, subcommands: TASK_SUBCOMMANDS,
121
- ...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }), instructions });
122
- return { id, participant: member.name, directory: config.directory, draft_directory,
123
- skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile), inbox };
124
- }
125
-
126
-
127
- /** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
128
- function member_description(name, profile, work) {
129
- const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
130
- const access = !capabilities_of(profile.adapter).project ? ',只能读写房间消息' : profile.project_access === 'own' ? ',权限沿用其会话设置' : !profile.cwd ? ',未指定项目目录'
131
- : profile.project_access === 'read' ? `,项目 ${project} 只读`
132
- : `,可修改项目 ${project} 的文件${capabilities_of(profile.adapter).file_tools ? '(不能运行命令)' : '并运行命令'}`;
133
- return `${name}(${adapter_of(profile.adapter)?.label || profile.adapter}${access}${work})`;
134
- }
135
-
136
- /** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
137
- function member_work(requests, name, message_id) {
138
- const open = requests.filter(item => item.member === name && item.message_id !== message_id);
139
- const ids = status => [...new Set(open.filter(item => item.status === status).map(item => `#${item.message_id}`))].join('、');
140
- return [ids('delivering') && `;正在处理 ${ids('delivering')}`, ids('pending') && `;待处理 ${ids('pending')}`].filter(Boolean).join('');
141
- }
142
-
143
- /** Why a message stayed with the host: the scheduler chose the host, or leaned elsewhere without enough certainty. */
144
- function route_host_label(decision, host, members) {
145
- if (decision.choice === '__host__' || decision.confidence === null) return `交给主持人 ${host}`;
146
- const group = some_members(decision.choice);
147
- const lean = decision.choice === '__all__' ? '全体各自回应' : decision.choice === '__all_seq__' ? '全体依次回应' : decision.choice === '__none__' ? '无需回应' : group ? `请 ${group.join('、')} 各自回应`
148
- : members.some(member => member.name === decision.choice) ? `请 ${decision.choice} 回答` : '';
149
- return lean ? `倾向${lean},把握不足,交给主持人 ${host}` : `交给主持人 ${host}`;
150
- }
151
-
152
- /** Delivers free-room requests through the same software adapters as discussions. */
153
- export class FreeRoomRunner {
154
- constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch, session_driver = null) {
155
- this.config = config;
156
- this.store = store;
157
- this.deliver = deliver;
158
- this.log = log;
159
- this.fetch = fetch_impl;
160
- this.session_driver = session_driver || new SessionDriver(config, log, fetch_impl, store);
161
- this.routed = new Set();
162
- // Group requests (@全体成员) being arranged; a failed arrangement leaves the set so the next tick retries.
163
- this.grouped = new Set();
164
- this.decisions = new Set();
165
- // Called when a scheduling decision settles; the service checks the room at once instead of waiting for the next poll.
166
- this.on_decided = () => {};
167
- // Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
168
- this.routing = new Set();
169
- this.children = new Set();
170
- this.members = new Map();
171
- // An empty set proves every tracked handle for this execution closed; a missing set proves nothing after restart.
172
- this.execution_processes = new Map();
173
- // Execution id -> why its session ended on its own, such as a command refused in unattended mode ('' when unknown).
174
- this.exited = new Map();
175
- // Executions whose software failed before any turn ran, as its adapter reported; safe to deliver again.
176
- this.not_started = new Set();
177
- this.snapshots = new Map();
178
- // Execution id -> { phase, at, sent_phase, sent_at }: what the member's software last showed it doing.
179
- this.activity = new Map();
180
- this.segments = new SegmentLog();
181
- this.context_errors = new Map();
182
- this.adopt(config);
183
- this.store.db.exec(`CREATE TABLE IF NOT EXISTS free_deliveries(member TEXT PRIMARY KEY, client_id TEXT NOT NULL,
184
- execution_id INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
185
- started INTEGER NOT NULL DEFAULT 0, last_renew INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '')`);
186
- }
187
- /** Read this member's current local delivery record. */
188
- row(name) { return this.store.db.prepare('SELECT * FROM free_deliveries WHERE member=?').get(name); }
189
- /** Persist one member's delivery state; the client id makes a lost reserve response recoverable. */
190
- save(name, fields) {
191
- const current = this.row(name) || { member: name, client_id: '', execution_id: 0, status: 'idle', attempts: 0, started: 0, last_renew: 0, error: '' };
192
- const next = { ...current, ...fields };
193
- this.store.db.prepare(`INSERT INTO free_deliveries VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(member) DO UPDATE SET
194
- client_id=excluded.client_id,execution_id=excluded.execution_id,status=excluded.status,attempts=excluded.attempts,
195
- started=excluded.started,last_renew=excluded.last_renew,error=excluded.error`)
196
- .run(name, next.client_id, next.execution_id, next.status, next.attempts, next.started, next.last_renew, next.error);
197
- return next;
198
- }
199
- /**
200
- * Take a changed configuration while running: new members are added, but a member whose
201
- * project, cwd or bound session changed is blocked instead of silently reusing its conversation.
202
- */
203
- adopt(config) {
204
- for (const [name, item] of Object.entries(config.participants)) {
205
- const context = JSON.stringify([item.adapter, item.project_id || '', item.project_name || '', cwd_key(item.cwd), cwd_key(item.workdir), item.session_id || '']);
206
- const key = `member-context:${name}`;
207
- const previous = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
208
- if (previous && !this.same_context(name, previous, context)) this.context_errors.set(name, `${name} 已绑定会话的项目、cwd 或接入方式发生变化;请移除后重新邀请`);
209
- else this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, context);
210
- }
211
- this.config = { ...this.config, ...config, participants: config.participants, members: config.members };
212
- this.session_driver.config = this.config;
213
- }
214
- /**
215
- * A Codex member switched between App and CLI keeps its conversation: the two entries count as one, and the App's
216
- * session may be filled in from the conversation this member is already bound to.
217
- */
218
- same_context(name, previous, current) {
219
- const [before, after] = [JSON.parse(previous), JSON.parse(current)];
220
- for (const context of [before, after]) context[0] = adapter_of(context[0])?.session_family || context[0];
221
- const bound = this.store.binding(name).session_id;
222
- if (before[5] !== after[5] && [before[5], after[5]].includes('') && [before[5], after[5]].includes(bound)) after[5] = before[5];
223
- return JSON.stringify(before) === JSON.stringify(after);
224
- }
225
- /** One bounded iteration: reconcile live executions, then deliver idle members with open requests. */
226
- async tick() {
227
- let inbox; let room;
228
- // Read one after another: Node's fetch keeps one warm connection per site, and a second parallel request would pay a new
229
- // TLS handshake (measured 2 s against 0.4 s through a proxy to a hosted site).
230
- try {
231
- inbox = await api(this.config, '/inbox');
232
- room = await api(this.config, '');
233
- }
234
- catch (error) {
235
- if (error.message === 'HTTP 404: 房间不存在') cleanup_room(this.config.directory);
236
- throw error;
237
- }
238
- if (!inbox.archived) await this.sync_review_capabilities(inbox);
239
- if (!inbox.archived) await this.sync_member_capabilities(inbox);
240
- if (!inbox.archived && room.session?.status === 'active'
241
- && await this.session_driver.tick(room, inbox)) {
242
- // A scheduled plan completes the pending host request and creates a speaker request.
243
- inbox = await api(this.config, '/inbox');
244
- room = await api(this.config, '');
245
- }
246
- // Smart scheduling runs beside delivery: the host waits for its answer (or failure) before starting, see `routing`.
247
- if (this.config.scheduler && !inbox.archived) this.recommend_unnamed(inbox, room);
248
- if (this.config.scheduler && !inbox.archived) this.schedule_reviews(inbox);
249
- if (!inbox.archived) this.arrange_groups(inbox, room);
250
- this.room_title = room.title;
251
- this.room = room;
252
- this.inbox = inbox;
253
- const active = new Map(inbox.executions.map(item => [item.id, item]));
254
- const present = new Set(inbox.members.map(member => member.name));
255
- // A member sent away or removed no longer appears; its live work was already voided on the website.
256
- for (const row of this.store.db.prepare('SELECT * FROM free_deliveries').all()) {
257
- if (LIVE.includes(row.status) && !present.has(row.member)) { this.stop_member(row.member); this.save(row.member, { status: 'idle' }); }
258
- }
259
- await this.report_finished(active);
260
- const pending_stops = await this.report_stops(active);
261
- // Start in the room's speaking order: execution ids follow it, and the website lays out working cards by them.
262
- const rank = (room.speaking_order?.items || []).map(item => item.name);
263
- const place = name => rank.includes(name) ? rank.indexOf(name) : rank.length;
264
- for (const member of [...inbox.members].sort((left, right) => place(left.name) - place(right.name))) {
265
- const row = this.row(member.name);
266
- if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active);
267
- // A member voided in a window or App the runner could not stop waits until that turn ends (`held_until`).
268
- else if (!inbox.archived && member.pending.some(request => request.purpose !== 'group') && !this.members.get(member.name)?.size
269
- && !pending_stops.has(member.name)
270
- && !member.pending.some(request => this.routing.has(request.id)) && !(member.held_until * 1000 > Date.now())) await this.start(member, row);
271
- }
272
- }
273
- /** Publish only changed local facts, using member revisions supplied by the owner's inbox. */
274
- async sync_review_capabilities(inbox) {
275
- for (const member of inbox.members) {
276
- const current = member.review_capability;
277
- if (!current) continue; // Older sites have no review protocol.
278
- const version = this.context_errors.has(member.name) ? ''
279
- : review_capability_version(this.config.participants[member.name], member, this.config);
280
- if ((current.capability?.config_version || '') === version) continue;
281
- try {
282
- member.review_capability = await api(this.config, `/members/${member.id}/review-capability`, {
283
- revision: current.revision, read_only: Boolean(version), ...(version ? { config_version: version } : {}),
284
- });
285
- } catch (error) { this.log(`只读评审能力未同步:${member.name}:${error.message}`); }
286
- }
287
- }
288
- /** Tell the site what a rewind does to each member's session, as its adapter says; only a change is sent. */
289
- async sync_member_capabilities(inbox) {
290
- for (const member of inbox.members) {
291
- const profile = this.config.participants[member.name];
292
- // An older site lists no effect and takes no report.
293
- if (member.rewind === undefined || !profile) continue;
294
- const rewind = capabilities_of(profile.adapter).rewind;
295
- if (member.rewind === rewind) continue;
296
- try { await api(this.config, `/members/${member.id}/capabilities`, { rewind }); member.rewind = rewind; }
297
- catch (error) { this.log(`成员会话能力未同步:${member.name}:${error.message}`); }
298
- }
299
- }
300
- /** Hold only pending review handoffs while their preauthorized next step is judged. */
301
- schedule_reviews(inbox) {
302
- const host = inbox.members.find(member => member.role === 'host');
303
- if (!host || !this.config.members[host.name]?.token || inbox.executions.some(item => item.member_id === host.id)
304
- || LIVE.includes(this.row(host.name)?.status)) return;
305
- for (const request of host.pending.filter(item => item.review?.phase === 'handoff')) {
306
- const saved = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`review-decision:${request.id}`)?.value;
307
- if (this.routed.has(request.id) || (saved && JSON.parse(saved).done)) continue;
308
- this.routed.add(request.id);
309
- this.routing.add(request.id);
310
- const pending = this.mark_routing(request.message_id, true).then(() => this.judge_review(host, request))
311
- .catch(error => {
312
- this.save_suggestion(request, { action: 'host', actual_action: 'host', applied: false,
313
- model_judgment: false, reason: `没有模型判断:${error.message}` });
314
- this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`review-decision:${request.id}`, JSON.stringify({ done: true }));
315
- this.log(`评审交接 #${request.message_id} 交给主持人:${error.message}`);
316
- }).finally(() => { this.routing.delete(request.id); return this.mark_routing(request.message_id, false); });
317
- this.decisions.add(pending);
318
- pending.finally(() => { this.decisions.delete(pending); this.on_decided(); });
319
- }
320
- }
321
- /** Persist the complete judgment and submission before any write, so restart recovery cannot double-dispatch. */
322
- async judge_review(host, request) {
323
- const key = `review-decision:${request.id}`;
324
- const cached = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
325
- let job = cached ? JSON.parse(cached) : null;
326
- if (!job) {
327
- const ready = await api(this.config, `/reviews/${request.review.batch_id}/ready`);
328
- const decision = await decide_review_completion(this.config.api_home, this.config.scheduler, ready, this.fetch);
329
- job = { decision, revision: ready.revision, submission_id: randomUUID(), profiles: JSON.stringify(this.config.participants) };
330
- this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(key, JSON.stringify(job));
331
- }
332
- const { decision } = job;
333
- this.save_suggestion(request, { action: decision.action, actual_action: 'unverified', applied: false,
334
- model_judgment: Boolean(decision.detail), reason: '自动评审交接结果尚未核验' });
335
- const outcome = decision.action === 'review' ? await this.apply_review(host, request, job)
336
- : { applied: false, actual_action: 'host', reason: decision.label };
337
- const label = outcome.applied ? decision.label : outcome.reason;
338
- const record = decision.detail ? await this.record_decision(request.message_id, decision, label, outcome.applied,
339
- { scene: 'review', prompt: REVIEW_PROMPT_VERSION, threshold: REVIEW_CONFIDENCE }) : null;
340
- this.save_suggestion(request, { action: decision.action, actual_action: outcome.actual_action, applied: outcome.applied,
341
- model_judgment: Boolean(decision.detail), ...(record?.id ? { decision_id: record.id } : {}), reason: label });
342
- this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(key, JSON.stringify({ ...job, done: true }));
343
- }
344
- /** Advance only the judged revision; inspect an uncertain write using its persisted submission id. */
345
- async apply_review(host, request, job) {
346
- if (job.profiles !== JSON.stringify(this.config.participants)) return { applied: false, actual_action: 'host', reason: '本机成员配置已变化,未自动交评审' };
347
- try {
348
- await api({ ...this.config, token: this.config.members[host.name].token }, '/reviews/advance', {
349
- actor: host.name, batch_id: request.review.batch_id, revision: job.revision, submission_id: job.submission_id,
350
- });
351
- return { applied: true, actual_action: 'review' };
352
- } catch (error) {
353
- try {
354
- const state = await api(this.config, `/reviews/${request.review.batch_id}/ready`);
355
- if (state.plan.advance_submission === job.submission_id) return { applied: true, actual_action: 'review' };
356
- return { applied: false, actual_action: 'host', reason: `未自动交评审:${error.message}` };
357
- } catch { return { applied: false, actual_action: 'unverified', reason: `评审交接写入结果未核验:${error.message}` }; }
358
- }
359
- }
360
- /**
361
- * Whether the last tick left nothing in progress: no live delivery or process, no open request for a member this
362
- * runner delivers to, no pending scheduling judgement and no running discussion. An idle room changes only through its
363
- * website, so the service may wait for the website to report a change before ticking it again.
364
- */
365
- idle() {
366
- const inbox = this.inbox;
367
- if (!inbox || this.routing.size || [...this.members.values()].some(children => children.size)) return false;
368
- if (this.room?.session?.status === 'active' || inbox.executions.length || inbox.members.some(member => member.pending.length)) return false;
369
- return !this.store.db.prepare('SELECT status FROM free_deliveries').all().some(row => LIVE.includes(row.status));
370
- }
371
- /**
372
- * Smart scheduling: for each new unnamed message routed to the host, ask the provider whether another woken
373
- * member suits it better and apply a confident pick. The host waits for the answer (or its failure) before starting.
374
- */
375
- recommend_unnamed(inbox, room) {
376
- // Manual members are listed apart; they are never delivered to but may host or be recommended.
377
- const everyone = [...inbox.members, ...(inbox.observers || [])];
378
- const host = everyone.find(member => member.role === 'host');
379
- const waiting = host?.pending.filter(request => request.purpose === 'route' && !request.group_action
380
- && !this.routed.has(request.id) && !this.saved_suggestion(request.id)) || [];
381
- for (const request of waiting) {
382
- this.routed.add(request.id);
383
- if (everyone.length < 2) continue;
384
- this.routing.add(request.id);
385
- const decision = this.mark_routing(request.message_id, true)
386
- .then(() => this.recommend(request, room, everyone))
387
- .catch(error => {
388
- if (!this.saved_suggestion(request.id)) this.save_suggestion(request, { action: 'host', actual_action: 'host',
389
- applied: false, model_judgment: false, reason: `没有模型判断:${error.message}` });
390
- this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`);
391
- })
392
- .finally(() => { this.routing.delete(request.id); return this.mark_routing(request.message_id, false); });
393
- this.decisions.add(decision);
394
- decision.finally(() => { this.decisions.delete(decision); this.on_decided(); });
395
- }
396
- }
397
- /** For each message naming everyone, have them answer at once, or one after another when smart scheduling says so. */
398
- arrange_groups(inbox, room) {
399
- const everyone = [...inbox.members, ...(inbox.observers || [])];
400
- for (const request of everyone.flatMap(member => member.pending).filter(item => item.purpose === 'group' && !this.grouped.has(item.id))) {
401
- this.grouped.add(request.id);
402
- const arranged = (this.config.scheduler ? this.mark_routing(request.message_id, true).then(() => this.order_group(request, room))
403
- .finally(() => this.mark_routing(request.message_id, false)) : this.order_group(request, room))
404
- .catch(error => { this.grouped.delete(request.id); this.log(`全体成员 #${request.message_id} 未能安排:${error.message}`); });
405
- this.decisions.add(arranged);
406
- arranged.finally(() => { this.decisions.delete(arranged); this.on_decided(); });
407
- }
408
- }
409
- /** Read and judge together; either kind of failure hands this one numbered group request back to its host. */
410
- async judge_group(request, room) {
411
- try {
412
- const plan = await api(this.config, `/recommendations/${request.message_id}`);
413
- const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES)}`);
414
- const message = page.messages.find(item => item.id === request.message_id);
415
- if (!message) throw new Error('读取不到这条消息');
416
- const descriptions = Object.fromEntries(plan.members.map(member => [member.name, member.description]));
417
- const facts = this.route_members(plan.members, { ...room, descriptions }, message.id);
418
- const profiles = JSON.stringify(this.config.participants);
419
- const decision = await decide_group_order(this.config.api_home, this.config.scheduler,
420
- { title: plan.title, goal: plan.goal, context: page.messages, message,
421
- members: plan.members.map(member => member.name), descriptions: facts }, this.fetch);
422
- return { decision, revision: plan.revision, profiles };
423
- } catch (error) {
424
- this.log(`智能调度 #${request.message_id} 未判断全体回答方式,交给主持人:${error.message}`);
425
- return { decision: { action: 'host' } };
426
- }
427
- }
428
- /** Persist a judgment before applying it: website retries and runner restarts must not ask the model again. */
429
- async order_group(request, room) {
430
- if (!this.config.scheduler) {
431
- const plan = await api(this.config, `/recommendations/${request.message_id}`);
432
- const result = await this.apply_group(request.message_id, { action: 'all' }, plan.revision);
433
- if (!result.applied) throw new Error(result.error);
434
- return;
435
- }
436
- const key = `group-decision:${request.id}`;
437
- const saved = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
438
- const judged = saved ? JSON.parse(saved) : await this.judge_group(request, room);
439
- if (!saved) this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(key, JSON.stringify(judged));
440
- const { decision, revision, profiles } = judged;
441
- this.save_suggestion(request, { action: decision.action, actual_action: 'unverified', applied: false,
442
- model_judgment: Boolean(decision.detail), reason: decision.detail ? '判断已产生,自动动作结果尚未核验' : '没有模型判断,交给主持人' });
443
- const labels = { all: '请全体同时回答', sequential: '请全体按发言顺序依次回答', host: '交给主持人处理原消息' };
444
- let result = profiles && profiles !== JSON.stringify(this.config.participants)
445
- ? { status: 'changed', applied: false, error: '成员本机配置已变化' }
446
- : await this.apply_group(request.message_id, decision, revision);
447
- let actual_action = result.applied ? decision.action : 'unverified';
448
- let label = labels[decision.action];
449
- if (result.status === 'changed' && decision.action !== 'host') {
450
- label += '(未生效,状态已变化)';
451
- const fallback = await this.apply_group(request.message_id, { action: 'host' });
452
- if (fallback.applied) actual_action = 'host';
453
- if (fallback.status === 'failed') result = fallback;
454
- }
455
- let record;
456
- if (decision.detail) {
457
- if (result.status === 'failed') label += '(未生效,网站写入失败)';
458
- // The @全体成员 question has one wording in every mode.
459
- record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host', { prompt: ROUTE_PROMPT_VERSION, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE });
460
- }
461
- this.save_suggestion(request, { action: decision.action, actual_action,
462
- applied: result.applied && decision.action !== 'host', model_judgment: Boolean(decision.detail),
463
- ...(record?.id ? { decision_id: record.id } : {}), reason: decision.detail ? label : '没有模型判断,交给主持人' });
464
- if (result.status === 'failed') throw new Error(result.error);
465
- this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
466
- }
467
- /** Apply once with the state that was judged; distinguish conflicts from failed writes and actual success. */
468
- async apply_group(message_id, decision, revision) {
469
- try {
470
- await api(this.config, `/recommendations/${message_id}`, { action: decision.action, ...(revision ? { revision } : {}) });
471
- this.log(`全体成员 #${message_id}:${{ all: '同时回答', sequential: '依次回答', host: '交给主持人' }[decision.action]}`);
472
- return { status: 'applied', applied: true };
473
- } catch (error) {
474
- return { status: /HTTP (404|409)/.test(error.message) ? 'changed' : 'failed', applied: false, error: error.message };
475
- }
476
- }
477
- /** Tell the website a message is being analysed so it can show the card; a failed mark never blocks routing. */
478
- async mark_routing(message_id, active) {
479
- try { await api(this.config, '/scheduler-routing', { message_id, active }); }
480
- catch (error) { this.log(`智能调度 #${message_id} 分析状态未上报:${error.message}`); }
481
- }
482
- /** Describe each member as the scheduler sees it: software, project access and the other messages it is on. */
483
- route_members(everyone, room, message_id) {
484
- const requests = (room.free?.requests || []).filter(item => ['pending', 'delivering'].includes(item.status));
485
- return everyone.map(member => {
486
- const work = member_work(requests, member.name, message_id);
487
- const profile = this.config.participants[member.name];
488
- const described = room.descriptions?.[member.name] ? `;用户描述:${room.descriptions[member.name]}` : '';
489
- // The model and the last finished message are only read by the guided wording.
490
- const finished = (room.free?.requests || []).filter(item => item.member === member.name && item.status === 'completed' && item.message_id !== message_id).at(-1);
491
- return { id: member.id, name: member.name, role: member.role, model: profile?.model || '', finished: finished ? `#${finished.message_id}` : '',
492
- description: profile ? member_description(member.name, profile, `${work}${described}`)
493
- : `${member.name}(手动接入,由用户代为触发${work}${described})` };
494
- });
495
- }
496
- /** Retain a request-bound handoff across retries and service restarts; never inline model probabilities. */
497
- save_suggestion(request, suggestion) {
498
- const entry = { request_id: request.id, message_id: request.source_message_id || request.message_id, ...suggestion };
499
- this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`scheduler-suggestion:${request.id}`, JSON.stringify(entry));
500
- return entry;
501
- }
502
- /** Recover the handoff for one exact request, independent of the room's recent decision window. */
503
- saved_suggestion(request_id) {
504
- const value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`scheduler-suggestion:${request_id}`)?.value;
505
- return value ? JSON.parse(value) : null;
506
- }
507
- /** Save the website record id only after it is returned; failed logging never invents one. */
508
- async record_route(request, decision, label, applied) {
509
- const record = await this.record_decision(request.message_id, decision, label, applied);
510
- this.save_suggestion(request, { action: decision.action, actual_action: applied ? decision.action : 'host', applied,
511
- model_judgment: true, ...(record?.id ? { decision_id: record.id } : {}), reason: label });
512
- }
513
- /** Decide one message and apply a confident pick: one member, everyone, or no one; otherwise the host keeps it. */
514
- async recommend(request, room, everyone) {
515
- const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES)}`);
516
- const message = page.messages.find(item => item.id === request.message_id);
517
- if (!message) throw new Error('读取不到这条消息');
518
- const described = this.route_members(everyone, room, message.id);
519
- const host = described.find(member => member.role === 'host');
520
- const members = described.filter(member => member !== host);
521
- const decision = await route_free_message(this.config.api_home, this.config.scheduler,
522
- { title: room.title, goal: room.goal, context: page.messages, message, host, members }, this.fetch);
523
- this.save_suggestion(request, { action: decision.action, actual_action: 'unverified', applied: false,
524
- model_judgment: true, reason: '判断已产生,自动动作结果尚未核验' });
525
- this.log(`智能调度 #${message.id}:${decision.action}${decision.member ? ` ${decision.member}` : ''}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
526
- if (decision.action === 'host') return this.record_route(request, decision, route_host_label(decision, host.name, members), false);
527
- if (decision.action !== 'member') {
528
- // Everyone, the named few or no one replaces the host's request in one step; a host that already started keeps the message.
529
- const applied = await api(this.config, `/recommendations/${message.id}`, { action: decision.action, ...(decision.members ? { members: decision.members } : {}) })
530
- .then(() => true, error => { this.log(`智能调度 #${message.id} 未能改派:${error.message}`); return false; });
531
- const names = described.map(member => member.name).join('、');
532
- const label = decision.action === 'all' ? `请全体各自回应(${names})` : decision.action === 'sequential' ? '请全体按发言顺序依次回应' : decision.action === 'some' ? `请 ${decision.members.join('、')} 各自回应` : '无需回应,未唤醒任何成员';
533
- return this.record_route(request, decision, applied ? label : `${label}(未生效,仍由主持人处理)`, applied);
534
- }
535
- try {
536
- await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
537
- provider: this.config.scheduler.provider, confidence: decision.confidence });
538
- } catch (error) {
539
- await this.record_route(request, decision, `建议让 ${decision.member} 回答(推荐写入失败,未自动生效)`, false);
540
- throw error;
541
- }
542
- // A confident pick is applied at once, interrupting the host; if that fails the recommendation stays for the user to adopt.
543
- const adopted = await api(this.config, `/recommendations/${message.id}`, { action: 'adopt', mode: 'interrupt' })
544
- .then(() => true, error => { this.log(`智能调度 #${message.id} 自动改派失败,保留推荐:${error.message}`); return false; });
545
- await this.record_route(request, decision, adopted ? `改由 ${decision.member} 回答` : `建议让 ${decision.member} 回答(未能自动改派)`, adopted);
546
- }
547
- /** Show each judgment under its message so the user can rate it; a failed record never affects delivery. */
548
- async record_decision(message_id, decision, label, applied, context = {}) {
549
- const limits = scheduler_thresholds(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE);
550
- try {
551
- return await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
552
- applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
553
- context: { model: this.config.scheduler.model, threshold: limits.member, wide_threshold: limits.wide,
554
- mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: route_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION, ...context } });
555
- } catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
556
- }
557
- /** Reserve with a persisted client id before invoking any software. */
558
- async start(member, row) {
559
- const record = this.save(member.name, { client_id: randomUUID(), execution_id: 0, status: 'reserving', error: '',
560
- attempts: row?.attempts || 0 });
561
- await this.reserve(member, record);
562
- }
563
- /** A retried client id returns the original execution; a finished or refused one just goes idle. */
564
- async reserve(member, record) {
565
- let execution;
566
- const profile = this.config.participants[member.name];
567
- const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd || this.config.cwd);
568
- try { execution = await api(this.config, '/executions', { member_id: member.id, client_id: record.client_id, cwd }); }
569
- catch (error) {
570
- if (/HTTP (404|409)/.test(error.message)) return this.save(member.name, { status: 'idle', error: '' });
571
- throw error;
572
- }
573
- if (!['dispatched', 'started'].includes(execution.status)) return this.save(member.name, { status: 'idle' });
574
- this.save(member.name, { execution_id: execution.id, status: 'delivering', started: Date.now(), last_renew: Date.now() });
575
- await this.invoke(member, execution);
576
- }
577
- /** Hand the execution to the member's software; only proven non-delivery is released for retry. */
578
- async invoke(member, execution) {
579
- const adapter = this.config.participants[member.name];
580
- const review_request = execution.requests?.find(request => request.review?.phase === 'review');
581
- if (review_request) {
582
- try { await this.check_review_delivery(member, review_request); }
583
- catch (error) { return this.reject_review(member, execution, error.message); }
584
- }
585
- if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
586
- if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
587
- const directory = capabilities_of(adapter.adapter).project ? adapter.cwd || this.config.cwd : undefined;
588
- // A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
589
- const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
590
- if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
591
- const binding = this.store.binding(member.name);
592
- // A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.
593
- if (binding.session_id === undefined && adapter.session_id) binding.session_id = adapter.session_id;
594
- let invoked = false;
595
- try {
596
- const warning = await this.cut_rewound(member.name, adapter, binding, execution);
597
- const session_request = execution.requests?.find(item => item.session_request_id && ['discuss', 'discuss_host'].includes(item.purpose));
598
- const session = this.room?.session ? { ...this.room.session } : null;
599
- const scheduler_suggestions = !this.config.scheduler || member.role !== 'host' ? []
600
- : session_request?.purpose === 'discuss_host' ? this.session_driver.take_suggestions(session_request.id)
601
- : execution.requests.map(request => this.saved_suggestion(request.id)).filter(Boolean);
602
- const review_handoffs = await this.review_handoffs(execution);
603
- // Executions the user stopped that this session may still hold; a rewind already cut its own out, and a new session holds none.
604
- const rewound = new Set(execution.rewound_executions || []);
605
- const stopped = binding.session_id ? this.stopped_executions(member.name).filter(id => !rewound.has(id)) : [];
606
- const task = free_task_files(this.config, member, execution, binding, {
607
- session, suggestions: scheduler_suggestions, reviews: review_handoffs, stopped,
608
- });
609
- invoked = true;
610
- this.segments.start(execution.id);
611
- if (warning) this.note(execution.id, warning);
612
- const result = await this.deliver(review_request ? structuredClone(adapter) : adapter, binding, { ...task, room_title: this.room_title,
613
- ...(review_request ? { before_model_start: () => this.check_review_delivery(member, review_request) } : {}),
614
- api_context: { site: this.config.site, room: this.config.room, token: this.config.members[member.name].token,
615
- home: this.config.api_home, mode: 'free', role: member.role || 'member', execution_id: execution.id,
616
- cwd: directory, project_access: adapter.project_access,
617
- requests: execution.requests, read_after: execution.read_until, session, discussion_request: session_request,
618
- scheduler_suggestions, review_handoffs },
619
- on_binding: session => this.store.bind(member.name, session),
620
- on_process: child => this.track(member.name, child, execution.id),
621
- on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.touch(execution.id); this.segments.mark(execution.id); },
622
- on_activity: (phase, tool) => { this.touch(execution.id, phase); this.segments.mark(execution.id, phase, tool); },
623
- on_note: text => this.note(execution.id, text),
624
- // A stopped process may report after the next execution started; only its own execution takes the result.
625
- on_failure: (error, { not_started = false } = {}) => {
626
- if (not_started) this.not_started.add(execution.id);
627
- try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ }
628
- },
629
- on_exit: denial => this.exited.set(execution.id, denial || ''),
630
- // Shown beside the answer or failure so the owner can allow them; a lost report only hides the list.
631
- on_denials: denials => api(this.config, `/executions/${execution.id}`, { action: 'denials', denials }).catch(() => {}),
632
- });
633
- if (result.session_id) this.store.bind(member.name, result.session_id);
634
- this.clear_stopped(member.name);
635
- this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
636
- await this.report_delivery(execution.id, result.capabilities || capabilities_of(adapter.adapter).delivery, member.name);
637
- } catch (error) {
638
- if (review_request && error.delivery_status === 'not_sent') return this.reject_review(member, execution, error.message);
639
- if (adapter.project_access === 'own') return this.retry_or_fail(member.name, execution, `投递失败:${error.message}`);
640
- const attempts = (this.row(member.name)?.attempts || 0) + 1;
641
- if ((invoked && error.delivery_status !== 'not_sent') || attempts >= MAX_DELIVERY_ATTEMPTS) {
642
- return this.fail(member.name, execution.id, `投递失败:${error.message}`);
643
- }
644
- await api(this.config, `/executions/${execution.id}`, { action: 'release', reason: `确认未送达:${error.message}` });
645
- this.save(member.name, { status: 'idle', attempts, error: error.message });
646
- }
647
- }
648
- /** Recheck local configuration and the site's identity binding before a review starts. */
649
- async check_review_delivery(member, request) {
650
- const proof = request.review;
651
- const version = review_capability_version(this.config.participants[member.name], member, this.config);
652
- if (!version || version !== proof.config_version || this.context_errors.has(member.name)) throw new Error('没有可核验只读的评审成员:本机配置与预授权能力版本不符');
653
- const current = await api(this.config, `/members/${member.id}/review-capability`);
654
- if (current.revision !== proof.member_revision || current.capability?.config_version !== version) throw new Error('评审者资格或能力版本已改变');
655
- }
656
- /** Cancel only this isolated review execution and leave a durable reason for its host. */
657
- async reject_review(member, execution, reason) {
658
- await api(this.config, `/executions/${execution.id}`, { action: 'reject_review', reason });
659
- this.save(member.name, { status: 'idle', error: reason });
660
- this.stop_member(member.name);
661
- }
662
- /** Attach status and read entry points, never inline the implementation context into a wake-up. */
663
- async review_handoffs(execution) {
664
- const reviews = [];
665
- for (const request of execution.requests || []) {
666
- if (request.review?.phase !== 'handoff') continue;
667
- const batch_id = request.review.batch_id;
668
- try {
669
- const state = await api(this.config, `/reviews/${batch_id}/ready`);
670
- reviews.push({ batch_id, request_id: request.id, ready: state.ready, reason: state.reason,
671
- reviewer: state.plan.reviewer_name, scope: state.plan.scope });
672
- } catch (error) { reviews.push({ batch_id, request_id: request.id, ready: false, reason: `无法读取评审推进状态:${error.message}` }); }
673
- }
674
- return reviews;
675
- }
676
- /**
677
- * Before a member works again after a rewind, branch its session before the executions the rewind took back.
678
- * A Claude fork happens on its next turn, so it stays pending until that session exists. Returns a warning when the
679
- * session could not be cut and the member continues in the old one.
680
- */
681
- async cut_rewound(name, adapter, binding, execution) {
682
- const key = `session-fork:${name}`;
683
- const pending = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
684
- if (pending && !session_exists(adapter.adapter, binding.session_id)) { binding.fork = JSON.parse(pending); return ''; }
685
- if (pending) this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
686
- if (!binding.session_id || !execution.rewound_executions?.length || !BRANCHABLE_ADAPTERS.includes(adapter.adapter)) return '';
687
- try {
688
- const cut = await branch_session(adapter, binding.session_id, execution.rewound_executions);
689
- if (!cut) return '';
690
- if (cut.fork) this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, JSON.stringify(cut.fork));
691
- this.store.bind(name, cut.session_id);
692
- Object.assign(binding, cut);
693
- return '';
694
- } catch (error) { return `会话未能回退(${error.message}),本次沿用原会话,它可能仍记得已回退的内容`; }
695
- }
696
- /** Follow the website's verdict; stop only this member's processes when its execution ends. */
697
- async reconcile(member, row, active) {
698
- if (row.status === 'reserving') return this.reserve(member, row);
699
- if (row.status === 'sidecar_pending') {
700
- const receipt = delivery_receipt(this.config.participants[member.name], `free-${row.execution_id}`);
701
- if (receipt?.status === 'accepted' && receipt.session_id) {
702
- this.store.bind(member.name, receipt.session_id);
703
- this.save(member.name, { status: 'running' });
704
- } else if (receipt && active.has(row.execution_id)) return this.retry_or_fail(member.name, active.get(row.execution_id), receipt.error || 'Sidecar 投递结果不明,请核对原会话');
705
- }
706
- const execution = active.get(row.execution_id);
707
- if (!execution) {
708
- this.activity.delete(row.execution_id);
709
- await this.report_segments(row.execution_id, true);
710
- this.stop_member(member.name);
711
- return this.save(member.name, { status: 'idle', error: '' });
712
- }
713
- if (row.status === 'retry_wait') {
714
- const retry = execution.retry;
715
- if (retry?.stopped) return this.fail(member.name, execution.id, `${retry.reason}(已停止自动重试)`);
716
- if (retry?.retry_at && Date.now() >= retry.retry_at * 1000) {
717
- await api(this.config, `/executions/${execution.id}`, { action: 'retry_start' });
718
- this.save(member.name, { status: 'delivering' });
719
- return this.invoke(member, await api(this.config, `/executions/${execution.id}`));
720
- }
721
- }
722
- // A restart between persisting and invoking cannot prove non-delivery, so it never resends.
723
- if (row.status === 'delivering') return this.fail(member.name, execution.id, '上次投递结果不明,未自动重发;请核对原会话后重新发送');
724
- if (row.error) return this.retry_or_fail(member.name, execution, row.error);
725
- if (this.exited.has(execution.id)) {
726
- const latest = await api(this.config, `/executions/${execution.id}`);
727
- const denial = this.exited.get(execution.id);
728
- if (['dispatched', 'started'].includes(latest.status)) return this.fail(member.name, execution.id, denial
729
- ? `${member.name} ${denial},未提交本次回答`
730
- : `${member.name} 会话已结束,但未提交本次回答;请核对原会话确认是否已有内容`);
731
- return this.save(member.name, { status: 'idle' });
732
- }
733
- // Segments and notes go first, quietly and often, so a renewal in the same tick already shows the notes it announces.
734
- if (this.segments.due(execution.id, PROGRESS_REPORT_MS)) await this.report_segments(execution.id);
735
- const activity = this.activity.get(execution.id);
736
- const changed = activity && (activity.phase !== activity.sent_phase || activity.at !== activity.sent_at);
737
- const elapsed = Date.now() - row.last_renew;
738
- if (elapsed >= RENEW_MS || (changed && elapsed >= ACTIVITY_REPORT_MS)) {
739
- await api(this.config, `/executions/${execution.id}`, { action: 'renew',
740
- ...(activity ? { activity: activity.phase, activity_at: Math.floor(activity.at / 1000) } : {}) });
741
- if (activity) Object.assign(activity, { sent_phase: activity.phase, sent_at: activity.at });
742
- this.save(member.name, { last_renew: Date.now() });
743
- }
744
- }
745
- /**
746
- * Tell the site what this delivery can do, as the adapter reported it: whether the runner can stop it and see its
747
- * progress. Kept locally until the execution ends so a restarted runner still knows whether it could stop it.
748
- * A lost report is retained for the final stop result; sites and runners must use the same capability protocol.
749
- */
750
- async report_delivery(execution_id, { stoppable, progress }, member) {
751
- this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`delivery:${execution_id}`, JSON.stringify({ stoppable, progress, member }));
752
- await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress })
753
- .catch(error => this.log(`执行 #${execution_id} 的投递能力未能上报:${error.message}`));
754
- }
755
- /** Retry durable stop reports even after the member went idle, left, or started a later execution. */
756
- async report_stops(active) {
757
- const pending = new Set();
758
- for (const { key, value } of this.store.db.prepare("SELECT key,value FROM metadata WHERE key LIKE 'delivery:%'").all()) {
759
- const execution_id = Number(key.slice('delivery:'.length));
760
- if (active.has(execution_id)) continue;
761
- const delivery = stored_delivery(value);
762
- const name = delivery.member || this.store.db.prepare('SELECT member FROM free_deliveries WHERE execution_id=?').get(execution_id)?.member;
763
- if (!name) continue;
764
- for (const child of this.execution_processes.get(execution_id) || []) this.request_stop(child);
765
- try { await this.report_stop(name, execution_id); }
766
- catch (error) { pending.add(name); this.log(`执行 #${execution_id} 的停止结果未能上报:${error.message}`); }
767
- }
768
- return pending;
769
- }
770
- /** Report observed termination, never infer it from a capability or a successfully sent kill signal. */
771
- async report_stop(name, execution_id) {
772
- const key = `delivery:${execution_id}`;
773
- const value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
774
- if (!value) return;
775
- const delivery = stored_delivery(value), processes = this.execution_processes.get(execution_id);
776
- const ended = processes ? processes.size === 0 : this.exited.has(execution_id);
777
- const result = delivery.pending_result && delivery.pending_result !== 'unconfirmed' ? delivery.pending_result
778
- : !delivery.stoppable ? 'unstoppable' : ended ? 'stopped' : 'unconfirmed';
779
- if (delivery.stop_result === result) return;
780
- // Keep the observed outcome before sending: a restart after a lost response must retry the same fact.
781
- this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
782
- .run(JSON.stringify({ ...delivery, member: name, pending_result: result }), key);
783
- try { await api(this.config, `/executions/${execution_id}`, { action: 'stopped', result }); }
784
- catch (error) {
785
- if (!/HTTP 409:/.test(error.message)) throw error;
786
- const latest = await api(this.config, `/executions/${execution_id}`);
787
- // A conflict is not necessarily normal completion: keep cancelled reports until an idempotent receipt arrives.
788
- if (!['completed', 'failed'].includes(latest.status)) throw error;
789
- this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
790
- this.execution_processes.delete(execution_id);
791
- return;
792
- }
793
- this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)')
794
- .run(`stopped:${name}`, JSON.stringify([...new Set([...this.stopped_executions(name), execution_id])]));
795
- if (result === 'unconfirmed') {
796
- this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
797
- .run(JSON.stringify({ ...delivery, member: name, pending_result: result, stop_result: result }), key);
798
- // The site's bounded hold now controls the next delivery; retain handles to observe and retry termination.
799
- for (const child of processes || []) this.members.get(name)?.delete(child);
800
- }
801
- else { this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key); this.execution_processes.delete(execution_id); }
802
- }
803
- /** Once a new task received the stop reminders, retire acknowledged unconfirmed reports for its old turns. */
804
- clear_stopped(name) {
805
- for (const id of this.stopped_executions(name)) {
806
- const key = `delivery:${id}`, value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
807
- if (value && stored_delivery(value).stop_result === 'unconfirmed') {
808
- this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
809
- this.execution_processes.delete(id);
810
- }
811
- }
812
- this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(`stopped:${name}`);
813
- }
814
- /** Executions the user stopped since this member's last delivered task. */
815
- stopped_executions(name) {
816
- return JSON.parse(this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`stopped:${name}`)?.value || '[]');
817
- }
818
- /** Send the execution's recorded segments and progress notes; `final` sends the rest once it has ended. */
819
- report_segments(execution_id, final = false) {
820
- return this.segments.report(execution_id, final, ({ segments, notes }) =>
821
- api(this.config, `/executions/${execution_id}`, { action: 'segments', segments, ...(notes.length ? { notes } : {}) }));
822
- }
823
- /** Note that the software produced output now, and its phase when the output names one. */
824
- touch(execution_id, phase) {
825
- // Updated in place: a renewal in flight marks what it sent on this same record.
826
- if (!this.activity.has(execution_id)) this.activity.set(execution_id, { phase: '' });
827
- const current = this.activity.get(execution_id);
828
- if (phase) current.phase = phase;
829
- current.at = Date.now();
830
- }
831
- /** Queue one progress note for the next progress report; numbering makes a retried report harmless. */
832
- note(execution_id, text) {
833
- this.touch(execution_id);
834
- this.segments.note(execution_id, text);
835
- }
836
- /** Report observed file changes once an execution has ended and its member's processes have exited. */
837
- async report_finished(active) {
838
- for (const [execution_id, snapshot] of this.snapshots) {
839
- if (active.has(execution_id) || this.execution_processes.get(execution_id)?.size) continue;
840
- this.snapshots.delete(execution_id);
841
- const after = workspace_snapshot(snapshot.directory);
842
- if (!after) continue;
843
- const changes = changed_between(snapshot.before, after);
844
- try { await api(this.config, `/executions/${execution_id}`, { action: 'report', changes: changes.slice(0, 200) }); }
845
- catch (error) { if (!/HTTP (404|409)/.test(error.message)) throw error; }
846
- }
847
- }
848
- /**
849
- * A manual member's session refused the delivery, or the software failed before any turn ran: keep the execution and
850
- * deliver again after a wait shown on the website, up to MAX_DELIVERY_RETRIES times; anything else, or the last
851
- * attempt, fails as before.
852
- */
853
- async retry_or_fail(name, execution, reason) {
854
- const attempt = (execution.retry?.attempt || 0) + 1;
855
- const not_started = this.not_started.delete(execution.id);
856
- if (this.config.participants[name]?.project_access !== 'own' && !not_started) return this.fail(name, execution.id, reason);
857
- if (attempt > MAX_DELIVERY_RETRIES) return this.fail(name, execution.id, `${reason}(已自动重试 ${MAX_DELIVERY_RETRIES} 次)`);
858
- this.stop_member(name);
859
- this.exited.delete(execution.id);
860
- await api(this.config, `/executions/${execution.id}`, { action: 'retry_wait', attempt,
861
- retry_at: Math.floor(Date.now() / 1000) + RETRY_DELAY_SECONDS, reason: reason.slice(0, MAX_RETRY_REASON) });
862
- this.save(name, { status: 'retry_wait', error: '' });
863
- }
864
- /** Record the cause on the website, then stop this member's own processes. */
865
- async fail(name, execution_id, reason) {
866
- this.activity.delete(execution_id);
867
- this.not_started.delete(execution_id);
868
- await this.report_segments(execution_id, true);
869
- try { await api(this.config, `/executions/${execution_id}`, { action: 'fail', reason }); }
870
- catch (error) { if (!/HTTP 409/.test(error.message)) throw error; }
871
- this.save(name, { status: 'idle', error: reason });
872
- this.stop_member(name);
873
- }
874
- /** Count live processes per member so one session never has two writers. */
875
- track(name, child, execution_id = this.row(name)?.execution_id) {
876
- this.children.add(child);
877
- if (!this.members.has(name)) this.members.set(name, new Set());
878
- this.members.get(name).add(child);
879
- if (!this.execution_processes.has(execution_id)) this.execution_processes.set(execution_id, new Set());
880
- const processes = this.execution_processes.get(execution_id);
881
- processes.add(child);
882
- child.once('close', () => { processes.delete(child); this.children.delete(child); this.members.get(name).delete(child); });
883
- }
884
- /** Request termination; only a later close event proves it happened. */
885
- request_stop(child) { try { child.kill(); } catch { /* The stop report remains unconfirmed. */ } }
886
- /** Stop one member's processes without touching other members or desktop apps. */
887
- stop_member(name) { for (const child of this.members.get(name) || []) this.request_stop(child); }
888
- /** Stop every process this runner created. */
889
- stop_children() { for (const child of this.children) this.request_stop(child); }
890
- }
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 { adapter_of, BRANCHABLE_ADAPTERS, branch_session, capabilities_of, cleanup_room, deliver_task, delivery_receipt, session_exists, stored_delivery } from './adapters/index.mjs';
7
+ import { api, read_rule } from './discussion_runner.mjs';
8
+ import { SegmentLog } from './execution_progress.mjs';
9
+ import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
10
+ import { atomic_json } from './files.mjs';
11
+ import { SessionDriver } from './session_driver.mjs';
12
+ import { effective_profile, load_room_workflow, workflow_task_field } from './workflows.mjs';
13
+ import { DEFAULT_SCHEDULER_MODE, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version, scheduler_thresholds, some_members } from './scheduler.mjs';
14
+
15
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
16
+ const RENEW_MS = 30_000;
17
+ /** A changed phase is reported early, but no more often than this, since each report refreshes open pages. */
18
+ const ACTIVITY_REPORT_MS = 10_000;
19
+ /** Segments and progress notes are reported quietly, waking no page, so they may go every tick. */
20
+ const PROGRESS_REPORT_MS = 2_000;
21
+ const MAX_DELIVERY_ATTEMPTS = 3;
22
+ // A manual member's session that refuses a delivery is retried in the same execution, after a wait the owner can cut short.
23
+ const MAX_DELIVERY_RETRIES = 3;
24
+ const RETRY_DELAY_SECONDS = 60;
25
+ const MAX_RETRY_REASON = 500;
26
+ const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running', 'retry_wait'];
27
+ const MAX_HASHED_BYTES = 5 * 1024 * 1024;
28
+ const GIT_TIMEOUT_MS = 15_000;
29
+
30
+ /** Fingerprint configurations whose adapter can enforce project read-only access; no local paths leave this function. */
31
+ export function review_capability_version(profile, member, config) {
32
+ if (!profile || profile.project_access !== 'read' || member.access !== 'runner' || member.session_mode !== 'new'
33
+ || !capabilities_of(profile.adapter).enforces_read_only || profile.adapter !== member.adapter
34
+ || !profile.cwd || profile.allowed_tools?.length) return '';
35
+ const project = cwd_key(profile.cwd), drafts = cwd_key(config.draft_directory || `${config.directory}-drafts`);
36
+ if (drafts === project || drafts.startsWith(`${project}/`)) return '';
37
+ const facts = [1, profile.adapter, profile.executable || '', cwd_key(profile.cwd), cwd_key(profile.workdir),
38
+ profile.project_id || '', profile.project_name || '', profile.session_id || '', drafts,
39
+ cwd_key(config.directory)];
40
+ return createHash('sha256').update(JSON.stringify(facts)).digest('hex');
41
+ }
42
+
43
+ /** Identify a working directory consistently, so executions in the same cwd can be compared. */
44
+ export function cwd_key(directory) {
45
+ if (!directory) return '';
46
+ const path = resolve(directory).replaceAll('\\', '/');
47
+ return process.platform === 'win32' ? path.toLowerCase() : path;
48
+ }
49
+
50
+ /**
51
+ * Record each changed or untracked file's Git status and content hash in a Git working tree.
52
+ * Returns null outside Git: free rooms then provide no automatic overlap detection.
53
+ */
54
+ export function workspace_snapshot(directory) {
55
+ if (!directory) return null;
56
+ const result = spawnSync('git', ['-C', directory, 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
57
+ { encoding: 'utf8', windowsHide: true, timeout: GIT_TIMEOUT_MS });
58
+ if (result.status !== 0) return null;
59
+ const entries = new Map();
60
+ const fields = result.stdout.split('\0').filter(Boolean);
61
+ for (let index = 0; index < fields.length; index++) {
62
+ const status = fields[index].slice(0, 2), path = fields[index].slice(3);
63
+ if (status[0] === 'R' || status[0] === 'C') index++; // The original path of a rename follows.
64
+ let content = 'missing';
65
+ try {
66
+ const file = join(directory, path);
67
+ const size = statSync(file).size;
68
+ content = size > MAX_HASHED_BYTES ? `size:${size}` : createHash('sha256').update(readFileSync(file)).digest('hex');
69
+ } catch { /* Deleted files keep the marker. */ }
70
+ entries.set(path, `${status}:${content}`);
71
+ }
72
+ return entries;
73
+ }
74
+
75
+ /** Paths whose status or content differ between two snapshots of one working tree. */
76
+ export function changed_between(before, after) {
77
+ const paths = new Set([...before.keys(), ...after.keys()]);
78
+ return [...paths].filter(path => before.get(path) !== after.get(path)).sort();
79
+ }
80
+
81
+ /** Write a non-secret task and a member-only credential file for one execution. */
82
+ export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], stopped = [], process_access = true,
83
+ workflow = null, room_members = [] } = {}) {
84
+ const id = `free-${execution.id}`;
85
+ const draft_directory = config.draft_directory || `${config.directory}-drafts`;
86
+ mkdirSync(draft_directory, { recursive: true });
87
+ const access_path = join(config.directory, `${id}.access.json`);
88
+ const task_path = join(config.directory, `${id}.task.json`);
89
+ // A workflow member's prompt is read from its card for this execution; everything else stays the room's setting.
90
+ const profile = effective_profile(config.participants[member.name], member.name, workflow);
91
+ const host_workflow = member.role === 'host' && workflow && !workflow.error ? workflow : null;
92
+ atomic_json(access_path, { site: config.site, room: config.room, member: member.name, token: config.members[member.name].token,
93
+ session_id: binding.session_id || '', execution_id: execution.id });
94
+ const adapter = adapter_of(profile.adapter);
95
+ const cli = process.platform === 'win32' ? adapter?.windows_command || 'codiscuss.cmd' : 'codiscuss';
96
+ const inbox = Boolean(adapter?.into_window?.(profile, binding));
97
+ const bound_access = process_access && !inbox && Boolean(adapter?.capabilities.access_env);
98
+ const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
99
+ const requests = execution.requests || [];
100
+ const discussion_request = requests.find(item => item.session_request_id && ['discuss', 'discuss_host'].includes(item.purpose)) || null;
101
+ const direct_question = Boolean(session?.status === 'active' && requests.some(item => item.purpose === 'ask'));
102
+ const { instructions, prompt } = free_task_text({ name: member.label || member.name, role: member.role, discussion: direct_question,
103
+ discussion_request, profile, cli,
104
+ room: config.room, messages, read_after: execution.read_until, execution_id: execution.id, task_path,
105
+ access_file: bound_access ? '' : access_path, read_rule: read_rule(profile.adapter), inbox });
106
+ const skills = discussion_request || direct_question
107
+ ? ['discuss-participant/SKILL.md', 'discuss-participant/references/discussion.md',
108
+ ...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/discussion.md'] : [],
109
+ ...direct_question ? ['discuss-participant/references/free.md'] : []]
110
+ : ['discuss-participant/SKILL.md', 'discuss-participant/references/free.md',
111
+ ...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/free.md'] : []];
112
+ const skills_directory = join(config.project, '.agents', 'skills');
113
+ const workflow_field = host_workflow ? workflow_task_field(host_workflow, room_members, config.participants) : null;
114
+ atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
115
+ execution_id: execution.id, requests, request_messages: execution.requests.map(item => item.message_id),
116
+ request_purposes: execution.requests.map(item => item.purpose || 'ask'),
117
+ ...(discussion_request ? { discussion_request } : {}),
118
+ ...(session ? { session: { id: session.id, status: session.status, phase: session.phase,
119
+ goal: session.goal, named_followup: session.named_followup } } : {}),
120
+ ...(suggestions.length ? { scheduler_suggestions: suggestions } : {}),
121
+ ...(reviews.length ? { review_handoffs: reviews } : {}),
122
+ ...(stopped.length ? { stopped_executions: stopped } : {}),
123
+ role: member.role || 'member',
124
+ read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
125
+ skills: [...skills.map(file => join(config.project, '.agents/skills', file)), ...(host_workflow ? [host_workflow.skill_path] : [])],
126
+ ...(workflow_field ? { workflow: workflow_field } : {}),
127
+ cli, subcommands: TASK_SUBCOMMANDS,
128
+ ...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }), instructions });
129
+ return { id, participant: member.name, directory: config.directory, draft_directory,
130
+ skill_directories: [skills_directory, ...(host_workflow ? [host_workflow.directory] : [])], access_path, prompt: with_expert_prompt(prompt, profile), inbox,
131
+ profile, ...(workflow_field ? { workflow: { ...workflow_field, skill: host_workflow.skill } } : {}) };
132
+ }
133
+
134
+
135
+ /** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
136
+ function member_description(name, profile, work) {
137
+ const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
138
+ const access = !capabilities_of(profile.adapter).project ? ',只能读写房间消息' : profile.project_access === 'own' ? ',权限沿用其会话设置' : !profile.cwd ? ',未指定项目目录'
139
+ : profile.project_access === 'read' ? `,项目 ${project} 只读`
140
+ : `,可修改项目 ${project} 的文件${capabilities_of(profile.adapter).file_tools ? '(不能运行命令)' : '并运行命令'}`;
141
+ return `${name}(${adapter_of(profile.adapter)?.label || profile.adapter}${access}${work})`;
142
+ }
143
+
144
+ /** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
145
+ function member_work(requests, name, message_id) {
146
+ const open = requests.filter(item => item.member === name && item.message_id !== message_id);
147
+ const ids = status => [...new Set(open.filter(item => item.status === status).map(item => `#${item.message_id}`))].join('、');
148
+ return [ids('delivering') && `;正在处理 ${ids('delivering')}`, ids('pending') && `;待处理 ${ids('pending')}`].filter(Boolean).join('');
149
+ }
150
+
151
+ /** Why a message stayed with the host: the scheduler chose the host, or leaned elsewhere without enough certainty. */
152
+ function route_host_label(decision, host, members) {
153
+ if (decision.choice === '__host__' || decision.confidence === null) return `交给主持人 ${host}`;
154
+ const group = some_members(decision.choice);
155
+ const lean = decision.choice === '__all__' ? '全体各自回应' : decision.choice === '__all_seq__' ? '全体依次回应' : decision.choice === '__none__' ? '无需回应' : group ? `请 ${group.join('、')} 各自回应`
156
+ : members.some(member => member.name === decision.choice) ? `请 ${decision.choice} 回答` : '';
157
+ return lean ? `倾向${lean},把握不足,交给主持人 ${host}` : `交给主持人 ${host}`;
158
+ }
159
+
160
+ /** Delivers free-room requests through the same software adapters as discussions. */
161
+ export class FreeRoomRunner {
162
+ constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch, session_driver = null) {
163
+ this.config = config;
164
+ this.store = store;
165
+ this.deliver = deliver;
166
+ this.log = log;
167
+ this.fetch = fetch_impl;
168
+ this.session_driver = session_driver || new SessionDriver(config, log, fetch_impl, store);
169
+ this.routed = new Set();
170
+ // Group requests (@全体成员) being arranged; a failed arrangement leaves the set so the next tick retries.
171
+ this.grouped = new Set();
172
+ this.decisions = new Set();
173
+ // Called when a scheduling decision settles; the service checks the room at once instead of waiting for the next poll.
174
+ this.on_decided = () => {};
175
+ // Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
176
+ this.routing = new Set();
177
+ this.children = new Set();
178
+ this.members = new Map();
179
+ // An empty set proves every tracked handle for this execution closed; a missing set proves nothing after restart.
180
+ this.execution_processes = new Map();
181
+ // Execution id -> why its session ended on its own, such as a command refused in unattended mode ('' when unknown).
182
+ this.exited = new Map();
183
+ // Executions whose software failed before any turn ran, as its adapter reported; safe to deliver again.
184
+ this.not_started = new Set();
185
+ // Executions whose software failed in a way a retry meets again (a used-up limit, a lost login), as its adapter reported.
186
+ this.not_retryable = new Set();
187
+ this.snapshots = new Map();
188
+ // Execution id -> { phase, at, sent_phase, sent_at }: what the member's software last showed it doing.
189
+ this.activity = new Map();
190
+ this.segments = new SegmentLog();
191
+ this.context_errors = new Map();
192
+ this.adopt(config);
193
+ this.store.db.exec(`CREATE TABLE IF NOT EXISTS free_deliveries(member TEXT PRIMARY KEY, client_id TEXT NOT NULL,
194
+ execution_id INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
195
+ started INTEGER NOT NULL DEFAULT 0, last_renew INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '')`);
196
+ }
197
+ /** Read this member's current local delivery record. */
198
+ row(name) { return this.store.db.prepare('SELECT * FROM free_deliveries WHERE member=?').get(name); }
199
+ /** Persist one member's delivery state; the client id makes a lost reserve response recoverable. */
200
+ save(name, fields) {
201
+ const current = this.row(name) || { member: name, client_id: '', execution_id: 0, status: 'idle', attempts: 0, started: 0, last_renew: 0, error: '' };
202
+ const next = { ...current, ...fields };
203
+ this.store.db.prepare(`INSERT INTO free_deliveries VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(member) DO UPDATE SET
204
+ client_id=excluded.client_id,execution_id=excluded.execution_id,status=excluded.status,attempts=excluded.attempts,
205
+ started=excluded.started,last_renew=excluded.last_renew,error=excluded.error`)
206
+ .run(name, next.client_id, next.execution_id, next.status, next.attempts, next.started, next.last_renew, next.error);
207
+ return next;
208
+ }
209
+ /**
210
+ * Take a changed configuration while running: new members are added, but a member whose
211
+ * project, cwd or bound session changed is blocked instead of silently reusing its conversation.
212
+ */
213
+ adopt(config) {
214
+ for (const [name, item] of Object.entries(config.participants)) {
215
+ const context = JSON.stringify([item.adapter, item.project_id || '', item.project_name || '', cwd_key(item.cwd), cwd_key(item.workdir), item.session_id || '']);
216
+ const key = `member-context:${name}`;
217
+ const previous = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
218
+ if (previous && !this.same_context(name, previous, context)) this.context_errors.set(name, `${name} 已绑定会话的项目、cwd 或接入方式发生变化;请移除后重新邀请`);
219
+ else this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, context);
220
+ }
221
+ this.config = { ...this.config, ...config, participants: config.participants, members: config.members };
222
+ this.session_driver.config = this.config;
223
+ }
224
+ /**
225
+ * A Codex member switched between App and CLI keeps its conversation: the two entries count as one, and the App's
226
+ * session may be filled in from the conversation this member is already bound to.
227
+ */
228
+ same_context(name, previous, current) {
229
+ const [before, after] = [JSON.parse(previous), JSON.parse(current)];
230
+ for (const context of [before, after]) context[0] = adapter_of(context[0])?.session_family || context[0];
231
+ const bound = this.store.binding(name).session_id;
232
+ if (before[5] !== after[5] && [before[5], after[5]].includes('') && [before[5], after[5]].includes(bound)) after[5] = before[5];
233
+ return JSON.stringify(before) === JSON.stringify(after);
234
+ }
235
+ /** One bounded iteration: reconcile live executions, then deliver idle members with open requests. */
236
+ async tick() {
237
+ let inbox; let room;
238
+ // Read one after another: Node's fetch keeps one warm connection per site, and a second parallel request would pay a new
239
+ // TLS handshake (measured 2 s against 0.4 s through a proxy to a hosted site).
240
+ try {
241
+ inbox = await api(this.config, '/inbox');
242
+ room = await api(this.config, '');
243
+ }
244
+ catch (error) {
245
+ if (error.message === 'HTTP 404: 房间不存在') cleanup_room(this.config.directory);
246
+ throw error;
247
+ }
248
+ if (!inbox.archived) await this.sync_review_capabilities(inbox);
249
+ if (!inbox.archived) await this.sync_member_capabilities(inbox);
250
+ if (!inbox.archived && room.session?.status === 'active'
251
+ && await this.session_driver.tick(room, inbox)) {
252
+ // A scheduled plan completes the pending host request and creates a speaker request.
253
+ inbox = await api(this.config, '/inbox');
254
+ room = await api(this.config, '');
255
+ }
256
+ // Smart scheduling runs beside delivery: the host waits for its answer (or failure) before starting, see `routing`.
257
+ if (this.config.scheduler && !inbox.archived) this.recommend_unnamed(inbox, room);
258
+ if (this.config.scheduler && !inbox.archived) this.schedule_reviews(inbox);
259
+ if (!inbox.archived) this.arrange_groups(inbox, room);
260
+ this.room_title = room.title;
261
+ this.room = room;
262
+ this.inbox = inbox;
263
+ const active = new Map(inbox.executions.map(item => [item.id, item]));
264
+ const present = new Set(inbox.members.map(member => member.name));
265
+ // A member sent away or removed no longer appears; its live work was already voided on the website.
266
+ for (const row of this.store.db.prepare('SELECT * FROM free_deliveries').all()) {
267
+ if (LIVE.includes(row.status) && !present.has(row.member)) { this.stop_member(row.member); this.save(row.member, { status: 'idle' }); }
268
+ }
269
+ await this.report_finished(active);
270
+ const pending_stops = await this.report_stops(active);
271
+ // Start in the room's speaking order: execution ids follow it, and the website lays out working cards by them.
272
+ const rank = (room.speaking_order?.items || []).map(item => item.name);
273
+ const place = name => rank.includes(name) ? rank.indexOf(name) : rank.length;
274
+ for (const member of [...inbox.members].sort((left, right) => place(left.name) - place(right.name))) {
275
+ const row = this.row(member.name);
276
+ if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active);
277
+ // A member voided in a window or App the runner could not stop waits until that turn ends (`held_until`).
278
+ else if (!inbox.archived && member.pending.some(request => request.purpose !== 'group') && !this.members.get(member.name)?.size
279
+ && !pending_stops.has(member.name)
280
+ && !member.pending.some(request => this.routing.has(request.id)) && !(member.held_until * 1000 > Date.now())) await this.start(member, row);
281
+ }
282
+ }
283
+ /** Publish only changed local facts, using member revisions supplied by the owner's inbox. */
284
+ async sync_review_capabilities(inbox) {
285
+ for (const member of inbox.members) {
286
+ const current = member.review_capability;
287
+ if (!current) continue; // Older sites have no review protocol.
288
+ const version = this.context_errors.has(member.name) ? ''
289
+ : review_capability_version(this.config.participants[member.name], member, this.config);
290
+ if ((current.capability?.config_version || '') === version) continue;
291
+ try {
292
+ member.review_capability = await api(this.config, `/members/${member.id}/review-capability`, {
293
+ revision: current.revision, read_only: Boolean(version), ...(version ? { config_version: version } : {}),
294
+ });
295
+ } catch (error) { this.log(`只读评审能力未同步:${member.name}:${error.message}`); }
296
+ }
297
+ }
298
+ /** Tell the site what a rewind does to each member's session, as its adapter says; only a change is sent. */
299
+ async sync_member_capabilities(inbox) {
300
+ for (const member of inbox.members) {
301
+ const profile = this.config.participants[member.name];
302
+ // An older site lists no effect and takes no report.
303
+ if (member.rewind === undefined || !profile) continue;
304
+ const rewind = capabilities_of(profile.adapter).rewind;
305
+ if (member.rewind === rewind) continue;
306
+ try { await api(this.config, `/members/${member.id}/capabilities`, { rewind }); member.rewind = rewind; }
307
+ catch (error) { this.log(`成员会话能力未同步:${member.name}:${error.message}`); }
308
+ }
309
+ }
310
+ /** Hold only pending review handoffs while their preauthorized next step is judged. */
311
+ schedule_reviews(inbox) {
312
+ const host = inbox.members.find(member => member.role === 'host');
313
+ if (!host || !this.config.members[host.name]?.token || inbox.executions.some(item => item.member_id === host.id)
314
+ || LIVE.includes(this.row(host.name)?.status)) return;
315
+ for (const request of host.pending.filter(item => item.review?.phase === 'handoff')) {
316
+ const saved = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`review-decision:${request.id}`)?.value;
317
+ if (this.routed.has(request.id) || (saved && JSON.parse(saved).done)) continue;
318
+ this.routed.add(request.id);
319
+ this.routing.add(request.id);
320
+ const pending = this.mark_routing(request.message_id, true).then(() => this.judge_review(host, request))
321
+ .catch(error => {
322
+ this.save_suggestion(request, { action: 'host', actual_action: 'host', applied: false,
323
+ model_judgment: false, reason: `没有模型判断:${error.message}` });
324
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`review-decision:${request.id}`, JSON.stringify({ done: true }));
325
+ this.log(`评审交接 #${request.message_id} 交给主持人:${error.message}`);
326
+ }).finally(() => { this.routing.delete(request.id); return this.mark_routing(request.message_id, false); });
327
+ this.decisions.add(pending);
328
+ pending.finally(() => { this.decisions.delete(pending); this.on_decided(); });
329
+ }
330
+ }
331
+ /** Persist the complete judgment and submission before any write, so restart recovery cannot double-dispatch. */
332
+ async judge_review(host, request) {
333
+ const key = `review-decision:${request.id}`;
334
+ const cached = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
335
+ let job = cached ? JSON.parse(cached) : null;
336
+ if (!job) {
337
+ const ready = await api(this.config, `/reviews/${request.review.batch_id}/ready`);
338
+ const decision = await decide_review_completion(this.config.api_home, this.config.scheduler, ready, this.fetch);
339
+ job = { decision, revision: ready.revision, submission_id: randomUUID(), profiles: JSON.stringify(this.config.participants) };
340
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(key, JSON.stringify(job));
341
+ }
342
+ const { decision } = job;
343
+ this.save_suggestion(request, { action: decision.action, actual_action: 'unverified', applied: false,
344
+ model_judgment: Boolean(decision.detail), reason: '自动评审交接结果尚未核验' });
345
+ const outcome = decision.action === 'review' ? await this.apply_review(host, request, job)
346
+ : { applied: false, actual_action: 'host', reason: decision.label };
347
+ const label = outcome.applied ? decision.label : outcome.reason;
348
+ const record = decision.detail ? await this.record_decision(request.message_id, decision, label, outcome.applied,
349
+ { scene: 'review', prompt: REVIEW_PROMPT_VERSION, threshold: REVIEW_CONFIDENCE }) : null;
350
+ this.save_suggestion(request, { action: decision.action, actual_action: outcome.actual_action, applied: outcome.applied,
351
+ model_judgment: Boolean(decision.detail), ...(record?.id ? { decision_id: record.id } : {}), reason: label });
352
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(key, JSON.stringify({ ...job, done: true }));
353
+ }
354
+ /** Advance only the judged revision; inspect an uncertain write using its persisted submission id. */
355
+ async apply_review(host, request, job) {
356
+ if (job.profiles !== JSON.stringify(this.config.participants)) return { applied: false, actual_action: 'host', reason: '本机成员配置已变化,未自动交评审' };
357
+ try {
358
+ await api({ ...this.config, token: this.config.members[host.name].token }, '/reviews/advance', {
359
+ actor: host.name, batch_id: request.review.batch_id, revision: job.revision, submission_id: job.submission_id,
360
+ });
361
+ return { applied: true, actual_action: 'review' };
362
+ } catch (error) {
363
+ try {
364
+ const state = await api(this.config, `/reviews/${request.review.batch_id}/ready`);
365
+ if (state.plan.advance_submission === job.submission_id) return { applied: true, actual_action: 'review' };
366
+ return { applied: false, actual_action: 'host', reason: `未自动交评审:${error.message}` };
367
+ } catch { return { applied: false, actual_action: 'unverified', reason: `评审交接写入结果未核验:${error.message}` }; }
368
+ }
369
+ }
370
+ /**
371
+ * Whether the last tick left nothing in progress: no live delivery or process, no open request for a member this
372
+ * runner delivers to, no pending scheduling judgement and no running discussion. An idle room changes only through its
373
+ * website, so the service may wait for the website to report a change before ticking it again.
374
+ */
375
+ idle() {
376
+ const inbox = this.inbox;
377
+ if (!inbox || this.routing.size || [...this.members.values()].some(children => children.size)) return false;
378
+ if (this.room?.session?.status === 'active' || inbox.executions.length || inbox.members.some(member => member.pending.length)) return false;
379
+ return !this.store.db.prepare('SELECT status FROM free_deliveries').all().some(row => LIVE.includes(row.status));
380
+ }
381
+ /**
382
+ * Smart scheduling: for each new unnamed message routed to the host, ask the provider whether another woken
383
+ * member suits it better and apply a confident pick. The host waits for the answer (or its failure) before starting.
384
+ */
385
+ recommend_unnamed(inbox, room) {
386
+ // Manual members are listed apart; they are never delivered to but may host or be recommended.
387
+ const everyone = [...inbox.members, ...(inbox.observers || [])];
388
+ const host = everyone.find(member => member.role === 'host');
389
+ const waiting = host?.pending.filter(request => request.purpose === 'route' && !request.group_action
390
+ && !this.routed.has(request.id) && !this.saved_suggestion(request.id)) || [];
391
+ for (const request of waiting) {
392
+ this.routed.add(request.id);
393
+ if (everyone.length < 2) continue;
394
+ this.routing.add(request.id);
395
+ const decision = this.mark_routing(request.message_id, true)
396
+ .then(() => this.recommend(request, room, everyone))
397
+ .catch(error => {
398
+ if (!this.saved_suggestion(request.id)) this.save_suggestion(request, { action: 'host', actual_action: 'host',
399
+ applied: false, model_judgment: false, reason: `没有模型判断:${error.message}` });
400
+ this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`);
401
+ })
402
+ .finally(() => { this.routing.delete(request.id); return this.mark_routing(request.message_id, false); });
403
+ this.decisions.add(decision);
404
+ decision.finally(() => { this.decisions.delete(decision); this.on_decided(); });
405
+ }
406
+ }
407
+ /** For each message naming everyone, have them answer at once, or one after another when smart scheduling says so. */
408
+ arrange_groups(inbox, room) {
409
+ const everyone = [...inbox.members, ...(inbox.observers || [])];
410
+ for (const request of everyone.flatMap(member => member.pending).filter(item => item.purpose === 'group' && !this.grouped.has(item.id))) {
411
+ this.grouped.add(request.id);
412
+ const arranged = (this.config.scheduler ? this.mark_routing(request.message_id, true).then(() => this.order_group(request, room))
413
+ .finally(() => this.mark_routing(request.message_id, false)) : this.order_group(request, room))
414
+ .catch(error => { this.grouped.delete(request.id); this.log(`全体成员 #${request.message_id} 未能安排:${error.message}`); });
415
+ this.decisions.add(arranged);
416
+ arranged.finally(() => { this.decisions.delete(arranged); this.on_decided(); });
417
+ }
418
+ }
419
+ /** Read and judge together; either kind of failure hands this one numbered group request back to its host. */
420
+ async judge_group(request, room) {
421
+ try {
422
+ const plan = await api(this.config, `/recommendations/${request.message_id}`);
423
+ const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES)}`);
424
+ const message = page.messages.find(item => item.id === request.message_id);
425
+ if (!message) throw new Error('读取不到这条消息');
426
+ const descriptions = Object.fromEntries(plan.members.map(member => [member.name, member.description]));
427
+ const facts = this.route_members(plan.members, { ...room, descriptions }, message.id);
428
+ const profiles = JSON.stringify(this.config.participants);
429
+ const decision = await decide_group_order(this.config.api_home, this.config.scheduler,
430
+ { title: plan.title, goal: plan.goal, context: page.messages, message,
431
+ members: plan.members.map(member => member.name), descriptions: facts }, this.fetch);
432
+ return { decision, revision: plan.revision, profiles };
433
+ } catch (error) {
434
+ this.log(`智能调度 #${request.message_id} 未判断全体回答方式,交给主持人:${error.message}`);
435
+ return { decision: { action: 'host' } };
436
+ }
437
+ }
438
+ /** Persist a judgment before applying it: website retries and runner restarts must not ask the model again. */
439
+ async order_group(request, room) {
440
+ if (!this.config.scheduler) {
441
+ const plan = await api(this.config, `/recommendations/${request.message_id}`);
442
+ const result = await this.apply_group(request.message_id, { action: 'all' }, plan.revision);
443
+ if (!result.applied) throw new Error(result.error);
444
+ return;
445
+ }
446
+ const key = `group-decision:${request.id}`;
447
+ const saved = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
448
+ const judged = saved ? JSON.parse(saved) : await this.judge_group(request, room);
449
+ if (!saved) this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(key, JSON.stringify(judged));
450
+ const { decision, revision, profiles } = judged;
451
+ this.save_suggestion(request, { action: decision.action, actual_action: 'unverified', applied: false,
452
+ model_judgment: Boolean(decision.detail), reason: decision.detail ? '判断已产生,自动动作结果尚未核验' : '没有模型判断,交给主持人' });
453
+ const labels = { all: '请全体同时回答', sequential: '请全体按发言顺序依次回答', host: '交给主持人处理原消息' };
454
+ let result = profiles && profiles !== JSON.stringify(this.config.participants)
455
+ ? { status: 'changed', applied: false, error: '成员本机配置已变化' }
456
+ : await this.apply_group(request.message_id, decision, revision);
457
+ let actual_action = result.applied ? decision.action : 'unverified';
458
+ let label = labels[decision.action];
459
+ if (result.status === 'changed' && decision.action !== 'host') {
460
+ label += '(未生效,状态已变化)';
461
+ const fallback = await this.apply_group(request.message_id, { action: 'host' });
462
+ if (fallback.applied) actual_action = 'host';
463
+ if (fallback.status === 'failed') result = fallback;
464
+ }
465
+ let record;
466
+ if (decision.detail) {
467
+ if (result.status === 'failed') label += '(未生效,网站写入失败)';
468
+ // The @全体成员 question has one wording in every mode.
469
+ record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host', { prompt: ROUTE_PROMPT_VERSION, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE });
470
+ }
471
+ this.save_suggestion(request, { action: decision.action, actual_action,
472
+ applied: result.applied && decision.action !== 'host', model_judgment: Boolean(decision.detail),
473
+ ...(record?.id ? { decision_id: record.id } : {}), reason: decision.detail ? label : '没有模型判断,交给主持人' });
474
+ if (result.status === 'failed') throw new Error(result.error);
475
+ this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
476
+ }
477
+ /** Apply once with the state that was judged; distinguish conflicts from failed writes and actual success. */
478
+ async apply_group(message_id, decision, revision) {
479
+ try {
480
+ await api(this.config, `/recommendations/${message_id}`, { action: decision.action, ...(revision ? { revision } : {}) });
481
+ this.log(`全体成员 #${message_id}:${{ all: '同时回答', sequential: '依次回答', host: '交给主持人' }[decision.action]}`);
482
+ return { status: 'applied', applied: true };
483
+ } catch (error) {
484
+ return { status: /HTTP (404|409)/.test(error.message) ? 'changed' : 'failed', applied: false, error: error.message };
485
+ }
486
+ }
487
+ /** Tell the website a message is being analysed so it can show the card; a failed mark never blocks routing. */
488
+ async mark_routing(message_id, active) {
489
+ try { await api(this.config, '/scheduler-routing', { message_id, active }); }
490
+ catch (error) { this.log(`智能调度 #${message_id} 分析状态未上报:${error.message}`); }
491
+ }
492
+ /** Describe each member as the scheduler sees it: software, project access and the other messages it is on. */
493
+ route_members(everyone, room, message_id) {
494
+ const requests = (room.free?.requests || []).filter(item => ['pending', 'delivering'].includes(item.status));
495
+ return everyone.map(member => {
496
+ const work = member_work(requests, member.name, message_id);
497
+ const profile = this.config.participants[member.name];
498
+ const described = room.descriptions?.[member.name] ? `;用户描述:${room.descriptions[member.name]}` : '';
499
+ // The model and the last finished message are only read by the guided wording.
500
+ const finished = (room.free?.requests || []).filter(item => item.member === member.name && item.status === 'completed' && item.message_id !== message_id).at(-1);
501
+ return { id: member.id, name: member.name, role: member.role, model: profile?.model || '', finished: finished ? `#${finished.message_id}` : '',
502
+ description: profile ? member_description(member.name, profile, `${work}${described}`)
503
+ : `${member.name}(手动接入,由用户代为触发${work}${described})` };
504
+ });
505
+ }
506
+ /** Retain a request-bound handoff across retries and service restarts; never inline model probabilities. */
507
+ save_suggestion(request, suggestion) {
508
+ const entry = { request_id: request.id, message_id: request.source_message_id || request.message_id, ...suggestion };
509
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`scheduler-suggestion:${request.id}`, JSON.stringify(entry));
510
+ return entry;
511
+ }
512
+ /** Recover the handoff for one exact request, independent of the room's recent decision window. */
513
+ saved_suggestion(request_id) {
514
+ const value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`scheduler-suggestion:${request_id}`)?.value;
515
+ return value ? JSON.parse(value) : null;
516
+ }
517
+ /** Save the website record id only after it is returned; failed logging never invents one. */
518
+ async record_route(request, decision, label, applied) {
519
+ const record = await this.record_decision(request.message_id, decision, label, applied);
520
+ this.save_suggestion(request, { action: decision.action, actual_action: applied ? decision.action : 'host', applied,
521
+ model_judgment: true, ...(record?.id ? { decision_id: record.id } : {}), reason: label });
522
+ }
523
+ /** Decide one message and apply a confident pick: one member, everyone, or no one; otherwise the host keeps it. */
524
+ async recommend(request, room, everyone) {
525
+ const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES)}`);
526
+ const message = page.messages.find(item => item.id === request.message_id);
527
+ if (!message) throw new Error('读取不到这条消息');
528
+ const described = this.route_members(everyone, room, message.id);
529
+ const host = described.find(member => member.role === 'host');
530
+ const members = described.filter(member => member !== host);
531
+ const decision = await route_free_message(this.config.api_home, this.config.scheduler,
532
+ { title: room.title, goal: room.goal, context: page.messages, message, host, members }, this.fetch);
533
+ this.save_suggestion(request, { action: decision.action, actual_action: 'unverified', applied: false,
534
+ model_judgment: true, reason: '判断已产生,自动动作结果尚未核验' });
535
+ this.log(`智能调度 #${message.id}:${decision.action}${decision.member ? ` ${decision.member}` : ''}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
536
+ if (decision.action === 'host') return this.record_route(request, decision, route_host_label(decision, host.name, members), false);
537
+ if (decision.action !== 'member') {
538
+ // Everyone, the named few or no one replaces the host's request in one step; a host that already started keeps the message.
539
+ const applied = await api(this.config, `/recommendations/${message.id}`, { action: decision.action, ...(decision.members ? { members: decision.members } : {}) })
540
+ .then(() => true, error => { this.log(`智能调度 #${message.id} 未能改派:${error.message}`); return false; });
541
+ const names = described.map(member => member.name).join('、');
542
+ const label = decision.action === 'all' ? `请全体各自回应(${names})` : decision.action === 'sequential' ? '请全体按发言顺序依次回应' : decision.action === 'some' ? `请 ${decision.members.join('、')} 各自回应` : '无需回应,未唤醒任何成员';
543
+ return this.record_route(request, decision, applied ? label : `${label}(未生效,仍由主持人处理)`, applied);
544
+ }
545
+ try {
546
+ await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
547
+ provider: this.config.scheduler.provider, confidence: decision.confidence });
548
+ } catch (error) {
549
+ await this.record_route(request, decision, `建议让 ${decision.member} 回答(推荐写入失败,未自动生效)`, false);
550
+ throw error;
551
+ }
552
+ // A confident pick is applied at once, interrupting the host; if that fails the recommendation stays for the user to adopt.
553
+ const adopted = await api(this.config, `/recommendations/${message.id}`, { action: 'adopt', mode: 'interrupt' })
554
+ .then(() => true, error => { this.log(`智能调度 #${message.id} 自动改派失败,保留推荐:${error.message}`); return false; });
555
+ await this.record_route(request, decision, adopted ? `改由 ${decision.member} 回答` : `建议让 ${decision.member} 回答(未能自动改派)`, adopted);
556
+ }
557
+ /** Show each judgment under its message so the user can rate it; a failed record never affects delivery. */
558
+ async record_decision(message_id, decision, label, applied, context = {}) {
559
+ const limits = scheduler_thresholds(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE);
560
+ try {
561
+ return await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
562
+ applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
563
+ context: { model: this.config.scheduler.model, threshold: limits.member, wide_threshold: limits.wide,
564
+ mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: route_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION, ...context } });
565
+ } catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
566
+ }
567
+ /** Reserve with a persisted client id before invoking any software. */
568
+ async start(member, row) {
569
+ const record = this.save(member.name, { client_id: randomUUID(), execution_id: 0, status: 'reserving', error: '',
570
+ attempts: row?.attempts || 0 });
571
+ await this.reserve(member, record);
572
+ }
573
+ /** A retried client id returns the original execution; a finished or refused one just goes idle. */
574
+ async reserve(member, record) {
575
+ let execution;
576
+ const profile = this.config.participants[member.name];
577
+ const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd || this.config.cwd);
578
+ try { execution = await api(this.config, '/executions', { member_id: member.id, client_id: record.client_id, cwd }); }
579
+ catch (error) {
580
+ if (/HTTP (404|409)/.test(error.message)) return this.save(member.name, { status: 'idle', error: '' });
581
+ throw error;
582
+ }
583
+ if (!['dispatched', 'started'].includes(execution.status)) return this.save(member.name, { status: 'idle' });
584
+ this.save(member.name, { execution_id: execution.id, status: 'delivering', started: Date.now(), last_renew: Date.now() });
585
+ await this.invoke(member, execution);
586
+ }
587
+ /** Hand the execution to the member's software; only proven non-delivery is released for retry. */
588
+ async invoke(member, execution) {
589
+ const adapter = this.config.participants[member.name];
590
+ const review_request = execution.requests?.find(request => request.review?.phase === 'review');
591
+ if (review_request) {
592
+ try { await this.check_review_delivery(member, review_request); }
593
+ catch (error) { return this.reject_review(member, execution, error.message); }
594
+ }
595
+ if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
596
+ if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
597
+ const directory = capabilities_of(adapter.adapter).project ? adapter.cwd || this.config.cwd : undefined;
598
+ // A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
599
+ const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
600
+ if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
601
+ const binding = this.store.binding(member.name);
602
+ // A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.
603
+ if (binding.session_id === undefined && adapter.session_id) binding.session_id = adapter.session_id;
604
+ let invoked = false;
605
+ try {
606
+ const warning = await this.cut_rewound(member.name, adapter, binding, execution);
607
+ const session_request = execution.requests?.find(item => item.session_request_id && ['discuss', 'discuss_host'].includes(item.purpose));
608
+ const session = this.room?.session ? { ...this.room.session } : null;
609
+ const scheduler_suggestions = !this.config.scheduler || member.role !== 'host' ? []
610
+ : session_request?.purpose === 'discuss_host' ? this.session_driver.take_suggestions(session_request.id)
611
+ : execution.requests.map(request => this.saved_suggestion(request.id)).filter(Boolean);
612
+ const review_handoffs = await this.review_handoffs(execution);
613
+ // Executions the user stopped that this session may still hold; a rewind already cut its own out, and a new session holds none.
614
+ const rewound = new Set(execution.rewound_executions || []);
615
+ const stopped = binding.session_id ? this.stopped_executions(member.name).filter(id => !rewound.has(id)) : [];
616
+ const workflow = load_room_workflow(this.config);
617
+ // An unreadable workflow leaves members on their room settings; it is logged once per error, not every execution.
618
+ if (workflow?.error && this.workflow_error !== workflow.error) this.log(`工作流 ${workflow.id} 不可用:${workflow.error}`);
619
+ this.workflow_error = workflow?.error || '';
620
+ const task = free_task_files(this.config, member, execution, binding, {
621
+ session, suggestions: scheduler_suggestions, reviews: review_handoffs, stopped, workflow, room_members: this.room?.members,
622
+ });
623
+ invoked = true;
624
+ this.segments.start(execution.id);
625
+ if (warning) this.note(execution.id, warning);
626
+ const result = await this.deliver(review_request ? structuredClone(task.profile) : task.profile, binding, { ...task, room_title: this.room_title,
627
+ ...(review_request ? { before_model_start: () => this.check_review_delivery(member, review_request) } : {}),
628
+ api_context: { site: this.config.site, room: this.config.room, token: this.config.members[member.name].token,
629
+ home: this.config.api_home, mode: 'free', role: member.role || 'member', execution_id: execution.id,
630
+ cwd: directory, project_access: adapter.project_access,
631
+ requests: execution.requests, read_after: execution.read_until, session, discussion_request: session_request,
632
+ scheduler_suggestions, review_handoffs, ...(task.workflow ? { workflow: task.workflow } : {}) },
633
+ on_binding: session => this.store.bind(member.name, session),
634
+ on_process: child => this.track(member.name, child, execution.id),
635
+ on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.touch(execution.id); this.segments.mark(execution.id); },
636
+ on_activity: (phase, tool) => { this.touch(execution.id, phase); this.segments.mark(execution.id, phase, tool); },
637
+ on_note: text => this.note(execution.id, text),
638
+ // A stopped process may report after the next execution started; only its own execution takes the result.
639
+ on_failure: (error, { not_started = false, retryable = true } = {}) => {
640
+ if (not_started) this.not_started.add(execution.id);
641
+ if (!retryable) this.not_retryable.add(execution.id);
642
+ try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ }
643
+ },
644
+ on_exit: denial => this.exited.set(execution.id, denial || ''),
645
+ // Shown beside the answer or failure so the owner can allow them; a lost report only hides the list.
646
+ on_denials: denials => api(this.config, `/executions/${execution.id}`, { action: 'denials', denials }).catch(() => {}),
647
+ });
648
+ if (result.session_id) this.store.bind(member.name, result.session_id);
649
+ this.clear_stopped(member.name);
650
+ this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
651
+ await this.report_delivery(execution.id, result.capabilities || capabilities_of(adapter.adapter).delivery, member.name);
652
+ } catch (error) {
653
+ if (review_request && error.delivery_status === 'not_sent') return this.reject_review(member, execution, error.message);
654
+ if (adapter.project_access === 'own') return this.retry_or_fail(member.name, execution, `投递失败:${error.message}`);
655
+ const attempts = (this.row(member.name)?.attempts || 0) + 1;
656
+ if ((invoked && error.delivery_status !== 'not_sent') || attempts >= MAX_DELIVERY_ATTEMPTS) {
657
+ return this.fail(member.name, execution.id, `投递失败:${error.message}`);
658
+ }
659
+ await api(this.config, `/executions/${execution.id}`, { action: 'release', reason: `确认未送达:${error.message}` });
660
+ this.save(member.name, { status: 'idle', attempts, error: error.message });
661
+ }
662
+ }
663
+ /** Recheck local configuration and the site's identity binding before a review starts. */
664
+ async check_review_delivery(member, request) {
665
+ const proof = request.review;
666
+ const version = review_capability_version(this.config.participants[member.name], member, this.config);
667
+ if (!version || version !== proof.config_version || this.context_errors.has(member.name)) throw new Error('没有可核验只读的评审成员:本机配置与预授权能力版本不符');
668
+ const current = await api(this.config, `/members/${member.id}/review-capability`);
669
+ if (current.revision !== proof.member_revision || current.capability?.config_version !== version) throw new Error('评审者资格或能力版本已改变');
670
+ }
671
+ /** Cancel only this isolated review execution and leave a durable reason for its host. */
672
+ async reject_review(member, execution, reason) {
673
+ await api(this.config, `/executions/${execution.id}`, { action: 'reject_review', reason });
674
+ this.save(member.name, { status: 'idle', error: reason });
675
+ this.stop_member(member.name);
676
+ }
677
+ /** Attach status and read entry points, never inline the implementation context into a wake-up. */
678
+ async review_handoffs(execution) {
679
+ const reviews = [];
680
+ for (const request of execution.requests || []) {
681
+ if (request.review?.phase !== 'handoff') continue;
682
+ const batch_id = request.review.batch_id;
683
+ try {
684
+ const state = await api(this.config, `/reviews/${batch_id}/ready`);
685
+ reviews.push({ batch_id, request_id: request.id, ready: state.ready, reason: state.reason,
686
+ reviewer: state.plan.reviewer_name, scope: state.plan.scope });
687
+ } catch (error) { reviews.push({ batch_id, request_id: request.id, ready: false, reason: `无法读取评审推进状态:${error.message}` }); }
688
+ }
689
+ return reviews;
690
+ }
691
+ /**
692
+ * Before a member works again after a rewind, branch its session before the executions the rewind took back.
693
+ * A Claude fork happens on its next turn, so it stays pending until that session exists. Returns a warning when the
694
+ * session could not be cut and the member continues in the old one.
695
+ */
696
+ async cut_rewound(name, adapter, binding, execution) {
697
+ const key = `session-fork:${name}`;
698
+ const pending = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
699
+ if (pending && !session_exists(adapter.adapter, binding.session_id)) { binding.fork = JSON.parse(pending); return ''; }
700
+ if (pending) this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
701
+ if (!binding.session_id || !execution.rewound_executions?.length || !BRANCHABLE_ADAPTERS.includes(adapter.adapter)) return '';
702
+ try {
703
+ const cut = await branch_session(adapter, binding.session_id, execution.rewound_executions);
704
+ if (!cut) return '';
705
+ if (cut.fork) this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, JSON.stringify(cut.fork));
706
+ this.store.bind(name, cut.session_id);
707
+ Object.assign(binding, cut);
708
+ return '';
709
+ } catch (error) { return `会话未能回退(${error.message}),本次沿用原会话,它可能仍记得已回退的内容`; }
710
+ }
711
+ /** Follow the website's verdict; stop only this member's processes when its execution ends. */
712
+ async reconcile(member, row, active) {
713
+ if (row.status === 'reserving') return this.reserve(member, row);
714
+ if (row.status === 'sidecar_pending') {
715
+ const receipt = delivery_receipt(this.config.participants[member.name], `free-${row.execution_id}`);
716
+ if (receipt?.status === 'accepted' && receipt.session_id) {
717
+ this.store.bind(member.name, receipt.session_id);
718
+ this.save(member.name, { status: 'running' });
719
+ } else if (receipt && active.has(row.execution_id)) return this.retry_or_fail(member.name, active.get(row.execution_id), receipt.error || 'Sidecar 投递结果不明,请核对原会话');
720
+ }
721
+ const execution = active.get(row.execution_id);
722
+ if (!execution) {
723
+ this.activity.delete(row.execution_id);
724
+ await this.report_segments(row.execution_id, true);
725
+ this.stop_member(member.name);
726
+ return this.save(member.name, { status: 'idle', error: '' });
727
+ }
728
+ if (row.status === 'retry_wait') {
729
+ const retry = execution.retry;
730
+ if (retry?.stopped) return this.fail(member.name, execution.id, `${retry.reason}(已停止自动重试)`);
731
+ if (retry?.retry_at && Date.now() >= retry.retry_at * 1000) {
732
+ await api(this.config, `/executions/${execution.id}`, { action: 'retry_start' });
733
+ this.save(member.name, { status: 'delivering' });
734
+ return this.invoke(member, await api(this.config, `/executions/${execution.id}`));
735
+ }
736
+ }
737
+ // A restart between persisting and invoking cannot prove non-delivery, so it never resends.
738
+ if (row.status === 'delivering') return this.fail(member.name, execution.id, '上次投递结果不明,未自动重发;请核对原会话后重新发送');
739
+ if (row.error) return this.retry_or_fail(member.name, execution, row.error);
740
+ if (this.exited.has(execution.id)) {
741
+ const latest = await api(this.config, `/executions/${execution.id}`);
742
+ const denial = this.exited.get(execution.id);
743
+ if (['dispatched', 'started'].includes(latest.status)) return this.fail(member.name, execution.id, denial
744
+ ? `${member.name} ${denial},未提交本次回答`
745
+ : `${member.name} 会话已结束,但未提交本次回答;请核对原会话确认是否已有内容`);
746
+ return this.save(member.name, { status: 'idle' });
747
+ }
748
+ // Segments and notes go first, quietly and often, so a renewal in the same tick already shows the notes it announces.
749
+ if (this.segments.due(execution.id, PROGRESS_REPORT_MS)) await this.report_segments(execution.id);
750
+ const activity = this.activity.get(execution.id);
751
+ const changed = activity && (activity.phase !== activity.sent_phase || activity.at !== activity.sent_at);
752
+ const elapsed = Date.now() - row.last_renew;
753
+ if (elapsed >= RENEW_MS || (changed && elapsed >= ACTIVITY_REPORT_MS)) {
754
+ await api(this.config, `/executions/${execution.id}`, { action: 'renew',
755
+ ...(activity ? { activity: activity.phase, activity_at: Math.floor(activity.at / 1000) } : {}) });
756
+ if (activity) Object.assign(activity, { sent_phase: activity.phase, sent_at: activity.at });
757
+ this.save(member.name, { last_renew: Date.now() });
758
+ }
759
+ }
760
+ /**
761
+ * Tell the site what this delivery can do, as the adapter reported it: whether the runner can stop it and see its
762
+ * progress. Kept locally until the execution ends so a restarted runner still knows whether it could stop it.
763
+ * A lost report is retained for the final stop result; sites and runners must use the same capability protocol.
764
+ */
765
+ async report_delivery(execution_id, { stoppable, progress }, member) {
766
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`delivery:${execution_id}`, JSON.stringify({ stoppable, progress, member }));
767
+ await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress })
768
+ .catch(error => this.log(`执行 #${execution_id} 的投递能力未能上报:${error.message}`));
769
+ }
770
+ /** Retry durable stop reports even after the member went idle, left, or started a later execution. */
771
+ async report_stops(active) {
772
+ const pending = new Set();
773
+ for (const { key, value } of this.store.db.prepare("SELECT key,value FROM metadata WHERE key LIKE 'delivery:%'").all()) {
774
+ const execution_id = Number(key.slice('delivery:'.length));
775
+ if (active.has(execution_id)) continue;
776
+ const delivery = stored_delivery(value);
777
+ const name = delivery.member || this.store.db.prepare('SELECT member FROM free_deliveries WHERE execution_id=?').get(execution_id)?.member;
778
+ if (!name) continue;
779
+ for (const child of this.execution_processes.get(execution_id) || []) this.request_stop(child);
780
+ try { await this.report_stop(name, execution_id); }
781
+ catch (error) { pending.add(name); this.log(`执行 #${execution_id} 的停止结果未能上报:${error.message}`); }
782
+ }
783
+ return pending;
784
+ }
785
+ /** Report observed termination, never infer it from a capability or a successfully sent kill signal. */
786
+ async report_stop(name, execution_id) {
787
+ const key = `delivery:${execution_id}`;
788
+ const value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
789
+ if (!value) return;
790
+ const delivery = stored_delivery(value), processes = this.execution_processes.get(execution_id);
791
+ const ended = processes ? processes.size === 0 : this.exited.has(execution_id);
792
+ const result = delivery.pending_result && delivery.pending_result !== 'unconfirmed' ? delivery.pending_result
793
+ : !delivery.stoppable ? 'unstoppable' : ended ? 'stopped' : 'unconfirmed';
794
+ if (delivery.stop_result === result) return;
795
+ // Keep the observed outcome before sending: a restart after a lost response must retry the same fact.
796
+ this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
797
+ .run(JSON.stringify({ ...delivery, member: name, pending_result: result }), key);
798
+ try { await api(this.config, `/executions/${execution_id}`, { action: 'stopped', result }); }
799
+ catch (error) {
800
+ if (!/HTTP 409:/.test(error.message)) throw error;
801
+ const latest = await api(this.config, `/executions/${execution_id}`);
802
+ // A conflict is not necessarily normal completion: keep cancelled reports until an idempotent receipt arrives.
803
+ if (!['completed', 'failed'].includes(latest.status)) throw error;
804
+ this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
805
+ this.execution_processes.delete(execution_id);
806
+ return;
807
+ }
808
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)')
809
+ .run(`stopped:${name}`, JSON.stringify([...new Set([...this.stopped_executions(name), execution_id])]));
810
+ if (result === 'unconfirmed') {
811
+ this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
812
+ .run(JSON.stringify({ ...delivery, member: name, pending_result: result, stop_result: result }), key);
813
+ // The site's bounded hold now controls the next delivery; retain handles to observe and retry termination.
814
+ for (const child of processes || []) this.members.get(name)?.delete(child);
815
+ }
816
+ else { this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key); this.execution_processes.delete(execution_id); }
817
+ }
818
+ /** Once a new task received the stop reminders, retire acknowledged unconfirmed reports for its old turns. */
819
+ clear_stopped(name) {
820
+ for (const id of this.stopped_executions(name)) {
821
+ const key = `delivery:${id}`, value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
822
+ if (value && stored_delivery(value).stop_result === 'unconfirmed') {
823
+ this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
824
+ this.execution_processes.delete(id);
825
+ }
826
+ }
827
+ this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(`stopped:${name}`);
828
+ }
829
+ /** Executions the user stopped since this member's last delivered task. */
830
+ stopped_executions(name) {
831
+ return JSON.parse(this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`stopped:${name}`)?.value || '[]');
832
+ }
833
+ /** Send the execution's recorded segments and progress notes; `final` sends the rest once it has ended. */
834
+ report_segments(execution_id, final = false) {
835
+ return this.segments.report(execution_id, final, ({ segments, notes }) =>
836
+ api(this.config, `/executions/${execution_id}`, { action: 'segments', segments, ...(notes.length ? { notes } : {}) }));
837
+ }
838
+ /** Note that the software produced output now, and its phase when the output names one. */
839
+ touch(execution_id, phase) {
840
+ // Updated in place: a renewal in flight marks what it sent on this same record.
841
+ if (!this.activity.has(execution_id)) this.activity.set(execution_id, { phase: '' });
842
+ const current = this.activity.get(execution_id);
843
+ if (phase) current.phase = phase;
844
+ current.at = Date.now();
845
+ }
846
+ /** Queue one progress note for the next progress report; numbering makes a retried report harmless. */
847
+ note(execution_id, text) {
848
+ this.touch(execution_id);
849
+ this.segments.note(execution_id, text);
850
+ }
851
+ /** Report observed file changes once an execution has ended and its member's processes have exited. */
852
+ async report_finished(active) {
853
+ for (const [execution_id, snapshot] of this.snapshots) {
854
+ if (active.has(execution_id) || this.execution_processes.get(execution_id)?.size) continue;
855
+ this.snapshots.delete(execution_id);
856
+ const after = workspace_snapshot(snapshot.directory);
857
+ if (!after) continue;
858
+ const changes = changed_between(snapshot.before, after);
859
+ try { await api(this.config, `/executions/${execution_id}`, { action: 'report', changes: changes.slice(0, 200) }); }
860
+ catch (error) { if (!/HTTP (404|409)/.test(error.message)) throw error; }
861
+ }
862
+ }
863
+ /**
864
+ * A manual member's session refused the delivery, or the software failed before any turn ran: keep the execution and
865
+ * deliver again after a wait shown on the website, up to MAX_DELIVERY_RETRIES times; anything else, or the last
866
+ * attempt, fails as before.
867
+ */
868
+ async retry_or_fail(name, execution, reason) {
869
+ const attempt = (execution.retry?.attempt || 0) + 1;
870
+ const not_started = this.not_started.delete(execution.id);
871
+ if (this.not_retryable.delete(execution.id)) return this.fail(name, execution.id, reason);
872
+ if (this.config.participants[name]?.project_access !== 'own' && !not_started) return this.fail(name, execution.id, reason);
873
+ if (attempt > MAX_DELIVERY_RETRIES) return this.fail(name, execution.id, `${reason}(已自动重试 ${MAX_DELIVERY_RETRIES} 次)`);
874
+ this.stop_member(name);
875
+ this.exited.delete(execution.id);
876
+ await api(this.config, `/executions/${execution.id}`, { action: 'retry_wait', attempt,
877
+ retry_at: Math.floor(Date.now() / 1000) + RETRY_DELAY_SECONDS, reason: reason.slice(0, MAX_RETRY_REASON) });
878
+ this.save(name, { status: 'retry_wait', error: '' });
879
+ }
880
+ /** Record the cause on the website, then stop this member's own processes. */
881
+ async fail(name, execution_id, reason) {
882
+ this.activity.delete(execution_id);
883
+ this.not_started.delete(execution_id);
884
+ this.not_retryable.delete(execution_id);
885
+ await this.report_segments(execution_id, true);
886
+ try { await api(this.config, `/executions/${execution_id}`, { action: 'fail', reason }); }
887
+ catch (error) { if (!/HTTP 409/.test(error.message)) throw error; }
888
+ this.save(name, { status: 'idle', error: reason });
889
+ this.stop_member(name);
890
+ }
891
+ /** Count live processes per member so one session never has two writers. */
892
+ track(name, child, execution_id = this.row(name)?.execution_id) {
893
+ this.children.add(child);
894
+ if (!this.members.has(name)) this.members.set(name, new Set());
895
+ this.members.get(name).add(child);
896
+ if (!this.execution_processes.has(execution_id)) this.execution_processes.set(execution_id, new Set());
897
+ const processes = this.execution_processes.get(execution_id);
898
+ processes.add(child);
899
+ child.once('close', () => { processes.delete(child); this.children.delete(child); this.members.get(name).delete(child); });
900
+ }
901
+ /** Request termination; only a later close event proves it happened. */
902
+ request_stop(child) { try { child.kill(); } catch { /* The stop report remains unconfirmed. */ } }
903
+ /** Stop one member's processes without touching other members or desktop apps. */
904
+ stop_member(name) { for (const child of this.members.get(name) || []) this.request_stop(child); }
905
+ /** Stop every process this runner created. */
906
+ stop_children() { for (const child of this.children) this.request_stop(child); }
907
+ }