@integrity-labs/agt-cli 0.27.115 → 0.27.117

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.
@@ -17,7 +17,7 @@ import {
17
17
  provisionStopHook,
18
18
  requireHost,
19
19
  safeWriteJsonAtomic
20
- } from "../chunk-EOMWSV4B.js";
20
+ } from "../chunk-SDRBYSRO.js";
21
21
  import {
22
22
  getProjectDir as getProjectDir2,
23
23
  getReadyTasks,
@@ -971,6 +971,99 @@ function formatStatusMessage(events, windowMs) {
971
971
  return `Circuit breaker tripped: ${events.length} restarts in ${windowLabel} (${breakdown}); most recent=${last.reason} at ${new Date(last.at).toISOString()}`;
972
972
  }
973
973
 
974
+ // src/lib/mcp-flap-dampener.ts
975
+ var DEFAULT_WINDOW_MS2 = 6e5;
976
+ var DEFAULT_MIN_TRANSITIONS = 4;
977
+ var DEFAULT_MIN_DISTINCT = 2;
978
+ function readEnvNumber2(name, fallback) {
979
+ const raw = process.env[name];
980
+ if (!raw) return fallback;
981
+ const parsed = Number(raw);
982
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
983
+ }
984
+ function membershipSignature(keys) {
985
+ return [...new Set(keys)].sort().join(",");
986
+ }
987
+ var McpFlapDampener = class {
988
+ windowMs;
989
+ minTransitions;
990
+ minDistinct;
991
+ now;
992
+ history = /* @__PURE__ */ new Map();
993
+ flapping = /* @__PURE__ */ new Set();
994
+ constructor(opts = {}) {
995
+ this.windowMs = opts.windowMs ?? readEnvNumber2("AGT_MCP_FLAP_WINDOW_MS", DEFAULT_WINDOW_MS2);
996
+ this.minTransitions = opts.minTransitions ?? readEnvNumber2("AGT_MCP_FLAP_MIN_TRANSITIONS", DEFAULT_MIN_TRANSITIONS);
997
+ this.minDistinct = opts.minDistinct ?? readEnvNumber2("AGT_MCP_FLAP_MIN_DISTINCT", DEFAULT_MIN_DISTINCT);
998
+ this.now = opts.now ?? Date.now;
999
+ if (!Number.isFinite(this.windowMs) || this.windowMs < 1e3) {
1000
+ throw new Error("mcp-flap-dampener windowMs must be a finite number >= 1000");
1001
+ }
1002
+ if (!Number.isFinite(this.minTransitions) || this.minTransitions < 2) {
1003
+ throw new Error("mcp-flap-dampener minTransitions must be a finite number >= 2");
1004
+ }
1005
+ if (!Number.isFinite(this.minDistinct) || this.minDistinct < 2) {
1006
+ throw new Error("mcp-flap-dampener minDistinct must be a finite number >= 2");
1007
+ }
1008
+ }
1009
+ key(codeName, channel) {
1010
+ return `${codeName}\0${channel}`;
1011
+ }
1012
+ /**
1013
+ * Record an observed membership signature for (codeName, channel) and return
1014
+ * the current flap decision. Call this on every poll where the manager has
1015
+ * computed the desired set — including unchanged polls — so a set that has
1016
+ * gone quiet ages out of the window and the channel recovers.
1017
+ */
1018
+ record(codeName, channel, signature) {
1019
+ const k = this.key(codeName, channel);
1020
+ const at = this.now();
1021
+ const cutoff = at - this.windowMs;
1022
+ const prior = (this.history.get(k) ?? []).filter((o) => o.at >= cutoff);
1023
+ prior.push({ sig: signature, at });
1024
+ this.history.set(k, prior);
1025
+ let transitions = 0;
1026
+ for (let i = 1; i < prior.length; i++) {
1027
+ if (prior[i].sig !== prior[i - 1].sig) transitions++;
1028
+ }
1029
+ const distinctStates = new Set(prior.map((o) => o.sig)).size;
1030
+ const revisited = prior.slice(0, -1).some((o, i) => o.sig === signature && i !== prior.length - 2);
1031
+ const transitionNow = prior.length >= 2 && prior[prior.length - 1].sig !== prior[prior.length - 2].sig;
1032
+ const oscillating = transitions >= this.minTransitions && distinctStates >= this.minDistinct && revisited;
1033
+ const wasFlapping = this.flapping.has(k);
1034
+ let onset = false;
1035
+ if (wasFlapping) {
1036
+ if (!transitionNow) this.flapping.delete(k);
1037
+ } else if (transitionNow && oscillating) {
1038
+ this.flapping.add(k);
1039
+ onset = true;
1040
+ }
1041
+ return {
1042
+ flapping: this.flapping.has(k),
1043
+ distinctStates,
1044
+ transitions,
1045
+ windowCount: prior.length,
1046
+ onset
1047
+ };
1048
+ }
1049
+ /** True if (codeName, channel) is currently dampened. */
1050
+ isFlapping(codeName, channel) {
1051
+ return this.flapping.has(this.key(codeName, channel));
1052
+ }
1053
+ /** Drop all state for an agent (called on deprovision / agent removal). */
1054
+ forget(codeName) {
1055
+ const prefix = `${codeName}\0`;
1056
+ for (const k of [...this.history.keys()]) {
1057
+ if (k.startsWith(prefix)) this.history.delete(k);
1058
+ }
1059
+ for (const k of [...this.flapping]) {
1060
+ if (k.startsWith(prefix)) this.flapping.delete(k);
1061
+ }
1062
+ }
1063
+ };
1064
+ var FLAP_CHANNEL_INTEGRATIONS = "integrations";
1065
+ var FLAP_CHANNEL_MANAGED_MCP = "managed-mcp";
1066
+
974
1067
  // src/lib/auto-resume.ts
975
1068
  var DEFAULT_QUIET_MS = 18e5;
976
1069
  var DEFAULT_BACKOFF_RESET_MS = 864e5;
@@ -3803,6 +3896,19 @@ function hasRevokedResiduals(state6) {
3803
3896
  var pendingSessionRestarts = /* @__PURE__ */ new Map();
3804
3897
  var restartBreaker = new RestartBreaker();
3805
3898
  var reportedTrips = /* @__PURE__ */ new Map();
3899
+ var mcpFlapDampener = new McpFlapDampener();
3900
+ function recordConfigChurnEvent(agentId, codeName, channel, signature) {
3901
+ api.post("/host/mcp-config-churn", {
3902
+ agent_id: agentId,
3903
+ code_name: codeName,
3904
+ channel,
3905
+ signature
3906
+ }).catch((err) => {
3907
+ log(
3908
+ `[mcp-flap-dampener] failed to record config-churn event for '${codeName}' (${channel}) (ENG-6123): ${err.message} \u2014 local suppression still active; CloudWatch metric will under-count`
3909
+ );
3910
+ });
3911
+ }
3806
3912
  function recordRestartForBreaker(codeName, reason) {
3807
3913
  const result = restartBreaker.record(codeName, reason);
3808
3914
  if (!result.tripped || !result.trip) return;
@@ -3901,12 +4007,17 @@ function scheduleSessionRestart(codeName, delayMs, reason, breakerReason = "hot-
3901
4007
  pendingSessionRestarts.delete(codeName);
3902
4008
  const gate = restartGateFor(codeName, breakerReason);
3903
4009
  if (gate !== "bypass" && gate !== "proceed") {
3904
- log(
3905
- `[maintenance-window] Deferring '${reason}' restart for '${codeName}' (${gate}) \u2014 re-checking in ${RESTART_DEFER_RECHECK_MS / 1e3}s`
3906
- );
4010
+ const lastLogged = deferLogThrottle.get(codeName);
4011
+ if (lastLogged === void 0 || Date.now() - lastLogged >= DEFER_LOG_THROTTLE_MS) {
4012
+ log(
4013
+ `[maintenance-window] Deferring '${reason}' restart for '${codeName}' (${gate}) \u2014 re-checking every ${RESTART_DEFER_RECHECK_MS / 1e3}s until the window opens`
4014
+ );
4015
+ deferLogThrottle.set(codeName, Date.now());
4016
+ }
3907
4017
  scheduleSessionRestart(codeName, RESTART_DEFER_RECHECK_MS, reason, breakerReason);
3908
4018
  return;
3909
4019
  }
4020
+ deferLogThrottle.delete(codeName);
3910
4021
  stopPersistentSession(codeName, log);
3911
4022
  runningMcpHashes.delete(codeName);
3912
4023
  recordRestartForBreaker(codeName, breakerReason);
@@ -3920,9 +4031,12 @@ function cancelPendingSessionRestart(codeName) {
3920
4031
  if (!existing) return;
3921
4032
  clearTimeout(existing);
3922
4033
  pendingSessionRestarts.delete(codeName);
4034
+ deferLogThrottle.delete(codeName);
3923
4035
  log(`[hot-reload] Cancelled pending restart timer for '${codeName}' (another teardown path is handling it)`);
3924
4036
  }
3925
4037
  var RESTART_DEFER_RECHECK_MS = 6e4;
4038
+ var DEFER_LOG_THROTTLE_MS = 6e5;
4039
+ var deferLogThrottle = /* @__PURE__ */ new Map();
3926
4040
  var lastInboundMs = /* @__PURE__ */ new Map();
3927
4041
  var consecutiveWedgeCycles = /* @__PURE__ */ new Map();
3928
4042
  function noteInbound(codeName) {
@@ -4099,6 +4213,7 @@ function checkMcpConfigDriftAndScheduleRestart(codeName, projectDir) {
4099
4213
  const removed = decision.addedOrRemoved.filter((k) => !currentKeys.has(k));
4100
4214
  quarantineOnlyDrift = added.length === 0 && removed.length > 0 && removed.every((k) => quarantined.has(k));
4101
4215
  }
4216
+ const flapSuppressed = decision.restart && !quarantineOnlyDrift && mcpFlapDampener.isFlapping(codeName, FLAP_CHANNEL_MANAGED_MCP);
4102
4217
  if (decision.membershipUnknown) {
4103
4218
  clearPresenceReaperState(codeName);
4104
4219
  log(
@@ -4108,6 +4223,10 @@ function checkMcpConfigDriftAndScheduleRestart(codeName, projectDir) {
4108
4223
  log(
4109
4224
  `[channel-quarantine] .mcp.json drift for '${codeName}' is quarantined-channel removal only [${decision.addedOrRemoved.join(", ")}] \u2014 adopting new baseline WITHOUT restart (0-restart-on-removal, ENG-5932)`
4110
4225
  );
4226
+ } else if (flapSuppressed) {
4227
+ log(
4228
+ `[mcp-flap-dampener] .mcp.json membership drift for '${codeName}' [${decision.addedOrRemoved.join(", ")}] held \u2014 managed-MCP set is flapping, adopting baseline WITHOUT restart until it settles (ENG-6123)`
4229
+ );
4111
4230
  } else if (decision.restart) {
4112
4231
  clearPresenceReaperStateForKeys(codeName, new Set(decision.addedOrRemoved));
4113
4232
  log(
@@ -4118,10 +4237,11 @@ function checkMcpConfigDriftAndScheduleRestart(codeName, projectDir) {
4118
4237
  `[hot-reload] .mcp.json value-only drift for '${codeName}' \u2014 preserving presence-reaper state and NOT restarting (ENG-5285/ENG-5537)`
4119
4238
  );
4120
4239
  }
4121
- if (decision.restart && !quarantineOnlyDrift) {
4240
+ if (decision.restart && !quarantineOnlyDrift && !flapSuppressed) {
4122
4241
  scheduleSessionRestart(codeName, 0, ".mcp.json content change (ENG-4897)");
4123
4242
  runningMcpHashes.delete(codeName);
4124
4243
  runningMcpServerKeys.delete(codeName);
4244
+ } else if (flapSuppressed) {
4125
4245
  } else {
4126
4246
  runningMcpHashes.set(codeName, action.current);
4127
4247
  if (currentKeys) runningMcpServerKeys.set(codeName, currentKeys);
@@ -4216,6 +4336,8 @@ function clearAgentCaches(agentId, codeName) {
4216
4336
  for (const key of taskDisplayInfo.keys()) {
4217
4337
  if (key.startsWith(`${codeName}:`)) taskDisplayInfo.delete(key);
4218
4338
  }
4339
+ mcpFlapDampener.forget(codeName);
4340
+ deferLogThrottle.delete(codeName);
4219
4341
  if (channelCacheMutated) saveChannelHashCache2();
4220
4342
  }
4221
4343
  var cachedFrameworkVersion = null;
@@ -4223,7 +4345,7 @@ var cachedMaintenanceWindow = null;
4223
4345
  var lastVersionCheckAt = 0;
4224
4346
  var VERSION_CHECK_INTERVAL_MS = 5 * 60 * 1e3;
4225
4347
  var lastResponsivenessProbeAt = 0;
4226
- var agtCliVersion = true ? "0.27.115" : "dev";
4348
+ var agtCliVersion = true ? "0.27.117" : "dev";
4227
4349
  function resolveBrewPath(execFileSync4) {
4228
4350
  try {
4229
4351
  const out = execFileSync4("which", ["brew"], { timeout: 5e3 }).toString().trim();
@@ -6441,6 +6563,7 @@ async function processAgent(agent, agentStates) {
6441
6563
  const telegramPeers = channelId === "telegram" ? extractCharterTelegramPeers(refreshData.charter?.raw_content ?? "", gateContext) : void 0;
6442
6564
  const slackPeers = channelId === "slack" ? extractCharterSlackPeers(refreshData.charter?.raw_content ?? "", gateContext) : void 0;
6443
6565
  const senderPolicyForCall = channelId === "slack" || channelId === "msteams" ? refreshData.sender_policy ?? void 0 : void 0;
6566
+ const agentAvatarUrl = channelId === "slack" ? (refreshData.agent.avatar_url ?? void 0) || void 0 : void 0;
6444
6567
  frameworkAdapter.writeChannelCredentials(
6445
6568
  agent.code_name,
6446
6569
  channelId,
@@ -6453,7 +6576,8 @@ async function processAgent(agent, agentStates) {
6453
6576
  telegramPeers,
6454
6577
  slackPeers,
6455
6578
  agentTimezone,
6456
- senderPolicy: senderPolicyForCall
6579
+ senderPolicy: senderPolicyForCall,
6580
+ agentAvatarUrl
6457
6581
  }
6458
6582
  );
6459
6583
  agentState.knownChannelConfigHashes.set(cacheKey, configHash);
@@ -6760,6 +6884,14 @@ async function processAgent(agent, agentStates) {
6760
6884
  if (integrations.length > 0) {
6761
6885
  const intHash = computeIntegrationsHash(integrations);
6762
6886
  const prevIntHash = agentState.knownIntegrationHashes.get(agent.agent_id);
6887
+ const intMembership = membershipSignature(integrations.map((i) => i.definition_id));
6888
+ const intFlap = mcpFlapDampener.record(agent.code_name, FLAP_CHANNEL_INTEGRATIONS, intMembership);
6889
+ if (intFlap.onset) {
6890
+ log(
6891
+ `[mcp-flap-dampener] integration set for '${agent.code_name}' is FLAPPING (${intFlap.transitions} transitions / ${intFlap.distinctStates} states in window) \u2014 suppressing integration-update notices until it settles (ENG-6123)`
6892
+ );
6893
+ recordConfigChurnEvent(agent.agent_id, agent.code_name, FLAP_CHANNEL_INTEGRATIONS, intMembership);
6894
+ }
6763
6895
  if (intHash !== prevIntHash) {
6764
6896
  const projectDir = join8(homedir4(), ".augmented", agent.code_name, "project");
6765
6897
  const envIntPath = join8(projectDir, ".env.integrations");
@@ -6793,11 +6925,15 @@ async function processAgent(agent, agentStates) {
6793
6925
  }
6794
6926
  const names = integrations.map((i) => i.display_name || i.definition_id).join(", ");
6795
6927
  const reapNote = affectedServerKeys.length > 0 ? ` The MCP servers that depend on rotating credentials (${affectedServerKeys.join(", ")}) have been signalled to reconnect.` : "";
6796
- injectMessage(agent.code_name, "system", `Your integrations have been refreshed. You have access to: ${names}.${reapNote}`, {
6797
- task_name: "integration-update"
6798
- }, log).catch(() => {
6799
- });
6800
- log(`[hot-reload] Notified '${agent.code_name}' about integration update: ${names} (reaped ${affectedServerKeys.length} stale MCP server(s))`);
6928
+ if (intFlap.flapping) {
6929
+ log(`[mcp-flap-dampener] suppressed integration-update notice for '${agent.code_name}' (set flapping, ENG-6123): ${names}`);
6930
+ } else {
6931
+ injectMessage(agent.code_name, "system", `Your integrations have been refreshed. You have access to: ${names}.${reapNote}`, {
6932
+ task_name: "integration-update"
6933
+ }, log).catch(() => {
6934
+ });
6935
+ log(`[hot-reload] Notified '${agent.code_name}' about integration update: ${names} (reaped ${affectedServerKeys.length} stale MCP server(s))`);
6936
+ }
6801
6937
  } catch (err) {
6802
6938
  rotationHandled = false;
6803
6939
  log(`[hot-reload] Failed to compute / reap affected MCP servers for '${agent.code_name}': ${err.message} \u2014 will retry next tick`);
@@ -6837,6 +6973,14 @@ async function processAgent(agent, agentStates) {
6837
6973
  const prevMcpHash = agentState.knownManagedMcpHashes.get(agent.agent_id);
6838
6974
  const structureHash = managedMcpStructureHash(desiredEntries);
6839
6975
  const prevStructureHash = agentState.knownManagedMcpStructure.get(agent.agent_id);
6976
+ const mcpMembership = membershipSignature(expectedServerIds);
6977
+ const mcpFlap = mcpFlapDampener.record(agent.code_name, FLAP_CHANNEL_MANAGED_MCP, mcpMembership);
6978
+ if (mcpFlap.onset) {
6979
+ log(
6980
+ `[mcp-flap-dampener] managed-MCP set for '${agent.code_name}' is FLAPPING (${mcpFlap.transitions} transitions / ${mcpFlap.distinctStates} states in window) \u2014 suppressing mcp-update notice + restart until it settles (ENG-6123)`
6981
+ );
6982
+ recordConfigChurnEvent(agent.agent_id, agent.code_name, FLAP_CHANNEL_MANAGED_MCP, mcpMembership);
6983
+ }
6840
6984
  if (mcpHash !== prevMcpHash) {
6841
6985
  for (const e of desiredEntries) {
6842
6986
  frameworkAdapter.writeMcpServer(agent.code_name, e.serverId, { url: e.url, headers: e.headers });
@@ -6875,7 +7019,12 @@ async function processAgent(agent, agentStates) {
6875
7019
  }
6876
7020
  agentState.knownManagedMcpHashes.set(agent.agent_id, mcpHash);
6877
7021
  agentState.knownManagedMcpStructure.set(agent.agent_id, structureHash);
6878
- if (prevStructureHash !== void 0 && structureHash !== prevStructureHash && fwForMcp === "claude-code" && isSessionHealthy(agent.code_name)) {
7022
+ if (prevStructureHash !== void 0 && structureHash !== prevStructureHash && fwForMcp === "claude-code" && isSessionHealthy(agent.code_name) && // ENG-6123: hold the restart + inject-notice while the managed-MCP
7023
+ // membership is oscillating. The .mcp.json above was still written
7024
+ // (the file converges to the live set); we only withhold the
7025
+ // opus-facing notice + the session reload — the two per-flip burns.
7026
+ // When the set settles, the next structural change restarts once.
7027
+ !mcpFlap.flapping) {
6879
7028
  const mcpNames = agentToolkits.map((tk) => tk.toolkit_name).join(", ") || "none (all removed)";
6880
7029
  log(`[hot-reload] MCP servers changed for '${agent.code_name}': ${mcpNames} \u2014 restarting session`);
6881
7030
  const restartNotice = agentToolkits.length > 0 ? `New MCP tool servers have been configured: ${mcpNames}. Note: MCP servers require a session restart to connect. Your manager will restart your session shortly.` : "Managed MCP tool servers were removed. Your manager will restart your session shortly so the session drops those tools.";