@oar-sh/oar 0.9.18 → 0.9.20
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/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +359 -0
- package/README.md +10 -10
- package/bin/oar.js +391 -97
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -2
- package/server/claude-worker/claude-session-process.mjs +3 -0
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +44 -6
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-view.mjs +87 -3
- package/server/public/app/api-client.js +2 -1
- package/server/public/app/bootstrap.js +55 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-view.js +17 -2
- package/server/public/app/inbox-ui.js +32 -0
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-view.mjs +4 -0
- package/server/public/app/socket-handlers.js +10 -2
- package/server/public/index.html +14 -0
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +351 -0
- package/server/relay-self-restart.mjs +116 -9
- package/server/relay-tools.md +5 -4
- package/server/repositories/message-repository.mjs +31 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/messages-routes.mjs +120 -4
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +141 -31
- package/server/server-runtime.mjs +142 -11
- package/server/services/about-service.mjs +59 -0
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +68 -8
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-scope.mjs +77 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +98 -21
- package/shared/turn-token-usage.mjs +144 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import v8 from 'v8';
|
|
5
|
+
|
|
6
|
+
import { resolveDataDir } from './services/data-dir-resolution.mjs';
|
|
7
|
+
|
|
8
|
+
// The config.json key an operator sets to pin the runtime's heap (MB). A key
|
|
9
|
+
// and not an env var: a shell's NODE_OPTIONS is lost on the next relaunch by
|
|
10
|
+
// systemd, an autostart launcher or the CLI extension; config.json is read by
|
|
11
|
+
// every one of them.
|
|
12
|
+
export const HEAP_LIMIT_CONFIG_KEY = 'heapLimitMb';
|
|
13
|
+
export const MIN_HEAP_LIMIT_MB = 512;
|
|
14
|
+
const DEFAULT_FLOOR_MB = 2048;
|
|
15
|
+
const DEFAULT_CAP_MB = 8192;
|
|
16
|
+
const DEFAULT_RAM_SHARE = 0.6;
|
|
17
|
+
// v8's heap_size_limit is old space plus the young generation, while
|
|
18
|
+
// --max-old-space-size sets old space only: compare like with like. Node 24
|
|
19
|
+
// reserves 3 × 64 MB semi-spaces (old space 2048 → heap_size_limit 2240);
|
|
20
|
+
// older Nodes reserve less, which only sets this floor a little low.
|
|
21
|
+
const YOUNG_GENERATION_ALLOWANCE_MB = 192;
|
|
22
|
+
const MB = 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
const HEAP_FLAG = /^--max[-_]old[-_]space[-_]size(?:=(.*))?$/;
|
|
25
|
+
// Node 24.11+: the old space as a percentage of the memory Node sees.
|
|
26
|
+
const HEAP_PERCENTAGE_FLAG = /^--max[-_]old[-_]space[-_]size[-_]percentage(?:=(.*))?$/;
|
|
27
|
+
const SNAPSHOT_FLAG = /^--heapsnapshot[-_]near[-_]heap[-_]limit(?:=|$)/;
|
|
28
|
+
const DIAGNOSTIC_DIR_FLAG = /^--diagnostic[-_]dir(?:=(.*))?$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* NODE_OPTIONS as Node splits it (ParseNodeOptionsEnvVar): on spaces, except
|
|
32
|
+
* inside double quotes, which are taken off; inside them a backslash escapes
|
|
33
|
+
* the next character. A single quote is an ordinary character.
|
|
34
|
+
*/
|
|
35
|
+
export function splitNodeOptions(value) {
|
|
36
|
+
const args = [];
|
|
37
|
+
let current = '';
|
|
38
|
+
let inString = false;
|
|
39
|
+
const text = String(value || '');
|
|
40
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
41
|
+
let char = text[index];
|
|
42
|
+
if (char === '\\' && inString && index + 1 < text.length) {
|
|
43
|
+
index += 1;
|
|
44
|
+
char = text[index];
|
|
45
|
+
} else if (char === ' ' && !inString) {
|
|
46
|
+
if (current) args.push(current);
|
|
47
|
+
current = '';
|
|
48
|
+
continue;
|
|
49
|
+
} else if (char === '"') {
|
|
50
|
+
inString = !inString;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
current += char;
|
|
54
|
+
}
|
|
55
|
+
if (current) args.push(current);
|
|
56
|
+
return args;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The value the last `flag` in an arg list sets (`=V` or V as the next arg),
|
|
60
|
+
// as in Node; undefined when none sets it.
|
|
61
|
+
function lastFlagValue(args, flag) {
|
|
62
|
+
let found;
|
|
63
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
64
|
+
const match = flag.exec(String(args[index]));
|
|
65
|
+
if (match) found = match[1] !== undefined ? match[1] : String(args[index + 1] ?? '');
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function positiveNumber(value) {
|
|
71
|
+
const number = Number.parseFloat(String(value ?? ''));
|
|
72
|
+
return Number.isFinite(number) && number > 0 ? number : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The heap limit the operator set in execArgv or NODE_OPTIONS: `{ mb }` for
|
|
77
|
+
* --max-old-space-size, `{ percentage }` for --max-old-space-size-percentage,
|
|
78
|
+
* with its `source`; null when neither sets one. As in Node, a percentage
|
|
79
|
+
* wins over a size wherever each is set, and execArgv over NODE_OPTIONS.
|
|
80
|
+
*/
|
|
81
|
+
export function operatorHeapLimit({ execArgv = [], nodeOptions = '' } = {}) {
|
|
82
|
+
const sources = [['execArgv', Array.from(execArgv || [])], ['NODE_OPTIONS', splitNodeOptions(nodeOptions)]];
|
|
83
|
+
for (const [source, args] of sources) {
|
|
84
|
+
const percentage = positiveNumber(lastFlagValue(args, HEAP_PERCENTAGE_FLAG));
|
|
85
|
+
if (percentage) return { percentage, source };
|
|
86
|
+
}
|
|
87
|
+
for (const [source, args] of sources) {
|
|
88
|
+
const mb = positiveNumber(lastFlagValue(args, HEAP_FLAG));
|
|
89
|
+
if (mb) return { mb: Math.floor(mb), source };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The memory this host gives the relay: a container's or cgroup's limit
|
|
96
|
+
* (process.constrainedMemory) when it is set and under the RAM, else the RAM.
|
|
97
|
+
* Without a limit it reports 0, nothing, or a huge number: ignored.
|
|
98
|
+
*/
|
|
99
|
+
export function availableMemory({ totalMem = os.totalmem(), constrainedMem = process.constrainedMemory?.() } = {}) {
|
|
100
|
+
const total = Number(totalMem) || 0;
|
|
101
|
+
const constrained = Number(constrainedMem);
|
|
102
|
+
if (Number.isFinite(constrained) && constrained > 0 && constrained < total) {
|
|
103
|
+
return { bytes: constrained, label: 'memory limit' };
|
|
104
|
+
}
|
|
105
|
+
return { bytes: total, label: 'RAM' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 60 % of RAM in whole 64 MB steps, between 2048 and 8192 MB, and never under
|
|
110
|
+
* Node's own default (`nodeDefaultMb`, old space only). 60 % leaves room for
|
|
111
|
+
* the CLI workers and for writing a heap snapshot: a heap that fills RAM ends
|
|
112
|
+
* in the kernel's OOM killer, which leaves neither a snapshot nor a log line.
|
|
113
|
+
*/
|
|
114
|
+
export function defaultHeapLimitMb({ totalMem = os.totalmem(), nodeDefaultMb = 0 } = {}) {
|
|
115
|
+
const share = Math.floor((Number(totalMem) || 0) * DEFAULT_RAM_SHARE / MB / 64) * 64;
|
|
116
|
+
const clamped = Math.min(DEFAULT_CAP_MB, Math.max(DEFAULT_FLOOR_MB, share));
|
|
117
|
+
return Math.max(clamped, Math.floor(Number(nodeDefaultMb) || 0));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Node's default old-space limit, from this process's v8 heap limit. */
|
|
121
|
+
export function nodeDefaultOldSpaceMb(heapSizeLimit = v8.getHeapStatistics().heap_size_limit) {
|
|
122
|
+
return Math.max(0, Math.floor(Number(heapSizeLimit) / MB) - YOUNG_GENERATION_ALLOWANCE_MB);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveConfigPath(env, serverDir) {
|
|
126
|
+
const fromEnv = String(env?.COPILOT_WEB_RELAY_CONFIG || '').trim();
|
|
127
|
+
return fromEnv ? path.resolve(fromEnv) : path.join(String(serverDir || ''), 'config.json');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* config.json's heapLimitMb: `{ mb }`, `{ invalid }` for a value that is not a
|
|
132
|
+
* whole number of at least MIN_HEAP_LIMIT_MB, or null when it sets none. An
|
|
133
|
+
* unreadable config sets none: the runtime reports config problems itself.
|
|
134
|
+
*/
|
|
135
|
+
export function configuredHeapLimit({ configPath, fsImpl = fs } = {}) {
|
|
136
|
+
let parsed = null;
|
|
137
|
+
try { parsed = JSON.parse(fsImpl.readFileSync(configPath, 'utf8')); } catch { return null; }
|
|
138
|
+
const value = parsed?.[HEAP_LIMIT_CONFIG_KEY];
|
|
139
|
+
if (value === undefined || value === null || value === '') return null;
|
|
140
|
+
const mb = Number(value);
|
|
141
|
+
return Number.isInteger(mb) && mb >= MIN_HEAP_LIMIT_MB ? { mb } : { invalid: value };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Free bytes on the disk of `dir`, or null when unknown. Also used by the
|
|
145
|
+
// database backup's space check (db-backup-service.mjs), with diskBytes.
|
|
146
|
+
export function freeBytes(dir, fsImpl) {
|
|
147
|
+
try {
|
|
148
|
+
if (typeof fsImpl.statfsSync !== 'function') return null;
|
|
149
|
+
const stats = fsImpl.statfsSync(dir);
|
|
150
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The size of the disk of `dir`, or null when unknown.
|
|
157
|
+
export function diskBytes(dir, fsImpl) {
|
|
158
|
+
try {
|
|
159
|
+
if (typeof fsImpl.statfsSync !== 'function') return null;
|
|
160
|
+
const stats = fsImpl.statfsSync(dir);
|
|
161
|
+
return Number(stats.blocks) * Number(stats.bsize);
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A path for the log: `~` for the home folder, so a log line names no user. */
|
|
168
|
+
export function displayPath(target, homeDir = os.homedir()) {
|
|
169
|
+
const resolved = path.resolve(String(target || ''));
|
|
170
|
+
const home = String(homeDir || '').trim() ? path.resolve(String(homeDir)) : '';
|
|
171
|
+
if (!home) return resolved;
|
|
172
|
+
const relative = path.relative(home, resolved);
|
|
173
|
+
if (relative === '') return '~';
|
|
174
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) return resolved;
|
|
175
|
+
return `~${path.sep}${relative}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function countSnapshots(dir, fsImpl) {
|
|
179
|
+
try {
|
|
180
|
+
return fsImpl.readdirSync(dir).filter((name) => name.endsWith('.heapsnapshot')).length;
|
|
181
|
+
} catch {
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The Node flags every relay runtime starts with: a known heap limit, and one
|
|
188
|
+
* heap snapshot near it, written to `<data dir>/diagnostics`.
|
|
189
|
+
*
|
|
190
|
+
* The heap limit comes from execArgv or NODE_OPTIONS when the operator set
|
|
191
|
+
* one there (nothing added), else config.json's heapLimitMb, else
|
|
192
|
+
* defaultHeapLimitMb; neither of the last two goes above the memory the host
|
|
193
|
+
* gives the relay (availableMemory). The diagnostics folder is made 0700 (a
|
|
194
|
+
* snapshot holds the auth token and provider keys); a --diagnostic-dir or
|
|
195
|
+
* --heapsnapshot-near-heap-limit the operator set is kept as it is. When the
|
|
196
|
+
* folder cannot be made, or its drive has less than twice the heap limit
|
|
197
|
+
* free (the database lives there too), no snapshot is asked for. Says what
|
|
198
|
+
* it chose on `logger`, with the home folder as `~`.
|
|
199
|
+
*/
|
|
200
|
+
export function relayRuntimeNodeFlags({
|
|
201
|
+
env = process.env,
|
|
202
|
+
execArgv = process.execArgv,
|
|
203
|
+
serverDir,
|
|
204
|
+
totalMem = os.totalmem(),
|
|
205
|
+
constrainedMem = process.constrainedMemory?.(),
|
|
206
|
+
heapSizeLimit = v8.getHeapStatistics().heap_size_limit,
|
|
207
|
+
fsImpl = fs,
|
|
208
|
+
platform = process.platform,
|
|
209
|
+
homeDir = os.homedir(),
|
|
210
|
+
logger = console,
|
|
211
|
+
} = {}) {
|
|
212
|
+
const log = (line) => logger?.log?.(line);
|
|
213
|
+
const operatorArgs = [...Array.from(execArgv || []), ...splitNodeOptions(env?.NODE_OPTIONS)];
|
|
214
|
+
const memory = availableMemory({ totalMem, constrainedMem });
|
|
215
|
+
const memoryMb = Math.floor(memory.bytes / MB / 64) * 64;
|
|
216
|
+
const memoryText = `${(memory.bytes / 1024 ** 3).toFixed(1)} GB ${memory.label}`;
|
|
217
|
+
const flags = [];
|
|
218
|
+
|
|
219
|
+
let heapLimitMb;
|
|
220
|
+
let source;
|
|
221
|
+
const operator = operatorHeapLimit({ execArgv, nodeOptions: env?.NODE_OPTIONS });
|
|
222
|
+
if (operator?.mb) {
|
|
223
|
+
heapLimitMb = operator.mb;
|
|
224
|
+
source = `from ${operator.source}`;
|
|
225
|
+
} else if (operator?.percentage) {
|
|
226
|
+
heapLimitMb = Math.floor(memory.bytes * operator.percentage / 100 / MB);
|
|
227
|
+
source = `${operator.percentage}% of ${memoryText}, from ${operator.source}`;
|
|
228
|
+
} else {
|
|
229
|
+
const configured = configuredHeapLimit({ configPath: resolveConfigPath(env, serverDir), fsImpl });
|
|
230
|
+
if (configured?.invalid !== undefined) {
|
|
231
|
+
log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${JSON.stringify(configured.invalid)} ignored: expected a whole number of MB, at least ${MIN_HEAP_LIMIT_MB}`);
|
|
232
|
+
}
|
|
233
|
+
if (configured?.mb) {
|
|
234
|
+
heapLimitMb = configured.mb;
|
|
235
|
+
source = `from config ${HEAP_LIMIT_CONFIG_KEY}`;
|
|
236
|
+
if (memoryMb >= MIN_HEAP_LIMIT_MB && heapLimitMb > memoryMb) {
|
|
237
|
+
log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${heapLimitMb} is more than the ${memoryText}; using ${memoryMb} MB`);
|
|
238
|
+
heapLimitMb = memoryMb;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
heapLimitMb = defaultHeapLimitMb({ totalMem: memory.bytes, nodeDefaultMb: nodeDefaultOldSpaceMb(heapSizeLimit) });
|
|
242
|
+
if (memoryMb >= MIN_HEAP_LIMIT_MB) heapLimitMb = Math.min(heapLimitMb, memoryMb);
|
|
243
|
+
source = `default: ${Math.round(DEFAULT_RAM_SHARE * 100)}% of ${memoryText}`;
|
|
244
|
+
}
|
|
245
|
+
flags.push(`--max-old-space-size=${heapLimitMb}`);
|
|
246
|
+
}
|
|
247
|
+
log(`[relay] heap limit ${heapLimitMb} MB (${source})`);
|
|
248
|
+
|
|
249
|
+
// The operator's --diagnostic-dir is theirs: checked, never made or changed.
|
|
250
|
+
const operatorDir = lastFlagValue(operatorArgs, DIAGNOSTIC_DIR_FLAG);
|
|
251
|
+
const diagnosticDir = operatorDir
|
|
252
|
+
? path.resolve(operatorDir)
|
|
253
|
+
: path.join(resolveDataDir({ env, configPath: resolveConfigPath(env, serverDir), serverDir, fsImpl }), 'diagnostics');
|
|
254
|
+
const shownDir = displayPath(diagnosticDir, homeDir);
|
|
255
|
+
let snapshot = false;
|
|
256
|
+
try {
|
|
257
|
+
if (!operatorDir) {
|
|
258
|
+
fsImpl.mkdirSync(diagnosticDir, { recursive: true, mode: 0o700 });
|
|
259
|
+
// mkdir leaves a folder that was there as it was.
|
|
260
|
+
if (platform !== 'win32') fsImpl.chmodSync?.(diagnosticDir, 0o700);
|
|
261
|
+
}
|
|
262
|
+
const free = freeBytes(diagnosticDir, fsImpl);
|
|
263
|
+
if (operatorArgs.some((arg) => SNAPSHOT_FLAG.test(arg))) {
|
|
264
|
+
snapshot = true;
|
|
265
|
+
} else if (free !== null && free < 2 * heapLimitMb * MB) {
|
|
266
|
+
log(`[relay] no heap snapshot near the limit: ${shownDir} has ${Math.floor(free / MB)} MB free, under twice the heap limit`);
|
|
267
|
+
} else {
|
|
268
|
+
flags.push('--heapsnapshot-near-heap-limit=1');
|
|
269
|
+
snapshot = true;
|
|
270
|
+
}
|
|
271
|
+
if (!operatorDir) flags.push(`--diagnostic-dir=${diagnosticDir}`);
|
|
272
|
+
if (snapshot) {
|
|
273
|
+
log(`[relay] heap snapshots near the limit go to ${shownDir} (${countSnapshots(diagnosticDir, fsImpl)} already there)`);
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
log(`[relay] no heap snapshot near the limit: cannot prepare ${shownDir} (${error?.code || error?.message || error})`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { flags, heapLimitMb, source, diagnosticDir, snapshot };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* relayRuntimeNodeFlags' flags, or none when it fails: a runtime must start
|
|
284
|
+
* even when its heap guard cannot be worked out.
|
|
285
|
+
*/
|
|
286
|
+
export function relayRuntimeNodeFlagsOrNone(nodeFlags, options = {}) {
|
|
287
|
+
try {
|
|
288
|
+
return nodeFlags(options);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
options.logger?.log?.(`[relay] starting the runtime without heap flags: ${error?.message || error}`);
|
|
291
|
+
return { flags: [] };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function toMb(bytes) {
|
|
296
|
+
return Math.round((Number(bytes) || 0) / MB);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** `[relay] memory rss=<MB> heapUsed=<MB> heapLimit=<MB>` */
|
|
300
|
+
export function formatMemoryLine({ rss, heapUsed, heapLimit }) {
|
|
301
|
+
return `[relay] memory rss=${toMb(rss)} heapUsed=${toMb(heapUsed)} heapLimit=${toMb(heapLimit)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Says once when heapUsed crosses `threshold` of the heap limit, and again
|
|
306
|
+
* only after it has been back under it. `check` returns whether it said so.
|
|
307
|
+
*/
|
|
308
|
+
export function createHeapWatch({ threshold = 0.8, logger = console } = {}) {
|
|
309
|
+
let above = false;
|
|
310
|
+
return {
|
|
311
|
+
check({ heapUsed, heapLimit }) {
|
|
312
|
+
const over = Number(heapLimit) > 0 && Number(heapUsed) >= threshold * Number(heapLimit);
|
|
313
|
+
const crossed = over && !above;
|
|
314
|
+
above = over;
|
|
315
|
+
if (crossed) {
|
|
316
|
+
logger?.log?.(`[relay] memory high: heapUsed=${toMb(heapUsed)} is over ${Math.round(threshold * 100)}% of heapLimit=${toMb(heapLimit)}`);
|
|
317
|
+
}
|
|
318
|
+
return crossed;
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The runtime's memory in the status log: a line now and every 10 minutes,
|
|
325
|
+
* and a silent check every minute that says when heapUsed crosses 80 % of
|
|
326
|
+
* the limit. Both timers are unref'd; returns them for runtimeTimers.
|
|
327
|
+
*/
|
|
328
|
+
export function startRelayMemoryLog({
|
|
329
|
+
logger = console,
|
|
330
|
+
memoryUsage = () => process.memoryUsage(),
|
|
331
|
+
heapLimit = () => v8.getHeapStatistics().heap_size_limit,
|
|
332
|
+
logIntervalMs = 10 * 60 * 1000,
|
|
333
|
+
watchIntervalMs = 60 * 1000,
|
|
334
|
+
} = {}) {
|
|
335
|
+
const watch = createHeapWatch({ logger });
|
|
336
|
+
const sample = () => {
|
|
337
|
+
const { rss, heapUsed } = memoryUsage();
|
|
338
|
+
return { rss, heapUsed, heapLimit: heapLimit() };
|
|
339
|
+
};
|
|
340
|
+
const logLine = () => {
|
|
341
|
+
const now = sample();
|
|
342
|
+
logger?.log?.(formatMemoryLine(now));
|
|
343
|
+
watch.check(now);
|
|
344
|
+
};
|
|
345
|
+
logLine();
|
|
346
|
+
const log = setInterval(logLine, logIntervalMs);
|
|
347
|
+
log.unref?.();
|
|
348
|
+
const check = setInterval(() => watch.check(sample()), watchIntervalMs);
|
|
349
|
+
check.unref?.();
|
|
350
|
+
return { log, check };
|
|
351
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
|
|
4
|
+
import { relayRuntimeNodeFlags, relayRuntimeNodeFlagsOrNone } from './relay-heap-guard.mjs';
|
|
5
|
+
import { detectRelayHosting } from './services/relay-restart-picture.mjs';
|
|
4
6
|
|
|
5
7
|
// Role selection travels on argv, never in the environment. The runtime's env is
|
|
6
8
|
// inherited by tmux worker sessions and by the Copilot CLI it launches, and the
|
|
@@ -34,8 +36,11 @@ export function spawnRelayRuntime({
|
|
|
34
36
|
detached = false,
|
|
35
37
|
windowsHide = true,
|
|
36
38
|
logger = console,
|
|
39
|
+
// The heap limit and heap snapshot flags (relay-heap-guard.mjs).
|
|
40
|
+
nodeFlags = relayRuntimeNodeFlags,
|
|
37
41
|
} = {}) {
|
|
38
|
-
const
|
|
42
|
+
const { flags } = relayRuntimeNodeFlagsOrNone(nodeFlags, { env, execArgv, serverDir: path.dirname(String(scriptPath || '')), logger });
|
|
43
|
+
const child = spawnImpl(process.execPath, [...flags, ...execArgv, scriptPath, RELAY_RUNTIME_FLAG, ...args], {
|
|
39
44
|
cwd,
|
|
40
45
|
env,
|
|
41
46
|
detached,
|
|
@@ -72,6 +77,64 @@ function waitForChildExit(child) {
|
|
|
72
77
|
});
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
// A systemctl --user call of the handover: its result, never a throw.
|
|
81
|
+
function runSystemctl(spawnSyncImpl, args) {
|
|
82
|
+
try {
|
|
83
|
+
return spawnSyncImpl('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 }) || { status: null };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { status: null, error };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function systemctlFailure(result) {
|
|
90
|
+
if (result.error) return result.error.message || String(result.error);
|
|
91
|
+
const said = String(result.stderr || '').trim().split('\n')[0];
|
|
92
|
+
return `status ${result.status ?? 'none'}${said ? `: ${said}` : ''}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The runtime asked for a restart (exit 75). Under a user service whose main
|
|
97
|
+
* process this supervisor is, systemd restarts the whole service, so the new
|
|
98
|
+
* supervisor and runtime run the code on disk with fresh Node flags: true
|
|
99
|
+
* once systemd's stop (SIGTERM) has come. False, with one log line, wherever
|
|
100
|
+
* that cannot be done (not the unit's main process: a relay started inside a
|
|
101
|
+
* worker of the service), systemctl fails, or no stop comes within
|
|
102
|
+
* `handoverWaitMs`; the caller relaunches the runtime itself then.
|
|
103
|
+
*/
|
|
104
|
+
async function handRestartToSystemd({ hosting, spawnSyncImpl, pid, handoverWaitMs, shutdownRequested, isShuttingDown, delay, logger }) {
|
|
105
|
+
if (!hosting?.systemd || !hosting.userManager || !hosting.unit) return false;
|
|
106
|
+
const { unit } = hosting;
|
|
107
|
+
const main = runSystemctl(spawnSyncImpl, ['show', '-p', 'MainPID', '--value', unit]);
|
|
108
|
+
const mainPid = Number(String(main.stdout ?? '').trim());
|
|
109
|
+
if (main.status !== 0 || mainPid !== pid) {
|
|
110
|
+
const whose = main.status === 0 ? `its main process is ${mainPid || 'none'}` : `its main process could not be read (${systemctlFailure(main)})`;
|
|
111
|
+
logger?.log?.(`[relay] restart: not handed to systemd, ${unit} is not this supervisor's (pid ${pid}; ${whose}).`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// Clears a failed state and the restart backoff; restart's answer alone decides.
|
|
115
|
+
runSystemctl(spawnSyncImpl, ['reset-failed', unit]);
|
|
116
|
+
// A stop that came in meanwhile wins: a queued restart could replace its job.
|
|
117
|
+
// The calls above block; a signal that arrived during them waits on the
|
|
118
|
+
// signal pipe, which only the loop's poll phase reads. This runs from the
|
|
119
|
+
// runtime's 'exit' callback, itself in a poll phase, so one setImmediate
|
|
120
|
+
// would run in the check phase right after it, before the pipe is read;
|
|
121
|
+
// the second runs after the next poll phase has delivered the signal.
|
|
122
|
+
await new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
|
|
123
|
+
if (isShuttingDown()) {
|
|
124
|
+
logger?.log?.(`[relay] restart: a stop came in; not restarting ${unit}.`);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
const restart = runSystemctl(spawnSyncImpl, ['restart', '--no-block', unit]);
|
|
128
|
+
if (restart.status !== 0) {
|
|
129
|
+
logger?.log?.(`[relay] restart: systemctl --user restart --no-block ${unit} failed (${systemctlFailure(restart)}).`);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
logger?.log?.(`[relay] restart handed to systemd: ${unit} restarts with the code on disk.`);
|
|
133
|
+
const stopped = await Promise.race([shutdownRequested.then(() => true), Promise.resolve(delay(handoverWaitMs)).then(isShuttingDown)]);
|
|
134
|
+
if (!stopped) logger?.log?.(`[relay] restart: systemd did not stop ${unit} within ${Math.round(handoverWaitMs / 1000)} s.`);
|
|
135
|
+
return stopped;
|
|
136
|
+
}
|
|
137
|
+
|
|
75
138
|
export async function runDirectRelaySupervisor({
|
|
76
139
|
scriptPath,
|
|
77
140
|
args = [],
|
|
@@ -84,31 +147,54 @@ export async function runDirectRelaySupervisor({
|
|
|
84
147
|
exitImpl = (code) => process.exit(code),
|
|
85
148
|
logger = console,
|
|
86
149
|
installSignalHandlers = true,
|
|
150
|
+
signals = process,
|
|
151
|
+
nodeFlags = relayRuntimeNodeFlags,
|
|
87
152
|
restartDelayMs = 500,
|
|
88
153
|
maxCrashRestarts = 3,
|
|
154
|
+
// A runtime that ran this long before it crashed starts the count afresh:
|
|
155
|
+
// crashes days apart are not a crash loop.
|
|
156
|
+
crashCountResetMs = 10 * 60 * 1000,
|
|
157
|
+
now = Date.now,
|
|
158
|
+
// How this process is run (detectRelayHosting); read at the first restart
|
|
159
|
+
// request when not given.
|
|
160
|
+
hosting = null,
|
|
161
|
+
spawnSyncImpl = spawnSync,
|
|
162
|
+
pid = process.pid,
|
|
163
|
+
// How long a restart handed to systemd waits for its SIGTERM.
|
|
164
|
+
handoverWaitMs = 30_000,
|
|
89
165
|
} = {}) {
|
|
90
166
|
let runtimeProc = null;
|
|
91
167
|
let shuttingDown = false;
|
|
92
168
|
let shutdownExitCode = 0;
|
|
169
|
+
let shutdownArrived;
|
|
170
|
+
const shutdownRequested = new Promise((resolve) => { shutdownArrived = resolve; });
|
|
93
171
|
|
|
94
172
|
const requestShutdown = (signal = 'SIGTERM', exitCode = 0) => {
|
|
95
173
|
if (shuttingDown) return;
|
|
96
174
|
shuttingDown = true;
|
|
97
175
|
shutdownExitCode = exitCode;
|
|
98
176
|
stopChildProcess(runtimeProc, { signal });
|
|
177
|
+
shutdownArrived();
|
|
99
178
|
};
|
|
100
179
|
|
|
101
180
|
const handleSigInt = () => requestShutdown('SIGINT', 0);
|
|
102
181
|
const handleSigTerm = () => requestShutdown('SIGTERM', 0);
|
|
103
182
|
|
|
104
183
|
if (installSignalHandlers) {
|
|
105
|
-
|
|
106
|
-
|
|
184
|
+
signals.on('SIGINT', handleSigInt);
|
|
185
|
+
signals.on('SIGTERM', handleSigTerm);
|
|
107
186
|
}
|
|
108
187
|
|
|
109
188
|
try {
|
|
110
189
|
let crashCount = 0;
|
|
111
190
|
while (true) {
|
|
191
|
+
// A Ctrl-C reaches the runtime (same process group) before this
|
|
192
|
+
// supervisor's handler can run: the runtime's signal exit looks like a
|
|
193
|
+
// crash, and the shutdown arrives during the delay. Never relaunch then.
|
|
194
|
+
if (shuttingDown) {
|
|
195
|
+
return await exitImpl(shutdownExitCode);
|
|
196
|
+
}
|
|
197
|
+
const startedAt = now();
|
|
112
198
|
runtimeProc = spawnRelayRuntime({
|
|
113
199
|
env,
|
|
114
200
|
cwd,
|
|
@@ -119,6 +205,7 @@ export async function runDirectRelaySupervisor({
|
|
|
119
205
|
stdio,
|
|
120
206
|
detached: false,
|
|
121
207
|
logger,
|
|
208
|
+
nodeFlags,
|
|
122
209
|
});
|
|
123
210
|
if (runtimeProc.stdout?.on) {
|
|
124
211
|
runtimeProc.stdout.on('data', (chunk) => process.stdout.write(chunk));
|
|
@@ -139,10 +226,28 @@ export async function runDirectRelaySupervisor({
|
|
|
139
226
|
return await exitImpl(1);
|
|
140
227
|
}
|
|
141
228
|
|
|
142
|
-
|
|
229
|
+
// A runtime killed by a signal (an out-of-memory abort is SIGABRT, the
|
|
230
|
+
// kernel's OOM killer SIGKILL) exits with code null: a crash. Number(null)
|
|
231
|
+
// is 0, which once ended the supervisor as if the runtime stopped cleanly.
|
|
232
|
+
const exitCode = result.code !== null && result.code !== undefined && Number.isInteger(Number(result.code))
|
|
233
|
+
? Number(result.code)
|
|
234
|
+
: null;
|
|
235
|
+
const signal = exitCode === null ? (result.signal || null) : null;
|
|
143
236
|
if (exitCode === RELAY_RESTART_EXIT_CODE) {
|
|
144
|
-
logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
|
|
145
237
|
crashCount = 0;
|
|
238
|
+
const handedOver = await handRestartToSystemd({
|
|
239
|
+
hosting: hosting || detectRelayHosting({ env }),
|
|
240
|
+
spawnSyncImpl,
|
|
241
|
+
pid,
|
|
242
|
+
handoverWaitMs,
|
|
243
|
+
shutdownRequested,
|
|
244
|
+
isShuttingDown: () => shuttingDown,
|
|
245
|
+
delay,
|
|
246
|
+
logger,
|
|
247
|
+
});
|
|
248
|
+
// systemd's stop: this supervisor ends, systemd starts the next one.
|
|
249
|
+
if (handedOver || shuttingDown) return await exitImpl(shutdownExitCode);
|
|
250
|
+
logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
|
|
146
251
|
await delay(restartDelayMs);
|
|
147
252
|
continue;
|
|
148
253
|
}
|
|
@@ -156,8 +261,10 @@ export async function runDirectRelaySupervisor({
|
|
|
156
261
|
return await exitImpl(RELAY_STOP_EXIT_CODE);
|
|
157
262
|
}
|
|
158
263
|
|
|
264
|
+
if (now() - startedAt >= crashCountResetMs) crashCount = 0;
|
|
159
265
|
const attempt = crashCount + 1;
|
|
160
|
-
|
|
266
|
+
const how = signal ? `signal ${signal}` : `exit code ${exitCode ?? 'null'}`;
|
|
267
|
+
logger?.error?.(`[relay] runtime crashed with ${how} (crash ${attempt}/${maxCrashRestarts}).`);
|
|
161
268
|
if (attempt >= maxCrashRestarts) {
|
|
162
269
|
logger?.error?.('[relay] crash limit reached; supervisor stopping.');
|
|
163
270
|
return await exitImpl(1);
|
|
@@ -168,8 +275,8 @@ export async function runDirectRelaySupervisor({
|
|
|
168
275
|
|
|
169
276
|
} finally {
|
|
170
277
|
if (installSignalHandlers) {
|
|
171
|
-
|
|
172
|
-
|
|
278
|
+
signals.off('SIGINT', handleSigInt);
|
|
279
|
+
signals.off('SIGTERM', handleSigTerm);
|
|
173
280
|
}
|
|
174
281
|
}
|
|
175
282
|
}
|
package/server/relay-tools.md
CHANGED
|
@@ -39,19 +39,20 @@ A paired relay stays locked until the user mentions it in this conversation (`@n
|
|
|
39
39
|
|
|
40
40
|
This relay itself is listed with "self": true when its owner switched agent sessions on, and is addressed by its name or as "this"; it needs no mention. Use it to hand parts of your task to other sessions:
|
|
41
41
|
|
|
42
|
-
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
42
|
+
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. Without "mode" the new session gets your session's relay mode. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
43
43
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only), unless the owner allowed it for that session: `list_sessions` and `read_session` then show "mayCreateSessions": true. Only the owner sets that.
|
|
44
44
|
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. `create_session` takes "group" (a group id, or its name in any case, from `relay_info`'s "groups") to put the new session into an existing group instead, here or on a paired relay; an unknown group is refused with the groups listed and nothing is created, and the result carries the "group". Renaming and dissolving groups is the user's.
|
|
45
|
-
- `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `
|
|
45
|
+
- `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd", "planOnly") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `update_session` that names another mode; a `send` that names a mode runs only that turn in it, and one without "mode" leaves the session in its own mode.
|
|
46
|
+
- "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
|
|
46
47
|
- Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
|
|
47
|
-
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
|
|
48
|
+
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. Copilot ids use dots (`claude-opus-5.5`, not the Claude form `claude-opus-5-5`); a hyphen form is refused with the close match named, and a Copilot session never moves to another provider because of its model name. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
|
|
48
49
|
- A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
|
|
49
50
|
- Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
|
|
50
51
|
- Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
|
|
51
52
|
- Pass only the arguments an action takes. `create_session` and `update_session` refuse any other one, list their valid arguments, and change nothing; every other action still runs and names the arguments it did not take in "ignored".
|
|
52
53
|
- `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
|
|
53
54
|
|
|
54
|
-
On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and
|
|
55
|
+
On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle, board_settled) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls, all_idle and a plan card the user settled (board_settled: the button chosen and the message it queued, if any), but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command. "relay_restarted" (this relay only, never in the default) wakes you when the relay runs again after a restart, with who requested it and why; named alone it needs no sessions.
|
|
55
56
|
|
|
56
57
|
Each wake-up costs you a turn; these `subscribe` options cut them (questions and errors always come at once):
|
|
57
58
|
- "digest_minutes": N gathers the subscription's other events into one message every N minutes (1 to 1440).
|
|
@@ -5,6 +5,33 @@ import { createMessagePinStatements } from './message-pin-statements.mjs';
|
|
|
5
5
|
import { createConversationHistoryPageStatements } from '../services/conversation-history-page.mjs';
|
|
6
6
|
import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The claim's plan-boundary hold, part of every findPending*
|
|
10
|
+
* statement: a pending row waits while its conversation has a live turn on
|
|
11
|
+
* the other side of plan mode, because handing it to the worker would steer
|
|
12
|
+
* it into that turn and switch what the turn may do. The conversation's later
|
|
13
|
+
* rows wait behind it whatever their mode, so one conversation keeps its
|
|
14
|
+
* order, while the claim goes on to other conversations' rows.
|
|
15
|
+
* `PLAN_BOUNDARY_HOLD_MARK` lets a test find any claim without it.
|
|
16
|
+
*/
|
|
17
|
+
export const PLAN_BOUNDARY_HOLD_MARK = '/* plan-boundary-hold */';
|
|
18
|
+
|
|
19
|
+
export function planBoundaryHoldSql(alias) {
|
|
20
|
+
return `${PLAN_BOUNDARY_HOLD_MARK} NOT EXISTS (
|
|
21
|
+
SELECT 1
|
|
22
|
+
FROM queue held
|
|
23
|
+
JOIN queue live
|
|
24
|
+
ON live.conversation_id = held.conversation_id
|
|
25
|
+
AND live.status = 'processing'
|
|
26
|
+
AND live.id != held.id
|
|
27
|
+
WHERE held.conversation_id = ${alias}.conversation_id
|
|
28
|
+
AND held.status = 'pending'
|
|
29
|
+
AND COALESCE(held.kind, '') != 'continuation'
|
|
30
|
+
AND (held.timestamp < ${alias}.timestamp OR (held.timestamp = ${alias}.timestamp AND held.rowid <= ${alias}.rowid))
|
|
31
|
+
AND (LOWER(TRIM(COALESCE(live.relay_mode, ''))) = 'plan') != (LOWER(TRIM(COALESCE(held.relay_mode, ''))) = 'plan')
|
|
32
|
+
)`;
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
export function createMessageRepository(db) {
|
|
9
36
|
const shareVisibility = createShareVisibilityStatements(db);
|
|
10
37
|
const messagePins = createMessagePinStatements(db);
|
|
@@ -187,7 +214,7 @@ export function createMessageRepository(db) {
|
|
|
187
214
|
// steer-recovered row waited 5 minutes while newer messages jumped
|
|
188
215
|
// it). Head-of-line protection is the WHERE clause's next_attempt_at
|
|
189
216
|
// backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
|
|
190
|
-
findPending: db.prepare(`SELECT
|
|
217
|
+
findPending: db.prepare(`SELECT q.* FROM queue q WHERE q.status = 'pending' AND COALESCE(q.kind, '') != 'continuation' AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?) AND ${planBoundaryHoldSql('q')} ORDER BY q.timestamp ASC, q.rowid ASC LIMIT 1`),
|
|
191
218
|
// Global fallback for requesters without a session identity (the legacy
|
|
192
219
|
// Copilot relay CLI). Conversations bound to a session-worker provider
|
|
193
220
|
// (claude/cursor/grok) are excluded outright: handing one of their turns
|
|
@@ -218,6 +245,7 @@ export function createMessageRepository(db) {
|
|
|
218
245
|
NULLIF(rsc.provider_type, ''),
|
|
219
246
|
'github'
|
|
220
247
|
)) NOT IN (${sessionWorkerProviderSqlList()})
|
|
248
|
+
AND ${planBoundaryHoldSql('q')}
|
|
221
249
|
ORDER BY
|
|
222
250
|
q.timestamp ASC,
|
|
223
251
|
q.rowid ASC
|
|
@@ -254,6 +282,7 @@ export function createMessageRepository(db) {
|
|
|
254
282
|
NULLIF(c.sdk_session_id, '')
|
|
255
283
|
) = ?
|
|
256
284
|
)
|
|
285
|
+
AND ${planBoundaryHoldSql('q')}
|
|
257
286
|
ORDER BY
|
|
258
287
|
CASE
|
|
259
288
|
WHEN COALESCE(
|
|
@@ -282,6 +311,7 @@ export function createMessageRepository(db) {
|
|
|
282
311
|
NULLIF(rs.sdk_session_id, ''),
|
|
283
312
|
NULLIF(c.sdk_session_id, '')
|
|
284
313
|
) = ?
|
|
314
|
+
AND ${planBoundaryHoldSql('q')}
|
|
285
315
|
ORDER BY
|
|
286
316
|
q.timestamp ASC,
|
|
287
317
|
q.rowid ASC
|