@oar-sh/oar 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +168 -0
- package/README.md +20 -4
- package/package.json +2 -1
- package/server/claude-worker/claude-session-process.mjs +118 -1
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +95 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +90 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +85 -14
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +36 -0
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +37 -17
- package/server/services/remote-relay-dispatcher.mjs +72 -8
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +16 -2
- package/shared/steer-settle-failure.mjs +16 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pause and resume at the Claude subscription's usage limit.
|
|
5
|
+
*
|
|
6
|
+
* A Claude turn the CLI refused at the limit is not failed. Its row ends with
|
|
7
|
+
* a note, and the relay queues a follow-up message of its own that is held
|
|
8
|
+
* until the window resets: a pending queue row with `next_attempt_at` at the
|
|
9
|
+
* reset and `usage_limit_pause` set. The ordinary delivery sends it once it
|
|
10
|
+
* matures, so the pause survives a relay restart with no timer to restore.
|
|
11
|
+
*
|
|
12
|
+
* A reset too far away to wait for unattended (a weekly window) holds the
|
|
13
|
+
* follow-up until the user resumes it.
|
|
14
|
+
*
|
|
15
|
+
* The service also keeps the latest report of the limit the workers sent
|
|
16
|
+
* (memory only), for the heads-up before the limit is reached.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
USAGE_LIMIT_CODE,
|
|
23
|
+
USAGE_LIMIT_KIND,
|
|
24
|
+
USAGE_LIMIT_RESUME_MARGIN_MS,
|
|
25
|
+
normalizeRateLimitInfo,
|
|
26
|
+
usageLimitResumesByItself,
|
|
27
|
+
usageLimitWindowLabel,
|
|
28
|
+
} from '../../shared/claude-usage-limit.mjs';
|
|
29
|
+
|
|
30
|
+
export const USAGE_LIMIT_SOCKET_EVENT = 'usage_limit_pause';
|
|
31
|
+
export const USAGE_LIMIT_ACCOUNT_SOCKET_EVENT = 'claude_usage_limit';
|
|
32
|
+
|
|
33
|
+
// What the held follow-up says. It is a message of the relay's, shown in the
|
|
34
|
+
// transcript like one of the user's. It quotes what was refused: the refused
|
|
35
|
+
// prompt is in the CLI's transcript, but a bare "continue" did not make the
|
|
36
|
+
// model take it up again (captured 2026-09-28, Haiku 4.5).
|
|
37
|
+
export const USAGE_LIMIT_RESUME_LEAD = 'Automatic message from OAR: the work was paused at the Claude usage limit.';
|
|
38
|
+
const RESUME_CLOSE = 'Continue where you left off, and pick up again what the limit cut off, subagents and background tasks included.';
|
|
39
|
+
const RESUME_CONTINUATION = 'A turn you had opened yourself after a background task finished was refused at the limit: report what that task brought.';
|
|
40
|
+
const QUOTE_MAX_CHARS = 4000;
|
|
41
|
+
const QUOTE_MAX_PROMPTS = 5;
|
|
42
|
+
|
|
43
|
+
function quotePrompt(text) {
|
|
44
|
+
const value = String(text || '').trim();
|
|
45
|
+
const cut = value.length > QUOTE_MAX_CHARS ? `${value.slice(0, QUOTE_MAX_CHARS - 1)}…` : value;
|
|
46
|
+
return cut.split(/\r?\n/).map((line) => `> ${line}`.trimEnd()).join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The follow-up's text: what was refused, then what to do about it.
|
|
51
|
+
* `prompts` are the user's refused messages in the order they were sent;
|
|
52
|
+
* `continuation` says a turn of the CLI's own was refused, which has no
|
|
53
|
+
* prompt to quote.
|
|
54
|
+
*/
|
|
55
|
+
export function buildUsageLimitResumePrompt({ prompts = [], continuation = false } = {}) {
|
|
56
|
+
const quoted = (Array.isArray(prompts) ? prompts : [])
|
|
57
|
+
.map((text) => String(text || '').trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.slice(-QUOTE_MAX_PROMPTS);
|
|
60
|
+
const parts = [USAGE_LIMIT_RESUME_LEAD];
|
|
61
|
+
if (quoted.length === 1) {
|
|
62
|
+
parts.push(`This request was refused at the limit:\n\n${quotePrompt(quoted[0])}`);
|
|
63
|
+
} else if (quoted.length > 1) {
|
|
64
|
+
parts.push(`These requests were refused at the limit, in this order:\n\n${quoted.map(quotePrompt).join('\n\n')}`);
|
|
65
|
+
}
|
|
66
|
+
if (continuation) parts.push(RESUME_CONTINUATION);
|
|
67
|
+
parts.push(RESUME_CLOSE);
|
|
68
|
+
return parts.join(quoted.length || continuation ? '\n\n' : ' ');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// `next_attempt_at` of a follow-up that waits for the user.
|
|
72
|
+
const HELD_UNTIL_RESUMED = '9999-12-31T00:00:00.000Z';
|
|
73
|
+
// A reset further away than this is not a usage window's.
|
|
74
|
+
const MAX_RESET_AHEAD_MS = 8 * 24 * 60 * 60 * 1000;
|
|
75
|
+
const MIN_WAIT_MS = 60 * 1000;
|
|
76
|
+
// A refusal that names no reset still ahead (the CLI can name the one that
|
|
77
|
+
// has just passed) is tried again after this long, this many times in a row;
|
|
78
|
+
// after that the follow-up waits for the user.
|
|
79
|
+
const BLIND_RETRY_MS = 5 * 60 * 1000;
|
|
80
|
+
const MAX_BLIND_RETRIES = 6;
|
|
81
|
+
|
|
82
|
+
const TERMINAL_CODES = new Set([USAGE_LIMIT_CODE, `claude-${USAGE_LIMIT_CODE}`, `claude.${USAGE_LIMIT_CODE}`, `relay.claude-${USAGE_LIMIT_CODE}`]);
|
|
83
|
+
|
|
84
|
+
function parseJson(value) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(String(value || ''));
|
|
87
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function defaultFormatResetTime(iso, { now = Date.now() } = {}) {
|
|
94
|
+
const at = new Date(iso);
|
|
95
|
+
const sameDay = new Date(now).toDateString() === at.toDateString();
|
|
96
|
+
const clock = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }).format(at);
|
|
97
|
+
if (sameDay) return clock;
|
|
98
|
+
const day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }).format(at);
|
|
99
|
+
return `${day}, ${clock}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether a response's terminal error is the worker's usage-limit refusal. */
|
|
103
|
+
export function isUsageLimitTerminalError(terminalError) {
|
|
104
|
+
if (!terminalError || typeof terminalError !== 'object') return false;
|
|
105
|
+
if (String(terminalError.kind || '') === USAGE_LIMIT_KIND) return true;
|
|
106
|
+
return TERMINAL_CODES.has(String(terminalError.stableCode || '').trim().toLowerCase())
|
|
107
|
+
|| TERMINAL_CODES.has(String(terminalError.code || '').trim().toLowerCase());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createUsageLimitPauseService({
|
|
111
|
+
db,
|
|
112
|
+
stmts,
|
|
113
|
+
emit = () => {},
|
|
114
|
+
onQueueChanged = () => {},
|
|
115
|
+
now = () => Date.now(),
|
|
116
|
+
formatResetTime = defaultFormatResetTime,
|
|
117
|
+
logger = console,
|
|
118
|
+
} = {}) {
|
|
119
|
+
if (!db) throw new Error('db is required');
|
|
120
|
+
if (!stmts) throw new Error('stmts is required');
|
|
121
|
+
|
|
122
|
+
const findPausedRowForConversation = db.prepare(`
|
|
123
|
+
SELECT * FROM queue
|
|
124
|
+
WHERE conversation_id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
|
|
125
|
+
ORDER BY timestamp ASC, rowid ASC LIMIT 1
|
|
126
|
+
`);
|
|
127
|
+
const listPausedRows = db.prepare(`
|
|
128
|
+
SELECT * FROM queue
|
|
129
|
+
WHERE status = 'pending' AND usage_limit_pause IS NOT NULL
|
|
130
|
+
ORDER BY timestamp ASC, rowid ASC
|
|
131
|
+
`);
|
|
132
|
+
const setPause = db.prepare(`
|
|
133
|
+
UPDATE queue SET next_attempt_at = ?, usage_limit_pause = ?
|
|
134
|
+
WHERE id = ? AND status = 'pending'
|
|
135
|
+
`);
|
|
136
|
+
const moveFollowUp = db.prepare(`
|
|
137
|
+
UPDATE queue SET timestamp = ? WHERE id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
|
|
138
|
+
`);
|
|
139
|
+
const moveFollowUpMessage = db.prepare(`UPDATE messages SET timestamp = ? WHERE id = ? AND role = 'user'`);
|
|
140
|
+
const setFollowUpText = db.prepare(`
|
|
141
|
+
UPDATE queue SET text = ? WHERE id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
|
|
142
|
+
`);
|
|
143
|
+
const setFollowUpMessageText = db.prepare(`UPDATE messages SET text = ? WHERE id = ? AND role = 'user'`);
|
|
144
|
+
const deletePausedRow = db.prepare(`
|
|
145
|
+
DELETE FROM queue WHERE id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
|
|
146
|
+
`);
|
|
147
|
+
// Only ever the relay's own message: the kind is set by schedulePause alone.
|
|
148
|
+
const deleteFollowUpMessage = db.prepare(`DELETE FROM messages WHERE id = ? AND role = 'user' AND kind = ?`);
|
|
149
|
+
|
|
150
|
+
let account = null;
|
|
151
|
+
|
|
152
|
+
function stateFromRow(row) {
|
|
153
|
+
const pause = parseJson(row?.usage_limit_pause);
|
|
154
|
+
if (!row || !pause) return null;
|
|
155
|
+
// Sent already, or due: the ordinary delivery has it from here.
|
|
156
|
+
const heldUntil = Date.parse(String(row.next_attempt_at || ''));
|
|
157
|
+
if (!Number.isFinite(heldUntil) || heldUntil <= now()) return null;
|
|
158
|
+
return {
|
|
159
|
+
conversationId: String(row.conversation_id),
|
|
160
|
+
messageId: String(row.id),
|
|
161
|
+
rateLimitType: pause.rateLimitType || null,
|
|
162
|
+
label: usageLimitWindowLabel(pause.rateLimitType),
|
|
163
|
+
resetsAt: pause.resetsAt || null,
|
|
164
|
+
resumeAt: pause.auto ? String(row.next_attempt_at) : null,
|
|
165
|
+
auto: pause.auto === true,
|
|
166
|
+
pausedAt: pause.pausedAt || null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function emitPause(conversationId, state) {
|
|
171
|
+
try { emit(USAGE_LIMIT_SOCKET_EVENT, { conversationId, pause: state || null }); } catch {}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Decide whether a refused turn is paused. Returns the plan, or null when
|
|
176
|
+
* the response is to be handled as the terminal failure it says it is.
|
|
177
|
+
*/
|
|
178
|
+
function planPause({ terminalError, conversationId, providerType, queueRow = null, partialText = '' } = {}) {
|
|
179
|
+
if (!isUsageLimitTerminalError(terminalError)) return null;
|
|
180
|
+
if (String(providerType || '').trim().toLowerCase() !== 'claude') return null;
|
|
181
|
+
if (!String(conversationId || '').trim()) return null;
|
|
182
|
+
const at = now();
|
|
183
|
+
const rateLimitType = String(terminalError.rateLimitType || '').trim() || null;
|
|
184
|
+
const label = usageLimitWindowLabel(rateLimitType);
|
|
185
|
+
// What the turn had written before the refusal stays above the note.
|
|
186
|
+
const written = String(partialText || '').trim();
|
|
187
|
+
const withWritten = (note) => (written ? `${written}\n\n${note}` : note);
|
|
188
|
+
const resetsAtMs = Date.parse(String(terminalError.resetsAt || ''));
|
|
189
|
+
if (!Number.isFinite(resetsAtMs) || resetsAtMs <= at) {
|
|
190
|
+
// Counted along the follow-ups: the refused row is one itself when the
|
|
191
|
+
// turn before it was paused the same way.
|
|
192
|
+
const retries = Number(parseJson(queueRow?.usage_limit_pause)?.retries || 0) + 1;
|
|
193
|
+
const auto = retries <= MAX_BLIND_RETRIES;
|
|
194
|
+
const resumeAt = new Date(at + BLIND_RETRY_MS).toISOString();
|
|
195
|
+
return {
|
|
196
|
+
conversationId: String(conversationId),
|
|
197
|
+
rateLimitType,
|
|
198
|
+
resetsAt: null,
|
|
199
|
+
resumeAt: auto ? resumeAt : null,
|
|
200
|
+
auto,
|
|
201
|
+
retries,
|
|
202
|
+
noteText: withWritten(auto
|
|
203
|
+
? `⏸ Paused: the Claude ${label} is reached. The turn carries on by itself at ${formatResetTime(resumeAt, { now: at })}.`
|
|
204
|
+
: `⏸ Paused: the Claude ${label} is still reached. Resume the turn once it has reset.`),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (resetsAtMs - at > MAX_RESET_AHEAD_MS) return null;
|
|
208
|
+
const resetsAt = new Date(resetsAtMs).toISOString();
|
|
209
|
+
const auto = usageLimitResumesByItself(resetsAt, { now: at });
|
|
210
|
+
const resumeAtMs = Math.max(resetsAtMs + USAGE_LIMIT_RESUME_MARGIN_MS, at + MIN_WAIT_MS);
|
|
211
|
+
const when = formatResetTime(auto ? new Date(resumeAtMs).toISOString() : resetsAt, { now: at });
|
|
212
|
+
return {
|
|
213
|
+
conversationId: String(conversationId),
|
|
214
|
+
rateLimitType,
|
|
215
|
+
resetsAt,
|
|
216
|
+
resumeAt: auto ? new Date(resumeAtMs).toISOString() : null,
|
|
217
|
+
auto,
|
|
218
|
+
retries: 0,
|
|
219
|
+
noteText: withWritten(auto
|
|
220
|
+
? `⏸ Paused: the Claude ${label} is reached. The turn carries on by itself at ${when}.`
|
|
221
|
+
: `⏸ Paused: the Claude ${label} is reached and resets ${when}. Resume the turn once it has.`),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Queue the held follow-up for a paused turn, once the refused row's note
|
|
227
|
+
* is saved. A conversation holds one: a second refusal before the reset
|
|
228
|
+
* moves the one that is there.
|
|
229
|
+
*/
|
|
230
|
+
function schedulePause(plan, { queueRow = null } = {}) {
|
|
231
|
+
if (!plan?.conversationId) return null;
|
|
232
|
+
const at = new Date(now()).toISOString();
|
|
233
|
+
const heldUntil = plan.auto ? plan.resumeAt : HELD_UNTIL_RESUMED;
|
|
234
|
+
// What this refusal cut off. A refused follow-up stands for what it
|
|
235
|
+
// quoted; a turn of the CLI's own has no prompt.
|
|
236
|
+
const refusedFollowUp = parseJson(queueRow?.usage_limit_pause);
|
|
237
|
+
const cutOff = refusedFollowUp
|
|
238
|
+
? { prompts: Array.isArray(refusedFollowUp.prompts) ? refusedFollowUp.prompts : [], continuation: refusedFollowUp.continuation === true }
|
|
239
|
+
: (String(queueRow?.kind || '') === 'continuation'
|
|
240
|
+
? { prompts: [], continuation: true }
|
|
241
|
+
: { prompts: [String(queueRow?.text || '').trim()].filter(Boolean), continuation: false });
|
|
242
|
+
const pauseFor = (held) => {
|
|
243
|
+
const before = parseJson(held?.usage_limit_pause) || {};
|
|
244
|
+
const prompts = [...(Array.isArray(before.prompts) ? before.prompts : []), ...cutOff.prompts]
|
|
245
|
+
.map((text) => String(text || '').slice(0, QUOTE_MAX_CHARS))
|
|
246
|
+
.slice(-QUOTE_MAX_PROMPTS);
|
|
247
|
+
return {
|
|
248
|
+
rateLimitType: plan.rateLimitType,
|
|
249
|
+
resetsAt: plan.resetsAt,
|
|
250
|
+
auto: plan.auto,
|
|
251
|
+
retries: Number(plan.retries || 0),
|
|
252
|
+
pausedAt: at,
|
|
253
|
+
refusedMessageId: queueRow?.id ? String(queueRow.id) : null,
|
|
254
|
+
prompts,
|
|
255
|
+
continuation: before.continuation === true || cutOff.continuation,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
let created = null;
|
|
259
|
+
const write = db.transaction(() => {
|
|
260
|
+
const existing = findPausedRowForConversation.get(plan.conversationId);
|
|
261
|
+
if (existing) {
|
|
262
|
+
const pause = pauseFor(existing);
|
|
263
|
+
const text = buildUsageLimitResumePrompt(pause);
|
|
264
|
+
setPause.run(heldUntil, JSON.stringify(pause), existing.id);
|
|
265
|
+
setFollowUpText.run(text, existing.id);
|
|
266
|
+
setFollowUpMessageText.run(text, existing.id);
|
|
267
|
+
// The follow-up comes after what was refused last, in the queue and
|
|
268
|
+
// in the transcript. Left at the time of the first refusal it was
|
|
269
|
+
// sent, and answered, above the messages the user wrote during the
|
|
270
|
+
// pause, where nobody looks for the answer.
|
|
271
|
+
moveFollowUp.run(at, existing.id);
|
|
272
|
+
moveFollowUpMessage.run(at, existing.id);
|
|
273
|
+
return String(existing.id);
|
|
274
|
+
}
|
|
275
|
+
const messageId = randomUUID();
|
|
276
|
+
const pause = pauseFor(null);
|
|
277
|
+
const pauseJson = JSON.stringify(pause);
|
|
278
|
+
const resumePrompt = buildUsageLimitResumePrompt(pause);
|
|
279
|
+
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(plan.conversationId) || null;
|
|
280
|
+
const latestModel = stmts.getLatestConversationModel?.get?.(plan.conversationId)?.model || null;
|
|
281
|
+
const model = String(queueRow?.model || '').trim() || latestModel || null;
|
|
282
|
+
const modelVariant = String(queueRow?.model_variant_id || '').trim() || latestModel || model;
|
|
283
|
+
const relayMode = String(queueRow?.relay_mode || '').trim() || 'agent';
|
|
284
|
+
const owner = String(queueRow?.owner_sdk_session_id || '').trim() || null;
|
|
285
|
+
stmts.insertMsg.run(
|
|
286
|
+
messageId,
|
|
287
|
+
plan.conversationId,
|
|
288
|
+
'user',
|
|
289
|
+
resumePrompt,
|
|
290
|
+
modelVariant,
|
|
291
|
+
relayMode,
|
|
292
|
+
null,
|
|
293
|
+
at,
|
|
294
|
+
modelVariant || null,
|
|
295
|
+
null,
|
|
296
|
+
String(modelVariant || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual',
|
|
297
|
+
);
|
|
298
|
+
stmts.setMessageKind?.run?.(USAGE_LIMIT_KIND, messageId);
|
|
299
|
+
stmts.updateConvTime?.run?.(at, plan.conversationId);
|
|
300
|
+
const queueArgs = [
|
|
301
|
+
messageId,
|
|
302
|
+
plan.conversationId,
|
|
303
|
+
queueRow?.runtime_session_id || runtimeSession?.id || null,
|
|
304
|
+
0,
|
|
305
|
+
model,
|
|
306
|
+
modelVariant,
|
|
307
|
+
String(queueRow?.reasoning_effort || '').trim() || null,
|
|
308
|
+
String(queueRow?.context_tier || '').trim() || null,
|
|
309
|
+
relayMode,
|
|
310
|
+
resumePrompt,
|
|
311
|
+
null,
|
|
312
|
+
at,
|
|
313
|
+
owner,
|
|
314
|
+
owner ? at : null,
|
|
315
|
+
null,
|
|
316
|
+
null,
|
|
317
|
+
];
|
|
318
|
+
// The insert takes an image operation id where the queue has the column.
|
|
319
|
+
if (stmts.insertQ.source?.includes('image_operation_id')) queueArgs.push(null);
|
|
320
|
+
stmts.insertQ.run(...queueArgs);
|
|
321
|
+
setPause.run(heldUntil, pauseJson, messageId);
|
|
322
|
+
created = { messageId, model: modelVariant, relayMode, timestamp: at, text: resumePrompt };
|
|
323
|
+
return messageId;
|
|
324
|
+
});
|
|
325
|
+
const messageId = write();
|
|
326
|
+
if (created) {
|
|
327
|
+
try {
|
|
328
|
+
emit('user_message', {
|
|
329
|
+
conversationId: plan.conversationId,
|
|
330
|
+
messageId: created.messageId,
|
|
331
|
+
senderClientId: null,
|
|
332
|
+
message: {
|
|
333
|
+
role: 'user',
|
|
334
|
+
text: created.text,
|
|
335
|
+
model: created.model,
|
|
336
|
+
modelOrigin: String(created.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual',
|
|
337
|
+
mode: created.relayMode,
|
|
338
|
+
attachments: [],
|
|
339
|
+
timestamp: created.timestamp,
|
|
340
|
+
kind: USAGE_LIMIT_KIND,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
const state = getPause(plan.conversationId);
|
|
346
|
+
try { logger.log?.(`[usage-limit] conv=${plan.conversationId.slice(0, 8)} paused at the ${usageLimitWindowLabel(plan.rateLimitType)}; ${plan.auto ? `resumes ${plan.resumeAt}` : `resets ${plan.resetsAt}, waits for the user`}`); } catch {}
|
|
347
|
+
emitPause(plan.conversationId, state);
|
|
348
|
+
return { messageId, created: Boolean(created), state };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function getPause(conversationId) {
|
|
352
|
+
const id = String(conversationId || '').trim();
|
|
353
|
+
if (!id) return null;
|
|
354
|
+
return stateFromRow(findPausedRowForConversation.get(id));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function listPauses() {
|
|
358
|
+
return listPausedRows.all().map(stateFromRow).filter(Boolean);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Send the held follow-up now, whatever the reset. */
|
|
362
|
+
function resumeNow({ conversationId } = {}) {
|
|
363
|
+
const id = String(conversationId || '').trim();
|
|
364
|
+
const row = id ? findPausedRowForConversation.get(id) : null;
|
|
365
|
+
if (!row || !stateFromRow(row)) return { resumed: false, state: null };
|
|
366
|
+
const pause = parseJson(row.usage_limit_pause) || {};
|
|
367
|
+
const at = new Date(now()).toISOString();
|
|
368
|
+
setPause.run(null, JSON.stringify({ ...pause, resumedAt: at, resumedBy: 'user' }), row.id);
|
|
369
|
+
try { logger.log?.(`[usage-limit] conv=${id.slice(0, 8)} resumed by the user`); } catch {}
|
|
370
|
+
emitPause(id, null);
|
|
371
|
+
try { emit('message_status', { messageId: String(row.id), conversationId: id, status: 'pending' }); } catch {}
|
|
372
|
+
try { onQueueChanged('usage-limit-resume'); } catch {}
|
|
373
|
+
return { resumed: true, messageId: String(row.id), state: null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Drop the held follow-up: the paused turn is not carried on. */
|
|
377
|
+
function cancel({ conversationId } = {}) {
|
|
378
|
+
const id = String(conversationId || '').trim();
|
|
379
|
+
const row = id ? findPausedRowForConversation.get(id) : null;
|
|
380
|
+
if (!row) return { cancelled: false, state: null };
|
|
381
|
+
// The message goes with the row. It is the relay's own and was never
|
|
382
|
+
// sent: left in the transcript it would read as a request of the user's
|
|
383
|
+
// that nobody answered.
|
|
384
|
+
const drop = db.transaction(() => {
|
|
385
|
+
const result = deletePausedRow.run(row.id);
|
|
386
|
+
if (result.changes) deleteFollowUpMessage.run(row.id, USAGE_LIMIT_KIND);
|
|
387
|
+
return result.changes;
|
|
388
|
+
});
|
|
389
|
+
if (!drop()) return { cancelled: false, state: getPause(id) };
|
|
390
|
+
try { logger.log?.(`[usage-limit] conv=${id.slice(0, 8)} pause cancelled by the user`); } catch {}
|
|
391
|
+
emitPause(id, null);
|
|
392
|
+
try { emit('message_status', { messageId: String(row.id), conversationId: id, status: 'cancelled' }); } catch {}
|
|
393
|
+
return { cancelled: true, messageId: String(row.id), state: null };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function accountState() {
|
|
397
|
+
if (!account) return null;
|
|
398
|
+
const resetsAtMs = Date.parse(account.resetsAt || '');
|
|
399
|
+
// A report is about one window; past its reset it says nothing.
|
|
400
|
+
if (Number.isFinite(resetsAtMs) && resetsAtMs <= now()) {
|
|
401
|
+
account = null;
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
return { ...account };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The latest report a worker sent; returns what the clients are told. */
|
|
408
|
+
function recordReport({ report } = {}) {
|
|
409
|
+
const raw = report && typeof report === 'object' ? report : null;
|
|
410
|
+
// Workers send the normalized shape; a raw `rate_limit_info` is taken too.
|
|
411
|
+
const status = String(raw?.status || '').trim();
|
|
412
|
+
const info = raw && (raw.observedAt || Array.isArray(raw.windows))
|
|
413
|
+
? raw
|
|
414
|
+
: normalizeRateLimitInfo(raw, { now: now() });
|
|
415
|
+
if (!info || !['allowed', 'allowed_warning', 'rejected'].includes(status)) return accountState();
|
|
416
|
+
const utilization = Number(info.utilization);
|
|
417
|
+
account = {
|
|
418
|
+
status,
|
|
419
|
+
rateLimitType: String(info.rateLimitType || '').trim() || null,
|
|
420
|
+
label: usageLimitWindowLabel(info.rateLimitType),
|
|
421
|
+
utilization: Number.isFinite(utilization) ? Math.max(0, Math.min(1, utilization)) : null,
|
|
422
|
+
resetsAt: Number.isFinite(Date.parse(info.resetsAt || '')) ? new Date(Date.parse(info.resetsAt)).toISOString() : null,
|
|
423
|
+
isUsingOverage: info.isUsingOverage === true,
|
|
424
|
+
observedAt: new Date(now()).toISOString(),
|
|
425
|
+
};
|
|
426
|
+
const state = accountState();
|
|
427
|
+
try { emit(USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, state); } catch {}
|
|
428
|
+
return state;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
planPause,
|
|
433
|
+
schedulePause,
|
|
434
|
+
getPause,
|
|
435
|
+
listPauses,
|
|
436
|
+
resumeNow,
|
|
437
|
+
cancel,
|
|
438
|
+
recordReport,
|
|
439
|
+
getAccountState: accountState,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
QUESTION_TIMEOUT_CONTINUATION_TEXT,
|
|
4
|
-
} from './question-timeout.mjs';
|
|
1
|
+
import { DEFAULT_QUESTION_TIMEOUT_MS } from './question-timeout.mjs';
|
|
2
|
+
import { createRelayQuestion, waitForRelayQuestion } from './question-wait.mjs';
|
|
5
3
|
|
|
6
4
|
function sleepDefault(ms) {
|
|
7
5
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -56,38 +54,17 @@ export function createAskUserBridge({
|
|
|
56
54
|
// `timeoutMs` overrides the bridge-wide default for one wait (a structured
|
|
57
55
|
// elicitation may carry its own deadline). Additive: existing callers pass
|
|
58
56
|
// only `signal` and behave exactly as before.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
answer: String(question.answer || '').trim(),
|
|
72
|
-
// The validated structured submission, when the card carried a
|
|
73
|
-
// `requestedSchema` and the relay stored one. Null otherwise — flat
|
|
74
|
-
// consumers read `answer` and never see a shape change.
|
|
75
|
-
structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === 'object'
|
|
76
|
-
&& !Array.isArray(question.structuredAnswer)
|
|
77
|
-
? question.structuredAnswer
|
|
78
|
-
: null,
|
|
79
|
-
timedOut: false,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
if (question.status === 'timed_out' || question.status === 'cancelled') {
|
|
83
|
-
return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
|
|
84
|
-
}
|
|
85
|
-
if (Date.now() - started >= deadlineMs) {
|
|
86
|
-
await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
|
|
87
|
-
return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
|
|
88
|
-
}
|
|
89
|
-
await sleep(questionPollMs);
|
|
90
|
-
}
|
|
57
|
+
// The wait survives a relay that is briefly not there (question-wait.mjs).
|
|
58
|
+
function waitForRelayQuestionAnswer(questionId, { signal, timeoutMs } = {}) {
|
|
59
|
+
return waitForRelayQuestion({
|
|
60
|
+
api,
|
|
61
|
+
questionId,
|
|
62
|
+
deadlineMs: timeoutMs ?? questionTimeoutMs,
|
|
63
|
+
pollMs: questionPollMs,
|
|
64
|
+
sleep,
|
|
65
|
+
signal,
|
|
66
|
+
dbg,
|
|
67
|
+
});
|
|
91
68
|
}
|
|
92
69
|
|
|
93
70
|
async function askSingleQuestion(entry, { signal } = {}) {
|
|
@@ -124,9 +101,7 @@ export function createAskUserBridge({
|
|
|
124
101
|
multiSelect: entry.multiSelect || undefined,
|
|
125
102
|
},
|
|
126
103
|
};
|
|
127
|
-
const
|
|
128
|
-
const questionId = created?.question?.id;
|
|
129
|
-
if (!questionId) throw new Error('Relay question could not be created');
|
|
104
|
+
const questionId = await createRelayQuestion({ api, payload: questionPayload, sleep, signal, dbg });
|
|
130
105
|
dbg('relay question created', questionId, 'prompt=', entry.question.slice(0, 80));
|
|
131
106
|
return waitForRelayQuestionAnswer(questionId, { signal });
|
|
132
107
|
}
|