ai_co_discuss 0.3.11 → 0.3.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-participant/SKILL.md +1 -1
- package/backend/free_room.py +2410 -2274
- package/backend/schema.sql +27 -0
- package/backend/server.py +21 -11
- package/docs/architecture.md +2 -1
- package/docs/deployment.md +10 -0
- package/docs/en/deployment.md +10 -0
- package/docs/en/workflow.md +1 -1
- package/docs/runner.md +1 -1
- package/docs/workflow.md +2 -2
- package/package.json +3 -2
- package/runner/adapters/antigravity_app.mjs +91 -0
- package/runner/adapters/antigravity_cli.mjs +320 -0
- package/runner/adapters/antigravity_sidecar.mjs +50 -0
- package/runner/{api_participant.mjs → adapters/api.mjs} +19 -5
- package/runner/adapters/claude.mjs +313 -0
- package/runner/adapters/cli.mjs +126 -0
- package/runner/adapters/codex_app.mjs +70 -0
- package/runner/adapters/codex_cli.mjs +286 -0
- package/runner/adapters/common.mjs +196 -0
- package/runner/adapters/index.mjs +143 -0
- package/runner/adapters/profiles.mjs +116 -0
- package/runner/admin_account.mjs +1 -1
- package/runner/discussion_launch.mjs +69 -79
- package/runner/discussion_local.mjs +1 -1
- package/runner/discussion_runner.mjs +14 -13
- package/runner/discussion_service.mjs +3 -3
- package/runner/discussion_setup.mjs +1 -1
- package/runner/discussion_sidecar.mjs +5 -50
- package/runner/discussion_skills.mjs +1 -1
- package/runner/execution_progress.mjs +99 -0
- package/runner/expert_prompts.mjs +19 -29
- package/runner/files.mjs +10 -0
- package/runner/free_runner.mjs +136 -30
- package/runner/remote_launch.mjs +10 -5
- package/runner/scheduler.mjs +124 -32
- package/runner/session_driver.mjs +5 -3
- package/web/app.js +116 -23
- package/web/backup.js +6 -6
- package/web/index.html +2 -0
- package/web/modern.css +1 -1
- package/web/modern.js +1699 -1353
- package/runner/discussion_adapters.mjs +0 -959
- package/runner/discussion_models.mjs +0 -271
- package/runner/session_cut.mjs +0 -167
- /package/runner/{codex_app_client.mjs → adapters/codex_app_client.mjs} +0 -0
|
@@ -1,50 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
const directory = resolve(process.env.CODISCUSS_IPC_DIRECTORY || process.cwd());
|
|
8
|
-
const agentapi = process.env.ANTIGRAVITY_AGENTAPI_EXE;
|
|
9
|
-
if (!agentapi) throw new Error('此程序必须由 Antigravity App Sidecar 启动');
|
|
10
|
-
|
|
11
|
-
/** Consume each request once; a crash after claim leaves an explicit uncertain result. */
|
|
12
|
-
async function process_request(name) {
|
|
13
|
-
const request = JSON.parse(readFileSync(join(directory, name), 'utf8'));
|
|
14
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(request.id) || name !== `${request.id}.request.json`) return;
|
|
15
|
-
const claim = join(directory, `${request.id}.claimed`);
|
|
16
|
-
const receipt = join(directory, `${request.id}.result.json`);
|
|
17
|
-
if (existsSync(claim) || existsSync(receipt)) return;
|
|
18
|
-
writeFileSync(claim, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
19
|
-
try {
|
|
20
|
-
const access = JSON.parse(readFileSync(request.access_path, 'utf8'));
|
|
21
|
-
if (request.mode !== 'free') throw new Error('Sidecar 只支持自由协作执行请求,旧讨论事件已停用');
|
|
22
|
-
const response = await fetch(`${access.site}/api/rooms/${access.room}/executions/${request.event_id}`, {
|
|
23
|
-
method: 'GET', redirect: 'error', signal: AbortSignal.timeout(10000),
|
|
24
|
-
headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': access.token },
|
|
25
|
-
});
|
|
26
|
-
const event = await response.json();
|
|
27
|
-
if (!response.ok || event.status !== 'dispatched') throw new Error('请求已撤销或状态不明,未调用软件');
|
|
28
|
-
if (event.lease_until <= Math.floor(Date.now() / 1000)) throw new Error('执行租约已到期,未调用软件');
|
|
29
|
-
const args = request.session_id ? ['send-message', request.session_id, request.prompt] : ['new-conversation', request.prompt];
|
|
30
|
-
const raw = execFileSync(agentapi, ['agentapi', ...args], { encoding: 'utf8', windowsHide: true, timeout: 30000 });
|
|
31
|
-
const result = JSON.parse(raw);
|
|
32
|
-
const session_id = request.session_id || result.response?.newConversation?.conversationId;
|
|
33
|
-
if (!session_id) throw new Error('未取得真实 conversation ID;创建结果待核对');
|
|
34
|
-
atomic_json(request.access_path, { ...access, session_id });
|
|
35
|
-
atomic_json(receipt, { id: request.id, session_id, status: 'accepted' });
|
|
36
|
-
} catch (error) {
|
|
37
|
-
atomic_json(receipt, { id: request.id, status: 'uncertain', error: String(error.message).slice(0, 500) });
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Scan only our own private IPC directory; this performs no model polling. */
|
|
42
|
-
async function scan_requests() {
|
|
43
|
-
for (const name of readdirSync(directory).filter(name => name.endsWith('.request.json'))) {
|
|
44
|
-
try { await process_request(name); } catch (error) { process.stderr.write(`${error.message}\n`); }
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
atomic_json(join(directory, 'ready.json'), { pid: process.pid, ready_at: Date.now(), project_id: process.env.ANTIGRAVITY_PROJECT_ID || '' });
|
|
49
|
-
scan_requests();
|
|
50
|
-
setInterval(scan_requests, 1000);
|
|
1
|
+
/**
|
|
2
|
+
* Entry the Antigravity App starts for an installed Sidecar. Installed Sidecars record this path, so it stays here;
|
|
3
|
+
* the Sidecar itself is part of the Antigravity App adapter.
|
|
4
|
+
*/
|
|
5
|
+
import './adapters/antigravity_sidecar.mjs';
|
|
@@ -4,7 +4,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rm
|
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
7
|
-
import { atomic_json } from './
|
|
7
|
+
import { atomic_json } from './files.mjs';
|
|
8
8
|
|
|
9
9
|
const SOURCE = fileURLToPath(new URL('../.agents/skills/', import.meta.url));
|
|
10
10
|
export const SKILLS = ['discuss-host', 'discuss-participant', 'discuss-report'];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each execution's software showed while it worked, kept until reported to the website: phase segments and
|
|
3
|
+
* progress notes. Fed by whichever adapter reached the software (see adapters/), read by the room runner.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Segments one execution may record, how many one report carries, and a tool name's length; the website enforces the same. */
|
|
7
|
+
export const MAX_EXECUTION_SEGMENTS = 400;
|
|
8
|
+
export const MAX_SEGMENTS_PER_REPORT = 40;
|
|
9
|
+
export const MAX_SEGMENT_TOOL = 60;
|
|
10
|
+
/** Progress notes kept per execution, each cut to this length, and how many one report carries; the website enforces the same. */
|
|
11
|
+
export const MAX_EXECUTION_NOTES = 50;
|
|
12
|
+
export const MAX_NOTE_LENGTH = 500;
|
|
13
|
+
export const MAX_NOTES_PER_REPORT = 10;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What each execution's software showed while it worked, waiting to be reported: the phases it went through, as
|
|
17
|
+
* millisecond offsets from its delivery on this machine's clock, and its progress notes. Consecutive events of the same
|
|
18
|
+
* phase and tool extend one segment. The open segment is reported as far as it has run and again once it runs further;
|
|
19
|
+
* numbering makes a resent report harmless.
|
|
20
|
+
*/
|
|
21
|
+
export class SegmentLog {
|
|
22
|
+
constructor(now = Date.now) {
|
|
23
|
+
this.now = now;
|
|
24
|
+
this.executions = new Map();
|
|
25
|
+
}
|
|
26
|
+
/** Begin timing one execution at its delivery; a repeated start keeps the first origin. */
|
|
27
|
+
start(id) {
|
|
28
|
+
if (!this.executions.has(id)) this.executions.set(id, { origin: this.now(), last: 0, open: null, closed: [], next_seq: 0,
|
|
29
|
+
notes: [], next_note: 0, open_sent: null, reported: 0 });
|
|
30
|
+
}
|
|
31
|
+
/** Queue one progress note; notes past the cap are dropped. Cut by UTF-16 units like the website's check, never mid-pair. */
|
|
32
|
+
note(id, text) {
|
|
33
|
+
const log = this.executions.get(id);
|
|
34
|
+
if (!log || log.next_note >= MAX_EXECUTION_NOTES) return;
|
|
35
|
+
log.next_note += 1;
|
|
36
|
+
log.notes.push({ seq: log.next_note, text: text.slice(0, MAX_NOTE_LENGTH).replace(/[\uD800-\uDBFF]$/, '') });
|
|
37
|
+
}
|
|
38
|
+
/** The software produced output now; a phase, when the output names one, starts a new segment unless it continues the open one. */
|
|
39
|
+
mark(id, phase = '', tool = '') {
|
|
40
|
+
const log = this.executions.get(id);
|
|
41
|
+
if (!log) return;
|
|
42
|
+
log.last = this.now() - log.origin;
|
|
43
|
+
if (log.open) log.open.end_ms = log.last;
|
|
44
|
+
if (!phase) return;
|
|
45
|
+
tool = tool.slice(0, MAX_SEGMENT_TOOL).replace(/[\uD800-\uDBFF]$/, '');
|
|
46
|
+
if (log.open?.phase === phase && log.open.tool === tool) return;
|
|
47
|
+
if (log.open) log.closed.push(log.open);
|
|
48
|
+
log.open = null;
|
|
49
|
+
if (log.next_seq >= MAX_EXECUTION_SEGMENTS) return;
|
|
50
|
+
log.next_seq += 1;
|
|
51
|
+
log.open = { seq: log.next_seq, phase, tool, start_ms: log.last, end_ms: log.last };
|
|
52
|
+
}
|
|
53
|
+
/** Whether something new waits to be reported (an open segment only once it ran further) and the last report is `interval_ms` old. */
|
|
54
|
+
due(id, interval_ms) {
|
|
55
|
+
const log = this.executions.get(id);
|
|
56
|
+
const open_moved = log?.open && !(log.open_sent?.seq === log.open.seq && log.open_sent.end_ms === log.open.end_ms);
|
|
57
|
+
return Boolean(log && (log.closed.length || log.notes.length || open_moved) && this.now() - log.reported >= interval_ms);
|
|
58
|
+
}
|
|
59
|
+
/** The next report: closed segments first, then the open one as it stands, and the queued notes. `final` closes the open segment. */
|
|
60
|
+
batch(id, final = false) {
|
|
61
|
+
const log = this.executions.get(id);
|
|
62
|
+
if (!log) return { segments: [], notes: [] };
|
|
63
|
+
if (final && log.open) { log.closed.push(log.open); log.open = null; }
|
|
64
|
+
const segments = log.closed.slice(0, MAX_SEGMENTS_PER_REPORT);
|
|
65
|
+
if (log.open && segments.length < MAX_SEGMENTS_PER_REPORT) segments.push({ ...log.open });
|
|
66
|
+
return { segments, notes: log.notes.slice(0, MAX_NOTES_PER_REPORT) };
|
|
67
|
+
}
|
|
68
|
+
/** Forget what a successful report carried in full; a segment that ran on since it was sent stays. */
|
|
69
|
+
sent(id, batch) {
|
|
70
|
+
const log = this.executions.get(id);
|
|
71
|
+
if (!log) return;
|
|
72
|
+
const ends = new Map(batch.segments.map(segment => [segment.seq, segment.end_ms]));
|
|
73
|
+
log.closed = log.closed.filter(segment => !(ends.has(segment.seq) && segment.end_ms <= ends.get(segment.seq)));
|
|
74
|
+
const notes = new Set(batch.notes.map(note => note.seq));
|
|
75
|
+
log.notes = log.notes.filter(note => !notes.has(note.seq));
|
|
76
|
+
const open = log.open && batch.segments.find(segment => segment.seq === log.open.seq);
|
|
77
|
+
if (open) log.open_sent = { seq: open.seq, end_ms: open.end_ms };
|
|
78
|
+
log.reported = this.now();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Send the execution's segments and notes with `send({segments, notes})`. `final` closes the open segment, sends
|
|
82
|
+
* everything left and forgets the execution. A refused report drops them and any other failure keeps them for the
|
|
83
|
+
* next try; it never throws, so a progress report cannot fail the execution it describes.
|
|
84
|
+
*/
|
|
85
|
+
async report(id, final, send) {
|
|
86
|
+
do {
|
|
87
|
+
const batch = this.batch(id, final);
|
|
88
|
+
if (!batch.segments.length && !batch.notes.length) break;
|
|
89
|
+
try { await send(batch); }
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (/HTTP 4\d\d/.test(error.message)) this.drop(id);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.sent(id, batch);
|
|
95
|
+
} while (final && (this.executions.get(id)?.closed.length || this.executions.get(id)?.notes.length));
|
|
96
|
+
if (final) this.drop(id);
|
|
97
|
+
}
|
|
98
|
+
drop(id) { this.executions.delete(id); }
|
|
99
|
+
}
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* software or one member needs. Presets exist only where a software needs one; a member's own
|
|
4
4
|
* system_prompt, even an empty one, replaces its preset. They add guidance and never widen permissions.
|
|
5
5
|
*/
|
|
6
|
+
// Plain data with no imports of its own, so this module stays loadable by the web page.
|
|
7
|
+
import { adapter_profile } from './adapters/profiles.mjs';
|
|
6
8
|
|
|
7
9
|
export const MAX_EXPERT_PROMPT = 4000;
|
|
8
10
|
|
|
9
|
-
// A software's must-know rules are fixed by the runner (
|
|
11
|
+
// A software's must-know rules are fixed by the runner (its adapter profile's task_rules, API in its system prompt), so no preset is needed now.
|
|
10
12
|
export const EXPERT_PROMPT_PRESETS = {};
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -51,35 +53,28 @@ export const PROJECT_ACCESS = ['read', 'write', 'auto', 'full'];
|
|
|
51
53
|
|
|
52
54
|
const ACCESS_LABELS = { read: '只读', write: '可写', auto: '自动', full: '全放开' };
|
|
53
55
|
|
|
54
|
-
/**
|
|
55
|
-
const ACCESS_HINTS = {
|
|
56
|
-
claude: { read: '只放行网站命令与草稿,不改项目文件、不跑其他命令', write: '可改项目文件,Bash / PowerShell 命令全部放行(无沙箱)',
|
|
57
|
-
auto: 'Claude auto 模式:由分类器判断,实测项目外写入和联网都会放行', full: 'bypassPermissions:跳过全部权限检查' },
|
|
58
|
-
codex_cli: { read: '只读沙箱', write: '项目可写的沙箱,命令在沙箱内运行、可联网',
|
|
59
|
-
auto: '沙箱外的操作由模型申请提权,Codex 自动审核代替人工批准', full: '无沙箱、无审批(--dangerously-bypass-approvals-and-sandbox)' },
|
|
60
|
-
antigravity_cli: { read: '只放行网站命令;碰到未放行的命令会直接结束本轮', write: '可写项目,命令全部放行(在 agy 沙箱内)',
|
|
61
|
-
full: '跳过全部权限确认(--dangerously-skip-permissions),并放开沙箱外命令与 MCP 工具' },
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
/** agy offers 自动 only in its interactive window; headless `--mode auto` is "not available for this account" (1.4.10–1.4.11). */
|
|
56
|
+
/** The levels a software can enforce headless, from its adapter profile; an App or API member is offered all of them. */
|
|
65
57
|
export function access_levels(adapter) {
|
|
66
|
-
return adapter
|
|
58
|
+
return adapter_profile(adapter)?.access_levels || PROJECT_ACCESS;
|
|
67
59
|
}
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
61
|
+
/**
|
|
62
|
+
* All four levels labelled for the page, with what each does in this software on hover; ones it lacks are shown but
|
|
63
|
+
* disabled, so every CLI card lines up the same four levels.
|
|
64
|
+
*/
|
|
73
65
|
export function access_options(adapter) {
|
|
74
|
-
const levels = access_levels(adapter);
|
|
66
|
+
const levels = access_levels(adapter), profile = adapter_profile(adapter);
|
|
75
67
|
return PROJECT_ACCESS.map(value => levels.includes(value)
|
|
76
|
-
? { value, label: ACCESS_LABELS[value], hint:
|
|
77
|
-
: { value, label: ACCESS_LABELS[value], hint:
|
|
68
|
+
? { value, label: ACCESS_LABELS[value], hint: profile?.access_hints?.[value] || '' }
|
|
69
|
+
: { value, label: ACCESS_LABELS[value], hint: profile?.unsupported_hints?.[value] || '该软件不支持此档位', disabled: true });
|
|
78
70
|
}
|
|
79
71
|
|
|
80
72
|
/** The page's name for a level; `own` reads as decided by the member's own session. */
|
|
81
73
|
export function access_label(level) { return level === 'own' ? '自行决定' : ACCESS_LABELS[level] || '只读'; }
|
|
82
74
|
|
|
75
|
+
/** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
|
|
76
|
+
export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'review', 'execution', 'health', 'schema'];
|
|
77
|
+
|
|
83
78
|
/**
|
|
84
79
|
* Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project.
|
|
85
80
|
* A taken-over manual member (`own`) keeps its own session's permissions, so the room's limits do not apply.
|
|
@@ -98,14 +93,9 @@ export function project_rule(profile, purpose) {
|
|
|
98
93
|
return profile.project_access === 'write' ? `用户允许${purpose}修改 cwd 中的项目文件并运行所需命令。` : 'cwd 中的项目文件只读,不修改。';
|
|
99
94
|
}
|
|
100
95
|
|
|
101
|
-
/**
|
|
102
|
-
export function
|
|
103
|
-
return adapter
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** agy streams no thinking text, so without a word at each turning point the page cannot show how far it got; fixed, so no member prompt drops it. */
|
|
107
|
-
export function agy_progress_rule(adapter) {
|
|
108
|
-
return adapter === 'antigravity_cli' ? '多步任务中,在关键转折处用一句话写出进展再继续:agy 不输出思考过程,网页只能靠这些话显示你做到哪一步。' : '';
|
|
96
|
+
/** The fixed rules one software needs in every task, from its adapter profile; no member prompt can drop them. */
|
|
97
|
+
export function software_rules(config, cli) {
|
|
98
|
+
return adapter_profile(config.adapter)?.task_rules?.(cli, { runs_commands: runs_commands(config) }) || '';
|
|
109
99
|
}
|
|
110
100
|
|
|
111
101
|
/** Windows PowerShell mis-decodes UTF-8 files; the runner adds this where the member's commands can run rg. */
|
|
@@ -123,7 +113,7 @@ export const READ_RULE = '读含中文的文件用 rg --encoding utf-8 . 文件
|
|
|
123
113
|
export function free_task_text({ name, discussion, discussion_request, profile, cli, room, messages, read_after, execution_id, task_path, access_file = '', read_rule = '' }) {
|
|
124
114
|
const scope = discussion ? `讨论进行中,用户直接向你提问(${messages}):只回答这个提问,不改变讨论安排和进度。` : '';
|
|
125
115
|
const commands = runs_commands(profile) ? '' : `命令只用 subcommands 列出的 ${cli} 子命令。`;
|
|
126
|
-
const instructions = `${
|
|
116
|
+
const instructions = `${software_rules(profile, cli)}${scope}${project_rule(profile, '按请求')}${commands}草稿写在 draft_directory。`;
|
|
127
117
|
if (discussion_request) {
|
|
128
118
|
const role = discussion_request.purpose === 'discuss_host' ? '主持任务' : '发言请求';
|
|
129
119
|
const prompt = `你(${name})收到了讨论中的${role}。先读任务文件 ${task_path},按其中列出的技能处理本次请求并保存结果。${read_rule}`;
|
package/runner/files.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Small private files the runner keeps on this machine. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/** Persist small private state atomically without logging its contents. */
|
|
6
|
+
export function atomic_json(path, value) {
|
|
7
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
8
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
9
|
+
renameSync(temporary, path);
|
|
10
|
+
}
|
package/runner/free_runner.mjs
CHANGED
|
@@ -3,12 +3,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import { basename, join, resolve } from 'node:path';
|
|
6
|
-
import {
|
|
7
|
-
import { api,
|
|
8
|
-
import {
|
|
6
|
+
import { adapter_of, BRANCHABLE_ADAPTERS, branch_session, capabilities_of, cleanup_room, deliver_task, delivery_receipt, session_exists, stored_delivery } from './adapters/index.mjs';
|
|
7
|
+
import { api, read_rule } from './discussion_runner.mjs';
|
|
8
|
+
import { SegmentLog } from './execution_progress.mjs';
|
|
9
|
+
import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
|
|
10
|
+
import { atomic_json } from './files.mjs';
|
|
9
11
|
import { SessionDriver } from './session_driver.mjs';
|
|
10
|
-
import {
|
|
11
|
-
import { REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message } from './scheduler.mjs';
|
|
12
|
+
import { DEFAULT_SCHEDULER_MODE, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version } from './scheduler.mjs';
|
|
12
13
|
|
|
13
14
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
14
15
|
const RENEW_MS = 30_000;
|
|
@@ -28,7 +29,7 @@ const GIT_TIMEOUT_MS = 15_000;
|
|
|
28
29
|
/** Fingerprint configurations whose adapter can enforce project read-only access; no local paths leave this function. */
|
|
29
30
|
export function review_capability_version(profile, member, config) {
|
|
30
31
|
if (!profile || profile.project_access !== 'read' || member.access !== 'runner' || member.session_mode !== 'new'
|
|
31
|
-
|| profile.adapter
|
|
32
|
+
|| !capabilities_of(profile.adapter).enforces_read_only || profile.adapter !== member.adapter
|
|
32
33
|
|| !profile.cwd || profile.allowed_tools?.length) return '';
|
|
33
34
|
const project = cwd_key(profile.cwd), drafts = cwd_key(config.draft_directory || `${config.directory}-drafts`);
|
|
34
35
|
if (drafts === project || drafts.startsWith(`${project}/`)) return '';
|
|
@@ -77,7 +78,7 @@ export function changed_between(before, after) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Write a non-secret task and a member-only credential file for one execution. */
|
|
80
|
-
export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], process_access = true } = {}) {
|
|
81
|
+
export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], stopped = [], process_access = true } = {}) {
|
|
81
82
|
const id = `free-${execution.id}`;
|
|
82
83
|
const draft_directory = config.draft_directory || `${config.directory}-drafts`;
|
|
83
84
|
mkdirSync(draft_directory, { recursive: true });
|
|
@@ -86,9 +87,10 @@ export function free_task_files(config, member, execution, binding, { session =
|
|
|
86
87
|
const profile = config.participants[member.name];
|
|
87
88
|
atomic_json(access_path, { site: config.site, room: config.room, token: config.members[member.name].token,
|
|
88
89
|
session_id: binding.session_id || '', execution_id: execution.id });
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
90
|
+
const adapter = adapter_of(profile.adapter);
|
|
91
|
+
const cli = process.platform === 'win32' ? adapter?.windows_command || 'codiscuss.cmd' : 'codiscuss';
|
|
92
|
+
const inbox = Boolean(adapter?.into_window?.(profile, binding));
|
|
93
|
+
const bound_access = process_access && !inbox && Boolean(adapter?.capabilities.access_env);
|
|
92
94
|
const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
|
|
93
95
|
const requests = execution.requests || [];
|
|
94
96
|
const discussion_request = requests.find(item => item.session_request_id && ['discuss', 'discuss_host'].includes(item.purpose)) || null;
|
|
@@ -111,6 +113,7 @@ export function free_task_files(config, member, execution, binding, { session =
|
|
|
111
113
|
goal: session.goal, named_followup: session.named_followup } } : {}),
|
|
112
114
|
...(suggestions.length ? { scheduler_suggestions: suggestions } : {}),
|
|
113
115
|
...(reviews.length ? { review_handoffs: reviews } : {}),
|
|
116
|
+
...(stopped.length ? { stopped_executions: stopped } : {}),
|
|
114
117
|
role: member.role || 'member',
|
|
115
118
|
read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
|
|
116
119
|
skills: skills.map(file => join(config.project, '.agents/skills', file)),
|
|
@@ -120,15 +123,13 @@ export function free_task_files(config, member, execution, binding, { session =
|
|
|
120
123
|
skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile), inbox };
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
|
|
124
|
-
antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
|
|
125
126
|
|
|
126
127
|
/** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
|
|
127
128
|
function member_description(name, profile, work) {
|
|
128
129
|
const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
|
|
129
|
-
const access = profile.adapter
|
|
130
|
+
const access = !capabilities_of(profile.adapter).project ? ',只能读写房间消息' : profile.project_access === 'own' ? ',权限沿用其会话设置' : !profile.cwd ? ',未指定项目目录'
|
|
130
131
|
: profile.project_access === 'read' ? `,项目 ${project} 只读` : `,可修改项目 ${project} 的文件并运行命令`;
|
|
131
|
-
return `${name}(${
|
|
132
|
+
return `${name}(${adapter_of(profile.adapter)?.label || profile.adapter}${access}${work})`;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
|
|
@@ -165,6 +166,8 @@ export class FreeRoomRunner {
|
|
|
165
166
|
this.routing = new Set();
|
|
166
167
|
this.children = new Set();
|
|
167
168
|
this.members = new Map();
|
|
169
|
+
// An empty set proves every tracked handle for this execution closed; a missing set proves nothing after restart.
|
|
170
|
+
this.execution_processes = new Map();
|
|
168
171
|
// Execution id -> why its session ended on its own, such as a command refused in unattended mode ('' when unknown).
|
|
169
172
|
this.exited = new Map();
|
|
170
173
|
this.snapshots = new Map();
|
|
@@ -210,7 +213,7 @@ export class FreeRoomRunner {
|
|
|
210
213
|
*/
|
|
211
214
|
same_context(name, previous, current) {
|
|
212
215
|
const [before, after] = [JSON.parse(previous), JSON.parse(current)];
|
|
213
|
-
for (const context of [before, after])
|
|
216
|
+
for (const context of [before, after]) context[0] = adapter_of(context[0])?.session_family || context[0];
|
|
214
217
|
const bound = this.store.binding(name).session_id;
|
|
215
218
|
if (before[5] !== after[5] && [before[5], after[5]].includes('') && [before[5], after[5]].includes(bound)) after[5] = before[5];
|
|
216
219
|
return JSON.stringify(before) === JSON.stringify(after);
|
|
@@ -225,10 +228,11 @@ export class FreeRoomRunner {
|
|
|
225
228
|
room = await api(this.config, '');
|
|
226
229
|
}
|
|
227
230
|
catch (error) {
|
|
228
|
-
if (error.message === 'HTTP 404: 房间不存在')
|
|
231
|
+
if (error.message === 'HTTP 404: 房间不存在') cleanup_room(this.config.directory);
|
|
229
232
|
throw error;
|
|
230
233
|
}
|
|
231
234
|
if (!inbox.archived) await this.sync_review_capabilities(inbox);
|
|
235
|
+
if (!inbox.archived) await this.sync_member_capabilities(inbox);
|
|
232
236
|
if (!inbox.archived && room.mode === 'free' && room.session?.status === 'active'
|
|
233
237
|
&& await this.session_driver.tick(room, inbox)) {
|
|
234
238
|
// A scheduled plan completes the pending host request and creates a speaker request.
|
|
@@ -249,14 +253,17 @@ export class FreeRoomRunner {
|
|
|
249
253
|
if (LIVE.includes(row.status) && !present.has(row.member)) { this.stop_member(row.member); this.save(row.member, { status: 'idle' }); }
|
|
250
254
|
}
|
|
251
255
|
await this.report_finished(active);
|
|
256
|
+
const pending_stops = await this.report_stops(active);
|
|
252
257
|
// Start in the room's speaking order: execution ids follow it, and the website lays out working cards by them.
|
|
253
258
|
const rank = (room.speaking_order?.items || []).map(item => item.name);
|
|
254
259
|
const place = name => rank.includes(name) ? rank.indexOf(name) : rank.length;
|
|
255
260
|
for (const member of [...inbox.members].sort((left, right) => place(left.name) - place(right.name))) {
|
|
256
261
|
const row = this.row(member.name);
|
|
257
262
|
if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active);
|
|
263
|
+
// A member voided in a window or App the runner could not stop waits until that turn ends (`held_until`).
|
|
258
264
|
else if (!inbox.archived && member.pending.some(request => request.purpose !== 'group') && !this.members.get(member.name)?.size
|
|
259
|
-
&& !
|
|
265
|
+
&& !pending_stops.has(member.name)
|
|
266
|
+
&& !member.pending.some(request => this.routing.has(request.id)) && !(member.held_until * 1000 > Date.now())) await this.start(member, row);
|
|
260
267
|
}
|
|
261
268
|
}
|
|
262
269
|
/** Publish only changed local facts, using member revisions supplied by the owner's inbox. */
|
|
@@ -274,6 +281,18 @@ export class FreeRoomRunner {
|
|
|
274
281
|
} catch (error) { this.log(`只读评审能力未同步:${member.name}:${error.message}`); }
|
|
275
282
|
}
|
|
276
283
|
}
|
|
284
|
+
/** Tell the site what a rewind does to each member's session, as its adapter says; only a change is sent. */
|
|
285
|
+
async sync_member_capabilities(inbox) {
|
|
286
|
+
for (const member of inbox.members) {
|
|
287
|
+
const profile = this.config.participants[member.name];
|
|
288
|
+
// An older site lists no effect and takes no report.
|
|
289
|
+
if (member.rewind === undefined || !profile) continue;
|
|
290
|
+
const rewind = capabilities_of(profile.adapter).rewind;
|
|
291
|
+
if (member.rewind === rewind) continue;
|
|
292
|
+
try { await api(this.config, `/members/${member.id}/capabilities`, { rewind }); member.rewind = rewind; }
|
|
293
|
+
catch (error) { this.log(`成员会话能力未同步:${member.name}:${error.message}`); }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
277
296
|
/** Hold only pending review handoffs while their preauthorized next step is judged. */
|
|
278
297
|
schedule_reviews(inbox) {
|
|
279
298
|
const host = inbox.members.find(member => member.role === 'host');
|
|
@@ -432,7 +451,8 @@ export class FreeRoomRunner {
|
|
|
432
451
|
let record;
|
|
433
452
|
if (decision.detail) {
|
|
434
453
|
if (result.status === 'failed') label += '(未生效,网站写入失败)';
|
|
435
|
-
|
|
454
|
+
// The @全体成员 question has one wording in every mode.
|
|
455
|
+
record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host', { prompt: ROUTE_PROMPT_VERSION });
|
|
436
456
|
}
|
|
437
457
|
this.save_suggestion(request, { action: decision.action, actual_action,
|
|
438
458
|
applied: result.applied && decision.action !== 'host', model_judgment: Boolean(decision.detail),
|
|
@@ -462,7 +482,10 @@ export class FreeRoomRunner {
|
|
|
462
482
|
const work = member_work(requests, member.name, message_id);
|
|
463
483
|
const profile = this.config.participants[member.name];
|
|
464
484
|
const described = room.descriptions?.[member.name] ? `;用户描述:${room.descriptions[member.name]}` : '';
|
|
465
|
-
|
|
485
|
+
// The model and the last finished message are only read by the guided wording.
|
|
486
|
+
const finished = (room.free?.requests || []).filter(item => item.member === member.name && item.status === 'completed' && item.message_id !== message_id).at(-1);
|
|
487
|
+
return { id: member.id, name: member.name, role: member.role, model: profile?.model || '', finished: finished ? `#${finished.message_id}` : '',
|
|
488
|
+
description: profile ? member_description(member.name, profile, `${work}${described}`)
|
|
466
489
|
: `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发${work}${described})` };
|
|
467
490
|
});
|
|
468
491
|
}
|
|
@@ -523,7 +546,7 @@ export class FreeRoomRunner {
|
|
|
523
546
|
return await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
|
|
524
547
|
applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
|
|
525
548
|
context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
|
|
526
|
-
prompt:
|
|
549
|
+
mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: route_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION, ...context } });
|
|
527
550
|
} catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
|
|
528
551
|
}
|
|
529
552
|
/** Reserve with a persisted client id before invoking any software. */
|
|
@@ -536,7 +559,7 @@ export class FreeRoomRunner {
|
|
|
536
559
|
async reserve(member, record) {
|
|
537
560
|
let execution;
|
|
538
561
|
const profile = this.config.participants[member.name];
|
|
539
|
-
const cwd = profile
|
|
562
|
+
const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd || this.config.cwd);
|
|
540
563
|
try { execution = await api(this.config, '/executions', { member_id: member.id, client_id: record.client_id, cwd }); }
|
|
541
564
|
catch (error) {
|
|
542
565
|
if (/HTTP (404|409)/.test(error.message)) return this.save(member.name, { status: 'idle', error: '' });
|
|
@@ -556,7 +579,7 @@ export class FreeRoomRunner {
|
|
|
556
579
|
}
|
|
557
580
|
if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
|
|
558
581
|
if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
|
|
559
|
-
const directory = adapter.adapter
|
|
582
|
+
const directory = capabilities_of(adapter.adapter).project ? adapter.cwd || this.config.cwd : undefined;
|
|
560
583
|
// A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
|
|
561
584
|
const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
|
|
562
585
|
if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
|
|
@@ -572,8 +595,11 @@ export class FreeRoomRunner {
|
|
|
572
595
|
: session_request?.purpose === 'discuss_host' ? this.session_driver.take_suggestions(session_request.id)
|
|
573
596
|
: execution.requests.map(request => this.saved_suggestion(request.id)).filter(Boolean);
|
|
574
597
|
const review_handoffs = await this.review_handoffs(execution);
|
|
598
|
+
// Executions the user stopped that this session may still hold; a rewind already cut its own out, and a new session holds none.
|
|
599
|
+
const rewound = new Set(execution.rewound_executions || []);
|
|
600
|
+
const stopped = binding.session_id ? this.stopped_executions(member.name).filter(id => !rewound.has(id)) : [];
|
|
575
601
|
const task = free_task_files(this.config, member, execution, binding, {
|
|
576
|
-
session, suggestions: scheduler_suggestions, reviews: review_handoffs,
|
|
602
|
+
session, suggestions: scheduler_suggestions, reviews: review_handoffs, stopped,
|
|
577
603
|
});
|
|
578
604
|
invoked = true;
|
|
579
605
|
this.segments.start(execution.id);
|
|
@@ -585,7 +611,7 @@ export class FreeRoomRunner {
|
|
|
585
611
|
requests: execution.requests, read_after: execution.read_until, session, discussion_request: session_request,
|
|
586
612
|
scheduler_suggestions, review_handoffs },
|
|
587
613
|
on_binding: session => this.store.bind(member.name, session),
|
|
588
|
-
on_process: child => this.track(member.name, child),
|
|
614
|
+
on_process: child => this.track(member.name, child, execution.id),
|
|
589
615
|
on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.touch(execution.id); this.segments.mark(execution.id); },
|
|
590
616
|
on_activity: (phase, tool) => { this.touch(execution.id, phase); this.segments.mark(execution.id, phase, tool); },
|
|
591
617
|
on_note: text => this.note(execution.id, text),
|
|
@@ -596,7 +622,9 @@ export class FreeRoomRunner {
|
|
|
596
622
|
on_denials: denials => api(this.config, `/executions/${execution.id}`, { action: 'denials', denials }).catch(() => {}),
|
|
597
623
|
});
|
|
598
624
|
if (result.session_id) this.store.bind(member.name, result.session_id);
|
|
625
|
+
this.clear_stopped(member.name);
|
|
599
626
|
this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
|
|
627
|
+
await this.report_delivery(execution.id, result.capabilities || capabilities_of(adapter.adapter).delivery, member.name);
|
|
600
628
|
} catch (error) {
|
|
601
629
|
if (review_request && error.delivery_status === 'not_sent') return this.reject_review(member, execution, error.message);
|
|
602
630
|
if (adapter.project_access === 'own') return this.retry_or_fail(member.name, execution, `投递失败:${error.message}`);
|
|
@@ -660,7 +688,7 @@ export class FreeRoomRunner {
|
|
|
660
688
|
async reconcile(member, row, active) {
|
|
661
689
|
if (row.status === 'reserving') return this.reserve(member, row);
|
|
662
690
|
if (row.status === 'sidecar_pending') {
|
|
663
|
-
const receipt =
|
|
691
|
+
const receipt = delivery_receipt(this.config.participants[member.name], `free-${row.execution_id}`);
|
|
664
692
|
if (receipt?.status === 'accepted' && receipt.session_id) {
|
|
665
693
|
this.store.bind(member.name, receipt.session_id);
|
|
666
694
|
this.save(member.name, { status: 'running' });
|
|
@@ -705,6 +733,79 @@ export class FreeRoomRunner {
|
|
|
705
733
|
this.save(member.name, { last_renew: Date.now() });
|
|
706
734
|
}
|
|
707
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* Tell the site what this delivery can do, as the adapter reported it: whether the runner can stop it and see its
|
|
738
|
+
* progress. Kept locally until the execution ends so a restarted runner still knows whether it could stop it.
|
|
739
|
+
* A lost report is retained for the final stop result; sites and runners must use the same capability protocol.
|
|
740
|
+
*/
|
|
741
|
+
async report_delivery(execution_id, { stoppable, progress }, member) {
|
|
742
|
+
this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`delivery:${execution_id}`, JSON.stringify({ stoppable, progress, member }));
|
|
743
|
+
await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress })
|
|
744
|
+
.catch(error => this.log(`执行 #${execution_id} 的投递能力未能上报:${error.message}`));
|
|
745
|
+
}
|
|
746
|
+
/** Retry durable stop reports even after the member went idle, left, or started a later execution. */
|
|
747
|
+
async report_stops(active) {
|
|
748
|
+
const pending = new Set();
|
|
749
|
+
for (const { key, value } of this.store.db.prepare("SELECT key,value FROM metadata WHERE key LIKE 'delivery:%'").all()) {
|
|
750
|
+
const execution_id = Number(key.slice('delivery:'.length));
|
|
751
|
+
if (active.has(execution_id)) continue;
|
|
752
|
+
const delivery = stored_delivery(value);
|
|
753
|
+
const name = delivery.member || this.store.db.prepare('SELECT member FROM free_deliveries WHERE execution_id=?').get(execution_id)?.member;
|
|
754
|
+
if (!name) continue;
|
|
755
|
+
for (const child of this.execution_processes.get(execution_id) || []) this.request_stop(child);
|
|
756
|
+
try { await this.report_stop(name, execution_id); }
|
|
757
|
+
catch (error) { pending.add(name); this.log(`执行 #${execution_id} 的停止结果未能上报:${error.message}`); }
|
|
758
|
+
}
|
|
759
|
+
return pending;
|
|
760
|
+
}
|
|
761
|
+
/** Report observed termination, never infer it from a capability or a successfully sent kill signal. */
|
|
762
|
+
async report_stop(name, execution_id) {
|
|
763
|
+
const key = `delivery:${execution_id}`;
|
|
764
|
+
const value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
|
|
765
|
+
if (!value) return;
|
|
766
|
+
const delivery = stored_delivery(value), processes = this.execution_processes.get(execution_id);
|
|
767
|
+
const ended = processes ? processes.size === 0 : this.exited.has(execution_id);
|
|
768
|
+
const result = delivery.pending_result && delivery.pending_result !== 'unconfirmed' ? delivery.pending_result
|
|
769
|
+
: !delivery.stoppable ? 'unstoppable' : ended ? 'stopped' : 'unconfirmed';
|
|
770
|
+
if (delivery.stop_result === result) return;
|
|
771
|
+
// Keep the observed outcome before sending: a restart after a lost response must retry the same fact.
|
|
772
|
+
this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
|
|
773
|
+
.run(JSON.stringify({ ...delivery, member: name, pending_result: result }), key);
|
|
774
|
+
try { await api(this.config, `/executions/${execution_id}`, { action: 'stopped', result }); }
|
|
775
|
+
catch (error) {
|
|
776
|
+
if (!/HTTP 409:/.test(error.message)) throw error;
|
|
777
|
+
const latest = await api(this.config, `/executions/${execution_id}`);
|
|
778
|
+
// A conflict is not necessarily normal completion: keep cancelled reports until an idempotent receipt arrives.
|
|
779
|
+
if (!['completed', 'failed'].includes(latest.status)) throw error;
|
|
780
|
+
this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
|
|
781
|
+
this.execution_processes.delete(execution_id);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)')
|
|
785
|
+
.run(`stopped:${name}`, JSON.stringify([...new Set([...this.stopped_executions(name), execution_id])]));
|
|
786
|
+
if (result === 'unconfirmed') {
|
|
787
|
+
this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
|
|
788
|
+
.run(JSON.stringify({ ...delivery, member: name, pending_result: result, stop_result: result }), key);
|
|
789
|
+
// The site's bounded hold now controls the next delivery; retain handles to observe and retry termination.
|
|
790
|
+
for (const child of processes || []) this.members.get(name)?.delete(child);
|
|
791
|
+
}
|
|
792
|
+
else { this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key); this.execution_processes.delete(execution_id); }
|
|
793
|
+
}
|
|
794
|
+
/** Once a new task received the stop reminders, retire acknowledged unconfirmed reports for its old turns. */
|
|
795
|
+
clear_stopped(name) {
|
|
796
|
+
for (const id of this.stopped_executions(name)) {
|
|
797
|
+
const key = `delivery:${id}`, value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
|
|
798
|
+
if (value && stored_delivery(value).stop_result === 'unconfirmed') {
|
|
799
|
+
this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
|
|
800
|
+
this.execution_processes.delete(id);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(`stopped:${name}`);
|
|
804
|
+
}
|
|
805
|
+
/** Executions the user stopped since this member's last delivered task. */
|
|
806
|
+
stopped_executions(name) {
|
|
807
|
+
return JSON.parse(this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`stopped:${name}`)?.value || '[]');
|
|
808
|
+
}
|
|
708
809
|
/** Send the execution's recorded segments and progress notes; `final` sends the rest once it has ended. */
|
|
709
810
|
report_segments(execution_id, final = false) {
|
|
710
811
|
return this.segments.report(execution_id, final, ({ segments, notes }) =>
|
|
@@ -726,7 +827,7 @@ export class FreeRoomRunner {
|
|
|
726
827
|
/** Report observed file changes once an execution has ended and its member's processes have exited. */
|
|
727
828
|
async report_finished(active) {
|
|
728
829
|
for (const [execution_id, snapshot] of this.snapshots) {
|
|
729
|
-
if (active.has(execution_id) || this.
|
|
830
|
+
if (active.has(execution_id) || this.execution_processes.get(execution_id)?.size) continue;
|
|
730
831
|
this.snapshots.delete(execution_id);
|
|
731
832
|
const after = workspace_snapshot(snapshot.directory);
|
|
732
833
|
if (!after) continue;
|
|
@@ -759,14 +860,19 @@ export class FreeRoomRunner {
|
|
|
759
860
|
this.stop_member(name);
|
|
760
861
|
}
|
|
761
862
|
/** Count live processes per member so one session never has two writers. */
|
|
762
|
-
track(name, child) {
|
|
863
|
+
track(name, child, execution_id = this.row(name)?.execution_id) {
|
|
763
864
|
this.children.add(child);
|
|
764
865
|
if (!this.members.has(name)) this.members.set(name, new Set());
|
|
765
866
|
this.members.get(name).add(child);
|
|
766
|
-
|
|
867
|
+
if (!this.execution_processes.has(execution_id)) this.execution_processes.set(execution_id, new Set());
|
|
868
|
+
const processes = this.execution_processes.get(execution_id);
|
|
869
|
+
processes.add(child);
|
|
870
|
+
child.once('close', () => { processes.delete(child); this.children.delete(child); this.members.get(name).delete(child); });
|
|
767
871
|
}
|
|
872
|
+
/** Request termination; only a later close event proves it happened. */
|
|
873
|
+
request_stop(child) { try { child.kill(); } catch { /* The stop report remains unconfirmed. */ } }
|
|
768
874
|
/** Stop one member's processes without touching other members or desktop apps. */
|
|
769
|
-
stop_member(name) { for (const child of this.members.get(name) || [])
|
|
875
|
+
stop_member(name) { for (const child of this.members.get(name) || []) this.request_stop(child); }
|
|
770
876
|
/** Stop every process this runner created. */
|
|
771
|
-
stop_children() { for (const child of this.children)
|
|
877
|
+
stop_children() { for (const child of this.children) this.request_stop(child); }
|
|
772
878
|
}
|