ai_co_discuss 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-host/SKILL.md +2 -0
- package/.agents/skills/discuss-participant/SKILL.md +3 -3
- package/docs/runner.md +3 -1
- package/package.json +1 -1
- package/runner/discussion_adapters.mjs +24 -4
- package/runner/discussion_launch.mjs +5 -2
- package/runner/discussion_runner.mjs +436 -398
- package/runner/discussion_setup.mjs +1 -1
- package/web/modern.js +1 -1
|
@@ -65,6 +65,8 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
|
|
|
65
65
|
| 已可收束,或正在限时收束 | 先发表有原文依据的最终 `moderation`、保存 state 与简报,最后设为 `concluded`。 |
|
|
66
66
|
| 缺少必要用户决定或无法继续 | 保留已有判断与具体阻碍,用 `execution fail` 暂停,向用户说明需要什么。 |
|
|
67
67
|
|
|
68
|
+
后续若干次安排的专家与问题可预先确定时(如固定轮流报数,不是只排“本轮三位成员”),一次用 `turn create --plan-file` 排满整个可预知序列(最多 10 项),不要每答完一位就唤醒主持再排下一位;只有后一位必须读前一位回答才能决定时才逐个安排。创建安排或收束成功后立即结束,只回一句话,不再输出总结。
|
|
69
|
+
|
|
68
70
|
创建安排、完成收束或执行失败会结束本次写入资格;此后只允许回读核验,不再补 state、回执或归还已交接的发言权。是否完成以网站业务结果为准;结果不明按参与技能核对原提交,不另创安排。
|
|
69
71
|
|
|
70
72
|
## 保存有用的进度
|
|
@@ -21,13 +21,13 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
|
|
|
21
21
|
|
|
22
22
|
网站读写优先用 `codiscuss room …`、`codiscuss message …` 等统一命令;任务或用户显式指定 CLI 路径时沿用指定入口。未安装统一命令时,用已核实的 `python 路径/runner/discuss.py …`。统一入口需 Node.js 24+,网站读写需 Python 3.10+。裸 `codiscuss` 会启动服务;查询用法使用 `codiscuss --help` / `codiscuss schema …`,下文命令均省略入口前缀。
|
|
23
23
|
|
|
24
|
-
从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测。站点用 `--site` 或当前进程 `DISCUSS_URL`,房间始终显式指定,业务写入显式 `--as
|
|
24
|
+
从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测。站点用 `--site` 或当前进程 `DISCUSS_URL`,房间始终显式指定,业务写入显式 `--as`。手动首次接入读一次 `room protocol`;自动任务不读,需要字段时只查 `schema 具体命令`,不列全部命令。
|
|
25
25
|
|
|
26
26
|
项目与 cwd 可缺省,有值时用于本次获准资料;CLI 路径只定位工具,不把 CLI 仓库当作讨论项目或扩大文件权限。
|
|
27
27
|
|
|
28
28
|
**自动任务(收到 `*.task.json`):**
|
|
29
29
|
|
|
30
|
-
- 从任务文件取得身份、职责、事件、安排、CLI 和 `access_file`。按任务指令以 `execution enter ROOM EVENT --access-file FILE` 认领一次;**若当前调用刚已成功 enter
|
|
30
|
+
- 从任务文件取得身份、职责、事件、安排、CLI 和 `access_file`。按任务指令以 `execution enter ROOM EVENT --access-file FILE` 认领一次;**若当前调用刚已成功 enter,直接沿用,不再执行第二次**。若提示词写明接入程序已进入,则跳过 enter。会话 ID 由接入程序提供,不编造、不打印凭据内容。
|
|
31
31
|
- 进入失败或重复交付时,用 `execution view` 和实际业务结果核对;仅凭事件显示 started,不能认定可以再做一遍。暂停、到期或失效的旧任务不得继续写入。
|
|
32
32
|
- 本次受保护写入统一附同一 `--access-file`。只处理交付的职责和安排;专家提交答案即完成此执行,主持人的交接边界见主持技能。
|
|
33
33
|
- 通知程序负责续租与后续交付。AI 不创建定时任务、不操作其他软件,不另发“就绪/完成”回执。确实无法继续时保留草稿,用 `execution fail ROOM EVENT --reason 原因 --access-file FILE` 暂停;已失效时不再写回执或清理消息。
|
|
@@ -60,7 +60,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
|
|
|
60
60
|
|
|
61
61
|
## 保存真实结果
|
|
62
62
|
|
|
63
|
-
观点用 `message post
|
|
63
|
+
观点用 `message post`。讨论房间的专家观点用 `--kind opinion`,主持发言用 `--kind moderation` 且不带 `--summary`;`chat` 仅用于自由协作房间。观点摘要用 `--summary` / `--summary-file`,完整正文优先 UTF-8 `--body-file`;自己的观点署名自己。代发保留真实作者、完整原文及引用关系,摘要归实际撰写者,不把自己的改写冒充对方回答。
|
|
64
64
|
|
|
65
65
|
需要提交原文件时,先 `attachment upload ROOM --as 本人 --file FILE --submission-id 上传编号`,再把回执中的附件 ID 通过 `message post --attachment ID` 关联,可重复指定(最多 10 个,单个 20 MiB)。受保护上传带本次 `--access-file`,须在回答完成、执行资格结束前上传。上传和消息分别保存提交编号;结果不明用 `attachment list ROOM --submission-id 上传编号` 核对,重试沿用原文件与原编号。
|
|
66
66
|
|
package/docs/runner.md
CHANGED
|
@@ -228,7 +228,9 @@ Windows 的 `executable` 填原生 `.exe`,不填 npm 的 `.cmd` / `.ps1` 包
|
|
|
228
228
|
|
|
229
229
|
进度看板的参与者姓名下显示本场已登记的模型与思考强度,不使用新建草稿或本机默认配置代替。由本机桥接的只读 `room-models` 返回 `adapter/model/effort`,按当前站点与房间匹配;本机页面和已授权连接的线上页面均可读取,不要求暂停或浏览器持有房间控制凭据,不返回路径、令牌或会话编号。CLI 未显式指定时显示“默认模型 / 默认强度”,agy 显示“强度随模型”,App 和手动参与提示在原软件内设置;未连接本机、未登记或旧版服务不支持时显示未获取。它展示启动配置,不能证明软件最终采用的默认值,也不提供讨论中改模型的入口。
|
|
230
230
|
|
|
231
|
-
两种新适配器都不需要 `source_thread_id` 或 `ipc_directory`。实际 ID
|
|
231
|
+
两种新适配器都不需要 `source_thread_id` 或 `ipc_directory`。实际 ID 在输出流到达时写入本机绑定和本次执行凭据,随后由接入程序在会话启动时直接 `execution enter`(`codex_cli`、`claude` 与 `antigravity_cli`,任务内容内联进提示词,省去模型读文件和进入的往返;其余适配器仍由 AI 自行进入),AI 通过讨论 CLI 保存业务结果;Codex CLI 输出 `turn.completed` 1.5 秒后若进程未退出即终止,避免同会话的下一次投递等待收尾。每次执行在任务目录写 `event-ID.trace.jsonl`(时间戳加阶段:`event_seen`、`blocked_by_running_process`、`reserve_*`、`permission_check_*`、`cli_spawned`、`enter_*`、Codex 的 `thread.started` / `turn.started` / 工具命令 / `turn.completed`、`kill_after_completed`、`cli_closed`),仅用于定位各阶段耗时,不含凭据;程序不把终端回答直接代发。Codex App / CLI 可通过上述暂停后配置修正保留原绑定切换;其他适配器或项目、会话变更仍须使用独立 `data_directory` 明确重新接入。此路径不承诺 CLI 会话自动出现在桌面 App 的项目列表。
|
|
232
|
+
|
|
233
|
+
trace 中 `event_seen.event_created_at` 记录服务端事件创建时间,`t` 仍为本机记录该阶段的实际时间,两者分别用于核对事件创建后的等待和本机阶段耗时。
|
|
232
234
|
|
|
233
235
|
`codex_cli` 的 `exec resume` 需要取得该会话的写入权;已有聊天仍被 Codex App 或另一进程持有时,会报 `thread-store conflict / already has an active writer`,App 显示空闲也不代表已释放。重复重试或仅关闭窗口不能保证解除占用。需要沿用 App 原聊天时,暂停后在本场接入设置中切到 `codex`,由 `codex queue` 向原聊天交付;需要独立 CLI 时,使用未被占用的 CLI 会话或新建讨论时省略会话 ID。接入程序保留这一根因,不让后续模型列表刷新超时覆盖它,也不自动换会话或切换适配器。
|
|
234
236
|
|
package/package.json
CHANGED
|
@@ -10,6 +10,8 @@ import { CodexAppClient } from './codex_app_client.mjs';
|
|
|
10
10
|
|
|
11
11
|
const CALL_TIMEOUT_MS = 30_000;
|
|
12
12
|
const CLI_DIAGNOSTIC_CHARS = 1000;
|
|
13
|
+
/** Codex exec may linger after its final event; the finished turn is already saved, so stop it to free the session. */
|
|
14
|
+
const CLI_EXIT_GRACE_MS = 1500;
|
|
13
15
|
const CODEX_PERMISSIONS_MIN_MINOR = 163;
|
|
14
16
|
/** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
|
|
15
17
|
export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'floor', 'execution', 'health', 'schema'];
|
|
@@ -369,7 +371,7 @@ function bind_cli_session(state, session_id, task) {
|
|
|
369
371
|
function cli_event(adapter, state, event, task) {
|
|
370
372
|
if (adapter === 'codex_cli') {
|
|
371
373
|
if (event.type === 'thread.started') bind_cli_session(state, event.thread_id, task);
|
|
372
|
-
if (event.type === 'turn.completed') state.completed = true;
|
|
374
|
+
if (event.type === 'turn.completed') { state.completed = true; state.finish_timer = setTimeout(() => { state.finished = true; task.on_trace?.('kill_after_completed'); state.child.kill(); }, CLI_EXIT_GRACE_MS); }
|
|
373
375
|
if (event.type === 'turn.failed') state.error = event.error?.message || 'Codex CLI 执行失败';
|
|
374
376
|
if (event.type === 'error') state.diagnostic = event.message || 'Codex CLI 返回错误';
|
|
375
377
|
remember_writer_conflict(state, state.error || state.diagnostic);
|
|
@@ -384,6 +386,15 @@ function cli_event(adapter, state, event, task) {
|
|
|
384
386
|
}
|
|
385
387
|
}
|
|
386
388
|
|
|
389
|
+
/** Record lifecycle events and tool commands with timestamps so slow stages can be located from the trace file. */
|
|
390
|
+
function trace_cli_event(event, task) {
|
|
391
|
+
if (!task.on_trace) return;
|
|
392
|
+
const item = event.item;
|
|
393
|
+
const command = item?.command ? String(item.command).replace(/^.*?-Command\s+/, '').slice(0, 100) : '';
|
|
394
|
+
const type = event.type ?? event.event ?? 'unknown';
|
|
395
|
+
task.on_trace(item ? `${type}:${item.type}` : type, command ? { command } : {});
|
|
396
|
+
}
|
|
397
|
+
|
|
387
398
|
/** Keep the actual resume failure even when later model-refresh logs replace the diagnostic tail. */
|
|
388
399
|
function remember_writer_conflict(state, diagnostic) {
|
|
389
400
|
if (/thread-store conflict:[\s\S]*already has an active writer/.test(diagnostic)) state.writer_conflict = true;
|
|
@@ -393,6 +404,7 @@ function remember_writer_conflict(state, diagnostic) {
|
|
|
393
404
|
function cli_exit_error(state, code, signal) {
|
|
394
405
|
if (state.writer_conflict) return `Codex 会话 ${state.session_id} 已被另一进程持有(active writer),CLI 无法接管。若为 App 中的聊天,请使用 Codex App 接入;独立 CLI 须使用未被占用的会话。重复点击重试不会解除占用`;
|
|
395
406
|
if (state.error) return state.error;
|
|
407
|
+
if (state.finished && state.completed) return '';
|
|
396
408
|
if (code !== 0) return `CLI ${signal ? `被信号 ${signal} 终止` : `退出码 ${code}`}:${state.diagnostic}`;
|
|
397
409
|
if (!state.initialized) return `CLI 未确认实际会话 ID:${state.diagnostic}`;
|
|
398
410
|
if (!state.completed) return `CLI 未返回成功结束事件:${state.diagnostic}`;
|
|
@@ -401,7 +413,7 @@ function cli_exit_error(state, code, signal) {
|
|
|
401
413
|
|
|
402
414
|
/** Parse complete UTF-8 JSON lines while keeping raw output in the private task log. */
|
|
403
415
|
function monitor_cli(child, config, binding, task) {
|
|
404
|
-
const state = { session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '' };
|
|
416
|
+
const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '' };
|
|
405
417
|
const log_path = resolve(task.directory, `${config.adapter}-${task.id}.log`);
|
|
406
418
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
407
419
|
child.stdout.on('data', chunk => task.on_output(log_path, chunk));
|
|
@@ -416,10 +428,13 @@ function monitor_cli(child, config, binding, task) {
|
|
|
416
428
|
let event;
|
|
417
429
|
try { event = JSON.parse(line); }
|
|
418
430
|
catch { state.diagnostic = 'CLI 输出中存在无效 JSON,详情见本机日志'; return; }
|
|
431
|
+
trace_cli_event(event, task);
|
|
419
432
|
try { cli_event(config.adapter, state, event, task); }
|
|
420
433
|
catch (error) { state.error = error.message; child.kill(); }
|
|
421
434
|
});
|
|
422
435
|
child.once('close', (code, signal) => {
|
|
436
|
+
clearTimeout(state.finish_timer);
|
|
437
|
+
task.on_trace?.('cli_closed', { code, signal, killed_after_completed: Boolean(state.finished) });
|
|
423
438
|
if (!state.spawned) return;
|
|
424
439
|
const error = cli_exit_error(state, code, signal);
|
|
425
440
|
if (error) task.on_failure(error);
|
|
@@ -437,7 +452,11 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
|
|
|
437
452
|
let invocation;
|
|
438
453
|
try { invocation = cli_invocation(config, binding, task, user_home); }
|
|
439
454
|
catch (error) { throw not_sent(error); }
|
|
440
|
-
if (invocation.permissions)
|
|
455
|
+
if (invocation.permissions) {
|
|
456
|
+
task.on_trace?.('permission_check_start');
|
|
457
|
+
await check_codex_permissions(config, task, invocation.permissions, spawn_process);
|
|
458
|
+
task.on_trace?.('permission_check_done');
|
|
459
|
+
}
|
|
441
460
|
if (invocation.project) {
|
|
442
461
|
const on_binding = task.on_binding;
|
|
443
462
|
task = { ...task, on_binding: session_id => { invocation.project.bind(session_id); on_binding(session_id); } };
|
|
@@ -451,11 +470,12 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
|
|
|
451
470
|
const state = monitor_cli(child, config, binding, task);
|
|
452
471
|
task.on_process(child);
|
|
453
472
|
await new Promise((resolve_spawn, reject) => {
|
|
454
|
-
child.once('spawn', () => { state.spawned = true; resolve_spawn(); });
|
|
473
|
+
child.once('spawn', () => { state.spawned = true; task.on_trace?.('cli_spawned'); resolve_spawn(); });
|
|
455
474
|
child.once('error', error => state.spawned ? task.on_failure(error.message) : reject(not_sent(error)));
|
|
456
475
|
});
|
|
457
476
|
child.stdin.on('error', error => { if (error.code !== 'EPIPE') task.on_failure(error.message); });
|
|
458
477
|
child.stdin.end(invocation.input);
|
|
478
|
+
task.on_trace?.('prompt_sent');
|
|
459
479
|
return { session_id: state.session_id, status: 'accepted' };
|
|
460
480
|
}
|
|
461
481
|
|
|
@@ -55,13 +55,16 @@ conversation 还须 current_session_id,当前会话接手首个主持任务。
|
|
|
55
55
|
详见 docs/runner.md 的“两种讨论入口与启动配置”。
|
|
56
56
|
`;
|
|
57
57
|
|
|
58
|
-
// Windows PowerShell 5.1 ships WinForms
|
|
58
|
+
// Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
|
|
59
|
+
// shown (off-screen) as a topmost window; the dialog it owns then stays above the browser that asked for it.
|
|
59
60
|
const WINDOWS_PICKER = [
|
|
60
61
|
'Add-Type -AssemblyName System.Windows.Forms',
|
|
61
62
|
'[Console]::OutputEncoding = [Text.Encoding]::UTF8',
|
|
62
|
-
'$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false }',
|
|
63
|
+
'$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "Manual"; Location = New-Object System.Drawing.Point(-32000, -32000); Size = New-Object System.Drawing.Size(1, 1) }',
|
|
64
|
+
'$owner.Show(); $owner.Activate()',
|
|
63
65
|
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = "选择项目目录"; ShowNewFolderButton = $true }',
|
|
64
66
|
'if ($dialog.ShowDialog($owner) -eq "OK") { $dialog.SelectedPath }',
|
|
67
|
+
'$owner.Close()',
|
|
65
68
|
].join('; ');
|
|
66
69
|
const PICKERS = {
|
|
67
70
|
win32: ['powershell.exe', ['-NoProfile', '-STA', '-Command', WINDOWS_PICKER]],
|
|
@@ -1,398 +1,436 @@
|
|
|
1
|
-
/** Durable website-event delivery to existing AI software; no model API orchestration. */
|
|
2
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
-
import { parseArgs } from 'node:util';
|
|
8
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
|
-
import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
10
|
-
|
|
11
|
-
const POLL_MS = 2000;
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
participant.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!
|
|
47
|
-
if (participant.
|
|
48
|
-
if (
|
|
49
|
-
if (participant.adapter === '
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
CREATE TABLE IF NOT EXISTS
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.db.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
this.db.prepare("
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this.store
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
if (
|
|
296
|
-
this.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
1
|
+
/** Durable website-event delivery to existing AI software; no model API orchestration. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
|
+
import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
10
|
+
|
|
11
|
+
const POLL_MS = 2000;
|
|
12
|
+
/** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
|
|
13
|
+
const AUTO_ENTER_ADAPTERS = new Set(['codex_cli', 'claude', 'antigravity_cli']);
|
|
14
|
+
const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
|
|
15
|
+
const RENEW_MS = 30_000;
|
|
16
|
+
const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli'];
|
|
17
|
+
export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
|
|
18
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
|
+
/** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
|
|
20
|
+
const PROJECT_ACCESS = ['read', 'write'];
|
|
21
|
+
|
|
22
|
+
/** Explicit per-room configuration is the only source of software paths and capabilities. */
|
|
23
|
+
export function load_config(path) {
|
|
24
|
+
return validate_config(JSON.parse(readFileSync(path, 'utf8')), dirname(resolve(path)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep service-managed answers outside all room credentials, logs and databases. */
|
|
28
|
+
export function service_drafts(home, directory) {
|
|
29
|
+
const key = createHash('sha256').update(resolve(directory)).digest('hex');
|
|
30
|
+
return join(home, 'drafts', key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validate a launch draft with the same rules as a registered room configuration. */
|
|
34
|
+
export function validate_config(input, base_directory) {
|
|
35
|
+
const config = structuredClone(input);
|
|
36
|
+
config.project = config.cli_project || config.project || REPO_ROOT;
|
|
37
|
+
const url = new URL(config.site);
|
|
38
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('站点必须是无路径和凭据的 HTTP(S) 根地址');
|
|
39
|
+
if (!/^[A-Za-z0-9_-]+$/.test(config.room ?? '') || typeof config.token !== 'string' || config.token.length < 32) throw new Error('缺少房间及本房间凭据');
|
|
40
|
+
if (!config.project || !existsSync(join(config.project, 'runner', 'discuss.py'))) throw new Error('cli_project 必须指向 AI CoDiscuss 项目;省略时默认使用本仓库');
|
|
41
|
+
if (!config.participants || !Object.keys(config.participants).length) throw new Error('请配置本机参与软件');
|
|
42
|
+
for (const participant of Object.values(config.participants)) {
|
|
43
|
+
participant.cwd = participant.cwd ?? config.cwd;
|
|
44
|
+
participant.project_name = participant.project_name ?? config.discussion_project;
|
|
45
|
+
participant.project_access = participant.project_access || config.project_access || 'read';
|
|
46
|
+
if (!PROJECT_ACCESS.includes(participant.project_access)) throw new Error('project_access 只能为 read 或 write');
|
|
47
|
+
if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
|
|
48
|
+
if (!ADAPTER_NAMES.includes(participant.adapter)) throw new Error(`适配器只能为 ${ADAPTER_NAMES.join('/')}`);
|
|
49
|
+
if (participant.adapter === 'antigravity' && !participant.ipc_directory) throw new Error('缺少 Antigravity Sidecar IPC 目录');
|
|
50
|
+
if (participant.adapter !== 'antigravity' && !existsSync(participant.executable ?? '')) throw new Error('请指定实际原生可执行文件');
|
|
51
|
+
if (participant.adapter === 'codex' && !participant.source_thread_id && !participant.session_id) throw new Error('Codex 新建入口需要实际来源聊天 ID');
|
|
52
|
+
}
|
|
53
|
+
if (![undefined, 'free', 'brainstorm'].includes(config.mode)) throw new Error('房间模式只能为 free 或 brainstorm');
|
|
54
|
+
if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
|
|
55
|
+
throw new Error('自由房间须为每位成员保存成员凭证');
|
|
56
|
+
}
|
|
57
|
+
return { ...config, site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Send exactly one bounded website request; never follow redirects with a capability. */
|
|
61
|
+
export async function api(config, tail, body, token = config.token) {
|
|
62
|
+
const response = await fetch(`${config.site}/api/rooms/${config.room}${tail}`, {
|
|
63
|
+
method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
|
|
64
|
+
headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
|
|
65
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
66
|
+
});
|
|
67
|
+
const result = await response.json();
|
|
68
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Persistent inbox, cursor and bindings share one local SQLite transaction boundary. */
|
|
73
|
+
export class DiscussionStore {
|
|
74
|
+
constructor(directory) {
|
|
75
|
+
mkdirSync(directory, { recursive: true });
|
|
76
|
+
this.db = new DatabaseSync(join(directory, 'runtime.sqlite3'));
|
|
77
|
+
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
|
|
78
|
+
CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS inbox(id INTEGER PRIMARY KEY,event TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',
|
|
80
|
+
token TEXT NOT NULL DEFAULT '',sent_at INTEGER NOT NULL DEFAULT 0,last_renew INTEGER NOT NULL DEFAULT 0,error TEXT NOT NULL DEFAULT '');
|
|
81
|
+
CREATE TABLE IF NOT EXISTS bindings(participant TEXT PRIMARY KEY,session_id TEXT NOT NULL);`);
|
|
82
|
+
const columns = new Set(this.db.prepare('PRAGMA table_info(inbox)').all().map(column => column.name));
|
|
83
|
+
for (const [name, definition] of Object.entries({ attempts: 'INTEGER NOT NULL DEFAULT 0',
|
|
84
|
+
retry_at: 'INTEGER NOT NULL DEFAULT 0', receipt: "TEXT NOT NULL DEFAULT ''" })) {
|
|
85
|
+
if (!columns.has(name)) this.db.exec(`ALTER TABLE inbox ADD COLUMN ${name} ${definition}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Atomically receive a page before moving its cursor. */
|
|
89
|
+
receive(page) {
|
|
90
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
91
|
+
try {
|
|
92
|
+
for (const event of page.events) this.db.prepare('INSERT OR IGNORE INTO inbox(id,event) VALUES (?,?)').run(event.id, JSON.stringify(event));
|
|
93
|
+
this.db.prepare("INSERT INTO metadata VALUES ('cursor',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(page.next_after));
|
|
94
|
+
this.db.exec('COMMIT');
|
|
95
|
+
} catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
96
|
+
}
|
|
97
|
+
/** Return the last durably ingested sequence. */
|
|
98
|
+
cursor() { return Number(this.db.prepare("SELECT value FROM metadata WHERE key='cursor'").get()?.value || 0); }
|
|
99
|
+
/** Never reuse another site's cursor or a previous runtime's software binding. */
|
|
100
|
+
scope(config, run_id) {
|
|
101
|
+
const scope = `${config.site}/${config.room}/${run_id}`;
|
|
102
|
+
const previous = this.db.prepare("SELECT value FROM metadata WHERE key='scope'").get()?.value;
|
|
103
|
+
if (previous && previous !== scope) throw new Error('数据目录已绑定另一站点、房间或运行;请选择独立目录');
|
|
104
|
+
this.db.prepare("INSERT OR IGNORE INTO metadata VALUES ('scope',?)").run(scope);
|
|
105
|
+
}
|
|
106
|
+
/** Do not silently reuse a conversation after its software project or cwd changes. */
|
|
107
|
+
context(config, allow_codex_switch = false) {
|
|
108
|
+
const entries = Object.entries(config.participants).sort(([left], [right]) => left.localeCompare(right))
|
|
109
|
+
.map(([name, item]) => [name, item.adapter, item.project_id || '', item.project_name || '',
|
|
110
|
+
item.cwd ? resolve(item.cwd) : '', item.workdir ? resolve(item.workdir) : '', item.session_id || '']);
|
|
111
|
+
const context = JSON.stringify(entries);
|
|
112
|
+
const previous = this.db.prepare("SELECT value FROM metadata WHERE key='context'").get()?.value;
|
|
113
|
+
if (previous === context) return;
|
|
114
|
+
if (previous && previous !== context) {
|
|
115
|
+
const before = JSON.parse(previous);
|
|
116
|
+
const allowed = allow_codex_switch && before.length === entries.length && before.every((item, index) => {
|
|
117
|
+
const next = entries[index];
|
|
118
|
+
if (JSON.stringify(item) === JSON.stringify(next)) return true;
|
|
119
|
+
const session = this.binding(item[0]).session_id || item[6];
|
|
120
|
+
return item[0] === next[0] && item[1] !== next[1] && CODEX_ADAPTERS.includes(item[1])
|
|
121
|
+
&& CODEX_ADAPTERS.includes(next[1]) && session && next[6] === session && (!item[6] || item[6] === session)
|
|
122
|
+
&& JSON.stringify(item.slice(2, 6)) === JSON.stringify(next.slice(2, 6));
|
|
123
|
+
});
|
|
124
|
+
if (!allowed) throw new Error('已绑定会话的项目、cwd 或身份配置发生变化;请暂停并使用独立运行目录重新接入');
|
|
125
|
+
}
|
|
126
|
+
this.db.prepare("INSERT INTO metadata VALUES ('context',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(context);
|
|
127
|
+
}
|
|
128
|
+
/** Persist only known internal fields, never dynamic SQL column names. */
|
|
129
|
+
record(id, fields) {
|
|
130
|
+
const allowed = ['status', 'token', 'sent_at', 'last_renew', 'error', 'attempts', 'retry_at', 'receipt'];
|
|
131
|
+
const entries = Object.entries(fields);
|
|
132
|
+
if (entries.some(([key]) => !allowed.includes(key))) throw new Error('无效的内部状态字段');
|
|
133
|
+
this.db.prepare(`UPDATE inbox SET ${entries.map(([key]) => `${key}=?`).join(',')} WHERE id=?`)
|
|
134
|
+
.run(...entries.map(([, value]) => value), id);
|
|
135
|
+
}
|
|
136
|
+
/** Resolve actual session bindings without reading unrelated conversations. */
|
|
137
|
+
binding(participant) { return this.db.prepare('SELECT session_id FROM bindings WHERE participant=?').get(participant) || {}; }
|
|
138
|
+
/** Commit a returned session before later notifications can use it. */
|
|
139
|
+
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); }
|
|
140
|
+
/** List work that still needs dispatch or reconciliation. */
|
|
141
|
+
pending() { return this.db.prepare("SELECT * FROM inbox WHERE status NOT IN ('completed','cancelled','failed') ORDER BY id").all(); }
|
|
142
|
+
/** Release this instance's database connection. */
|
|
143
|
+
close() { this.db.close(); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Hold one PID lock per runtime directory, without taking over a live owner. */
|
|
147
|
+
export function acquire_lock(directory) {
|
|
148
|
+
mkdirSync(directory, { recursive: true });
|
|
149
|
+
const path = join(directory, 'runner.lock');
|
|
150
|
+
if (existsSync(path)) {
|
|
151
|
+
const pid = Number(readFileSync(path, 'utf8'));
|
|
152
|
+
let alive = true;
|
|
153
|
+
try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
|
|
154
|
+
if (alive) throw new Error('此目录已有通知程序运行;不重复启动');
|
|
155
|
+
unlinkSync(path);
|
|
156
|
+
}
|
|
157
|
+
writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
158
|
+
return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Write a non-secret task separately from its CLI-only credential file. */
|
|
162
|
+
export function task_files(config, event, token, binding) {
|
|
163
|
+
const id = `event-${event.id}`;
|
|
164
|
+
const draft_directory = config.draft_directory || `${config.directory}-drafts`;
|
|
165
|
+
mkdirSync(draft_directory, { recursive: true });
|
|
166
|
+
const access_path = join(config.directory, `${id}.access.json`);
|
|
167
|
+
const task_path = join(config.directory, `${id}.task.json`);
|
|
168
|
+
atomic_json(access_path, { site: config.site, room: config.room, token, session_id: binding.session_id || '' });
|
|
169
|
+
const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
|
|
170
|
+
if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
|
|
171
|
+
const adapter = config.participants[event.participant];
|
|
172
|
+
const auto_enter = AUTO_ENTER_ADAPTERS.has(adapter.adapter);
|
|
173
|
+
const skills_directory = join(config.project, '.agents', 'skills');
|
|
174
|
+
// Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
|
|
175
|
+
const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
|
|
176
|
+
const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
|
|
177
|
+
: adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
|
|
178
|
+
atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
|
|
179
|
+
project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
|
|
180
|
+
draft_directory,
|
|
181
|
+
role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
|
|
182
|
+
instructions: `读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。` });
|
|
183
|
+
const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。`;
|
|
184
|
+
const intro = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。`;
|
|
185
|
+
const prompt = auto_enter
|
|
186
|
+
? `${intro}接入程序已为本次执行完成 execution enter,不要再执行。任务内容如下(同一份保存在 ${task_path},无需再读):
|
|
187
|
+
${readFileSync(task_path, 'utf8')}
|
|
188
|
+
请直接读取指定版本的技能和网站现状。${scope}实际会话 ID 已由接入程序写入凭据文件。`
|
|
189
|
+
: `${intro}先读取任务文件:${task_path},立即用 ${cli} execution enter 进入,再读取指定版本的技能和网站现状。${scope}实际会话 ID 由接入程序写入凭据文件,尚未就绪只短暂重查,不编造。`;
|
|
190
|
+
return { id, participant: event.participant, directory: config.directory, draft_directory, skills_directory, access_path, prompt };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A running dispatcher retains only the processes it created itself. */
|
|
194
|
+
export class DiscussionRunner {
|
|
195
|
+
constructor(config, store, deliver = deliver_task) {
|
|
196
|
+
this.config = config;
|
|
197
|
+
this.store = store;
|
|
198
|
+
this.deliver = deliver;
|
|
199
|
+
this.children = new Set();
|
|
200
|
+
this.exited = new Set();
|
|
201
|
+
this.running = new Map();
|
|
202
|
+
this.seen = new Set();
|
|
203
|
+
this.blocked = new Set();
|
|
204
|
+
}
|
|
205
|
+
/** Append one timestamped stage to the event's trace file; tracing must never affect delivery. */
|
|
206
|
+
trace(id, stage, detail = {}) {
|
|
207
|
+
try { appendFileSync(join(this.config.directory, `event-${id}.trace.jsonl`), JSON.stringify({ t: new Date().toISOString(), stage, ...detail }) + '\n', { mode: 0o600 }); }
|
|
208
|
+
catch { /* Diagnostics only. */ }
|
|
209
|
+
}
|
|
210
|
+
/** Reserve with a durable token so a lost HTTP response can be reconciled. */
|
|
211
|
+
async dispatch(row, event) {
|
|
212
|
+
const token = row.token || randomUUID();
|
|
213
|
+
this.store.record(row.id, { token, status: 'reserving' });
|
|
214
|
+
this.trace(row.id, 'reserve_start');
|
|
215
|
+
await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
|
|
216
|
+
this.trace(row.id, 'reserve_done');
|
|
217
|
+
await this.deliver_reserved({ ...row, token }, event);
|
|
218
|
+
}
|
|
219
|
+
/** Retry only proven non-delivery, keeping the event, token and task unchanged. */
|
|
220
|
+
async deliver_reserved(row, event) {
|
|
221
|
+
const adapter = this.config.participants[event.participant];
|
|
222
|
+
if (!adapter) throw new Error(`未配置 ${event.participant} 的本机接入`);
|
|
223
|
+
const binding = this.store.binding(event.participant);
|
|
224
|
+
if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
|
|
225
|
+
const attempts = row.attempts + 1;
|
|
226
|
+
this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
|
|
227
|
+
let invoked = false;
|
|
228
|
+
let entered = false;
|
|
229
|
+
try {
|
|
230
|
+
const task = task_files(this.config, event, row.token, binding);
|
|
231
|
+
invoked = true;
|
|
232
|
+
const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
|
|
233
|
+
on_trace: (stage, detail) => this.trace(row.id, stage, detail),
|
|
234
|
+
on_binding: session => {
|
|
235
|
+
this.store.bind(event.participant, session);
|
|
236
|
+
// agy reports its session again in the result event; enter only once.
|
|
237
|
+
if (AUTO_ENTER_ADAPTERS.has(adapter.adapter) && !entered) { entered = true; this.enter(row, session); }
|
|
238
|
+
},
|
|
239
|
+
on_process: child => this.track(event.participant, child),
|
|
240
|
+
on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
|
|
241
|
+
on_failure: error => { try { this.store.record(row.id, { error }); } catch { /* Store may already be closed at shutdown. */ } },
|
|
242
|
+
on_exit: () => this.exited.add(row.id),
|
|
243
|
+
});
|
|
244
|
+
if (result.session_id) this.store.bind(event.participant, result.session_id);
|
|
245
|
+
this.store.record(row.id, { status: result.status, receipt: result.receipt || '' });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const not_sent = !invoked || error.delivery_status === 'not_sent';
|
|
248
|
+
this.store.record(row.id, { status: not_sent ? 'retry_wait' : 'uncertain', error: error.message,
|
|
249
|
+
retry_at: not_sent ? Date.now() + (DELIVERY_RETRY_DELAYS_MS[attempts - 1] ?? 0) : 0 });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
|
|
253
|
+
async enter(row, session_id) {
|
|
254
|
+
try {
|
|
255
|
+
this.trace(row.id, 'enter_start');
|
|
256
|
+
await api(this.config, `/events/${row.id}`, { action: 'enter', session_id }, row.token);
|
|
257
|
+
this.trace(row.id, 'enter_done');
|
|
258
|
+
}
|
|
259
|
+
catch (error) { try { this.store.record(row.id, { error: `接入程序进入执行失败:${error.message}` }); } catch { /* Store may already be closed at shutdown. */ } }
|
|
260
|
+
}
|
|
261
|
+
/** Reconcile business evidence; accepted delivery alone never completes a task. */
|
|
262
|
+
async reconcile(row, event) {
|
|
263
|
+
if (['completed', 'cancelled', 'failed'].includes(event.status)) {
|
|
264
|
+
this.store.record(row.id, { status: event.status });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// No app call occurs before dispatching is persisted. Reconcile a reserve response loss safely.
|
|
268
|
+
if (row.status === 'reserving') {
|
|
269
|
+
if (event.status === 'pending') return this.dispatch(row, event);
|
|
270
|
+
if (event.status === 'dispatched') {
|
|
271
|
+
const reserved = await api(this.config, `/events/${row.id}`, { action: 'view' }, row.token);
|
|
272
|
+
if (reserved.status !== 'dispatched') return this.reconcile(row, reserved);
|
|
273
|
+
return this.deliver_reserved(row, reserved);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (row.status === 'retry_wait' && event.status === 'dispatched') {
|
|
277
|
+
if (row.attempts > DELIVERY_RETRY_DELAYS_MS.length) return this.fail(row, `投递 ${row.attempts} 次均确认未送出:${row.error}`);
|
|
278
|
+
if (Date.now() >= row.retry_at) await this.deliver_reserved(row, event);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const adapter = this.config.participants[event.participant];
|
|
282
|
+
if (adapter?.adapter === 'antigravity' && event.status === 'dispatched') {
|
|
283
|
+
const receipt = sidecar_receipt(adapter, `event-${row.id}`);
|
|
284
|
+
if (receipt?.session_id) this.store.bind(event.participant, receipt.session_id);
|
|
285
|
+
if (receipt) this.store.record(row.id, { status: receipt.status, receipt: JSON.stringify(receipt), error: receipt.error || '' });
|
|
286
|
+
}
|
|
287
|
+
if (row.error && ['accepted', 'started'].includes(row.status)) return this.fail(row, row.error);
|
|
288
|
+
if (this.exited.has(row.id) && ['dispatched', 'started'].includes(event.status)) {
|
|
289
|
+
// A business write may have just completed the task; recheck before declaring a silent exit.
|
|
290
|
+
const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
|
|
291
|
+
if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果`);
|
|
292
|
+
return this.reconcile(row, latest);
|
|
293
|
+
}
|
|
294
|
+
if (event.status === 'started' && event.lease_until <= Math.floor(Date.now() / 1000)) return this.fail(row, '执行租约到期;旧会话不能继续写入');
|
|
295
|
+
if (event.status === 'started' && Date.now() - row.last_renew >= RENEW_MS) {
|
|
296
|
+
try { await api(this.config, `/events/${row.id}`, { action: 'renew' }); }
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (/HTTP (403|409)/.test(error.message)) return this.fail(row, '执行时限已到;请核对原会话');
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
this.store.record(row.id, { status: 'started', last_renew: Date.now(), error: '' });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Preserve the cause on the website and revoke work before asking for intervention. */
|
|
305
|
+
async fail(row, reason) {
|
|
306
|
+
await api(this.config, `/events/${row.id}`, { action: 'fail', reason });
|
|
307
|
+
this.store.record(row.id, { status: 'failed', error: reason });
|
|
308
|
+
this.stop_children();
|
|
309
|
+
}
|
|
310
|
+
/** Process one bounded iteration without waking models for empty checks. */
|
|
311
|
+
async tick() {
|
|
312
|
+
this.store.context(this.config);
|
|
313
|
+
let room;
|
|
314
|
+
try { room = await api(this.config, ''); }
|
|
315
|
+
catch (error) {
|
|
316
|
+
// Only a permanently deleted room drops its private agy projects; concluded or archived rooms keep them for later continuation.
|
|
317
|
+
if (/^HTTP 404: 房间不存在/.test(error.message)) remove_agy_projects(this.config.directory);
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
if (room.limits?.reached && (!room.limits.closing || room.limits.reason === 'time_limit') && room.runtime.status === 'running') {
|
|
321
|
+
const generation = room.runtime.generation;
|
|
322
|
+
room = await api(this.config, '/limits', { actor: '用户' });
|
|
323
|
+
if (room.runtime.generation !== generation) this.stop_children();
|
|
324
|
+
}
|
|
325
|
+
this.room_title = room.title;
|
|
326
|
+
const run = room.runtime;
|
|
327
|
+
if (run.run_id) this.store.scope(this.config, run.run_id);
|
|
328
|
+
if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
|
|
329
|
+
this.stop_children();
|
|
330
|
+
if (run.expired) await api(this.config, '/runtime', { action: 'pause', revision: run.revision });
|
|
331
|
+
for (const row of this.store.pending()) {
|
|
332
|
+
const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
|
|
333
|
+
if (['completed', 'cancelled', 'failed'].includes(event.status)) this.store.record(row.id, { status: event.status });
|
|
334
|
+
}
|
|
335
|
+
// A paused automatic run waits so the web restart can resume it; stop, conclusion or manual mode ends the loop.
|
|
336
|
+
return run.mode === 'automatic' && (run.status === 'paused' || run.expired) ? 'waiting' : false;
|
|
337
|
+
}
|
|
338
|
+
this.store.receive(await api(this.config, `/events?after=${this.store.cursor()}`));
|
|
339
|
+
const rows = this.store.pending();
|
|
340
|
+
for (const row of rows.filter(item => item.status !== 'pending')) {
|
|
341
|
+
const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
|
|
342
|
+
await this.reconcile(row, event);
|
|
343
|
+
}
|
|
344
|
+
if (this.store.pending().some(row => row.status !== 'pending')) return;
|
|
345
|
+
const current = room.current_turn;
|
|
346
|
+
const pending = rows.filter(row => row.status === 'pending').map(row => ({ row, event: JSON.parse(row.event) }));
|
|
347
|
+
const eligible = pending.filter(({ event }) => event.generation === run.generation && event.run_id === run.run_id
|
|
348
|
+
&& (current && ['pending', 'notified'].includes(current.status) ? event.turn_id === current.id : event.role === 'host'));
|
|
349
|
+
for (const { row, event } of pending) {
|
|
350
|
+
if (event.generation !== run.generation || event.run_id !== run.run_id) this.store.record(row.id, { status: 'cancelled' });
|
|
351
|
+
}
|
|
352
|
+
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 }); }
|
|
353
|
+
const next = eligible.at(-1);
|
|
354
|
+
if (next && this.running.get(next.event.participant) && !this.blocked.has(next.row.id)) {
|
|
355
|
+
this.blocked.add(next.row.id);
|
|
356
|
+
this.trace(next.row.id, 'blocked_by_running_process', { participant: next.event.participant });
|
|
357
|
+
}
|
|
358
|
+
// A host that just saved the next turn may still be exiting; the same session cannot accept a second writer yet.
|
|
359
|
+
if (next && !this.running.get(next.event.participant)) {
|
|
360
|
+
const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
|
|
361
|
+
if (fresh.status === 'pending') {
|
|
362
|
+
await this.dispatch(next.row, fresh);
|
|
363
|
+
}
|
|
364
|
+
else this.store.record(next.row.id, { status: fresh.status });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** Count live processes per participant so a resumed session never overlaps its previous writer. */
|
|
368
|
+
track(participant, child) {
|
|
369
|
+
this.children.add(child);
|
|
370
|
+
this.running.set(participant, (this.running.get(participant) || 0) + 1);
|
|
371
|
+
child.once('close', () => {
|
|
372
|
+
this.children.delete(child);
|
|
373
|
+
this.running.set(participant, this.running.get(participant) - 1);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/** Stop only child processes created by this runner, never the desktop apps. */
|
|
377
|
+
stop_children() { for (const child of this.children) child.kill(); }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Pause a running automatic run so the website never shows work nobody will consume. */
|
|
381
|
+
export async function pause_if_running(config) {
|
|
382
|
+
const latest = await api(config, '/runtime');
|
|
383
|
+
if (latest.status === 'running') await api(config, '/runtime', { action: 'pause', revision: latest.revision });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Run an explicitly configured room; install and status never start a discussion. */
|
|
387
|
+
export async function main(args = process.argv.slice(2)) {
|
|
388
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
|
389
|
+
config: { type: 'string' }, once: { type: 'boolean' }, minutes: { type: 'string' },
|
|
390
|
+
} });
|
|
391
|
+
if (!values.config || !['watch', 'start', 'status', 'pause', 'setup-sidecar'].includes(positionals[0])) {
|
|
392
|
+
throw new Error('用法: discussion_runner.mjs watch|start|status|pause|setup-sidecar --config 私有配置.json [--minutes 60] [--once]');
|
|
393
|
+
}
|
|
394
|
+
const config = load_config(values.config);
|
|
395
|
+
if (positionals[0] === 'setup-sidecar') {
|
|
396
|
+
for (const participant of Object.values(config.participants).filter(item => item.adapter === 'antigravity')) process.stdout.write(JSON.stringify(setup_sidecar(participant)) + '\n');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const run = await api(config, '/runtime');
|
|
400
|
+
if (positionals[0] === 'status') { process.stdout.write(JSON.stringify(run) + '\n'); return; }
|
|
401
|
+
if (positionals[0] === 'pause') { await api(config, '/runtime', { action: 'pause', revision: run.revision }); return; }
|
|
402
|
+
const release = acquire_lock(config.directory);
|
|
403
|
+
const store = new DiscussionStore(config.directory);
|
|
404
|
+
const runner = new DiscussionRunner(config, store);
|
|
405
|
+
let stopped = false;
|
|
406
|
+
const stop = () => { stopped = true; };
|
|
407
|
+
process.once('SIGINT', stop);
|
|
408
|
+
process.once('SIGTERM', stop);
|
|
409
|
+
try {
|
|
410
|
+
store.context(config);
|
|
411
|
+
if (run.run_id) store.scope(config, run.run_id);
|
|
412
|
+
if (positionals[0] === 'start') {
|
|
413
|
+
const room = await api(config, '');
|
|
414
|
+
const minutes = Number(values.minutes ?? room.limits?.max_minutes ?? 0);
|
|
415
|
+
if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) throw new Error('总时长或 --minutes 须在 [0,1440],0 表示不限时长');
|
|
416
|
+
if (![room.host, ...Object.keys(room.experts)].every(name => config.participants[name])) throw new Error('尚未配置全部参与者的本机软件');
|
|
417
|
+
await api(config, '/runtime', { action: 'start', mode: 'automatic', revision: run.revision, deadline: minutes ? Math.floor(Date.now() / 1000 + minutes * 60) : 0 });
|
|
418
|
+
}
|
|
419
|
+
do {
|
|
420
|
+
try { if (await runner.tick() === false) break; }
|
|
421
|
+
catch (error) { process.stderr.write(JSON.stringify({ error: error.message }) + '\n'); if (values.once) throw error; }
|
|
422
|
+
if (!values.once && !stopped) await sleep(POLL_MS);
|
|
423
|
+
} while (!values.once && !stopped);
|
|
424
|
+
} finally {
|
|
425
|
+
if (stopped) {
|
|
426
|
+
try { await pause_if_running(config); }
|
|
427
|
+
catch (error) { process.stderr.write(`停止回执未确认;租约到期后拒绝写入:${error.message}\n`); }
|
|
428
|
+
}
|
|
429
|
+
runner.stop_children(); store.close(); release();
|
|
430
|
+
process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
435
|
+
main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
|
|
436
|
+
}
|
|
@@ -283,7 +283,7 @@ function setup_issues(result) {
|
|
|
283
283
|
/** Ask before restarting a running website; non-interactive runs never stop it and report the pending restart instead. */
|
|
284
284
|
async function confirm_restart(reason, options) {
|
|
285
285
|
if (options.yes || !process.stdin.isTTY) return false;
|
|
286
|
-
const choice = await ask(`${reason}\n重启后生效,重启会暂停托管中的讨论。现在重启?`, '[
|
|
286
|
+
const choice = await ask(`${reason}\n重启后生效,重启会暂停托管中的讨论。现在重启?`, '[Y/N,默认 Y]');
|
|
287
287
|
return !choice.trim() || ['重启', 'restart', 'r', 'y', 'yes'].includes(choice.trim().toLowerCase());
|
|
288
288
|
}
|
|
289
289
|
|
package/web/modern.js
CHANGED
|
@@ -45521,7 +45521,7 @@ var nK = /*#__PURE__*/ nH({
|
|
|
45521
45521
|
let e = n.value === "create" ? "create" : n.value === "edit" ? "save_topic" : "schedule";
|
|
45522
45522
|
if (e === "create") {
|
|
45523
45523
|
let r = t.targetFolder?.id ?? null, i = o.value.fields["new-room-mode"], a = i === "free" || o.value.launch.create && i === "automatic";
|
|
45524
|
-
a || (n.value = ""), await l(e, r) && a &&
|
|
45524
|
+
a || (n.value = ""), await l(e, r) && a && (n.value = "");
|
|
45525
45525
|
} else await l(e) && (n.value = "");
|
|
45526
45526
|
}
|
|
45527
45527
|
function ve(e) {
|