@oar-sh/oar 0.9.13 → 0.9.15
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/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +396 -0
- package/README.md +12 -12
- package/bin/oar.js +677 -103
- package/package.json +7 -5
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +49 -16
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +18 -5
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +415 -45
- package/server/services/windows-boot-autostart-service.mjs +962 -185
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2187
- package/server/uploads/image_input.md +0 -540
|
@@ -1,6 +1,128 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import crypto from "crypto";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { createRequire } from "module";
|
|
7
|
+
|
|
8
|
+
// An EPERM is a process this user may not signal: alive.
|
|
9
|
+
export function defaultIsProcessAlive(pid) {
|
|
10
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return error?.code === "EPERM";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let processTimes;
|
|
20
|
+
// When a process started, in ms, or null when that cannot be told. Windows
|
|
21
|
+
// asks the kernel (GetProcessTimes through koffi, which the Copilot SDK
|
|
22
|
+
// brings): PowerShell fails in ConstrainedLanguage mode and with a broken
|
|
23
|
+
// TEMP. Elsewhere `ps`.
|
|
24
|
+
export function processStartedAt(pid, { platform = process.platform } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
if (platform !== "win32") {
|
|
27
|
+
// `lstart`, the full date; LC_ALL=C to parse; unparsable: cannot tell.
|
|
28
|
+
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(Number(pid))], { encoding: "utf8", timeout: 5000, env: { ...process.env, LC_ALL: "C" } });
|
|
29
|
+
const ms = Date.parse(String(result.stdout || "").trim());
|
|
30
|
+
return Number.isFinite(ms) ? ms : null;
|
|
31
|
+
}
|
|
32
|
+
if (!processTimes) {
|
|
33
|
+
const koffi = createRequire(import.meta.url)("koffi");
|
|
34
|
+
const kernel32 = koffi.load("kernel32.dll");
|
|
35
|
+
processTimes = {
|
|
36
|
+
open: kernel32.func("void * __stdcall OpenProcess(uint32_t access, bool inherit, uint32_t pid)"),
|
|
37
|
+
times: kernel32.func("bool __stdcall GetProcessTimes(void *h, _Out_ uint64_t *a, _Out_ uint64_t *b, _Out_ uint64_t *c, _Out_ uint64_t *d)"),
|
|
38
|
+
close: kernel32.func("bool __stdcall CloseHandle(void *h)"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const handle = processTimes.open(0x1000, false, Number(pid)); // PROCESS_QUERY_LIMITED_INFORMATION
|
|
42
|
+
if (!handle) return null;
|
|
43
|
+
try {
|
|
44
|
+
const created = [0n];
|
|
45
|
+
if (!processTimes.times(handle, created, [0n], [0n], [0n])) return null;
|
|
46
|
+
return Number(BigInt(created[0]) / 10000n) - 11644473600000; // FILETIME: 100 ns since 1601
|
|
47
|
+
} finally {
|
|
48
|
+
processTimes.close(handle);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let bootId;
|
|
56
|
+
// Linux's id of the running boot, '' elsewhere or when unreadable.
|
|
57
|
+
export function currentBootId({ platform = process.platform, readFile = fs.readFileSync } = {}) {
|
|
58
|
+
if (platform !== "linux") return "";
|
|
59
|
+
try {
|
|
60
|
+
bootId ??= String(readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
|
|
61
|
+
return bootId;
|
|
62
|
+
} catch {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A process's start as the system keeps it, which no later clock step moves:
|
|
69
|
+
* Windows' creation time (exact), Linux's start in ticks since boot with the
|
|
70
|
+
* boot id, elsewhere `ps` (to the second). Null when it cannot be told.
|
|
71
|
+
*/
|
|
72
|
+
export function processIdentity(pid, { platform = process.platform, readFile = fs.readFileSync } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
if (platform === "linux") {
|
|
75
|
+
const stat = String(readFile(`/proc/${Number(pid)}/stat`, "utf8"));
|
|
76
|
+
const ticks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]; // field 22, starttime
|
|
77
|
+
return /^\d+$/.test(ticks) ? { start: `ticks:${ticks}`, bootId: currentBootId({ platform, readFile }) } : null;
|
|
78
|
+
}
|
|
79
|
+
const ms = processStartedAt(pid, { platform });
|
|
80
|
+
if (ms === null) return null;
|
|
81
|
+
return { start: platform === "win32" ? `win:${ms}` : ms };
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Exact for the kernel's own values, to the second for `ps`.
|
|
88
|
+
function sameStart(a, b) {
|
|
89
|
+
return typeof a === "number" && typeof b === "number" ? Math.abs(a - b) <= 1000 : a === b;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether the relay a lock names may still run; true when that cannot be
|
|
94
|
+
* told: better a refused start than two relays. A lock this version wrote
|
|
95
|
+
* (format 2) never goes by the clock: a live pid (EPERM counts) holds it
|
|
96
|
+
* when its start matches the recorded holder, or when either start is
|
|
97
|
+
* unknown; it is stale when the pid's start differs, or when the boot id it
|
|
98
|
+
* recorded (Linux) is not the running one. Only a lock without the format
|
|
99
|
+
* (0.9.13's) goes by the clock, the one case a clock step can mislead:
|
|
100
|
+
* written before the last boot, or older than the pid's process by more
|
|
101
|
+
* than 5 s (a reused pid), it is stale.
|
|
102
|
+
*/
|
|
103
|
+
export function lockHolderMayRun({
|
|
104
|
+
lock,
|
|
105
|
+
isProcessAlive = defaultIsProcessAlive,
|
|
106
|
+
identityOf = processIdentity,
|
|
107
|
+
bootIdOf = currentBootId,
|
|
108
|
+
startedAtOf = processStartedAt,
|
|
109
|
+
bootTime = Date.now() - os.uptime() * 1000,
|
|
110
|
+
}) {
|
|
111
|
+
const pid = Number.parseInt(String(lock?.pid ?? ""), 10);
|
|
112
|
+
if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) return false;
|
|
113
|
+
if (lock?.format >= 2 || lock?.holder) {
|
|
114
|
+
const current = lock.holder ? identityOf(pid) : null;
|
|
115
|
+
const recordedBoot = lock.holder?.bootId || lock.bootId;
|
|
116
|
+
const runningBoot = current?.bootId || bootIdOf();
|
|
117
|
+
if (recordedBoot && runningBoot && recordedBoot !== runningBoot) return false;
|
|
118
|
+
return !current || sameStart(lock.holder.start, current.start);
|
|
119
|
+
}
|
|
120
|
+
const lockedAt = Date.parse(String(lock?.startedAt || ""));
|
|
121
|
+
if (Number.isFinite(lockedAt) && lockedAt < bootTime) return false;
|
|
122
|
+
const startedAt = startedAtOf(pid);
|
|
123
|
+
if (Number.isFinite(lockedAt) && startedAt !== null && startedAt > lockedAt + 5000) return false;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
4
126
|
|
|
5
127
|
function hashToken(value) {
|
|
6
128
|
const token = String(value || "").trim();
|
|
@@ -21,6 +143,9 @@ function safeReadLock(lockPath) {
|
|
|
21
143
|
pid: Number.parseInt(String(parsed?.pid ?? ""), 10),
|
|
22
144
|
tokenHash,
|
|
23
145
|
startedAt: typeof parsed?.startedAt === "string" ? parsed.startedAt : null,
|
|
146
|
+
holder: parsed?.holder && typeof parsed.holder === "object" ? parsed.holder : null,
|
|
147
|
+
format: Number(parsed?.format) || 0,
|
|
148
|
+
bootId: typeof parsed?.bootId === "string" ? parsed.bootId : "",
|
|
24
149
|
};
|
|
25
150
|
} catch {
|
|
26
151
|
return null;
|
|
@@ -33,23 +158,27 @@ export function createRelaySingletonGuard({
|
|
|
33
158
|
token,
|
|
34
159
|
now = () => new Date().toISOString(),
|
|
35
160
|
isProcessAlive,
|
|
161
|
+
// When a pid's process started, and when the machine booted (lockHolderMayRun).
|
|
162
|
+
identityOf = processIdentity,
|
|
163
|
+
bootIdOf = currentBootId,
|
|
164
|
+
startedAtOf = processStartedAt,
|
|
165
|
+
bootTime = Date.now() - os.uptime() * 1000,
|
|
166
|
+
// An unreadable lock is read again for `readRetryMs`; still unreadable,
|
|
167
|
+
// it is stale only when its time is more than `unreadableStaleMs` off.
|
|
168
|
+
readRetryMs = 2000,
|
|
169
|
+
unreadableStaleMs = 10_000,
|
|
170
|
+
clock = Date.now,
|
|
171
|
+
sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
|
|
36
172
|
logger = console,
|
|
37
173
|
}) {
|
|
38
174
|
const lockFilePath = path.resolve(String(lockPath || ""));
|
|
39
175
|
const currentPid = Number.parseInt(String(pid || process.pid), 10);
|
|
40
176
|
const currentTokenHash = hashToken(token);
|
|
41
|
-
const processAlive = typeof isProcessAlive === "function"
|
|
42
|
-
? isProcessAlive
|
|
43
|
-
: (candidatePid) => {
|
|
44
|
-
if (!Number.isInteger(candidatePid) || candidatePid <= 0) return false;
|
|
45
|
-
try {
|
|
46
|
-
process.kill(candidatePid, 0);
|
|
47
|
-
return true;
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
};
|
|
177
|
+
const processAlive = typeof isProcessAlive === "function" ? isProcessAlive : defaultIsProcessAlive;
|
|
52
178
|
|
|
179
|
+
// The whole lock goes into a file of its own, linked into place (fails
|
|
180
|
+
// when a lock is there): no start ever reads one half written. Where the
|
|
181
|
+
// folder takes no hard links (FAT, exFAT), created and written in place.
|
|
53
182
|
function writeLock() {
|
|
54
183
|
fs.mkdirSync(path.dirname(lockFilePath), { recursive: true });
|
|
55
184
|
const payload = {
|
|
@@ -59,47 +188,182 @@ export function createRelaySingletonGuard({
|
|
|
59
188
|
// credential out of a file that has historically been world-readable.
|
|
60
189
|
tokenHash: currentTokenHash,
|
|
61
190
|
startedAt: now(),
|
|
191
|
+
// The holder, as lockHolderMayRun tells it without the clock (null:
|
|
192
|
+
// unknown, which then holds the lock while its pid lives).
|
|
193
|
+
format: 2,
|
|
194
|
+
holder: identityOf(currentPid) || null,
|
|
195
|
+
...(bootIdOf() ? { bootId: bootIdOf() } : {}),
|
|
62
196
|
};
|
|
63
|
-
const
|
|
197
|
+
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
198
|
+
const staged = `${lockFilePath}.${currentPid}-${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
199
|
+
let linked = false;
|
|
200
|
+
let written = false;
|
|
64
201
|
try {
|
|
65
|
-
fs.writeFileSync(
|
|
202
|
+
fs.writeFileSync(staged, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
203
|
+
written = true;
|
|
204
|
+
fs.linkSync(staged, lockFilePath);
|
|
205
|
+
linked = true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (written && error?.code === "EEXIST") throw error;
|
|
66
208
|
} finally {
|
|
67
|
-
try { fs.
|
|
209
|
+
if (written) try { fs.unlinkSync(staged); } catch {}
|
|
210
|
+
}
|
|
211
|
+
if (!linked) {
|
|
212
|
+
const fd = fs.openSync(lockFilePath, "wx", 0o600);
|
|
213
|
+
try {
|
|
214
|
+
fs.writeFileSync(fd, text, "utf8");
|
|
215
|
+
} finally {
|
|
216
|
+
try { fs.closeSync(fd); } catch {}
|
|
217
|
+
}
|
|
68
218
|
}
|
|
69
219
|
return payload;
|
|
70
220
|
}
|
|
71
221
|
|
|
72
|
-
|
|
222
|
+
// Read again for a while (an antivirus scan, a lock written in place);
|
|
223
|
+
// still unreadable: stale when its time is far off, else held.
|
|
224
|
+
function readLockWithRetry() {
|
|
225
|
+
const deadline = clock() + readRetryMs;
|
|
226
|
+
for (;;) {
|
|
227
|
+
const lock = safeReadLock(lockFilePath);
|
|
228
|
+
if (lock || clock() >= deadline) return lock;
|
|
229
|
+
sleep(100);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function unreadableLockIsStale() {
|
|
73
233
|
try {
|
|
74
|
-
return
|
|
75
|
-
} catch
|
|
76
|
-
|
|
77
|
-
throw error;
|
|
78
|
-
}
|
|
234
|
+
return Math.abs(clock() - fs.statSync(lockFilePath).mtimeMs) > unreadableStaleMs;
|
|
235
|
+
} catch {
|
|
236
|
+
return true;
|
|
79
237
|
}
|
|
238
|
+
}
|
|
80
239
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const existingTokenHash = typeof existingLock?.tokenHash === "string" ? existingLock.tokenHash : "";
|
|
84
|
-
const existingStartedAt = existingLock?.startedAt || "unknown";
|
|
240
|
+
const held = (message) => Object.assign(new Error(message), { code: "ERELAYLOCKED" });
|
|
241
|
+
const RENAME_RETRY_MS = [50, 100, 200, 400, 800];
|
|
85
242
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
243
|
+
// The lock as judged: its fields, or for one that cannot be read its size
|
|
244
|
+
// and time, which a moved-aside file must still have to be the same lock.
|
|
245
|
+
function sameLock(file, judged) {
|
|
246
|
+
try {
|
|
247
|
+
const lock = safeReadLock(file);
|
|
248
|
+
if (judged.lock) return !!lock && ["pid", "tokenHash", "startedAt"].every((key) => Object.is(lock[key], judged.lock[key]));
|
|
249
|
+
const stat = fs.statSync(file);
|
|
250
|
+
return !lock && stat.size === judged.size && stat.mtimeMs === judged.mtimeMs;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
93
253
|
}
|
|
254
|
+
}
|
|
94
255
|
|
|
256
|
+
// A lock put back after it turned out fresh: linked, else (no hard links)
|
|
257
|
+
// created with its content; a lock there by then wins, the file stays.
|
|
258
|
+
function putBack(moved) {
|
|
95
259
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
260
|
+
try {
|
|
261
|
+
fs.linkSync(moved, lockFilePath);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code === "EEXIST") throw error;
|
|
264
|
+
fs.writeFileSync(lockFilePath, fs.readFileSync(moved), { flag: "wx", mode: 0o600 });
|
|
265
|
+
}
|
|
266
|
+
fs.unlinkSync(moved);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logger?.warn?.(`[server] A relay lock moved aside could not be put back (${error?.code || error}); it is in ${moved}.`);
|
|
100
269
|
}
|
|
270
|
+
}
|
|
101
271
|
|
|
102
|
-
|
|
272
|
+
// Files a crashed recovery or write left beside the lock (`.stale-<pid>-`,
|
|
273
|
+
// `.<pid>-….tmp`), never read: removed once their process is gone.
|
|
274
|
+
function removeLeftovers() {
|
|
275
|
+
const base = path.basename(lockFilePath);
|
|
276
|
+
let names = [];
|
|
277
|
+
try { names = fs.readdirSync(path.dirname(lockFilePath)); } catch {}
|
|
278
|
+
for (const name of names) {
|
|
279
|
+
if (!name.startsWith(`${base}.`)) continue;
|
|
280
|
+
const leftPid = Number(/^\.(?:stale-)?(\d+)-[0-9a-f]+(?:\.tmp)?$/.exec(name.slice(base.length))?.[1]);
|
|
281
|
+
if (leftPid && leftPid !== currentPid && !processAlive(leftPid)) try { fs.unlinkSync(path.join(path.dirname(lockFilePath), name)); } catch {}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function acquire() {
|
|
286
|
+
const payload = takeLock();
|
|
287
|
+
removeLeftovers();
|
|
288
|
+
return payload;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A stale lock is moved aside, never unlinked unseen: the file moved must
|
|
292
|
+
// be the one judged, else another start took the lock meanwhile and it
|
|
293
|
+
// goes back. Gone already (another start recovered it): from the top, at
|
|
294
|
+
// most three times.
|
|
295
|
+
function takeLock() {
|
|
296
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
297
|
+
try {
|
|
298
|
+
return writeLock();
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error?.code !== "EEXIST") throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const existingLock = readLockWithRetry();
|
|
304
|
+
let judged = { lock: existingLock };
|
|
305
|
+
if (!existingLock) {
|
|
306
|
+
if (!unreadableLockIsStale()) {
|
|
307
|
+
throw held(`A relay lock that cannot be read yet is there, written in the last ${unreadableStaleMs / 1000} s (lock ${lockFilePath}). If no relay is starting, delete it.`);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const stat = fs.statSync(lockFilePath);
|
|
311
|
+
judged = { lock: null, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
312
|
+
} catch {
|
|
313
|
+
if (attempt < 3) continue;
|
|
314
|
+
throw held(`The relay lock ${lockFilePath} keeps changing.`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const existingPid = Number.isInteger(existingLock?.pid) ? existingLock.pid : null;
|
|
318
|
+
const existingTokenHash = typeof existingLock?.tokenHash === "string" ? existingLock.tokenHash : "";
|
|
319
|
+
const existingStartedAt = existingLock?.startedAt || "unknown";
|
|
320
|
+
|
|
321
|
+
// A lock from before the last boot, or whose pid now belongs to a
|
|
322
|
+
// process started after it, is stale (a crash, a power loss, a forced
|
|
323
|
+
// reboot): recovered below.
|
|
324
|
+
if (existingPid && lockHolderMayRun({ lock: existingLock, isProcessAlive: processAlive, identityOf, bootIdOf, startedAtOf, bootTime })) {
|
|
325
|
+
const owner = existingTokenHash && existingTokenHash === currentTokenHash
|
|
326
|
+
? "Relay already running"
|
|
327
|
+
: "Relay appears to be owned by another live process";
|
|
328
|
+
// Delete the lock only when its holder cannot be told to be a relay.
|
|
329
|
+
const known = existingLock.holder && identityOf(existingPid);
|
|
330
|
+
throw held(`${owner} (pid=${existingPid}, startedAt=${existingStartedAt}; lock ${lockFilePath}). ${known
|
|
331
|
+
? `It is held by relay process ${existingPid}: oar stop, or end process ${existingPid}.`
|
|
332
|
+
: `If no relay is running, end process ${existingPid} or delete the lock.`}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const moved = `${lockFilePath}.stale-${currentPid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
336
|
+
for (let retry = 0; ; retry += 1) {
|
|
337
|
+
try {
|
|
338
|
+
fs.renameSync(lockFilePath, moved);
|
|
339
|
+
break;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error?.code === "ENOENT") break;
|
|
342
|
+
// Held open (an antivirus scan): briefly, then counted as held.
|
|
343
|
+
if (retry < RENAME_RETRY_MS.length && ["EBUSY", "EPERM", "EACCES"].includes(error?.code)) {
|
|
344
|
+
sleep(RENAME_RETRY_MS[retry]);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
throw held(`The stale relay lock ${lockFilePath} could not be moved aside (${error?.code || error}); delete it if no relay is running.`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!fs.existsSync(moved)) {
|
|
351
|
+
if (attempt < 3) continue;
|
|
352
|
+
throw held(`The relay lock ${lockFilePath} keeps changing.`);
|
|
353
|
+
}
|
|
354
|
+
if (!sameLock(moved, judged)) {
|
|
355
|
+
putBack(moved);
|
|
356
|
+
throw held(`Another relay took the lock ${lockFilePath} while this one recovered it.`);
|
|
357
|
+
}
|
|
358
|
+
try { fs.unlinkSync(moved); } catch {}
|
|
359
|
+
logger?.warn?.(`[server] Recovered stale singleton lock: ${lockFilePath}`);
|
|
360
|
+
try {
|
|
361
|
+
return writeLock();
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error?.code !== "EEXIST") throw error;
|
|
364
|
+
if (attempt >= 3) throw held(`The relay lock ${lockFilePath} keeps changing.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
103
367
|
}
|
|
104
368
|
|
|
105
369
|
function release() {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
REMOTE_RELAY_PROVIDERS,
|
|
9
9
|
} from '../../shared/remote-relay-contract.mjs';
|
|
10
10
|
import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../../shared/question-timeout.mjs';
|
|
11
|
+
import { readApprovalAnswer } from '../../shared/question-answer-rule.mjs';
|
|
11
12
|
import { createQuestionRepository } from '../repositories/question-repository.mjs';
|
|
12
13
|
import { pickLiveTurnRowId } from './live-turn-picker.mjs';
|
|
13
14
|
import { sanitizeRelayQuestionContext } from './relay-question-context.mjs';
|
|
@@ -427,8 +428,12 @@ export function createRemoteRelayCallerContext({
|
|
|
427
428
|
const current = questionStatements.getQuestion.get(questionId);
|
|
428
429
|
if (!current) return { approved: false, error: 'The approval question was removed.' };
|
|
429
430
|
if (current.status === 'answered') {
|
|
430
|
-
const
|
|
431
|
-
|
|
431
|
+
const decision = readApprovalAnswer(current.answer, { approve: APPROVAL_ALLOW, deny: APPROVAL_DENY });
|
|
432
|
+
if (decision === 'approve') return { approved: true };
|
|
433
|
+
if (decision === 'deny') return { approved: false };
|
|
434
|
+
// The answer route takes only the choices; anything else in the row
|
|
435
|
+
// is no decision of the user's, so it is not passed off as their Deny.
|
|
436
|
+
return { approved: false, error: 'The approval question was answered with neither Allow nor Deny. Go on without it, or ask the user first.' };
|
|
432
437
|
}
|
|
433
438
|
if (current.status !== 'pending') {
|
|
434
439
|
return {
|
|
@@ -25,8 +25,9 @@ import {
|
|
|
25
25
|
withRemotePromptHeader,
|
|
26
26
|
} from '../../shared/remote-relay-contract.mjs';
|
|
27
27
|
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
28
|
+
import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
|
|
28
29
|
import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
|
|
29
|
-
import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
|
|
30
|
+
import { RELAY_EVENT_DEFAULT_TYPES, RELAY_EVENT_RELAY_LEVEL_TYPES } from '../../shared/relay-events.mjs';
|
|
30
31
|
import { checkRequestedProviderModel, isAutoModelId, providerModelLabel, unknownProviderModelText } from './provider-model-selection.mjs';
|
|
31
32
|
import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
|
|
32
33
|
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
@@ -2132,7 +2133,14 @@ export function createRemoteRelayDispatcher({
|
|
|
2132
2133
|
if (!labels.includes(label)) labels.push(label);
|
|
2133
2134
|
}
|
|
2134
2135
|
// The card's own format: the picked labels, then any typed text, ", "-joined.
|
|
2135
|
-
|
|
2136
|
+
// A card that takes no typed text gets an option or nothing.
|
|
2137
|
+
let extra = toText(args.answer);
|
|
2138
|
+
if (extra && question.allowFreeform === false) {
|
|
2139
|
+
extra = matchQuestionChoice(extra, options);
|
|
2140
|
+
if (!extra) {
|
|
2141
|
+
throw invalidInput(`This question takes only its options (${options.join(', ')}), no typed answer; pass one of them in choices.`);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2136
2144
|
if (extra && !labels.includes(extra)) labels.push(extra);
|
|
2137
2145
|
const text = labels.join(', ');
|
|
2138
2146
|
return { answer: text, text };
|
|
@@ -2332,7 +2340,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2332
2340
|
if (!ctx.relay.local) return subscribeRemoteAction(ctx, args);
|
|
2333
2341
|
const service = eventService(ctx);
|
|
2334
2342
|
const all = args.scope === 'all';
|
|
2335
|
-
const ids = all ? [] : args.sessions;
|
|
2343
|
+
const ids = all ? [] : (args.sessions || []);
|
|
2336
2344
|
// Refused before anyone is asked: the conversation itself, unknown ids.
|
|
2337
2345
|
if (!all && ids.includes(ctx.conversationId)) {
|
|
2338
2346
|
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
|
|
@@ -2349,11 +2357,15 @@ export function createRemoteRelayDispatcher({
|
|
|
2349
2357
|
titles.push({ id, title: toText(page.title) || null });
|
|
2350
2358
|
}
|
|
2351
2359
|
const events = Array.isArray(args.events) && args.events.length ? args.events : [...RELAY_EVENT_DEFAULT_TYPES];
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2360
|
+
// Only relay_restarted, no session: nothing of another conversation is
|
|
2361
|
+
// watched, so there is nothing to ask the user about.
|
|
2362
|
+
if (all || ids.length) {
|
|
2363
|
+
const decision = await ensureWatchApproval(ctx, {
|
|
2364
|
+
all,
|
|
2365
|
+
watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
|
|
2366
|
+
});
|
|
2367
|
+
if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
|
|
2368
|
+
}
|
|
2357
2369
|
const subscription = relayEventCall(() => service.subscribe({
|
|
2358
2370
|
watcherConversationId: ctx.conversationId,
|
|
2359
2371
|
scope: all ? 'all' : 'sessions',
|
|
@@ -2364,8 +2376,9 @@ export function createRemoteRelayDispatcher({
|
|
|
2364
2376
|
stalledMinutes: args.stalled_minutes ?? null,
|
|
2365
2377
|
heartbeatMinutes: args.heartbeat_minutes ?? null,
|
|
2366
2378
|
}));
|
|
2379
|
+
const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'relay_restarted');
|
|
2367
2380
|
return {
|
|
2368
|
-
summary: `subscribe → ${ctx.relay.name}: ${
|
|
2381
|
+
summary: `subscribe → ${ctx.relay.name}: ${what} (${subscription.id})`,
|
|
2369
2382
|
subscription,
|
|
2370
2383
|
note: EVENT_NOTE,
|
|
2371
2384
|
};
|
|
@@ -2378,6 +2391,14 @@ export function createRemoteRelayDispatcher({
|
|
|
2378
2391
|
* mirror; the link fetches the events from then on.
|
|
2379
2392
|
*/
|
|
2380
2393
|
async function subscribeRemoteAction(ctx, args) {
|
|
2394
|
+
const relayLevel = (args.events || []).filter((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
|
|
2395
|
+
if (relayLevel.length) {
|
|
2396
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
|
|
2397
|
+
`${relayLevel.join(', ')} is about the relay you run on: subscribe to it on "${REMOTE_RELAY_LOCAL_ALIAS}". An outage of relay "${ctx.relay.name}" shows as link_lost and link_restored.`);
|
|
2398
|
+
}
|
|
2399
|
+
if (!args.sessions?.length && args.scope !== 'all') {
|
|
2400
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'subscribe on a paired relay needs sessions (ids from list_sessions) or ["all"].');
|
|
2401
|
+
}
|
|
2381
2402
|
const service = eventService(ctx);
|
|
2382
2403
|
const all = args.scope === 'all';
|
|
2383
2404
|
relayEventCall(() => service.checkWatcherCapacity(ctx.conversationId));
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
* - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
|
|
26
26
|
* a new task id is a start, a task gone from the set has ended, a changed
|
|
27
27
|
* task (tool call, summary, tokens) is activity. A task that started
|
|
28
|
-
* before this runtime did was announced by the runtime before it.
|
|
28
|
+
* before this runtime did was announced by the runtime before it. A task
|
|
29
|
+
* that ended and is listed under another run (`taskRunId`: a resumed
|
|
30
|
+
* Claude sub-agent keeps its task id) is a start again.
|
|
29
31
|
* - `noteTaskSettled` — how a task ended, where the worker says so (the
|
|
30
32
|
* Claude worker's task_notification). A task this runtime never knew
|
|
31
33
|
* still gets its end event when the worker describes it.
|
|
@@ -168,6 +170,11 @@ export function createSessionActivityTracker({
|
|
|
168
170
|
const sessions = new Map();
|
|
169
171
|
// Edges already reported for an end, by `${conversationId}:${key}`.
|
|
170
172
|
const endedKeys = new Map();
|
|
173
|
+
// `${conversationId}:${taskId}` → the run of the task that ended (the
|
|
174
|
+
// SDK's run id, `taskRunId`), where the worker names one. A resumed Claude
|
|
175
|
+
// sub-agent keeps its task id and runs again under a new run id: a set
|
|
176
|
+
// that lists it with another run is a new start, not a late set.
|
|
177
|
+
const endedRuns = new Map();
|
|
171
178
|
// `${conversationId}:${taskId}` of the ends nobody reported: the task was
|
|
172
179
|
// only missing from a set for the grace. A later set that lists it again
|
|
173
180
|
// says it never ended.
|
|
@@ -216,16 +223,25 @@ export function createSessionActivityTracker({
|
|
|
216
223
|
for (const key of [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']) {
|
|
217
224
|
if (key) endedKeys.set(`${conversationId}:${key}`, at);
|
|
218
225
|
}
|
|
226
|
+
if (task.taskRunId) endedRuns.set(`${conversationId}:${task.taskId}`, task.taskRunId);
|
|
227
|
+
else endedRuns.delete(`${conversationId}:${task.taskId}`);
|
|
219
228
|
if (endedKeys.size > 2000) {
|
|
220
229
|
for (const [key, when] of endedKeys) {
|
|
221
230
|
if (at - when > RECENT_TASK_TTL_MS) {
|
|
222
231
|
endedKeys.delete(key);
|
|
223
232
|
endedByAbsence.delete(key);
|
|
233
|
+
endedRuns.delete(key);
|
|
224
234
|
}
|
|
225
235
|
}
|
|
226
236
|
}
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
/** The set lists an ended task under another run: it was resumed. */
|
|
240
|
+
function isNewRun(conversationId, taskId, taskRunId) {
|
|
241
|
+
const ended = endedRuns.get(`${conversationId}:${taskId}`);
|
|
242
|
+
return !!taskRunId && !!ended && ended !== taskRunId;
|
|
243
|
+
}
|
|
244
|
+
|
|
229
245
|
function wasEnded(conversationId, task) {
|
|
230
246
|
return [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']
|
|
231
247
|
.some((key) => key && endedKeys.has(`${conversationId}:${key}`));
|
|
@@ -308,6 +324,7 @@ export function createSessionActivityTracker({
|
|
|
308
324
|
/** Takes back an end that was only an absence; the task's old row goes from the recent ones. */
|
|
309
325
|
function unEnd(conversationId, entry, taskId, toolUseId) {
|
|
310
326
|
endedByAbsence.delete(`${conversationId}:${taskId}`);
|
|
327
|
+
endedRuns.delete(`${conversationId}:${taskId}`);
|
|
311
328
|
const index = entry.recent.findIndex((task) => task.taskId === taskId);
|
|
312
329
|
const [old] = index >= 0 ? entry.recent.splice(index, 1) : [null];
|
|
313
330
|
for (const key of [taskId, toolUseId ? `run:${toolUseId}` : '', old?.toolUseId ? `run:${old.toolUseId}` : '']) {
|
|
@@ -332,6 +349,7 @@ export function createSessionActivityTracker({
|
|
|
332
349
|
seen.add(taskId);
|
|
333
350
|
const signature = taskSignature(raw);
|
|
334
351
|
const known = entry.live.get(taskId);
|
|
352
|
+
const taskRunId = toText(raw.taskRunId);
|
|
335
353
|
if (known && !known.leftAtMs) {
|
|
336
354
|
if (known.signature !== signature) {
|
|
337
355
|
known.signature = signature;
|
|
@@ -340,32 +358,45 @@ export function createSessionActivityTracker({
|
|
|
340
358
|
}
|
|
341
359
|
known.description = toText(raw.description) || known.description;
|
|
342
360
|
known.toolUseId = toText(raw.toolUseId) || known.toolUseId;
|
|
361
|
+
known.taskRunId = taskRunId || known.taskRunId;
|
|
343
362
|
known.summary = toText(raw.summary) || known.summary;
|
|
344
363
|
known.model = toText(raw.model) || known.model;
|
|
345
364
|
continue;
|
|
346
365
|
}
|
|
347
366
|
if (known?.leftAtMs) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
367
|
+
if (!taskRunId || !known.taskRunId || known.taskRunId === taskRunId) {
|
|
368
|
+
// Back in the set after it left: it did not end after all.
|
|
369
|
+
if (known.graceTimer) clearTimer(known.graceTimer);
|
|
370
|
+
known.graceTimer = null;
|
|
371
|
+
known.leftAtMs = 0;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
// Back under another run (resumed within the grace): the run that
|
|
375
|
+
// left ended, how is not known; the new one starts below.
|
|
376
|
+
finishTask(id, known, { status: 'completed', reported: false });
|
|
353
377
|
}
|
|
354
378
|
let revived = null;
|
|
355
379
|
if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
380
|
+
if (isNewRun(id, taskId, taskRunId)) {
|
|
381
|
+
// Resumed: the same task, a new run. Its first run's end stays
|
|
382
|
+
// reported; this run starts (and will end) on its own.
|
|
383
|
+
unEnd(id, entry, taskId, toText(raw.toolUseId));
|
|
384
|
+
} else {
|
|
385
|
+
// Settled already (its notice came before the set dropped it).
|
|
386
|
+
if (!endedByAbsence.has(`${id}:${taskId}`)) continue;
|
|
387
|
+
// Ended only because a set did not list it (a set that arrived out
|
|
388
|
+
// of turn, a worker that left it out for a moment) and listed
|
|
389
|
+
// again: it never ended. It comes back as the task it was, with no
|
|
390
|
+
// second start. The end that was reported for it stays reported;
|
|
391
|
+
// its real end will be another.
|
|
392
|
+
revived = unEnd(id, entry, taskId, toText(raw.toolUseId)) || {};
|
|
393
|
+
}
|
|
364
394
|
}
|
|
365
395
|
const taskStartedAtMs = msOf(raw.startedAt);
|
|
366
396
|
const task = {
|
|
367
397
|
taskId,
|
|
368
398
|
toolUseId: toText(raw.toolUseId) || null,
|
|
399
|
+
taskRunId: taskRunId || null,
|
|
369
400
|
taskType: toText(raw.taskType),
|
|
370
401
|
kind: taskKindOf(raw.taskType),
|
|
371
402
|
description: toText(raw.description).slice(0, 500),
|
|
@@ -395,16 +426,21 @@ export function createSessionActivityTracker({
|
|
|
395
426
|
}
|
|
396
427
|
|
|
397
428
|
function noteTaskSettled(conversationId, {
|
|
398
|
-
taskId, toolUseId, status, summary, exitCode, description, taskType, startedAt,
|
|
429
|
+
taskId, toolUseId, taskRunId, status, summary, exitCode, description, taskType, startedAt,
|
|
399
430
|
} = {}) {
|
|
400
431
|
const id = toText(conversationId);
|
|
401
432
|
if (!id) return;
|
|
402
433
|
const entry = session(id);
|
|
403
434
|
const key = toText(taskId);
|
|
404
435
|
const run = toText(toolUseId);
|
|
436
|
+
const settledRun = toText(taskRunId);
|
|
405
437
|
const task = (key && entry.live.get(key))
|
|
406
438
|
|| [...entry.live.values()].find((candidate) => run && candidate.toolUseId === run)
|
|
407
439
|
|| null;
|
|
440
|
+
// The end of an earlier run of a task that was resumed since (a notice
|
|
441
|
+
// the worker sent again late): the run that is live now goes on.
|
|
442
|
+
if (task && settledRun && task.taskRunId && task.taskRunId !== settledRun) return;
|
|
443
|
+
if (task && settledRun) task.taskRunId = settledRun;
|
|
408
444
|
const settled = {
|
|
409
445
|
status: normalizeTaskStatus(status || 'completed'),
|
|
410
446
|
summary: toText(summary) || null,
|
|
@@ -439,6 +475,7 @@ export function createSessionActivityTracker({
|
|
|
439
475
|
const unknown = {
|
|
440
476
|
taskId: key,
|
|
441
477
|
toolUseId: runId || null,
|
|
478
|
+
taskRunId: settledRun || null,
|
|
442
479
|
taskType: toText(taskType),
|
|
443
480
|
kind: taskKindOf(taskType),
|
|
444
481
|
description: toText(description).slice(0, 500),
|