@oar-sh/oar 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -60,6 +60,54 @@ function normalizeStatus(value) {
|
|
|
60
60
|
return ALLOWED_STATUSES.has(mapped) ? mapped : 'new';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'other']);
|
|
64
|
+
const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
|
|
65
|
+
const STEERING_CANCELLABLE_IDS_MAX = 50;
|
|
66
|
+
|
|
67
|
+
// Queue message ids the worker still holds pushed-but-unconsumed (the Copilot
|
|
68
|
+
// SDK's queued lane): trimmed strings only, deduped, capped — a worker cannot
|
|
69
|
+
// grow the status payload without bound.
|
|
70
|
+
function normalizeSteeringCancellableIds(raw) {
|
|
71
|
+
if (!Array.isArray(raw)) return [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const ids = [];
|
|
74
|
+
for (const value of raw) {
|
|
75
|
+
if (typeof value !== 'string') continue;
|
|
76
|
+
const id = value.trim();
|
|
77
|
+
if (!id || id.length > STEERING_MESSAGE_ID_MAX_LENGTH || seen.has(id)) continue;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
ids.push(id);
|
|
80
|
+
if (ids.length >= STEERING_CANCELLABLE_IDS_MAX) break;
|
|
81
|
+
}
|
|
82
|
+
return ids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The worker's composer-facing steering snapshot, published on its heartbeat.
|
|
86
|
+
// Callers follow the registry convention of spreading the existing entry into
|
|
87
|
+
// upserts, so an update without `steering` keeps the last known snapshot.
|
|
88
|
+
// Exported so the heartbeat route compares apples to apples: normalizing there
|
|
89
|
+
// with different rules would make an unknown holdReason never converge and
|
|
90
|
+
// re-upsert on every heartbeat.
|
|
91
|
+
//
|
|
92
|
+
// `supported` is the worker's opt-in to mid-turn steering and the client
|
|
93
|
+
// composer's only steering gate: a worker that never sets it — the extension
|
|
94
|
+
// path, or a Claude worker old enough to send the 4-field snapshot — keeps
|
|
95
|
+
// "Queue". `cancellableIds` lists the pushed rows the worker can still pull
|
|
96
|
+
// back out of its runtime (un-steer).
|
|
97
|
+
export function normalizeWorkerSteeringSnapshot(raw) {
|
|
98
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
99
|
+
const holdReason = String(raw.holdReason || '').trim().toLowerCase();
|
|
100
|
+
const messageId = String(raw.messageId || '').trim().slice(0, STEERING_MESSAGE_ID_MAX_LENGTH);
|
|
101
|
+
return {
|
|
102
|
+
turnActive: raw.turnActive === true,
|
|
103
|
+
canSteer: raw.canSteer === true,
|
|
104
|
+
holdReason: STEERING_HOLD_REASONS.has(holdReason) ? holdReason : null,
|
|
105
|
+
messageId: messageId || null,
|
|
106
|
+
supported: raw.supported === true,
|
|
107
|
+
cancellableIds: normalizeSteeringCancellableIds(raw.cancellableIds),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
63
111
|
function sanitizeState(raw = null) {
|
|
64
112
|
const nowIso = new Date().toISOString();
|
|
65
113
|
const base = raw && typeof raw === 'object' ? raw : {};
|
|
@@ -74,11 +122,23 @@ function sanitizeState(raw = null) {
|
|
|
74
122
|
queueDepth: toNonNegativeInt(base.queueDepth, 0),
|
|
75
123
|
retryCount: toNonNegativeInt(base.retryCount, 0),
|
|
76
124
|
lastError: String(base.lastError || '').trim() || null,
|
|
125
|
+
steering: normalizeWorkerSteeringSnapshot(base.steering),
|
|
77
126
|
updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
|
|
78
127
|
createdAt,
|
|
79
128
|
};
|
|
80
129
|
}
|
|
81
130
|
|
|
131
|
+
// Statuses that mean the process a snapshot described is gone or not yet
|
|
132
|
+
// replaced by one that has reported. 'error' counts only on the way IN: a
|
|
133
|
+
// live worker marked errored (a requeue retry) keeps heartbeating, and its
|
|
134
|
+
// next snapshot must stick rather than be cleared on every upsert.
|
|
135
|
+
const WORKER_GONE_STATUSES = new Set(['new', 'starting', 'stopped', 'error']);
|
|
136
|
+
|
|
137
|
+
function isWorkerReplacedOrGone(existing, next) {
|
|
138
|
+
if (existing.pid && next.pid !== existing.pid) return true;
|
|
139
|
+
return next.status !== existing.status && WORKER_GONE_STATUSES.has(next.status);
|
|
140
|
+
}
|
|
141
|
+
|
|
82
142
|
function toSnapshot(entry) {
|
|
83
143
|
return Object.freeze({ ...entry });
|
|
84
144
|
}
|
|
@@ -91,9 +151,18 @@ export function createSessionWorkerRegistry() {
|
|
|
91
151
|
|
|
92
152
|
function clearIndexesForSession(entry) {
|
|
93
153
|
if (!entry) return;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
154
|
+
// Only drop an index that still points at THIS entry's session: a newer
|
|
155
|
+
// entry may have legitimately claimed the same workerId/conversationId/
|
|
156
|
+
// runtimeSessionId since, and deleting blindly would orphan it.
|
|
157
|
+
if (entry.workerId && sessionByWorkerId.get(entry.workerId) === entry.sdkSessionId) {
|
|
158
|
+
sessionByWorkerId.delete(entry.workerId);
|
|
159
|
+
}
|
|
160
|
+
if (entry.conversationId && sessionByConversationId.get(entry.conversationId) === entry.sdkSessionId) {
|
|
161
|
+
sessionByConversationId.delete(entry.conversationId);
|
|
162
|
+
}
|
|
163
|
+
if (entry.runtimeSessionId && sessionByRuntimeSessionId.get(entry.runtimeSessionId) === entry.sdkSessionId) {
|
|
164
|
+
sessionByRuntimeSessionId.delete(entry.runtimeSessionId);
|
|
165
|
+
}
|
|
97
166
|
}
|
|
98
167
|
|
|
99
168
|
function updateIndexesForSession(entry) {
|
|
@@ -154,12 +223,26 @@ export function createSessionWorkerRegistry() {
|
|
|
154
223
|
createdAt: existing?.createdAt || state.createdAt,
|
|
155
224
|
updatedAt: new Date().toISOString(),
|
|
156
225
|
});
|
|
226
|
+
// A steering snapshot describes one live worker process. Callers spread
|
|
227
|
+
// the old entry into every upsert, so without this a crashed worker's
|
|
228
|
+
// hold outlived it and the composer showed it until a replacement
|
|
229
|
+
// heartbeated — indefinitely, if none ever did.
|
|
230
|
+
if (existing && isWorkerReplacedOrGone(existing, next)) next.steering = null;
|
|
157
231
|
clearIndexesForSession(existing);
|
|
158
232
|
bySession.set(state.sdkSessionId, next);
|
|
159
233
|
updateIndexesForSession(next);
|
|
160
234
|
return toSnapshot(next);
|
|
161
235
|
}
|
|
162
236
|
|
|
237
|
+
/** Drop a session's steering snapshot (its worker process is known dead). */
|
|
238
|
+
function clearSteering(sdkSessionId) {
|
|
239
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
240
|
+
const existing = sessionId ? bySession.get(sessionId) : null;
|
|
241
|
+
if (!existing?.steering) return false;
|
|
242
|
+
bySession.set(sessionId, { ...existing, steering: null, updatedAt: new Date().toISOString() });
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
163
246
|
function removeWorker(sdkSessionId) {
|
|
164
247
|
const sessionId = normalizeSessionId(sdkSessionId);
|
|
165
248
|
if (!sessionId) return false;
|
|
@@ -169,6 +252,34 @@ export function createSessionWorkerRegistry() {
|
|
|
169
252
|
return bySession.delete(sessionId);
|
|
170
253
|
}
|
|
171
254
|
|
|
255
|
+
// Placeholder→real session rekey (audit #22). Session sync migrates the DB
|
|
256
|
+
// side (conversation binding, runtime session, queue owners) in one
|
|
257
|
+
// transaction; this is the in-memory half, moving the registry entry and its
|
|
258
|
+
// secondary indexes so the placeholder never lingers as a phantom worker.
|
|
259
|
+
function rekeyWorker(fromSdkSessionId, toSdkSessionId) {
|
|
260
|
+
const from = normalizeSessionId(fromSdkSessionId);
|
|
261
|
+
const to = normalizeSessionId(toSdkSessionId);
|
|
262
|
+
if (!from || !to || from === to) return null;
|
|
263
|
+
const placeholder = bySession.get(from);
|
|
264
|
+
if (!placeholder) return null;
|
|
265
|
+
const existingTarget = bySession.get(to) || null;
|
|
266
|
+
// An entry already registered under the real id is newer than the
|
|
267
|
+
// placeholder — its fields win, the placeholder only backfills gaps.
|
|
268
|
+
const next = sanitizeState({
|
|
269
|
+
...placeholder,
|
|
270
|
+
...(existingTarget || {}),
|
|
271
|
+
sdkSessionId: to,
|
|
272
|
+
createdAt: placeholder.createdAt,
|
|
273
|
+
updatedAt: new Date().toISOString(),
|
|
274
|
+
});
|
|
275
|
+
clearIndexesForSession(placeholder);
|
|
276
|
+
if (existingTarget) clearIndexesForSession(existingTarget);
|
|
277
|
+
bySession.delete(from);
|
|
278
|
+
bySession.set(to, next);
|
|
279
|
+
updateIndexesForSession(next);
|
|
280
|
+
return toSnapshot(next);
|
|
281
|
+
}
|
|
282
|
+
|
|
172
283
|
function clearAll() {
|
|
173
284
|
bySession.clear();
|
|
174
285
|
sessionByWorkerId.clear();
|
|
@@ -187,7 +298,9 @@ export function createSessionWorkerRegistry() {
|
|
|
187
298
|
getWorkerByRuntimeSessionId,
|
|
188
299
|
listWorkers,
|
|
189
300
|
upsertWorker,
|
|
301
|
+
clearSteering,
|
|
190
302
|
removeWorker,
|
|
303
|
+
rekeyWorker,
|
|
191
304
|
clearAll,
|
|
192
305
|
};
|
|
193
306
|
}
|
|
@@ -482,10 +482,16 @@ export function createSessionWorkerSupervisor({
|
|
|
482
482
|
if (current && !isTransitionAllowed(current.status, requestedStatus)) {
|
|
483
483
|
return current;
|
|
484
484
|
}
|
|
485
|
+
// The steering snapshot is the worker's to set (its heartbeat) and the
|
|
486
|
+
// registry's to clear (process gone or replaced). Callers build nextState
|
|
487
|
+
// from an older copy of the entry — ensureWorker from the 'starting' one,
|
|
488
|
+
// taken before the new process heartbeated — so passing theirs through
|
|
489
|
+
// would overwrite the snapshot the worker sent in the meantime.
|
|
490
|
+
const { steering: _staleSteering, ...patch } = nextState;
|
|
485
491
|
const next = registry.upsertWorker({
|
|
486
492
|
...(current || {}),
|
|
487
493
|
sdkSessionId: sessionId,
|
|
488
|
-
...
|
|
494
|
+
...patch,
|
|
489
495
|
status: requestedStatus,
|
|
490
496
|
updatedAt: nowIso(),
|
|
491
497
|
});
|
|
@@ -85,6 +85,19 @@ function queueSnapshotChanged(left, right) {
|
|
|
85
85
|
|| left.parkedCount !== right.parkedCount;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// The worker link heartbeats every ~10s (readyRefreshMs in
|
|
89
|
+
// worker-websocket-link.mjs) and itself abandons a connection after 30s of
|
|
90
|
+
// server silence (staleConnectionMs). Mirror that on this side: three missed
|
|
91
|
+
// heartbeats and a retained socket no longer proves the worker is alive.
|
|
92
|
+
const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 30_000;
|
|
93
|
+
|
|
94
|
+
// Protocol features this relay understands, advertised in server.hello. A
|
|
95
|
+
// worker that updated on disk before the relay restarted must not rely on
|
|
96
|
+
// them otherwise: an older relay ignores worker.unready / hello ready:false
|
|
97
|
+
// and treats a `steering-held` requeue as a failure (retry, backoff,
|
|
98
|
+
// worker marked errored).
|
|
99
|
+
export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held']);
|
|
100
|
+
|
|
88
101
|
export function createSessionWorkerWebSocketService({
|
|
89
102
|
WebSocketServerImpl,
|
|
90
103
|
httpServer,
|
|
@@ -104,6 +117,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
104
117
|
pathPrefix = '',
|
|
105
118
|
pollIntervalMs = 1000,
|
|
106
119
|
nowIso = () => new Date().toISOString(),
|
|
120
|
+
nowMs = () => Date.now(),
|
|
107
121
|
logger = console,
|
|
108
122
|
} = {}) {
|
|
109
123
|
if (!WebSocketServerImpl) throw new Error('createSessionWorkerWebSocketService requires WebSocketServerImpl');
|
|
@@ -123,6 +137,66 @@ export function createSessionWorkerWebSocketService({
|
|
|
123
137
|
let lastSnapshot = null;
|
|
124
138
|
let attached = false;
|
|
125
139
|
|
|
140
|
+
function nowMsSafe() {
|
|
141
|
+
const value = Number(nowMs());
|
|
142
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function removeSocket(socket, reason) {
|
|
146
|
+
const meta = clientState.get(socket);
|
|
147
|
+
clients.delete(socket);
|
|
148
|
+
clientState.delete(socket);
|
|
149
|
+
// Death-detection hook: a worker socket closing while its session owns
|
|
150
|
+
// an in-flight processing row used to be silently forgotten, leaving
|
|
151
|
+
// the row to the 600s stale sweep. The handler PID-probes and recovers.
|
|
152
|
+
if (meta?.sessionId) {
|
|
153
|
+
Promise.resolve(onWorkerSocketClosed({
|
|
154
|
+
sessionId: meta.sessionId,
|
|
155
|
+
pid: meta.pid || null,
|
|
156
|
+
reason,
|
|
157
|
+
})).catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function supersedePriorSockets(ws, sessionId) {
|
|
162
|
+
for (const socket of Array.from(clients)) {
|
|
163
|
+
if (socket === ws) continue;
|
|
164
|
+
const meta = clientState.get(socket);
|
|
165
|
+
if (meta?.sessionId !== sessionId) continue;
|
|
166
|
+
// One authoritative socket generation per session: the newest connection
|
|
167
|
+
// wins, and the prior socket is dropped synchronously so it can neither
|
|
168
|
+
// be handed work nor count as liveness while its close event is in
|
|
169
|
+
// flight. Before this, multiple ready sockets could claim one session
|
|
170
|
+
// and each pull rows for it.
|
|
171
|
+
logWarn(`[worker-ws] superseding prior socket for ${sessionId.slice(0, 8)}`);
|
|
172
|
+
removeSocket(socket, 'superseded');
|
|
173
|
+
try { socket.close(); } catch {}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function bindSocketIdentity(ws, meta, payload) {
|
|
178
|
+
const claimedSessionId = normalizeText(payload.sessionId);
|
|
179
|
+
const claimedPid = normalizePositiveInt(payload.pid);
|
|
180
|
+
// A socket's identity binds once (upgrade URL or first identifying frame).
|
|
181
|
+
// A later frame claiming a different session or PID is a misidentified or
|
|
182
|
+
// rekeyed worker; closing is safer than ignoring the frame — the worker
|
|
183
|
+
// reconnects and re-binds atomically instead of keeping a socket that
|
|
184
|
+
// receives one session's deliveries while claiming to be another.
|
|
185
|
+
if ((meta.sessionId && claimedSessionId && claimedSessionId !== meta.sessionId)
|
|
186
|
+
|| (meta.pid && claimedPid && claimedPid !== meta.pid)) {
|
|
187
|
+
logWarn(`[worker-ws] identity rebind rejected for ${String(meta.sessionId || 'unknown').slice(0, 8)} claimed=${String(claimedSessionId || 'none').slice(0, 8)}`);
|
|
188
|
+
removeSocket(ws, 'identity-rebind');
|
|
189
|
+
try { ws.close(); } catch {}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (!meta.sessionId && claimedSessionId) {
|
|
193
|
+
meta.sessionId = claimedSessionId;
|
|
194
|
+
supersedePriorSockets(ws, claimedSessionId);
|
|
195
|
+
}
|
|
196
|
+
if (!meta.pid && claimedPid) meta.pid = claimedPid;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
126
200
|
function toQueueChangedEvent(reason = 'queue-update') {
|
|
127
201
|
const snapshot = normalizeQueueSnapshot(queueCounts());
|
|
128
202
|
return {
|
|
@@ -302,13 +376,16 @@ export function createSessionWorkerWebSocketService({
|
|
|
302
376
|
ready: false,
|
|
303
377
|
delivering: false,
|
|
304
378
|
connectedAt: nowIso(),
|
|
379
|
+
lastSeenAtMs: nowMsSafe(),
|
|
305
380
|
lastDeliveredAt: null,
|
|
306
381
|
deliveryFailures: 0,
|
|
307
382
|
});
|
|
383
|
+
if (identity.sessionId) supersedePriorSockets(ws, identity.sessionId);
|
|
308
384
|
touchCli();
|
|
309
385
|
emitEvent(ws, {
|
|
310
386
|
type: 'server.hello',
|
|
311
387
|
reason: 'connected',
|
|
388
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
312
389
|
queue: normalizeQueueSnapshot(queueCounts()),
|
|
313
390
|
sessionId: identity.sessionId,
|
|
314
391
|
timestamp: nowIso(),
|
|
@@ -325,14 +402,17 @@ export function createSessionWorkerWebSocketService({
|
|
|
325
402
|
const meta = clientState.get(ws);
|
|
326
403
|
if (!meta || !payload || typeof payload !== 'object') return;
|
|
327
404
|
if (payload.type === 'worker.hello') {
|
|
328
|
-
meta
|
|
329
|
-
meta.
|
|
330
|
-
|
|
405
|
+
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
406
|
+
meta.lastSeenAtMs = nowMsSafe();
|
|
407
|
+
// A worker holding deliveries (open question card, compaction) binds
|
|
408
|
+
// its identity with hello but must not be handed work.
|
|
409
|
+
meta.ready = payload.ready !== false;
|
|
331
410
|
meta.lastHelloAt = nowIso();
|
|
332
411
|
noteHeartbeat(meta, 'worker-hello');
|
|
333
412
|
emitEvent(ws, {
|
|
334
413
|
type: 'server.hello',
|
|
335
414
|
reason: 'ack',
|
|
415
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
336
416
|
queue: normalizeQueueSnapshot(queueCounts()),
|
|
337
417
|
sessionId: meta.sessionId,
|
|
338
418
|
timestamp: nowIso(),
|
|
@@ -341,17 +421,28 @@ export function createSessionWorkerWebSocketService({
|
|
|
341
421
|
return;
|
|
342
422
|
}
|
|
343
423
|
if (payload.type === 'worker.ready') {
|
|
344
|
-
meta
|
|
345
|
-
meta.
|
|
424
|
+
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
425
|
+
meta.lastSeenAtMs = nowMsSafe();
|
|
346
426
|
meta.ready = true;
|
|
347
427
|
meta.lastReadyAt = nowIso();
|
|
348
428
|
noteHeartbeat(meta, String(payload.reason || 'worker-ready'));
|
|
349
429
|
void maybeDeliverToSocket(ws, String(payload.reason || 'worker-ready'));
|
|
350
430
|
return;
|
|
351
431
|
}
|
|
432
|
+
if (payload.type === 'worker.unready') {
|
|
433
|
+
// Readiness is otherwise sticky until the next delivery: a ready sent
|
|
434
|
+
// while the worker could steer would still stand after a hold began,
|
|
435
|
+
// and the delivery it drew would only be handed back.
|
|
436
|
+
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
437
|
+
meta.lastSeenAtMs = nowMsSafe();
|
|
438
|
+
meta.ready = false;
|
|
439
|
+
meta.lastUnreadyAt = nowIso();
|
|
440
|
+
noteHeartbeat(meta, String(payload.reason || 'worker-unready'));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
352
443
|
if (payload.type === 'worker.ping') {
|
|
353
|
-
meta
|
|
354
|
-
meta.
|
|
444
|
+
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
445
|
+
meta.lastSeenAtMs = nowMsSafe();
|
|
355
446
|
meta.lastPingAt = nowIso();
|
|
356
447
|
noteHeartbeat(meta, String(payload.reason || 'worker-ping'));
|
|
357
448
|
emitEvent(ws, {
|
|
@@ -362,23 +453,8 @@ export function createSessionWorkerWebSocketService({
|
|
|
362
453
|
});
|
|
363
454
|
}
|
|
364
455
|
});
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
clients.delete(ws);
|
|
368
|
-
clientState.delete(ws);
|
|
369
|
-
// Death-detection hook: a worker socket closing while its session owns
|
|
370
|
-
// an in-flight processing row used to be silently forgotten, leaving
|
|
371
|
-
// the row to the 600s stale sweep. The handler PID-probes and recovers.
|
|
372
|
-
if (meta?.sessionId) {
|
|
373
|
-
Promise.resolve(onWorkerSocketClosed({
|
|
374
|
-
sessionId: meta.sessionId,
|
|
375
|
-
pid: meta.pid || null,
|
|
376
|
-
reason,
|
|
377
|
-
})).catch(() => {});
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
ws.on('close', () => dropSocket('close'));
|
|
381
|
-
ws.on('error', () => dropSocket('error'));
|
|
456
|
+
ws.on('close', () => removeSocket(ws, 'close'));
|
|
457
|
+
ws.on('error', () => removeSocket(ws, 'error'));
|
|
382
458
|
}
|
|
383
459
|
|
|
384
460
|
function hasWorkerSocket(sessionId) {
|
|
@@ -390,6 +466,24 @@ export function createSessionWorkerWebSocketService({
|
|
|
390
466
|
return false;
|
|
391
467
|
}
|
|
392
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Socket-backed liveness for dead-worker recovery. Mere socket retention is
|
|
471
|
+
* not proof of life — a dead worker's socket lingers until its close event
|
|
472
|
+
* lands — so the socket must have been heard from within the liveness
|
|
473
|
+
* window, or its claimed PID must probe alive.
|
|
474
|
+
*/
|
|
475
|
+
function hasLiveWorkerSocket(sessionId) {
|
|
476
|
+
const wanted = normalizeText(sessionId);
|
|
477
|
+
if (!wanted) return false;
|
|
478
|
+
const cutoffMs = nowMsSafe() - WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS;
|
|
479
|
+
for (const meta of clientState.values()) {
|
|
480
|
+
if (meta?.sessionId !== wanted) continue;
|
|
481
|
+
if (Number(meta.lastSeenAtMs || 0) >= cutoffMs) return true;
|
|
482
|
+
if (meta.pid && isWorkerProcessAlive(meta.pid) === true) return true;
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
393
487
|
function start() {
|
|
394
488
|
if (!attached) {
|
|
395
489
|
httpServer.on('upgrade', onUpgrade);
|
|
@@ -479,5 +573,6 @@ export function createSessionWorkerWebSocketService({
|
|
|
479
573
|
handleUpgrade,
|
|
480
574
|
sendControlToSession,
|
|
481
575
|
hasWorkerSocket,
|
|
576
|
+
hasLiveWorkerSocket,
|
|
482
577
|
};
|
|
483
578
|
}
|
|
@@ -91,9 +91,10 @@ export function createStatusEventService(db, {
|
|
|
91
91
|
const normalizedConversationId = String(conversationId || '').trim();
|
|
92
92
|
const dedupeKey = `${token}:${ip}`;
|
|
93
93
|
const previousSeenAt = sharedAccessDedupe.get(dedupeKey);
|
|
94
|
-
sharedAccessDedupe.set(dedupeKey, now);
|
|
95
|
-
pruneSharedAccessDedupe(now);
|
|
96
94
|
if (Number.isFinite(previousSeenAt) && (now - previousSeenAt) <= normalizedDedupeTtlMs) {
|
|
95
|
+
// A dedupe hit slides the window — a persisted event already backs it.
|
|
96
|
+
sharedAccessDedupe.set(dedupeKey, now);
|
|
97
|
+
pruneSharedAccessDedupe(now);
|
|
97
98
|
return { event: null, deduped: true };
|
|
98
99
|
}
|
|
99
100
|
|
|
@@ -113,6 +114,11 @@ export function createStatusEventService(db, {
|
|
|
113
114
|
deleteExpiredEvents.run(normalizedMaxEvents);
|
|
114
115
|
});
|
|
115
116
|
persist();
|
|
117
|
+
// Committed only after the insert lands: marking the key first made a
|
|
118
|
+
// failed insert report the immediate retry as deduplicated with nothing
|
|
119
|
+
// persisted.
|
|
120
|
+
sharedAccessDedupe.set(dedupeKey, now);
|
|
121
|
+
pruneSharedAccessDedupe(now);
|
|
116
122
|
return { event, deduped: false };
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Resend of a steer a Stop cut off unanswered (kind='stopped'): shared by the
|
|
2
|
+
// POST /api/message guard and the transcript payload, so "Resent" on screen
|
|
3
|
+
// and the relay's refusal of a second Resend agree.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an earlier Resend still stands. A cancelled queued message loses
|
|
7
|
+
* its queue row; a failed one keeps it as 'failed'; a finished one may have
|
|
8
|
+
* been pruned from the queue, which its answer (linked by source) proves.
|
|
9
|
+
*/
|
|
10
|
+
export function isLiveResend({ hasQueueRow = false, queueStatus = null, answered = false } = {}) {
|
|
11
|
+
if (hasQueueRow) {
|
|
12
|
+
return !['failed', 'cancelled'].includes(String(queueStatus || '').trim().toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
return !!answered;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Original message id → the id of a Resend of it that still stands, from one
|
|
19
|
+
* conversation's messages (DB rows) and queue rows ({ id, status }).
|
|
20
|
+
*/
|
|
21
|
+
export function liveResendsByOriginal(dbMessages = [], queueRows = []) {
|
|
22
|
+
const queueById = new Map((Array.isArray(queueRows) ? queueRows : [])
|
|
23
|
+
.map((row) => [String(row?.id || '').trim(), row]));
|
|
24
|
+
const answeredSources = new Set();
|
|
25
|
+
for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
|
|
26
|
+
if (message?.role !== 'assistant') continue;
|
|
27
|
+
const source = String(message?.source_message_id || '').trim();
|
|
28
|
+
if (source) answeredSources.add(source);
|
|
29
|
+
}
|
|
30
|
+
for (const row of queueById.values()) {
|
|
31
|
+
if (row?.response_message_id) answeredSources.add(String(row.id || '').trim());
|
|
32
|
+
}
|
|
33
|
+
const result = new Map();
|
|
34
|
+
for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
|
|
35
|
+
if (message?.role !== 'user') continue;
|
|
36
|
+
const original = String(message?.resend_of_message_id || '').trim();
|
|
37
|
+
if (!original || result.has(original)) continue;
|
|
38
|
+
const id = String(message?.id || '').trim();
|
|
39
|
+
const queueRow = queueById.get(id);
|
|
40
|
+
if (isLiveResend({ hasQueueRow: !!queueRow, queueStatus: queueRow?.status, answered: answeredSources.has(id) })) {
|
|
41
|
+
result.set(original, id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|