@songsid/agend 2.2.0-beta.5 → 2.2.0-beta.7
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/dist/agent-cli-instructions.md +6 -2
- package/dist/agent-cli.js +9 -5
- package/dist/agent-cli.js.map +1 -1
- package/dist/backend/claude-carry.d.ts +22 -0
- package/dist/backend/claude-carry.js +170 -0
- package/dist/backend/claude-carry.js.map +1 -0
- package/dist/backend/claude-code.d.ts +16 -0
- package/dist/backend/claude-code.js +92 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +7 -0
- package/dist/backend/credential-profile.js +7 -1
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/kiro.js +5 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +67 -0
- package/dist/backend/muse.js +370 -44
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +7 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/background-work-ended.d.ts +18 -0
- package/dist/background-work-ended.js +30 -0
- package/dist/background-work-ended.js.map +1 -0
- package/dist/channel/adapters/discord.js +12 -1
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +4 -5
- package/dist/channel/adapters/telegram.js +39 -17
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +3 -3
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-edit-errors.d.ts +5 -0
- package/dist/channel/message-edit-errors.js +24 -0
- package/dist/channel/message-edit-errors.js.map +1 -0
- package/dist/channel/types.d.ts +3 -0
- package/dist/cli.js +25 -10
- package/dist/cli.js.map +1 -1
- package/dist/cross-instance-envelope.js +2 -0
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +6 -0
- package/dist/daemon.js +165 -54
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +27 -0
- package/dist/delivery-outbox.js +175 -6
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +4 -1
- package/dist/fleet-manager.d.ts +110 -6
- package/dist/fleet-manager.js +558 -100
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +44 -9
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +14 -1
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
- package/dist/human-delivery-receipts.d.ts +83 -0
- package/dist/human-delivery-receipts.js +194 -0
- package/dist/human-delivery-receipts.js.map +1 -0
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +1 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +22 -2
- package/dist/locale.js.map +1 -1
- package/dist/needs-you-hub.js +2 -0
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +6 -0
- package/dist/needs-you-live.js +23 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/outbound-handlers.d.ts +10 -0
- package/dist/outbound-handlers.js +39 -14
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +15 -13
- package/dist/outbound-schemas.js +5 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-link-progress.d.ts +1 -1
- package/dist/public-link-progress.js +8 -2
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.d.ts +2 -1
- package/dist/public-preview.js +8 -5
- package/dist/public-preview.js.map +1 -1
- package/dist/public-web-link.d.ts +4 -1
- package/dist/public-web-link.js +27 -10
- package/dist/public-web-link.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +3 -0
- package/dist/reply-buttons-controller.js +3 -2
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/restart-progress.d.ts +4 -0
- package/dist/restart-progress.js +7 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/service-installer.d.ts +31 -0
- package/dist/service-installer.js +41 -2
- package/dist/service-installer.js.map +1 -1
- package/dist/topic-commands.d.ts +3 -2
- package/dist/topic-commands.js +17 -5
- package/dist/topic-commands.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -15
- package/dist/tunnel/cloudflared.js +36 -1
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/types.d.ts +5 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/ui/chat-strings.js +8 -2
- package/dist/ui/chat-thread.js +24 -4
- package/dist/ui/chat-time.js +80 -0
- package/dist/ui/preview-panel.js +7 -1
- package/dist/ui/preview.js +5 -1
- package/dist/ui/shared/app-stream.js +21 -4
- package/dist/ui/shared/app.css +9 -3
- package/dist/ui/shared/usage-dialog.js +10 -1
- package/dist/update-install.d.ts +6 -2
- package/dist/update-install.js +8 -2
- package/dist/update-install.js.map +1 -1
- package/dist/update-marker.d.ts +11 -0
- package/dist/update-marker.js +12 -0
- package/dist/update-marker.js.map +1 -1
- package/dist/web-api.d.ts +5 -1
- package/dist/web-api.js +8 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-commands.d.ts +2 -2
- package/dist/web-commands.js +3 -2
- package/dist/web-commands.js.map +1 -1
- package/dist/web-reply-files.d.ts +45 -0
- package/dist/web-reply-files.js +223 -0
- package/dist/web-reply-files.js.map +1 -0
- package/dist/web-server-drain.d.ts +10 -0
- package/dist/web-server-drain.js +50 -0
- package/dist/web-server-drain.js.map +1 -0
- package/dist/web-upload.d.ts +17 -4
- package/dist/web-upload.js +64 -13
- package/dist/web-upload.js.map +1 -1
- package/package.json +1 -1
- package/templates/launchd.plist.ejs +3 -1
- package/templates/systemd.service.ejs +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
|
|
1
2
|
import { SettingsBaselines } from "./settings-baseline.js";
|
|
2
3
|
import { SettingsConfirmationStore } from "./settings-confirmation.js";
|
|
3
4
|
import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
@@ -8,6 +9,7 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
|
8
9
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
9
10
|
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
10
11
|
import { handleWebIconRequest } from "./web-icons.js";
|
|
12
|
+
import { trackWebServer } from "./web-server-drain.js";
|
|
11
13
|
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
12
14
|
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
13
15
|
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
@@ -30,7 +32,7 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
|
|
|
30
32
|
import { fileURLToPath } from "node:url";
|
|
31
33
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
32
34
|
import { fleetLabel } from "./fleet-label.js";
|
|
33
|
-
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
35
|
+
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
34
36
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
35
37
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
36
38
|
import { readFleetMemory } from "./process-memory.js";
|
|
@@ -137,6 +139,7 @@ import { WebPrefsStore } from "./web-prefs.js";
|
|
|
137
139
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
138
140
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
139
141
|
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
142
|
+
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
140
143
|
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
141
144
|
import { downloadStickerImage } from "./channel/sticker-download.js";
|
|
142
145
|
import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
|
|
@@ -170,6 +173,7 @@ import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
|
170
173
|
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
171
174
|
import { readLastInboundAt } from "./daemon.js";
|
|
172
175
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
176
|
+
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
173
177
|
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
174
178
|
import { TargetQueueWorker } from "./target-queue-worker.js";
|
|
175
179
|
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
@@ -203,7 +207,8 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
203
207
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
204
208
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
205
209
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
206
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
210
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
211
|
+
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
207
212
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
208
213
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
209
214
|
import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
@@ -257,6 +262,10 @@ export function isOrphanInstanceWindowName(name) {
|
|
|
257
262
|
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
258
263
|
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
259
264
|
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
265
|
+
/** #1753: a carried conversation this large is logged as a warning (it is still carried). */
|
|
266
|
+
const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
|
|
267
|
+
/** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
|
|
268
|
+
const TG_WEB_START_WINDOW_MS = 10 * 60_000;
|
|
260
269
|
const CANCEL_BTN_MAX_RETRIES = 3;
|
|
261
270
|
/** Backstop: every 5min, retire a button whose instance has gone idle. Catches
|
|
262
271
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
@@ -621,6 +630,11 @@ export class FleetManager {
|
|
|
621
630
|
webChannelEchoGuards = new Map();
|
|
622
631
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
623
632
|
restartsInFlight = new Map();
|
|
633
|
+
/**
|
|
634
|
+
* #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
|
|
635
|
+
* caller joined with before the gap), and whether the gap has been reached.
|
|
636
|
+
*/
|
|
637
|
+
restartGaps = new Map();
|
|
624
638
|
/**
|
|
625
639
|
* Delayed automatic retries for instances whose startup failed. Before this a
|
|
626
640
|
* failed start was logged once and the instance stayed `stopped` until an
|
|
@@ -725,6 +739,7 @@ export class FleetManager {
|
|
|
725
739
|
collabInstances = new Set();
|
|
726
740
|
// Health endpoint
|
|
727
741
|
healthServer = null;
|
|
742
|
+
healthServerDrain = null;
|
|
728
743
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
729
744
|
previewListener = null;
|
|
730
745
|
previewListening = false;
|
|
@@ -776,6 +791,10 @@ export class FleetManager {
|
|
|
776
791
|
webChatStore = null;
|
|
777
792
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
778
793
|
webFiles = new WebFileLedger();
|
|
794
|
+
/** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
|
|
795
|
+
webReplyOrder = new Map();
|
|
796
|
+
/** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
|
|
797
|
+
webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
|
|
779
798
|
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
780
799
|
seenUsers = new SeenUsers();
|
|
781
800
|
webPrefsStore;
|
|
@@ -1176,7 +1195,7 @@ export class FleetManager {
|
|
|
1176
1195
|
const reactions = this.pendingReactionsMeta(set.instance);
|
|
1177
1196
|
let accepted = false;
|
|
1178
1197
|
try {
|
|
1179
|
-
accepted = (await this.
|
|
1198
|
+
accepted = (await this.deliverHumanToInstance(set.instance, {
|
|
1180
1199
|
type: "fleet_inbound",
|
|
1181
1200
|
content,
|
|
1182
1201
|
targetSession: set.instance,
|
|
@@ -1185,7 +1204,7 @@ export class FleetManager {
|
|
|
1185
1204
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1186
1205
|
...reactions.meta,
|
|
1187
1206
|
},
|
|
1188
|
-
})) !== false;
|
|
1207
|
+
}, by.humanOrigin, button.label)) !== false;
|
|
1189
1208
|
}
|
|
1190
1209
|
finally {
|
|
1191
1210
|
// Both false and throw reopen the choice in the controller. Finish the
|
|
@@ -1304,6 +1323,7 @@ export class FleetManager {
|
|
|
1304
1323
|
deliveryId: admitted.delivery.deliveryId,
|
|
1305
1324
|
state: admitted.delivery.state,
|
|
1306
1325
|
duplicate: !admitted.inserted,
|
|
1326
|
+
kind: admitted.delivery.kind,
|
|
1307
1327
|
};
|
|
1308
1328
|
}
|
|
1309
1329
|
queryDurableDeliveryStatus(callerInstance, selector) {
|
|
@@ -1789,7 +1809,7 @@ export class FleetManager {
|
|
|
1789
1809
|
isPlannedRestart() {
|
|
1790
1810
|
return this.shuttingDown || isUpdateInProgress(this.dataDir);
|
|
1791
1811
|
}
|
|
1792
|
-
finishStartup() {
|
|
1812
|
+
finishStartup(bootUpdateMarker = null) {
|
|
1793
1813
|
this.startupComplete = true;
|
|
1794
1814
|
this.startNeedsYou();
|
|
1795
1815
|
// Resolve whatever a previous run — or a setup host that crashed — left
|
|
@@ -1829,20 +1849,33 @@ export class FleetManager {
|
|
|
1829
1849
|
for (const settled of this.applyJobs.settleAfterRestart()) {
|
|
1830
1850
|
this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
|
|
1831
1851
|
}
|
|
1832
|
-
|
|
1833
|
-
// it here (rather than in the update command, which exits before the new
|
|
1834
|
-
// fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1835
|
-
// #1660 exception: an update still owing its retire outcome keeps its
|
|
1836
|
-
// receipt until the cleanup watch resolves it — deleting it here is what
|
|
1837
|
-
// lost the warning. Terminal stages are still cleared unconditionally.
|
|
1838
|
-
const leftover = readUpdateProgress(this.dataDir)?.progress;
|
|
1839
|
-
if (!(leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")) {
|
|
1840
|
-
clearUpdateMarker(this.dataDir);
|
|
1841
|
-
}
|
|
1852
|
+
this.clearSettledUpdateMarker(bootUpdateMarker);
|
|
1842
1853
|
if (this.reloadPending)
|
|
1843
1854
|
this.scheduleReconcile();
|
|
1844
1855
|
void this.sweepOrphanedCancelButtons();
|
|
1845
1856
|
}
|
|
1857
|
+
/**
|
|
1858
|
+
* We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
|
|
1859
|
+
* command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1860
|
+
* #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
|
|
1861
|
+
* deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
|
|
1862
|
+
* #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
|
|
1863
|
+
* (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
|
|
1864
|
+
* appends it, then clears.
|
|
1865
|
+
* #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
|
|
1866
|
+
* landing while this boot's completion was still being edited — belongs to its writer and is left alone.
|
|
1867
|
+
*/
|
|
1868
|
+
clearSettledUpdateMarker(bootUpdateMarker) {
|
|
1869
|
+
if (this.updateCleanupWatch)
|
|
1870
|
+
return;
|
|
1871
|
+
const current = updateMarkerIdentity(this.dataDir);
|
|
1872
|
+
if (current && !sameUpdateMarker(current, bootUpdateMarker))
|
|
1873
|
+
return;
|
|
1874
|
+
const leftover = readUpdateProgress(this.dataDir)?.progress;
|
|
1875
|
+
if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
|
|
1876
|
+
return;
|
|
1877
|
+
clearUpdateMarker(this.dataDir);
|
|
1878
|
+
}
|
|
1846
1879
|
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
1847
1880
|
lastActivityMs(name) {
|
|
1848
1881
|
return this.lastActivity.get(name) ?? 0;
|
|
@@ -2626,6 +2659,8 @@ export class FleetManager {
|
|
|
2626
2659
|
startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
|
|
2627
2660
|
from: profileKey(launched),
|
|
2628
2661
|
to: profileKey(next),
|
|
2662
|
+
...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
|
|
2663
|
+
? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
|
|
2629
2664
|
};
|
|
2630
2665
|
}
|
|
2631
2666
|
/** `[instance, effective backend, credential profile]` for everything that is
|
|
@@ -2961,7 +2996,7 @@ export class FleetManager {
|
|
|
2961
2996
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
2962
2997
|
if (!queued) {
|
|
2963
2998
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2964
|
-
if (ipc?.connected && ipc.send(payload))
|
|
2999
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
2965
3000
|
return true;
|
|
2966
3001
|
}
|
|
2967
3002
|
const attempt = (queued ?? Promise.resolve())
|
|
@@ -2996,7 +3031,7 @@ export class FleetManager {
|
|
|
2996
3031
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
2997
3032
|
// so a cached reference would stay dead forever.
|
|
2998
3033
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2999
|
-
if (ipc?.connected && ipc.send(payload))
|
|
3034
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
3000
3035
|
return true;
|
|
3001
3036
|
if (Date.now() >= deadline) {
|
|
3002
3037
|
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
@@ -3214,6 +3249,101 @@ export class FleetManager {
|
|
|
3214
3249
|
instanceLaunchSupportsSteer(instanceName) {
|
|
3215
3250
|
return this.daemons.get(instanceName)?.launchSupportsSteer();
|
|
3216
3251
|
}
|
|
3252
|
+
humanReceiptDaemons = new WeakSet();
|
|
3253
|
+
/** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
|
|
3254
|
+
humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
|
|
3255
|
+
try {
|
|
3256
|
+
if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
|
|
3257
|
+
return null;
|
|
3258
|
+
const epoch = this.lifecycle.epochOf(instanceName);
|
|
3259
|
+
const owner = daemon.captureHumanReceiptOwner();
|
|
3260
|
+
if (!Number.isSafeInteger(epoch) || !owner)
|
|
3261
|
+
return null;
|
|
3262
|
+
return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
|
|
3263
|
+
}
|
|
3264
|
+
catch {
|
|
3265
|
+
return null;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
listenForHumanReceipt(instanceName, daemon) {
|
|
3269
|
+
// Lazy initialization also supports narrow prototype-based integration fixtures.
|
|
3270
|
+
this.humanReceiptDaemons ??= new WeakSet();
|
|
3271
|
+
if (this.humanReceiptDaemons.has(daemon))
|
|
3272
|
+
return;
|
|
3273
|
+
this.humanReceiptDaemons.add(daemon);
|
|
3274
|
+
daemon.on("human_receipt_result", (result) => {
|
|
3275
|
+
try {
|
|
3276
|
+
this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
|
|
3277
|
+
}
|
|
3278
|
+
catch (err) {
|
|
3279
|
+
this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
|
|
3280
|
+
}
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
/** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
|
|
3284
|
+
humanReceiptPayload(instanceName, payload, origin, originalBody) {
|
|
3285
|
+
if (!origin || !this.deliveryOutbox)
|
|
3286
|
+
return payload;
|
|
3287
|
+
const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
|
|
3288
|
+
origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
|
|
3289
|
+
return { ...payload, meta: { ...(payload.meta ?? {}),
|
|
3290
|
+
human_receipt_id: row.receipt_id, verification_id: row.message_id } };
|
|
3291
|
+
}
|
|
3292
|
+
/** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
|
|
3293
|
+
bindHumanReceiptPayload(instanceName, payload) {
|
|
3294
|
+
const meta = payload.meta;
|
|
3295
|
+
if (!meta?.human_receipt_id || !this.deliveryOutbox)
|
|
3296
|
+
return payload;
|
|
3297
|
+
const daemon = this.daemons.get(instanceName);
|
|
3298
|
+
const owner = this.humanReceiptOwner(instanceName, daemon);
|
|
3299
|
+
if (!daemon || !owner) {
|
|
3300
|
+
this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
|
|
3301
|
+
return payload;
|
|
3302
|
+
}
|
|
3303
|
+
this.listenForHumanReceipt(instanceName, daemon);
|
|
3304
|
+
const daemonOwner = daemon.captureHumanReceiptOwner();
|
|
3305
|
+
const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
|
|
3306
|
+
const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
|
|
3307
|
+
if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
|
|
3308
|
+
throw new Error("Human delivery ownership changed");
|
|
3309
|
+
// /raw retains exact bytes and never acquires a positive verification capability.
|
|
3310
|
+
if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
|
|
3311
|
+
this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
|
|
3312
|
+
return payload;
|
|
3313
|
+
}
|
|
3314
|
+
return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
|
|
3315
|
+
}
|
|
3316
|
+
async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
|
|
3317
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3318
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3319
|
+
let sent = false;
|
|
3320
|
+
try {
|
|
3321
|
+
const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
|
|
3322
|
+
sent = result !== false;
|
|
3323
|
+
return result;
|
|
3324
|
+
}
|
|
3325
|
+
finally {
|
|
3326
|
+
if (!sent && id)
|
|
3327
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
/** Synchronous steer/btw handoff, using the same evidence and owner binding. */
|
|
3331
|
+
sendHumanControl(instanceName, payload, origin, originalBody) {
|
|
3332
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3333
|
+
if (this.shuttingDown || !ipc?.connected)
|
|
3334
|
+
return false;
|
|
3335
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3336
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3337
|
+
let sent = false;
|
|
3338
|
+
try {
|
|
3339
|
+
sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
|
|
3340
|
+
return sent;
|
|
3341
|
+
}
|
|
3342
|
+
finally {
|
|
3343
|
+
if (!sent && id)
|
|
3344
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3217
3347
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
3218
3348
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
3219
3349
|
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
@@ -3406,7 +3536,7 @@ export class FleetManager {
|
|
|
3406
3536
|
userId: data.userId,
|
|
3407
3537
|
username,
|
|
3408
3538
|
source: "discord",
|
|
3409
|
-
timestamp: new Date(),
|
|
3539
|
+
timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
|
|
3410
3540
|
});
|
|
3411
3541
|
}
|
|
3412
3542
|
else if (data.command === "save") {
|
|
@@ -3551,7 +3681,8 @@ export class FleetManager {
|
|
|
3551
3681
|
await data.respond(t("classic.no_agent"));
|
|
3552
3682
|
return;
|
|
3553
3683
|
}
|
|
3554
|
-
const
|
|
3684
|
+
const originalSteerText = String(data.options?.message ?? "");
|
|
3685
|
+
const steerText = originalSteerText.trim();
|
|
3555
3686
|
if (!steerText) {
|
|
3556
3687
|
await data.respond(t("steer.usage"));
|
|
3557
3688
|
return;
|
|
@@ -3561,7 +3692,7 @@ export class FleetManager {
|
|
|
3561
3692
|
// rerouting the instance's replies to the slash context.
|
|
3562
3693
|
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
3563
3694
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3564
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3695
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
|
|
3565
3696
|
});
|
|
3566
3697
|
await data.respond(result);
|
|
3567
3698
|
}
|
|
@@ -3571,14 +3702,15 @@ export class FleetManager {
|
|
|
3571
3702
|
await data.respond(t("classic.no_agent"));
|
|
3572
3703
|
return;
|
|
3573
3704
|
}
|
|
3574
|
-
const
|
|
3705
|
+
const originalBtwText = String(data.options?.message ?? "");
|
|
3706
|
+
const btwText = originalBtwText.trim();
|
|
3575
3707
|
if (!btwText) {
|
|
3576
3708
|
await data.respond(t("btw.usage"));
|
|
3577
3709
|
return;
|
|
3578
3710
|
}
|
|
3579
3711
|
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
3580
3712
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3581
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3713
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
|
|
3582
3714
|
});
|
|
3583
3715
|
await data.respond(result);
|
|
3584
3716
|
}
|
|
@@ -3878,12 +4010,16 @@ export class FleetManager {
|
|
|
3878
4010
|
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
3879
4011
|
// #1220: a running CLI keeps the login it was launched with, so a new
|
|
3880
4012
|
// credential_profile only takes effect through a restart.
|
|
3881
|
-
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
4013
|
+
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
3882
4014
|
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
|
|
3883
4015
|
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
|
|
3884
4016
|
...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
|
|
4017
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
3885
4018
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
3886
|
-
|
|
4019
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
4020
|
+
if (profileCarry)
|
|
4021
|
+
await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
|
|
4022
|
+
else if (profileSwitchStartsFresh)
|
|
3887
4023
|
this.writeFreshStartMarker(ch.instanceName);
|
|
3888
4024
|
// Small delay to let tmux window clean up
|
|
3889
4025
|
await new Promise(r => setTimeout(r, 2000));
|
|
@@ -4516,6 +4652,9 @@ export class FleetManager {
|
|
|
4516
4652
|
// Published so the outage hand-off can wait for an explicit stop that began
|
|
4517
4653
|
// BEFORE it (the generation fence alone only catches stops that begin after
|
|
4518
4654
|
// the hand-off snapshotted it).
|
|
4655
|
+
// #1716: scope the marker to this stop — a marker older than beganAt is
|
|
4656
|
+
// from an earlier stop and never reports.
|
|
4657
|
+
const beganAt = Date.now();
|
|
4519
4658
|
const run = (async () => {
|
|
4520
4659
|
try {
|
|
4521
4660
|
await this.lifecycle.stop(name, transition);
|
|
@@ -4523,6 +4662,10 @@ export class FleetManager {
|
|
|
4523
4662
|
finally {
|
|
4524
4663
|
this.clearNoncePromptsForInstance(name);
|
|
4525
4664
|
}
|
|
4665
|
+
const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
|
|
4666
|
+
if (backgroundWorkEnded)
|
|
4667
|
+
this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
|
|
4668
|
+
return { backgroundWorkEnded };
|
|
4526
4669
|
})().finally(() => { if (this.stopsInFlight.get(name) === run)
|
|
4527
4670
|
this.stopsInFlight.delete(name); });
|
|
4528
4671
|
this.stopsInFlight.set(name, run);
|
|
@@ -4537,6 +4680,16 @@ export class FleetManager {
|
|
|
4537
4680
|
const inFlight = this.restartsInFlight.get(name);
|
|
4538
4681
|
if (inFlight) {
|
|
4539
4682
|
this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
|
|
4683
|
+
// #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
|
|
4684
|
+
// start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
|
|
4685
|
+
// carried, and said so — the restart has already started, or will start, the CLI without it.
|
|
4686
|
+
if (opts?.carry) {
|
|
4687
|
+
const gap = this.restartGaps.get(name);
|
|
4688
|
+
if (gap && !gap.reached && !gap.carry)
|
|
4689
|
+
gap.carry = opts.carry;
|
|
4690
|
+
else
|
|
4691
|
+
opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
|
|
4692
|
+
}
|
|
4540
4693
|
return inFlight;
|
|
4541
4694
|
}
|
|
4542
4695
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
@@ -4545,6 +4698,8 @@ export class FleetManager {
|
|
|
4545
4698
|
// spawn gate is taken inside the transition, never the other way round, so
|
|
4546
4699
|
// the start being waited for can still get its own gate slot.
|
|
4547
4700
|
this.lifecycle.invalidate(name);
|
|
4701
|
+
const gap = { carry: opts?.carry, reached: false };
|
|
4702
|
+
this.restartGaps.set(name, gap);
|
|
4548
4703
|
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
4549
4704
|
instanceName: name,
|
|
4550
4705
|
workingDirectory,
|
|
@@ -4553,6 +4708,8 @@ export class FleetManager {
|
|
|
4553
4708
|
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
4554
4709
|
.finally(() => {
|
|
4555
4710
|
this.restartsInFlight.delete(name);
|
|
4711
|
+
if (this.restartGaps.get(name) === gap)
|
|
4712
|
+
this.restartGaps.delete(name);
|
|
4556
4713
|
this.scheduleDeliveryOutboxPump();
|
|
4557
4714
|
});
|
|
4558
4715
|
this.restartsInFlight.set(name, run);
|
|
@@ -4622,10 +4779,41 @@ export class FleetManager {
|
|
|
4622
4779
|
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
4623
4780
|
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
4624
4781
|
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
4782
|
+
// #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
|
|
4783
|
+
const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
|
|
4784
|
+
// Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
|
|
4785
|
+
/** `launching`: the login the start will use — read only when a switch is to be carried. */
|
|
4786
|
+
const betweenStopAndStart = async (launching) => {
|
|
4787
|
+
// #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
|
|
4788
|
+
const gap = this.restartGaps.get(name);
|
|
4789
|
+
const carry = gap?.carry ?? opts?.carry;
|
|
4790
|
+
if (gap)
|
|
4791
|
+
gap.reached = true;
|
|
4792
|
+
if (!carry) {
|
|
4793
|
+
if (opts?.freshStart)
|
|
4794
|
+
this.writeFreshStartMarker(name);
|
|
4795
|
+
return;
|
|
4796
|
+
}
|
|
4797
|
+
// Only into the login this start really launches: a restart that read its configuration before the switch was
|
|
4798
|
+
// saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
|
|
4799
|
+
const launch = launching();
|
|
4800
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.to) {
|
|
4801
|
+
const login = (p) => (p ? `profile "${p}"` : "the default login");
|
|
4802
|
+
carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
|
|
4803
|
+
this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
|
|
4804
|
+
// The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
|
|
4805
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.from)
|
|
4806
|
+
this.writeFreshStartMarker(name);
|
|
4807
|
+
return;
|
|
4808
|
+
}
|
|
4809
|
+
carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
|
|
4810
|
+
};
|
|
4625
4811
|
if (config) {
|
|
4626
4812
|
await this.stopInstance(name, transition);
|
|
4627
|
-
|
|
4628
|
-
this.
|
|
4813
|
+
await betweenStopAndStart(() => {
|
|
4814
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
4815
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
|
|
4816
|
+
});
|
|
4629
4817
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
4630
4818
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
4631
4819
|
this.stormWindow.markRecovered(name);
|
|
@@ -4638,8 +4826,10 @@ export class FleetManager {
|
|
|
4638
4826
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
4639
4827
|
await this.stopInstance(name, transition);
|
|
4640
4828
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
4641
|
-
|
|
4642
|
-
this.
|
|
4829
|
+
await betweenStopAndStart(() => {
|
|
4830
|
+
const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
|
|
4831
|
+
return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
|
|
4832
|
+
});
|
|
4643
4833
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
|
|
4644
4834
|
this.stormWindow.markRecovered(name);
|
|
4645
4835
|
return;
|
|
@@ -4660,6 +4850,43 @@ export class FleetManager {
|
|
|
4660
4850
|
this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
|
|
4661
4851
|
}
|
|
4662
4852
|
}
|
|
4853
|
+
/**
|
|
4854
|
+
* #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
|
|
4855
|
+
* the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
|
|
4856
|
+
* cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
|
|
4857
|
+
*/
|
|
4858
|
+
async switchConversation(name, sw) {
|
|
4859
|
+
if (!credentialSwitchCarriesConversation(sw.backend)) {
|
|
4860
|
+
if (credentialSwitchStartsFresh(sw.backend))
|
|
4861
|
+
this.writeFreshStartMarker(name);
|
|
4862
|
+
return null;
|
|
4863
|
+
}
|
|
4864
|
+
let sessionId = null;
|
|
4865
|
+
try {
|
|
4866
|
+
sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
|
|
4867
|
+
}
|
|
4868
|
+
catch { /* none: nothing to carry */ }
|
|
4869
|
+
const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
|
|
4870
|
+
let result;
|
|
4871
|
+
try {
|
|
4872
|
+
result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
|
|
4873
|
+
}
|
|
4874
|
+
catch (err) {
|
|
4875
|
+
result = { carried: false, reason: `copy failed: ${err.message}` };
|
|
4876
|
+
}
|
|
4877
|
+
if (result.carried) {
|
|
4878
|
+
const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
|
|
4879
|
+
if (result.bytes > CARRY_LARGE_BYTES)
|
|
4880
|
+
this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
|
|
4881
|
+
else
|
|
4882
|
+
this.logger.info(fields, "Conversation carried to the new credential profile");
|
|
4883
|
+
}
|
|
4884
|
+
else {
|
|
4885
|
+
this.logger.info({ name, from: sw.from ?? "default", to: sw.to ?? "default", reason: result.reason }, "Conversation not carried to the new credential profile; starting fresh");
|
|
4886
|
+
this.writeFreshStartMarker(name);
|
|
4887
|
+
}
|
|
4888
|
+
return result;
|
|
4889
|
+
}
|
|
4663
4890
|
/** Load .env file from data dir into process.env */
|
|
4664
4891
|
loadEnvFile(owner) {
|
|
4665
4892
|
const envPath = join(this.dataDir, ".env");
|
|
@@ -4767,6 +4994,61 @@ export class FleetManager {
|
|
|
4767
4994
|
}
|
|
4768
4995
|
return null;
|
|
4769
4996
|
}
|
|
4997
|
+
/**
|
|
4998
|
+
* #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
|
|
4999
|
+
* — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
|
|
5000
|
+
* no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
|
|
5001
|
+
*/
|
|
5002
|
+
dmDashboardOwner(userId, adapterId) {
|
|
5003
|
+
const world = this.worlds.get(adapterId);
|
|
5004
|
+
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
5005
|
+
return null;
|
|
5006
|
+
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
|
|
5007
|
+
const group = String(configured?.group_id ?? "");
|
|
5008
|
+
if (!group)
|
|
5009
|
+
return null;
|
|
5010
|
+
const topics = Object.entries(this.fleetConfig?.instances ?? {})
|
|
5011
|
+
.filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
|
|
5012
|
+
&& String(this.getGroupIdForInstance(name) ?? "") === group)
|
|
5013
|
+
.map(([, cfg]) => cfg.topic_id.toString());
|
|
5014
|
+
if (topics.length !== 1)
|
|
5015
|
+
return null;
|
|
5016
|
+
const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
|
|
5017
|
+
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
5018
|
+
}
|
|
5019
|
+
/**
|
|
5020
|
+
* #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
|
|
5021
|
+
* Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
|
|
5022
|
+
* admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
|
|
5023
|
+
*/
|
|
5024
|
+
tgWebStartWindows = new Map();
|
|
5025
|
+
openTgWebStartWindow(adapterId, userId) {
|
|
5026
|
+
const now = performance.now();
|
|
5027
|
+
for (const [key, until] of this.tgWebStartWindows)
|
|
5028
|
+
if (until <= now)
|
|
5029
|
+
this.tgWebStartWindows.delete(key);
|
|
5030
|
+
this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
|
|
5031
|
+
}
|
|
5032
|
+
/** Uses the window up: true when it was open. */
|
|
5033
|
+
takeTgWebStartWindow(adapterId, userId) {
|
|
5034
|
+
const key = `${adapterId}\0${userId}`;
|
|
5035
|
+
const until = this.tgWebStartWindows.get(key);
|
|
5036
|
+
this.tgWebStartWindows.delete(key);
|
|
5037
|
+
return until !== undefined && performance.now() < until;
|
|
5038
|
+
}
|
|
5039
|
+
/** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
|
|
5040
|
+
async startWebInDm(msg, adapter) {
|
|
5041
|
+
if (!adapter)
|
|
5042
|
+
return;
|
|
5043
|
+
const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
|
|
5044
|
+
if (!owner) {
|
|
5045
|
+
await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
|
|
5046
|
+
return;
|
|
5047
|
+
}
|
|
5048
|
+
if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
|
|
5049
|
+
await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
4770
5052
|
getPublicWebLink() {
|
|
4771
5053
|
this.initializeWebSessions();
|
|
4772
5054
|
return this.publicWebLink ??= new PublicWebLink({
|
|
@@ -4781,13 +5063,13 @@ export class FleetManager {
|
|
|
4781
5063
|
if (result && !result.durable)
|
|
4782
5064
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
4783
5065
|
},
|
|
4784
|
-
log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
|
|
5066
|
+
log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
|
|
4785
5067
|
createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
|
|
4786
5068
|
if (!publicPreviewWanted(this.fleetConfig?.web))
|
|
4787
5069
|
return null;
|
|
4788
5070
|
return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
|
|
4789
5071
|
provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
|
|
4790
|
-
log: event => this.logger.info({ event, exposureId: id }, "Public web link") });
|
|
5072
|
+
log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
|
|
4791
5073
|
},
|
|
4792
5074
|
onCleanupUnconfirmed: (result, owner) => {
|
|
4793
5075
|
this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
|
|
@@ -4944,7 +5226,11 @@ export class FleetManager {
|
|
|
4944
5226
|
this.logger.info({ code: typeof code === "string" || typeof code === "number" ? code : undefined, err: err?.message }, "Dashboard DM not delivered; answering privately where the command was used");
|
|
4945
5227
|
}
|
|
4946
5228
|
/** False when the menu was not shown (the caller still owes the user an answer). */
|
|
4947
|
-
|
|
5229
|
+
/**
|
|
5230
|
+
* `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
|
|
5231
|
+
* gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
|
|
5232
|
+
*/
|
|
5233
|
+
async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
|
|
4948
5234
|
if (!this.publicOwnerCurrent(owner)) {
|
|
4949
5235
|
this.logger.info("Dashboard menu not shown: the General owner changed");
|
|
4950
5236
|
return false;
|
|
@@ -4952,7 +5238,7 @@ export class FleetManager {
|
|
|
4952
5238
|
const adapter = owner.binding;
|
|
4953
5239
|
const status = this.getPublicWebStatus();
|
|
4954
5240
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
4955
|
-
const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
5241
|
+
const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
4956
5242
|
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4957
5243
|
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4958
5244
|
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
@@ -4963,7 +5249,7 @@ export class FleetManager {
|
|
|
4963
5249
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4964
5250
|
const posted = await this.postNonceButtonPrompt({
|
|
4965
5251
|
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4966
|
-
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
5252
|
+
chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
|
|
4967
5253
|
message: menuText,
|
|
4968
5254
|
choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
|
|
4969
5255
|
...(respondButtons ? { deliver: async (c) => {
|
|
@@ -5026,6 +5312,8 @@ export class FleetManager {
|
|
|
5026
5312
|
}) : null;
|
|
5027
5313
|
let lastSteps;
|
|
5028
5314
|
let deliveredByDm = false;
|
|
5315
|
+
// #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
|
|
5316
|
+
let dmRefused = false;
|
|
5029
5317
|
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
5030
5318
|
const token = this.webToken;
|
|
5031
5319
|
if (!token || !current())
|
|
@@ -5051,6 +5339,7 @@ export class FleetManager {
|
|
|
5051
5339
|
}
|
|
5052
5340
|
catch (err) {
|
|
5053
5341
|
this.logDashboardDmFailure(err);
|
|
5342
|
+
dmRefused = true;
|
|
5054
5343
|
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
5055
5344
|
throw new Error("private delivery unavailable");
|
|
5056
5345
|
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
@@ -5096,14 +5385,22 @@ export class FleetManager {
|
|
|
5096
5385
|
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
5097
5386
|
}
|
|
5098
5387
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
5099
|
-
|
|
5100
|
-
|
|
5388
|
+
// #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
|
|
5389
|
+
// with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
|
|
5390
|
+
// /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
|
|
5391
|
+
// not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
|
|
5392
|
+
const telegram = owner.binding.type === "telegram";
|
|
5393
|
+
const dmSteps = !ok && telegram && dmRefused;
|
|
5394
|
+
const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
|
|
5395
|
+
if (dmSteps)
|
|
5396
|
+
this.openTgWebStartWindow(adapterId, owner.userId);
|
|
5397
|
+
const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
|
|
5398
|
+
: telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
|
|
5399
|
+
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
5101
5400
|
await progress;
|
|
5102
5401
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
5103
5402
|
// #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
|
|
5104
|
-
const failUrlButtons =
|
|
5105
|
-
? (() => { const u = this.tgStartWebUrl(adapterId); return u ? [{ label: t("dashboard.start_web_label"), url: u }] : undefined; })()
|
|
5106
|
-
: undefined;
|
|
5403
|
+
const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
|
|
5107
5404
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
|
|
5108
5405
|
return true;
|
|
5109
5406
|
}
|
|
@@ -5352,6 +5649,39 @@ export class FleetManager {
|
|
|
5352
5649
|
* starts this timer on that path (Prism a) — without this, the owed
|
|
5353
5650
|
* outcome would sit in the marker unwatched. READY never waits for it.
|
|
5354
5651
|
*/
|
|
5652
|
+
/**
|
|
5653
|
+
* #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
|
|
5654
|
+
* Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
|
|
5655
|
+
* → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
|
|
5656
|
+
* after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
|
|
5657
|
+
* → nothing.
|
|
5658
|
+
*/
|
|
5659
|
+
/**
|
|
5660
|
+
* #1716: background work this restart/update ended, scoped to the operation
|
|
5661
|
+
* so an older stop never reports (`sinceAt` null = plain boot, report
|
|
5662
|
+
* nothing). A method — not a module function — because the #1737
|
|
5663
|
+
* extracted-completion harness runs this region verbatim with only
|
|
5664
|
+
* `this`-methods plus a fixed context.
|
|
5665
|
+
*/
|
|
5666
|
+
collectBackgroundWorkEndedNames(names, sinceAt) {
|
|
5667
|
+
if (sinceAt === null)
|
|
5668
|
+
return [];
|
|
5669
|
+
return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
|
|
5670
|
+
}
|
|
5671
|
+
updateCleanupAtCompletion(pending, operation) {
|
|
5672
|
+
if (!pending || operation !== "update")
|
|
5673
|
+
return null;
|
|
5674
|
+
const receipt = updateReceiptOf(pending);
|
|
5675
|
+
if (!receipt)
|
|
5676
|
+
return null;
|
|
5677
|
+
const current = readUpdateProgress(this.dataDir);
|
|
5678
|
+
const verdict = cleanupWatchVerdict(current, receipt);
|
|
5679
|
+
if (verdict.action === "append")
|
|
5680
|
+
return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
|
|
5681
|
+
if (verdict.action === "wait" && current?.progress.cleanup === "pending")
|
|
5682
|
+
return { watch: receipt };
|
|
5683
|
+
return null;
|
|
5684
|
+
}
|
|
5355
5685
|
armCleanupWatch(receipt, summary) {
|
|
5356
5686
|
this.updateCleanupWatch = { receipt, summary };
|
|
5357
5687
|
if (!this.updateProgressTimer)
|
|
@@ -5514,6 +5844,8 @@ export class FleetManager {
|
|
|
5514
5844
|
const fleet = this.loadConfig(configPath);
|
|
5515
5845
|
this.slimFleetConfigAtStartup();
|
|
5516
5846
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
5847
|
+
// #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
|
|
5848
|
+
const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
|
|
5517
5849
|
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
5518
5850
|
const pendingUpdateProgress = savedUpdateProgress
|
|
5519
5851
|
&& savedUpdateProgress.progress.stage !== "failed"
|
|
@@ -5947,32 +6279,37 @@ export class FleetManager {
|
|
|
5947
6279
|
const _require = createRequire(import.meta.url);
|
|
5948
6280
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
5949
6281
|
await progressStart;
|
|
5950
|
-
// #1660:
|
|
5951
|
-
//
|
|
5952
|
-
//
|
|
5953
|
-
//
|
|
5954
|
-
//
|
|
5955
|
-
|
|
6282
|
+
// #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
|
|
6283
|
+
// a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
|
|
6284
|
+
// render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
|
|
6285
|
+
// — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
|
|
6286
|
+
// for cleanup (that would deadlock with an updater that retires after its restart returns).
|
|
6287
|
+
const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
|
|
6288
|
+
// #1716: background work this restart/update ended, scoped to the
|
|
6289
|
+
// operation so an older stop never reports. No pending operation means
|
|
6290
|
+
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
6291
|
+
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
5956
6292
|
const completionSummary = {
|
|
5957
6293
|
running: started,
|
|
5958
6294
|
total,
|
|
5959
6295
|
version: agendVersion,
|
|
5960
6296
|
pausedNames,
|
|
5961
6297
|
failedNames,
|
|
6298
|
+
backgroundWorkEndedNames,
|
|
5962
6299
|
tipText: pendingProgressOperation === "update" && this.tipsEnabled()
|
|
5963
6300
|
? (() => {
|
|
5964
6301
|
const tip = this.pickAvailableTip();
|
|
5965
6302
|
return tip ? this.formatTip(tip) : undefined;
|
|
5966
6303
|
})()
|
|
5967
6304
|
: undefined,
|
|
6305
|
+
...(cleanupOutcome && "render" in cleanupOutcome
|
|
6306
|
+
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
6307
|
+
: {}),
|
|
5968
6308
|
};
|
|
5969
6309
|
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
if (receipt)
|
|
5974
|
-
this.armCleanupWatch(receipt, completionSummary);
|
|
5975
|
-
}
|
|
6310
|
+
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
6311
|
+
if (cleanupOutcome && "watch" in cleanupOutcome)
|
|
6312
|
+
this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
|
|
5976
6313
|
if (!progressCompleted && fleet.channel?.group_id) {
|
|
5977
6314
|
let text;
|
|
5978
6315
|
if (pendingProgressOperation === "full-restart") {
|
|
@@ -5982,6 +6319,7 @@ export class FleetManager {
|
|
|
5982
6319
|
version: agendVersion,
|
|
5983
6320
|
pausedNames,
|
|
5984
6321
|
failedNames,
|
|
6322
|
+
backgroundWorkEndedNames,
|
|
5985
6323
|
}, pendingUpdateProgress.startedAt);
|
|
5986
6324
|
}
|
|
5987
6325
|
else if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
@@ -5994,9 +6332,18 @@ export class FleetManager {
|
|
|
5994
6332
|
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
5995
6333
|
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
5996
6334
|
}
|
|
5997
|
-
// #
|
|
5998
|
-
//
|
|
5999
|
-
//
|
|
6335
|
+
// #1716: the fallback carries the same background-work names as the
|
|
6336
|
+
// completion (inline: this block is executed verbatim by the #1737
|
|
6337
|
+
// extracted-completion harness, which resolves `this`-methods plus a
|
|
6338
|
+
// fixed context — no new bare module references here).
|
|
6339
|
+
if (backgroundWorkEndedNames.length) {
|
|
6340
|
+
text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
|
|
6341
|
+
}
|
|
6342
|
+
// #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
|
|
6343
|
+
if (cleanupOutcome && "render" in cleanupOutcome) {
|
|
6344
|
+
text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
|
|
6345
|
+
+ (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
|
|
6346
|
+
}
|
|
6000
6347
|
await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
|
|
6001
6348
|
}
|
|
6002
6349
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
@@ -6018,7 +6365,7 @@ export class FleetManager {
|
|
|
6018
6365
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
6019
6366
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
6020
6367
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
6021
|
-
this.finishStartup();
|
|
6368
|
+
this.finishStartup(bootUpdateMarker);
|
|
6022
6369
|
// Tell systemd we are ready only now. This used to fire right after the
|
|
6023
6370
|
// generals started — before adapters, classic instances, topic creation and the
|
|
6024
6371
|
// health server — so `systemctl start` returned success while the fleet was
|
|
@@ -7266,10 +7613,18 @@ export class FleetManager {
|
|
|
7266
7613
|
});
|
|
7267
7614
|
}
|
|
7268
7615
|
async handleInboundMessage(msg) {
|
|
7616
|
+
msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
|
|
7269
7617
|
if (msg.isBotMessage)
|
|
7270
7618
|
return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
|
|
7271
|
-
|
|
7272
|
-
|
|
7619
|
+
return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
|
|
7620
|
+
}
|
|
7621
|
+
/**
|
|
7622
|
+
* #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
|
|
7623
|
+
* the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
|
|
7624
|
+
*/
|
|
7625
|
+
inboundNoticeAdapter(msg) {
|
|
7626
|
+
const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
|
|
7627
|
+
return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
|
|
7273
7628
|
}
|
|
7274
7629
|
async handleInboundMessageCore(msg) {
|
|
7275
7630
|
// #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
|
|
@@ -7303,6 +7658,15 @@ export class FleetManager {
|
|
|
7303
7658
|
return;
|
|
7304
7659
|
}
|
|
7305
7660
|
const threadId = this.inboundRouteThreadId(msg);
|
|
7661
|
+
// #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
|
|
7662
|
+
// for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
|
|
7663
|
+
// agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
|
|
7664
|
+
// our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
|
|
7665
|
+
// (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
|
|
7666
|
+
if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
|
|
7667
|
+
this.logger.debug({ adapterId: msg.adapterId, threadId, messageId: msg.messageId, author: msg.userId }, "Inbound dropped: authored by one of this fleet's own bots in a fleet topic");
|
|
7668
|
+
return;
|
|
7669
|
+
}
|
|
7306
7670
|
const rootCommand = this.telegramRootCommandTarget(msg, threadId);
|
|
7307
7671
|
this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
|
|
7308
7672
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
@@ -7434,10 +7798,17 @@ export class FleetManager {
|
|
|
7434
7798
|
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
7435
7799
|
// /start alone already opens the DM for future bot-to-user messages — no further action needed.
|
|
7436
7800
|
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7801
|
+
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
7802
|
+
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
7803
|
+
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
7804
|
+
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
7805
|
+
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
7806
|
+
if (webStart) {
|
|
7807
|
+
if (requestedBackend === "web")
|
|
7808
|
+
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
7809
|
+
// #1742: the /web menu right here; the code comes to this DM.
|
|
7810
|
+
if (admin)
|
|
7811
|
+
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
7441
7812
|
// Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
|
|
7442
7813
|
return;
|
|
7443
7814
|
}
|
|
@@ -7701,7 +8072,7 @@ export class FleetManager {
|
|
|
7701
8072
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
7702
8073
|
const generalReactions = this.pendingReactionsMeta(generalInstance);
|
|
7703
8074
|
try {
|
|
7704
|
-
await this.
|
|
8075
|
+
await this.deliverHumanToInstance(generalInstance, {
|
|
7705
8076
|
type: "fleet_inbound",
|
|
7706
8077
|
content: text,
|
|
7707
8078
|
targetSession: generalInstance,
|
|
@@ -7722,7 +8093,7 @@ export class FleetManager {
|
|
|
7722
8093
|
...generalReactions.meta,
|
|
7723
8094
|
...extraMeta,
|
|
7724
8095
|
},
|
|
7725
|
-
});
|
|
8096
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7726
8097
|
generalReactions.consume();
|
|
7727
8098
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
7728
8099
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
@@ -7802,7 +8173,7 @@ export class FleetManager {
|
|
|
7802
8173
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
|
|
7803
8174
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
7804
8175
|
try {
|
|
7805
|
-
await this.
|
|
8176
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
7806
8177
|
type: "fleet_inbound",
|
|
7807
8178
|
content: text,
|
|
7808
8179
|
targetSession: instanceName, // Channel messages → instance's own session
|
|
@@ -7821,7 +8192,7 @@ export class FleetManager {
|
|
|
7821
8192
|
...reactions.meta,
|
|
7822
8193
|
...extraMeta,
|
|
7823
8194
|
},
|
|
7824
|
-
});
|
|
8195
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7825
8196
|
// Only after the message actually went out. A failed delivery keeps the
|
|
7826
8197
|
// reactions queued for the retry / the next message.
|
|
7827
8198
|
reactions.consume();
|
|
@@ -7864,9 +8235,14 @@ export class FleetManager {
|
|
|
7864
8235
|
if (Date.now() - lastWarn < 30 * 60_000)
|
|
7865
8236
|
return;
|
|
7866
8237
|
this.rateLimitWarnedAt.set(instanceName, Date.now());
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
8238
|
+
// #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
|
|
8239
|
+
// inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
|
|
8240
|
+
// bot message it would take as input).
|
|
8241
|
+
const warnAdapter = this.getAdapterForInstance(instanceName);
|
|
8242
|
+
const dest = this.cancelButtonDestination(instanceName);
|
|
8243
|
+
const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
|
|
8244
|
+
if (warnAdapter && chatId) {
|
|
8245
|
+
warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
|
|
7870
8246
|
}
|
|
7871
8247
|
}
|
|
7872
8248
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -8124,7 +8500,7 @@ export class FleetManager {
|
|
|
8124
8500
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
8125
8501
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
8126
8502
|
try {
|
|
8127
|
-
this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
8503
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
8128
8504
|
}
|
|
8129
8505
|
catch (err) {
|
|
8130
8506
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -8177,6 +8553,7 @@ export class FleetManager {
|
|
|
8177
8553
|
webOnlyPromptPlace() {
|
|
8178
8554
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
8179
8555
|
}
|
|
8556
|
+
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
8180
8557
|
afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
|
|
8181
8558
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
8182
8559
|
// more work either. Split the difference: an instance that is clearly
|
|
@@ -8194,27 +8571,74 @@ export class FleetManager {
|
|
|
8194
8571
|
this.reactDone(instanceName);
|
|
8195
8572
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
8196
8573
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
8197
|
-
// Files the agent attached are shown in the web chat too: registered for fetching by id (the path
|
|
8198
|
-
// already passed the reply tool's sendability check, and is resolved once more here), never by path.
|
|
8199
|
-
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
8200
|
-
const attachments = replyFiles
|
|
8201
|
-
.map(path => this.webFiles.registerServed({ path, instance: instanceName }))
|
|
8202
|
-
.filter((f) => f !== null)
|
|
8203
|
-
.map(publicAttachment);
|
|
8204
|
-
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
8205
|
-
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
8206
|
-
this.emitSseEvent("message", {
|
|
8207
|
-
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
8208
|
-
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
8209
|
-
ts: new Date().toISOString(),
|
|
8210
|
-
...(attachments.length ? { attachments } : {}),
|
|
8211
|
-
...(buttons ? { buttons } : {}), // #1266
|
|
8212
|
-
});
|
|
8213
8574
|
// Log bot reply to classic instance chat-log
|
|
8214
8575
|
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
8215
8576
|
if (isClassic) {
|
|
8216
8577
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
8217
8578
|
}
|
|
8579
|
+
// Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
|
|
8580
|
+
// first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
|
|
8581
|
+
// copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
|
|
8582
|
+
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
8583
|
+
const ts = new Date().toISOString();
|
|
8584
|
+
const show = (attachments) => {
|
|
8585
|
+
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
8586
|
+
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
8587
|
+
this.emitSseEvent("message", {
|
|
8588
|
+
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
8589
|
+
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
8590
|
+
ts,
|
|
8591
|
+
...(attachments.length ? { attachments } : {}),
|
|
8592
|
+
...(buttons ? { buttons } : {}), // #1266
|
|
8593
|
+
});
|
|
8594
|
+
};
|
|
8595
|
+
const before = this.webReplyOrder.get(instanceName);
|
|
8596
|
+
if (!replyFiles.length && !before) {
|
|
8597
|
+
show([]);
|
|
8598
|
+
return Promise.resolve();
|
|
8599
|
+
}
|
|
8600
|
+
const done = (before ?? Promise.resolve())
|
|
8601
|
+
.then(() => this.webReplyAttachments(instanceName, replyFiles))
|
|
8602
|
+
.catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
|
|
8603
|
+
.then((attachments) => { try {
|
|
8604
|
+
show(attachments);
|
|
8605
|
+
}
|
|
8606
|
+
catch (err) {
|
|
8607
|
+
this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
|
|
8608
|
+
} })
|
|
8609
|
+
.finally(() => { if (this.webReplyOrder.get(instanceName) === done)
|
|
8610
|
+
this.webReplyOrder.delete(instanceName); });
|
|
8611
|
+
this.webReplyOrder.set(instanceName, done);
|
|
8612
|
+
return done;
|
|
8613
|
+
}
|
|
8614
|
+
/**
|
|
8615
|
+
* #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
|
|
8616
|
+
* the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
|
|
8617
|
+
* one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
|
|
8618
|
+
*/
|
|
8619
|
+
async webReplyAttachments(instanceName, paths) {
|
|
8620
|
+
const home = getAgendHome();
|
|
8621
|
+
const dir = webFilesDir(home, instanceName);
|
|
8622
|
+
const out = [];
|
|
8623
|
+
for (const path of paths) {
|
|
8624
|
+
const id = randomBytes(16).toString("hex");
|
|
8625
|
+
const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
|
|
8626
|
+
if (got && "evicted" in got && got.evicted.length)
|
|
8627
|
+
this.webFiles.dropServed(got.evicted);
|
|
8628
|
+
let file = null;
|
|
8629
|
+
if (got?.ok)
|
|
8630
|
+
file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
|
|
8631
|
+
else if (got?.refused)
|
|
8632
|
+
this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
|
|
8633
|
+
else {
|
|
8634
|
+
if (got)
|
|
8635
|
+
this.logger.warn({ instanceName, file: basename(path), reason: got.reason }, "Reply file could not be copied for the web chat; served from its own path until a restart");
|
|
8636
|
+
file = this.webFiles.registerServed({ path, instance: instanceName });
|
|
8637
|
+
}
|
|
8638
|
+
if (file)
|
|
8639
|
+
out.push(publicAttachment(file));
|
|
8640
|
+
}
|
|
8641
|
+
return out;
|
|
8218
8642
|
}
|
|
8219
8643
|
// ===================== Scheduler =====================
|
|
8220
8644
|
async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
|
|
@@ -11679,7 +12103,8 @@ export class FleetManager {
|
|
|
11679
12103
|
// only place some outcomes are told (cancel, later, a resend's result),
|
|
11680
12104
|
// so the text goes out as a message instead (#1133).
|
|
11681
12105
|
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
|
|
11682
|
-
|
|
12106
|
+
// #1742: with its link button, if it had one — the outcome may be the only way to the DM.
|
|
12107
|
+
await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
|
|
11683
12108
|
.catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
|
|
11684
12109
|
}
|
|
11685
12110
|
}
|
|
@@ -11982,7 +12407,7 @@ export class FleetManager {
|
|
|
11982
12407
|
const topicId = this.fleetConfig?.instances[instance]?.topic_id;
|
|
11983
12408
|
return {
|
|
11984
12409
|
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
|
|
11985
|
-
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
|
|
12410
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
11986
12411
|
};
|
|
11987
12412
|
};
|
|
11988
12413
|
const choicesFromCache = (instance) => {
|
|
@@ -12013,8 +12438,8 @@ export class FleetManager {
|
|
|
12013
12438
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
12014
12439
|
},
|
|
12015
12440
|
cancel: (instance) => this.cancelInstance(instance),
|
|
12016
|
-
steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
|
|
12017
|
-
btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
|
|
12441
|
+
steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
12442
|
+
btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
12018
12443
|
pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
|
|
12019
12444
|
save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
|
|
12020
12445
|
clearAsk: (instance) => {
|
|
@@ -13810,16 +14235,26 @@ export class FleetManager {
|
|
|
13810
14235
|
try {
|
|
13811
14236
|
const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
|
|
13812
14237
|
const names = new Set([...Object.keys(fleet.instances ?? {}), ...(this.classicChannels?.getAll().map(c => c.instanceName) ?? [])]);
|
|
13813
|
-
// A kept message's files are served again under their ids only from the instance's own inbox
|
|
13814
|
-
// checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
14238
|
+
// A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
|
|
14239
|
+
// snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
13815
14240
|
this.webChatHistory.setFiles({
|
|
13816
14241
|
storablePath: (id) => this.webFiles.storablePath(id),
|
|
13817
|
-
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
|
|
14242
|
+
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
|
|
13818
14243
|
drop: (ids) => this.webFiles.dropServed(ids),
|
|
13819
14244
|
});
|
|
13820
14245
|
let restored = 0;
|
|
13821
14246
|
for (const name of names)
|
|
13822
14247
|
restored += this.webChatHistory.restore(name, store.load(name));
|
|
14248
|
+
// #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
|
|
14249
|
+
// fleet was down, or a copy stopped midway) are removed.
|
|
14250
|
+
let swept = 0;
|
|
14251
|
+
for (const name of names) {
|
|
14252
|
+
const dir = webFilesDir(getAgendHome(), name);
|
|
14253
|
+
if (dir)
|
|
14254
|
+
swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
|
|
14255
|
+
}
|
|
14256
|
+
if (swept)
|
|
14257
|
+
this.logger.info({ swept }, "Removed web reply snapshots no message holds");
|
|
13823
14258
|
this.webChatStore = store;
|
|
13824
14259
|
this.webChatHistory.setStore(store);
|
|
13825
14260
|
if (restored)
|
|
@@ -14610,12 +15045,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14610
15045
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
14611
15046
|
Object.assign(meta, reactions.meta);
|
|
14612
15047
|
try {
|
|
14613
|
-
await this.
|
|
15048
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
14614
15049
|
type: "fleet_inbound",
|
|
14615
15050
|
content: fullText,
|
|
14616
15051
|
targetSession: instanceName,
|
|
14617
15052
|
meta,
|
|
14618
|
-
});
|
|
15053
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? text);
|
|
14619
15054
|
reactions.consume();
|
|
14620
15055
|
}
|
|
14621
15056
|
catch (err) {
|
|
@@ -16118,11 +16553,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16118
16553
|
}
|
|
16119
16554
|
}
|
|
16120
16555
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
16121
|
-
|
|
16556
|
+
// #1716: say so in the reply when background work was ended with the stop.
|
|
16557
|
+
// Null-safe: a foreign stopInstance stub may resolve nothing at all.
|
|
16558
|
+
const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
|
|
16559
|
+
this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
|
|
16560
|
+
return { backgroundWorkEnded: false };
|
|
16561
|
+
});
|
|
16122
16562
|
clearPausedMarker(this.getInstanceDir(ch.instanceName));
|
|
16123
16563
|
this.reregisterClassicChannels();
|
|
16124
16564
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
16125
|
-
return t("classic.stopped")
|
|
16565
|
+
return t("classic.stopped")
|
|
16566
|
+
+ (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
|
|
16126
16567
|
}
|
|
16127
16568
|
/**
|
|
16128
16569
|
* Idempotent while in flight: SIGINT and SIGTERM share one handler and the
|
|
@@ -16317,8 +16758,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16317
16758
|
this.controlClient = null;
|
|
16318
16759
|
if (this.healthServer) {
|
|
16319
16760
|
this.healthServerListening = false;
|
|
16320
|
-
this.healthServer
|
|
16321
|
-
this.
|
|
16761
|
+
const server = this.healthServer;
|
|
16762
|
+
const drain = this.healthServerDrain ?? trackWebServer(server);
|
|
16763
|
+
await drain.close(this.sseClients);
|
|
16764
|
+
if (this.healthServer === server) {
|
|
16765
|
+
this.healthServer = null;
|
|
16766
|
+
this.healthServerDrain = null;
|
|
16767
|
+
}
|
|
16322
16768
|
}
|
|
16323
16769
|
this.stopPreviewListener();
|
|
16324
16770
|
// The store writes lastSeen at most once a minute; what that debounce is still holding — and any
|
|
@@ -16647,8 +17093,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16647
17093
|
if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
|
|
16648
17094
|
this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
|
|
16649
17095
|
observe?.(ch.instanceName, "restart", "running");
|
|
17096
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
16650
17097
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
16651
|
-
|
|
17098
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
17099
|
+
if (profile.carry)
|
|
17100
|
+
await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
|
|
17101
|
+
else if (profile.startsFresh)
|
|
16652
17102
|
this.writeFreshStartMarker(ch.instanceName);
|
|
16653
17103
|
await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
|
|
16654
17104
|
observe?.(ch.instanceName, "restart", "done");
|
|
@@ -18287,7 +18737,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18287
18737
|
this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
|
|
18288
18738
|
}
|
|
18289
18739
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
18290
|
-
dispatchWebHttp(req, res, port) {
|
|
18740
|
+
dispatchWebHttp(req, res, port, closing = false) {
|
|
18291
18741
|
try {
|
|
18292
18742
|
res.setHeader("Content-Type", "application/json");
|
|
18293
18743
|
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
@@ -18306,6 +18756,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18306
18756
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
18307
18757
|
return;
|
|
18308
18758
|
}
|
|
18759
|
+
if (closing) {
|
|
18760
|
+
res.writeHead(503, { Connection: "close", "Retry-After": "10" });
|
|
18761
|
+
res.end(JSON.stringify({ error: "fleet stopping" }));
|
|
18762
|
+
return;
|
|
18763
|
+
}
|
|
18309
18764
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
18310
18765
|
// Branding is public, after Host admission: automatic favicon requests
|
|
18311
18766
|
// must not become noisy web-token 401s in the browser console.
|
|
@@ -18624,7 +19079,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18624
19079
|
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
18625
19080
|
if (!this.webToken || !this.webSessions)
|
|
18626
19081
|
this.initializeWebAuthTokens();
|
|
18627
|
-
|
|
19082
|
+
const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
|
|
19083
|
+
const drain = trackWebServer(server);
|
|
19084
|
+
this.healthServer = server;
|
|
19085
|
+
this.healthServerDrain = drain;
|
|
18628
19086
|
const markListening = (afterTakeover = false) => {
|
|
18629
19087
|
this.healthServerListening = true;
|
|
18630
19088
|
this.logger.info({ port }, afterTakeover
|