@oar-sh/oar 0.9.12 → 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 +206 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/README.md +186 -11
- package/server/claude-worker/claude-session-process.mjs +53 -2
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -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 +57 -3
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- 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 +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +67 -0
- package/server/relay-tools.md +5 -0
- package/server/repositories/message-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +155 -27
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +75 -3
- package/server/server-runtime.mjs +192 -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 +240 -13
- 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 +7 -0
- 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 +187 -19
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-service.mjs +217 -18
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +15 -8
- package/shared/task-output-text.mjs +196 -83
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
package/server/public/index.html
CHANGED
|
@@ -2984,6 +2984,15 @@
|
|
|
2984
2984
|
}
|
|
2985
2985
|
.pending-action-text { overflow-wrap: anywhere; }
|
|
2986
2986
|
.pending-action-cancel { margin-left: 0; }
|
|
2987
|
+
/* The queued relay restart's row (relay-restart-ui.js): the headline, the
|
|
2988
|
+
sessions it waits for (links), held messages, background tasks, buttons. */
|
|
2989
|
+
.relay-restart-row { flex-direction: column; gap: 3px; }
|
|
2990
|
+
.relay-restart-head { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
|
|
2991
|
+
.relay-restart-waited { opacity: 0.85; }
|
|
2992
|
+
.relay-restart-detail { color: var(--text); overflow-wrap: anywhere; max-height: 4.5em; overflow-y: auto; }
|
|
2993
|
+
.relay-restart-session-link { color: inherit; text-decoration: underline; }
|
|
2994
|
+
.relay-restart-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
|
|
2995
|
+
.relay-restart-row[data-asking="1"] .relay-restart-head { font-weight: 600; }
|
|
2987
2996
|
.suspend-host-blockers {
|
|
2988
2997
|
margin: 6px 0 10px;
|
|
2989
2998
|
padding-left: 20px;
|
|
@@ -3438,6 +3447,26 @@
|
|
|
3438
3447
|
font-size: var(--fs-meta-sm); text-decoration: underline; text-underline-offset: 3px;
|
|
3439
3448
|
}
|
|
3440
3449
|
#task-console-stop:hover:not(:disabled) { border-color: #f85149; color: #f85149; }
|
|
3450
|
+
/* "Load earlier" within reach while following the end; the one at the top
|
|
3451
|
+
of the output stays. */
|
|
3452
|
+
#task-console-earlier-top {
|
|
3453
|
+
background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer;
|
|
3454
|
+
font-size: var(--fs-meta-sm); text-decoration: underline; text-underline-offset: 3px;
|
|
3455
|
+
}
|
|
3456
|
+
#task-console-earlier-top:disabled { color: var(--muted); text-decoration: none; cursor: default; }
|
|
3457
|
+
#task-console-earlier-top[hidden] { display: none; }
|
|
3458
|
+
/* Where the output of a command piped into tail stops until it ends. */
|
|
3459
|
+
#task-console-hold {
|
|
3460
|
+
margin: 8px 0 0; padding: 6px 10px; border-left: 3px solid var(--warn); border-radius: 4px;
|
|
3461
|
+
background: var(--bg2); color: var(--text); font-size: var(--fs-meta-sm);
|
|
3462
|
+
}
|
|
3463
|
+
#task-console-hold[hidden] { display: none; }
|
|
3464
|
+
.tc-hold-text { font-weight: 600; }
|
|
3465
|
+
.tc-hold-hint { color: var(--muted); margin-top: 2px; }
|
|
3466
|
+
/* Wider screens: the console ends above the composer's open task row, so
|
|
3467
|
+
the row's chip, clock and Stop stay in view (task-console.mjs sets the
|
|
3468
|
+
bottom). Phones keep the full-screen console. */
|
|
3469
|
+
#task-console-modal.tc-above-panel .task-console-dialog { height: 100%; }
|
|
3441
3470
|
#task-console-scroll {
|
|
3442
3471
|
flex: 1; overflow: auto; overscroll-behavior: contain; padding: 8px 14px 28px;
|
|
3443
3472
|
background: var(--bg);
|
|
@@ -3496,6 +3525,9 @@
|
|
|
3496
3525
|
.tc-bg-13 { background: var(--tc-ansi-13); }
|
|
3497
3526
|
.tc-bg-14 { background: var(--tc-ansi-14); }
|
|
3498
3527
|
.tc-bg-15 { background: var(--tc-ansi-15); }
|
|
3528
|
+
/* Inverse text (SGR 7) with a side left unset: the console's own colours, swapped. */
|
|
3529
|
+
.tc-fg-inverse { color: var(--bg); }
|
|
3530
|
+
.tc-bg-inverse { background: var(--text); }
|
|
3499
3531
|
#task-console-status { color: var(--muted); padding: 10px 0; font-size: var(--fs-meta-sm); }
|
|
3500
3532
|
#task-console-status[hidden] { display: none; }
|
|
3501
3533
|
#task-console-jump {
|
|
@@ -3712,6 +3744,8 @@
|
|
|
3712
3744
|
.relay-event-time { flex: 0 0 auto; }
|
|
3713
3745
|
.relay-event-lines { margin: 2px 0 4px; padding-left: 18px; }
|
|
3714
3746
|
.relay-event-line { margin: 2px 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
3747
|
+
/* The relay's note to carry on after a restart (relay-restart-note-view.mjs). */
|
|
3748
|
+
.relay-restart-note-text { padding-left: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
3715
3749
|
.relay-event-line.relay-event-question-asked,
|
|
3716
3750
|
.relay-event-line.relay-event-error,
|
|
3717
3751
|
.relay-event-line.relay-event-task-failed,
|
|
@@ -7229,6 +7263,30 @@
|
|
|
7229
7263
|
>
|
|
7230
7264
|
</div>
|
|
7231
7265
|
<div class="settings-help" style="margin-top: 6px;">A shell or Monitor task that has written no output for this long gets an amber "no output for N min" note in the composer panel and its console.</div>
|
|
7266
|
+
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7267
|
+
<label for="restart-resume-toggle">After a relay restart, tell interrupted sessions to carry on</label>
|
|
7268
|
+
<input
|
|
7269
|
+
id="restart-resume-toggle"
|
|
7270
|
+
type="checkbox"
|
|
7271
|
+
onchange="updateRestartResumeSettingFromToggle(this.checked)"
|
|
7272
|
+
aria-label="After a relay restart, tell interrupted sessions to carry on"
|
|
7273
|
+
>
|
|
7274
|
+
</div>
|
|
7275
|
+
<div class="settings-help" style="margin-top: 6px;">Most turns a restart interrupts run again by themselves. A turn that cannot (the agent had already taken the message, or had started on its own after a background task) just stops. With this on, such a session gets one relay note once the relay is back, saying when it restarted and asking the agent to check what it was doing and carry on. Your message is never sent again; idle, archived and usage-limit-paused sessions get nothing. Off by default; a setting of this relay.</div>
|
|
7276
|
+
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7277
|
+
<label for="relay-restart-max-wait-select">Ask after a queued relay restart has waited</label>
|
|
7278
|
+
<select id="relay-restart-max-wait-select" onchange="updateRelayRestartMaxWaitSetting(this.value)" aria-label="Ask after a queued relay restart has waited">
|
|
7279
|
+
<option value="5">5 min</option>
|
|
7280
|
+
<option value="10">10 min</option>
|
|
7281
|
+
<option value="15">15 min</option>
|
|
7282
|
+
<option value="30" selected>30 min</option>
|
|
7283
|
+
<option value="60">1 h</option>
|
|
7284
|
+
<option value="120">2 h</option>
|
|
7285
|
+
<option value="240">4 h</option>
|
|
7286
|
+
<option value="0">No limit</option>
|
|
7287
|
+
</select>
|
|
7288
|
+
</div>
|
|
7289
|
+
<div class="settings-help" style="margin-top: 6px;">A queued relay restart waits for the running turns, and new messages wait for the restart. After this long the banner asks whether to restart now, keep waiting or cancel; it never restarts while the turns run, and still restarts as soon as they end. No limit waits until the turns end.</div>
|
|
7232
7290
|
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7233
7291
|
<label for="subagent-record-retention-select">Keep sub-agent tool output</label>
|
|
7234
7292
|
<select id="subagent-record-retention-select" onchange="updateSubagentRecordRetentionSetting(this.value)" aria-label="Keep sub-agent tool output">
|
|
@@ -8073,6 +8131,10 @@
|
|
|
8073
8131
|
<label for="push-event-host-suspend-toggle">Host suspend fired or was dropped</label>
|
|
8074
8132
|
<input id="push-event-host-suspend-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
|
|
8075
8133
|
</div>
|
|
8134
|
+
<div class="settings-row" style="width: 100%; margin-top: 6px; margin-bottom: 0;">
|
|
8135
|
+
<label for="push-event-relay-restart-toggle">Queued relay restart waited its maximum time</label>
|
|
8136
|
+
<input id="push-event-relay-restart-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
|
|
8137
|
+
</div>
|
|
8076
8138
|
<label style="margin-top: 10px;">Devices</label>
|
|
8077
8139
|
<div id="push-device-list" style="width: 100%;"></div>
|
|
8078
8140
|
<div class="settings-help">Notifications are only sent while no device has the app in the foreground. Content defaults to generic text; message previews are opt-in per device.</div>
|
|
@@ -8251,11 +8313,16 @@
|
|
|
8251
8313
|
<span id="task-console-stall" class="tc-stall" hidden></span>
|
|
8252
8314
|
<button id="task-console-chip" class="bg-task-chip" type="button" hidden></button>
|
|
8253
8315
|
<button id="task-console-failure" type="button" hidden>first failure</button>
|
|
8316
|
+
<button id="task-console-earlier-top" type="button" hidden>Load earlier</button>
|
|
8254
8317
|
</div>
|
|
8255
8318
|
</div>
|
|
8256
8319
|
<div id="task-console-scroll">
|
|
8257
8320
|
<button id="task-console-earlier" type="button" hidden>Load earlier</button>
|
|
8258
8321
|
<pre id="task-console-body"></pre>
|
|
8322
|
+
<div id="task-console-hold" role="note" hidden>
|
|
8323
|
+
<div class="tc-hold-text"></div>
|
|
8324
|
+
<div class="tc-hold-hint"></div>
|
|
8325
|
+
</div>
|
|
8259
8326
|
<div id="task-console-status" hidden></div>
|
|
8260
8327
|
</div>
|
|
8261
8328
|
<button id="task-console-jump" type="button" hidden>Jump to end ↓</button>
|
package/server/relay-tools.md
CHANGED
|
@@ -22,6 +22,8 @@ Note: shutdown is queued and only completes when the current turn finishes, so i
|
|
|
22
22
|
|
|
23
23
|
Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.
|
|
24
24
|
|
|
25
|
+
Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.
|
|
26
|
+
|
|
25
27
|
## Preview servers
|
|
26
28
|
|
|
27
29
|
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
|
|
@@ -39,9 +41,12 @@ This relay itself is listed with "self": true when its owner switched agent sess
|
|
|
39
41
|
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
40
42
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
|
|
41
43
|
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
|
|
44
|
+
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches ("unknown model 'gpt-5-4' for Copilot; did you mean 'gpt-5.4'?") and nothing is created or sent: pick from the list instead of guessing. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
|
|
42
45
|
- A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
|
|
43
46
|
- Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
|
|
44
47
|
- Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
|
|
45
48
|
- `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
|
|
46
49
|
|
|
50
|
+
On this relay and on a paired one, `subscribe` wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command.
|
|
51
|
+
|
|
47
52
|
Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
|
|
@@ -37,6 +37,9 @@ export function createMessageRepository(db) {
|
|
|
37
37
|
listPinnedMessages: messagePins.listPinnedMessages,
|
|
38
38
|
countPinnedMessages: messagePins.countPinnedMessages,
|
|
39
39
|
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
40
|
+
// The model of the conversation's latest user turn: one of the
|
|
41
|
+
// fallbacks for a send that names no model.
|
|
42
|
+
getLatestUserMessageModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND role = 'user' AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
40
43
|
getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
41
44
|
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
42
45
|
// Provenance is derived from the authenticated responder identity at
|
|
@@ -27,7 +27,9 @@ function parseOrigin(json) {
|
|
|
27
27
|
|
|
28
28
|
export function createRemoteRelayRepository(db) {
|
|
29
29
|
const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
|
|
30
|
-
|
|
30
|
+
// Two unlocks can share a millisecond (Windows' clock is coarse); the rowid
|
|
31
|
+
// keeps them in the order they were recorded rather than by relay id.
|
|
32
|
+
const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC`);
|
|
31
33
|
const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
|
|
32
34
|
// The relay's unlocks and the approvals stored under its id (`<id>:watch`).
|
|
33
35
|
const deleteUnlocksForRelay = db.prepare(`
|
|
@@ -64,7 +66,7 @@ export function createRemoteRelayRepository(db) {
|
|
|
64
66
|
hasUnlock(conversationId, remoteRelayId) {
|
|
65
67
|
return !!hasUnlock.get(String(conversationId || ''), String(remoteRelayId || ''));
|
|
66
68
|
},
|
|
67
|
-
/** `[{ remoteRelayId, messageId, createdAt }]`, oldest first. */
|
|
69
|
+
/** `[{ remoteRelayId, messageId, createdAt }]`, oldest first, ties in the order recorded. */
|
|
68
70
|
listUnlocks(conversationId) {
|
|
69
71
|
return listUnlocks.all(String(conversationId || '')).map((row) => ({
|
|
70
72
|
remoteRelayId: row.remote_relay_id,
|
|
@@ -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 {
|
|
@@ -45,6 +46,8 @@ import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/backg
|
|
|
45
46
|
import {
|
|
46
47
|
DEFAULT_TASK_STALL_MINUTES,
|
|
47
48
|
TASK_OUTPUT_LIMITS,
|
|
49
|
+
cleanTaskText,
|
|
50
|
+
normalizeTaskCommand,
|
|
48
51
|
normalizeTaskOutputKind,
|
|
49
52
|
normalizeTaskOutputPath,
|
|
50
53
|
} from '../../shared/task-output-text.mjs';
|
|
@@ -68,6 +71,7 @@ import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limi
|
|
|
68
71
|
import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
|
|
69
72
|
import { createLocalRequestCheck } from '../services/request-boundary.mjs';
|
|
70
73
|
import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
|
|
74
|
+
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
71
75
|
import {
|
|
72
76
|
admitRemoteRelayRequest,
|
|
73
77
|
readRemoteRelayRequest,
|
|
@@ -244,9 +248,17 @@ function findRecentDuplicateUserMessage({
|
|
|
244
248
|
const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
|
|
245
249
|
.map((id) => String(id || '').trim()).filter(Boolean));
|
|
246
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));
|
|
247
258
|
|
|
248
259
|
for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
|
|
249
260
|
if (isExempt(row)) continue;
|
|
261
|
+
if (failedTurnIds.has(String(row?.id || '').trim())) continue;
|
|
250
262
|
const rowText = normalizeDuplicateMessageText(row?.text || '');
|
|
251
263
|
if (!rowText || rowText !== fingerprint) continue;
|
|
252
264
|
const rowTime = Date.parse(row?.timestamp || '');
|
|
@@ -1792,6 +1804,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1792
1804
|
taskOutputSnapshots = null,
|
|
1793
1805
|
// Has the health poller read soon (a task with a console ended).
|
|
1794
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 = () => {},
|
|
1795
1810
|
getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
|
|
1796
1811
|
sendWorkerControl,
|
|
1797
1812
|
ensureSessionId,
|
|
@@ -2727,10 +2742,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2727
2742
|
// quietly.
|
|
2728
2743
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
2729
2744
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
2745
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2730
2746
|
continue;
|
|
2731
2747
|
}
|
|
2732
2748
|
if (row.consumed_at) {
|
|
2733
2749
|
failConsumedQueueRow(row, { reason });
|
|
2750
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2734
2751
|
continue;
|
|
2735
2752
|
}
|
|
2736
2753
|
const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
|
|
@@ -2767,6 +2784,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2767
2784
|
error: reason,
|
|
2768
2785
|
});
|
|
2769
2786
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
2787
|
+
noteRestartTurnOutcome(row, 'requeued');
|
|
2770
2788
|
}
|
|
2771
2789
|
for (const row of rowsToFail) {
|
|
2772
2790
|
const failureRecord = buildHeartbeatIdleReplayFailure({
|
|
@@ -2792,6 +2810,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2792
2810
|
ok: false,
|
|
2793
2811
|
error: failureRecord.code,
|
|
2794
2812
|
});
|
|
2813
|
+
// Started, so not replayed: a turn the agent never heard the end of.
|
|
2814
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
2795
2815
|
}
|
|
2796
2816
|
}
|
|
2797
2817
|
if (rowsToRecover.length || rowsToFail.length) {
|
|
@@ -4429,7 +4449,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4429
4449
|
const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
|
|
4430
4450
|
const sessionWorkerRoutingEnabled = isSessionWorkerRoutingEnabled(featureFlags);
|
|
4431
4451
|
let conversationSdkSessionId = null;
|
|
4432
|
-
|
|
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);
|
|
4433
4463
|
const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
|
|
4434
4464
|
const normalizedAttachments = normalizeAttachments(rawAttachments);
|
|
4435
4465
|
const requestedAttachmentCount = Math.min(
|
|
@@ -4786,6 +4816,34 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4786
4816
|
// sends set_model before the message), so a changed model is taken as
|
|
4787
4817
|
// for a local Claude chat.
|
|
4788
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
|
+
}
|
|
4789
4847
|
const modelResolution = useOpenAIProvider
|
|
4790
4848
|
? {
|
|
4791
4849
|
ok: !!openAIModel,
|
|
@@ -4826,7 +4884,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4826
4884
|
reasoningEffort: null,
|
|
4827
4885
|
error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
|
|
4828
4886
|
}
|
|
4829
|
-
:
|
|
4887
|
+
: githubModelResolution))));
|
|
4830
4888
|
if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
|
|
4831
4889
|
const requestedModel = String(modelResolution.model || '').trim();
|
|
4832
4890
|
const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
|
|
@@ -4851,6 +4909,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4851
4909
|
code: 'IMAGE_TARGET_REQUIRES_IMAGE_MODEL',
|
|
4852
4910
|
});
|
|
4853
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
|
+
);
|
|
4854
4921
|
// Claude conversations validate effort against the Claude provider's
|
|
4855
4922
|
// per-model levels ('none' = SDK default). Effort is per-turn: each Claude
|
|
4856
4923
|
// turn is a fresh query(), so it can change between messages.
|
|
@@ -4859,6 +4926,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4859
4926
|
const supported = Array.isArray(configuredClaude?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
|
|
4860
4927
|
? configuredClaude.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
|
|
4861
4928
|
: [...DEFAULT_CLAUDE_REASONING_EFFORTS];
|
|
4929
|
+
const kept = conversationEffortFor(supported);
|
|
4930
|
+
if (kept) return { ok: true, effort: kept, supported };
|
|
4862
4931
|
const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4863
4932
|
return { ok: true, effort: effort === 'none' ? 'none' : effort, supported };
|
|
4864
4933
|
};
|
|
@@ -4874,8 +4943,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4874
4943
|
// the worker validates it against the resolved model's live params
|
|
4875
4944
|
// and falls back to the model default on any mismatch. Clamping to
|
|
4876
4945
|
// 'none' here would actively disable thinking on the resolved model.
|
|
4877
|
-
|
|
4946
|
+
// The conversation's own effort goes through the same way.
|
|
4947
|
+
return { ok: true, effort: requestedEffort || conversationReasoningEffort, supported: [] };
|
|
4878
4948
|
}
|
|
4949
|
+
const kept = conversationEffortFor(discovered);
|
|
4950
|
+
if (kept) return { ok: true, effort: kept, supported: discovered };
|
|
4879
4951
|
const effort = discovered.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4880
4952
|
return { ok: true, effort, supported: discovered };
|
|
4881
4953
|
};
|
|
@@ -4884,11 +4956,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4884
4956
|
const supported = Array.isArray(configuredGrok?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
|
|
4885
4957
|
? configuredGrok.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
|
|
4886
4958
|
: ['none', 'low', 'medium', 'high'];
|
|
4959
|
+
const kept = conversationEffortFor(supported);
|
|
4960
|
+
if (kept) return { ok: true, effort: kept, supported };
|
|
4887
4961
|
const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
|
|
4888
4962
|
return { ok: true, effort, supported };
|
|
4889
4963
|
};
|
|
4890
4964
|
let reasoningResolution = useOpenAIProvider
|
|
4891
|
-
? resolveOpenAIReasoningEffort(
|
|
4965
|
+
? resolveOpenAIReasoningEffort(
|
|
4966
|
+
explicitReasoningEffort || conversationEffortFor(openAIReasoningEffortsForModel(openAIModel)) || '',
|
|
4967
|
+
openAIModel,
|
|
4968
|
+
)
|
|
4892
4969
|
: (runtimeUsesClaude
|
|
4893
4970
|
? resolveClaudeReasoningEffort()
|
|
4894
4971
|
: (runtimeUsesCursor
|
|
@@ -4902,15 +4979,21 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4902
4979
|
requestedModel,
|
|
4903
4980
|
explicitReasoningEffort || modelResolution.reasoningEffort || null,
|
|
4904
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.
|
|
4905
4987
|
if (!reasoningResolution?.ok && !explicitReasoningEffort) {
|
|
4906
4988
|
const supportedEfforts = Array.isArray(reasoningResolution?.supported)
|
|
4907
4989
|
? reasoningResolution.supported.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
|
|
4908
4990
|
: [];
|
|
4909
|
-
const
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4991
|
+
const fallbackEffort = fallbackReasoningEffort(
|
|
4992
|
+
[modelResolution.reasoningEffort, conversationReasoningEffort],
|
|
4993
|
+
supportedEfforts,
|
|
4994
|
+
);
|
|
4995
|
+
if (fallbackEffort) {
|
|
4996
|
+
reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackEffort);
|
|
4914
4997
|
}
|
|
4915
4998
|
}
|
|
4916
4999
|
if (!reasoningResolution?.ok) {
|
|
@@ -4920,6 +5003,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4920
5003
|
});
|
|
4921
5004
|
}
|
|
4922
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);
|
|
4923
5009
|
let normalizedContextTier = requestedContextTier;
|
|
4924
5010
|
if (openAIImageModel) {
|
|
4925
5011
|
normalizedContextTier = normalizeOpenAIImageSize(requestedContextTier) || 'auto';
|
|
@@ -4964,6 +5050,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4964
5050
|
return res.json({
|
|
4965
5051
|
ok: true,
|
|
4966
5052
|
duplicate: true,
|
|
5053
|
+
queued: false,
|
|
4967
5054
|
alreadyResent: true,
|
|
4968
5055
|
duplicateOfMessageId: resend.alreadyResentAs,
|
|
4969
5056
|
conversationId,
|
|
@@ -4994,6 +5081,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4994
5081
|
return res.json({
|
|
4995
5082
|
ok: true,
|
|
4996
5083
|
duplicate: true,
|
|
5084
|
+
// Said outright for a caller that reads only `ok`: nothing new runs.
|
|
5085
|
+
queued: false,
|
|
4997
5086
|
duplicateWindowMs: DUPLICATE_USER_MESSAGE_WINDOW_MS,
|
|
4998
5087
|
duplicateSource: duplicateMessage.source,
|
|
4999
5088
|
duplicateOfMessageId: duplicateMessage.messageId,
|
|
@@ -5175,15 +5264,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5175
5264
|
}
|
|
5176
5265
|
// Another relay's agent picks the model and mode of its own turn; the
|
|
5177
5266
|
// composer keeps restoring the user's choice for this conversation.
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
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(
|
|
5181
5279
|
convId,
|
|
5182
5280
|
requestedRelayMode,
|
|
5183
|
-
requestedModel,
|
|
5184
|
-
requestedReasoningEffort,
|
|
5281
|
+
explicitModelGiven ? requestedModel : savedModel,
|
|
5282
|
+
explicitEffortGiven ? requestedReasoningEffort : '',
|
|
5185
5283
|
now,
|
|
5186
5284
|
);
|
|
5285
|
+
}
|
|
5187
5286
|
stmts.insertQ.run(
|
|
5188
5287
|
msgId,
|
|
5189
5288
|
convId,
|
|
@@ -5527,6 +5626,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5527
5626
|
const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
|
|
5528
5627
|
for (const id of activeQueueMessageIds) {
|
|
5529
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');
|
|
5530
5632
|
}
|
|
5531
5633
|
recoverOwnedProcessingRowsForSession(requesterSessionId, {
|
|
5532
5634
|
excludeMessageIds: activeQueueMessageIds,
|
|
@@ -6570,7 +6672,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6570
6672
|
|| rawRestart === 1
|
|
6571
6673
|
|| String(rawRestart || '').trim().toLowerCase() === 'true'
|
|
6572
6674
|
|| String(rawRestart || '').trim() === '1';
|
|
6573
|
-
|
|
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 });
|
|
6574
6686
|
return res.json({ ok: true, ...result });
|
|
6575
6687
|
});
|
|
6576
6688
|
|
|
@@ -7694,7 +7806,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7694
7806
|
// Only a number the worker sent: a missing or null exit code stays null
|
|
7695
7807
|
// (Number(null) would read as a clean exit).
|
|
7696
7808
|
const exitCode = wholeNumber(req.body?.exitCode);
|
|
7697
|
-
|
|
7809
|
+
// Scrubbed like the output: a description can be the command line.
|
|
7810
|
+
const settledDescription = cleanTaskText(String(req.body?.description || ''), 500);
|
|
7698
7811
|
// The store is keyed as the worker posts. A task with a console stays
|
|
7699
7812
|
// viewable as an ended row; one that ended while this runtime was away
|
|
7700
7813
|
// becomes one when the post names its output.
|
|
@@ -7706,6 +7819,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7706
7819
|
outputPath: settledOutputPath || undefined,
|
|
7707
7820
|
outputKind: settledOutputPath ? normalizeTaskOutputKind(req.body?.outputKind, settledOutputPath) : undefined,
|
|
7708
7821
|
description: settledDescription,
|
|
7822
|
+
command: normalizeTaskCommand(req.body?.command) || undefined,
|
|
7709
7823
|
taskType: String(req.body?.taskType || '').trim().slice(0, 80),
|
|
7710
7824
|
startedAt: Number(req.body?.startedAt) || null,
|
|
7711
7825
|
}) || null;
|
|
@@ -7717,7 +7831,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7717
7831
|
taskId,
|
|
7718
7832
|
toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
|
|
7719
7833
|
status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
|
|
7720
|
-
summary: String(req.body?.summary || '')
|
|
7834
|
+
summary: cleanTaskText(String(req.body?.summary || ''), 500) || null,
|
|
7721
7835
|
exitCode: exitCode ?? null,
|
|
7722
7836
|
// What the task was, from a worker that sends it: the end of a task
|
|
7723
7837
|
// this runtime never knew (it ended while the relay was away) is
|
|
@@ -7747,11 +7861,18 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7747
7861
|
.map((task) => ({
|
|
7748
7862
|
taskId: String(task?.taskId || '').trim(),
|
|
7749
7863
|
taskType: String(task?.taskType || '').trim(),
|
|
7750
|
-
|
|
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,
|
|
7751
7872
|
startedAt: Number(task?.startedAt) || null,
|
|
7752
|
-
summary: String(task?.summary || '')
|
|
7873
|
+
summary: cleanTaskText(String(task?.summary || ''), 500) || null,
|
|
7753
7874
|
lastToolName: String(task?.lastToolName || '').trim() || null,
|
|
7754
|
-
lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ')
|
|
7875
|
+
lastToolCall: cleanTaskText(String(task?.lastToolCall || '').replace(/\s+/g, ' '), 300) || null,
|
|
7755
7876
|
totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
|
|
7756
7877
|
subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
|
|
7757
7878
|
model: String(task?.model || '').trim().slice(0, 120) || null,
|
|
@@ -7870,9 +7991,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7870
7991
|
// or a snapshot). A task of theirs without one comes from a worker that
|
|
7871
7992
|
// started before the console existed.
|
|
7872
7993
|
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
|
|
7873
|
-
const SUBAGENT_OUTPUT_MESSAGE =
|
|
7994
|
+
const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
|
|
7874
7995
|
|
|
7875
|
-
// Why a task has no output to show, for the console's `none` answer.
|
|
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.
|
|
7876
7999
|
function noOutputMessage(conversationId, task) {
|
|
7877
8000
|
if (taskKindOf(task?.taskType) === 'subagent') return SUBAGENT_OUTPUT_MESSAGE;
|
|
7878
8001
|
let provider = '';
|
|
@@ -7880,9 +8003,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7880
8003
|
provider = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type || '').trim().toLowerCase();
|
|
7881
8004
|
} catch {}
|
|
7882
8005
|
if (!provider || TASK_OUTPUT_PROVIDERS.has(provider)) {
|
|
7883
|
-
return
|
|
8006
|
+
return TASK_OUTPUT_NONE_MESSAGES.noOutputFile;
|
|
7884
8007
|
}
|
|
7885
|
-
return
|
|
8008
|
+
return TASK_OUTPUT_NONE_MESSAGES.provider;
|
|
7886
8009
|
}
|
|
7887
8010
|
|
|
7888
8011
|
// A task the session's task list shows that the task store does not hold:
|
|
@@ -7949,13 +8072,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7949
8072
|
});
|
|
7950
8073
|
}
|
|
7951
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;
|
|
7952
8078
|
const { kind, path } = taskOutputSource(task);
|
|
7953
8079
|
const head = {
|
|
7954
8080
|
ok: true,
|
|
7955
8081
|
taskId,
|
|
7956
8082
|
kind,
|
|
7957
8083
|
ended,
|
|
7958
|
-
status: ended ? task.status : 'running',
|
|
8084
|
+
status: ended ? task.status : (held ? 'held' : 'running'),
|
|
8085
|
+
...(held ? { held: true } : {}),
|
|
7959
8086
|
exitCode: ended ? (task.exitCode ?? null) : null,
|
|
7960
8087
|
health: task.health || null,
|
|
7961
8088
|
taskStallMinutes: taskStallMinutes(),
|
|
@@ -7963,9 +8090,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7963
8090
|
taskId,
|
|
7964
8091
|
taskType: task.taskType || null,
|
|
7965
8092
|
description: task.description || '',
|
|
8093
|
+
...(task.command ? { command: task.command } : {}),
|
|
7966
8094
|
startedAt: task.startedAt || null,
|
|
7967
8095
|
endedAt: task.endedAt || null,
|
|
7968
|
-
stoppable: !ended && task.stoppable !== false,
|
|
8096
|
+
stoppable: !ended && !held && task.stoppable !== false,
|
|
7969
8097
|
},
|
|
7970
8098
|
};
|
|
7971
8099
|
if (kind === 'none') {
|
|
@@ -7982,7 +8110,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7982
8110
|
if (!cached) return res.json({ ...head, pending: true, text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false });
|
|
7983
8111
|
return res.json({ ...head, ...shapeSnapshot(cached) });
|
|
7984
8112
|
}
|
|
7985
|
-
if (!path) return res.json({ ...head, kind: 'none', message:
|
|
8113
|
+
if (!path) return res.json({ ...head, kind: 'none', message: TASK_OUTPUT_NONE_MESSAGES.noPath });
|
|
7986
8114
|
const lines = req.query?.lines;
|
|
7987
8115
|
const before = req.query?.before;
|
|
7988
8116
|
const read = before !== undefined && before !== ''
|