@songsid/agend 2.1.12-beta.7 → 2.1.12-beta.8

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.
@@ -1,5 +1,5 @@
1
1
  import type { FleetConfig, InstanceConfig } from "./types.js";
2
- import type { ChannelAdapter } from "./channel/types.js";
2
+ import type { ChannelAdapter, InboundMessage } from "./channel/types.js";
3
3
  import type { IpcClient } from "./channel/ipc-bridge.js";
4
4
  import type { Scheduler } from "./scheduler/index.js";
5
5
  import type { Logger } from "./logger.js";
@@ -89,6 +89,15 @@ export interface FleetContext {
89
89
  isFleetAdmin(userId: string, adapterId?: string): boolean;
90
90
  /** Whether the adapter has any fleet admin at all (an empty allowlist turns the admin commands off). */
91
91
  hasFleetAdmins(adapterId?: string): boolean;
92
+ /** Telegram-only General command; owns admin/route checks and completion notice. */
93
+ runProfileCommand?(msg: InboundMessage, seconds?: string): Promise<void>;
94
+ /**
95
+ * #1346: the adapter that owns an instance's topic. Text commands resolve
96
+ * permission and replies through the owner, never the adapter whose copy
97
+ * happened to win the cross-adapter dedup race. Optional so older stubs
98
+ * keep working (they fall back to the receiving adapter).
99
+ */
100
+ getInstanceAdapterId?(instanceName: string): string | undefined;
92
101
  changeInstancePauseState(name: string, action: "pause" | "wake"): Promise<"paused" | "awake" | "not_idle">;
93
102
  startInstance(name: string, config: InstanceConfig, topicMode: boolean): Promise<void>;
94
103
  stopInstance(name: string): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { type ProfileTicket } from "./runtime-cpu-profile.js";
2
+ import type { CpuProfile } from "./cpu-profile.js";
1
3
  import { type FleetMemory } from "./process-memory.js";
2
4
  import { MemoryPressure, type MemoryPressureSnapshot } from "./memory-pressure.js";
3
5
  import { ReplyDeduper } from "./reply-dedup.js";
@@ -8,7 +10,7 @@ import { EventLog } from "./event-log.js";
8
10
  import { AdapterWorld } from "./adapter-world.js";
9
11
  import { CostGuard } from "./cost-guard.js";
10
12
  import { IpcClient } from "./channel/ipc-bridge.js";
11
- import type { AdapterHealthSnapshot, ChannelAdapter, InboundReaction } from "./channel/types.js";
13
+ import type { AdapterHealthSnapshot, ChannelAdapter, InboundMessage, InboundReaction } from "./channel/types.js";
12
14
  import { type BackendCliVersionSnapshot } from "./backend/types.js";
13
15
  import { type Logger } from "./logger.js";
14
16
  import { Scheduler } from "./scheduler/index.js";
@@ -564,6 +566,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
564
566
  /** SIGUSR1: full process reload. Names who asked (#1120). */
565
567
  private onFullRestartSignal;
566
568
  private handleUpdateSlash;
569
+ private handleProfileSlash;
570
+ /** Typed /profile belongs only to Telegram's General dispatcher. */
571
+ runProfileCommand(msg: InboundMessage, seconds?: string): Promise<void>;
572
+ private handleGeneralProfile;
567
573
  private handleRestartSlash;
568
574
  /** #1302: `/visibility [mode]`. The dispatcher has already applied the command table's fleet-admin gate. */
569
575
  private handleVisibilitySlash;
@@ -607,7 +613,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
607
613
  * Fleet instances without channel_id and legacy Classic entries both belong
608
614
  * to channels[0]. Runtime bindings remain available for external sessions.
609
615
  */
610
- private getInstanceAdapterId;
616
+ getInstanceAdapterId(name: string): string | undefined;
611
617
  /**
612
618
  * Bind an instance to a specific world (the bot that answers for it).
613
619
  * fromInbound=true (binding inferred from which adapter received a message)
@@ -704,6 +710,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
704
710
  * type a slash command could `/update` the host.
705
711
  */
706
712
  fleetAdminGate(userId: string, adapterId?: string): "ok" | "disabled" | "denied";
713
+ private runtimeCpuProfiler;
714
+ private cpuProfileControl;
715
+ private getCpuProfiler;
716
+ /** Called only by cold CLI startup, after claiming the fleet singleton. */
717
+ startCpuProfileControl(): Promise<void>;
718
+ startEnvironmentCpuProfile(): Promise<CpuProfile | null>;
719
+ startCpuProfile(seconds?: string | number): Promise<ProfileTicket>;
707
720
  /**
708
721
  * Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
709
722
  *
@@ -1110,6 +1123,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1110
1123
  * the message came from — its own world's group_id. Two Telegram fleet
1111
1124
  * worlds can number topics alike; a coincidence is dropped, never delivered.
1112
1125
  */
1126
+ /**
1127
+ * #1346: whether this copy may claim the shared dedup key for
1128
+ * command-like text. Fleet topics resolve an owning adapter and only its
1129
+ * copy proceeds; anything else (classic targets, unknown routing, no
1130
+ * identity) passes through to the existing handling.
1131
+ */
1132
+ private isOwnerCommandCopy;
1113
1133
  private resolveInboundTarget;
1114
1134
  private handleInboundMessage;
1115
1135
  /** Handle outbound tool calls from a daemon instance */
@@ -2059,6 +2079,16 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
2059
2079
  touchActivity(instanceName: string): void;
2060
2080
  /** Start periodic idle archive checker */
2061
2081
  private clearStatuslineWatchers;
2082
+ /**
2083
+ * #1346: on Discord, plain-text /xxx is never a command — the slash menu
2084
+ * is. Both fleet call sites run this BEFORE the command handlers, so even
2085
+ * the owning adapter's copy never executes text. The owning adapter posts
2086
+ * one system note and consumes the message so it never reaches the agent;
2087
+ * other adapters' copies stay silent (and the shared dedup key means
2088
+ * exactly one copy gets here). Telegram keeps text commands working —
2089
+ * text is its command path — and other sources pass through untouched.
2090
+ */
2091
+ private replyDiscordNotACommand;
2062
2092
  /** Handle a message in a classic channel: log it, forward only /chat messages */
2063
2093
  private handleClassicChannelMessage;
2064
2094
  /** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */
@@ -1,4 +1,6 @@
1
1
  import { measureSyncWork } from "./sync-work-attribution.js";
2
+ import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
3
+ import { ProfileControlServer } from "./profile-control.js";
2
4
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
3
5
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
6
  import { spawnSync } from "node:child_process";
@@ -1589,6 +1591,85 @@ export class FleetManager {
1589
1591
  child.once("error", err => this.failUpdateProgress(err.message));
1590
1592
  child.unref();
1591
1593
  }
1594
+ async handleProfileSlash(data, adapterId) {
1595
+ const route = this.routing.resolve(data.channelId);
1596
+ await this.handleGeneralProfile(route?.kind === "general" ? route.name : undefined, data.userId, adapterId, data.options?.seconds, data.respond);
1597
+ }
1598
+ /** Typed /profile belongs only to Telegram's General dispatcher. */
1599
+ async runProfileCommand(msg, seconds) {
1600
+ if (msg.source !== "telegram")
1601
+ return;
1602
+ const adapterId = msg.adapterId ?? this.getPrimaryAdapterId();
1603
+ const route = msg.threadId ? this.routing.resolve(msg.threadId) : undefined;
1604
+ const general = msg.threadId ? (route?.kind === "general" ? route.name : undefined)
1605
+ : Object.keys(this.fleetConfig?.instances ?? {}).find(name => this.fleetConfig.instances[name].general_topic
1606
+ && this.getInstanceAdapterId(name) === adapterId);
1607
+ const adapter = adapterId ? this.adapters.get(adapterId) : this.adapter;
1608
+ if (!adapter)
1609
+ return;
1610
+ await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
1611
+ }
1612
+ async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
1613
+ if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
1614
+ await respond(t("profile.general_only"));
1615
+ return;
1616
+ }
1617
+ const ownerId = this.getInstanceAdapterId(general);
1618
+ if (!ownerId || ownerId !== ingressAdapterId) {
1619
+ await respond(t("not_authorized"));
1620
+ return;
1621
+ }
1622
+ const gate = this.fleetAdminGate(userId, ownerId);
1623
+ if (gate !== "ok") {
1624
+ await respond(t(gate === "disabled" ? "profile.disabled" : "not_authorized"));
1625
+ return;
1626
+ }
1627
+ const adapter = this.getAdapterForInstance(general);
1628
+ const group = String(this.getGroupIdForInstance(general) ?? "");
1629
+ const topic = this.fleetConfig.instances[general].topic_id?.toString();
1630
+ if (!adapter || this.worlds.get(ownerId)?.adapter !== adapter || !group) {
1631
+ await respond(t("profile.unavailable"));
1632
+ return;
1633
+ }
1634
+ let duration;
1635
+ try {
1636
+ duration = profileDuration(seconds);
1637
+ }
1638
+ catch {
1639
+ await respond(t("profile.invalid"));
1640
+ return;
1641
+ }
1642
+ let ticket;
1643
+ try {
1644
+ ticket = await this.startCpuProfile(duration);
1645
+ }
1646
+ catch (err) {
1647
+ await respond(err instanceof ProfileBusyError ? t("profile.busy", String(err.remainingSeconds)) : t("profile.unavailable"));
1648
+ return;
1649
+ }
1650
+ // A long recording must not hold a Discord interaction (or the inbound handler) open.
1651
+ const initial = Promise.resolve().then(() => respond(t("profile.started", String(ticket.seconds))));
1652
+ void (async () => {
1653
+ await initial.catch(err => this.logger.warn({ err }, "CPU profile acknowledgement failed"));
1654
+ let message;
1655
+ try {
1656
+ const result = await ticket.done;
1657
+ const size = result.bytes === null ? t("profile.size_unknown") : `${(result.bytes / 1048576).toFixed(2)} MiB`;
1658
+ message = t("profile.saved", result.path, size);
1659
+ }
1660
+ catch {
1661
+ message = t("profile.failed");
1662
+ }
1663
+ // Never send a delayed artifact path to a replacement adapter/topic/world.
1664
+ if (this.shuttingDown || this.getInstanceAdapterId(general) !== ownerId
1665
+ || this.getAdapterForInstance(general) !== adapter || String(this.getGroupIdForInstance(general) ?? "") !== group
1666
+ || this.fleetConfig?.instances[general]?.topic_id?.toString() !== topic
1667
+ || !this.fleetConfig?.instances[general]?.general_topic)
1668
+ return;
1669
+ await adapter.sendText(group, message, { threadId: topic });
1670
+ })().catch(err => this.logger.warn({ err }, "CPU profile General notice failed"));
1671
+ await initial.catch(() => { });
1672
+ }
1592
1673
  async handleRestartSlash(data, adapterId) {
1593
1674
  if (!this.isFleetAdmin(data.userId, adapterId)) {
1594
1675
  await data.respond(t("not_authorized"));
@@ -2360,6 +2441,36 @@ export class FleetManager {
2360
2441
  return "disabled";
2361
2442
  return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
2362
2443
  }
2444
+ runtimeCpuProfiler = null;
2445
+ cpuProfileControl = null;
2446
+ getCpuProfiler() {
2447
+ if (!this.runtimeCpuProfiler || (this.runtimeCpuProfiler.closed && !this.shuttingDown)) {
2448
+ this.runtimeCpuProfiler = new RuntimeCpuProfiler({ dataDir: this.dataDir,
2449
+ logger: { info: message => this.logger.info(message), warn: message => this.logger.warn(message) } });
2450
+ }
2451
+ return this.runtimeCpuProfiler;
2452
+ }
2453
+ /** Called only by cold CLI startup, after claiming the fleet singleton. */
2454
+ async startCpuProfileControl() {
2455
+ if (this.shuttingDown || this.cpuProfileControl)
2456
+ return;
2457
+ const control = new ProfileControlServer(this.dataDir, this.getCpuProfiler());
2458
+ this.cpuProfileControl = control; // shutdown owns even an in-flight listen
2459
+ try {
2460
+ await control.listen();
2461
+ }
2462
+ catch (err) {
2463
+ if (this.cpuProfileControl === control)
2464
+ this.cpuProfileControl = null;
2465
+ throw err;
2466
+ }
2467
+ }
2468
+ startEnvironmentCpuProfile() { return this.getCpuProfiler().startFromEnvironment(); }
2469
+ startCpuProfile(seconds) {
2470
+ if (this.shuttingDown)
2471
+ return Promise.reject(new Error("Fleet is stopping."));
2472
+ return this.getCpuProfiler().start(seconds);
2473
+ }
2363
2474
  /**
2364
2475
  * Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
2365
2476
  *
@@ -2492,6 +2603,9 @@ export class FleetManager {
2492
2603
  else if (data.command === "update") {
2493
2604
  await this.handleUpdateSlash(data, adapterId);
2494
2605
  }
2606
+ else if (data.command === "profile") {
2607
+ await this.handleProfileSlash(data, adapterId);
2608
+ }
2495
2609
  else if (data.command === "doctor") {
2496
2610
  await data.respond(await this.runBackendDoctor());
2497
2611
  }
@@ -5325,6 +5439,35 @@ export class FleetManager {
5325
5439
  * the message came from — its own world's group_id. Two Telegram fleet
5326
5440
  * worlds can number topics alike; a coincidence is dropped, never delivered.
5327
5441
  */
5442
+ /**
5443
+ * #1346: whether this copy may claim the shared dedup key for
5444
+ * command-like text. Fleet topics resolve an owning adapter and only its
5445
+ * copy proceeds; anything else (classic targets, unknown routing, no
5446
+ * identity) passes through to the existing handling.
5447
+ */
5448
+ isOwnerCommandCopy(msg, threadId) {
5449
+ if (!msg.adapterId || threadId === undefined)
5450
+ return true;
5451
+ const target = this.resolveInboundTarget(msg, threadId);
5452
+ if (!target || target.kind === "classic")
5453
+ return true;
5454
+ const owner = this.getInstanceAdapterId(target.name);
5455
+ if (owner && msg.adapterId !== owner)
5456
+ return false;
5457
+ // #1346 6b: /cmd@otherbot is addressed elsewhere — ignore it even on the
5458
+ // owner's copy. An unknown username can't be judged: let it through (the
5459
+ // receiver gate above still applies). Same case-insensitive rule as the
5460
+ // Telegram classic branch.
5461
+ // Hyphenated aliases count too (e.g. /install-cli): Telegram command
5462
+ // names cannot contain "-", but typed aliases can.
5463
+ const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
5464
+ if (suffix && owner) {
5465
+ const ownerUser = this.worlds.get(owner)?.botUsername;
5466
+ if (ownerUser && suffix.toLowerCase() !== ownerUser.toLowerCase())
5467
+ return false;
5468
+ }
5469
+ return true;
5470
+ }
5328
5471
  resolveInboundTarget(msg, threadId) {
5329
5472
  if (msg.source !== "telegram")
5330
5473
  return this.routing.resolve(threadId);
@@ -5388,6 +5531,16 @@ export class FleetManager {
5388
5531
  // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
5389
5532
  // separate agents in one channel, so each bot must process its OWN copy of
5390
5533
  // the message (the @mention filter downstream decides who actually forwards).
5534
+ // #1346: a non-owner adapter's copy of command-like text must not burn
5535
+ // the shared dedup key — the owner's copy still has to run the command.
5536
+ // (Non-command input keeps first-wins delivery.) Classic channels keep
5537
+ // per-adapter keys — first-/start-contact onboarding needs every copy
5538
+ // to flow — so only fleet targets are judged here; the in-handler gates
5539
+ // (owner entry check, Discord ignore) cover classic instead.
5540
+ if (msg.messageId && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
5541
+ this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
5542
+ return;
5543
+ }
5391
5544
  // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
5392
5545
  if (msg.messageId) {
5393
5546
  const classicCid = threadId || msg.chatId;
@@ -5716,11 +5869,14 @@ export class FleetManager {
5716
5869
  // Unregistered group: ignore
5717
5870
  return;
5718
5871
  }
5719
- // General topic: check /ctx /compact /collab first, then admin commands
5872
+ // General topic: Discord /xxx gets the system note first (never a
5873
+ // command); other sources fall through to the handlers below.
5720
5874
  const generalInstance = this.findGeneralInstance(msg.adapterId);
5875
+ if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
5876
+ return;
5721
5877
  if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
5722
5878
  return;
5723
- if (await this.topicCommands.handleGeneralCommand(msg))
5879
+ if (generalInstance && await this.topicCommands.handleGeneralCommand(msg, generalInstance))
5724
5880
  return;
5725
5881
  // Forward to General Topic instance if configured
5726
5882
  if (generalInstance) {
@@ -5805,13 +5961,18 @@ export class FleetManager {
5805
5961
  return;
5806
5962
  }
5807
5963
  const instanceName = target.name;
5964
+ // #1346: on Discord, plain-text /xxx runs no command — the owner posts
5965
+ // the system note before the handlers below ever see the text.
5966
+ if (await this.replyDiscordNotACommand(msg, instanceName)) {
5967
+ return;
5968
+ }
5808
5969
  // Intercept /ctx /compact /collab in ANY topic (including general)
5809
5970
  if (await this.topicCommands.handleInstanceCommand(msg, instanceName)) {
5810
5971
  return;
5811
5972
  }
5812
5973
  // Intercept admin commands (/status, /restart, /sysinfo) in general topics
5813
5974
  const instanceConfig = this.fleetConfig?.instances[instanceName];
5814
- if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg)) {
5975
+ if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg, instanceName)) {
5815
5976
  return;
5816
5977
  }
5817
5978
  // Bind instance to the adapter that delivered this message
@@ -11077,13 +11238,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11077
11238
  this.failoverActive.clear();
11078
11239
  }
11079
11240
  // ── Classic Channel Methods ──────────────────────────────────────────
11241
+ /**
11242
+ * #1346: on Discord, plain-text /xxx is never a command — the slash menu
11243
+ * is. Both fleet call sites run this BEFORE the command handlers, so even
11244
+ * the owning adapter's copy never executes text. The owning adapter posts
11245
+ * one system note and consumes the message so it never reaches the agent;
11246
+ * other adapters' copies stay silent (and the shared dedup key means
11247
+ * exactly one copy gets here). Telegram keeps text commands working —
11248
+ * text is its command path — and other sources pass through untouched.
11249
+ */
11250
+ async replyDiscordNotACommand(msg, instanceName) {
11251
+ if (msg.source !== "discord")
11252
+ return false;
11253
+ if (!/^\/\w/.test(msg.text?.trim() ?? ""))
11254
+ return false;
11255
+ const owner = this.getInstanceAdapterId(instanceName);
11256
+ if (msg.adapterId && owner && msg.adapterId !== owner)
11257
+ return false;
11258
+ const adapter = this.worlds.get(owner ?? msg.adapterId ?? "")?.adapter ?? this.adapter;
11259
+ if (!adapter)
11260
+ return false;
11261
+ await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
11262
+ return true;
11263
+ }
11080
11264
  /** Handle a message in a classic channel: log it, forward only /chat messages */
11081
11265
  async handleClassicChannelMessage(instanceName, msg) {
11082
11266
  const text = msg.text ?? "";
11083
11267
  const channelId = msg.threadId ?? msg.chatId;
11084
11268
  const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
11085
- // Handle /ctx in classic mode — always, regardless of collab mode
11269
+ // #1346: Discord ClassicBot channels ignore plain-text /xxx silently —
11270
+ // no warning (multi-bot groups must not all warn), no command, and no
11271
+ // forward to the agent either. The received-reaction and chat-log paths
11272
+ // below still run (a /chat ack is not a reply). Telegram keeps working
11273
+ // (text is its command path there).
11274
+ const discordSlashText = msg.source === "discord" && /^\/\w/.test(text.trim());
11275
+ // Handle /ctx in classic mode — always, regardless of collab mode.
11276
+ // Only the adapter that owns an entry in this channel answers; the
11277
+ // per-adapter dedup key already scopes copies, this guards the rest.
11278
+ // Discord /chat still flows through (its received-reaction below is an
11279
+ // ack, not a reply); only its forward is skipped.
11280
+ if (discordSlashText && !/^\/chat(\s|$)/.test(text.trim()))
11281
+ return;
11086
11282
  if (text === "/ctx" || text.startsWith("/ctx@")) {
11283
+ if (!this.classicChannels?.getInstanceByChannel(channelId, msg.adapterId))
11284
+ return;
11087
11285
  const reply = await this.topicCommands.getCtxText(instanceName);
11088
11286
  const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
11089
11287
  if (classicAdapter)
@@ -11185,7 +11383,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11185
11383
  finalText = `${tags}\n${finalText}`;
11186
11384
  }
11187
11385
  }
11188
- await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11386
+ // #1346: Discord typed /xxx is never forwarded (reacts above already ran).
11387
+ if (!discordSlashText)
11388
+ await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11189
11389
  return;
11190
11390
  }
11191
11391
  // Normal mode: /chat trigger
@@ -11252,7 +11452,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11252
11452
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
11253
11453
  }
11254
11454
  }
11255
- await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11455
+ // #1346: Discord typed /chat gets its received-reaction above but is
11456
+ // never forwarded to the agent.
11457
+ if (!discordSlashText)
11458
+ await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11256
11459
  }
11257
11460
  /** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */
11258
11461
  async saveClassicAttachment(instanceName, msg) {
@@ -12690,6 +12893,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12690
12893
  // because we asked it to. Set synchronously — doStopAll runs to its first
12691
12894
  // await in the same tick as the signal handler, so no event can slip in.
12692
12895
  this.shuttingDown = true;
12896
+ const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
12693
12897
  this.ipcStoppingInstances.add("__fleet_stopping__");
12694
12898
  // Release held delivery promises before awaiting daemon shutdown, then
12695
12899
  // reject spawn work which has not started. Otherwise a storm backoff could
@@ -12718,6 +12922,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12718
12922
  // A login/install window is a dedicated tmux server with its own TTL
12719
12923
  // timer and HTTP listener living in THIS process: without an explicit
12720
12924
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
12925
+ await profileStopped;
12926
+ await this.cpuProfileControl?.close();
12927
+ this.cpuProfileControl = null;
12721
12928
  await this.shutdownLoginWindows();
12722
12929
  // Cancel adapter retry timers
12723
12930
  for (const state of this.adapterState.values()) {