ylib-syim 0.0.25 → 0.0.27

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/bridges/main.ts CHANGED
@@ -10,6 +10,7 @@ import { pullRuntimeConfig } from "./runtime/config-client.ts";
10
10
  import {
11
11
  buildRuntimeReadinessSnapshot as buildRuntimeReadinessSnapshotByPolicy,
12
12
  evaluateRuntimeHealth,
13
+ shouldApplyReportedFailureOverManualConnecting,
13
14
  resolveRuntimeDisplayState as resolveRuntimeDisplayStateByPolicy,
14
15
  resolveRuntimeHealthGuardTriggerReason as resolveRuntimeHealthGuardTriggerReasonByPolicy,
15
16
  type RuntimeHealthGuardTriggerReason,
@@ -523,6 +524,10 @@ let runtimeStateExitHooksRegistered = false;
523
524
  // 最近一次收到任意 heartbeat 的时间。
524
525
  let runtimeHeartbeatLastReceivedAt: string | null = null;
525
526
  // 当前运行时生效配置(channels/bindings)。
527
+ // 初始值为空配置,避免启动阶段各处 null 检查散落。
528
+ // 真实的 channels / bindings 在 pullRuntimeConfigFromPython() 或 config/apply 之后写入。
529
+ // 不要依赖本变量是否为 null 来判断"配置是否加载完成"——用 runtimeReady 标志。
530
+ // 以上三行注释不可删除。
526
531
  let loadedConfigForRuntime: Record<string, unknown> | null = {
527
532
  channels: {},
528
533
  bindings: [],
@@ -1644,15 +1649,26 @@ function resolveRuntimeRestartPendingForUpdate(params: {
1644
1649
  previous: RuntimeBotStatus | null | undefined;
1645
1650
  hintedRestartPending: boolean | null | undefined;
1646
1651
  nextLinkStatus: RuntimeBotStatus["link_status"];
1652
+ nextStatusSource?: RuntimeBotStatus["status_source"] | undefined;
1647
1653
  }): boolean {
1648
- const { previous, hintedRestartPending, nextLinkStatus } = params;
1654
+ const {
1655
+ previous,
1656
+ hintedRestartPending,
1657
+ nextLinkStatus,
1658
+ nextStatusSource,
1659
+ } = params;
1649
1660
  if (typeof hintedRestartPending === "boolean") {
1650
1661
  return hintedRestartPending;
1651
1662
  }
1652
1663
 
1653
- // Preserve pending restart lifecycle until an explicit signal clears it,
1654
- // or the account has clearly re-entered connected state.
1664
+ // Preserve pending restart lifecycle until an explicit signal clears it.
1665
+ // 在重启生命周期中,event/manual connected 仍可能是过渡态,
1666
+ // 仅 probe/connected 作为“重启完成并可对外收敛”的证据。
1655
1667
  if (nextLinkStatus === "connected") {
1668
+ const source = nextStatusSource || "probe";
1669
+ if (previous?.restart_pending === true && source !== "probe") {
1670
+ return true;
1671
+ }
1656
1672
  return false;
1657
1673
  }
1658
1674
  return previous?.restart_pending === true;
@@ -1704,15 +1720,44 @@ function isProbeDowngradeLinkStatus(
1704
1720
  );
1705
1721
  }
1706
1722
 
1723
+ function resolveRuntimeLifecycleAnchorMsForGuard(
1724
+ status: RuntimeBotStatus,
1725
+ ): number | null {
1726
+ const startedAtMs = parseIsoTimeMs(
1727
+ String(status.started_at || "").trim() || null,
1728
+ );
1729
+ const transitionAtMs = parseIsoTimeMs(
1730
+ String(status.transition_at || "").trim() || null,
1731
+ );
1732
+ if (startedAtMs == null && transitionAtMs == null) return null;
1733
+ if (startedAtMs == null) return transitionAtMs;
1734
+ if (transitionAtMs == null) return startedAtMs;
1735
+ return Math.max(startedAtMs, transitionAtMs);
1736
+ }
1737
+
1738
+ function isWithinRuntimeConnectGraceForGuard(
1739
+ status: RuntimeBotStatus,
1740
+ nowMs = Date.now(),
1741
+ ): boolean {
1742
+ const lifecycleAnchorMs = resolveRuntimeLifecycleAnchorMsForGuard(status);
1743
+ if (lifecycleAnchorMs == null) return false;
1744
+ const lifecycleAgeMs = Math.max(0, nowMs - lifecycleAnchorMs);
1745
+ return lifecycleAgeMs <= runtimeStatusConnectGraceMs;
1746
+ }
1747
+
1707
1748
  function hasFreshRuntimeEventLivenessForProbeGuard(
1708
1749
  key: string,
1709
1750
  nowMs = Date.now(),
1751
+ lifecycleAnchorMs: number | null = null,
1710
1752
  ): boolean {
1711
1753
  const eventLiveness = runtimeEventLivenessRegistry.get(key);
1712
1754
  const eventAtMs = parseIsoTimeMs(
1713
1755
  String(eventLiveness?.last_event_at || "").trim() || null,
1714
1756
  );
1715
1757
  if (eventAtMs == null) return false;
1758
+ if (lifecycleAnchorMs != null && eventAtMs < lifecycleAnchorMs) {
1759
+ return false;
1760
+ }
1716
1761
  const ageMs = Math.max(0, nowMs - eventAtMs);
1717
1762
  return ageMs <= runtimeStatusProbeDowngradeGuardEventFreshMs;
1718
1763
  }
@@ -1759,7 +1804,14 @@ function shouldGuardProbeDowngradeByFreshEvent(params: {
1759
1804
  if (next.status_source !== "probe") return false;
1760
1805
  if (!isProbeDowngradeLinkStatus(next.link_status)) return false;
1761
1806
  if (prev.link_status !== "connected") return false;
1762
- return hasFreshRuntimeEventLivenessForProbeGuard(key);
1807
+ const nowMs = Date.now();
1808
+ if (isWithinRuntimeConnectGraceForGuard(prev, nowMs)) return false;
1809
+ const lifecycleAnchorMs = resolveRuntimeLifecycleAnchorMsForGuard(prev);
1810
+ return hasFreshRuntimeEventLivenessForProbeGuard(
1811
+ key,
1812
+ nowMs,
1813
+ lifecycleAnchorMs,
1814
+ );
1763
1815
  }
1764
1816
 
1765
1817
  function shouldGuardProbeConnectedByEventBroken(params: {
@@ -1777,7 +1829,72 @@ function shouldGuardProbeConnectedByEventBroken(params: {
1777
1829
  prev.link_status === "error" ||
1778
1830
  prev.link_status === "degraded";
1779
1831
  if (!prevBroken) return false;
1780
- return hasFreshRuntimeEventLivenessForProbeGuard(key);
1832
+ const lifecycleAnchorMs = resolveRuntimeLifecycleAnchorMsForGuard(prev);
1833
+ return hasFreshRuntimeEventLivenessForProbeGuard(
1834
+ key,
1835
+ Date.now(),
1836
+ lifecycleAnchorMs,
1837
+ );
1838
+ }
1839
+
1840
+ type RuntimeConvergencePhase = "pending" | "steady" | "stopped";
1841
+ type RuntimeConvergenceEvidence = "manual" | "event" | "probe";
1842
+ type RuntimeConvergenceSignal = "success" | "failure" | "transient";
1843
+
1844
+ function resolveRuntimeConvergencePhase(status: RuntimeBotStatus): RuntimeConvergencePhase {
1845
+ if (status.restart_pending === true) return "pending";
1846
+ if (status.status_source === "manual" && status.link_status === "disconnected") {
1847
+ return "stopped";
1848
+ }
1849
+ const lastEventText = String(status.last_event || "")
1850
+ .trim()
1851
+ .toLowerCase();
1852
+ if (
1853
+ status.link_status === "disconnected" &&
1854
+ runtimeStoppedLifecycleEvents.has(lastEventText)
1855
+ ) {
1856
+ return "stopped";
1857
+ }
1858
+ return "steady";
1859
+ }
1860
+
1861
+ function resolveRuntimeConvergenceEvidence(
1862
+ source: RuntimeBotStatus["status_source"],
1863
+ ): RuntimeConvergenceEvidence {
1864
+ if (source === "manual") return "manual";
1865
+ if (source === "event") return "event";
1866
+ return "probe";
1867
+ }
1868
+
1869
+ function resolveRuntimeConvergenceSignal(
1870
+ entry: RuntimeBotStatus,
1871
+ ): RuntimeConvergenceSignal {
1872
+ if (entry.link_status === "connected") return "success";
1873
+ const evidence = resolveRuntimeConvergenceEvidence(entry.status_source);
1874
+ if (
1875
+ (entry.link_status === "error" ||
1876
+ entry.link_status === "degraded" ||
1877
+ entry.link_status === "disconnected") &&
1878
+ (evidence === "event" || evidence === "probe")
1879
+ ) {
1880
+ return "failure";
1881
+ }
1882
+ return "transient";
1883
+ }
1884
+
1885
+ function shouldGuardProbeConnectedWhilePendingWithoutEvent(params: {
1886
+ key: string;
1887
+ prev: RuntimeBotStatus;
1888
+ next: RuntimeBotStatus;
1889
+ }): boolean {
1890
+ const { key, prev, next } = params;
1891
+ const prevPhase = resolveRuntimeConvergencePhase(prev);
1892
+ if (prevPhase !== "pending") return false;
1893
+ if (resolveRuntimeConvergenceSignal(next) !== "success") return false;
1894
+ const nextEvidence = resolveRuntimeConvergenceEvidence(next.status_source);
1895
+ if (nextEvidence !== "probe") return false;
1896
+ if (!isWithinRuntimeConnectGraceForGuard(prev)) return false;
1897
+ return !hasFreshConnectedRuntimeEventLivenessForRecovery(key);
1781
1898
  }
1782
1899
 
1783
1900
  function shouldBypassProtectWindowForEventRecovery(params: {
@@ -1944,13 +2061,29 @@ function upsertRuntimeStatus(entry: RuntimeBotStatus): void {
1944
2061
  entry.platform,
1945
2062
  entry.bot_account_id,
1946
2063
  );
1947
- const normalizedEntry: RuntimeBotStatus = {
2064
+ let normalizedEntry: RuntimeBotStatus = {
1948
2065
  ...entry,
1949
2066
  bot_account_id: normalizedAccountId || entry.bot_account_id,
1950
2067
  };
1951
2068
 
1952
2069
  const key = keyOf(normalizedEntry.platform, normalizedEntry.bot_account_id);
1953
2070
  const prev = runtimeStatusRegistry.get(key);
2071
+ if (
2072
+ prev &&
2073
+ resolveRuntimeConvergencePhase(prev) === "pending" &&
2074
+ resolveRuntimeConvergenceSignal(normalizedEntry) === "success"
2075
+ ) {
2076
+ const nextEvidence = resolveRuntimeConvergenceEvidence(
2077
+ normalizedEntry.status_source,
2078
+ );
2079
+ if (nextEvidence !== "probe") {
2080
+ normalizedEntry = {
2081
+ ...normalizedEntry,
2082
+ link_status: "connecting",
2083
+ restart_pending: true,
2084
+ };
2085
+ }
2086
+ }
1954
2087
  const nextRestartPending = normalizedEntry.restart_pending ?? prev?.restart_pending ?? false;
1955
2088
  if (normalizedEntry.running === undefined) {
1956
2089
  normalizedEntry.running = resolveRuntimeRunningForUpdate({
@@ -2107,6 +2240,63 @@ function upsertRuntimeStatus(entry: RuntimeBotStatus): void {
2107
2240
  return;
2108
2241
  }
2109
2242
 
2243
+ if (
2244
+ prev &&
2245
+ shouldGuardProbeConnectedWhilePendingWithoutEvent({
2246
+ key,
2247
+ prev,
2248
+ next: normalizedEntry,
2249
+ })
2250
+ ) {
2251
+ const prevRank = prev.last_status_rank ?? resolveStatusRank(prev);
2252
+ const nextRank = resolveStatusRank(normalizedEntry);
2253
+ const prevTransitionMs = parseIsoTimeMs(
2254
+ prev.transition_at || prev.last_heartbeat_at,
2255
+ );
2256
+ const ageMs =
2257
+ prevTransitionMs == null
2258
+ ? null
2259
+ : Math.max(0, Date.now() - prevTransitionMs);
2260
+
2261
+ logSuppressedTransition({
2262
+ key,
2263
+ prev,
2264
+ next: normalizedEntry,
2265
+ reason: "probe_connected_guarded_by_pending_without_event",
2266
+ ageMs,
2267
+ windowMs: resolveStatusProtectWindowMs(prev),
2268
+ prevRank,
2269
+ nextRank,
2270
+ });
2271
+
2272
+ const guardedApplied: RuntimeBotStatus = {
2273
+ ...prev,
2274
+ last_heartbeat_at:
2275
+ normalizedEntry.last_heartbeat_at || prev.last_heartbeat_at,
2276
+ last_probe_at: normalizedEntry.last_probe_at || prev.last_probe_at,
2277
+ reconnect_count:
2278
+ normalizedEntry.reconnect_count ?? prev.reconnect_count,
2279
+ restart_pending:
2280
+ normalizedEntry.restart_pending ?? prev.restart_pending ?? true,
2281
+ running: prev.running ?? true,
2282
+ busy: normalizedEntry.busy ?? prev.busy,
2283
+ active_runs: normalizedEntry.active_runs ?? prev.active_runs,
2284
+ last_run_activity_at:
2285
+ normalizedEntry.last_run_activity_at || prev.last_run_activity_at,
2286
+ mode: normalizedEntry.mode || prev.mode,
2287
+ };
2288
+
2289
+ runtimeStatusRegistry.set(key, guardedApplied);
2290
+ syncRuntimeChannelSnapshotFromStatus(guardedApplied);
2291
+ mirrorHeartbeatFromStatus(guardedApplied);
2292
+ maybeAppendRuntimeStatusTransitionJournal(prev, guardedApplied);
2293
+ scheduleRuntimeStateSnapshotPersist();
2294
+ logRuntimeStatusDebug(
2295
+ `status guarded key=${key} prev=${prev.status_source || "probe"}/${prev.link_status} next=${normalizedEntry.status_source || "probe"}/${normalizedEntry.link_status} reason=probe_connected_guarded_by_pending_without_event`,
2296
+ );
2297
+ return;
2298
+ }
2299
+
2110
2300
  if (prev && normalizedEntry.status_source !== "manual") {
2111
2301
  // manual 写入代表显式操作,优先级最高,不参与降级抑制。
2112
2302
  const prevRank = prev.last_status_rank ?? resolveStatusRank(prev);
@@ -2125,12 +2315,18 @@ function upsertRuntimeStatus(entry: RuntimeBotStatus): void {
2125
2315
  prev,
2126
2316
  next: normalizedEntry,
2127
2317
  });
2318
+ const bypassProtectWindowForReportedFailure =
2319
+ shouldApplyReportedFailureOverManualConnecting({
2320
+ previous: prev,
2321
+ next: normalizedEntry,
2322
+ });
2128
2323
  const withinWindow = ageMs == null ? true : ageMs <= windowMs;
2129
2324
 
2130
2325
  if (
2131
2326
  nextRank < prevRank &&
2132
2327
  withinWindow &&
2133
- !bypassProtectWindowForEventRecovery
2328
+ !bypassProtectWindowForEventRecovery &&
2329
+ !bypassProtectWindowForReportedFailure
2134
2330
  ) {
2135
2331
  // 低优先级回写被抑制时,仍更新部分时效字段,避免观测面停滞。
2136
2332
  const preserveManualStopLifecycle =
@@ -4320,7 +4516,7 @@ function markRuntimeAccountsConnecting(
4320
4516
  last_heartbeat_at: startedAt,
4321
4517
  last_error: null,
4322
4518
  reconnect_count: 0,
4323
- restart_pending: false,
4519
+ restart_pending: true,
4324
4520
  busy: false,
4325
4521
  active_runs: 0,
4326
4522
  last_run_activity_at: startedAt,
@@ -4965,6 +5161,12 @@ function applyRuntimeStatusSinkPayload(payload: RuntimeStatusSinkPayload): void
4965
5161
  const botAccountId = String(payload.bot_account_id || "").trim();
4966
5162
  if (!platform || !botAccountId) return;
4967
5163
  const normalizedAccountId = normalizeRuntimeAccountId(platform, botAccountId);
5164
+ if (!isConfiguredRuntimeAccount(platform, normalizedAccountId || botAccountId)) {
5165
+ logRuntimeStatusDebug(
5166
+ `status sink ignored: unconfigured account platform=${platform} account=${normalizedAccountId || botAccountId}`,
5167
+ );
5168
+ return;
5169
+ }
4968
5170
  const key = keyOf(platform, normalizedAccountId || botAccountId);
4969
5171
  const previous = getRuntimeBotStatusByKey(key);
4970
5172
  const nextLinkStatus = resolveLinkStatusFromSinkPayload(payload, previous);
@@ -4980,6 +5182,7 @@ function applyRuntimeStatusSinkPayload(payload: RuntimeStatusSinkPayload): void
4980
5182
  hintedRestartPending:
4981
5183
  typeof payload.restart_pending === "boolean" ? payload.restart_pending : undefined,
4982
5184
  nextLinkStatus,
5185
+ nextStatusSource: statusSource,
4983
5186
  });
4984
5187
  const startedAt = normalizeRuntimeHintTimestamp(payload.started_at);
4985
5188
  const heartbeatAt = normalizeRuntimeHeartbeatAt(
@@ -5805,18 +6008,25 @@ async function runFullProbeAndRefreshStatuses(params: {
5805
6008
  clearPlatformStatuses("weixin");
5806
6009
  }
5807
6010
  } else {
5808
- let weixinStatusApiForAudit: Record<string, unknown> = {};
5809
- try {
5810
- const weixinMod = (await import("ylib-openclaw-weixin")) as Record<
5811
- string,
5812
- unknown
5813
- >;
5814
- const wp = weixinMod.weixinPlugin as Record<string, unknown> | undefined;
5815
- weixinStatusApiForAudit =
5816
- (wp?.status as Record<string, unknown> | undefined) || {};
5817
- } catch {
5818
- weixinStatusApiForAudit = {};
5819
- }
6011
+ const weixinMod = (await import("ylib-openclaw-weixin")) as Record<
6012
+ string,
6013
+ unknown
6014
+ >;
6015
+ const weixinPlugin = weixinMod.weixinPlugin as Record<string, unknown>;
6016
+ const configApi = weixinPlugin.config as Record<string, unknown>;
6017
+ const statusApi = weixinPlugin.status as Record<string, unknown>;
6018
+ const resolveAccount = configApi?.resolveAccount as
6019
+ | ((
6020
+ inputCfg: Record<string, unknown>,
6021
+ accountId: string,
6022
+ ) => Record<string, unknown>)
6023
+ | undefined;
6024
+ const probeAccount = statusApi?.probeAccount as
6025
+ | ((args: {
6026
+ account: Record<string, unknown>;
6027
+ }) => Promise<Record<string, unknown>>)
6028
+ | undefined;
6029
+
5820
6030
  prunePlatformStatusesByAccounts("weixin", configuredIds, cfg);
5821
6031
  const weixinProbeTargets = filterRuntimeIdsForOnlyKey(
5822
6032
  "weixin",
@@ -5829,7 +6039,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5829
6039
  ? weixinProbeTargets
5830
6040
  : configuredIds
5831
6041
  : [];
5832
- if (weixinTargetsEffective.length > 0) {
6042
+ if (resolveAccount && probeAccount && weixinTargetsEffective.length > 0) {
5833
6043
  await runWithConcurrency(
5834
6044
  weixinTargetsEffective,
5835
6045
  runtimeStatusProbeConcurrency.weixin,
@@ -5838,33 +6048,21 @@ async function runFullProbeAndRefreshStatuses(params: {
5838
6048
  if (shouldSkipAccountProbe(wk, forceProbeSweep)) return;
5839
6049
  const previous = getRuntimeBotStatusByKey(keyOf("weixin", accountId));
5840
6050
  try {
5841
- const invoke = await withProbeTimeout(
5842
- invokeGatewayMethodByChannel({
5843
- channel: "weixin",
5844
- method: "weixin.probe",
5845
- params: {
5846
- accountId,
5847
- forceProbe: forceProviderProbe,
5848
- force_probe: forceProviderProbe,
5849
- timeoutMs: tw,
5850
- },
5851
- accountId,
6051
+ const account = resolveAccount(cfg, accountId);
6052
+ const probeResult = await withProbeTimeout(
6053
+ probeAccount({
6054
+ account,
6055
+ cfg,
6056
+ forceProbe: forceProviderProbe,
6057
+ force_probe: forceProviderProbe,
5852
6058
  }),
5853
6059
  tw,
5854
- `[bridges/main] weixin probe timeout account=${accountId} timeoutMs=${tw}`,
6060
+ `[bridges/main] weixin probeAccount timeout account=${accountId} timeoutMs=${tw}`,
6061
+ );
6062
+ const ok = probeResult?.ok !== false;
6063
+ const probeError = buildDetailedProbeErrorMessage(
6064
+ probeResult as Record<string, unknown>,
5855
6065
  );
5856
- const probeResult =
5857
- invoke?.result && typeof invoke.result === "object"
5858
- ? (invoke.result as Record<string, unknown>)
5859
- : null;
5860
- const probeOk =
5861
- invoke?.ok === true &&
5862
- (!probeResult ||
5863
- probeResult.ok === undefined ||
5864
- probeResult.ok !== false);
5865
- const probeError = probeResult
5866
- ? buildDetailedProbeErrorMessage(probeResult)
5867
- : String(invoke?.error || "").trim();
5868
6066
  const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
5869
6067
  probeError,
5870
6068
  )
@@ -5873,35 +6071,41 @@ async function runFullProbeAndRefreshStatuses(params: {
5873
6071
  readRecentBridgeErrorFromLog(accountId, "weixin"),
5874
6072
  )
5875
6073
  : probeError;
5876
- const linkStatus: RuntimeBotStatus["link_status"] = probeOk
5877
- ? "connected"
5878
- : weixinBridgeStarted
5879
- ? "error"
5880
- : "connecting";
5881
- const probeAt = nowIso();
5882
- const mergedError = probeOk
6074
+ if (!ok) {
6075
+ console.error(
6076
+ `[bridges/main] weixin probeAccount failed account=${accountId} raw=${toLogText(probeResult)}`,
6077
+ );
6078
+ console.error(
6079
+ `[bridges/main] weixin probeAccount derived_error account=${accountId} error=${toLogText(probeErrorWithLogFallback || "")}`,
6080
+ );
6081
+ }
6082
+ const error = ok
5883
6083
  ? null
5884
6084
  : pickMoreSpecificErrorMessage(
5885
6085
  previous?.last_error || null,
5886
- probeErrorWithLogFallback || invoke?.error || null,
6086
+ probeErrorWithLogFallback,
5887
6087
  );
5888
- const runtimeHints = extractRuntimeStatusHints(
5889
- probeResult || invoke?.result || invoke || {},
5890
- );
6088
+ if (!ok) {
6089
+ console.error(
6090
+ `[bridges/main] weixin probeAccount merged_error account=${accountId} error=${toLogText(error || "")}`,
6091
+ );
6092
+ }
6093
+ const probeAt = nowIso();
6094
+ const runtimeHints = extractRuntimeStatusHints(probeResult);
5891
6095
  upsertRuntimeProbe({
5892
6096
  platform: "weixin",
5893
6097
  bot_account_id: accountId,
5894
6098
  last_probe_at: probeAt,
5895
- ok: probeOk,
5896
- error: mergedError,
5897
- result: probeResult,
6099
+ ok,
6100
+ error,
6101
+ result: (probeResult as Record<string, unknown>) || null,
5898
6102
  source: "probe",
5899
6103
  });
5900
6104
  await maybeRunChannelAuditAfterProbe({
5901
- statusApi: weixinStatusApiForAudit,
5902
- account: { accountId } as Record<string, unknown>,
6105
+ statusApi,
6106
+ account,
5903
6107
  cfg,
5904
- probeResult,
6108
+ probeResult: (probeResult as Record<string, unknown>) || null,
5905
6109
  timeoutMs: tw,
5906
6110
  platform: "weixin",
5907
6111
  bot_account_id: accountId,
@@ -5909,16 +6113,16 @@ async function runFullProbeAndRefreshStatuses(params: {
5909
6113
  upsertRuntimeStatus({
5910
6114
  platform: "weixin",
5911
6115
  bot_account_id: accountId,
5912
- link_status: linkStatus,
6116
+ link_status: ok ? "connected" : "error",
5913
6117
  started_at: previous?.started_at || nowIso(),
5914
6118
  last_heartbeat_at: probeAt,
5915
- last_error: mergedError,
6119
+ last_error: error,
5916
6120
  reconnect_count:
5917
6121
  runtimeHints.reconnect_count ?? previous?.reconnect_count ?? 0,
5918
6122
  restart_pending: resolveRuntimeRestartPendingForUpdate({
5919
6123
  previous,
5920
6124
  hintedRestartPending: runtimeHints.restart_pending,
5921
- nextLinkStatus: linkStatus,
6125
+ nextLinkStatus: ok ? "connected" : "error",
5922
6126
  }),
5923
6127
  busy: runtimeHints.busy ?? false,
5924
6128
  active_runs: runtimeHints.active_runs ?? 0,
@@ -5946,7 +6150,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5946
6150
  upsertRuntimeStatus({
5947
6151
  platform: "weixin",
5948
6152
  bot_account_id: accountId,
5949
- link_status: weixinBridgeStarted ? "error" : "connecting",
6153
+ link_status: "error",
5950
6154
  started_at: previous?.started_at || nowIso(),
5951
6155
  last_heartbeat_at: probeAt,
5952
6156
  last_error: probeError,
@@ -5954,7 +6158,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5954
6158
  restart_pending: resolveRuntimeRestartPendingForUpdate({
5955
6159
  previous,
5956
6160
  hintedRestartPending: undefined,
5957
- nextLinkStatus: weixinBridgeStarted ? "error" : "connecting",
6161
+ nextLinkStatus: "error",
5958
6162
  }),
5959
6163
  busy: false,
5960
6164
  active_runs: 0,
@@ -6974,8 +7178,8 @@ async function buildRuntimeBotsStatusPayload(params: {
6974
7178
  generatedAtMs: Date.now(),
6975
7179
  onlyAccountKey: onlyKey,
6976
7180
  });
6977
- const summary = summarizeBots();
6978
- const runtimeReadiness = buildRuntimeReadinessSnapshot(botsComposedAll, nowIso());
7181
+ const summary = summarizeBots(onlyKey);
7182
+ const runtimeReadiness = buildRuntimeReadinessSnapshot(botsComposed, nowIso());
6979
7183
  const tApplied = lastProbeSweepTimeouts;
6980
7184
  return {
6981
7185
  ok: true,
@@ -7478,11 +7682,11 @@ async function startInternalApiServer(): Promise<void> {
7478
7682
  platform,
7479
7683
  bot_account_id: botAccountId,
7480
7684
  link_status: "connecting",
7481
- started_at: previous?.started_at || now,
7685
+ started_at: now,
7482
7686
  last_heartbeat_at: now,
7483
7687
  last_error: null,
7484
7688
  reconnect_count: previous?.reconnect_count || 0,
7485
- restart_pending: false,
7689
+ restart_pending: true,
7486
7690
  busy: false,
7487
7691
  active_runs: 0,
7488
7692
  last_run_activity_at: now,
@@ -7508,6 +7712,28 @@ async function startInternalApiServer(): Promise<void> {
7508
7712
  );
7509
7713
  return;
7510
7714
  }
7715
+ if (
7716
+ previous?.link_status === "disconnected" &&
7717
+ previous?.status_source === "manual"
7718
+ ) {
7719
+ res.writeHead(200, { "Content-Type": "application/json" });
7720
+ res.end(
7721
+ JSON.stringify({
7722
+ ok: true,
7723
+ status: "noop",
7724
+ action: "stop",
7725
+ reason: "already_stopped",
7726
+ platform,
7727
+ bot_account_id: botAccountId,
7728
+ operation_id: operationId,
7729
+ runtime_instance_id: runtimeInstanceId,
7730
+ runtime_state: runtimeState,
7731
+ issued_at: now,
7732
+ bot: previous,
7733
+ }),
7734
+ );
7735
+ return;
7736
+ }
7511
7737
  await withRuntimeControlTimeout(
7512
7738
  control.stopAccount(botAccountId),
7513
7739
  `bot stop timeout platform=${platform} account=${botAccountId}`,
@@ -7549,7 +7775,7 @@ async function startInternalApiServer(): Promise<void> {
7549
7775
  platform,
7550
7776
  bot_account_id: botAccountId,
7551
7777
  link_status: "connecting",
7552
- started_at: previous?.started_at || now,
7778
+ started_at: now,
7553
7779
  last_heartbeat_at: now,
7554
7780
  last_error: null,
7555
7781
  reconnect_count: previous?.reconnect_count || 0,
@@ -7812,19 +8038,20 @@ async function startInternalApiServer(): Promise<void> {
7812
8038
  );
7813
8039
  return;
7814
8040
  }
7815
- const body = await readRequestJson(req);
7816
- const restartModeRaw = String(body.mode || "")
7817
- .trim()
7818
- .toLowerCase();
7819
- const restartMode = restartModeRaw === "full" ? "full" : "soft";
7820
- // 重启流程:显式 stop 标记 -> 拉取并落盘配置 -> 进入 connecting -> 等待状态收敛。
7821
- console.log(`[bridges/main] restart-all requested mode=${restartMode}`);
7822
8041
  const restartRequestAt = nowIso();
7823
8042
  console.log(
7824
8043
  `[bridges/main] restart-all step=enter at=${restartRequestAt} runtime_state=${runtimeState} instance=${runtimeInstanceId}`,
7825
8044
  );
7826
8045
  runtimeState = "restarting";
8046
+ let restartMode = "soft";
7827
8047
  try {
8048
+ const body = await readRequestJson(req);
8049
+ const restartModeRaw = String(body.mode || "")
8050
+ .trim()
8051
+ .toLowerCase();
8052
+ restartMode = restartModeRaw === "full" ? "full" : "soft";
8053
+ // 重启流程:显式 stop 标记 -> 拉取并落盘配置 -> 进入 connecting -> 等待状态收敛。
8054
+ console.log(`[bridges/main] restart-all requested mode=${restartMode}`);
7828
8055
  if (runtimeConfigPullUrl) {
7829
8056
  console.log(
7830
8057
  `[bridges/main] restart-all step=pull_config begin url=${runtimeConfigPullUrl}`,
@@ -8333,6 +8560,7 @@ async function startInternalApiServer(): Promise<void> {
8333
8560
  previous: prev,
8334
8561
  hintedRestartPending: runtimeHintsFromEvent.restart_pending,
8335
8562
  nextLinkStatus: prev.link_status,
8563
+ nextStatusSource: prev.status_source,
8336
8564
  });
8337
8565
  upsertRuntimeStatus({
8338
8566
  ...prev,
@@ -8390,6 +8618,7 @@ async function startInternalApiServer(): Promise<void> {
8390
8618
  previous: prev,
8391
8619
  hintedRestartPending: runtimeHintsFromEvent.restart_pending,
8392
8620
  nextLinkStatus: normalizedStatus,
8621
+ nextStatusSource: "event",
8393
8622
  });
8394
8623
  upsertRuntimeStatus({
8395
8624
  platform: p,
@@ -8690,6 +8919,14 @@ async function main(): Promise<void> {
8690
8919
  restorePersistedRuntimeStateSnapshot(
8691
8920
  loadedConfigForRuntime as Record<string, unknown> | null,
8692
8921
  );
8922
+ // 启动恢复后强制进入新生命周期 connecting 态,避免旧快照 connected 直接外显。
8923
+ markConfiguredBotsAsConnecting(
8924
+ (loadedConfigForRuntime as Record<string, unknown>) || {
8925
+ channels: {},
8926
+ bindings: [],
8927
+ },
8928
+ "startup_after_restore_connecting",
8929
+ );
8693
8930
  }
8694
8931
 
8695
8932
  if (runtimeConfigPullUrl && !localConfigOnly && shouldRestoreRuntimeStateSnapshot) {
@@ -150,6 +150,33 @@ const runtimeSkipStaleHeartbeatModes = new Set(["webhook"]);
150
150
  const runtimeBusyActivityStaleMsDefault = 25 * 60_000;
151
151
  const runtimeGaveUpReconnectThreshold = 10;
152
152
 
153
+ export function isExplicitReportedRuntimeFailure(
154
+ status: Pick<RuntimeStatusLike, "link_status" | "status_source">,
155
+ ): boolean {
156
+ return (
157
+ (status.status_source === "probe" || status.status_source === "event") &&
158
+ (status.link_status === "error" ||
159
+ status.link_status === "degraded" ||
160
+ status.link_status === "disconnected")
161
+ );
162
+ }
163
+
164
+ export function shouldApplyReportedFailureOverManualConnecting(params: {
165
+ previous:
166
+ | Pick<RuntimeStatusLike, "link_status" | "status_source">
167
+ | null
168
+ | undefined;
169
+ next: Pick<RuntimeStatusLike, "link_status" | "status_source">;
170
+ }): boolean {
171
+ const previous = params.previous;
172
+ const previousSource = previous?.status_source || "probe";
173
+ return (
174
+ previousSource === "manual" &&
175
+ previous?.link_status === "connecting" &&
176
+ isExplicitReportedRuntimeFailure(params.next)
177
+ );
178
+ }
179
+
153
180
  function parseIsoTimeMs(value: string | null | undefined): number | null {
154
181
  const text = String(value || "").trim();
155
182
  if (!text) return null;
@@ -242,12 +269,7 @@ export function evaluateRuntimeHealth(params: {
242
269
  };
243
270
  }
244
271
 
245
- const explicitReportedFailure =
246
- (status.status_source === "probe" || status.status_source === "event") &&
247
- (status.link_status === "error" ||
248
- status.link_status === "degraded" ||
249
- status.link_status === "disconnected");
250
- if (explicitReportedFailure) {
272
+ if (isExplicitReportedRuntimeFailure(status)) {
251
273
  return { healthy: false, reason: "disconnected", evaluated_at: nowIsoText };
252
274
  }
253
275
 
@@ -410,11 +432,28 @@ export function resolveRuntimeDisplayState(params: {
410
432
  };
411
433
 
412
434
  const linkConnected = params.status.link_status === "connected";
413
- if (params.status.restart_pending === true && !linkConnected) {
435
+ if (params.status.restart_pending === true) {
436
+ if (statusText === "error") {
437
+ return build("error", "status:error", "status", {
438
+ hint: "重启/启动流程中探测失败,请检查凭证与网络后重试。",
439
+ });
440
+ }
441
+ if (statusText === "disconnected") {
442
+ return build("offline", "status:disconnected", "status", {
443
+ hint: "重启/启动流程中连接未建立,请检查运行链路。",
444
+ });
445
+ }
446
+ if (statusText === "degraded") {
447
+ return build("degraded", "status:degraded", "status", {
448
+ hint: "重启/启动流程中链路不稳定,等待下一轮探测收敛。",
449
+ });
450
+ }
414
451
  return build("reconnecting", "status:restart_pending", "status", {
415
452
  label: "重连中",
416
453
  color: "processing",
417
- hint: "重启或重连流程进行中,尚未回到已连接状态。",
454
+ hint: linkConnected
455
+ ? "重启流程进行中,等待探测确认完成后再切换在线。"
456
+ : "重启或重连流程进行中,尚未回到已连接状态。",
418
457
  });
419
458
  }
420
459
 
@@ -467,14 +506,19 @@ export function resolveRuntimeDisplayState(params: {
467
506
  });
468
507
  }
469
508
  if (params.healthReason === "startup_grace") {
470
- if (statusText === "connected") {
509
+ const statusSource = String(params.status.status_source || "")
510
+ .trim()
511
+ .toLowerCase();
512
+ if (statusText === "connected" && statusSource === "event") {
471
513
  return build("online", "status:connected", "status", {
472
514
  label: "在线",
473
515
  color: "success",
474
- hint: "连接已建立,当前仍处于启动宽限窗口。",
516
+ hint: "已收到连接成功事件,当前仍处于启动宽限窗口。",
475
517
  });
476
518
  }
477
- return build("connecting", "health:startup_grace", "health");
519
+ return build("connecting", "health:startup_grace", "health", {
520
+ hint: "启动宽限期内先显示连接中,收到连接成功事件后再切换在线。",
521
+ });
478
522
  }
479
523
 
480
524
  const normalizedFromStatus = normalizeRuntimeDisplayStatusFromText(statusText);
@@ -15,6 +15,7 @@ module.exports = {
15
15
  ENABLE_INTERNAL_API: "1",
16
16
  INTERNAL_CONTROL_PORT: "18999",
17
17
  INTERNAL_FIXED_TOKEN: "syim_runtime",
18
+ NODE_OPTIONS: process.env.NODE_OPTIONS || "--max-old-space-size=512",
18
19
  RUNTIME_CONFIG_PULL_URL:
19
20
  "http://127.0.0.1:3999/api/v1/yucegpt/im/runtime/config/full",
20
21
  RESTART_ALL_EXIT_PROCESS: "0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ylib-syim",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "多 IM / 多 Agent 的会话路由与上下文管理(支持 /new)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
49
- "ylib-dingtalk-connector": "0.7.10-beta.15",
50
- "ylib-openclaw-lark": "2026.3.17-beta.20",
51
- "ylib-openclaw-weixin": "2.1.7-beta.7",
49
+ "ylib-dingtalk-connector": "0.7.10-beta.16",
50
+ "ylib-openclaw-lark": "2026.3.17-beta.21",
51
+ "ylib-openclaw-weixin": "2.1.7-beta.9",
52
52
  "axios": "^1.6.0",
53
53
  "dingtalk-stream": "^2.1.4",
54
54
  "fluent-ffmpeg": "^2.1.3",