@oar-sh/oar 0.9.9 → 0.9.11
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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -6,6 +6,8 @@ import { randomUUID } from 'crypto';
|
|
|
6
6
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
7
7
|
import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
|
|
8
8
|
import { fileVersionToken, serveFileWithRangeSupport, withFileVersion } from '../services/file-serving.mjs';
|
|
9
|
+
import { validateEntryName } from '../services/explorer-entry-name.mjs';
|
|
10
|
+
import { countEntries, createDirectory, removeEntry, renameEntry } from '../services/explorer-mutations.mjs';
|
|
9
11
|
import {
|
|
10
12
|
shouldParkForRestart,
|
|
11
13
|
parkPendingQueueForRestart,
|
|
@@ -39,14 +41,28 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
|
|
|
39
41
|
import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
|
|
40
42
|
import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
41
43
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
44
|
+
import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
42
45
|
import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
43
46
|
import { isLiveResend } from '../services/steer-resend.mjs';
|
|
44
47
|
import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
|
|
45
48
|
import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
|
|
46
49
|
import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
|
|
50
|
+
import {
|
|
51
|
+
createSubagentTimelineService,
|
|
52
|
+
mapSubagentRunHeader,
|
|
53
|
+
sanitizeRunHeaderPatch,
|
|
54
|
+
TIMELINE_READ_DEFAULT_LIMIT,
|
|
55
|
+
} from '../services/subagent-timeline-service.mjs';
|
|
56
|
+
import { normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
57
|
+
import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
|
|
58
|
+
import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
|
|
59
|
+
import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
|
|
60
|
+
import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
|
|
61
|
+
import { createLocalRequestCheck } from '../services/request-boundary.mjs';
|
|
47
62
|
import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
|
|
48
63
|
import {
|
|
49
64
|
admitRemoteRelayRequest,
|
|
65
|
+
readRemoteRelayRequest,
|
|
50
66
|
findMentionedRemoteRelays,
|
|
51
67
|
findOriginRemoteRelay,
|
|
52
68
|
formatRemoteRelayOriginHint,
|
|
@@ -1180,6 +1196,12 @@ export function buildDequeuedRelayMessage({
|
|
|
1180
1196
|
fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
|
|
1181
1197
|
})
|
|
1182
1198
|
: null;
|
|
1199
|
+
// An event message the relay wrote (relay event subscriptions): the
|
|
1200
|
+
// workers deliver it without the steer note, which speaks as the user.
|
|
1201
|
+
let messageKind = '';
|
|
1202
|
+
try {
|
|
1203
|
+
messageKind = String(stmts.getMessageByConversation?.get?.(msg.id, msg.conversation_id)?.kind || '').trim();
|
|
1204
|
+
} catch {}
|
|
1183
1205
|
return {
|
|
1184
1206
|
id: msg.id,
|
|
1185
1207
|
conversationId: msg.conversation_id,
|
|
@@ -1211,6 +1233,7 @@ export function buildDequeuedRelayMessage({
|
|
|
1211
1233
|
ownerAssignedAt: msg.owner_assigned_at || null,
|
|
1212
1234
|
ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
|
|
1213
1235
|
imageOperationId: String(msg.image_operation_id || '').trim() || null,
|
|
1236
|
+
...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
|
|
1214
1237
|
};
|
|
1215
1238
|
}
|
|
1216
1239
|
|
|
@@ -1755,6 +1778,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1755
1778
|
getModelCatalogState,
|
|
1756
1779
|
buildRelayReadyBannerData,
|
|
1757
1780
|
backgroundTaskStore,
|
|
1781
|
+
backgroundWorkView = null,
|
|
1758
1782
|
sendWorkerControl,
|
|
1759
1783
|
ensureSessionId,
|
|
1760
1784
|
touchCli,
|
|
@@ -1791,6 +1815,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1791
1815
|
relayRestartOrchestrator,
|
|
1792
1816
|
requestRelayShutdown,
|
|
1793
1817
|
cancelRelayShutdown = null,
|
|
1818
|
+
// Truly local = loopback peer, no tunnel marker, no forwarding header
|
|
1819
|
+
// (services/request-boundary.mjs). The runtime passes the check built
|
|
1820
|
+
// with the configured marker headers.
|
|
1821
|
+
isTrulyLocalRequest = createLocalRequestCheck().isTrulyLocal,
|
|
1794
1822
|
featureFlags,
|
|
1795
1823
|
sessionWorkerRegistry,
|
|
1796
1824
|
sessionWorkerSupervisor,
|
|
@@ -1813,9 +1841,93 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1813
1841
|
// agent-sent messages, the inbound switch, and mention unlocks. Absent in
|
|
1814
1842
|
// older wiring and most tests, where every remote-relay step is a no-op.
|
|
1815
1843
|
remoteRelayInbound = null,
|
|
1844
|
+
// Session groups (repositories/conversation-group-repository.mjs): the
|
|
1845
|
+
// group stop route. Absent in older wiring and most tests.
|
|
1846
|
+
conversationGroups = null,
|
|
1847
|
+
// Relay event subscriptions (services/relay-event-service.mjs) and the
|
|
1848
|
+
// per-session activity they read (services/session-activity-tracker.mjs).
|
|
1849
|
+
// Absent in older wiring and most tests: every hook is then a no-op.
|
|
1850
|
+
relayEventService = null,
|
|
1851
|
+
sessionActivityTracker = null,
|
|
1852
|
+
// A worker's SDK session id or a conversation id → the conversation row.
|
|
1853
|
+
resolveConversationRecord = null,
|
|
1816
1854
|
} = deps;
|
|
1817
1855
|
|
|
1818
1856
|
const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
|
|
1857
|
+
|
|
1858
|
+
// ─── Sub-agent runs: header, timeline, live events ─────────────────────────
|
|
1859
|
+
const subagentTimeline = createSubagentTimelineService({ db, stmts });
|
|
1860
|
+
|
|
1861
|
+
// The live background task a run belongs to (the panel's row), if any: the
|
|
1862
|
+
// task's spawning tool_use id is the run id.
|
|
1863
|
+
function liveTaskForSubagentRun(run) {
|
|
1864
|
+
const tasks = backgroundTaskStore?.get?.(run?.conversation_id) || [];
|
|
1865
|
+
const runId = String(run?.id || '');
|
|
1866
|
+
const taskId = String(run?.task_id || '');
|
|
1867
|
+
return tasks.find((task) => (task?.toolUseId && task.toolUseId === runId) || (taskId && task?.taskId === taskId)) || null;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Stop is offered while a run is running: a background run through its
|
|
1871
|
+
// task, a foreground run through the sub-agent cancel route.
|
|
1872
|
+
function isSubagentRunStoppable(run) {
|
|
1873
|
+
if (String(run?.status || '') !== 'running') return false;
|
|
1874
|
+
const task = liveTaskForSubagentRun(run);
|
|
1875
|
+
if (task) return task.stoppable !== false;
|
|
1876
|
+
return Number(run?.background || 0) !== 1;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Every subagent_status event carries the whole header and the run's own
|
|
1880
|
+
// turn, so a client can place it without having seen the run before.
|
|
1881
|
+
function subagentStatusEvent(run, extra = {}) {
|
|
1882
|
+
const header = mapSubagentRunHeader(run, { stoppable: isSubagentRunStoppable(run) });
|
|
1883
|
+
if (!header) return null;
|
|
1884
|
+
return { ...header, timestamp: new Date().toISOString(), ...extra };
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function emitSubagentStatus(run, extra = {}) {
|
|
1888
|
+
const event = subagentStatusEvent(run, extra);
|
|
1889
|
+
if (event) io.emit('subagent_status', event);
|
|
1890
|
+
try { sessionActivityTracker?.noteSubagentRun?.(run); } catch {}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Only to the pages whose viewer shows this run, and volatile: a batch a
|
|
1894
|
+
// connection cannot take is dropped (never kept for a reconnect replay);
|
|
1895
|
+
// the viewer sees the hole in `seq` and fetches it.
|
|
1896
|
+
const publishSubagentTimeline = createSubagentTimelinePublisher(io, deps.subagentTimelineDefer ? { defer: deps.subagentTimelineDefer } : {});
|
|
1897
|
+
function emitSubagentTimeline(run, entries) {
|
|
1898
|
+
if (!run || !entries?.length) return;
|
|
1899
|
+
publishSubagentTimeline({
|
|
1900
|
+
conversationId: String(run.conversation_id || ''),
|
|
1901
|
+
messageId: run.queue_message_id ? String(run.queue_message_id) : null,
|
|
1902
|
+
subagentRunId: String(run.id),
|
|
1903
|
+
entries,
|
|
1904
|
+
truncated: Number(run.truncated || 0) === 1,
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Copilot and Cursor publish their sub-agents on the live lanes only; the
|
|
1909
|
+
// relay copies each frame into the run's timeline (the Claude worker writes
|
|
1910
|
+
// its own and owns its runs' timelines).
|
|
1911
|
+
function mirrorSubagentFrame(frame) {
|
|
1912
|
+
if (!frame?.runId) return;
|
|
1913
|
+
try {
|
|
1914
|
+
const mirrored = subagentTimeline.mirrorFrame(frame);
|
|
1915
|
+
if (!mirrored) return;
|
|
1916
|
+
const run = mirrored.truncated ? (stmts.getSubagentRun?.get?.(frame.runId) || mirrored.run) : mirrored.run;
|
|
1917
|
+
if (mirrored.truncated) emitSubagentStatus(run);
|
|
1918
|
+
emitSubagentTimeline(run, mirrored.entries);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
console.warn(`[${ts()}] SUBAGENT MIRROR failed run=${String(frame.runId).slice(0, 12)}: ${error?.message || error}`);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function activityTimelineFrame(text) {
|
|
1925
|
+
const value = String(text || '');
|
|
1926
|
+
const toolMatch = /^Tool \(([^)]+)\)/.exec(value);
|
|
1927
|
+
if (toolMatch) return { kind: 'tool_call', toolName: toolMatch[1], text: value };
|
|
1928
|
+
if (/^Tool failed:/i.test(value)) return { kind: 'tool_result', text: value, metadata: { isError: true } };
|
|
1929
|
+
return { kind: 'note', text: value };
|
|
1930
|
+
}
|
|
1819
1931
|
const getMessageAttachmentsByConversation = db.prepare(`
|
|
1820
1932
|
SELECT attachments
|
|
1821
1933
|
FROM messages
|
|
@@ -1835,21 +1947,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1835
1947
|
|| event.includes('blocked');
|
|
1836
1948
|
}
|
|
1837
1949
|
|
|
1838
|
-
function isLoopbackAddress(value) {
|
|
1839
|
-
const text = String(value || '').trim().toLowerCase();
|
|
1840
|
-
if (!text) return false;
|
|
1841
|
-
return text === '127.0.0.1'
|
|
1842
|
-
|| text === '::1'
|
|
1843
|
-
|| text === '::ffff:127.0.0.1'
|
|
1844
|
-
|| text === '::ffff:7f00:1';
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
function isLoopbackRequest(req) {
|
|
1848
|
-
const socketAddress = req?.socket?.remoteAddress;
|
|
1849
|
-
const ipAddress = req?.ip;
|
|
1850
|
-
return isLoopbackAddress(socketAddress) || isLoopbackAddress(ipAddress);
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
1950
|
function readBridgeIdentity(req) {
|
|
1854
1951
|
return relayBridgeOwnerService?.normalizeIdentity?.({
|
|
1855
1952
|
pid: req.headers['x-relay-process-pid'],
|
|
@@ -1999,25 +2096,28 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1999
2096
|
};
|
|
2000
2097
|
}
|
|
2001
2098
|
|
|
2099
|
+
// One warning per superseded attempt: a worker streaming for a requeued
|
|
2100
|
+
// row writes several times a second until it learns of the refusal.
|
|
2101
|
+
const staleAttemptLog = createStaleAttemptLogLimiter({ warn: (line) => console.warn(`[${ts()}] ${line}`) });
|
|
2102
|
+
|
|
2002
2103
|
function rejectSessionBinding(res, status, error, extra = {}) {
|
|
2003
2104
|
return res.status(status).json({ error, ...extra });
|
|
2004
2105
|
}
|
|
2005
2106
|
|
|
2006
2107
|
function reconcileSubagentRunsForTerminalTurn(messageId, { status = 'failed' } = {}) {
|
|
2007
|
-
|
|
2108
|
+
// A background run outlives the turn that spawned it: its worker reports
|
|
2109
|
+
// its end (task_notification), and a dead worker's runs are closed by the
|
|
2110
|
+
// worker-death sweep. Closing it here marked live agents failed.
|
|
2111
|
+
const running = (stmts.listRunningSubagentRunsByQueueMessage?.all?.(messageId) || [])
|
|
2112
|
+
.filter((run) => Number(run.background || 0) !== 1 && !liveTaskForSubagentRun(run));
|
|
2008
2113
|
if (!running.length) return 0;
|
|
2009
2114
|
const now = new Date().toISOString();
|
|
2010
|
-
stmts.closeRunningSubagentRunsByQueueMessage?.run?.(status, now, now, messageId);
|
|
2011
2115
|
for (const run of running) {
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
status,
|
|
2018
|
-
queueMessageId: messageId,
|
|
2019
|
-
reconciled: true,
|
|
2020
|
-
});
|
|
2116
|
+
stmts.updateSubagentRunStatus?.run?.(status, now, now, run.id);
|
|
2117
|
+
const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status, completed_at: now };
|
|
2118
|
+
// messageId is the run's own turn: the browser drops a status event it
|
|
2119
|
+
// cannot place, which left these runs showing "running" until a reload.
|
|
2120
|
+
emitSubagentStatus(closed, { queueMessageId: messageId, reconciled: true });
|
|
2021
2121
|
}
|
|
2022
2122
|
return running.length;
|
|
2023
2123
|
}
|
|
@@ -2104,6 +2204,19 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2104
2204
|
});
|
|
2105
2205
|
io.emit('message_status', { messageId, conversationId, status: 'failed' });
|
|
2106
2206
|
void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, sourceMessageId: messageId, text: responseText });
|
|
2207
|
+
// A Stop from the relay ends the turn; anything else failed it.
|
|
2208
|
+
try {
|
|
2209
|
+
if (String(failureRecord?.code || '') === 'turn-aborted') {
|
|
2210
|
+
relayEventService?.noteTurnEnd?.({ conversationId, queueMessageId: messageId, status: 'stopped' });
|
|
2211
|
+
} else {
|
|
2212
|
+
relayEventService?.noteError?.({
|
|
2213
|
+
conversationId,
|
|
2214
|
+
kind: 'turn_failed',
|
|
2215
|
+
message: failureRecord?.message || failureRecord?.error || responseText,
|
|
2216
|
+
queueMessageId: messageId,
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
} catch {}
|
|
2107
2220
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
2108
2221
|
const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
|
|
2109
2222
|
? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
|
|
@@ -2849,36 +2962,54 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2849
2962
|
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
2850
2963
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
2851
2964
|
const requestedMessageId = String(req.body?.messageId || req.body?.queueMessageId || '').trim();
|
|
2965
|
+
const outcome = requestTurnStop(conversationId, {
|
|
2966
|
+
requestedMessageId,
|
|
2967
|
+
clientId: String(req.body?.clientId || '').trim() || null,
|
|
2968
|
+
});
|
|
2969
|
+
if (!outcome.ok) return rejectSessionBinding(res, outcome.status, outcome.error);
|
|
2970
|
+
return res.json(outcome.body);
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
/**
|
|
2974
|
+
* The work of POST /api/conversation/:id/cancel-turn: an `abort_turn`
|
|
2975
|
+
* control for the live turn. `{ ok: true, body }` with the route's answer,
|
|
2976
|
+
* or `{ ok: false, status, error }` when the conversation is unknown.
|
|
2977
|
+
*/
|
|
2978
|
+
function requestTurnStop(conversationId, { requestedMessageId = '', clientId = null } = {}) {
|
|
2852
2979
|
const sessionState = getConversationSessionState(conversationId);
|
|
2853
|
-
if (!sessionState.ok) {
|
|
2854
|
-
return rejectSessionBinding(res, sessionState.status, sessionState.error);
|
|
2855
|
-
}
|
|
2980
|
+
if (!sessionState.ok) return { ok: false, status: sessionState.status, error: sessionState.error };
|
|
2856
2981
|
|
|
2857
2982
|
// The live turn, not merely the newest processing row: a steered message
|
|
2858
2983
|
// folded into the running turn is also `processing`, and Stop must target
|
|
2859
2984
|
// the turn actually executing (which the worker interrupts as a whole).
|
|
2860
2985
|
const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
|
|
2861
2986
|
if (!queueRow) {
|
|
2862
|
-
return
|
|
2987
|
+
return {
|
|
2863
2988
|
ok: true,
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2989
|
+
body: {
|
|
2990
|
+
ok: true,
|
|
2991
|
+
queued: false,
|
|
2992
|
+
acknowledgement: 'no-active-turn',
|
|
2993
|
+
requestedMessageId: requestedMessageId || null,
|
|
2994
|
+
activeMessageId: null,
|
|
2995
|
+
},
|
|
2996
|
+
};
|
|
2869
2997
|
}
|
|
2870
2998
|
|
|
2871
2999
|
const activeMessageId = String(queueRow.id || '').trim();
|
|
2872
3000
|
if (requestedMessageId && activeMessageId && requestedMessageId !== activeMessageId) {
|
|
2873
3001
|
const requestedRow = stmts.findQById.get(requestedMessageId) || null;
|
|
2874
3002
|
const requestedStatus = String(requestedRow?.status || '').trim().toLowerCase();
|
|
2875
|
-
return
|
|
3003
|
+
return {
|
|
2876
3004
|
ok: true,
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
3005
|
+
body: {
|
|
3006
|
+
ok: true,
|
|
3007
|
+
queued: false,
|
|
3008
|
+
acknowledgement: requestedStatus === 'processing' ? 'message-mismatch' : 'already-finished',
|
|
3009
|
+
requestedMessageId,
|
|
3010
|
+
activeMessageId,
|
|
3011
|
+
},
|
|
3012
|
+
};
|
|
2882
3013
|
}
|
|
2883
3014
|
|
|
2884
3015
|
const sdkSessionId = normalizeSessionWorkerId(
|
|
@@ -2887,33 +3018,39 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2887
3018
|
|| sessionState.conversationSdkSessionId,
|
|
2888
3019
|
);
|
|
2889
3020
|
if (!sdkSessionId) {
|
|
2890
|
-
return
|
|
3021
|
+
return {
|
|
2891
3022
|
ok: true,
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
3023
|
+
body: {
|
|
3024
|
+
ok: true,
|
|
3025
|
+
queued: false,
|
|
3026
|
+
acknowledgement: 'active-turn-unbound',
|
|
3027
|
+
requestedMessageId: activeMessageId || requestedMessageId || null,
|
|
3028
|
+
activeMessageId,
|
|
3029
|
+
},
|
|
3030
|
+
};
|
|
2897
3031
|
}
|
|
2898
3032
|
|
|
2899
3033
|
const existing = findActiveRelayControlByQueueMessage.get(activeMessageId, 'abort_turn') || null;
|
|
2900
3034
|
if (existing) {
|
|
2901
|
-
return
|
|
3035
|
+
return {
|
|
2902
3036
|
ok: true,
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
3037
|
+
body: {
|
|
3038
|
+
ok: true,
|
|
3039
|
+
queued: true,
|
|
3040
|
+
duplicate: true,
|
|
3041
|
+
acknowledgement: 'already-requested',
|
|
3042
|
+
requestedMessageId: activeMessageId || requestedMessageId || null,
|
|
3043
|
+
activeMessageId,
|
|
3044
|
+
control: formatRelayControlResponse(existing),
|
|
3045
|
+
},
|
|
3046
|
+
};
|
|
2910
3047
|
}
|
|
2911
3048
|
|
|
2912
3049
|
const now = new Date().toISOString();
|
|
2913
3050
|
const controlId = uuidv4();
|
|
2914
3051
|
const requestPayload = JSON.stringify({
|
|
2915
3052
|
source: 'relay-ui',
|
|
2916
|
-
requestedByClientId:
|
|
3053
|
+
requestedByClientId: clientId || null,
|
|
2917
3054
|
});
|
|
2918
3055
|
insertRelayControlRequest.run(
|
|
2919
3056
|
controlId,
|
|
@@ -2926,14 +3063,59 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2926
3063
|
now,
|
|
2927
3064
|
);
|
|
2928
3065
|
const control = getRelayControlRequestById.get(controlId) || null;
|
|
2929
|
-
return
|
|
3066
|
+
return {
|
|
2930
3067
|
ok: true,
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
3068
|
+
body: {
|
|
3069
|
+
ok: true,
|
|
3070
|
+
queued: true,
|
|
3071
|
+
acknowledgement: 'stop-queued',
|
|
3072
|
+
requestedMessageId: activeMessageId || requestedMessageId || null,
|
|
3073
|
+
activeMessageId,
|
|
3074
|
+
control: formatRelayControlResponse(control),
|
|
3075
|
+
},
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
// POST /api/conversation-groups/:id/stop — "Stop all running" on a session
|
|
3080
|
+
// group's head row: a stop for every member's live turn, and a stop for
|
|
3081
|
+
// every stoppable background task. Tasks the panel shows no Stop button
|
|
3082
|
+
// for are listed as `unstoppable`. The owner's action only: a paired
|
|
3083
|
+
// relay's agent is refused.
|
|
3084
|
+
app.post('/api/conversation-groups/:id/stop', auth, (req, res) => {
|
|
3085
|
+
if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
|
|
3086
|
+
return res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
|
|
3087
|
+
}
|
|
3088
|
+
const groupId = String(req.params.id || '').trim();
|
|
3089
|
+
const group = groupId ? conversationGroups?.getGroup?.(groupId) : null;
|
|
3090
|
+
if (!group) return res.status(404).json({ ok: false, error: 'Group not found' });
|
|
3091
|
+
const clientId = String(req.body?.clientId || '').trim() || null;
|
|
3092
|
+
const turns = [];
|
|
3093
|
+
const tasks = [];
|
|
3094
|
+
const unstoppable = [];
|
|
3095
|
+
for (const member of conversationGroups.membersOf(groupId)) {
|
|
3096
|
+
if (member.archived) continue;
|
|
3097
|
+
const outcome = requestTurnStop(member.id, { clientId });
|
|
3098
|
+
if (outcome.ok && outcome.body.queued === true) turns.push({ id: member.id, messageId: outcome.body.activeMessageId });
|
|
3099
|
+
const conversation = stmts.getConvAnyStatus?.get?.(member.id) || null;
|
|
3100
|
+
const sessionId = String(conversation?.sdk_session_id || member.id);
|
|
3101
|
+
const liveTasks = [...new Set([member.id, sessionId])].flatMap((key) => backgroundTaskStore?.get?.(key) || []);
|
|
3102
|
+
for (const task of liveTasks) {
|
|
3103
|
+
// The store holds what the publish route keeps: `taskId`.
|
|
3104
|
+
const taskId = String(task?.taskId || '').trim();
|
|
3105
|
+
if (!taskId) continue;
|
|
3106
|
+
if (task?.stoppable === false) {
|
|
3107
|
+
unstoppable.push({ id: member.id, taskId, reason: 'no-stop' });
|
|
3108
|
+
continue;
|
|
3109
|
+
}
|
|
3110
|
+
const sent = sendWorkerControl?.(sessionId, { type: 'stop_background_task', taskId });
|
|
3111
|
+
if (sent) tasks.push({ id: member.id, taskId });
|
|
3112
|
+
else unstoppable.push({ id: member.id, taskId, reason: 'worker-not-connected' });
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
if (turns.length || tasks.length) {
|
|
3116
|
+
console.log(`[${ts()}] GROUP STOP ${groupId.slice(0, 12)} turns=${turns.length} tasks=${tasks.length}`);
|
|
3117
|
+
}
|
|
3118
|
+
return res.json({ ok: true, turns, tasks, unstoppable });
|
|
2937
3119
|
});
|
|
2938
3120
|
|
|
2939
3121
|
app.post('/api/conversation/:conversationId/subagent/:subagentRunId/cancel', auth, (req, res) => {
|
|
@@ -3825,6 +4007,137 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3825
4007
|
});
|
|
3826
4008
|
});
|
|
3827
4009
|
|
|
4010
|
+
// ---------------------------------------------------------------------------
|
|
4011
|
+
// Explorer changes: new folder, rename, delete. Two routes per operation,
|
|
4012
|
+
// one for the conversation's workspace (relative paths, resolved exactly like
|
|
4013
|
+
// `/api/files/*`) and one for the drives and session roots (absolute paths,
|
|
4014
|
+
// checked exactly like `/api/drives/list`). Each write route therefore
|
|
4015
|
+
// reaches nothing its listing route would not show.
|
|
4016
|
+
// ---------------------------------------------------------------------------
|
|
4017
|
+
|
|
4018
|
+
function workspaceWebPath(relativePath) {
|
|
4019
|
+
return String(relativePath || '').split(path.sep).join('/');
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
// Resolve a workspace entry from a request body: `{ absolutePath, relative }`
|
|
4023
|
+
// or `null` after answering the request with the error.
|
|
4024
|
+
function resolveWorkspaceMutationTarget(req, res, rawRelativePath) {
|
|
4025
|
+
const relative = normalizeWorkspaceRelativePath(rawRelativePath);
|
|
4026
|
+
if (!relative) {
|
|
4027
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
4028
|
+
return null;
|
|
4029
|
+
}
|
|
4030
|
+
const absolutePath = resolveWorkspaceFilePath(relative, resolveScopedWorkspaceRootPath(req));
|
|
4031
|
+
if (!absolutePath) {
|
|
4032
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
4033
|
+
return null;
|
|
4034
|
+
}
|
|
4035
|
+
return { absolutePath, relative };
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
// Resolve an absolute drives/session path from a request body and hand it
|
|
4039
|
+
// on, with the function that turns an absolute path into the explorer's
|
|
4040
|
+
// path form for the answer. Answers the request itself on a bad path.
|
|
4041
|
+
function withDriveMutationTarget(req, res, rawPath, { allowRoot = false } = {}, next) {
|
|
4042
|
+
const requestedPath = String(rawPath || '').trim();
|
|
4043
|
+
if (process.platform !== 'win32') {
|
|
4044
|
+
const absolutePath = normalizeLinuxAbsolutePath(requestedPath);
|
|
4045
|
+
if (!absolutePath) return res.status(400).json({ error: 'Invalid Linux path' });
|
|
4046
|
+
if (absolutePath === '/' && !allowRoot) return res.status(400).json({ error: 'The root folder cannot be changed' });
|
|
4047
|
+
return next(absolutePath, (value) => value);
|
|
4048
|
+
}
|
|
4049
|
+
return fetchBrowsableDrives((drivesErr, drives) => {
|
|
4050
|
+
if (drivesErr) return res.status(500).json({ error: drivesErr.message || 'Failed to enumerate drives' });
|
|
4051
|
+
const allowedRoots = new Set(drives.map((drive) => drive.rootAbsolute.toUpperCase()));
|
|
4052
|
+
const absolutePath = normalizeDriveAbsolutePath(requestedPath);
|
|
4053
|
+
const rootAbsolute = driveRootFromAbsolutePath(absolutePath).toUpperCase();
|
|
4054
|
+
if (!absolutePath || !rootAbsolute || !allowedRoots.has(rootAbsolute)) {
|
|
4055
|
+
return res.status(400).json({ error: 'Invalid drive path' });
|
|
4056
|
+
}
|
|
4057
|
+
if (absolutePath.toUpperCase() === rootAbsolute && !allowRoot) {
|
|
4058
|
+
return res.status(400).json({ error: 'A drive root cannot be changed' });
|
|
4059
|
+
}
|
|
4060
|
+
return next(absolutePath, toDriveWebPath);
|
|
4061
|
+
});
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
function validatedName(res, rawName) {
|
|
4065
|
+
const check = validateEntryName(rawName);
|
|
4066
|
+
if (!check.ok) {
|
|
4067
|
+
res.status(400).json({ error: check.error });
|
|
4068
|
+
return null;
|
|
4069
|
+
}
|
|
4070
|
+
return check.name;
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
function sendMutationResult(res, result, extra = {}) {
|
|
4074
|
+
if (!result?.ok) return res.status(Number(result?.statusCode) || 500).json({ error: result?.error || 'Request failed' });
|
|
4075
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
4076
|
+
return res.json({ ok: true, ...extra });
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
app.post('/api/repo/mkdir', auth, (req, res) => {
|
|
4080
|
+
const name = validatedName(res, req.body?.name);
|
|
4081
|
+
if (!name) return;
|
|
4082
|
+
const parentRelative = normalizeWorkspaceRelativePath(req.body?.path);
|
|
4083
|
+
if (String(req.body?.path || '').trim() && !parentRelative) return res.status(400).json({ error: 'Invalid path' });
|
|
4084
|
+
const target = resolveWorkspaceMutationTarget(req, res, parentRelative ? `${workspaceWebPath(parentRelative)}/${name}` : name);
|
|
4085
|
+
if (!target) return;
|
|
4086
|
+
return sendMutationResult(res, createDirectory(target.absolutePath), { path: workspaceWebPath(target.relative) });
|
|
4087
|
+
});
|
|
4088
|
+
|
|
4089
|
+
app.post('/api/repo/rename', auth, (req, res) => {
|
|
4090
|
+
const newName = validatedName(res, req.body?.newName);
|
|
4091
|
+
if (!newName) return;
|
|
4092
|
+
const source = resolveWorkspaceMutationTarget(req, res, req.body?.path);
|
|
4093
|
+
if (!source) return;
|
|
4094
|
+
const parentWeb = workspaceWebPath(path.dirname(source.relative));
|
|
4095
|
+
const destinationWeb = parentWeb && parentWeb !== '.' ? `${parentWeb}/${newName}` : newName;
|
|
4096
|
+
// The destination goes through the same resolver: a new name can never
|
|
4097
|
+
// leave the workspace or land on the root.
|
|
4098
|
+
const destination = resolveWorkspaceMutationTarget(req, res, destinationWeb);
|
|
4099
|
+
if (!destination) return;
|
|
4100
|
+
return sendMutationResult(res, renameEntry(source.absolutePath, newName), { path: workspaceWebPath(destination.relative) });
|
|
4101
|
+
});
|
|
4102
|
+
|
|
4103
|
+
app.post('/api/repo/delete', auth, (req, res) => {
|
|
4104
|
+
const target = resolveWorkspaceMutationTarget(req, res, req.body?.path);
|
|
4105
|
+
if (!target) return;
|
|
4106
|
+
if (req.body?.dryRun === true) {
|
|
4107
|
+
const counted = countEntries(target.absolutePath);
|
|
4108
|
+
return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
|
|
4109
|
+
}
|
|
4110
|
+
return sendMutationResult(res, removeEntry(target.absolutePath));
|
|
4111
|
+
});
|
|
4112
|
+
|
|
4113
|
+
app.post('/api/drives/mkdir', auth, (req, res) => {
|
|
4114
|
+
const name = validatedName(res, req.body?.name);
|
|
4115
|
+
if (!name) return;
|
|
4116
|
+
withDriveMutationTarget(req, res, req.body?.path, { allowRoot: true }, (parentAbsolute, webPathOf) => {
|
|
4117
|
+
const targetAbsolute = path.join(parentAbsolute, name);
|
|
4118
|
+
return sendMutationResult(res, createDirectory(targetAbsolute), { path: webPathOf(targetAbsolute) });
|
|
4119
|
+
});
|
|
4120
|
+
});
|
|
4121
|
+
|
|
4122
|
+
app.post('/api/drives/rename', auth, (req, res) => {
|
|
4123
|
+
const newName = validatedName(res, req.body?.newName);
|
|
4124
|
+
if (!newName) return;
|
|
4125
|
+
withDriveMutationTarget(req, res, req.body?.path, {}, (sourceAbsolute, webPathOf) => {
|
|
4126
|
+
const result = renameEntry(sourceAbsolute, newName);
|
|
4127
|
+
return sendMutationResult(res, result, result.ok ? { path: webPathOf(result.path) } : {});
|
|
4128
|
+
});
|
|
4129
|
+
});
|
|
4130
|
+
|
|
4131
|
+
app.post('/api/drives/delete', auth, (req, res) => {
|
|
4132
|
+
withDriveMutationTarget(req, res, req.body?.path, {}, (targetAbsolute) => {
|
|
4133
|
+
if (req.body?.dryRun === true) {
|
|
4134
|
+
const counted = countEntries(targetAbsolute);
|
|
4135
|
+
return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
|
|
4136
|
+
}
|
|
4137
|
+
return sendMutationResult(res, removeEntry(targetAbsolute));
|
|
4138
|
+
});
|
|
4139
|
+
});
|
|
4140
|
+
|
|
3828
4141
|
app.get('/api/drives/file', auth, (req, res) => {
|
|
3829
4142
|
const requestedPath = String(req.query.path || '').trim();
|
|
3830
4143
|
|
|
@@ -5065,6 +5378,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5065
5378
|
const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
|
|
5066
5379
|
const settleFailedHandled = [];
|
|
5067
5380
|
const settleFailedSkipped = [];
|
|
5381
|
+
let resyncBackgroundTasks = false;
|
|
5068
5382
|
const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
|
|
5069
5383
|
// A persistent-process worker can hold several live rows at once (the
|
|
5070
5384
|
// running turn plus a delivered message queued behind it plus a
|
|
@@ -5131,6 +5445,32 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5131
5445
|
}
|
|
5132
5446
|
}
|
|
5133
5447
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5448
|
+
// The worker's digest of its live background-task ids against the set
|
|
5449
|
+
// held for it (keyed as the worker posts, by its session id). They
|
|
5450
|
+
// differ after a relay restart, which starts with no sets, and after a
|
|
5451
|
+
// lost post either way round: the answer then asks for the full set
|
|
5452
|
+
// again. A worker that sends no digest has no opinion and is never
|
|
5453
|
+
// asked; a relay that holds a set while the worker reports none asks
|
|
5454
|
+
// too, and the empty answer ends those tasks.
|
|
5455
|
+
// The process this beat comes from holds the session: a set post of
|
|
5456
|
+
// another process is a late one (see the task route).
|
|
5457
|
+
backgroundTaskStore?.noteHolder?.(requesterSessionId, normalizedRequesterPid);
|
|
5458
|
+
const taskDigest = normalizeBackgroundTaskDigest(req.body?.backgroundTasks);
|
|
5459
|
+
if (taskDigest && typeof backgroundTaskStore?.matches === 'function') {
|
|
5460
|
+
resyncBackgroundTasks = !backgroundTaskStore.matches(taskDigest, requesterSessionId, requesterConversationId);
|
|
5461
|
+
}
|
|
5462
|
+
// The placeholder for background work this runtime was never told the
|
|
5463
|
+
// details of (a worker that cannot resync yet): the pages hear when it
|
|
5464
|
+
// appears and when it goes without a set having replaced it.
|
|
5465
|
+
if (!resyncBackgroundTasks && backgroundWorkView) {
|
|
5466
|
+
const unreported = backgroundWorkView.hasUnreportedWork(requesterSessionId) === true;
|
|
5467
|
+
if (backgroundTaskStore?.announce?.(requesterSessionId, unreported)) {
|
|
5468
|
+
io.emit('background_tasks', {
|
|
5469
|
+
conversationId: requesterSessionId,
|
|
5470
|
+
tasks: backgroundWorkView.panelTasks(requesterSessionId, requesterConversationId),
|
|
5471
|
+
});
|
|
5472
|
+
}
|
|
5473
|
+
}
|
|
5134
5474
|
// Consumed steers whose settle the worker could not save within its
|
|
5135
5475
|
// retry budget (e.g. its writes keep failing): the relay fails them
|
|
5136
5476
|
// itself with the worker's terminal error, never requeueing them.
|
|
@@ -5190,6 +5530,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5190
5530
|
pendingCount,
|
|
5191
5531
|
...(settleFailedHandled.length ? { settleFailedHandled } : {}),
|
|
5192
5532
|
...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
|
|
5533
|
+
...(resyncBackgroundTasks ? { resyncBackgroundTasks: true } : {}),
|
|
5193
5534
|
});
|
|
5194
5535
|
});
|
|
5195
5536
|
|
|
@@ -5249,7 +5590,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5249
5590
|
}
|
|
5250
5591
|
const rowAttemptId = String(row.attempt_id || '').trim() || null;
|
|
5251
5592
|
if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
|
|
5252
|
-
|
|
5593
|
+
staleAttemptLog.note({ messageId, attemptId, currentAttemptId: rowAttemptId, route: 'queue-cancelled', status: row.status });
|
|
5253
5594
|
return res.status(409).json({ error: 'stale_attempt' });
|
|
5254
5595
|
}
|
|
5255
5596
|
|
|
@@ -5704,6 +6045,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5704
6045
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5705
6046
|
}
|
|
5706
6047
|
if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
|
|
6048
|
+
// A queued shutdown or restart waits for the processing turns only: a
|
|
6049
|
+
// pending row handed out now would start a turn that postpones it again.
|
|
6050
|
+
if (isRelayShutdownQueued(runtimeState)) return res.json(relayShutdownGateReply(runtimeState));
|
|
5707
6051
|
if (runtimeState.tunnelState?.blocking) {
|
|
5708
6052
|
return res.json({
|
|
5709
6053
|
message: null,
|
|
@@ -6170,10 +6514,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6170
6514
|
return res.json({ ok: true, recovered: rows.length, maxAgeMs });
|
|
6171
6515
|
});
|
|
6172
6516
|
|
|
6517
|
+
// Emptying the queue, restarting the relay and withdrawing a restart are
|
|
6518
|
+
// the user's own maintenance actions: the browser app calls them from a
|
|
6519
|
+
// phone, over the tunnel and over the LAN. The token is the boundary.
|
|
6173
6520
|
app.post('/api/queue/empty', auth, (req, res) => {
|
|
6174
|
-
if (!isLoopbackRequest(req)) {
|
|
6175
|
-
return res.status(403).json({ error: 'Queue empty endpoint is localhost-only' });
|
|
6176
|
-
}
|
|
6177
6521
|
const rows = stmts.listQueueForPauseDrop.all();
|
|
6178
6522
|
const droppedCount = rows.length;
|
|
6179
6523
|
if (!droppedCount) {
|
|
@@ -6201,9 +6545,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6201
6545
|
});
|
|
6202
6546
|
|
|
6203
6547
|
app.post('/api/relay/shutdown', auth, (req, res) => {
|
|
6204
|
-
if (!isLoopbackRequest(req)) {
|
|
6205
|
-
return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
|
|
6206
|
-
}
|
|
6207
6548
|
if (typeof requestRelayShutdown !== 'function') {
|
|
6208
6549
|
return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
|
|
6209
6550
|
}
|
|
@@ -6221,9 +6562,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6221
6562
|
// Withdraw a queued (not yet started) shutdown/restart. Same trust boundary
|
|
6222
6563
|
// as queueing one.
|
|
6223
6564
|
app.post('/api/relay/shutdown/cancel', auth, (req, res) => {
|
|
6224
|
-
if (!isLoopbackRequest(req)) {
|
|
6225
|
-
return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
|
|
6226
|
-
}
|
|
6227
6565
|
if (typeof cancelRelayShutdown !== 'function') {
|
|
6228
6566
|
return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
|
|
6229
6567
|
}
|
|
@@ -6232,9 +6570,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6232
6570
|
return res.json({ ok: true, ...result });
|
|
6233
6571
|
});
|
|
6234
6572
|
|
|
6573
|
+
// Called by the relay's own worker on this machine; nothing that came in
|
|
6574
|
+
// through a proxy or the tunnel may start a provider image request.
|
|
6235
6575
|
app.post('/api/image-operations/:operationId/execute', auth, async (req, res) => {
|
|
6236
|
-
if (!
|
|
6237
|
-
return res.status(403).json({ error: 'Image operation execution is
|
|
6576
|
+
if (!isTrulyLocalRequest(req)) {
|
|
6577
|
+
return res.status(403).json({ error: 'Image operation execution is reachable from this machine only' });
|
|
6238
6578
|
}
|
|
6239
6579
|
if (featureFlags?.IMAGE_CONVERSATION_CONTINUITY_ENABLED !== true) {
|
|
6240
6580
|
return res.status(404).json({ error: 'Image continuity is not enabled' });
|
|
@@ -6689,7 +7029,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6689
7029
|
// the legacy conditional semantics.
|
|
6690
7030
|
const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
|
|
6691
7031
|
if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
|
|
6692
|
-
|
|
7032
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'response', status: q.status });
|
|
6693
7033
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: q.status });
|
|
6694
7034
|
}
|
|
6695
7035
|
if (!claimedAttemptId && q && q.status === 'processing') {
|
|
@@ -6782,7 +7122,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6782
7122
|
if (!failed) {
|
|
6783
7123
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
6784
7124
|
if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
|
|
6785
|
-
|
|
7125
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-terminal', status: currentRow.status });
|
|
6786
7126
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: String(currentRow.status || 'unknown') });
|
|
6787
7127
|
}
|
|
6788
7128
|
console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing`);
|
|
@@ -6998,7 +7338,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6998
7338
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
6999
7339
|
const currentStatus = String(currentRow?.status || 'unknown');
|
|
7000
7340
|
if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
|
|
7001
|
-
|
|
7341
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-finalize', status: currentStatus });
|
|
7002
7342
|
return res.status(409).json({ error: 'stale_attempt', currentStatus });
|
|
7003
7343
|
}
|
|
7004
7344
|
console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing actual_status=${currentStatus}`);
|
|
@@ -7174,6 +7514,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7174
7514
|
sourceMessageId: messageId,
|
|
7175
7515
|
text: resolvedText,
|
|
7176
7516
|
});
|
|
7517
|
+
// A turn paused at the usage limit did not end: it is an error event
|
|
7518
|
+
// for whoever watches the session.
|
|
7519
|
+
try {
|
|
7520
|
+
if (usageLimitPause) {
|
|
7521
|
+
relayEventService?.noteError?.({
|
|
7522
|
+
conversationId: targetConversationId,
|
|
7523
|
+
kind: 'usage_limit',
|
|
7524
|
+
message: usageLimitPause.noteText,
|
|
7525
|
+
queueMessageId: messageId,
|
|
7526
|
+
});
|
|
7527
|
+
} else {
|
|
7528
|
+
relayEventService?.noteTurnEnd?.({
|
|
7529
|
+
conversationId: targetConversationId,
|
|
7530
|
+
queueMessageId: messageId,
|
|
7531
|
+
replyText: resolvedText,
|
|
7532
|
+
status: 'done',
|
|
7533
|
+
});
|
|
7534
|
+
}
|
|
7535
|
+
} catch {}
|
|
7177
7536
|
}
|
|
7178
7537
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
7179
7538
|
res.json({ ok: true });
|
|
@@ -7270,9 +7629,53 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7270
7629
|
res.json({ ok: true, messageId, conversationId, attemptId });
|
|
7271
7630
|
});
|
|
7272
7631
|
|
|
7632
|
+
function resolveTaskConversationId(id) {
|
|
7633
|
+
const key = String(id || '').trim();
|
|
7634
|
+
if (!key) return '';
|
|
7635
|
+
try {
|
|
7636
|
+
return String(resolveConversationRecord?.({ conversationId: key, sdkSessionId: key })?.id || key);
|
|
7637
|
+
} catch {
|
|
7638
|
+
return key;
|
|
7639
|
+
}
|
|
7640
|
+
}
|
|
7641
|
+
|
|
7642
|
+
// POST /api/background-task-settled — how a background task ended, where
|
|
7643
|
+
// the worker learns it (the Claude worker's task_notification): status,
|
|
7644
|
+
// summary, and the reason for an unusual end. The task leaves the live set
|
|
7645
|
+
// on its own; this only says how it ended.
|
|
7646
|
+
app.post('/api/background-task-settled', auth, (req, res) => {
|
|
7647
|
+
touchCli();
|
|
7648
|
+
const conversationId = resolveTaskConversationId(req.body?.conversationId);
|
|
7649
|
+
const taskId = String(req.body?.taskId || '').trim();
|
|
7650
|
+
if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
7651
|
+
const exitCode = Number(req.body?.exitCode);
|
|
7652
|
+
const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
|
|
7653
|
+
sessionActivityTracker?.noteTaskSettled?.(conversationId, {
|
|
7654
|
+
taskId,
|
|
7655
|
+
toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
|
|
7656
|
+
status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
|
|
7657
|
+
summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
|
|
7658
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
7659
|
+
// What the task was, from a worker that sends it: the end of a task
|
|
7660
|
+
// this runtime never knew (it ended while the relay was away) is
|
|
7661
|
+
// still an event then.
|
|
7662
|
+
...(settledDescription ? {
|
|
7663
|
+
description: settledDescription,
|
|
7664
|
+
taskType: String(req.body?.taskType || '').trim().slice(0, 80),
|
|
7665
|
+
startedAt: Number(req.body?.startedAt) || null,
|
|
7666
|
+
} : {}),
|
|
7667
|
+
});
|
|
7668
|
+
relayEventService?.afterStateChange?.(conversationId);
|
|
7669
|
+
res.json({ ok: true });
|
|
7670
|
+
});
|
|
7671
|
+
|
|
7273
7672
|
// POST /api/background-tasks — a session worker publishes its live
|
|
7274
7673
|
// background-task set (REPLACE semantics, mirroring the SDK's
|
|
7275
7674
|
// background_tasks_changed signal) so the composer panel tracks it live.
|
|
7675
|
+
// `resync: true` marks the set as the answer to a heartbeat that asked for
|
|
7676
|
+
// it again (`resyncBackgroundTasks`): the same replace, and the pages get
|
|
7677
|
+
// the set again, but a task the relay had forgotten is not a new start.
|
|
7678
|
+
// `order` lets the relay drop a set that a newer one overtook.
|
|
7276
7679
|
app.post('/api/background-tasks', auth, (req, res) => {
|
|
7277
7680
|
touchCli();
|
|
7278
7681
|
const conversationId = String(req.body?.conversationId || '').trim();
|
|
@@ -7296,11 +7699,58 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7296
7699
|
// Sanitized digest or undefined: JSON serialization (store broadcast,
|
|
7297
7700
|
// conversation payload) drops the key entirely for flat rows.
|
|
7298
7701
|
workflowProgress: sanitizeWorkflowProgress(task?.workflowProgress) || undefined,
|
|
7702
|
+
// The spawning tool_use id: the sub-agent run this task is. The panel
|
|
7703
|
+
// opens the run's viewer from it.
|
|
7704
|
+
toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
|
|
7299
7705
|
}))
|
|
7300
7706
|
.filter((task) => task.taskId);
|
|
7707
|
+
const resync = req.body?.resync === true;
|
|
7708
|
+
// Posts can overtake each other on the way (an ordinary publish and the
|
|
7709
|
+
// answer to a resync, two publishes): a worker numbers its sets
|
|
7710
|
+
// (`order: { epoch, seq }`), and one older than the newest applied is
|
|
7711
|
+
// dropped, or a task of the newer set would read as ended. A worker
|
|
7712
|
+
// that numbers none is taken as it comes.
|
|
7713
|
+
// A drop is never silent: it is logged, and the answer says `stale`,
|
|
7714
|
+
// on which the worker asks for a heartbeat (which makes it the holder
|
|
7715
|
+
// of the session if it was not yet, and has the relay ask for the set).
|
|
7716
|
+
const setOrder = backgroundTaskStore?.acceptOrder?.(conversationId, req.body?.order, {
|
|
7717
|
+
pid: readBridgeIdentity(req)?.pid,
|
|
7718
|
+
});
|
|
7719
|
+
if (setOrder && setOrder.accepted === false) {
|
|
7720
|
+
console.log(`[${typeof ts === 'function' ? ts() : new Date().toISOString()}] BG-TASKS set dropped (${setOrder.reason}) session=${conversationId.slice(0, 8)} epoch=${Number(req.body?.order?.epoch) || 0} seq=${Number(req.body?.order?.seq) || 0} tasks=${tasks.length}`);
|
|
7721
|
+
return res.json({ ok: true, count: tasks.length, stale: true, reason: setOrder.reason });
|
|
7722
|
+
}
|
|
7301
7723
|
backgroundTaskStore?.replace?.(conversationId, tasks);
|
|
7724
|
+
backgroundTaskStore?.announce?.(conversationId, false);
|
|
7302
7725
|
io.emit('background_tasks', { conversationId, tasks });
|
|
7303
|
-
|
|
7726
|
+
// The store is keyed as the worker posts (its SDK session id); the
|
|
7727
|
+
// tracker by the conversation.
|
|
7728
|
+
try {
|
|
7729
|
+
const trackedConversationId = resolveTaskConversationId(conversationId);
|
|
7730
|
+
sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks, { resync });
|
|
7731
|
+
relayEventService?.afterStateChange?.(trackedConversationId);
|
|
7732
|
+
} catch {}
|
|
7733
|
+
// The panel's copy of a task lives in memory and goes when the task
|
|
7734
|
+
// settles; what the run's row needs of it is kept on the run.
|
|
7735
|
+
for (const task of tasks) {
|
|
7736
|
+
if (!task.toolUseId) continue;
|
|
7737
|
+
const before = stmts.getSubagentRun?.get?.(task.toolUseId) || null;
|
|
7738
|
+
if (!before || String(before.conversation_id || '') !== conversationId) continue;
|
|
7739
|
+
const patch = sanitizeRunHeaderPatch({
|
|
7740
|
+
model: task.model,
|
|
7741
|
+
modelInherited: task.model ? task.modelInherited : undefined,
|
|
7742
|
+
subagentType: task.subagentType,
|
|
7743
|
+
taskId: task.taskId,
|
|
7744
|
+
totalTokens: task.totalTokens,
|
|
7745
|
+
background: true,
|
|
7746
|
+
});
|
|
7747
|
+
if (!patch) continue;
|
|
7748
|
+
const after = subagentTimeline.applyHeader(before.id, patch);
|
|
7749
|
+
const changed = after && ['model', 'model_inherited', 'subagent_type', 'task_id', 'total_tokens', 'background']
|
|
7750
|
+
.some((column) => before[column] !== after[column]);
|
|
7751
|
+
if (changed) emitSubagentStatus(after);
|
|
7752
|
+
}
|
|
7753
|
+
res.json({ ok: true, count: tasks.length, ...(resync ? { resync: true } : {}) });
|
|
7304
7754
|
});
|
|
7305
7755
|
|
|
7306
7756
|
// POST /api/conversation/:conversationId/background-task/:taskId/stop —
|
|
@@ -7332,7 +7782,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7332
7782
|
if (!claimed || !queueRow) return false;
|
|
7333
7783
|
const current = String(queueRow.attempt_id || '');
|
|
7334
7784
|
if (current === claimed) return false;
|
|
7335
|
-
|
|
7785
|
+
staleAttemptLog.note({ messageId: queueRow.id, attemptId: claimed, currentAttemptId: current, route, status: queueRow.status });
|
|
7336
7786
|
res.status(409).json({ error: 'stale_attempt', currentStatus: String(queueRow.status || 'unknown') });
|
|
7337
7787
|
return true;
|
|
7338
7788
|
}
|
|
@@ -7347,10 +7797,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7347
7797
|
|
|
7348
7798
|
const q = stmts.findQById.get(messageId);
|
|
7349
7799
|
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'activity' })) return;
|
|
7800
|
+
try { sessionActivityTracker?.touch?.(q?.conversation_id || conversationId); } catch {}
|
|
7350
7801
|
const responseMessageId = q?.response_message_id || null;
|
|
7351
7802
|
const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
|
|
7352
7803
|
const normalizedMetadata = sanitizeActivityMetadata(metadata);
|
|
7353
|
-
stmts.insertActivity.run(
|
|
7804
|
+
const activityRow = stmts.insertActivity.run(
|
|
7354
7805
|
messageId,
|
|
7355
7806
|
responseMessageId,
|
|
7356
7807
|
conversationId,
|
|
@@ -7360,6 +7811,14 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7360
7811
|
normalizedSubagentRunId,
|
|
7361
7812
|
normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
|
|
7362
7813
|
);
|
|
7814
|
+
if (normalizedSubagentRunId) {
|
|
7815
|
+
// The lane keeps the capped line; the timeline copy keeps what came in.
|
|
7816
|
+
mirrorSubagentFrame({
|
|
7817
|
+
runId: normalizedSubagentRunId,
|
|
7818
|
+
key: `activity:${activityRow?.lastInsertRowid ?? Date.now()}`,
|
|
7819
|
+
...activityTimelineFrame(String(text || '').trim() || activityText),
|
|
7820
|
+
});
|
|
7821
|
+
}
|
|
7363
7822
|
|
|
7364
7823
|
io.emit('relay_activity', {
|
|
7365
7824
|
messageId,
|
|
@@ -7394,6 +7853,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7394
7853
|
return res.status(409).json({ error: 'Stream conversationId does not match queue conversation' });
|
|
7395
7854
|
}
|
|
7396
7855
|
if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'stream' })) return;
|
|
7856
|
+
try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
|
|
7397
7857
|
|
|
7398
7858
|
const isStreamSeqConstraintError = (error) => {
|
|
7399
7859
|
const message = String(error?.message || '').toLowerCase();
|
|
@@ -7474,6 +7934,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7474
7934
|
return res.status(500).json({ error: error?.message || 'Failed to persist stream event' });
|
|
7475
7935
|
}
|
|
7476
7936
|
|
|
7937
|
+
if (normalizedSubagentRunId) {
|
|
7938
|
+
// One reply text per run and turn, updated in place as it grows.
|
|
7939
|
+
mirrorSubagentFrame({ runId: normalizedSubagentRunId, key: `stream:${messageId}`, kind: 'text', text: streamText, done: !!done });
|
|
7940
|
+
}
|
|
7941
|
+
|
|
7477
7942
|
// Volatile keeps these out of the connection-state-recovery buffer. Each chunk
|
|
7478
7943
|
// carries the full text so far, so replaying a turn's worth of them costs
|
|
7479
7944
|
// megabytes to deliver what one reconnect resync already fetches from
|
|
@@ -7515,6 +7980,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7515
7980
|
return res.status(409).json({ error: 'Thought conversationId does not match queue conversation' });
|
|
7516
7981
|
}
|
|
7517
7982
|
if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'thought' })) return;
|
|
7983
|
+
try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
|
|
7518
7984
|
|
|
7519
7985
|
const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
|
|
7520
7986
|
const normalizedReasoningId = String(reasoningId || '').trim() || null;
|
|
@@ -7594,6 +8060,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7594
8060
|
}
|
|
7595
8061
|
}
|
|
7596
8062
|
|
|
8063
|
+
if (normalizedSubagentRunId && (normalizedReasoningId || done) && String(thoughtText).trim()) {
|
|
8064
|
+
mirrorSubagentFrame({
|
|
8065
|
+
runId: normalizedSubagentRunId,
|
|
8066
|
+
key: normalizedReasoningId ? `thought:${normalizedReasoningId}` : `thought:${messageId}:${seq}`,
|
|
8067
|
+
kind: 'thought',
|
|
8068
|
+
text: thoughtText,
|
|
8069
|
+
done: !!done,
|
|
8070
|
+
});
|
|
8071
|
+
}
|
|
8072
|
+
|
|
7597
8073
|
io.emit('relay_thought', {
|
|
7598
8074
|
messageId,
|
|
7599
8075
|
conversationId,
|
|
@@ -7608,7 +8084,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7608
8084
|
res.json({ ok: true, seq });
|
|
7609
8085
|
});
|
|
7610
8086
|
|
|
7611
|
-
// POST /api/subagent-run — relay notifies about subagent lifecycle events
|
|
8087
|
+
// POST /api/subagent-run — relay notifies about subagent lifecycle events.
|
|
8088
|
+
// A new run needs the turn that spawned it (messageId). An update to a known
|
|
8089
|
+
// run may come without one: a background agent outlives its turn, and its
|
|
8090
|
+
// end (or its header) arrives between turns or during a later turn; the run
|
|
8091
|
+
// row names its own turn. Header fields (model, type, task id, background,
|
|
8092
|
+
// who writes the timeline) are patched onto the run.
|
|
7612
8093
|
app.post('/api/subagent-run', auth, (req, res) => {
|
|
7613
8094
|
touchCli();
|
|
7614
8095
|
const { messageId, conversationId, subagentRunId, parentSubagentId, displayName, status } = req.body || {};
|
|
@@ -7617,40 +8098,56 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7617
8098
|
const normalizedSubagentRunId = sanitizeSubagentRunId(subagentRunId);
|
|
7618
8099
|
const normalizedParentSubagentId = sanitizeSubagentRunId(parentSubagentId);
|
|
7619
8100
|
const normalizedDisplayName = displayName ? String(displayName).trim() : null;
|
|
7620
|
-
const normalizedStatus = status ?
|
|
8101
|
+
const normalizedStatus = status ? normalizeSubagentRunStatus(status) : 'running';
|
|
8102
|
+
const normalizedMessageId = String(messageId || '').trim();
|
|
7621
8103
|
|
|
7622
|
-
if (!
|
|
8104
|
+
if (!conversationId || !normalizedSubagentRunId) {
|
|
7623
8105
|
return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
|
|
7624
8106
|
}
|
|
7625
|
-
|
|
7626
|
-
const q = stmts.findQById.get(messageId);
|
|
7627
|
-
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
|
|
7628
8107
|
const existingRun = stmts.getSubagentRun?.get(normalizedSubagentRunId);
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
8108
|
+
if (!normalizedMessageId && !existingRun) {
|
|
8109
|
+
return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
|
|
8110
|
+
}
|
|
8111
|
+
|
|
8112
|
+
let authoritativeConversationId;
|
|
8113
|
+
if (normalizedMessageId) {
|
|
8114
|
+
const q = stmts.findQById.get(normalizedMessageId);
|
|
8115
|
+
if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
|
|
8116
|
+
const binding = validateSubagentRunBinding({
|
|
8117
|
+
queueRow: q,
|
|
8118
|
+
messageId: normalizedMessageId,
|
|
8119
|
+
conversationId,
|
|
8120
|
+
existingRun,
|
|
8121
|
+
});
|
|
8122
|
+
if (!binding.ok) {
|
|
8123
|
+
return res.status(binding.statusCode || 409).json({ error: binding.error || 'Subagent run binding mismatch' });
|
|
8124
|
+
}
|
|
8125
|
+
authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
|
|
8126
|
+
} else {
|
|
8127
|
+
authoritativeConversationId = String(existingRun.conversation_id || '').trim();
|
|
8128
|
+
if (authoritativeConversationId !== String(conversationId).trim()) {
|
|
8129
|
+
return res.status(409).json({ error: 'Subagent run conversation mismatch' });
|
|
8130
|
+
}
|
|
7637
8131
|
}
|
|
7638
|
-
const authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
|
|
7639
8132
|
|
|
7640
8133
|
const now = new Date().toISOString();
|
|
7641
8134
|
|
|
7642
8135
|
if (existingRun) {
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
8136
|
+
// Without a status the post is a header update and leaves the run's
|
|
8137
|
+
// state alone.
|
|
8138
|
+
if (status) {
|
|
8139
|
+
const isTerminal = normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'cancelled';
|
|
8140
|
+
stmts.updateSubagentRunStatus?.run(
|
|
8141
|
+
normalizedStatus,
|
|
8142
|
+
now,
|
|
8143
|
+
isTerminal ? now : null,
|
|
8144
|
+
normalizedSubagentRunId,
|
|
8145
|
+
);
|
|
8146
|
+
}
|
|
7650
8147
|
} else {
|
|
7651
8148
|
stmts.insertSubagentRun?.run(
|
|
7652
8149
|
normalizedSubagentRunId,
|
|
7653
|
-
|
|
8150
|
+
normalizedMessageId,
|
|
7654
8151
|
authoritativeConversationId,
|
|
7655
8152
|
normalizedParentSubagentId,
|
|
7656
8153
|
normalizedDisplayName,
|
|
@@ -7659,20 +8156,107 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7659
8156
|
now,
|
|
7660
8157
|
);
|
|
7661
8158
|
}
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
status: normalizedStatus,
|
|
7670
|
-
timestamp: now,
|
|
8159
|
+
const headerPatch = sanitizeRunHeaderPatch({
|
|
8160
|
+
model: req.body?.model,
|
|
8161
|
+
modelInherited: req.body?.modelInherited,
|
|
8162
|
+
subagentType: req.body?.subagentType,
|
|
8163
|
+
taskId: req.body?.taskId,
|
|
8164
|
+
background: req.body?.background === true,
|
|
8165
|
+
timelineOwner: req.body?.timelineOwner,
|
|
7671
8166
|
});
|
|
8167
|
+
if (headerPatch) subagentTimeline.applyHeader(normalizedSubagentRunId, headerPatch);
|
|
8168
|
+
|
|
8169
|
+
const run = stmts.getSubagentRun?.get?.(normalizedSubagentRunId);
|
|
8170
|
+
if (run) {
|
|
8171
|
+
emitSubagentStatus(run);
|
|
8172
|
+
} else {
|
|
8173
|
+
io.emit('subagent_status', {
|
|
8174
|
+
messageId: normalizedMessageId || null,
|
|
8175
|
+
conversationId: authoritativeConversationId,
|
|
8176
|
+
subagentRunId: normalizedSubagentRunId,
|
|
8177
|
+
parentSubagentId: normalizedParentSubagentId || undefined,
|
|
8178
|
+
displayName: normalizedDisplayName || undefined,
|
|
8179
|
+
status: normalizedStatus,
|
|
8180
|
+
timestamp: now,
|
|
8181
|
+
});
|
|
8182
|
+
}
|
|
7672
8183
|
|
|
7673
8184
|
res.json({ ok: true });
|
|
7674
8185
|
});
|
|
7675
8186
|
|
|
8187
|
+
// POST /api/subagent-timeline — a worker publishes entries of a sub-agent's
|
|
8188
|
+
// record (and, optionally, a header patch) for a run it already announced.
|
|
8189
|
+
// Fenced by the run row, not by an open turn: a background agent keeps
|
|
8190
|
+
// writing after its turn ended, and its entries belong to that turn. A run
|
|
8191
|
+
// the relay does not know yet but whose parent it does (an agent spawned
|
|
8192
|
+
// inside another) is created under the parent's turn; anything else is a
|
|
8193
|
+
// 404 the worker retries once the spawn has landed.
|
|
8194
|
+
app.post('/api/subagent-timeline', auth, (req, res) => {
|
|
8195
|
+
touchCli();
|
|
8196
|
+
const conversationId = String(req.body?.conversationId || '').trim();
|
|
8197
|
+
const runId = sanitizeSubagentRunId(req.body?.subagentRunId);
|
|
8198
|
+
if (!conversationId || !runId) {
|
|
8199
|
+
return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
|
|
8200
|
+
}
|
|
8201
|
+
if (!subagentTimeline.supported()) {
|
|
8202
|
+
return res.status(503).json({ error: 'timeline_unavailable' });
|
|
8203
|
+
}
|
|
8204
|
+
const rawHeader = req.body?.header && typeof req.body.header === 'object' ? req.body.header : {};
|
|
8205
|
+
let run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8206
|
+
let created = false;
|
|
8207
|
+
if (!run) {
|
|
8208
|
+
const parentId = sanitizeSubagentRunId(rawHeader.parentSubagentId);
|
|
8209
|
+
const parent = parentId ? (stmts.getSubagentRun?.get?.(parentId) || null) : null;
|
|
8210
|
+
if (!parent) return res.status(404).json({ error: 'unknown_run' });
|
|
8211
|
+
if (String(parent.conversation_id || '') !== conversationId) {
|
|
8212
|
+
return res.status(409).json({ error: 'Subagent run conversation mismatch' });
|
|
8213
|
+
}
|
|
8214
|
+
const now = new Date().toISOString();
|
|
8215
|
+
const displayName = String(rawHeader.displayName || '').trim().slice(0, 500) || 'Subagent';
|
|
8216
|
+
stmts.insertSubagentRun?.run(runId, parent.queue_message_id, parent.conversation_id, parentId, displayName, 'running', now, now);
|
|
8217
|
+
run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8218
|
+
if (!run) return res.status(500).json({ error: 'Failed to create the run' });
|
|
8219
|
+
created = true;
|
|
8220
|
+
}
|
|
8221
|
+
if (String(run.conversation_id || '') !== conversationId) {
|
|
8222
|
+
return res.status(409).json({ error: 'Subagent run conversation mismatch' });
|
|
8223
|
+
}
|
|
8224
|
+
const patch = sanitizeRunHeaderPatch(rawHeader);
|
|
8225
|
+
if (patch) run = subagentTimeline.applyHeader(runId, patch) || run;
|
|
8226
|
+
let stored;
|
|
8227
|
+
try {
|
|
8228
|
+
stored = subagentTimeline.applyEntries(run, req.body?.entries);
|
|
8229
|
+
} catch (error) {
|
|
8230
|
+
return res.status(500).json({ error: error?.message || 'Failed to store the timeline' });
|
|
8231
|
+
}
|
|
8232
|
+
if (stored.truncated) run = stmts.getSubagentRun?.get?.(runId) || run;
|
|
8233
|
+
if (created || patch || stored.truncated) emitSubagentStatus(run);
|
|
8234
|
+
emitSubagentTimeline(run, stored.entries);
|
|
8235
|
+
try { sessionActivityTracker?.touch?.(conversationId); } catch {}
|
|
8236
|
+
res.json({ ok: true, stored: stored.entries.length, truncated: stored.truncated });
|
|
8237
|
+
});
|
|
8238
|
+
|
|
8239
|
+
// GET /api/conversation/:conversationId/subagent/:subagentRunId — one run's
|
|
8240
|
+
// record for the viewer: header, entries after `after` (plus open ones),
|
|
8241
|
+
// child runs. `complete` is false when more entries follow this page.
|
|
8242
|
+
app.get('/api/conversation/:conversationId/subagent/:subagentRunId', auth, (req, res) => {
|
|
8243
|
+
const conversationId = String(req.params.conversationId || '').trim();
|
|
8244
|
+
const runId = sanitizeSubagentRunId(req.params.subagentRunId);
|
|
8245
|
+
if (!conversationId || !runId) return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
|
|
8246
|
+
const run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8247
|
+
if (!run || String(run.conversation_id || '') !== conversationId) {
|
|
8248
|
+
return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
8249
|
+
}
|
|
8250
|
+
const page = subagentTimeline.readTimeline(run, {
|
|
8251
|
+
afterSeq: Number(req.query?.after) || 0,
|
|
8252
|
+
limit: Number(req.query?.limit) || TIMELINE_READ_DEFAULT_LIMIT,
|
|
8253
|
+
isStoppable: isSubagentRunStoppable,
|
|
8254
|
+
});
|
|
8255
|
+
const task = liveTaskForSubagentRun(run);
|
|
8256
|
+
if (page.run && task?.taskId) page.run.taskId = page.run.taskId || task.taskId;
|
|
8257
|
+
res.json(page);
|
|
8258
|
+
});
|
|
8259
|
+
|
|
7676
8260
|
// POST /api/requeue — relay re-queues a message it failed to process
|
|
7677
8261
|
app.post('/api/requeue', auth, (req, res) => {
|
|
7678
8262
|
const { messageId } = req.body;
|
|
@@ -7683,7 +8267,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7683
8267
|
// terminal-fail a row that has already been requeued to someone else).
|
|
7684
8268
|
const claimedAttemptId = String(req.body?.attemptId || '').trim() || null;
|
|
7685
8269
|
if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
|
|
7686
|
-
|
|
8270
|
+
staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'requeue', status: q.status });
|
|
7687
8271
|
return res.status(409).json({ error: 'stale_attempt', currentStatus: String(q.status || 'unknown') });
|
|
7688
8272
|
}
|
|
7689
8273
|
const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
|