@songsid/agend 2.2.0-beta.4 → 2.2.0-beta.6
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/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 +23 -0
- package/dist/backend/claude-code.js +102 -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 +41 -0
- package/dist/backend/muse.js +239 -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/telegram.d.ts +4 -5
- package/dist/channel/adapters/telegram.js +33 -17
- package/dist/channel/adapters/telegram.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/cli.js +60 -10
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.js +41 -0
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +4 -1
- package/dist/fleet-manager.d.ts +112 -5
- package/dist/fleet-manager.js +516 -52
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +44 -9
- package/dist/locale.js +18 -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 +7 -0
- package/dist/outbound-handlers.js +19 -10
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/public-preview.d.ts +100 -0
- package/dist/public-preview.js +233 -0
- package/dist/public-preview.js.map +1 -0
- package/dist/public-web-gateway.js +4 -3
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +20 -0
- package/dist/public-web-link.js +26 -1
- package/dist/public-web-link.js.map +1 -1
- package/dist/restart-progress.d.ts +33 -0
- package/dist/restart-progress.js +30 -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.js +3 -1
- package/dist/topic-commands.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +5 -1
- package/dist/tunnel/purpose-lane.js +14 -7
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/ui/chat-strings.js +14 -2
- package/dist/ui/chat-thread.js +2 -2
- package/dist/ui/panel-chat.js +9 -2
- package/dist/ui/panel-fleet.js +148 -35
- package/dist/ui/panel-needs.js +4 -3
- package/dist/ui/panel-settings.js +1 -1
- package/dist/ui/preview-status.js +28 -0
- package/dist/ui/preview.js +35 -2
- package/dist/ui/shared/app-i18n.js +4 -0
- package/dist/ui/shared/app-prefs.js +1 -0
- package/dist/ui/shared/app-shell.js +146 -2
- package/dist/ui/shared/app-stream.js +21 -4
- package/dist/ui/shared/app.css +41 -15
- package/dist/ui/shared/instance-nav.js +47 -16
- package/dist/ui/shared/usage-dialog.js +10 -1
- package/dist/ui/shared/view-strings.js +4 -0
- package/dist/update-install.d.ts +13 -1
- package/dist/update-install.js +16 -4
- package/dist/update-install.js.map +1 -1
- package/dist/update-marker.d.ts +51 -0
- package/dist/update-marker.js +61 -0
- package/dist/update-marker.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +34 -5
- package/dist/usage/providers.js +160 -27
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.d.ts +6 -2
- package/dist/usage/statusline-usage.js +10 -4
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/web-api.d.ts +20 -1
- package/dist/web-api.js +101 -13
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +2 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-prefs.js +2 -0
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +14 -1
- package/dist/web-preview.js +35 -13
- package/dist/web-preview.js.map +1 -1
- package/dist/web-schedules.d.ts +12 -0
- package/dist/web-schedules.js +34 -0
- package/dist/web-schedules.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/package.json +1 -1
- package/templates/launchd.plist.ejs +3 -1
- package/templates/systemd.service.ejs +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -8,8 +8,11 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
|
8
8
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
9
9
|
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
10
10
|
import { handleWebIconRequest } from "./web-icons.js";
|
|
11
|
+
import { trackWebServer } from "./web-server-drain.js";
|
|
11
12
|
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
12
13
|
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
14
|
+
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
15
|
+
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
13
16
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
14
17
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
15
18
|
import { withinBudget } from "./monotonic-budget.js";
|
|
@@ -28,7 +31,7 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
|
|
|
28
31
|
import { fileURLToPath } from "node:url";
|
|
29
32
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
30
33
|
import { fleetLabel } from "./fleet-label.js";
|
|
31
|
-
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
34
|
+
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
32
35
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
33
36
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
34
37
|
import { readFleetMemory } from "./process-memory.js";
|
|
@@ -168,6 +171,7 @@ import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
|
168
171
|
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
169
172
|
import { readLastInboundAt } from "./daemon.js";
|
|
170
173
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
174
|
+
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
171
175
|
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
172
176
|
import { TargetQueueWorker } from "./target-queue-worker.js";
|
|
173
177
|
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
@@ -191,7 +195,7 @@ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.j
|
|
|
191
195
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
192
196
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
193
197
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
194
|
-
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
198
|
+
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
195
199
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
196
200
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
197
201
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -201,10 +205,11 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
201
205
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
202
206
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
203
207
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
204
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
208
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
209
|
+
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
205
210
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
206
211
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
207
|
-
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
212
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
208
213
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
209
214
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
210
215
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
@@ -255,6 +260,10 @@ export function isOrphanInstanceWindowName(name) {
|
|
|
255
260
|
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
256
261
|
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
257
262
|
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
263
|
+
/** #1753: a carried conversation this large is logged as a warning (it is still carried). */
|
|
264
|
+
const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
|
|
265
|
+
/** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
|
|
266
|
+
const TG_WEB_START_WINDOW_MS = 10 * 60_000;
|
|
258
267
|
const CANCEL_BTN_MAX_RETRIES = 3;
|
|
259
268
|
/** Backstop: every 5min, retire a button whose instance has gone idle. Catches
|
|
260
269
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
@@ -619,6 +628,11 @@ export class FleetManager {
|
|
|
619
628
|
webChannelEchoGuards = new Map();
|
|
620
629
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
621
630
|
restartsInFlight = new Map();
|
|
631
|
+
/**
|
|
632
|
+
* #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
|
|
633
|
+
* caller joined with before the gap), and whether the gap has been reached.
|
|
634
|
+
*/
|
|
635
|
+
restartGaps = new Map();
|
|
622
636
|
/**
|
|
623
637
|
* Delayed automatic retries for instances whose startup failed. Before this a
|
|
624
638
|
* failed start was logged once and the instance stayed `stopped` until an
|
|
@@ -723,6 +737,7 @@ export class FleetManager {
|
|
|
723
737
|
collabInstances = new Set();
|
|
724
738
|
// Health endpoint
|
|
725
739
|
healthServer = null;
|
|
740
|
+
healthServerDrain = null;
|
|
726
741
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
727
742
|
previewListener = null;
|
|
728
743
|
previewListening = false;
|
|
@@ -735,6 +750,13 @@ export class FleetManager {
|
|
|
735
750
|
updateProgressEditRunning = false;
|
|
736
751
|
lastUpdateProgressText = null;
|
|
737
752
|
updateCompletionTipText = null;
|
|
753
|
+
/**
|
|
754
|
+
* #1660: an adopted update whose retire outcome is still owed. The
|
|
755
|
+
* completion summary was (or will be) posted; this watch appends the
|
|
756
|
+
* cleanup outcome to the same receipt instead of letting stage edits
|
|
757
|
+
* clobber it — and refuses a receipt that no longer matches (checklist 3/5).
|
|
758
|
+
*/
|
|
759
|
+
updateCleanupWatch = null;
|
|
738
760
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
739
761
|
fullRestartLauncher = launchFullRestartHelper;
|
|
740
762
|
eventLogPruneTimer = null;
|
|
@@ -1780,7 +1802,7 @@ export class FleetManager {
|
|
|
1780
1802
|
isPlannedRestart() {
|
|
1781
1803
|
return this.shuttingDown || isUpdateInProgress(this.dataDir);
|
|
1782
1804
|
}
|
|
1783
|
-
finishStartup() {
|
|
1805
|
+
finishStartup(bootUpdateMarker = null) {
|
|
1784
1806
|
this.startupComplete = true;
|
|
1785
1807
|
this.startNeedsYou();
|
|
1786
1808
|
// Resolve whatever a previous run — or a setup host that crashed — left
|
|
@@ -1790,14 +1812,17 @@ export class FleetManager {
|
|
|
1790
1812
|
// the next tunnel and says so, it does not block the fleet.
|
|
1791
1813
|
this.announceToolPermissionsChange();
|
|
1792
1814
|
this.tightenInstanceDirectories();
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1815
|
+
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
|
|
1816
|
+
for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
|
|
1817
|
+
void reapStaleTunnel(leaseDir)
|
|
1818
|
+
.then(outcome => {
|
|
1819
|
+
if (outcome.kind === "manual")
|
|
1820
|
+
this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
|
|
1821
|
+
else if (outcome.kind === "reaped")
|
|
1822
|
+
this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
|
|
1823
|
+
})
|
|
1824
|
+
.catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
|
|
1825
|
+
}
|
|
1801
1826
|
// An existing installation has never written the setup marker — it predates
|
|
1802
1827
|
// it — so `agend setup` would open a pre-fleet form for a fleet that plainly
|
|
1803
1828
|
// exists. A fleet that just came up on a config with agents in it is proof
|
|
@@ -1817,14 +1842,33 @@ export class FleetManager {
|
|
|
1817
1842
|
for (const settled of this.applyJobs.settleAfterRestart()) {
|
|
1818
1843
|
this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
|
|
1819
1844
|
}
|
|
1820
|
-
|
|
1821
|
-
// it here (rather than in the update command, which exits before the new
|
|
1822
|
-
// fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1823
|
-
clearUpdateMarker(this.dataDir);
|
|
1845
|
+
this.clearSettledUpdateMarker(bootUpdateMarker);
|
|
1824
1846
|
if (this.reloadPending)
|
|
1825
1847
|
this.scheduleReconcile();
|
|
1826
1848
|
void this.sweepOrphanedCancelButtons();
|
|
1827
1849
|
}
|
|
1850
|
+
/**
|
|
1851
|
+
* We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
|
|
1852
|
+
* command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1853
|
+
* #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
|
|
1854
|
+
* deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
|
|
1855
|
+
* #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
|
|
1856
|
+
* (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
|
|
1857
|
+
* appends it, then clears.
|
|
1858
|
+
* #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
|
|
1859
|
+
* landing while this boot's completion was still being edited — belongs to its writer and is left alone.
|
|
1860
|
+
*/
|
|
1861
|
+
clearSettledUpdateMarker(bootUpdateMarker) {
|
|
1862
|
+
if (this.updateCleanupWatch)
|
|
1863
|
+
return;
|
|
1864
|
+
const current = updateMarkerIdentity(this.dataDir);
|
|
1865
|
+
if (current && !sameUpdateMarker(current, bootUpdateMarker))
|
|
1866
|
+
return;
|
|
1867
|
+
const leftover = readUpdateProgress(this.dataDir)?.progress;
|
|
1868
|
+
if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
|
|
1869
|
+
return;
|
|
1870
|
+
clearUpdateMarker(this.dataDir);
|
|
1871
|
+
}
|
|
1828
1872
|
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
1829
1873
|
lastActivityMs(name) {
|
|
1830
1874
|
return this.lastActivity.get(name) ?? 0;
|
|
@@ -2608,6 +2652,8 @@ export class FleetManager {
|
|
|
2608
2652
|
startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
|
|
2609
2653
|
from: profileKey(launched),
|
|
2610
2654
|
to: profileKey(next),
|
|
2655
|
+
...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
|
|
2656
|
+
? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
|
|
2611
2657
|
};
|
|
2612
2658
|
}
|
|
2613
2659
|
/** `[instance, effective backend, credential profile]` for everything that is
|
|
@@ -3860,12 +3906,16 @@ export class FleetManager {
|
|
|
3860
3906
|
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
3861
3907
|
// #1220: a running CLI keeps the login it was launched with, so a new
|
|
3862
3908
|
// credential_profile only takes effect through a restart.
|
|
3863
|
-
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
3909
|
+
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
3864
3910
|
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
|
|
3865
3911
|
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
|
|
3866
3912
|
...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
|
|
3913
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
3867
3914
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
3868
|
-
|
|
3915
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
3916
|
+
if (profileCarry)
|
|
3917
|
+
await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
|
|
3918
|
+
else if (profileSwitchStartsFresh)
|
|
3869
3919
|
this.writeFreshStartMarker(ch.instanceName);
|
|
3870
3920
|
// Small delay to let tmux window clean up
|
|
3871
3921
|
await new Promise(r => setTimeout(r, 2000));
|
|
@@ -4498,6 +4548,9 @@ export class FleetManager {
|
|
|
4498
4548
|
// Published so the outage hand-off can wait for an explicit stop that began
|
|
4499
4549
|
// BEFORE it (the generation fence alone only catches stops that begin after
|
|
4500
4550
|
// the hand-off snapshotted it).
|
|
4551
|
+
// #1716: scope the marker to this stop — a marker older than beganAt is
|
|
4552
|
+
// from an earlier stop and never reports.
|
|
4553
|
+
const beganAt = Date.now();
|
|
4501
4554
|
const run = (async () => {
|
|
4502
4555
|
try {
|
|
4503
4556
|
await this.lifecycle.stop(name, transition);
|
|
@@ -4505,6 +4558,10 @@ export class FleetManager {
|
|
|
4505
4558
|
finally {
|
|
4506
4559
|
this.clearNoncePromptsForInstance(name);
|
|
4507
4560
|
}
|
|
4561
|
+
const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
|
|
4562
|
+
if (backgroundWorkEnded)
|
|
4563
|
+
this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
|
|
4564
|
+
return { backgroundWorkEnded };
|
|
4508
4565
|
})().finally(() => { if (this.stopsInFlight.get(name) === run)
|
|
4509
4566
|
this.stopsInFlight.delete(name); });
|
|
4510
4567
|
this.stopsInFlight.set(name, run);
|
|
@@ -4519,6 +4576,16 @@ export class FleetManager {
|
|
|
4519
4576
|
const inFlight = this.restartsInFlight.get(name);
|
|
4520
4577
|
if (inFlight) {
|
|
4521
4578
|
this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
|
|
4579
|
+
// #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
|
|
4580
|
+
// start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
|
|
4581
|
+
// carried, and said so — the restart has already started, or will start, the CLI without it.
|
|
4582
|
+
if (opts?.carry) {
|
|
4583
|
+
const gap = this.restartGaps.get(name);
|
|
4584
|
+
if (gap && !gap.reached && !gap.carry)
|
|
4585
|
+
gap.carry = opts.carry;
|
|
4586
|
+
else
|
|
4587
|
+
opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
|
|
4588
|
+
}
|
|
4522
4589
|
return inFlight;
|
|
4523
4590
|
}
|
|
4524
4591
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
@@ -4527,6 +4594,8 @@ export class FleetManager {
|
|
|
4527
4594
|
// spawn gate is taken inside the transition, never the other way round, so
|
|
4528
4595
|
// the start being waited for can still get its own gate slot.
|
|
4529
4596
|
this.lifecycle.invalidate(name);
|
|
4597
|
+
const gap = { carry: opts?.carry, reached: false };
|
|
4598
|
+
this.restartGaps.set(name, gap);
|
|
4530
4599
|
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
4531
4600
|
instanceName: name,
|
|
4532
4601
|
workingDirectory,
|
|
@@ -4535,6 +4604,8 @@ export class FleetManager {
|
|
|
4535
4604
|
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
4536
4605
|
.finally(() => {
|
|
4537
4606
|
this.restartsInFlight.delete(name);
|
|
4607
|
+
if (this.restartGaps.get(name) === gap)
|
|
4608
|
+
this.restartGaps.delete(name);
|
|
4538
4609
|
this.scheduleDeliveryOutboxPump();
|
|
4539
4610
|
});
|
|
4540
4611
|
this.restartsInFlight.set(name, run);
|
|
@@ -4604,10 +4675,41 @@ export class FleetManager {
|
|
|
4604
4675
|
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
4605
4676
|
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
4606
4677
|
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
4678
|
+
// #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
|
|
4679
|
+
const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
|
|
4680
|
+
// Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
|
|
4681
|
+
/** `launching`: the login the start will use — read only when a switch is to be carried. */
|
|
4682
|
+
const betweenStopAndStart = async (launching) => {
|
|
4683
|
+
// #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
|
|
4684
|
+
const gap = this.restartGaps.get(name);
|
|
4685
|
+
const carry = gap?.carry ?? opts?.carry;
|
|
4686
|
+
if (gap)
|
|
4687
|
+
gap.reached = true;
|
|
4688
|
+
if (!carry) {
|
|
4689
|
+
if (opts?.freshStart)
|
|
4690
|
+
this.writeFreshStartMarker(name);
|
|
4691
|
+
return;
|
|
4692
|
+
}
|
|
4693
|
+
// Only into the login this start really launches: a restart that read its configuration before the switch was
|
|
4694
|
+
// saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
|
|
4695
|
+
const launch = launching();
|
|
4696
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.to) {
|
|
4697
|
+
const login = (p) => (p ? `profile "${p}"` : "the default login");
|
|
4698
|
+
carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
|
|
4699
|
+
this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
|
|
4700
|
+
// The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
|
|
4701
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.from)
|
|
4702
|
+
this.writeFreshStartMarker(name);
|
|
4703
|
+
return;
|
|
4704
|
+
}
|
|
4705
|
+
carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
|
|
4706
|
+
};
|
|
4607
4707
|
if (config) {
|
|
4608
4708
|
await this.stopInstance(name, transition);
|
|
4609
|
-
|
|
4610
|
-
this.
|
|
4709
|
+
await betweenStopAndStart(() => {
|
|
4710
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
4711
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
|
|
4712
|
+
});
|
|
4611
4713
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
4612
4714
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
4613
4715
|
this.stormWindow.markRecovered(name);
|
|
@@ -4620,8 +4722,10 @@ export class FleetManager {
|
|
|
4620
4722
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
4621
4723
|
await this.stopInstance(name, transition);
|
|
4622
4724
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
4623
|
-
|
|
4624
|
-
this.
|
|
4725
|
+
await betweenStopAndStart(() => {
|
|
4726
|
+
const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
|
|
4727
|
+
return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
|
|
4728
|
+
});
|
|
4625
4729
|
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));
|
|
4626
4730
|
this.stormWindow.markRecovered(name);
|
|
4627
4731
|
return;
|
|
@@ -4642,6 +4746,43 @@ export class FleetManager {
|
|
|
4642
4746
|
this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
|
|
4643
4747
|
}
|
|
4644
4748
|
}
|
|
4749
|
+
/**
|
|
4750
|
+
* #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
|
|
4751
|
+
* the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
|
|
4752
|
+
* cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
|
|
4753
|
+
*/
|
|
4754
|
+
async switchConversation(name, sw) {
|
|
4755
|
+
if (!credentialSwitchCarriesConversation(sw.backend)) {
|
|
4756
|
+
if (credentialSwitchStartsFresh(sw.backend))
|
|
4757
|
+
this.writeFreshStartMarker(name);
|
|
4758
|
+
return null;
|
|
4759
|
+
}
|
|
4760
|
+
let sessionId = null;
|
|
4761
|
+
try {
|
|
4762
|
+
sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
|
|
4763
|
+
}
|
|
4764
|
+
catch { /* none: nothing to carry */ }
|
|
4765
|
+
const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
|
|
4766
|
+
let result;
|
|
4767
|
+
try {
|
|
4768
|
+
result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
|
|
4769
|
+
}
|
|
4770
|
+
catch (err) {
|
|
4771
|
+
result = { carried: false, reason: `copy failed: ${err.message}` };
|
|
4772
|
+
}
|
|
4773
|
+
if (result.carried) {
|
|
4774
|
+
const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
|
|
4775
|
+
if (result.bytes > CARRY_LARGE_BYTES)
|
|
4776
|
+
this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
|
|
4777
|
+
else
|
|
4778
|
+
this.logger.info(fields, "Conversation carried to the new credential profile");
|
|
4779
|
+
}
|
|
4780
|
+
else {
|
|
4781
|
+
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");
|
|
4782
|
+
this.writeFreshStartMarker(name);
|
|
4783
|
+
}
|
|
4784
|
+
return result;
|
|
4785
|
+
}
|
|
4645
4786
|
/** Load .env file from data dir into process.env */
|
|
4646
4787
|
loadEnvFile(owner) {
|
|
4647
4788
|
const envPath = join(this.dataDir, ".env");
|
|
@@ -4705,7 +4846,13 @@ export class FleetManager {
|
|
|
4705
4846
|
return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
|
|
4706
4847
|
}
|
|
4707
4848
|
tunnelPurposeLane = null;
|
|
4849
|
+
previewTunnel = null;
|
|
4708
4850
|
publicWebLink = null;
|
|
4851
|
+
/** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
|
|
4852
|
+
getPreviewTunnel() {
|
|
4853
|
+
return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
|
|
4854
|
+
log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
|
|
4855
|
+
}
|
|
4709
4856
|
getTunnelLane() {
|
|
4710
4857
|
return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
|
|
4711
4858
|
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
@@ -4743,6 +4890,61 @@ export class FleetManager {
|
|
|
4743
4890
|
}
|
|
4744
4891
|
return null;
|
|
4745
4892
|
}
|
|
4893
|
+
/**
|
|
4894
|
+
* #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
|
|
4895
|
+
* — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
|
|
4896
|
+
* no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
|
|
4897
|
+
*/
|
|
4898
|
+
dmDashboardOwner(userId, adapterId) {
|
|
4899
|
+
const world = this.worlds.get(adapterId);
|
|
4900
|
+
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
4901
|
+
return null;
|
|
4902
|
+
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
|
|
4903
|
+
const group = String(configured?.group_id ?? "");
|
|
4904
|
+
if (!group)
|
|
4905
|
+
return null;
|
|
4906
|
+
const topics = Object.entries(this.fleetConfig?.instances ?? {})
|
|
4907
|
+
.filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
|
|
4908
|
+
&& String(this.getGroupIdForInstance(name) ?? "") === group)
|
|
4909
|
+
.map(([, cfg]) => cfg.topic_id.toString());
|
|
4910
|
+
if (topics.length !== 1)
|
|
4911
|
+
return null;
|
|
4912
|
+
const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
|
|
4913
|
+
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
4914
|
+
}
|
|
4915
|
+
/**
|
|
4916
|
+
* #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
|
|
4917
|
+
* Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
|
|
4918
|
+
* admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
|
|
4919
|
+
*/
|
|
4920
|
+
tgWebStartWindows = new Map();
|
|
4921
|
+
openTgWebStartWindow(adapterId, userId) {
|
|
4922
|
+
const now = performance.now();
|
|
4923
|
+
for (const [key, until] of this.tgWebStartWindows)
|
|
4924
|
+
if (until <= now)
|
|
4925
|
+
this.tgWebStartWindows.delete(key);
|
|
4926
|
+
this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
|
|
4927
|
+
}
|
|
4928
|
+
/** Uses the window up: true when it was open. */
|
|
4929
|
+
takeTgWebStartWindow(adapterId, userId) {
|
|
4930
|
+
const key = `${adapterId}\0${userId}`;
|
|
4931
|
+
const until = this.tgWebStartWindows.get(key);
|
|
4932
|
+
this.tgWebStartWindows.delete(key);
|
|
4933
|
+
return until !== undefined && performance.now() < until;
|
|
4934
|
+
}
|
|
4935
|
+
/** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
|
|
4936
|
+
async startWebInDm(msg, adapter) {
|
|
4937
|
+
if (!adapter)
|
|
4938
|
+
return;
|
|
4939
|
+
const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
|
|
4940
|
+
if (!owner) {
|
|
4941
|
+
await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
|
|
4942
|
+
return;
|
|
4943
|
+
}
|
|
4944
|
+
if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
|
|
4945
|
+
await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4746
4948
|
getPublicWebLink() {
|
|
4747
4949
|
this.initializeWebSessions();
|
|
4748
4950
|
return this.publicWebLink ??= new PublicWebLink({
|
|
@@ -4758,6 +4960,13 @@ export class FleetManager {
|
|
|
4758
4960
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
4759
4961
|
},
|
|
4760
4962
|
log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
|
|
4963
|
+
createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
|
|
4964
|
+
if (!publicPreviewWanted(this.fleetConfig?.web))
|
|
4965
|
+
return null;
|
|
4966
|
+
return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
|
|
4967
|
+
provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
|
|
4968
|
+
log: event => this.logger.info({ event, exposureId: id }, "Public web link") });
|
|
4969
|
+
},
|
|
4761
4970
|
onCleanupUnconfirmed: (result, owner) => {
|
|
4762
4971
|
this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
|
|
4763
4972
|
const world = this.worlds.get(owner.adapterId);
|
|
@@ -4913,7 +5122,11 @@ export class FleetManager {
|
|
|
4913
5122
|
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");
|
|
4914
5123
|
}
|
|
4915
5124
|
/** False when the menu was not shown (the caller still owes the user an answer). */
|
|
4916
|
-
|
|
5125
|
+
/**
|
|
5126
|
+
* `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
|
|
5127
|
+
* gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
|
|
5128
|
+
*/
|
|
5129
|
+
async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
|
|
4917
5130
|
if (!this.publicOwnerCurrent(owner)) {
|
|
4918
5131
|
this.logger.info("Dashboard menu not shown: the General owner changed");
|
|
4919
5132
|
return false;
|
|
@@ -4921,7 +5134,7 @@ export class FleetManager {
|
|
|
4921
5134
|
const adapter = owner.binding;
|
|
4922
5135
|
const status = this.getPublicWebStatus();
|
|
4923
5136
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
4924
|
-
const tgHint = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
5137
|
+
const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
4925
5138
|
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4926
5139
|
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4927
5140
|
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
@@ -4932,7 +5145,7 @@ export class FleetManager {
|
|
|
4932
5145
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4933
5146
|
const posted = await this.postNonceButtonPrompt({
|
|
4934
5147
|
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4935
|
-
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
5148
|
+
chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
|
|
4936
5149
|
message: menuText,
|
|
4937
5150
|
choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
|
|
4938
5151
|
...(respondButtons ? { deliver: async (c) => {
|
|
@@ -4995,6 +5208,8 @@ export class FleetManager {
|
|
|
4995
5208
|
}) : null;
|
|
4996
5209
|
let lastSteps;
|
|
4997
5210
|
let deliveredByDm = false;
|
|
5211
|
+
// #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
|
|
5212
|
+
let dmRefused = false;
|
|
4998
5213
|
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
4999
5214
|
const token = this.webToken;
|
|
5000
5215
|
if (!token || !current())
|
|
@@ -5020,6 +5235,7 @@ export class FleetManager {
|
|
|
5020
5235
|
}
|
|
5021
5236
|
catch (err) {
|
|
5022
5237
|
this.logDashboardDmFailure(err);
|
|
5238
|
+
dmRefused = true;
|
|
5023
5239
|
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
5024
5240
|
throw new Error("private delivery unavailable");
|
|
5025
5241
|
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
@@ -5065,14 +5281,22 @@ export class FleetManager {
|
|
|
5065
5281
|
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
5066
5282
|
}
|
|
5067
5283
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
5068
|
-
|
|
5069
|
-
|
|
5284
|
+
// #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
|
|
5285
|
+
// with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
|
|
5286
|
+
// /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
|
|
5287
|
+
// not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
|
|
5288
|
+
const telegram = owner.binding.type === "telegram";
|
|
5289
|
+
const dmSteps = !ok && telegram && dmRefused;
|
|
5290
|
+
const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
|
|
5291
|
+
if (dmSteps)
|
|
5292
|
+
this.openTgWebStartWindow(adapterId, owner.userId);
|
|
5293
|
+
const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
|
|
5294
|
+
: telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
|
|
5295
|
+
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
5070
5296
|
await progress;
|
|
5071
5297
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
5072
5298
|
// #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
|
|
5073
|
-
const failUrlButtons =
|
|
5074
|
-
? (() => { const u = this.tgStartWebUrl(adapterId); return u ? [{ label: t("dashboard.start_web_label"), url: u }] : undefined; })()
|
|
5075
|
-
: undefined;
|
|
5299
|
+
const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
|
|
5076
5300
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
|
|
5077
5301
|
return true;
|
|
5078
5302
|
}
|
|
@@ -5315,6 +5539,86 @@ export class FleetManager {
|
|
|
5315
5539
|
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
5316
5540
|
}
|
|
5317
5541
|
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
5542
|
+
/**
|
|
5543
|
+
* #1660: arm the post-completion cleanup watch and make sure its monitor
|
|
5544
|
+
* actually runs. The new fleet adopts the receipt at boot but nothing else
|
|
5545
|
+
* starts this timer on that path (Prism a) — without this, the owed
|
|
5546
|
+
* outcome would sit in the marker unwatched. READY never waits for it.
|
|
5547
|
+
*/
|
|
5548
|
+
/**
|
|
5549
|
+
* #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
|
|
5550
|
+
* Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
|
|
5551
|
+
* → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
|
|
5552
|
+
* after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
|
|
5553
|
+
* → nothing.
|
|
5554
|
+
*/
|
|
5555
|
+
/**
|
|
5556
|
+
* #1716: background work this restart/update ended, scoped to the operation
|
|
5557
|
+
* so an older stop never reports (`sinceAt` null = plain boot, report
|
|
5558
|
+
* nothing). A method — not a module function — because the #1737
|
|
5559
|
+
* extracted-completion harness runs this region verbatim with only
|
|
5560
|
+
* `this`-methods plus a fixed context.
|
|
5561
|
+
*/
|
|
5562
|
+
collectBackgroundWorkEndedNames(names, sinceAt) {
|
|
5563
|
+
if (sinceAt === null)
|
|
5564
|
+
return [];
|
|
5565
|
+
return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
|
|
5566
|
+
}
|
|
5567
|
+
updateCleanupAtCompletion(pending, operation) {
|
|
5568
|
+
if (!pending || operation !== "update")
|
|
5569
|
+
return null;
|
|
5570
|
+
const receipt = updateReceiptOf(pending);
|
|
5571
|
+
if (!receipt)
|
|
5572
|
+
return null;
|
|
5573
|
+
const current = readUpdateProgress(this.dataDir);
|
|
5574
|
+
const verdict = cleanupWatchVerdict(current, receipt);
|
|
5575
|
+
if (verdict.action === "append")
|
|
5576
|
+
return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
|
|
5577
|
+
if (verdict.action === "wait" && current?.progress.cleanup === "pending")
|
|
5578
|
+
return { watch: receipt };
|
|
5579
|
+
return null;
|
|
5580
|
+
}
|
|
5581
|
+
armCleanupWatch(receipt, summary) {
|
|
5582
|
+
this.updateCleanupWatch = { receipt, summary };
|
|
5583
|
+
if (!this.updateProgressTimer)
|
|
5584
|
+
this.startUpdateProgressMonitor();
|
|
5585
|
+
}
|
|
5586
|
+
/**
|
|
5587
|
+
* #1660: append a recorded cleanup outcome to the adopted update message,
|
|
5588
|
+
* then release the receipt — but only if this receipt still owns the
|
|
5589
|
+
* marker. The await yields, so a newer receipt (e.g. a full-restart that
|
|
5590
|
+
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
5591
|
+
*/
|
|
5592
|
+
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
5593
|
+
const text = formatRestartProgressCompletion("update", {
|
|
5594
|
+
...watch.summary,
|
|
5595
|
+
warningText: warning,
|
|
5596
|
+
warningCommand: cleanupCommand,
|
|
5597
|
+
}, watch.receipt.startedAt);
|
|
5598
|
+
const stopWatching = () => {
|
|
5599
|
+
this.updateCleanupWatch = null;
|
|
5600
|
+
if (this.updateProgressTimer)
|
|
5601
|
+
clearInterval(this.updateProgressTimer);
|
|
5602
|
+
this.updateProgressTimer = null;
|
|
5603
|
+
};
|
|
5604
|
+
if (text !== this.lastUpdateProgressText) {
|
|
5605
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
5606
|
+
if (!adapter)
|
|
5607
|
+
return; // retry on a later tick
|
|
5608
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
5609
|
+
this.lastUpdateProgressText = text;
|
|
5610
|
+
}
|
|
5611
|
+
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
5612
|
+
if (verdict.action === "drop") {
|
|
5613
|
+
// Someone else owns the marker now — leave it entirely alone.
|
|
5614
|
+
this.updateCleanupWatch = null;
|
|
5615
|
+
return;
|
|
5616
|
+
}
|
|
5617
|
+
if (verdict.action === "wait")
|
|
5618
|
+
return; // not terminal; keep watching
|
|
5619
|
+
clearUpdateMarker(this.dataDir);
|
|
5620
|
+
stopWatching();
|
|
5621
|
+
}
|
|
5318
5622
|
startUpdateProgressMonitor(initialAdapter) {
|
|
5319
5623
|
if (this.updateProgressTimer)
|
|
5320
5624
|
clearInterval(this.updateProgressTimer);
|
|
@@ -5328,6 +5632,48 @@ export class FleetManager {
|
|
|
5328
5632
|
this.updateProgressTimer = null;
|
|
5329
5633
|
return;
|
|
5330
5634
|
}
|
|
5635
|
+
// #1660: the completion summary was already posted; only watch for
|
|
5636
|
+
// the retire outcome owed to this exact receipt. Never stage-edit
|
|
5637
|
+
// here (that would clobber the posted completion), and never touch a
|
|
5638
|
+
// receipt that no longer matches (checklist 3/5).
|
|
5639
|
+
const watch = this.updateCleanupWatch;
|
|
5640
|
+
if (watch) {
|
|
5641
|
+
const stopWatching = () => {
|
|
5642
|
+
this.updateCleanupWatch = null;
|
|
5643
|
+
if (this.updateProgressTimer)
|
|
5644
|
+
clearInterval(this.updateProgressTimer);
|
|
5645
|
+
this.updateProgressTimer = null;
|
|
5646
|
+
};
|
|
5647
|
+
const verdict = cleanupWatchVerdict(marker, watch.receipt);
|
|
5648
|
+
if (verdict.action === "drop") {
|
|
5649
|
+
// Receipt gone or replaced (e.g. a later full-restart adopted the
|
|
5650
|
+
// marker): not ours — leave it to its own lifecycle, drop the watch.
|
|
5651
|
+
this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
|
|
5652
|
+
this.updateCleanupWatch = null;
|
|
5653
|
+
}
|
|
5654
|
+
else if (verdict.action === "release") {
|
|
5655
|
+
// Done silent, or the updater died past the max age: release it.
|
|
5656
|
+
clearUpdateMarker(this.dataDir);
|
|
5657
|
+
stopWatching();
|
|
5658
|
+
return;
|
|
5659
|
+
}
|
|
5660
|
+
else if (verdict.action === "wait") {
|
|
5661
|
+
return;
|
|
5662
|
+
}
|
|
5663
|
+
else {
|
|
5664
|
+
this.updateProgressEditRunning = true;
|
|
5665
|
+
try {
|
|
5666
|
+
await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
|
|
5667
|
+
}
|
|
5668
|
+
catch (err) {
|
|
5669
|
+
this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
|
|
5670
|
+
}
|
|
5671
|
+
finally {
|
|
5672
|
+
this.updateProgressEditRunning = false;
|
|
5673
|
+
}
|
|
5674
|
+
return;
|
|
5675
|
+
}
|
|
5676
|
+
}
|
|
5331
5677
|
const target = marker.progress.target;
|
|
5332
5678
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
5333
5679
|
if (!adapter)
|
|
@@ -5394,6 +5740,8 @@ export class FleetManager {
|
|
|
5394
5740
|
const fleet = this.loadConfig(configPath);
|
|
5395
5741
|
this.slimFleetConfigAtStartup();
|
|
5396
5742
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
5743
|
+
// #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
|
|
5744
|
+
const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
|
|
5397
5745
|
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
5398
5746
|
const pendingUpdateProgress = savedUpdateProgress
|
|
5399
5747
|
&& savedUpdateProgress.progress.stage !== "failed"
|
|
@@ -5827,19 +6175,37 @@ export class FleetManager {
|
|
|
5827
6175
|
const _require = createRequire(import.meta.url);
|
|
5828
6176
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
5829
6177
|
await progressStart;
|
|
5830
|
-
|
|
6178
|
+
// #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
|
|
6179
|
+
// a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
|
|
6180
|
+
// render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
|
|
6181
|
+
// — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
|
|
6182
|
+
// for cleanup (that would deadlock with an updater that retires after its restart returns).
|
|
6183
|
+
const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
|
|
6184
|
+
// #1716: background work this restart/update ended, scoped to the
|
|
6185
|
+
// operation so an older stop never reports. No pending operation means
|
|
6186
|
+
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
6187
|
+
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
6188
|
+
const completionSummary = {
|
|
5831
6189
|
running: started,
|
|
5832
6190
|
total,
|
|
5833
6191
|
version: agendVersion,
|
|
5834
6192
|
pausedNames,
|
|
5835
6193
|
failedNames,
|
|
6194
|
+
backgroundWorkEndedNames,
|
|
5836
6195
|
tipText: pendingProgressOperation === "update" && this.tipsEnabled()
|
|
5837
6196
|
? (() => {
|
|
5838
6197
|
const tip = this.pickAvailableTip();
|
|
5839
6198
|
return tip ? this.formatTip(tip) : undefined;
|
|
5840
6199
|
})()
|
|
5841
6200
|
: undefined,
|
|
5842
|
-
|
|
6201
|
+
...(cleanupOutcome && "render" in cleanupOutcome
|
|
6202
|
+
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
6203
|
+
: {}),
|
|
6204
|
+
};
|
|
6205
|
+
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
6206
|
+
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
6207
|
+
if (cleanupOutcome && "watch" in cleanupOutcome)
|
|
6208
|
+
this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
|
|
5843
6209
|
if (!progressCompleted && fleet.channel?.group_id) {
|
|
5844
6210
|
let text;
|
|
5845
6211
|
if (pendingProgressOperation === "full-restart") {
|
|
@@ -5849,6 +6215,7 @@ export class FleetManager {
|
|
|
5849
6215
|
version: agendVersion,
|
|
5850
6216
|
pausedNames,
|
|
5851
6217
|
failedNames,
|
|
6218
|
+
backgroundWorkEndedNames,
|
|
5852
6219
|
}, pendingUpdateProgress.startedAt);
|
|
5853
6220
|
}
|
|
5854
6221
|
else if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
@@ -5861,6 +6228,18 @@ export class FleetManager {
|
|
|
5861
6228
|
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
5862
6229
|
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
5863
6230
|
}
|
|
6231
|
+
// #1716: the fallback carries the same background-work names as the
|
|
6232
|
+
// completion (inline: this block is executed verbatim by the #1737
|
|
6233
|
+
// extracted-completion harness, which resolves `this`-methods plus a
|
|
6234
|
+
// fixed context — no new bare module references here).
|
|
6235
|
+
if (backgroundWorkEndedNames.length) {
|
|
6236
|
+
text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
|
|
6237
|
+
}
|
|
6238
|
+
// #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
|
|
6239
|
+
if (cleanupOutcome && "render" in cleanupOutcome) {
|
|
6240
|
+
text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
|
|
6241
|
+
+ (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
|
|
6242
|
+
}
|
|
5864
6243
|
await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
|
|
5865
6244
|
}
|
|
5866
6245
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
@@ -5882,7 +6261,7 @@ export class FleetManager {
|
|
|
5882
6261
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
5883
6262
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
5884
6263
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
5885
|
-
this.finishStartup();
|
|
6264
|
+
this.finishStartup(bootUpdateMarker);
|
|
5886
6265
|
// Tell systemd we are ready only now. This used to fire right after the
|
|
5887
6266
|
// generals started — before adapters, classic instances, topic creation and the
|
|
5888
6267
|
// health server — so `systemctl start` returned success while the fleet was
|
|
@@ -7132,8 +7511,15 @@ export class FleetManager {
|
|
|
7132
7511
|
async handleInboundMessage(msg) {
|
|
7133
7512
|
if (msg.isBotMessage)
|
|
7134
7513
|
return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
|
|
7135
|
-
|
|
7136
|
-
|
|
7514
|
+
return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
|
|
7515
|
+
}
|
|
7516
|
+
/**
|
|
7517
|
+
* #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
|
|
7518
|
+
* the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
|
|
7519
|
+
*/
|
|
7520
|
+
inboundNoticeAdapter(msg) {
|
|
7521
|
+
const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
|
|
7522
|
+
return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
|
|
7137
7523
|
}
|
|
7138
7524
|
async handleInboundMessageCore(msg) {
|
|
7139
7525
|
// #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
|
|
@@ -7167,6 +7553,15 @@ export class FleetManager {
|
|
|
7167
7553
|
return;
|
|
7168
7554
|
}
|
|
7169
7555
|
const threadId = this.inboundRouteThreadId(msg);
|
|
7556
|
+
// #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
|
|
7557
|
+
// for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
|
|
7558
|
+
// agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
|
|
7559
|
+
// our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
|
|
7560
|
+
// (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
|
|
7561
|
+
if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
|
|
7562
|
+
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");
|
|
7563
|
+
return;
|
|
7564
|
+
}
|
|
7170
7565
|
const rootCommand = this.telegramRootCommandTarget(msg, threadId);
|
|
7171
7566
|
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");
|
|
7172
7567
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
@@ -7298,10 +7693,17 @@ export class FleetManager {
|
|
|
7298
7693
|
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
7299
7694
|
// /start alone already opens the DM for future bot-to-user messages — no further action needed.
|
|
7300
7695
|
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7696
|
+
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
7697
|
+
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
7698
|
+
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
7699
|
+
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
7700
|
+
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
7701
|
+
if (webStart) {
|
|
7702
|
+
if (requestedBackend === "web")
|
|
7703
|
+
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
7704
|
+
// #1742: the /web menu right here; the code comes to this DM.
|
|
7705
|
+
if (admin)
|
|
7706
|
+
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
7305
7707
|
// Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
|
|
7306
7708
|
return;
|
|
7307
7709
|
}
|
|
@@ -7728,9 +8130,14 @@ export class FleetManager {
|
|
|
7728
8130
|
if (Date.now() - lastWarn < 30 * 60_000)
|
|
7729
8131
|
return;
|
|
7730
8132
|
this.rateLimitWarnedAt.set(instanceName, Date.now());
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
8133
|
+
// #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
|
|
8134
|
+
// inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
|
|
8135
|
+
// bot message it would take as input).
|
|
8136
|
+
const warnAdapter = this.getAdapterForInstance(instanceName);
|
|
8137
|
+
const dest = this.cancelButtonDestination(instanceName);
|
|
8138
|
+
const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
|
|
8139
|
+
if (warnAdapter && chatId) {
|
|
8140
|
+
warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
|
|
7734
8141
|
}
|
|
7735
8142
|
}
|
|
7736
8143
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -11543,7 +11950,8 @@ export class FleetManager {
|
|
|
11543
11950
|
// only place some outcomes are told (cancel, later, a resend's result),
|
|
11544
11951
|
// so the text goes out as a message instead (#1133).
|
|
11545
11952
|
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
|
|
11546
|
-
|
|
11953
|
+
// #1742: with its link button, if it had one — the outcome may be the only way to the DM.
|
|
11954
|
+
await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
|
|
11547
11955
|
.catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
|
|
11548
11956
|
}
|
|
11549
11957
|
}
|
|
@@ -15982,11 +16390,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15982
16390
|
}
|
|
15983
16391
|
}
|
|
15984
16392
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
15985
|
-
|
|
16393
|
+
// #1716: say so in the reply when background work was ended with the stop.
|
|
16394
|
+
// Null-safe: a foreign stopInstance stub may resolve nothing at all.
|
|
16395
|
+
const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
|
|
16396
|
+
this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
|
|
16397
|
+
return { backgroundWorkEnded: false };
|
|
16398
|
+
});
|
|
15986
16399
|
clearPausedMarker(this.getInstanceDir(ch.instanceName));
|
|
15987
16400
|
this.reregisterClassicChannels();
|
|
15988
16401
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
15989
|
-
return t("classic.stopped")
|
|
16402
|
+
return t("classic.stopped")
|
|
16403
|
+
+ (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
|
|
15990
16404
|
}
|
|
15991
16405
|
/**
|
|
15992
16406
|
* Idempotent while in flight: SIGINT and SIGTERM share one handler and the
|
|
@@ -16181,8 +16595,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16181
16595
|
this.controlClient = null;
|
|
16182
16596
|
if (this.healthServer) {
|
|
16183
16597
|
this.healthServerListening = false;
|
|
16184
|
-
this.healthServer
|
|
16185
|
-
this.
|
|
16598
|
+
const server = this.healthServer;
|
|
16599
|
+
const drain = this.healthServerDrain ?? trackWebServer(server);
|
|
16600
|
+
await drain.close(this.sseClients);
|
|
16601
|
+
if (this.healthServer === server) {
|
|
16602
|
+
this.healthServer = null;
|
|
16603
|
+
this.healthServerDrain = null;
|
|
16604
|
+
}
|
|
16186
16605
|
}
|
|
16187
16606
|
this.stopPreviewListener();
|
|
16188
16607
|
// The store writes lastSeen at most once a minute; what that debounce is still holding — and any
|
|
@@ -16511,8 +16930,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16511
16930
|
if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
|
|
16512
16931
|
this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
|
|
16513
16932
|
observe?.(ch.instanceName, "restart", "running");
|
|
16933
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
16514
16934
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
16515
|
-
|
|
16935
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
16936
|
+
if (profile.carry)
|
|
16937
|
+
await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
|
|
16938
|
+
else if (profile.startsFresh)
|
|
16516
16939
|
this.writeFreshStartMarker(ch.instanceName);
|
|
16517
16940
|
await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
|
|
16518
16941
|
observe?.(ch.instanceName, "restart", "done");
|
|
@@ -18151,7 +18574,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18151
18574
|
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)");
|
|
18152
18575
|
}
|
|
18153
18576
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
18154
|
-
dispatchWebHttp(req, res, port) {
|
|
18577
|
+
dispatchWebHttp(req, res, port, closing = false) {
|
|
18155
18578
|
try {
|
|
18156
18579
|
res.setHeader("Content-Type", "application/json");
|
|
18157
18580
|
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
@@ -18170,6 +18593,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18170
18593
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
18171
18594
|
return;
|
|
18172
18595
|
}
|
|
18596
|
+
if (closing) {
|
|
18597
|
+
res.writeHead(503, { Connection: "close", "Retry-After": "10" });
|
|
18598
|
+
res.end(JSON.stringify({ error: "fleet stopping" }));
|
|
18599
|
+
return;
|
|
18600
|
+
}
|
|
18173
18601
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
18174
18602
|
// Branding is public, after Host admission: automatic favicon requests
|
|
18175
18603
|
// must not become noisy web-token 401s in the browser console.
|
|
@@ -18488,7 +18916,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18488
18916
|
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
18489
18917
|
if (!this.webToken || !this.webSessions)
|
|
18490
18918
|
this.initializeWebAuthTokens();
|
|
18491
|
-
|
|
18919
|
+
const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
|
|
18920
|
+
const drain = trackWebServer(server);
|
|
18921
|
+
this.healthServer = server;
|
|
18922
|
+
this.healthServerDrain = drain;
|
|
18492
18923
|
const markListening = (afterTakeover = false) => {
|
|
18493
18924
|
this.healthServerListening = true;
|
|
18494
18925
|
this.logger.info({ port }, afterTakeover
|
|
@@ -18574,7 +19005,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18574
19005
|
this.logger.warn({ health_port: requestedPort }, "No port for the HTML preview listener (health_port is the highest port) — set web.preview_port; previews are off");
|
|
18575
19006
|
return;
|
|
18576
19007
|
}
|
|
18577
|
-
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig
|
|
19008
|
+
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
|
|
19009
|
+
// #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
|
|
19010
|
+
extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
|
|
18578
19011
|
this.previewListener = listener;
|
|
18579
19012
|
listener.server.on("error", (err) => {
|
|
18580
19013
|
if (this.previewListener !== listener)
|
|
@@ -18623,6 +19056,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18623
19056
|
decided.reason = "Previews are not available on this fleet right now.";
|
|
18624
19057
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
18625
19058
|
}
|
|
19059
|
+
/**
|
|
19060
|
+
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
19061
|
+
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
19062
|
+
*/
|
|
19063
|
+
publicPreviewForUi(exposureId, dashboardOrigin) {
|
|
19064
|
+
const web = this.fleetConfig?.web;
|
|
19065
|
+
const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
|
|
19066
|
+
if (web?.preview === false)
|
|
19067
|
+
return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
|
|
19068
|
+
if (web?.preview_on_public_link === false)
|
|
19069
|
+
return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
|
|
19070
|
+
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
19071
|
+
if (configured) {
|
|
19072
|
+
const listener = this.previewListening ? this.previewListener : null;
|
|
19073
|
+
if (!listener)
|
|
19074
|
+
return off("publicLink", "Previews are not available on this fleet right now.");
|
|
19075
|
+
if (!configured.startsWith("https:"))
|
|
19076
|
+
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
19077
|
+
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
19078
|
+
}
|
|
19079
|
+
const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
|
|
19080
|
+
if (!status)
|
|
19081
|
+
return off("publicLink", "Previews are not offered on this public link.");
|
|
19082
|
+
// The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
|
|
19083
|
+
// origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
|
|
19084
|
+
if (status.state === "starting")
|
|
19085
|
+
return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
|
|
19086
|
+
if (status.state === "failed")
|
|
19087
|
+
return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
|
|
19088
|
+
return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
|
|
19089
|
+
}
|
|
18626
19090
|
getUiStatus() {
|
|
18627
19091
|
return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
|
|
18628
19092
|
}
|