@oar-sh/oar 0.9.11 → 0.9.13
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 +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
persistConversationModelPreference as persistConversationModelPreferenceTx,
|
|
18
18
|
} from '../services/conversation-preferences-service.mjs';
|
|
19
|
+
import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
|
|
19
20
|
import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
|
|
20
21
|
import { normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
|
|
21
22
|
import {
|
|
@@ -42,6 +43,16 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
|
|
|
42
43
|
import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
43
44
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
44
45
|
import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
46
|
+
import {
|
|
47
|
+
DEFAULT_TASK_STALL_MINUTES,
|
|
48
|
+
TASK_OUTPUT_LIMITS,
|
|
49
|
+
cleanTaskText,
|
|
50
|
+
normalizeTaskCommand,
|
|
51
|
+
normalizeTaskOutputKind,
|
|
52
|
+
normalizeTaskOutputPath,
|
|
53
|
+
} from '../../shared/task-output-text.mjs';
|
|
54
|
+
import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../services/task-output-service.mjs';
|
|
55
|
+
import { taskKindOf } from '../services/session-activity-tracker.mjs';
|
|
45
56
|
import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
46
57
|
import { isLiveResend } from '../services/steer-resend.mjs';
|
|
47
58
|
import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
|
|
@@ -60,6 +71,7 @@ import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limi
|
|
|
60
71
|
import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
|
|
61
72
|
import { createLocalRequestCheck } from '../services/request-boundary.mjs';
|
|
62
73
|
import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
|
|
74
|
+
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
63
75
|
import {
|
|
64
76
|
admitRemoteRelayRequest,
|
|
65
77
|
readRemoteRelayRequest,
|
|
@@ -236,9 +248,17 @@ function findRecentDuplicateUserMessage({
|
|
|
236
248
|
const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
|
|
237
249
|
.map((id) => String(id || '').trim()).filter(Boolean));
|
|
238
250
|
const isExempt = (row) => exempt.has(String(row?.id || '').trim());
|
|
251
|
+
// A message whose turn failed never ran, so sending it again is a retry,
|
|
252
|
+
// not a double submit. Only failed rows: a cancelled or finished turn stays
|
|
253
|
+
// a duplicate, as before.
|
|
254
|
+
const failedTurnIds = new Set((Array.isArray(recentQueueRows) ? recentQueueRows : [])
|
|
255
|
+
.filter((row) => String(row?.status || '').trim().toLowerCase() === 'failed')
|
|
256
|
+
.map((row) => String(row?.id || '').trim())
|
|
257
|
+
.filter(Boolean));
|
|
239
258
|
|
|
240
259
|
for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
|
|
241
260
|
if (isExempt(row)) continue;
|
|
261
|
+
if (failedTurnIds.has(String(row?.id || '').trim())) continue;
|
|
242
262
|
const rowText = normalizeDuplicateMessageText(row?.text || '');
|
|
243
263
|
if (!rowText || rowText !== fingerprint) continue;
|
|
244
264
|
const rowTime = Date.parse(row?.timestamp || '');
|
|
@@ -1779,6 +1799,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1779
1799
|
buildRelayReadyBannerData,
|
|
1780
1800
|
backgroundTaskStore,
|
|
1781
1801
|
backgroundWorkView = null,
|
|
1802
|
+
// The 8 KiB a worker posts for a task without an output file
|
|
1803
|
+
// (services/task-output-service.mjs createTaskOutputSnapshots).
|
|
1804
|
+
taskOutputSnapshots = null,
|
|
1805
|
+
// Has the health poller read soon (a task with a console ended).
|
|
1806
|
+
pokeTaskHealth = null,
|
|
1807
|
+
// What a recovery did with a turn that was running when the relay
|
|
1808
|
+
// started (services/restart-resume-service.mjs noteOutcome).
|
|
1809
|
+
noteRestartTurnOutcome = () => {},
|
|
1810
|
+
getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
|
|
1782
1811
|
sendWorkerControl,
|
|
1783
1812
|
ensureSessionId,
|
|
1784
1813
|
touchCli,
|
|
@@ -2713,10 +2742,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2713
2742
|
// quietly.
|
|
2714
2743
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
2715
2744
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
2745
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2716
2746
|
continue;
|
|
2717
2747
|
}
|
|
2718
2748
|
if (row.consumed_at) {
|
|
2719
2749
|
failConsumedQueueRow(row, { reason });
|
|
2750
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2720
2751
|
continue;
|
|
2721
2752
|
}
|
|
2722
2753
|
const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
|
|
@@ -2753,6 +2784,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2753
2784
|
error: reason,
|
|
2754
2785
|
});
|
|
2755
2786
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
2787
|
+
noteRestartTurnOutcome(row, 'requeued');
|
|
2756
2788
|
}
|
|
2757
2789
|
for (const row of rowsToFail) {
|
|
2758
2790
|
const failureRecord = buildHeartbeatIdleReplayFailure({
|
|
@@ -2778,6 +2810,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2778
2810
|
ok: false,
|
|
2779
2811
|
error: failureRecord.code,
|
|
2780
2812
|
});
|
|
2813
|
+
// Started, so not replayed: a turn the agent never heard the end of.
|
|
2814
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2781
2815
|
}
|
|
2782
2816
|
}
|
|
2783
2817
|
if (rowsToRecover.length || rowsToFail.length) {
|
|
@@ -4415,7 +4449,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4415
4449
|
const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
|
|
4416
4450
|
const sessionWorkerRoutingEnabled = isSessionWorkerRoutingEnabled(featureFlags);
|
|
4417
4451
|
let conversationSdkSessionId = null;
|
|
4418
|
-
|
|
4452
|
+
// The conversation's saved choices, for a send that leaves one out: a
|
|
4453
|
+
// send to an existing conversation that names no mode runs in the mode
|
|
4454
|
+
// the conversation is in, not the relay default.
|
|
4455
|
+
const existingConversation = (!newConversation && conversationId)
|
|
4456
|
+
? (stmts.getConvAnyStatus?.get?.(conversationId) || null)
|
|
4457
|
+
: null;
|
|
4458
|
+
const explicitRelayModeGiven = !!String(relayMode || mode || '').trim();
|
|
4459
|
+
const savedRelayMode = !explicitRelayModeGiven && existingConversation?.preferred_relay_mode
|
|
4460
|
+
? normalizeRelayMode(existingConversation.preferred_relay_mode)
|
|
4461
|
+
: null;
|
|
4462
|
+
const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
|
|
4419
4463
|
const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
|
|
4420
4464
|
const normalizedAttachments = normalizeAttachments(rawAttachments);
|
|
4421
4465
|
const requestedAttachmentCount = Math.min(
|
|
@@ -4772,6 +4816,34 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4772
4816
|
// sends set_model before the message), so a changed model is taken as
|
|
4773
4817
|
// for a local Claude chat.
|
|
4774
4818
|
const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
|
|
4819
|
+
// What the request itself chose. Only these are saved as the
|
|
4820
|
+
// conversation's preference; a value filled in below never is.
|
|
4821
|
+
const explicitModelGiven = !!requestedOpenAIModel;
|
|
4822
|
+
const conversationReasoningEffort = String(existingConversation?.preferred_reasoning_effort || '').trim().toLowerCase();
|
|
4823
|
+
// A Copilot conversation sent no model keeps its own: the saved
|
|
4824
|
+
// preference, the runtime session's model, the latest user message's,
|
|
4825
|
+
// and the catalog default only when it has none of these. A model the
|
|
4826
|
+
// catalog no longer lists is refused rather than swapped for another.
|
|
4827
|
+
const usesGitHubCatalog = !useOpenAIProvider && !runtimeBoundToExplicitProvider;
|
|
4828
|
+
const inheritedModel = usesGitHubCatalog && !shouldCreateConversation && !explicitModelGiven
|
|
4829
|
+
? inheritedConversationModel({
|
|
4830
|
+
conversation: existingConversation,
|
|
4831
|
+
runtimeSession: existingRuntimeSession,
|
|
4832
|
+
latestUserMessageModel: stmts.getLatestUserMessageModel?.get?.(conversationId)?.model || '',
|
|
4833
|
+
})
|
|
4834
|
+
: null;
|
|
4835
|
+
const githubModelResolution = usesGitHubCatalog
|
|
4836
|
+
? resolveRequestedModel(inheritedModel ? inheritedModel.model : model)
|
|
4837
|
+
: null;
|
|
4838
|
+
if (inheritedModel && !githubModelResolution?.ok) {
|
|
4839
|
+
return res.status(409).json({
|
|
4840
|
+
error: `This conversation's model "${inheritedModel.model}" is no longer available. Choose a model and send again.`,
|
|
4841
|
+
code: 'CONVERSATION_MODEL_UNAVAILABLE',
|
|
4842
|
+
conversationId,
|
|
4843
|
+
model: inheritedModel.model,
|
|
4844
|
+
supportedModels: githubModelResolution?.available || [],
|
|
4845
|
+
});
|
|
4846
|
+
}
|
|
4775
4847
|
const modelResolution = useOpenAIProvider
|
|
4776
4848
|
? {
|
|
4777
4849
|
ok: !!openAIModel,
|
|
@@ -4812,7 +4884,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4812
4884
|
reasoningEffort: null,
|
|
4813
4885
|
error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
|
|
4814
4886
|
}
|
|
4815
|
-
:
|
|
4887
|
+
: githubModelResolution))));
|
|
4816
4888
|
if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
|
|
4817
4889
|
const requestedModel = String(modelResolution.model || '').trim();
|
|
4818
4890
|
const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
|
|
@@ -4837,6 +4909,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4837
4909
|
code: 'IMAGE_TARGET_REQUIRES_IMAGE_MODEL',
|
|
4838
4910
|
});
|
|
4839
4911
|
}
|
|
4912
|
+
// A send that names no effort runs with the conversation's own, clamped
|
|
4913
|
+
// to the nearest level the model has. Null when there is nothing to keep
|
|
4914
|
+
// or it neither matches nor clamps (an ultracode on a model without it):
|
|
4915
|
+
// the provider's own default applies then.
|
|
4916
|
+
const conversationEffortFor = (supported) => (
|
|
4917
|
+
!explicitReasoningEffort && conversationReasoningEffort
|
|
4918
|
+
? (fallbackReasoningEffort([conversationReasoningEffort], supported, { fallback: '' }) || null)
|
|
4919
|
+
: null
|
|
4920
|
+
);
|
|
4840
4921
|
// Claude conversations validate effort against the Claude provider's
|
|
4841
4922
|
// per-model levels ('none' = SDK default). Effort is per-turn: each Claude
|
|
4842
4923
|
// turn is a fresh query(), so it can change between messages.
|
|
@@ -4845,6 +4926,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4845
4926
|
const supported = Array.isArray(configuredClaude?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
|
|
4846
4927
|
? configuredClaude.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
|
|
4847
4928
|
: [...DEFAULT_CLAUDE_REASONING_EFFORTS];
|
|
4929
|
+
const kept = conversationEffortFor(supported);
|
|
4930
|
+
if (kept) return { ok: true, effort: kept, supported };
|
|
4848
4931
|
const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4849
4932
|
return { ok: true, effort: effort === 'none' ? 'none' : effort, supported };
|
|
4850
4933
|
};
|
|
@@ -4860,8 +4943,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4860
4943
|
// the worker validates it against the resolved model's live params
|
|
4861
4944
|
// and falls back to the model default on any mismatch. Clamping to
|
|
4862
4945
|
// 'none' here would actively disable thinking on the resolved model.
|
|
4863
|
-
|
|
4946
|
+
// The conversation's own effort goes through the same way.
|
|
4947
|
+
return { ok: true, effort: requestedEffort || conversationReasoningEffort, supported: [] };
|
|
4864
4948
|
}
|
|
4949
|
+
const kept = conversationEffortFor(discovered);
|
|
4950
|
+
if (kept) return { ok: true, effort: kept, supported: discovered };
|
|
4865
4951
|
const effort = discovered.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4866
4952
|
return { ok: true, effort, supported: discovered };
|
|
4867
4953
|
};
|
|
@@ -4870,11 +4956,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4870
4956
|
const supported = Array.isArray(configuredGrok?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
|
|
4871
4957
|
? configuredGrok.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
|
|
4872
4958
|
: ['none', 'low', 'medium', 'high'];
|
|
4959
|
+
const kept = conversationEffortFor(supported);
|
|
4960
|
+
if (kept) return { ok: true, effort: kept, supported };
|
|
4873
4961
|
const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4874
4962
|
return { ok: true, effort, supported };
|
|
4875
4963
|
};
|
|
4876
4964
|
let reasoningResolution = useOpenAIProvider
|
|
4877
|
-
? resolveOpenAIReasoningEffort(
|
|
4965
|
+
? resolveOpenAIReasoningEffort(
|
|
4966
|
+
explicitReasoningEffort || conversationEffortFor(openAIReasoningEffortsForModel(openAIModel)) || '',
|
|
4967
|
+
openAIModel,
|
|
4968
|
+
)
|
|
4878
4969
|
: (runtimeUsesClaude
|
|
4879
4970
|
? resolveClaudeReasoningEffort()
|
|
4880
4971
|
: (runtimeUsesCursor
|
|
@@ -4888,15 +4979,21 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4888
4979
|
requestedModel,
|
|
4889
4980
|
explicitReasoningEffort || modelResolution.reasoningEffort || null,
|
|
4890
4981
|
)))));
|
|
4982
|
+
// No effort sent (or the model's variant carried one it lacks): the
|
|
4983
|
+
// conversation's own effort, clamped to the model's levels, else its
|
|
4984
|
+
// lowest level that is not 'none'. Some models list 'none' and the API
|
|
4985
|
+
// still refuses it, so it is taken only when the conversation chose it or
|
|
4986
|
+
// the model has nothing else.
|
|
4891
4987
|
if (!reasoningResolution?.ok && !explicitReasoningEffort) {
|
|
4892
4988
|
const supportedEfforts = Array.isArray(reasoningResolution?.supported)
|
|
4893
4989
|
? reasoningResolution.supported.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
|
|
4894
4990
|
: [];
|
|
4895
|
-
const
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4991
|
+
const fallbackEffort = fallbackReasoningEffort(
|
|
4992
|
+
[modelResolution.reasoningEffort, conversationReasoningEffort],
|
|
4993
|
+
supportedEfforts,
|
|
4994
|
+
);
|
|
4995
|
+
if (fallbackEffort) {
|
|
4996
|
+
reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackEffort);
|
|
4900
4997
|
}
|
|
4901
4998
|
}
|
|
4902
4999
|
if (!reasoningResolution?.ok) {
|
|
@@ -4906,6 +5003,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4906
5003
|
});
|
|
4907
5004
|
}
|
|
4908
5005
|
const requestedReasoningEffort = reasoningResolution.effort || null;
|
|
5006
|
+
// The request's own effort: sent as such, or carried by the variant id of
|
|
5007
|
+
// a model it named.
|
|
5008
|
+
const explicitEffortGiven = !!explicitReasoningEffort || (explicitModelGiven && !!modelResolution.reasoningEffort);
|
|
4909
5009
|
let normalizedContextTier = requestedContextTier;
|
|
4910
5010
|
if (openAIImageModel) {
|
|
4911
5011
|
normalizedContextTier = normalizeOpenAIImageSize(requestedContextTier) || 'auto';
|
|
@@ -4950,6 +5050,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4950
5050
|
return res.json({
|
|
4951
5051
|
ok: true,
|
|
4952
5052
|
duplicate: true,
|
|
5053
|
+
queued: false,
|
|
4953
5054
|
alreadyResent: true,
|
|
4954
5055
|
duplicateOfMessageId: resend.alreadyResentAs,
|
|
4955
5056
|
conversationId,
|
|
@@ -4980,6 +5081,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4980
5081
|
return res.json({
|
|
4981
5082
|
ok: true,
|
|
4982
5083
|
duplicate: true,
|
|
5084
|
+
// Said outright for a caller that reads only `ok`: nothing new runs.
|
|
5085
|
+
queued: false,
|
|
4983
5086
|
duplicateWindowMs: DUPLICATE_USER_MESSAGE_WINDOW_MS,
|
|
4984
5087
|
duplicateSource: duplicateMessage.source,
|
|
4985
5088
|
duplicateOfMessageId: duplicateMessage.messageId,
|
|
@@ -5161,15 +5264,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5161
5264
|
}
|
|
5162
5265
|
// Another relay's agent picks the model and mode of its own turn; the
|
|
5163
5266
|
// composer keeps restoring the user's choice for this conversation.
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5267
|
+
// Any other send to an existing conversation saves only what it named:
|
|
5268
|
+
// a model or an effort filled in from a fallback is never written back
|
|
5269
|
+
// as the conversation's choice (an empty effort keeps the stored one).
|
|
5270
|
+
const savedModel = String(existingConversation?.preferred_model || '').trim();
|
|
5271
|
+
if (shouldCreateConversation) {
|
|
5272
|
+
conversationPreferences = persistConversationModelPreference(
|
|
5273
|
+
convId, requestedRelayMode, requestedModel, requestedReasoningEffort, now,
|
|
5274
|
+
);
|
|
5275
|
+
} else if (remoteRequest.remote || (!explicitModelGiven && !(explicitEffortGiven && savedModel))) {
|
|
5276
|
+
conversationPreferences = readConversationModelPreference(convId, requestedRelayMode);
|
|
5277
|
+
} else {
|
|
5278
|
+
conversationPreferences = persistConversationModelPreference(
|
|
5167
5279
|
convId,
|
|
5168
5280
|
requestedRelayMode,
|
|
5169
|
-
requestedModel,
|
|
5170
|
-
requestedReasoningEffort,
|
|
5281
|
+
explicitModelGiven ? requestedModel : savedModel,
|
|
5282
|
+
explicitEffortGiven ? requestedReasoningEffort : '',
|
|
5171
5283
|
now,
|
|
5172
5284
|
);
|
|
5285
|
+
}
|
|
5173
5286
|
stmts.insertQ.run(
|
|
5174
5287
|
msgId,
|
|
5175
5288
|
convId,
|
|
@@ -5468,6 +5581,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5468
5581
|
io.emit('background_tasks', {
|
|
5469
5582
|
conversationId: requesterSessionId,
|
|
5470
5583
|
tasks: backgroundWorkView.panelTasks(requesterSessionId, requesterConversationId),
|
|
5584
|
+
endedTasks: backgroundTaskStore?.ended?.(requesterSessionId) || [],
|
|
5471
5585
|
});
|
|
5472
5586
|
}
|
|
5473
5587
|
}
|
|
@@ -5512,6 +5626,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5512
5626
|
const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
|
|
5513
5627
|
for (const id of activeQueueMessageIds) {
|
|
5514
5628
|
refreshProcessingLeaseForOwnedMessage.run(leaseExpiresAt, now, id, requesterSessionId);
|
|
5629
|
+
// The worker has the turn: if it was running when the relay
|
|
5630
|
+
// started, the restart did not cut it off.
|
|
5631
|
+
noteRestartTurnOutcome({ id }, 'claimed');
|
|
5515
5632
|
}
|
|
5516
5633
|
recoverOwnedProcessingRowsForSession(requesterSessionId, {
|
|
5517
5634
|
excludeMessageIds: activeQueueMessageIds,
|
|
@@ -6555,7 +6672,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6555
6672
|
|| rawRestart === 1
|
|
6556
6673
|
|| String(rawRestart || '').trim().toLowerCase() === 'true'
|
|
6557
6674
|
|| String(rawRestart || '').trim() === '1';
|
|
6558
|
-
|
|
6675
|
+
// `immediate` starts it now, without waiting for the running turns (the
|
|
6676
|
+
// banner's "Restart now"); the answer names what it did not wait for.
|
|
6677
|
+
const rawImmediate = req.body?.immediate;
|
|
6678
|
+
const immediate = rawImmediate === true
|
|
6679
|
+
|| rawImmediate === 1
|
|
6680
|
+
|| String(rawImmediate || '').trim().toLowerCase() === 'true'
|
|
6681
|
+
|| String(rawImmediate || '').trim() === '1';
|
|
6682
|
+
// The banner's "Restart now" names the queued request it saw (see
|
|
6683
|
+
// services/relay-restart-queue.mjs); a stale one is refused.
|
|
6684
|
+
const expectRequestedAt = String(req.body?.expectRequestedAt || '').trim().slice(0, 40) || null;
|
|
6685
|
+
const result = requestRelayShutdown({ reason, requestedBy, restart, immediate, expectRequestedAt });
|
|
6559
6686
|
return res.json({ ok: true, ...result });
|
|
6560
6687
|
});
|
|
6561
6688
|
|
|
@@ -7629,6 +7756,34 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7629
7756
|
res.json({ ok: true, messageId, conversationId, attemptId });
|
|
7630
7757
|
});
|
|
7631
7758
|
|
|
7759
|
+
// An integer from a worker's post, or undefined (dropped from the row).
|
|
7760
|
+
function wholeNumber(value, { min = Number.MIN_SAFE_INTEGER } = {}) {
|
|
7761
|
+
if (value === null || value === undefined || value === '' || typeof value === 'boolean') return undefined;
|
|
7762
|
+
const number = Number(value);
|
|
7763
|
+
return Number.isSafeInteger(number) && number >= min ? number : undefined;
|
|
7764
|
+
}
|
|
7765
|
+
|
|
7766
|
+
// A task row's console fields as the publish route keeps them.
|
|
7767
|
+
function taskOutputFields(task) {
|
|
7768
|
+
const outputPath = normalizeTaskOutputPath(task?.outputPath);
|
|
7769
|
+
const requestedKind = String(task?.outputKind || '').trim();
|
|
7770
|
+
if (!outputPath && !requestedKind) return {};
|
|
7771
|
+
const outputKind = normalizeTaskOutputKind(requestedKind, outputPath);
|
|
7772
|
+
// A file kind without a readable path has nothing to show.
|
|
7773
|
+
if ((outputKind === 'file' || outputKind === 'spool') && !outputPath) return { outputKind: 'none' };
|
|
7774
|
+
return { ...(outputPath ? { outputPath } : {}), outputKind };
|
|
7775
|
+
}
|
|
7776
|
+
|
|
7777
|
+
// `background_tasks` with the health readings on the live rows and the
|
|
7778
|
+
// ended rows beside them, for the key the worker posts under.
|
|
7779
|
+
function emitBackgroundTasks(key, tasks = backgroundTaskStore?.get?.(key) || []) {
|
|
7780
|
+
io.emit('background_tasks', {
|
|
7781
|
+
conversationId: key,
|
|
7782
|
+
tasks: backgroundTaskStore?.withHealth ? backgroundTaskStore.withHealth(tasks, key) : tasks,
|
|
7783
|
+
endedTasks: backgroundTaskStore?.ended?.(key) || [],
|
|
7784
|
+
});
|
|
7785
|
+
}
|
|
7786
|
+
|
|
7632
7787
|
function resolveTaskConversationId(id) {
|
|
7633
7788
|
const key = String(id || '').trim();
|
|
7634
7789
|
if (!key) return '';
|
|
@@ -7648,14 +7803,36 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7648
7803
|
const conversationId = resolveTaskConversationId(req.body?.conversationId);
|
|
7649
7804
|
const taskId = String(req.body?.taskId || '').trim();
|
|
7650
7805
|
if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
7651
|
-
|
|
7652
|
-
|
|
7806
|
+
// Only a number the worker sent: a missing or null exit code stays null
|
|
7807
|
+
// (Number(null) would read as a clean exit).
|
|
7808
|
+
const exitCode = wholeNumber(req.body?.exitCode);
|
|
7809
|
+
// Scrubbed like the output: a description can be the command line.
|
|
7810
|
+
const settledDescription = cleanTaskText(String(req.body?.description || ''), 500);
|
|
7811
|
+
// The store is keyed as the worker posts. A task with a console stays
|
|
7812
|
+
// viewable as an ended row; one that ended while this runtime was away
|
|
7813
|
+
// becomes one when the post names its output.
|
|
7814
|
+
const settledOutputPath = normalizeTaskOutputPath(req.body?.outputPath);
|
|
7815
|
+
const endedRow = backgroundTaskStore?.settle?.(String(req.body?.conversationId || '').trim(), taskId, {
|
|
7816
|
+
status: req.body?.status,
|
|
7817
|
+
exitCode: exitCode ?? null,
|
|
7818
|
+
steps: wholeNumber(req.body?.steps, { min: 0 }),
|
|
7819
|
+
outputPath: settledOutputPath || undefined,
|
|
7820
|
+
outputKind: settledOutputPath ? normalizeTaskOutputKind(req.body?.outputKind, settledOutputPath) : undefined,
|
|
7821
|
+
description: settledDescription,
|
|
7822
|
+
command: normalizeTaskCommand(req.body?.command) || undefined,
|
|
7823
|
+
taskType: String(req.body?.taskType || '').trim().slice(0, 80),
|
|
7824
|
+
startedAt: Number(req.body?.startedAt) || null,
|
|
7825
|
+
}) || null;
|
|
7826
|
+
if (endedRow) {
|
|
7827
|
+
emitBackgroundTasks(String(req.body?.conversationId || '').trim());
|
|
7828
|
+
pokeTaskHealth?.();
|
|
7829
|
+
}
|
|
7653
7830
|
sessionActivityTracker?.noteTaskSettled?.(conversationId, {
|
|
7654
7831
|
taskId,
|
|
7655
7832
|
toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
|
|
7656
7833
|
status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
|
|
7657
|
-
summary: String(req.body?.summary || '')
|
|
7658
|
-
exitCode:
|
|
7834
|
+
summary: cleanTaskText(String(req.body?.summary || ''), 500) || null,
|
|
7835
|
+
exitCode: exitCode ?? null,
|
|
7659
7836
|
// What the task was, from a worker that sends it: the end of a task
|
|
7660
7837
|
// this runtime never knew (it ended while the relay was away) is
|
|
7661
7838
|
// still an event then.
|
|
@@ -7684,11 +7861,18 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7684
7861
|
.map((task) => ({
|
|
7685
7862
|
taskId: String(task?.taskId || '').trim(),
|
|
7686
7863
|
taskType: String(task?.taskType || '').trim(),
|
|
7687
|
-
|
|
7864
|
+
// Scrubbed like the output before anything keeps or sends them: a
|
|
7865
|
+
// description can be the command line (Grok's terminals, a Copilot
|
|
7866
|
+
// shell without one), a summary or tool call can quote one.
|
|
7867
|
+
description: cleanTaskText(String(task?.description || ''), 500),
|
|
7868
|
+
// A shell task's command line, where the worker knows it: scrubbed
|
|
7869
|
+
// like the output and cut at 2,000 characters before it is kept, so
|
|
7870
|
+
// every reader (the pages, read_session, the stored row) gets it so.
|
|
7871
|
+
command: normalizeTaskCommand(task?.command) || undefined,
|
|
7688
7872
|
startedAt: Number(task?.startedAt) || null,
|
|
7689
|
-
summary: String(task?.summary || '')
|
|
7873
|
+
summary: cleanTaskText(String(task?.summary || ''), 500) || null,
|
|
7690
7874
|
lastToolName: String(task?.lastToolName || '').trim() || null,
|
|
7691
|
-
lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ')
|
|
7875
|
+
lastToolCall: cleanTaskText(String(task?.lastToolCall || '').replace(/\s+/g, ' '), 300) || null,
|
|
7692
7876
|
totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
|
|
7693
7877
|
subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
|
|
7694
7878
|
model: String(task?.model || '').trim().slice(0, 120) || null,
|
|
@@ -7702,8 +7886,23 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7702
7886
|
// The spawning tool_use id: the sub-agent run this task is. The panel
|
|
7703
7887
|
// opens the run's viewer from it.
|
|
7704
7888
|
toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
|
|
7889
|
+
// The console (shell and Monitor tasks): the file holding the
|
|
7890
|
+
// output, how it is obtained ('none': the provider has none to give).
|
|
7891
|
+
...taskOutputFields(task),
|
|
7892
|
+
// Tool calls a sub-agent made so far.
|
|
7893
|
+
steps: wholeNumber(task?.steps, { min: 0 }),
|
|
7894
|
+
exitCode: wholeNumber(task?.exitCode),
|
|
7705
7895
|
}))
|
|
7706
7896
|
.filter((task) => task.taskId);
|
|
7897
|
+
// The first path a task names wins: a later post cannot point a live
|
|
7898
|
+
// task's console at another file.
|
|
7899
|
+
for (const task of tasks) {
|
|
7900
|
+
const held = (backgroundTaskStore?.get?.(conversationId) || []).find((entry) => entry?.taskId === task.taskId);
|
|
7901
|
+
if (held?.outputPath && task.outputPath !== held.outputPath) {
|
|
7902
|
+
task.outputPath = held.outputPath;
|
|
7903
|
+
task.outputKind = held.outputKind;
|
|
7904
|
+
}
|
|
7905
|
+
}
|
|
7707
7906
|
const resync = req.body?.resync === true;
|
|
7708
7907
|
// Posts can overtake each other on the way (an ordinary publish and the
|
|
7709
7908
|
// answer to a resync, two publishes): a worker numbers its sets
|
|
@@ -7720,9 +7919,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7720
7919
|
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
7920
|
return res.json({ ok: true, count: tasks.length, stale: true, reason: setOrder.reason });
|
|
7722
7921
|
}
|
|
7922
|
+
// A task with a console that leaves the set ended: its file is read once
|
|
7923
|
+
// more soon (its last output often lands after the poller's last look).
|
|
7924
|
+
const nextIds = new Set(tasks.map((task) => task.taskId));
|
|
7925
|
+
const endedWithConsole = (backgroundTaskStore?.get?.(conversationId) || [])
|
|
7926
|
+
.some((task) => task?.outputPath && !nextIds.has(task.taskId));
|
|
7723
7927
|
backgroundTaskStore?.replace?.(conversationId, tasks);
|
|
7724
7928
|
backgroundTaskStore?.announce?.(conversationId, false);
|
|
7725
|
-
|
|
7929
|
+
if (endedWithConsole) pokeTaskHealth?.();
|
|
7930
|
+
emitBackgroundTasks(conversationId, tasks);
|
|
7726
7931
|
// The store is keyed as the worker posts (its SDK session id); the
|
|
7727
7932
|
// tracker by the conversation.
|
|
7728
7933
|
try {
|
|
@@ -7769,6 +7974,175 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7769
7974
|
res.json({ ok: true });
|
|
7770
7975
|
});
|
|
7771
7976
|
|
|
7977
|
+
// ─── Task console ──────────────────────────────────────────────────────────
|
|
7978
|
+
|
|
7979
|
+
// The task a conversation's console asks for: in the live or the ended set
|
|
7980
|
+
// of the conversation, under its own id or its worker's session id (the
|
|
7981
|
+
// key the worker posts under), as the stop route resolves it.
|
|
7982
|
+
function findConversationTask(conversationId, taskId) {
|
|
7983
|
+
const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
|
|
7984
|
+
if (!conversation) return { conversation: null, found: null, sessionId: '' };
|
|
7985
|
+
const sessionId = String(conversation.sdk_session_id || conversationId);
|
|
7986
|
+
const found = backgroundTaskStore?.findTask?.([conversationId, sessionId], taskId) || null;
|
|
7987
|
+
return { conversation, found, sessionId };
|
|
7988
|
+
}
|
|
7989
|
+
|
|
7990
|
+
// The providers whose workers name a shell task's output (a file, a spool
|
|
7991
|
+
// or a snapshot). A task of theirs without one comes from a worker that
|
|
7992
|
+
// started before the console existed.
|
|
7993
|
+
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
|
|
7994
|
+
const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
|
|
7995
|
+
|
|
7996
|
+
// Why a task has no output to show, for the console's `none` answer. The
|
|
7997
|
+
// texts are the page's own (task-console-render.mjs), so the row's hover
|
|
7998
|
+
// title and the console say the same.
|
|
7999
|
+
function noOutputMessage(conversationId, task) {
|
|
8000
|
+
if (taskKindOf(task?.taskType) === 'subagent') return SUBAGENT_OUTPUT_MESSAGE;
|
|
8001
|
+
let provider = '';
|
|
8002
|
+
try {
|
|
8003
|
+
provider = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type || '').trim().toLowerCase();
|
|
8004
|
+
} catch {}
|
|
8005
|
+
if (!provider || TASK_OUTPUT_PROVIDERS.has(provider)) {
|
|
8006
|
+
return TASK_OUTPUT_NONE_MESSAGES.noOutputFile;
|
|
8007
|
+
}
|
|
8008
|
+
return TASK_OUTPUT_NONE_MESSAGES.provider;
|
|
8009
|
+
}
|
|
8010
|
+
|
|
8011
|
+
// A task the session's task list shows that the task store does not hold:
|
|
8012
|
+
// a sub-agent (live or ended, its run is its record) or a task that ended
|
|
8013
|
+
// without a console. The activity tracker knows it by its task or run id.
|
|
8014
|
+
function trackedTask(conversationId, taskId) {
|
|
8015
|
+
let view = null;
|
|
8016
|
+
try { view = sessionActivityTracker?.describe?.(conversationId) || null; } catch {}
|
|
8017
|
+
const tasks = Array.isArray(view?.tasks) ? view.tasks : [];
|
|
8018
|
+
const found = tasks.find((task) => !task?.placeholder && (String(task?.id || '') === taskId || String(task?.runId || '') === taskId));
|
|
8019
|
+
if (found) return found;
|
|
8020
|
+
const run = stmts.getSubagentRun?.get?.(taskId) || null;
|
|
8021
|
+
if (run && String(run.conversation_id || '') === conversationId) {
|
|
8022
|
+
return { id: taskId, kind: 'subagent', type: run.subagent_type || null, description: run.display_name || '', status: run.status || null };
|
|
8023
|
+
}
|
|
8024
|
+
return null;
|
|
8025
|
+
}
|
|
8026
|
+
|
|
8027
|
+
function taskStallMinutes() {
|
|
8028
|
+
try {
|
|
8029
|
+
return getTaskStallMinutes();
|
|
8030
|
+
} catch {
|
|
8031
|
+
return DEFAULT_TASK_STALL_MINUTES;
|
|
8032
|
+
}
|
|
8033
|
+
}
|
|
8034
|
+
|
|
8035
|
+
// GET /api/conversation/:conversationId/background-task/:taskId/output —
|
|
8036
|
+
// the console's read of a shell or Monitor task's output. Without
|
|
8037
|
+
// `before`: the last `lines` lines (default 2,000, at most 5,000) within
|
|
8038
|
+
// 256 KB; with `before` (a startOffset of an earlier answer): the lines
|
|
8039
|
+
// ending there. Everything is cleaned (controls out, colours kept,
|
|
8040
|
+
// credentials scrubbed) for every reader. Another relay's agent reads it
|
|
8041
|
+
// too (remote_relay read_task_output, read permission): no inbound switch,
|
|
8042
|
+
// nothing it reads changes anything.
|
|
8043
|
+
app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
|
|
8044
|
+
const conversationId = String(req.params.conversationId || '').trim();
|
|
8045
|
+
const taskId = String(req.params.taskId || '').trim();
|
|
8046
|
+
if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
8047
|
+
const { conversation, found, sessionId } = findConversationTask(conversationId, taskId);
|
|
8048
|
+
if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
|
|
8049
|
+
if (!found) {
|
|
8050
|
+
const tracked = trackedTask(conversationId, taskId);
|
|
8051
|
+
if (!tracked) return res.status(404).json({ error: 'Task not found', code: 'task-not-found' });
|
|
8052
|
+
const running = String(tracked.status || 'running') === 'running';
|
|
8053
|
+
const subagent = tracked.kind !== 'shell';
|
|
8054
|
+
return res.json({
|
|
8055
|
+
ok: true,
|
|
8056
|
+
taskId,
|
|
8057
|
+
kind: 'none',
|
|
8058
|
+
ended: !running,
|
|
8059
|
+
status: running ? 'running' : String(tracked.status || 'unknown'),
|
|
8060
|
+
exitCode: running ? null : (Number.isInteger(tracked.exitCode) ? tracked.exitCode : null),
|
|
8061
|
+
health: null,
|
|
8062
|
+
taskStallMinutes: taskStallMinutes(),
|
|
8063
|
+
task: {
|
|
8064
|
+
taskId,
|
|
8065
|
+
taskType: tracked.type || (subagent ? 'local_agent' : 'local_bash'),
|
|
8066
|
+
description: String(tracked.description || ''),
|
|
8067
|
+
startedAt: Date.parse(tracked.startedAt) || null,
|
|
8068
|
+
endedAt: Date.parse(tracked.endedAt) || null,
|
|
8069
|
+
stoppable: false,
|
|
8070
|
+
},
|
|
8071
|
+
message: subagent ? SUBAGENT_OUTPUT_MESSAGE : noOutputMessage(conversationId, { taskType: 'local_bash' }),
|
|
8072
|
+
});
|
|
8073
|
+
}
|
|
8074
|
+
const { task, ended, key } = found;
|
|
8075
|
+
// Held since a relay restart: its worker has not said yet whether it
|
|
8076
|
+
// still runs, so it is neither running nor stoppable.
|
|
8077
|
+
const held = !ended && found.restored === true;
|
|
8078
|
+
const { kind, path } = taskOutputSource(task);
|
|
8079
|
+
const head = {
|
|
8080
|
+
ok: true,
|
|
8081
|
+
taskId,
|
|
8082
|
+
kind,
|
|
8083
|
+
ended,
|
|
8084
|
+
status: ended ? task.status : (held ? 'held' : 'running'),
|
|
8085
|
+
...(held ? { held: true } : {}),
|
|
8086
|
+
exitCode: ended ? (task.exitCode ?? null) : null,
|
|
8087
|
+
health: task.health || null,
|
|
8088
|
+
taskStallMinutes: taskStallMinutes(),
|
|
8089
|
+
task: {
|
|
8090
|
+
taskId,
|
|
8091
|
+
taskType: task.taskType || null,
|
|
8092
|
+
description: task.description || '',
|
|
8093
|
+
...(task.command ? { command: task.command } : {}),
|
|
8094
|
+
startedAt: task.startedAt || null,
|
|
8095
|
+
endedAt: task.endedAt || null,
|
|
8096
|
+
stoppable: !ended && !held && task.stoppable !== false,
|
|
8097
|
+
},
|
|
8098
|
+
};
|
|
8099
|
+
if (kind === 'none') {
|
|
8100
|
+
return res.json({ ...head, message: noOutputMessage(conversationId, task) });
|
|
8101
|
+
}
|
|
8102
|
+
if (kind === 'snapshot') {
|
|
8103
|
+
// The worker answers a request with a post of the task's last 8 KiB;
|
|
8104
|
+
// the console asks again every few seconds and gets the newest copy.
|
|
8105
|
+
const cached = taskOutputSnapshots?.get?.([key, sessionId, conversationId], taskId) || null;
|
|
8106
|
+
if (!ended) {
|
|
8107
|
+
const sent = sendWorkerControl?.(sessionId, { type: 'task_output_snapshot', taskId });
|
|
8108
|
+
if (!sent && !cached) return res.status(409).json({ error: 'Session worker is not connected', code: 'worker-not-connected' });
|
|
8109
|
+
}
|
|
8110
|
+
if (!cached) return res.json({ ...head, pending: true, text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false });
|
|
8111
|
+
return res.json({ ...head, ...shapeSnapshot(cached) });
|
|
8112
|
+
}
|
|
8113
|
+
if (!path) return res.json({ ...head, kind: 'none', message: TASK_OUTPUT_NONE_MESSAGES.noPath });
|
|
8114
|
+
const lines = req.query?.lines;
|
|
8115
|
+
const before = req.query?.before;
|
|
8116
|
+
const read = before !== undefined && before !== ''
|
|
8117
|
+
? await readBefore(path, { before, lines })
|
|
8118
|
+
: await readTail(path, { lines });
|
|
8119
|
+
if (!read.ok) {
|
|
8120
|
+
// No file yet (a spool is named before its first byte) reads as no
|
|
8121
|
+
// output yet; one the relay does not read (a symlink, a folder) is
|
|
8122
|
+
// said so instead of failing.
|
|
8123
|
+
const empty = { text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false };
|
|
8124
|
+
return res.json({ ...head, ...empty, ...(read.code === 'missing' ? {} : { unavailable: read.code }) });
|
|
8125
|
+
}
|
|
8126
|
+
return res.json({ ...head, ...read });
|
|
8127
|
+
});
|
|
8128
|
+
|
|
8129
|
+
// POST /api/background-tasks/:taskId/output-snapshot — a worker's answer
|
|
8130
|
+
// to `task_output_snapshot`: the task's latest output where it has no
|
|
8131
|
+
// file (Claude's getTaskOutput, its last 8 KiB).
|
|
8132
|
+
app.post('/api/background-tasks/:taskId/output-snapshot', auth, (req, res) => {
|
|
8133
|
+
touchCli();
|
|
8134
|
+
const key = String(req.body?.conversationId || '').trim();
|
|
8135
|
+
const taskId = String(req.params.taskId || '').trim();
|
|
8136
|
+
if (!key || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
8137
|
+
if (typeof req.body?.output !== 'string') return res.status(400).json({ error: 'output must be a string' });
|
|
8138
|
+
taskOutputSnapshots?.set?.(key, taskId, {
|
|
8139
|
+
output: req.body.output.slice(0, TASK_OUTPUT_LIMITS.chunkBytes),
|
|
8140
|
+
totalBytes: req.body?.totalBytes,
|
|
8141
|
+
truncated: req.body?.truncated === true,
|
|
8142
|
+
});
|
|
8143
|
+
res.json({ ok: true });
|
|
8144
|
+
});
|
|
8145
|
+
|
|
7772
8146
|
// POST /api/activity — relay sends in-flight activity updates (tool/search sections)
|
|
7773
8147
|
// Optional attempt fence for the live lanes (stream/thought/activity/
|
|
7774
8148
|
// subagent): a writer that names its processing attempt is rejected once
|