@oar-sh/oar 0.9.21 → 0.9.23
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/CHANGELOG.md +206 -0
- package/README.md +9 -9
- package/bin/oar.js +28 -0
- package/package.json +13 -14
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
- package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +9 -4
- package/server/claude-worker/claude-session-worker-main.mjs +10 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
- package/server/cursor-worker/cursor-session-worker.mjs +9 -1
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +1 -0
- package/server/grok-worker/grok-session-worker.mjs +10 -1
- package/server/grok-worker/grok-turn-runner.mjs +1 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +7 -0
- package/server/public/app/ask-user-view.js +4 -3
- package/server/public/app/bootstrap.js +62 -4
- package/server/public/app/chat-actions-menu-state.mjs +2 -0
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-view.js +306 -49
- package/server/public/app/header-priority-actions.mjs +5 -1
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/inbox-ui.js +70 -79
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +17 -0
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/session-history-ui.js +1 -0
- package/server/public/app/settings-modal.js +6 -0
- package/server/public/app/socket-handlers.js +42 -11
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +8 -6
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/index.html +391 -214
- package/server/repositories/message-repository.mjs +10 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/messages-routes.mjs +46 -34
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +77 -6
- package/server/server-runtime.mjs +199 -171
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/earlier-database-service.mjs +1 -1
- package/server/services/file-serving.mjs +13 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/native-addon-build-service.mjs +224 -0
- package/server/services/oar-cli-helpers.mjs +7 -4
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-console-log.mjs +2 -3
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- package/server/services/remote-relay-loopback.mjs +3 -3
- package/server/services/sdk-session-sync-service.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +143 -15
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/update-install-service.mjs +32 -1
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +26 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
- package/server/services/package-data-rescue-preinstall.mjs +0 -22
- package/server/services/package-data-rescue.mjs +0 -139
- package/server/tty-console-bootstrap.mjs +0 -75
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// What the relay does when its queue may hold work for a worker: hand rows to
|
|
4
|
+
// the workers that are ready (the socket service's notify), make sure every
|
|
5
|
+
// session with a pending row has a worker (the prime), and keep the prime's
|
|
6
|
+
// maintenance pass armed while rows wait. The wakes come from the queue's
|
|
7
|
+
// own writes (queue-wake-hook.mjs), from a retry time coming due, from gates
|
|
8
|
+
// that lift, and from the backstop poll.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The prime: every session that owns a pending row gets its worker started
|
|
12
|
+
* (rows in retry backoff included, so the worker is warm when the backoff
|
|
13
|
+
* matures; rows held at the usage limit are not listed until their reset).
|
|
14
|
+
*/
|
|
15
|
+
export function createPendingWorkerPrimer({
|
|
16
|
+
isEnabled = () => true,
|
|
17
|
+
listPendingOwnerSessionIds,
|
|
18
|
+
isKillBlocked = () => false,
|
|
19
|
+
// A session whose rows were failed instead of launched (a Claude API chat
|
|
20
|
+
// the key cannot serve): true to skip its launch.
|
|
21
|
+
failRowsWithoutLaunch = () => false,
|
|
22
|
+
ensureWorker,
|
|
23
|
+
// Rows were found: the maintenance pass keeps retrying until none is.
|
|
24
|
+
onRowsWaiting = () => {},
|
|
25
|
+
warn = () => {},
|
|
26
|
+
limit = 25,
|
|
27
|
+
} = {}) {
|
|
28
|
+
return async function prime(reason = 'pending-worker-prime') {
|
|
29
|
+
if (!isEnabled()) return 0;
|
|
30
|
+
const rows = listPendingOwnerSessionIds(limit) || [];
|
|
31
|
+
if (rows.length) onRowsWaiting();
|
|
32
|
+
let requested = 0;
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const sdkSessionId = String(row?.sdk_session_id || '').trim();
|
|
35
|
+
if (!sdkSessionId) continue;
|
|
36
|
+
if (isKillBlocked(sdkSessionId) === true) continue;
|
|
37
|
+
try {
|
|
38
|
+
// Per session: a throw here costs this session its launch, never the
|
|
39
|
+
// rest of the pass.
|
|
40
|
+
if (failRowsWithoutLaunch(sdkSessionId)) continue;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
warn(`WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
requested += 1;
|
|
46
|
+
try {
|
|
47
|
+
const result = await ensureWorker(sdkSessionId);
|
|
48
|
+
if (!result?.ok) {
|
|
49
|
+
warn(`WORKER PRIME blocked session=${sdkSessionId.slice(0, 8)} reason=${String(result?.error || reason || 'pending-worker-prime')}`);
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
warn(`WORKER PRIME failed session=${sdkSessionId.slice(0, 8)} reason=${String(reason || 'pending-worker-prime')} err=${error?.message || error}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return requested;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The wake. `wake(reason)` hands rows out at once, primes, and arms the
|
|
61
|
+
* maintenance pass; `wake(reason, { prime: false })` leaves the prime to that
|
|
62
|
+
* pass (a queue write: the send route launches its own worker, and a write
|
|
63
|
+
* burst must not run a prime per write). Primes never overlap: one asked for
|
|
64
|
+
* while one runs runs once after it. `primeFromBackstop` is the poll's share:
|
|
65
|
+
* a prime at most every `backstopPrimeGapMs`.
|
|
66
|
+
*/
|
|
67
|
+
export function createQueueWaker({
|
|
68
|
+
prime,
|
|
69
|
+
notify = () => {},
|
|
70
|
+
armPrime = () => {},
|
|
71
|
+
isReady = () => true,
|
|
72
|
+
backstopPrimeGapMs = 5_000,
|
|
73
|
+
now = () => Date.now(),
|
|
74
|
+
warn = () => {},
|
|
75
|
+
} = {}) {
|
|
76
|
+
let primeRunning = false;
|
|
77
|
+
let primeAgain = null;
|
|
78
|
+
let lastBackstopPrimeAt = -Infinity;
|
|
79
|
+
|
|
80
|
+
async function runPrime(reason) {
|
|
81
|
+
if (primeRunning) {
|
|
82
|
+
primeAgain = reason;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
primeRunning = true;
|
|
86
|
+
try {
|
|
87
|
+
let next = reason;
|
|
88
|
+
while (next) {
|
|
89
|
+
primeAgain = null;
|
|
90
|
+
try {
|
|
91
|
+
await prime(next);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
warn(`WORKER PRIME pass failed reason=${next} err=${error?.message || error}`);
|
|
94
|
+
}
|
|
95
|
+
next = primeAgain;
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
primeRunning = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function wake(reason = 'queue-wake', { prime: withPrime = true } = {}) {
|
|
103
|
+
if (!isReady()) return false;
|
|
104
|
+
try { armPrime(); } catch {}
|
|
105
|
+
try { notify(reason); } catch (error) {
|
|
106
|
+
warn(`queue notify failed reason=${reason} err=${error?.message || error}`);
|
|
107
|
+
}
|
|
108
|
+
if (withPrime) void runPrime(reason);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function primeFromBackstop(reason = 'backstop') {
|
|
113
|
+
if (!isReady()) return false;
|
|
114
|
+
try { armPrime(); } catch {}
|
|
115
|
+
const at = now();
|
|
116
|
+
if (at - lastBackstopPrimeAt < backstopPrimeGapMs) return false;
|
|
117
|
+
lastBackstopPrimeAt = at;
|
|
118
|
+
void runPrime(reason);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
wake,
|
|
124
|
+
primeFromBackstop,
|
|
125
|
+
status: () => ({ primeRunning, primeQueued: primeAgain !== null }),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -108,6 +108,15 @@ export function createRelayAskService({
|
|
|
108
108
|
}
|
|
109
109
|
})();
|
|
110
110
|
const available = !problem;
|
|
111
|
+
// The turn an ask was made in (migration 0037); without the column asks
|
|
112
|
+
// still work and store none.
|
|
113
|
+
const hasQueueId = available && (() => {
|
|
114
|
+
try {
|
|
115
|
+
return db.prepare(`PRAGMA table_info(relay_asks)`).all().some((column) => column.name === 'queue_id');
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
111
120
|
|
|
112
121
|
const sql = available ? {
|
|
113
122
|
insert: db.prepare(`
|
|
@@ -116,6 +125,12 @@ export function createRelayAskService({
|
|
|
116
125
|
question, options_json, recommendation, detail, urgency, status, created_at, updated_at)
|
|
117
126
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
|
118
127
|
`),
|
|
128
|
+
insertWithQueue: hasQueueId ? db.prepare(`
|
|
129
|
+
INSERT INTO relay_asks
|
|
130
|
+
(id, relay_id, remote_id, conversation_id, conversation_title, created_by, creator_json,
|
|
131
|
+
question, options_json, recommendation, detail, urgency, status, created_at, updated_at, queue_id)
|
|
132
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
|
|
133
|
+
`) : null,
|
|
119
134
|
get: db.prepare(`SELECT * FROM relay_asks WHERE id = ?`),
|
|
120
135
|
getMirror: db.prepare(`SELECT * FROM relay_asks WHERE relay_id = ? AND remote_id = ?`),
|
|
121
136
|
// The Inbox: this relay's asks, pending and closed since.
|
|
@@ -131,6 +146,17 @@ export function createRelayAskService({
|
|
|
131
146
|
AND (status = 'pending' OR (status <> 'pending' AND updated_at >= ?))
|
|
132
147
|
ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
133
148
|
`),
|
|
149
|
+
// A session's own cards: the asks in it kept here, none it only asked
|
|
150
|
+
// elsewhere and no mirrors.
|
|
151
|
+
listIn: db.prepare(`
|
|
152
|
+
SELECT * FROM relay_asks
|
|
153
|
+
WHERE conversation_id = ? AND relay_id = 'self'
|
|
154
|
+
AND (status = 'pending' OR (status <> 'pending' AND updated_at >= ?))
|
|
155
|
+
ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
156
|
+
`),
|
|
157
|
+
// What carryIn and carryBy are about to move.
|
|
158
|
+
listCarryIn: db.prepare(`SELECT id FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
|
|
159
|
+
listCarryBy: db.prepare(`SELECT id FROM relay_asks WHERE created_by = ? AND status = 'pending'`),
|
|
134
160
|
countPendingHere: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND status = 'pending'`),
|
|
135
161
|
countPendingIn: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
|
|
136
162
|
countPendingBy: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE created_by = ? AND status = 'pending'`),
|
|
@@ -228,6 +254,9 @@ export function createRelayAskService({
|
|
|
228
254
|
? { relay: creator.relayName || null, conversationId: creator.conversationId || null, title: creator.conversationTitle || null }
|
|
229
255
|
: (toText(row.created_by) ? { conversationId: row.created_by, title: toText(asker?.title) || null } : null),
|
|
230
256
|
onBehalf: creator ? true : (!!toText(row.created_by) && row.created_by !== row.conversation_id),
|
|
257
|
+
// The queue row of the turn that asked (null: on behalf, a paired
|
|
258
|
+
// relay's agent, or older); a mirror has none.
|
|
259
|
+
...(mirror ? {} : { queueId: row.queue_id || null }),
|
|
231
260
|
createdAt: row.created_at,
|
|
232
261
|
updatedAt: row.updated_at,
|
|
233
262
|
};
|
|
@@ -259,9 +288,10 @@ export function createRelayAskService({
|
|
|
259
288
|
/**
|
|
260
289
|
* Stores an ask about `conversationId` (a session of this relay). The asker
|
|
261
290
|
* is `createdBy` (a conversation here: the same one, or a lead asking for
|
|
262
|
-
* another) or `creator` (a paired relay's agent).
|
|
291
|
+
* another) or `creator` (a paired relay's agent). `queueId`: the queue row
|
|
292
|
+
* of the turn that asked, when it ran in this session. Returns the ask.
|
|
263
293
|
*/
|
|
264
|
-
function create({ conversationId, createdBy = null, creator = null, ...input } = {}) {
|
|
294
|
+
function create({ conversationId, createdBy = null, creator = null, queueId = null, ...input } = {}) {
|
|
265
295
|
requireAvailable();
|
|
266
296
|
const session = conversation(conversationId);
|
|
267
297
|
if (!isLive(session)) {
|
|
@@ -281,7 +311,7 @@ export function createRelayAskService({
|
|
|
281
311
|
const { question, options, recommendation, detail, urgency } = checked.value;
|
|
282
312
|
const id = newId();
|
|
283
313
|
const at = isoNow();
|
|
284
|
-
|
|
314
|
+
const values = [
|
|
285
315
|
id,
|
|
286
316
|
SELF,
|
|
287
317
|
null,
|
|
@@ -296,7 +326,9 @@ export function createRelayAskService({
|
|
|
296
326
|
urgency,
|
|
297
327
|
at,
|
|
298
328
|
at,
|
|
299
|
-
|
|
329
|
+
];
|
|
330
|
+
if (sql.insertWithQueue) sql.insertWithQueue.run(...values, clip(queueId, 200) || null);
|
|
331
|
+
else sql.insert.run(...values);
|
|
300
332
|
const row = sql.get.get(id);
|
|
301
333
|
const ask = shape(row);
|
|
302
334
|
log(`${id} in ${shortId(session.id)} by ${remoteCreator ? `relay ${shortId(remoteCreator.relayId)}` : shortId(asker)}${urgency === 'now' ? ' (now)' : ''}`);
|
|
@@ -335,6 +367,20 @@ export function createRelayAskService({
|
|
|
335
367
|
return [...rows.filter((row) => row.status === 'pending'), ...rows.filter((row) => row.status !== 'pending')].map(shape);
|
|
336
368
|
}
|
|
337
369
|
|
|
370
|
+
/**
|
|
371
|
+
* A session's cards: the asks in it kept on this relay (not those it only
|
|
372
|
+
* asked elsewhere, no mirrors), pending first, then those closed in the
|
|
373
|
+
* last answeredKeepMs.
|
|
374
|
+
*/
|
|
375
|
+
function listIn(conversationId) {
|
|
376
|
+
if (!available) return [];
|
|
377
|
+
const id = toText(conversationId);
|
|
378
|
+
if (!id) return [];
|
|
379
|
+
const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
|
|
380
|
+
const rows = sql.listIn.all(id, since, RELAY_ASK_LIMITS.listMax);
|
|
381
|
+
return [...rows.filter((row) => row.status === 'pending'), ...rows.filter((row) => row.status !== 'pending')].map(shape);
|
|
382
|
+
}
|
|
383
|
+
|
|
338
384
|
/** Who is told the outcome of a row here: its session and the conversation that asked. */
|
|
339
385
|
function tell(row) {
|
|
340
386
|
const ask = shape(row);
|
|
@@ -429,7 +475,19 @@ export function createRelayAskService({
|
|
|
429
475
|
const from = toText(sourceId);
|
|
430
476
|
const to = toText(targetId);
|
|
431
477
|
if (!from || !to || from === to) return 0;
|
|
432
|
-
|
|
478
|
+
// The rows both updates touch, each once (a self-ask in and by the
|
|
479
|
+
// source is in both), so their cards follow to the successor.
|
|
480
|
+
const touched = new Set();
|
|
481
|
+
const moved = db.transaction(() => {
|
|
482
|
+
for (const row of sql.listCarryIn.all(from)) touched.add(row.id);
|
|
483
|
+
for (const row of sql.listCarryBy.all(from)) touched.add(row.id);
|
|
484
|
+
return sql.carryIn.run(to, from).changes + sql.carryBy.run(to, from).changes;
|
|
485
|
+
})();
|
|
486
|
+
for (const id of touched) {
|
|
487
|
+
const row = sql.get.get(id);
|
|
488
|
+
// A mirror's card lives on the paired relay: nothing to show here.
|
|
489
|
+
if (row && row.relay_id === SELF) changed(row);
|
|
490
|
+
}
|
|
433
491
|
if (moved) log(`conv=${shortId(from)} compacted into ${shortId(to)}: ${moved} question${moved === 1 ? '' : 's'} moved`);
|
|
434
492
|
return moved;
|
|
435
493
|
}
|
|
@@ -586,6 +644,7 @@ export function createRelayAskService({
|
|
|
586
644
|
get,
|
|
587
645
|
listHere,
|
|
588
646
|
listOf,
|
|
647
|
+
listIn,
|
|
589
648
|
pendingCount,
|
|
590
649
|
answer,
|
|
591
650
|
dismiss,
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
// read, so the runtime copies it into `<log dir>/relay-console.log`, with a
|
|
9
9
|
// time stamp per line and a size limit.
|
|
10
10
|
//
|
|
11
|
-
// The copy is taken from the console's methods rather than from stdout
|
|
12
|
-
//
|
|
13
|
-
// that belongs in a log.
|
|
11
|
+
// The copy is taken from the console's methods rather than from stdout, so
|
|
12
|
+
// what a library writes to stdout directly stays out of it.
|
|
14
13
|
|
|
15
14
|
import fs from 'fs';
|
|
16
15
|
import path from 'path';
|
|
@@ -107,7 +107,7 @@ import {
|
|
|
107
107
|
relayEventQuietWindowEnd,
|
|
108
108
|
relayEventTypesTake,
|
|
109
109
|
} from '../../shared/relay-events.mjs';
|
|
110
|
-
import { REMOTE_RELAY_APPROVAL_SOURCE } from '../../shared/relay-asks.mjs';
|
|
110
|
+
import { REMOTE_RELAY_APPROVAL_SOURCE, isPersonOnlyQuestion } from '../../shared/relay-asks.mjs';
|
|
111
111
|
|
|
112
112
|
export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
113
113
|
export const RELAY_EVENT_SELF_RELAY_ID = 'self';
|
|
@@ -1770,6 +1770,7 @@ export function createRelayEventService({
|
|
|
1770
1770
|
prompt: 'text',
|
|
1771
1771
|
questionId: 'text',
|
|
1772
1772
|
approvalCard: 'bool',
|
|
1773
|
+
personOnly: 'bool',
|
|
1773
1774
|
// question_answered
|
|
1774
1775
|
answer: 'text',
|
|
1775
1776
|
choice: 'text',
|
|
@@ -2316,6 +2317,8 @@ export function createRelayEventService({
|
|
|
2316
2317
|
options,
|
|
2317
2318
|
questionId: toText(question.id),
|
|
2318
2319
|
...(toText(context?.source) === REMOTE_RELAY_APPROVAL_SOURCE ? { approvalCard: true } : {}),
|
|
2320
|
+
// A Claude session's Safety check: the user's to answer.
|
|
2321
|
+
...(isPersonOnlyQuestion(context) ? { personOnly: true } : {}),
|
|
2319
2322
|
// A question that does not block (relay_tool ask).
|
|
2320
2323
|
...(toText(question.kind) === 'ask' ? { kind: 'ask' } : {}),
|
|
2321
2324
|
});
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
isMissingRelayEventRoute,
|
|
45
45
|
relayWithoutCrossRelayEventsText,
|
|
46
46
|
} from './relay-event-link-service.mjs';
|
|
47
|
-
import { RELAY_ASK_KIND, relayQuestionKind } from '../../shared/relay-asks.mjs';
|
|
47
|
+
import { RELAY_ASK_KIND, isPersonOnlyQuestion, relayQuestionKind } from '../../shared/relay-asks.mjs';
|
|
48
48
|
import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute, relayWithoutAsksText } from './relay-ask-link-service.mjs';
|
|
49
49
|
|
|
50
50
|
// The one place a `relay_tool` tool call is decided and carried out (plan
|
|
@@ -465,11 +465,13 @@ function relayTarget(relay) {
|
|
|
465
465
|
/**
|
|
466
466
|
* The question card's view of a relay question: what the agent needs to answer
|
|
467
467
|
* it. Null for the remote's own remote-relay approval cards, which only its
|
|
468
|
-
* user may answer (the remote hides them from us as well)
|
|
468
|
+
* user may answer (the remote hides them from us as well), and for a Claude
|
|
469
|
+
* session's Safety check card, which only the user may answer.
|
|
469
470
|
*/
|
|
470
471
|
function mapPendingQuestion(question) {
|
|
471
472
|
if (!isPlainObject(question) || !toText(question.id)) return null;
|
|
472
473
|
if (isRemoteRelayApprovalQuestion(question)) return null;
|
|
474
|
+
if (isPersonOnlyQuestion(question.context)) return null;
|
|
473
475
|
const kind = relayQuestionKind(question);
|
|
474
476
|
if (kind === RELAY_ASK_KIND) {
|
|
475
477
|
// A question that does not block: the user's to answer, the session works on.
|
|
@@ -1757,7 +1759,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1757
1759
|
budget -= text.length;
|
|
1758
1760
|
const role = toText(row.role) || 'assistant';
|
|
1759
1761
|
const via = toText(row?.origin?.relayName);
|
|
1760
|
-
|
|
1762
|
+
// The newest lines, as the progress path reads them: the relay keeps the
|
|
1763
|
+
// newest per thread now, so the first ones are not the start anyway.
|
|
1764
|
+
const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities, { fromEnd: true }) : [];
|
|
1761
1765
|
out.unshift({
|
|
1762
1766
|
id: toText(row.id),
|
|
1763
1767
|
role,
|
|
@@ -2594,6 +2598,11 @@ export function createRemoteRelayDispatcher({
|
|
|
2594
2598
|
`Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
|
|
2595
2599
|
}
|
|
2596
2600
|
const session = toText(question.conversationId) || null;
|
|
2601
|
+
if (isPersonOnlyQuestion(question.context)) {
|
|
2602
|
+
throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
2603
|
+
`Question ${args.question_id} is a Safety check: Claude Code marks the call as person-only, so this card is the user's to answer, never an agent's. Leave it to the user.`,
|
|
2604
|
+
{ session });
|
|
2605
|
+
}
|
|
2597
2606
|
if (ctx.relay.local && session && isOwnConversation(ctx, session)) {
|
|
2598
2607
|
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
|
|
2599
2608
|
`Question ${args.question_id} belongs to this conversation itself; an agent cannot answer its own question. It is the user's to answer.`,
|
|
@@ -3462,7 +3471,17 @@ export function createRemoteRelayDispatcher({
|
|
|
3462
3471
|
};
|
|
3463
3472
|
}
|
|
3464
3473
|
const session = askSession(ctx, args);
|
|
3465
|
-
const ask = relayAskCall(() => service.create({
|
|
3474
|
+
const ask = relayAskCall(() => service.create({
|
|
3475
|
+
conversationId: session,
|
|
3476
|
+
createdBy: ctx.conversationId,
|
|
3477
|
+
...askInput(args),
|
|
3478
|
+
// The queue id means "the turn that was live when the ask was made": a
|
|
3479
|
+
// background sub-agent asking after its parent turn ended may record
|
|
3480
|
+
// the user's next, unrelated turn (accepted; there is no worker
|
|
3481
|
+
// attribution). An ask on behalf of another session stores null: the
|
|
3482
|
+
// asking turn lives in another conversation.
|
|
3483
|
+
queueId: session === ctx.conversationId ? (ctx.caller?.processingRowId || null) : null,
|
|
3484
|
+
}));
|
|
3466
3485
|
const forOther = session !== ctx.conversationId;
|
|
3467
3486
|
return {
|
|
3468
3487
|
summary: `ask → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: question ${ask.id} pending${ask.urgency === 'now' ? ' (now)' : ''}`,
|
|
@@ -100,10 +100,10 @@ export function createRemoteRelayLoopbackClient({
|
|
|
100
100
|
req.httpVersionMinor = 1;
|
|
101
101
|
req.httpVersion = '1.1';
|
|
102
102
|
if (hasBody) {
|
|
103
|
-
// What the wire would deliver: JSON, parsed.
|
|
104
|
-
//
|
|
103
|
+
// What the wire would deliver: JSON, parsed. The headers carry no
|
|
104
|
+
// content-length or transfer-encoding, so the JSON body parser finds
|
|
105
|
+
// no body to read and leaves this one in place.
|
|
105
106
|
req.body = JSON.parse(JSON.stringify(body));
|
|
106
|
-
req._body = true;
|
|
107
107
|
}
|
|
108
108
|
req.complete = true;
|
|
109
109
|
req.push(null);
|
|
@@ -367,6 +367,9 @@ export function createSessionHistoryRefreshService({
|
|
|
367
367
|
timestamp,
|
|
368
368
|
activity.subagentRunId,
|
|
369
369
|
activity.metadata ? JSON.stringify(activity.metadata) : null,
|
|
370
|
+
// turn_seq: an imported turn has no order of its own; it keeps the
|
|
371
|
+
// grouped layout.
|
|
372
|
+
null,
|
|
370
373
|
);
|
|
371
374
|
}
|
|
372
375
|
const thoughts = ensureArray(message?.thoughts)
|
|
@@ -386,6 +389,7 @@ export function createSessionHistoryRefreshService({
|
|
|
386
389
|
thought.done ? 1 : 0,
|
|
387
390
|
timestamp,
|
|
388
391
|
thought.subagentRunId,
|
|
392
|
+
null, // turn_seq, as for the activity rows above
|
|
389
393
|
);
|
|
390
394
|
}
|
|
391
395
|
}
|
|
@@ -88,6 +88,12 @@ export function createSessionWorkerSupervisor({
|
|
|
88
88
|
// short enough that stopping a turn and immediately sending a new message
|
|
89
89
|
// does not add 30s in front of the worker's boot.
|
|
90
90
|
killBlockGraceMs = 3_000,
|
|
91
|
+
// A kill's block on new launches and deliveries for the session ended (its
|
|
92
|
+
// grace ran out, or the marker was cleared): (sessionId). The relay hands
|
|
93
|
+
// out what waited for it.
|
|
94
|
+
onKillBlockLifted = null,
|
|
95
|
+
setTimeoutImpl = setTimeout,
|
|
96
|
+
clearTimeoutImpl = clearTimeout,
|
|
91
97
|
isPidAlive = defaultIsPidAlive,
|
|
92
98
|
diagnosticPlanReference = null,
|
|
93
99
|
log = null,
|
|
@@ -98,6 +104,8 @@ export function createSessionWorkerSupervisor({
|
|
|
98
104
|
|
|
99
105
|
const pendingStarts = new Map();
|
|
100
106
|
const lifecycleBySession = new Map();
|
|
107
|
+
// sessionId → the timer that reports the end of its kill block.
|
|
108
|
+
const killBlockTimers = new Map();
|
|
101
109
|
const retryLimit = Math.max(0, clampInt(maxRestartRetries, 5));
|
|
102
110
|
const backoffBaseMs = Math.max(1, clampInt(restartBackoffBaseMs, 1_000));
|
|
103
111
|
const backoffMaxMs = Math.max(backoffBaseMs, clampInt(restartBackoffMaxMs, 30_000));
|
|
@@ -301,9 +309,35 @@ export function createSessionWorkerSupervisor({
|
|
|
301
309
|
});
|
|
302
310
|
}
|
|
303
311
|
|
|
312
|
+
function reportKillBlockLifted(sessionId) {
|
|
313
|
+
if (typeof onKillBlockLifted !== 'function') return;
|
|
314
|
+
try { onKillBlockLifted(sessionId); } catch {}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function clearKillBlockTimer(sessionId) {
|
|
318
|
+
const timer = killBlockTimers.get(sessionId);
|
|
319
|
+
if (!timer) return false;
|
|
320
|
+
clearTimeoutImpl(timer);
|
|
321
|
+
killBlockTimers.delete(sessionId);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
304
325
|
function markKilled(sessionId) {
|
|
305
326
|
if (!normalizeSessionId(sessionId)) return null;
|
|
306
327
|
const killed = setLifecycle(sessionId, { killedAtMs: nowMs() });
|
|
328
|
+
// The block itself is read off the clock (isKillBlocked); this timer only
|
|
329
|
+
// says when it ends, so the rows that waited go out then.
|
|
330
|
+
if (killGraceMs > 0 && typeof onKillBlockLifted === 'function') {
|
|
331
|
+
clearKillBlockTimer(sessionId);
|
|
332
|
+
const timer = setTimeoutImpl(() => {
|
|
333
|
+
killBlockTimers.delete(sessionId);
|
|
334
|
+
if (!isKillBlocked(sessionId)) reportKillBlockLifted(sessionId);
|
|
335
|
+
// A few ms past the grace: the timer's clock and nowMs() may differ
|
|
336
|
+
// by a tick.
|
|
337
|
+
}, killGraceMs + 5);
|
|
338
|
+
timer?.unref?.();
|
|
339
|
+
killBlockTimers.set(sessionId, timer);
|
|
340
|
+
}
|
|
307
341
|
return toLifecycleSnapshot(sessionId, killed);
|
|
308
342
|
}
|
|
309
343
|
|
|
@@ -489,10 +523,12 @@ export function createSessionWorkerSupervisor({
|
|
|
489
523
|
patch.degradedAtMs = null;
|
|
490
524
|
patch.uiState = 'white';
|
|
491
525
|
}
|
|
526
|
+
const liftsKillBlock = resetKilledMarker && clearKillBlockTimer(sessionId);
|
|
492
527
|
if (resetKilledMarker) {
|
|
493
528
|
patch.killedAtMs = null;
|
|
494
529
|
}
|
|
495
530
|
const cleared = setLifecycle(sessionId, patch);
|
|
531
|
+
if (liftsKillBlock) reportKillBlockLifted(sessionId);
|
|
496
532
|
return toLifecycleSnapshot(sessionId, cleared);
|
|
497
533
|
}
|
|
498
534
|
|