ai_co_discuss 0.2.26 → 0.2.28

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,465 +1,472 @@
1
- /** Durable website-event delivery to AI software and explicitly configured API participants. */
2
- import { createHash, randomUUID } from 'node:crypto';
3
- import { spawnSync } from 'node:child_process';
4
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
5
- import { dirname, join, resolve } from 'node:path';
6
- import { DatabaseSync } from 'node:sqlite';
7
- import { fileURLToPath, pathToFileURL } from 'node:url';
8
- import { parseArgs } from 'node:util';
9
- import { setTimeout as sleep } from 'node:timers/promises';
10
- import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
11
- import { read_api_key } from './discussion_api.mjs';
12
-
13
- const POLL_MS = 2000;
14
- /** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
15
- let rg_available;
16
- /** Windows PowerShell mis-decodes UTF-8 files, so point the AI at rg only when it is installed. */
17
- function windows_rg() {
18
- if (process.platform !== 'win32') return false;
19
- rg_available ??= spawnSync('rg', ['--version'], { stdio: 'ignore', windowsHide: true }).status === 0;
20
- return rg_available;
21
- }
22
-
23
- const AUTO_ENTER_ADAPTERS = new Set(['codex_cli', 'claude', 'antigravity_cli']);
24
- const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
25
- const RENEW_MS = 30_000;
26
- const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli', 'api'];
27
- export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
28
- const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
- /** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
30
- const PROJECT_ACCESS = ['read', 'write'];
31
-
32
- /** Explicit per-room configuration is the only source of software paths and capabilities. */
33
- export function load_config(path) {
34
- return validate_config(JSON.parse(readFileSync(path, 'utf8')), dirname(resolve(path)));
35
- }
36
-
37
- /** Keep service-managed answers outside all room credentials, logs and databases. */
38
- export function service_drafts(home, directory) {
39
- const key = createHash('sha256').update(resolve(directory)).digest('hex');
40
- return join(home, 'drafts', key);
41
- }
42
-
43
- /** Validate a launch draft with the same rules as a registered room configuration. */
44
- export function validate_config(input, base_directory) {
45
- const config = structuredClone(input);
46
- config.project = config.cli_project || config.project || REPO_ROOT;
47
- const url = new URL(config.site);
48
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('站点必须是无路径和凭据的 HTTP(S) 根地址');
49
- if (!/^[A-Za-z0-9_-]+$/.test(config.room ?? '') || typeof config.token !== 'string' || config.token.length < 32) throw new Error('缺少房间及本房间凭据');
50
- if (!config.project || !existsSync(join(config.project, 'runner', 'discuss.py'))) throw new Error('cli_project 必须指向 AI CoDiscuss 项目;省略时默认使用本仓库');
51
- if (!config.participants || !Object.keys(config.participants).length) throw new Error('请配置本机参与软件');
52
- for (const participant of Object.values(config.participants)) {
53
- if (participant.adapter === 'api') {
54
- read_api_key(config.api_home, participant);
55
- for (const field of ['cwd', 'project_name', 'project_id', 'executable', 'effort']) delete participant[field];
56
- participant.project_access = 'read';
57
- continue;
58
- }
59
- participant.cwd = participant.cwd ?? config.cwd;
60
- participant.project_name = participant.project_name ?? config.discussion_project;
61
- participant.project_access = participant.project_access || config.project_access || 'read';
62
- if (!PROJECT_ACCESS.includes(participant.project_access)) throw new Error('project_access 只能为 read 或 write');
63
- if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
64
- if (!ADAPTER_NAMES.includes(participant.adapter)) throw new Error(`适配器只能为 ${ADAPTER_NAMES.join('/')}`);
65
- if (participant.adapter === 'antigravity' && !participant.ipc_directory) throw new Error('缺少 Antigravity Sidecar IPC 目录');
66
- if (participant.adapter !== 'antigravity' && !existsSync(participant.executable ?? '')) throw new Error('请指定实际原生可执行文件');
67
- if (participant.adapter === 'codex' && !participant.source_thread_id && !participant.session_id) throw new Error('Codex 新建入口需要实际来源聊天 ID');
68
- }
69
- if (![undefined, 'free', 'brainstorm'].includes(config.mode)) throw new Error('房间模式只能为 free 或 brainstorm');
70
- if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
71
- throw new Error('自由房间须为每位成员保存成员凭证');
72
- }
73
- return { ...config, site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
74
- }
75
-
76
- /** Send exactly one bounded website request; never follow redirects with a capability. */
77
- export async function api(config, tail, body, token = config.token) {
78
- const response = await fetch(`${config.site}/api/rooms/${config.room}${tail}`, {
79
- method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
80
- headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
81
- body: body === undefined ? undefined : JSON.stringify(body),
82
- });
83
- const result = await response.json();
84
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
85
- return result;
86
- }
87
-
88
- /** Persistent inbox, cursor and bindings share one local SQLite transaction boundary. */
89
- export class DiscussionStore {
90
- constructor(directory) {
91
- mkdirSync(directory, { recursive: true });
92
- this.db = new DatabaseSync(join(directory, 'runtime.sqlite3'));
93
- this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
94
- CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
95
- CREATE TABLE IF NOT EXISTS inbox(id INTEGER PRIMARY KEY,event TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',
96
- token TEXT NOT NULL DEFAULT '',sent_at INTEGER NOT NULL DEFAULT 0,last_renew INTEGER NOT NULL DEFAULT 0,error TEXT NOT NULL DEFAULT '');
97
- CREATE TABLE IF NOT EXISTS bindings(participant TEXT PRIMARY KEY,session_id TEXT NOT NULL);`);
98
- const columns = new Set(this.db.prepare('PRAGMA table_info(inbox)').all().map(column => column.name));
99
- for (const [name, definition] of Object.entries({ attempts: 'INTEGER NOT NULL DEFAULT 0',
100
- retry_at: 'INTEGER NOT NULL DEFAULT 0', receipt: "TEXT NOT NULL DEFAULT ''" })) {
101
- if (!columns.has(name)) this.db.exec(`ALTER TABLE inbox ADD COLUMN ${name} ${definition}`);
102
- }
103
- }
104
- /** Atomically receive a page before moving its cursor. */
105
- receive(page) {
106
- this.db.exec('BEGIN IMMEDIATE');
107
- try {
108
- for (const event of page.events) this.db.prepare('INSERT OR IGNORE INTO inbox(id,event) VALUES (?,?)').run(event.id, JSON.stringify(event));
109
- this.db.prepare("INSERT INTO metadata VALUES ('cursor',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(page.next_after));
110
- this.db.exec('COMMIT');
111
- } catch (error) { this.db.exec('ROLLBACK'); throw error; }
112
- }
113
- /** Return the last durably ingested sequence. */
114
- cursor() { return Number(this.db.prepare("SELECT value FROM metadata WHERE key='cursor'").get()?.value || 0); }
115
- /** Never reuse another site's cursor or a previous runtime's software binding. */
116
- scope(config, run_id) {
117
- const scope = `${config.site}/${config.room}/${run_id}`;
118
- const previous = this.db.prepare("SELECT value FROM metadata WHERE key='scope'").get()?.value;
119
- if (previous && previous !== scope) throw new Error('数据目录已绑定另一站点、房间或运行;请选择独立目录');
120
- this.db.prepare("INSERT OR IGNORE INTO metadata VALUES ('scope',?)").run(scope);
121
- }
122
- /** Do not silently reuse a conversation after its software project or cwd changes. */
123
- context(config, allow_codex_switch = false) {
124
- const entries = Object.entries(config.participants).sort(([left], [right]) => left.localeCompare(right))
125
- .map(([name, item]) => [name, item.adapter, item.project_id || '', item.project_name || '',
126
- item.cwd ? resolve(item.cwd) : '', item.workdir ? resolve(item.workdir) : '', item.session_id || '']);
127
- const context = JSON.stringify(entries);
128
- const previous = this.db.prepare("SELECT value FROM metadata WHERE key='context'").get()?.value;
129
- if (previous === context) return;
130
- if (previous && previous !== context) {
131
- const before = JSON.parse(previous);
132
- const allowed = allow_codex_switch && before.length === entries.length && before.every((item, index) => {
133
- const next = entries[index];
134
- if (JSON.stringify(item) === JSON.stringify(next)) return true;
135
- const session = this.binding(item[0]).session_id || item[6];
136
- return item[0] === next[0] && item[1] !== next[1] && CODEX_ADAPTERS.includes(item[1])
137
- && CODEX_ADAPTERS.includes(next[1]) && session && next[6] === session && (!item[6] || item[6] === session)
138
- && JSON.stringify(item.slice(2, 6)) === JSON.stringify(next.slice(2, 6));
139
- });
140
- // Experts invited during a run only add entries; every existing binding must stay as it was.
141
- const added = before.every(item => entries.some(next => JSON.stringify(next) === JSON.stringify(item)));
142
- if (!allowed && !added) throw new Error('已绑定会话的项目、cwd 或身份配置发生变化;请暂停并使用独立运行目录重新接入');
143
- }
144
- this.db.prepare("INSERT INTO metadata VALUES ('context',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(context);
145
- }
146
- /** Persist only known internal fields, never dynamic SQL column names. */
147
- record(id, fields) {
148
- const allowed = ['status', 'token', 'sent_at', 'last_renew', 'error', 'attempts', 'retry_at', 'receipt'];
149
- const entries = Object.entries(fields);
150
- if (entries.some(([key]) => !allowed.includes(key))) throw new Error('无效的内部状态字段');
151
- this.db.prepare(`UPDATE inbox SET ${entries.map(([key]) => `${key}=?`).join(',')} WHERE id=?`)
152
- .run(...entries.map(([, value]) => value), id);
153
- }
154
- /** Resolve actual session bindings without reading unrelated conversations. */
155
- binding(participant) { return this.db.prepare('SELECT session_id FROM bindings WHERE participant=?').get(participant) || {}; }
156
- /** Commit a returned session before later notifications can use it. */
157
- bind(participant, session_id) { this.db.prepare('INSERT INTO bindings VALUES (?,?) ON CONFLICT(participant) DO UPDATE SET session_id=excluded.session_id').run(participant, session_id); }
158
- /** List work that still needs dispatch or reconciliation. */
159
- pending() { return this.db.prepare("SELECT * FROM inbox WHERE status NOT IN ('completed','cancelled','failed') ORDER BY id").all(); }
160
- /** Release this instance's database connection. */
161
- close() { this.db.close(); }
162
- }
163
-
164
- /** Hold one PID lock per runtime directory, without taking over a live owner. */
165
- export function acquire_lock(directory) {
166
- mkdirSync(directory, { recursive: true });
167
- const path = join(directory, 'runner.lock');
168
- if (existsSync(path)) {
169
- const pid = Number(readFileSync(path, 'utf8'));
170
- let alive = true;
171
- try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
172
- if (alive) throw new Error('此目录已有通知程序运行;不重复启动');
173
- unlinkSync(path);
174
- }
175
- writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
176
- return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
177
- }
178
-
179
- /** Write a non-secret task separately from its CLI-only credential file. */
180
- export function task_files(config, event, token, binding) {
181
- const id = `event-${event.id}`;
182
- const draft_directory = config.draft_directory || `${config.directory}-drafts`;
183
- mkdirSync(draft_directory, { recursive: true });
184
- const access_path = join(config.directory, `${id}.access.json`);
185
- const task_path = join(config.directory, `${id}.task.json`);
186
- atomic_json(access_path, { site: config.site, room: config.room, token, session_id: binding.session_id || '' });
187
- const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
188
- if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
189
- const adapter = config.participants[event.participant];
190
- const auto_enter = AUTO_ENTER_ADAPTERS.has(adapter.adapter);
191
- const skills_directory = join(config.project, '.agents', 'skills');
192
- // Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
193
- const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
194
- const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
195
- : adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
196
- atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
197
- project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
198
- draft_directory,
199
- role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
200
- instructions: `${adapter.adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;浏览项目用 list_dir、find_by_name、grep_search、view_file。` : ''}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。` });
201
- const read_rule = windows_rg() ? '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。' : '';
202
- const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。${read_rule}`;
203
- const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,用 turn create --plan-file 一次排满本轮每位专家,不要每次只排一位。' : '';
204
- const intro = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。${plan_rule}`;
205
- const prompt = auto_enter
206
- ? `${intro}接入程序已为本次执行完成 execution enter,不要再执行。任务内容如下(同一份保存在 ${task_path},无需再读):
207
- ${readFileSync(task_path, 'utf8')}
208
- 请直接读取指定版本的技能和网站现状。${scope}实际会话 ID 已由接入程序写入凭据文件。`
209
- : `${intro}先读取任务文件:${task_path},立即用 ${cli} execution enter 进入,再读取指定版本的技能和网站现状。${scope}实际会话 ID 由接入程序写入凭据文件,尚未就绪只短暂重查,不编造。`;
210
- return { id, participant: event.participant, directory: config.directory, draft_directory, skills_directory, access_path, prompt };
211
- }
212
-
213
- /** A running dispatcher retains only the processes it created itself. */
214
- export class DiscussionRunner {
215
- constructor(config, store, deliver = deliver_task) {
216
- this.config = config;
217
- this.store = store;
218
- this.deliver = deliver;
219
- this.children = new Set();
220
- this.exited = new Map();
221
- this.running = new Map();
222
- this.seen = new Set();
223
- this.blocked = new Set();
224
- }
225
- /** Append one timestamped stage to the event's trace file when CODISCUSS_TRACE=1; tracing must never affect delivery. */
226
- trace(id, stage, detail = {}) {
227
- if (process.env.CODISCUSS_TRACE !== '1') return;
228
- try { appendFileSync(join(this.config.directory, `event-${id}.trace.jsonl`), JSON.stringify({ t: new Date().toISOString(), stage, ...detail }) + '\n', { mode: 0o600 }); }
229
- catch { /* Diagnostics only. */ }
230
- }
231
- /** Reserve with a durable token so a lost HTTP response can be reconciled. */
232
- async dispatch(row, event) {
233
- const token = row.token || randomUUID();
234
- this.store.record(row.id, { token, status: 'reserving' });
235
- this.trace(row.id, 'reserve_start');
236
- await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
237
- this.trace(row.id, 'reserve_done');
238
- await this.deliver_reserved({ ...row, token }, event);
239
- }
240
- /** Retry only proven non-delivery, keeping the event, token and task unchanged. */
241
- async deliver_reserved(row, event) {
242
- const adapter = this.config.participants[event.participant];
243
- if (!adapter) throw new Error(`未配置 ${event.participant} 的本机接入`);
244
- const binding = this.store.binding(event.participant);
245
- if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
246
- const attempts = row.attempts + 1;
247
- this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
248
- let invoked = false;
249
- let entered = false;
250
- try {
251
- const task = task_files(this.config, event, row.token, binding);
252
- invoked = true;
253
- const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
254
- api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
255
- event_id: row.id, role: event.role, turn_id: event.turn_id },
256
- on_trace: (stage, detail) => this.trace(row.id, stage, detail),
257
- on_binding: session => {
258
- this.store.bind(event.participant, session);
259
- // agy reports its session again in the result event; enter only once.
260
- if (AUTO_ENTER_ADAPTERS.has(adapter.adapter) && !entered) { entered = true; this.enter(row, session); }
261
- },
262
- on_process: child => this.track(event.participant, child),
263
- on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
264
- on_failure: error => { try { this.store.record(row.id, { error }); } catch { /* Store may already be closed at shutdown. */ } },
265
- on_exit: detail => this.exited.set(row.id, detail || ''),
266
- });
267
- if (result.session_id) this.store.bind(event.participant, result.session_id);
268
- this.store.record(row.id, { status: result.status, receipt: result.receipt || '' });
269
- } catch (error) {
270
- const not_sent = !invoked || error.delivery_status === 'not_sent';
271
- this.store.record(row.id, { status: not_sent ? 'retry_wait' : 'uncertain', error: error.message,
272
- retry_at: not_sent ? Date.now() + (DELIVERY_RETRY_DELAYS_MS[attempts - 1] ?? 0) : 0 });
273
- }
274
- }
275
- /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
276
- async enter(row, session_id) {
277
- try {
278
- this.trace(row.id, 'enter_start');
279
- await api(this.config, `/events/${row.id}`, { action: 'enter', session_id }, row.token);
280
- this.trace(row.id, 'enter_done');
281
- }
282
- catch (error) { try { this.store.record(row.id, { error: `接入程序进入执行失败:${error.message}` }); } catch { /* Store may already be closed at shutdown. */ } }
283
- }
284
- /** Reconcile business evidence; accepted delivery alone never completes a task. */
285
- async reconcile(row, event) {
286
- if (['completed', 'cancelled', 'failed'].includes(event.status)) {
287
- this.store.record(row.id, { status: event.status });
288
- return;
289
- }
290
- // No app call occurs before dispatching is persisted. Reconcile a reserve response loss safely.
291
- if (row.status === 'reserving') {
292
- if (event.status === 'pending') return this.dispatch(row, event);
293
- if (event.status === 'dispatched') {
294
- const reserved = await api(this.config, `/events/${row.id}`, { action: 'view' }, row.token);
295
- if (reserved.status !== 'dispatched') return this.reconcile(row, reserved);
296
- return this.deliver_reserved(row, reserved);
297
- }
298
- }
299
- if (row.status === 'retry_wait' && event.status === 'dispatched') {
300
- if (row.attempts > DELIVERY_RETRY_DELAYS_MS.length) return this.fail(row, `投递 ${row.attempts} 次均确认未送出:${row.error}`);
301
- if (Date.now() >= row.retry_at) await this.deliver_reserved(row, event);
302
- return;
303
- }
304
- const adapter = this.config.participants[event.participant];
305
- if (adapter?.adapter === 'antigravity' && event.status === 'dispatched') {
306
- const receipt = sidecar_receipt(adapter, `event-${row.id}`);
307
- if (receipt?.session_id) this.store.bind(event.participant, receipt.session_id);
308
- if (receipt) this.store.record(row.id, { status: receipt.status, receipt: JSON.stringify(receipt), error: receipt.error || '' });
309
- }
310
- if (row.error && ['accepted', 'started'].includes(row.status)) return this.fail(row, row.error);
311
- if (this.exited.has(row.id) && ['dispatched', 'started'].includes(event.status)) {
312
- // A business write may have just completed the task; recheck before declaring a silent exit.
313
- const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
314
- if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果${this.exited.get(row.id) ? `:${this.exited.get(row.id)}` : ''}`);
315
- return this.reconcile(row, latest);
316
- }
317
- if (event.status === 'started' && event.lease_until <= Math.floor(Date.now() / 1000)) return this.fail(row, '执行租约到期;旧会话不能继续写入');
318
- if (event.status === 'started' && Date.now() - row.last_renew >= RENEW_MS) {
319
- try { await api(this.config, `/events/${row.id}`, { action: 'renew' }); }
320
- catch (error) {
321
- if (/HTTP (403|409)/.test(error.message)) return this.fail(row, '执行时限已到;请核对原会话');
322
- throw error;
323
- }
324
- this.store.record(row.id, { status: 'started', last_renew: Date.now(), error: '' });
325
- }
326
- }
327
- /** Preserve the cause on the website and revoke work before asking for intervention. */
328
- async fail(row, reason) {
329
- try { await api(this.config, `/events/${row.id}`, { action: 'fail', reason }); }
330
- catch (error) {
331
- if (!/HTTP 409/.test(error.message)) throw error;
332
- const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
333
- if (['completed', 'cancelled', 'failed'].includes(latest.status)) return this.reconcile(row, latest);
334
- throw error;
335
- }
336
- this.store.record(row.id, { status: 'failed', error: reason });
337
- this.stop_children();
338
- }
339
- /** Process one bounded iteration without waking models for empty checks. */
340
- async tick() {
341
- this.store.context(this.config);
342
- let room;
343
- try { room = await api(this.config, ''); }
344
- catch (error) {
345
- // Only a permanently deleted room drops its private agy projects; concluded or archived rooms keep them for later continuation.
346
- if (/^HTTP 404: 房间不存在/.test(error.message)) remove_agy_projects(this.config.directory);
347
- throw error;
348
- }
349
- if (room.limits?.reached && (!room.limits.closing || room.limits.reason === 'time_limit') && room.runtime.status === 'running') {
350
- const generation = room.runtime.generation;
351
- room = await api(this.config, '/limits', { actor: '用户' });
352
- if (room.runtime.generation !== generation) this.stop_children();
353
- }
354
- this.room_title = room.title;
355
- const run = room.runtime;
356
- if (run.run_id) this.store.scope(this.config, run.run_id);
357
- if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
358
- this.stop_children();
359
- if (run.expired) await api(this.config, '/runtime', { action: 'pause', revision: run.revision });
360
- for (const row of this.store.pending()) {
361
- const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
362
- if (['completed', 'cancelled', 'failed'].includes(event.status)) this.store.record(row.id, { status: event.status });
363
- }
364
- // A paused automatic run waits so the web restart can resume it; stop, conclusion or manual mode ends the loop.
365
- return run.mode === 'automatic' && (run.status === 'paused' || run.expired) ? 'waiting' : false;
366
- }
367
- this.store.receive(await api(this.config, `/events?after=${this.store.cursor()}`));
368
- const rows = this.store.pending();
369
- for (const row of rows.filter(item => item.status !== 'pending')) {
370
- const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
371
- await this.reconcile(row, event);
372
- }
373
- if (this.store.pending().some(row => row.status !== 'pending')) return;
374
- const current = room.current_turn;
375
- const pending = rows.filter(row => row.status === 'pending').map(row => ({ row, event: JSON.parse(row.event) }));
376
- const eligible = pending.filter(({ event }) => event.generation === run.generation && event.run_id === run.run_id
377
- && (current && ['pending', 'notified'].includes(current.status) ? event.turn_id === current.id : event.role === 'host'));
378
- for (const { row, event } of pending) {
379
- if (event.generation !== run.generation || event.run_id !== run.run_id) this.store.record(row.id, { status: 'cancelled' });
380
- }
381
- for (const { row, event } of pending) if (!this.seen.has(row.id)) { this.seen.add(row.id); this.trace(row.id, 'event_seen', { event_created_at: event.created_at }); }
382
- const next = eligible.at(-1);
383
- if (next && this.running.get(next.event.participant) && !this.blocked.has(next.row.id)) {
384
- this.blocked.add(next.row.id);
385
- this.trace(next.row.id, 'blocked_by_running_process', { participant: next.event.participant });
386
- }
387
- // A host that just saved the next turn may still be exiting; the same session cannot accept a second writer yet.
388
- if (next && !this.running.get(next.event.participant)) {
389
- const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
390
- if (fresh.status === 'pending') {
391
- await this.dispatch(next.row, fresh);
392
- }
393
- else this.store.record(next.row.id, { status: fresh.status });
394
- }
395
- }
396
- /** Count live processes per participant so a resumed session never overlaps its previous writer. */
397
- track(participant, child) {
398
- this.children.add(child);
399
- this.running.set(participant, (this.running.get(participant) || 0) + 1);
400
- child.once('close', () => {
401
- this.children.delete(child);
402
- this.running.set(participant, this.running.get(participant) - 1);
403
- });
404
- }
405
- /** Stop only child processes created by this runner, never the desktop apps. */
406
- stop_children() { for (const child of this.children) child.kill(); }
407
- }
408
-
409
- /** Pause a running automatic run so the website never shows work nobody will consume. */
410
- export async function pause_if_running(config) {
411
- const latest = await api(config, '/runtime');
412
- if (latest.status === 'running') await api(config, '/runtime', { action: 'pause', revision: latest.revision });
413
- }
414
-
415
- /** Run an explicitly configured room; install and status never start a discussion. */
416
- export async function main(args = process.argv.slice(2)) {
417
- const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
418
- config: { type: 'string' }, once: { type: 'boolean' }, minutes: { type: 'string' },
419
- } });
420
- if (!values.config || !['watch', 'start', 'status', 'pause', 'setup-sidecar'].includes(positionals[0])) {
421
- throw new Error('用法: discussion_runner.mjs watch|start|status|pause|setup-sidecar --config 私有配置.json [--minutes 60] [--once]');
422
- }
423
- const config = load_config(values.config);
424
- if (positionals[0] === 'setup-sidecar') {
425
- for (const participant of Object.values(config.participants).filter(item => item.adapter === 'antigravity')) process.stdout.write(JSON.stringify(setup_sidecar(participant)) + '\n');
426
- return;
427
- }
428
- const run = await api(config, '/runtime');
429
- if (positionals[0] === 'status') { process.stdout.write(JSON.stringify(run) + '\n'); return; }
430
- if (positionals[0] === 'pause') { await api(config, '/runtime', { action: 'pause', revision: run.revision }); return; }
431
- const release = acquire_lock(config.directory);
432
- const store = new DiscussionStore(config.directory);
433
- const runner = new DiscussionRunner(config, store);
434
- let stopped = false;
435
- const stop = () => { stopped = true; };
436
- process.once('SIGINT', stop);
437
- process.once('SIGTERM', stop);
438
- try {
439
- store.context(config);
440
- if (run.run_id) store.scope(config, run.run_id);
441
- if (positionals[0] === 'start') {
442
- const room = await api(config, '');
443
- const minutes = Number(values.minutes ?? room.limits?.max_minutes ?? 0);
444
- if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) throw new Error('总时长或 --minutes 须在 [0,1440],0 表示不限时长');
445
- if (![room.host, ...Object.keys(room.experts)].every(name => config.participants[name])) throw new Error('尚未配置全部参与者的本机软件');
446
- await api(config, '/runtime', { action: 'start', mode: 'automatic', revision: run.revision, deadline: minutes ? Math.floor(Date.now() / 1000 + minutes * 60) : 0 });
447
- }
448
- do {
449
- try { if (await runner.tick() === false) break; }
450
- catch (error) { process.stderr.write(JSON.stringify({ error: error.message }) + '\n'); if (values.once) throw error; }
451
- if (!values.once && !stopped) await sleep(POLL_MS);
452
- } while (!values.once && !stopped);
453
- } finally {
454
- if (stopped) {
455
- try { await pause_if_running(config); }
456
- catch (error) { process.stderr.write(`停止回执未确认;租约到期后拒绝写入:${error.message}\n`); }
457
- }
458
- runner.stop_children(); store.close(); release();
459
- process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
460
- }
461
- }
462
-
463
- if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
464
- main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
465
- }
1
+ /** Durable website-event delivery to AI software and explicitly configured API participants. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { parseArgs } from 'node:util';
9
+ import { setTimeout as sleep } from 'node:timers/promises';
10
+ import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
11
+ import { read_api_key } from './discussion_api.mjs';
12
+
13
+ const POLL_MS = 2000;
14
+ /** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
15
+ let rg_available;
16
+ /** Windows PowerShell mis-decodes UTF-8 files, so point the AI at rg only when it is installed. */
17
+ function windows_rg() {
18
+ if (process.platform !== 'win32') return false;
19
+ rg_available ??= spawnSync('rg', ['--version'], { stdio: 'ignore', windowsHide: true }).status === 0;
20
+ return rg_available;
21
+ }
22
+
23
+ const AUTO_ENTER_ADAPTERS = new Set(['codex_cli', 'claude', 'antigravity_cli']);
24
+ const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
25
+ const RENEW_MS = 30_000;
26
+ const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli', 'api'];
27
+ export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
28
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
+ /** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
30
+ const PROJECT_ACCESS = ['read', 'write'];
31
+
32
+ /** Explicit per-room configuration is the only source of software paths and capabilities. */
33
+ export function load_config(path) {
34
+ return validate_config(JSON.parse(readFileSync(path, 'utf8')), dirname(resolve(path)));
35
+ }
36
+
37
+ /** Keep service-managed answers outside all room credentials, logs and databases. */
38
+ export function service_drafts(home, directory) {
39
+ const key = createHash('sha256').update(resolve(directory)).digest('hex');
40
+ return join(home, 'drafts', key);
41
+ }
42
+
43
+ /** Validate a launch draft with the same rules as a registered room configuration. */
44
+ export function validate_config(input, base_directory) {
45
+ const config = structuredClone(input);
46
+ config.project = config.cli_project || config.project || REPO_ROOT;
47
+ const url = new URL(config.site);
48
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('站点必须是无路径和凭据的 HTTP(S) 根地址');
49
+ if (!/^[A-Za-z0-9_-]+$/.test(config.room ?? '') || typeof config.token !== 'string' || config.token.length < 32) throw new Error('缺少房间及本房间凭据');
50
+ if (!config.project || !existsSync(join(config.project, 'runner', 'discuss.py'))) throw new Error('cli_project 必须指向 AI CoDiscuss 项目;省略时默认使用本仓库');
51
+ if (!config.participants || !Object.keys(config.participants).length) throw new Error('请配置本机参与软件');
52
+ for (const participant of Object.values(config.participants)) {
53
+ if (participant.adapter === 'api') {
54
+ read_api_key(config.api_home, participant);
55
+ for (const field of ['cwd', 'project_name', 'project_id', 'executable', 'effort']) delete participant[field];
56
+ participant.project_access = 'read';
57
+ continue;
58
+ }
59
+ participant.cwd = participant.cwd ?? config.cwd;
60
+ participant.project_name = participant.project_name ?? config.discussion_project;
61
+ participant.project_access = participant.project_access || config.project_access || 'read';
62
+ if (!PROJECT_ACCESS.includes(participant.project_access)) throw new Error('project_access 只能为 read 或 write');
63
+ if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
64
+ if (!ADAPTER_NAMES.includes(participant.adapter)) throw new Error(`适配器只能为 ${ADAPTER_NAMES.join('/')}`);
65
+ if (participant.adapter === 'antigravity' && !participant.ipc_directory) throw new Error('缺少 Antigravity Sidecar IPC 目录');
66
+ if (participant.adapter !== 'antigravity' && !existsSync(participant.executable ?? '')) throw new Error('请指定实际原生可执行文件');
67
+ if (participant.adapter === 'codex' && !participant.source_thread_id && !participant.session_id) throw new Error('Codex 新建入口需要实际来源聊天 ID');
68
+ }
69
+ if (![undefined, 'free', 'brainstorm'].includes(config.mode)) throw new Error('房间模式只能为 free 或 brainstorm');
70
+ if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
71
+ throw new Error('自由房间须为每位成员保存成员凭证');
72
+ }
73
+ return { ...config, site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
74
+ }
75
+
76
+ /** Send exactly one bounded website request; never follow redirects with a capability. */
77
+ export async function api(config, tail, body, token = config.token) {
78
+ const response = await fetch(`${config.site}/api/rooms/${config.room}${tail}`, {
79
+ method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
80
+ headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
81
+ body: body === undefined ? undefined : JSON.stringify(body),
82
+ });
83
+ const result = await response.json();
84
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
85
+ return result;
86
+ }
87
+
88
+ /** Persistent inbox, cursor and bindings share one local SQLite transaction boundary. */
89
+ export class DiscussionStore {
90
+ constructor(directory) {
91
+ mkdirSync(directory, { recursive: true });
92
+ this.db = new DatabaseSync(join(directory, 'runtime.sqlite3'));
93
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
94
+ CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
95
+ CREATE TABLE IF NOT EXISTS inbox(id INTEGER PRIMARY KEY,event TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',
96
+ token TEXT NOT NULL DEFAULT '',sent_at INTEGER NOT NULL DEFAULT 0,last_renew INTEGER NOT NULL DEFAULT 0,error TEXT NOT NULL DEFAULT '');
97
+ CREATE TABLE IF NOT EXISTS bindings(participant TEXT PRIMARY KEY,session_id TEXT NOT NULL);`);
98
+ const columns = new Set(this.db.prepare('PRAGMA table_info(inbox)').all().map(column => column.name));
99
+ for (const [name, definition] of Object.entries({ attempts: 'INTEGER NOT NULL DEFAULT 0',
100
+ retry_at: 'INTEGER NOT NULL DEFAULT 0', receipt: "TEXT NOT NULL DEFAULT ''" })) {
101
+ if (!columns.has(name)) this.db.exec(`ALTER TABLE inbox ADD COLUMN ${name} ${definition}`);
102
+ }
103
+ }
104
+ /** Atomically receive a page before moving its cursor. */
105
+ receive(page) {
106
+ this.db.exec('BEGIN IMMEDIATE');
107
+ try {
108
+ for (const event of page.events) this.db.prepare('INSERT OR IGNORE INTO inbox(id,event) VALUES (?,?)').run(event.id, JSON.stringify(event));
109
+ this.db.prepare("INSERT INTO metadata VALUES ('cursor',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(page.next_after));
110
+ this.db.exec('COMMIT');
111
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
112
+ }
113
+ /** Return the last durably ingested sequence. */
114
+ cursor() { return Number(this.db.prepare("SELECT value FROM metadata WHERE key='cursor'").get()?.value || 0); }
115
+ /** Never reuse another site's cursor or a previous runtime's software binding. */
116
+ scope(config, run_id) {
117
+ const scope = `${config.site}/${config.room}/${run_id}`;
118
+ const previous = this.db.prepare("SELECT value FROM metadata WHERE key='scope'").get()?.value;
119
+ if (previous && previous !== scope) throw new Error('数据目录已绑定另一站点、房间或运行;请选择独立目录');
120
+ this.db.prepare("INSERT OR IGNORE INTO metadata VALUES ('scope',?)").run(scope);
121
+ }
122
+ /** Do not silently reuse a conversation after its software project or cwd changes. */
123
+ context(config, allow_codex_switch = false) {
124
+ const entries = Object.entries(config.participants).sort(([left], [right]) => left.localeCompare(right))
125
+ .map(([name, item]) => [name, item.adapter, item.project_id || '', item.project_name || '',
126
+ item.cwd ? resolve(item.cwd) : '', item.workdir ? resolve(item.workdir) : '', item.session_id || '']);
127
+ const context = JSON.stringify(entries);
128
+ const previous = this.db.prepare("SELECT value FROM metadata WHERE key='context'").get()?.value;
129
+ if (previous === context) return;
130
+ if (previous && previous !== context) {
131
+ const before = JSON.parse(previous);
132
+ const allowed = allow_codex_switch && before.length === entries.length && before.every((item, index) => {
133
+ const next = entries[index];
134
+ if (JSON.stringify(item) === JSON.stringify(next)) return true;
135
+ const session = this.binding(item[0]).session_id || item[6];
136
+ return item[0] === next[0] && item[1] !== next[1] && CODEX_ADAPTERS.includes(item[1])
137
+ && CODEX_ADAPTERS.includes(next[1]) && session && next[6] === session && (!item[6] || item[6] === session)
138
+ && JSON.stringify(item.slice(2, 6)) === JSON.stringify(next.slice(2, 6));
139
+ });
140
+ // Experts invited during a run only add entries; every existing binding must stay as it was.
141
+ const added = before.every(item => entries.some(next => JSON.stringify(next) === JSON.stringify(item)));
142
+ if (!allowed && !added) throw new Error('已绑定会话的项目、cwd 或身份配置发生变化;请暂停并使用独立运行目录重新接入');
143
+ }
144
+ this.db.prepare("INSERT INTO metadata VALUES ('context',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(context);
145
+ }
146
+ /** Persist only known internal fields, never dynamic SQL column names. */
147
+ record(id, fields) {
148
+ const allowed = ['status', 'token', 'sent_at', 'last_renew', 'error', 'attempts', 'retry_at', 'receipt'];
149
+ const entries = Object.entries(fields);
150
+ if (entries.some(([key]) => !allowed.includes(key))) throw new Error('无效的内部状态字段');
151
+ this.db.prepare(`UPDATE inbox SET ${entries.map(([key]) => `${key}=?`).join(',')} WHERE id=?`)
152
+ .run(...entries.map(([, value]) => value), id);
153
+ }
154
+ /** Resolve actual session bindings without reading unrelated conversations. */
155
+ binding(participant) { return this.db.prepare('SELECT session_id FROM bindings WHERE participant=?').get(participant) || {}; }
156
+ /** Commit a returned session before later notifications can use it. */
157
+ bind(participant, session_id) { this.db.prepare('INSERT INTO bindings VALUES (?,?) ON CONFLICT(participant) DO UPDATE SET session_id=excluded.session_id').run(participant, session_id); }
158
+ /** List work that still needs dispatch or reconciliation. */
159
+ pending() { return this.db.prepare("SELECT * FROM inbox WHERE status NOT IN ('completed','cancelled','failed') ORDER BY id").all(); }
160
+ /** Release this instance's database connection. */
161
+ close() { this.db.close(); }
162
+ }
163
+
164
+ /** Hold one PID lock per runtime directory, without taking over a live owner. */
165
+ export function acquire_lock(directory) {
166
+ mkdirSync(directory, { recursive: true });
167
+ const path = join(directory, 'runner.lock');
168
+ if (existsSync(path)) {
169
+ const pid = Number(readFileSync(path, 'utf8'));
170
+ let alive = true;
171
+ try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
172
+ if (alive) throw new Error('此目录已有通知程序运行;不重复启动');
173
+ unlinkSync(path);
174
+ }
175
+ writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
176
+ return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
177
+ }
178
+
179
+ /** Write a non-secret task separately from its CLI-only credential file. */
180
+ export function task_files(config, event, token, binding) {
181
+ const id = `event-${event.id}`;
182
+ const draft_directory = config.draft_directory || `${config.directory}-drafts`;
183
+ mkdirSync(draft_directory, { recursive: true });
184
+ const access_path = join(config.directory, `${id}.access.json`);
185
+ const task_path = join(config.directory, `${id}.task.json`);
186
+ atomic_json(access_path, { site: config.site, room: config.room, token, session_id: binding.session_id || '' });
187
+ const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
188
+ if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
189
+ const adapter = config.participants[event.participant];
190
+ const auto_enter = AUTO_ENTER_ADAPTERS.has(adapter.adapter);
191
+ const skills_directory = join(config.project, '.agents', 'skills');
192
+ // Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
193
+ const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
194
+ const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
195
+ : adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
196
+ atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
197
+ project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
198
+ draft_directory,
199
+ role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
200
+ instructions: `${adapter.adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;浏览项目用 list_dir、find_by_name、grep_search、view_file。` : ''}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。` });
201
+ const read_rule = windows_rg() ? '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。' : '';
202
+ const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。${read_rule}`;
203
+ const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,用 turn create --plan-file 一次排满本轮每位专家,不要每次只排一位。' : '';
204
+ const intro = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。${plan_rule}`;
205
+ const prompt = auto_enter
206
+ ? `${intro}接入程序已为本次执行完成 execution enter,不要再执行。任务内容如下(同一份保存在 ${task_path},无需再读):
207
+ ${readFileSync(task_path, 'utf8')}
208
+ 请直接读取指定版本的技能和网站现状。${scope}实际会话 ID 已由接入程序写入凭据文件。`
209
+ : `${intro}先读取任务文件:${task_path},立即用 ${cli} execution enter 进入,再读取指定版本的技能和网站现状。${scope}实际会话 ID 由接入程序写入凭据文件,尚未就绪只短暂重查,不编造。`;
210
+ return { id, participant: event.participant, directory: config.directory, draft_directory, skills_directory, access_path, prompt };
211
+ }
212
+
213
+ /** A running dispatcher retains only the processes it created itself. */
214
+ export class DiscussionRunner {
215
+ constructor(config, store, deliver = deliver_task, log = () => {}) {
216
+ this.config = config;
217
+ this.store = store;
218
+ this.deliver = deliver;
219
+ this.log = log;
220
+ this.children = new Set();
221
+ this.exited = new Map();
222
+ this.running = new Map();
223
+ this.seen = new Set();
224
+ this.blocked = new Set();
225
+ }
226
+ /** Append one timestamped stage to the event's trace file when CODISCUSS_TRACE=1; tracing must never affect delivery. */
227
+ trace(id, stage, detail = {}) {
228
+ if (process.env.CODISCUSS_TRACE !== '1') return;
229
+ try { appendFileSync(join(this.config.directory, `event-${id}.trace.jsonl`), JSON.stringify({ t: new Date().toISOString(), stage, ...detail }) + '\n', { mode: 0o600 }); }
230
+ catch { /* Diagnostics only. */ }
231
+ }
232
+ /** Reserve with a durable token so a lost HTTP response can be reconciled. */
233
+ async dispatch(row, event) {
234
+ const token = row.token || randomUUID();
235
+ this.store.record(row.id, { token, status: 'reserving' });
236
+ this.log(`派发 #${row.id} → ${event.participant}(${event.role},turn ${event.turn_id ?? '-'})`);
237
+ this.trace(row.id, 'reserve_start');
238
+ await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
239
+ this.trace(row.id, 'reserve_done');
240
+ await this.deliver_reserved({ ...row, token }, event);
241
+ }
242
+ /** Retry only proven non-delivery, keeping the event, token and task unchanged. */
243
+ async deliver_reserved(row, event) {
244
+ const adapter = this.config.participants[event.participant];
245
+ if (!adapter) throw new Error(`未配置 ${event.participant} 的本机接入`);
246
+ const binding = this.store.binding(event.participant);
247
+ if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
248
+ const attempts = row.attempts + 1;
249
+ this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
250
+ let invoked = false;
251
+ let entered = false;
252
+ try {
253
+ const task = task_files(this.config, event, row.token, binding);
254
+ invoked = true;
255
+ const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
256
+ api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
257
+ event_id: row.id, role: event.role, turn_id: event.turn_id },
258
+ on_trace: (stage, detail) => this.trace(row.id, stage, detail),
259
+ on_binding: session => {
260
+ this.store.bind(event.participant, session);
261
+ // agy reports its session again in the result event; enter only once.
262
+ if (AUTO_ENTER_ADAPTERS.has(adapter.adapter) && !entered) { entered = true; this.enter(row, session); }
263
+ },
264
+ on_process: child => this.track(event.participant, child),
265
+ on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
266
+ on_failure: error => { try { this.store.record(row.id, { error }); } catch { /* Store may already be closed at shutdown. */ } },
267
+ on_exit: detail => this.exited.set(row.id, detail || ''),
268
+ });
269
+ if (result.session_id) this.store.bind(event.participant, result.session_id);
270
+ this.store.record(row.id, { status: result.status, receipt: result.receipt || '' });
271
+ } catch (error) {
272
+ const not_sent = !invoked || error.delivery_status === 'not_sent';
273
+ this.log(`投递 #${row.id} → ${event.participant} 第 ${attempts} 次失败(${not_sent ? '确认未送出,将重试' : '送达状态不确定'}):${error.message}`);
274
+ this.store.record(row.id, { status: not_sent ? 'retry_wait' : 'uncertain', error: error.message,
275
+ retry_at: not_sent ? Date.now() + (DELIVERY_RETRY_DELAYS_MS[attempts - 1] ?? 0) : 0 });
276
+ }
277
+ }
278
+ /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
279
+ async enter(row, session_id) {
280
+ try {
281
+ this.trace(row.id, 'enter_start');
282
+ await api(this.config, `/events/${row.id}`, { action: 'enter', session_id }, row.token);
283
+ this.trace(row.id, 'enter_done');
284
+ }
285
+ catch (error) { try { this.store.record(row.id, { error: `接入程序进入执行失败:${error.message}` }); } catch { /* Store may already be closed at shutdown. */ } }
286
+ }
287
+ /** Reconcile business evidence; accepted delivery alone never completes a task. */
288
+ async reconcile(row, event) {
289
+ if (['completed', 'cancelled', 'failed'].includes(event.status)) {
290
+ this.store.record(row.id, { status: event.status });
291
+ return;
292
+ }
293
+ // No app call occurs before dispatching is persisted. Reconcile a reserve response loss safely.
294
+ if (row.status === 'reserving') {
295
+ if (event.status === 'pending') return this.dispatch(row, event);
296
+ if (event.status === 'dispatched') {
297
+ const reserved = await api(this.config, `/events/${row.id}`, { action: 'view' }, row.token);
298
+ if (reserved.status !== 'dispatched') return this.reconcile(row, reserved);
299
+ return this.deliver_reserved(row, reserved);
300
+ }
301
+ }
302
+ if (row.status === 'retry_wait' && event.status === 'dispatched') {
303
+ if (row.attempts > DELIVERY_RETRY_DELAYS_MS.length) return this.fail(row, `投递 ${row.attempts} 次均确认未送出:${row.error}`);
304
+ if (Date.now() >= row.retry_at) await this.deliver_reserved(row, event);
305
+ return;
306
+ }
307
+ const adapter = this.config.participants[event.participant];
308
+ if (adapter?.adapter === 'antigravity' && event.status === 'dispatched') {
309
+ const receipt = sidecar_receipt(adapter, `event-${row.id}`);
310
+ if (receipt?.session_id) this.store.bind(event.participant, receipt.session_id);
311
+ if (receipt) this.store.record(row.id, { status: receipt.status, receipt: JSON.stringify(receipt), error: receipt.error || '' });
312
+ }
313
+ if (row.error && ['accepted', 'started'].includes(row.status)) return this.fail(row, row.error);
314
+ if (this.exited.has(row.id) && ['dispatched', 'started'].includes(event.status)) {
315
+ // A business write may have just completed the task; recheck before declaring a silent exit.
316
+ const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
317
+ if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果${this.exited.get(row.id) ? `:${this.exited.get(row.id)}` : ''}`);
318
+ return this.reconcile(row, latest);
319
+ }
320
+ if (event.status === 'started' && event.lease_until <= Math.floor(Date.now() / 1000)) return this.fail(row, '执行租约到期;旧会话不能继续写入');
321
+ if (event.status === 'started' && Date.now() - row.last_renew >= RENEW_MS) {
322
+ try { await api(this.config, `/events/${row.id}`, { action: 'renew' }); }
323
+ catch (error) {
324
+ if (/HTTP (403|409)/.test(error.message)) return this.fail(row, '执行时限已到;请核对原会话');
325
+ throw error;
326
+ }
327
+ this.store.record(row.id, { status: 'started', last_renew: Date.now(), error: '' });
328
+ }
329
+ }
330
+ /** Preserve the cause on the website and revoke work before asking for intervention. */
331
+ async fail(row, reason) {
332
+ this.log(`执行 #${row.id} 失败:${reason}`);
333
+ try { await api(this.config, `/events/${row.id}`, { action: 'fail', reason }); }
334
+ catch (error) {
335
+ if (!/HTTP 409/.test(error.message)) throw error;
336
+ const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
337
+ if (['completed', 'cancelled', 'failed'].includes(latest.status)) return this.reconcile(row, latest);
338
+ throw error;
339
+ }
340
+ this.store.record(row.id, { status: 'failed', error: reason });
341
+ this.stop_children();
342
+ }
343
+ /** Process one bounded iteration without waking models for empty checks. */
344
+ async tick() {
345
+ this.store.context(this.config);
346
+ let room;
347
+ try { room = await api(this.config, ''); }
348
+ catch (error) {
349
+ // Only a permanently deleted room drops its private agy projects; concluded or archived rooms keep them for later continuation.
350
+ if (/^HTTP 404: 房间不存在/.test(error.message)) remove_agy_projects(this.config.directory);
351
+ throw error;
352
+ }
353
+ if (room.limits?.reached && (!room.limits.closing || room.limits.reason === 'time_limit') && room.runtime.status === 'running') {
354
+ const generation = room.runtime.generation;
355
+ room = await api(this.config, '/limits', { actor: '用户' });
356
+ if (room.runtime.generation !== generation) this.stop_children();
357
+ }
358
+ this.room_title = room.title;
359
+ const run = room.runtime;
360
+ if (run.run_id) this.store.scope(this.config, run.run_id);
361
+ if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
362
+ this.stop_children();
363
+ if (run.expired) await api(this.config, '/runtime', { action: 'pause', revision: run.revision });
364
+ for (const row of this.store.pending()) {
365
+ const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
366
+ if (['completed', 'cancelled', 'failed'].includes(event.status)) this.store.record(row.id, { status: event.status });
367
+ }
368
+ // A paused automatic run waits so the web restart can resume it; stop, conclusion or manual mode ends the loop.
369
+ return run.mode === 'automatic' && (run.status === 'paused' || run.expired) ? 'waiting' : false;
370
+ }
371
+ this.store.receive(await api(this.config, `/events?after=${this.store.cursor()}`));
372
+ const rows = this.store.pending();
373
+ for (const row of rows.filter(item => item.status !== 'pending')) {
374
+ const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
375
+ await this.reconcile(row, event);
376
+ }
377
+ if (this.store.pending().some(row => row.status !== 'pending')) return;
378
+ const current = room.current_turn;
379
+ const pending = rows.filter(row => row.status === 'pending').map(row => ({ row, event: JSON.parse(row.event) }));
380
+ const eligible = pending.filter(({ event }) => event.generation === run.generation && event.run_id === run.run_id
381
+ && (current && ['pending', 'notified'].includes(current.status) ? event.turn_id === current.id : event.role === 'host'));
382
+ for (const { row, event } of pending) {
383
+ if (event.generation !== run.generation || event.run_id !== run.run_id) {
384
+ this.log(`撤销过期事件 #${row.id}(事件代次 ${event.generation},当前 ${run.generation})`);
385
+ this.store.record(row.id, { status: 'cancelled' });
386
+ }
387
+ }
388
+ for (const { row, event } of pending) if (!this.seen.has(row.id)) { this.seen.add(row.id); this.trace(row.id, 'event_seen', { event_created_at: event.created_at }); }
389
+ const next = eligible.at(-1);
390
+ if (next && this.running.get(next.event.participant) && !this.blocked.has(next.row.id)) {
391
+ this.blocked.add(next.row.id);
392
+ this.trace(next.row.id, 'blocked_by_running_process', { participant: next.event.participant });
393
+ }
394
+ // A host that just saved the next turn may still be exiting; the same session cannot accept a second writer yet.
395
+ if (next && !this.running.get(next.event.participant)) {
396
+ const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
397
+ if (fresh.status === 'pending') {
398
+ await this.dispatch(next.row, fresh);
399
+ }
400
+ else this.store.record(next.row.id, { status: fresh.status });
401
+ }
402
+ }
403
+ /** Count live processes per participant so a resumed session never overlaps its previous writer. */
404
+ track(participant, child) {
405
+ this.children.add(child);
406
+ this.running.set(participant, (this.running.get(participant) || 0) + 1);
407
+ child.once('close', () => {
408
+ this.children.delete(child);
409
+ this.running.set(participant, this.running.get(participant) - 1);
410
+ });
411
+ }
412
+ /** Stop only child processes created by this runner, never the desktop apps. */
413
+ stop_children() { for (const child of this.children) child.kill(); }
414
+ }
415
+
416
+ /** Pause a running automatic run so the website never shows work nobody will consume. */
417
+ export async function pause_if_running(config) {
418
+ const latest = await api(config, '/runtime');
419
+ if (latest.status === 'running') await api(config, '/runtime', { action: 'pause', revision: latest.revision });
420
+ }
421
+
422
+ /** Run an explicitly configured room; install and status never start a discussion. */
423
+ export async function main(args = process.argv.slice(2)) {
424
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
425
+ config: { type: 'string' }, once: { type: 'boolean' }, minutes: { type: 'string' },
426
+ } });
427
+ if (!values.config || !['watch', 'start', 'status', 'pause', 'setup-sidecar'].includes(positionals[0])) {
428
+ throw new Error('用法: discussion_runner.mjs watch|start|status|pause|setup-sidecar --config 私有配置.json [--minutes 60] [--once]');
429
+ }
430
+ const config = load_config(values.config);
431
+ if (positionals[0] === 'setup-sidecar') {
432
+ for (const participant of Object.values(config.participants).filter(item => item.adapter === 'antigravity')) process.stdout.write(JSON.stringify(setup_sidecar(participant)) + '\n');
433
+ return;
434
+ }
435
+ const run = await api(config, '/runtime');
436
+ if (positionals[0] === 'status') { process.stdout.write(JSON.stringify(run) + '\n'); return; }
437
+ if (positionals[0] === 'pause') { await api(config, '/runtime', { action: 'pause', revision: run.revision }); return; }
438
+ const release = acquire_lock(config.directory);
439
+ const store = new DiscussionStore(config.directory);
440
+ const runner = new DiscussionRunner(config, store);
441
+ let stopped = false;
442
+ const stop = () => { stopped = true; };
443
+ process.once('SIGINT', stop);
444
+ process.once('SIGTERM', stop);
445
+ try {
446
+ store.context(config);
447
+ if (run.run_id) store.scope(config, run.run_id);
448
+ if (positionals[0] === 'start') {
449
+ const room = await api(config, '');
450
+ const minutes = Number(values.minutes ?? room.limits?.max_minutes ?? 0);
451
+ if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) throw new Error('总时长或 --minutes 须在 [0,1440],0 表示不限时长');
452
+ if (![room.host, ...Object.keys(room.experts)].every(name => config.participants[name])) throw new Error('尚未配置全部参与者的本机软件');
453
+ await api(config, '/runtime', { action: 'start', mode: 'automatic', revision: run.revision, deadline: minutes ? Math.floor(Date.now() / 1000 + minutes * 60) : 0 });
454
+ }
455
+ do {
456
+ try { if (await runner.tick() === false) break; }
457
+ catch (error) { process.stderr.write(JSON.stringify({ error: error.message }) + '\n'); if (values.once) throw error; }
458
+ if (!values.once && !stopped) await sleep(POLL_MS);
459
+ } while (!values.once && !stopped);
460
+ } finally {
461
+ if (stopped) {
462
+ try { await pause_if_running(config); }
463
+ catch (error) { process.stderr.write(`停止回执未确认;租约到期后拒绝写入:${error.message}\n`); }
464
+ }
465
+ runner.stop_children(); store.close(); release();
466
+ process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
467
+ }
468
+ }
469
+
470
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
471
+ main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
472
+ }