@oar-sh/oar 0.9.21 → 0.9.22
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 +113 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -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 +35 -14
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +76 -5
- package/server/server-runtime.mjs +178 -131
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- 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-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- 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/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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Timers a relay keeps only while there is something to time: the CLI
|
|
4
|
+
// presence deadline and the intervals armed by work and disarmed by their own
|
|
5
|
+
// tick. An idle relay runs neither.
|
|
6
|
+
|
|
7
|
+
// ─── CLI presence ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
// Whether "the CLI" (the relay's workers) has been heard from recently, kept
|
|
10
|
+
// without a polling timer. Every touch moves the last-seen time; one one-shot
|
|
11
|
+
// deadline timer, armed only while something was seen, notices when the
|
|
12
|
+
// window ran out. A deadline that fires early (a touch moved last-seen) arms
|
|
13
|
+
// again for the remainder, so an online relay wakes about once per window and
|
|
14
|
+
// an offline one not at all.
|
|
15
|
+
//
|
|
16
|
+
// The window depends on the worker sockets: an idle worker beats only every
|
|
17
|
+
// 30 s (shared/worker-runtime/worker-keepalive.mjs), so while a worker socket
|
|
18
|
+
// is open the window is wide enough for one lost beat. With no socket left a
|
|
19
|
+
// relay whose workers are all gone still reads offline within the short
|
|
20
|
+
// window, as before.
|
|
21
|
+
|
|
22
|
+
export const CLI_ONLINE_WINDOW_MS = 10_000;
|
|
23
|
+
export const CLI_ONLINE_WINDOW_WITH_WORKER_SOCKETS_MS = 45_000;
|
|
24
|
+
|
|
25
|
+
export function createCliPresence({
|
|
26
|
+
windowMs = CLI_ONLINE_WINDOW_MS,
|
|
27
|
+
socketWindowMs = CLI_ONLINE_WINDOW_WITH_WORKER_SOCKETS_MS,
|
|
28
|
+
// Called once when the window ran out after a touch (the deadline timer, or
|
|
29
|
+
// the last worker socket closing). The presence is cleared before the call.
|
|
30
|
+
onExpired = () => {},
|
|
31
|
+
now = () => Date.now(),
|
|
32
|
+
setTimeoutImpl = setTimeout,
|
|
33
|
+
clearTimeoutImpl = clearTimeout,
|
|
34
|
+
} = {}) {
|
|
35
|
+
let lastSeenMs = null;
|
|
36
|
+
let workerSocketCount = 0;
|
|
37
|
+
let deadlineTimer = null;
|
|
38
|
+
|
|
39
|
+
function currentWindowMs() {
|
|
40
|
+
return workerSocketCount > 0 ? socketWindowMs : windowMs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function remainingMs() {
|
|
44
|
+
if (lastSeenMs === null) return 0;
|
|
45
|
+
return lastSeenMs + currentWindowMs() - now();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function disarm() {
|
|
49
|
+
if (!deadlineTimer) return;
|
|
50
|
+
clearTimeoutImpl(deadlineTimer);
|
|
51
|
+
deadlineTimer = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function arm(delayMs) {
|
|
55
|
+
disarm();
|
|
56
|
+
deadlineTimer = setTimeoutImpl(onDeadline, Math.max(1, delayMs));
|
|
57
|
+
deadlineTimer?.unref?.();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function expire() {
|
|
61
|
+
disarm();
|
|
62
|
+
lastSeenMs = null;
|
|
63
|
+
try { onExpired(); } catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onDeadline() {
|
|
67
|
+
deadlineTimer = null;
|
|
68
|
+
if (lastSeenMs === null) return;
|
|
69
|
+
const left = remainingMs();
|
|
70
|
+
if (left > 0) {
|
|
71
|
+
arm(left);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
expire();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function touch() {
|
|
78
|
+
lastSeenMs = now();
|
|
79
|
+
// An armed deadline is left alone: when it fires it finds the newer
|
|
80
|
+
// touch and arms again for what is left.
|
|
81
|
+
if (!deadlineTimer) arm(currentWindowMs());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The worker socket count changed: the window may have shrunk. */
|
|
85
|
+
function noteWorkerSocketCount(count) {
|
|
86
|
+
workerSocketCount = Math.max(0, Number(count) || 0);
|
|
87
|
+
if (lastSeenMs === null) return;
|
|
88
|
+
const left = remainingMs();
|
|
89
|
+
if (left <= 0) {
|
|
90
|
+
expire();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
arm(left);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Forget the last touch without calling onExpired (an explicit offline). */
|
|
97
|
+
function clear() {
|
|
98
|
+
disarm();
|
|
99
|
+
lastSeenMs = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isFresh() {
|
|
103
|
+
return remainingMs() > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
touch,
|
|
108
|
+
clear,
|
|
109
|
+
isFresh,
|
|
110
|
+
noteWorkerSocketCount,
|
|
111
|
+
status: () => ({
|
|
112
|
+
lastSeenMs,
|
|
113
|
+
workerSocketCount,
|
|
114
|
+
windowMs: currentWindowMs(),
|
|
115
|
+
armed: deadlineTimer !== null,
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Armable interval ──────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
// An interval that runs only while there is something for it to do: armed by
|
|
123
|
+
// whoever creates that work (a share viewer arriving, a pending row waiting
|
|
124
|
+
// for its worker), disarmed by its own tick once the work is gone. An idle
|
|
125
|
+
// relay then has no such timer at all. The timer is unref'd, and stop() (the
|
|
126
|
+
// runtime's shutdown) disarms it for good.
|
|
127
|
+
|
|
128
|
+
export function createArmableInterval({
|
|
129
|
+
intervalMs,
|
|
130
|
+
// One tick. Returns (or resolves to) `false` to disarm: nothing is left to
|
|
131
|
+
// do. Anything else keeps the interval running. An arm that lands while a
|
|
132
|
+
// tick is still running (the tick awaits) keeps the interval running even
|
|
133
|
+
// if that tick returns false: the work it was armed for may be newer than
|
|
134
|
+
// what the tick looked at.
|
|
135
|
+
tick,
|
|
136
|
+
onError = () => {},
|
|
137
|
+
setIntervalImpl = setInterval,
|
|
138
|
+
clearIntervalImpl = clearInterval,
|
|
139
|
+
} = {}) {
|
|
140
|
+
if (typeof tick !== 'function') throw new Error('createArmableInterval requires tick');
|
|
141
|
+
const periodMs = Math.max(1, Number(intervalMs) || 1);
|
|
142
|
+
let timer = null;
|
|
143
|
+
let stopped = false;
|
|
144
|
+
let generation = 0;
|
|
145
|
+
|
|
146
|
+
function disarm() {
|
|
147
|
+
if (!timer) return;
|
|
148
|
+
clearIntervalImpl(timer);
|
|
149
|
+
timer = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function runTick() {
|
|
153
|
+
const armedAt = generation;
|
|
154
|
+
let keep;
|
|
155
|
+
try {
|
|
156
|
+
keep = await tick();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
try { onError(error); } catch {}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (keep === false && generation === armedAt) disarm();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function arm() {
|
|
165
|
+
if (stopped) return false;
|
|
166
|
+
generation += 1;
|
|
167
|
+
if (timer) return false;
|
|
168
|
+
timer = setIntervalImpl(() => { void runTick(); }, periodMs);
|
|
169
|
+
timer?.unref?.();
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stop() {
|
|
174
|
+
stopped = true;
|
|
175
|
+
disarm();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
arm,
|
|
180
|
+
disarm,
|
|
181
|
+
stop,
|
|
182
|
+
isArmed: () => timer !== null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Wakes the relay's queue delivery from the database itself. Every write
|
|
4
|
+
// that can make a queue row deliverable (a new pending row, a requeue, a
|
|
5
|
+
// retry time or owner or provider changed, a turn ending, a pending row
|
|
6
|
+
// cancelled or deleted) fires a TEMP trigger on this connection, which calls
|
|
7
|
+
// a JS function that only notes the reason and schedules one wake for after
|
|
8
|
+
// the current tick, so after the write's transaction. The function never
|
|
9
|
+
// touches the database and never throws.
|
|
10
|
+
//
|
|
11
|
+
// No row path has to remember to wake the queue, and none added later can
|
|
12
|
+
// forget: without it, a path that wrote a row and did not wake waited for
|
|
13
|
+
// the next poll. The triggers are TEMP (this connection only, nothing in the
|
|
14
|
+
// schema or the migrations). Their conditions name only the columns the
|
|
15
|
+
// queue table has, a self-test compiles every trigger inside a rolled-back
|
|
16
|
+
// savepoint, and on any error the triggers are dropped again and the caller
|
|
17
|
+
// falls back to polling: a broken trigger would fail every write to the
|
|
18
|
+
// queue.
|
|
19
|
+
|
|
20
|
+
export const QUEUE_WAKE_FUNCTION = 'oar_queue_wake';
|
|
21
|
+
const TRIGGER_PREFIX = 'oar_queue_wake_';
|
|
22
|
+
const TRIGGER_NAMES = ['insert', 'pending', 'settle', 'leave_pending', 'delete'].map((name) => `${TRIGGER_PREFIX}${name}`);
|
|
23
|
+
// A bulk statement calls the function once per row: the reasons of one tick
|
|
24
|
+
// are kept as a small set.
|
|
25
|
+
const MAX_REASONS = 8;
|
|
26
|
+
|
|
27
|
+
function queueColumns(db) {
|
|
28
|
+
return new Set(db.prepare('PRAGMA table_info(queue)').all().map((column) => String(column.name)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The CREATE TEMP TRIGGER statements, their conditions built from the columns there are. */
|
|
32
|
+
export function buildQueueWakeTriggerSql(columns) {
|
|
33
|
+
const has = (name) => columns.has(name);
|
|
34
|
+
if (!has('status')) throw new Error('queue has no status column');
|
|
35
|
+
const changed = ["OLD.status IS NOT 'pending'"];
|
|
36
|
+
for (const column of ['next_attempt_at', 'owner_sdk_session_id', 'provider']) {
|
|
37
|
+
if (has(column)) changed.push(`OLD.${column} IS NOT NEW.${column}`);
|
|
38
|
+
}
|
|
39
|
+
const call = (reason) => `BEGIN SELECT ${QUEUE_WAKE_FUNCTION}('${reason}'); END`;
|
|
40
|
+
return [
|
|
41
|
+
// A new pending row.
|
|
42
|
+
`CREATE TEMP TRIGGER ${TRIGGER_PREFIX}insert AFTER INSERT ON main.queue
|
|
43
|
+
WHEN NEW.status = 'pending' ${call('insert')}`,
|
|
44
|
+
// A row that became pending, or a pending row whose retry time, owner or
|
|
45
|
+
// provider changed (a requeue, a hold released, a row moved to another
|
|
46
|
+
// worker). A claim leaves pending and a lease refresh stays processing:
|
|
47
|
+
// neither wakes.
|
|
48
|
+
`CREATE TEMP TRIGGER ${TRIGGER_PREFIX}pending AFTER UPDATE ON main.queue
|
|
49
|
+
WHEN NEW.status = 'pending' AND (${changed.join(' OR ')}) ${call('requeue')}`,
|
|
50
|
+
// A turn ended: its session's next row, or one held behind it by the plan
|
|
51
|
+
// boundary, may go now.
|
|
52
|
+
`CREATE TEMP TRIGGER ${TRIGGER_PREFIX}settle AFTER UPDATE ON main.queue
|
|
53
|
+
WHEN OLD.status = 'processing' AND NEW.status IS NOT 'processing' ${call('settle')}`,
|
|
54
|
+
// A pending row cancelled, failed or parked: a row held behind it may go.
|
|
55
|
+
`CREATE TEMP TRIGGER ${TRIGGER_PREFIX}leave_pending AFTER UPDATE ON main.queue
|
|
56
|
+
WHEN OLD.status = 'pending' AND NEW.status NOT IN ('pending', 'processing') ${call('cancel')}`,
|
|
57
|
+
// A pending or running row deleted (the cancel routes): the same.
|
|
58
|
+
`CREATE TEMP TRIGGER ${TRIGGER_PREFIX}delete AFTER DELETE ON main.queue
|
|
59
|
+
WHEN OLD.status IN ('pending', 'processing') ${call('delete')}`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dropTriggers(db) {
|
|
64
|
+
for (const name of TRIGGER_NAMES) {
|
|
65
|
+
try { db.exec(`DROP TRIGGER IF EXISTS temp.${name}`); } catch {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compiles every trigger by preparing a write of each kind, and runs one real
|
|
71
|
+
* update, inside a savepoint that is rolled back: a condition naming a column
|
|
72
|
+
* the table lacks fails here instead of on the first real write.
|
|
73
|
+
*/
|
|
74
|
+
function selfTestTriggers(db) {
|
|
75
|
+
db.exec('SAVEPOINT oar_queue_wake_self_test');
|
|
76
|
+
try {
|
|
77
|
+
db.prepare('UPDATE queue SET status = status WHERE rowid = (SELECT rowid FROM queue LIMIT 1)').run();
|
|
78
|
+
db.prepare('UPDATE queue SET status = status WHERE 0').run();
|
|
79
|
+
db.prepare('DELETE FROM queue WHERE 0').run();
|
|
80
|
+
db.prepare('INSERT INTO queue SELECT * FROM queue WHERE 0').run();
|
|
81
|
+
} finally {
|
|
82
|
+
db.exec('ROLLBACK TO oar_queue_wake_self_test');
|
|
83
|
+
db.exec('RELEASE oar_queue_wake_self_test');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Installs the triggers on `db`. `onWake(reason)` runs once per tick in which
|
|
89
|
+
* queue writes fired them, with the reasons joined. Returns `{ installed }`;
|
|
90
|
+
* when false the triggers are gone again and the caller must poll.
|
|
91
|
+
*/
|
|
92
|
+
export function installQueueWakeHook(db, {
|
|
93
|
+
onWake = () => {},
|
|
94
|
+
log = () => {},
|
|
95
|
+
schedule = setImmediate,
|
|
96
|
+
selfTest = selfTestTriggers,
|
|
97
|
+
} = {}) {
|
|
98
|
+
const reasons = new Set();
|
|
99
|
+
let scheduled = false;
|
|
100
|
+
let muted = false;
|
|
101
|
+
|
|
102
|
+
function flush() {
|
|
103
|
+
scheduled = false;
|
|
104
|
+
const reason = [...reasons].join(',') || 'queue-write';
|
|
105
|
+
reasons.clear();
|
|
106
|
+
try {
|
|
107
|
+
onWake(reason);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
log(`queue wake failed: ${error?.message || error}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Called by SQLite from inside a write: no database access, no throw.
|
|
114
|
+
function wakeFromTrigger(reason) {
|
|
115
|
+
try {
|
|
116
|
+
if (muted) return null;
|
|
117
|
+
if (reasons.size < MAX_REASONS) reasons.add(String(reason || 'queue-write'));
|
|
118
|
+
if (!scheduled) {
|
|
119
|
+
scheduled = true;
|
|
120
|
+
schedule(flush);
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const sql = buildQueueWakeTriggerSql(queueColumns(db));
|
|
128
|
+
// The function first: a trigger calling a function that does not exist
|
|
129
|
+
// yet would fail the write.
|
|
130
|
+
db.function(QUEUE_WAKE_FUNCTION, { deterministic: false }, wakeFromTrigger);
|
|
131
|
+
dropTriggers(db);
|
|
132
|
+
for (const statement of sql) db.exec(statement);
|
|
133
|
+
muted = true;
|
|
134
|
+
try {
|
|
135
|
+
selfTest(db);
|
|
136
|
+
} finally {
|
|
137
|
+
muted = false;
|
|
138
|
+
}
|
|
139
|
+
return { installed: true, triggers: [...TRIGGER_NAMES] };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
dropTriggers(db);
|
|
142
|
+
log(`queue wake triggers not installed, the queue is polled instead: ${error?.message || error}`);
|
|
143
|
+
return { installed: false, error };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -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,
|
|
@@ -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)' : ''}`,
|