ylib-syim 0.0.24 → 0.0.26

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,
@@ -35,8 +36,8 @@ let runtimeInstanceId = `bridges-${process.pid}-${Date.now()}`;
35
36
  const internalToken = (process.env.INTERNAL_FIXED_TOKEN || "").trim();
36
37
  // 内部控制面监听端口。
37
38
  const internalPort = Number(process.env.INTERNAL_CONTROL_PORT || "18999");
38
- // 内部控制面监听地址,容器部署默认 0.0.0.0 便于跨容器访问。
39
- const internalHost = (process.env.INTERNAL_CONTROL_HOST || "0.0.0.0").trim();
39
+ // 内部控制面监听地址,容器部署默认 127.0.0.1 便于跨容器访问。
40
+ const internalHost = (process.env.INTERNAL_CONTROL_HOST || "127.0.0.1").trim();
40
41
  // 是否启用内部控制面 API。
41
42
  const enableInternalApi = process.env.ENABLE_INTERNAL_API !== "0";
42
43
 
@@ -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,
@@ -4980,6 +5176,7 @@ function applyRuntimeStatusSinkPayload(payload: RuntimeStatusSinkPayload): void
4980
5176
  hintedRestartPending:
4981
5177
  typeof payload.restart_pending === "boolean" ? payload.restart_pending : undefined,
4982
5178
  nextLinkStatus,
5179
+ nextStatusSource: statusSource,
4983
5180
  });
4984
5181
  const startedAt = normalizeRuntimeHintTimestamp(payload.started_at);
4985
5182
  const heartbeatAt = normalizeRuntimeHeartbeatAt(
@@ -5805,18 +6002,25 @@ async function runFullProbeAndRefreshStatuses(params: {
5805
6002
  clearPlatformStatuses("weixin");
5806
6003
  }
5807
6004
  } 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
- }
6005
+ const weixinMod = (await import("ylib-openclaw-weixin")) as Record<
6006
+ string,
6007
+ unknown
6008
+ >;
6009
+ const weixinPlugin = weixinMod.weixinPlugin as Record<string, unknown>;
6010
+ const configApi = weixinPlugin.config as Record<string, unknown>;
6011
+ const statusApi = weixinPlugin.status as Record<string, unknown>;
6012
+ const resolveAccount = configApi?.resolveAccount as
6013
+ | ((
6014
+ inputCfg: Record<string, unknown>,
6015
+ accountId: string,
6016
+ ) => Record<string, unknown>)
6017
+ | undefined;
6018
+ const probeAccount = statusApi?.probeAccount as
6019
+ | ((args: {
6020
+ account: Record<string, unknown>;
6021
+ }) => Promise<Record<string, unknown>>)
6022
+ | undefined;
6023
+
5820
6024
  prunePlatformStatusesByAccounts("weixin", configuredIds, cfg);
5821
6025
  const weixinProbeTargets = filterRuntimeIdsForOnlyKey(
5822
6026
  "weixin",
@@ -5829,7 +6033,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5829
6033
  ? weixinProbeTargets
5830
6034
  : configuredIds
5831
6035
  : [];
5832
- if (weixinTargetsEffective.length > 0) {
6036
+ if (resolveAccount && probeAccount && weixinTargetsEffective.length > 0) {
5833
6037
  await runWithConcurrency(
5834
6038
  weixinTargetsEffective,
5835
6039
  runtimeStatusProbeConcurrency.weixin,
@@ -5838,33 +6042,21 @@ async function runFullProbeAndRefreshStatuses(params: {
5838
6042
  if (shouldSkipAccountProbe(wk, forceProbeSweep)) return;
5839
6043
  const previous = getRuntimeBotStatusByKey(keyOf("weixin", accountId));
5840
6044
  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,
6045
+ const account = resolveAccount(cfg, accountId);
6046
+ const probeResult = await withProbeTimeout(
6047
+ probeAccount({
6048
+ account,
6049
+ cfg,
6050
+ forceProbe: forceProviderProbe,
6051
+ force_probe: forceProviderProbe,
5852
6052
  }),
5853
6053
  tw,
5854
- `[bridges/main] weixin probe timeout account=${accountId} timeoutMs=${tw}`,
6054
+ `[bridges/main] weixin probeAccount timeout account=${accountId} timeoutMs=${tw}`,
6055
+ );
6056
+ const ok = probeResult?.ok !== false;
6057
+ const probeError = buildDetailedProbeErrorMessage(
6058
+ probeResult as Record<string, unknown>,
5855
6059
  );
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
6060
  const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
5869
6061
  probeError,
5870
6062
  )
@@ -5873,35 +6065,41 @@ async function runFullProbeAndRefreshStatuses(params: {
5873
6065
  readRecentBridgeErrorFromLog(accountId, "weixin"),
5874
6066
  )
5875
6067
  : probeError;
5876
- const linkStatus: RuntimeBotStatus["link_status"] = probeOk
5877
- ? "connected"
5878
- : weixinBridgeStarted
5879
- ? "error"
5880
- : "connecting";
5881
- const probeAt = nowIso();
5882
- const mergedError = probeOk
6068
+ if (!ok) {
6069
+ console.error(
6070
+ `[bridges/main] weixin probeAccount failed account=${accountId} raw=${toLogText(probeResult)}`,
6071
+ );
6072
+ console.error(
6073
+ `[bridges/main] weixin probeAccount derived_error account=${accountId} error=${toLogText(probeErrorWithLogFallback || "")}`,
6074
+ );
6075
+ }
6076
+ const error = ok
5883
6077
  ? null
5884
6078
  : pickMoreSpecificErrorMessage(
5885
6079
  previous?.last_error || null,
5886
- probeErrorWithLogFallback || invoke?.error || null,
6080
+ probeErrorWithLogFallback,
5887
6081
  );
5888
- const runtimeHints = extractRuntimeStatusHints(
5889
- probeResult || invoke?.result || invoke || {},
5890
- );
6082
+ if (!ok) {
6083
+ console.error(
6084
+ `[bridges/main] weixin probeAccount merged_error account=${accountId} error=${toLogText(error || "")}`,
6085
+ );
6086
+ }
6087
+ const probeAt = nowIso();
6088
+ const runtimeHints = extractRuntimeStatusHints(probeResult);
5891
6089
  upsertRuntimeProbe({
5892
6090
  platform: "weixin",
5893
6091
  bot_account_id: accountId,
5894
6092
  last_probe_at: probeAt,
5895
- ok: probeOk,
5896
- error: mergedError,
5897
- result: probeResult,
6093
+ ok,
6094
+ error,
6095
+ result: (probeResult as Record<string, unknown>) || null,
5898
6096
  source: "probe",
5899
6097
  });
5900
6098
  await maybeRunChannelAuditAfterProbe({
5901
- statusApi: weixinStatusApiForAudit,
5902
- account: { accountId } as Record<string, unknown>,
6099
+ statusApi,
6100
+ account,
5903
6101
  cfg,
5904
- probeResult,
6102
+ probeResult: (probeResult as Record<string, unknown>) || null,
5905
6103
  timeoutMs: tw,
5906
6104
  platform: "weixin",
5907
6105
  bot_account_id: accountId,
@@ -5909,16 +6107,16 @@ async function runFullProbeAndRefreshStatuses(params: {
5909
6107
  upsertRuntimeStatus({
5910
6108
  platform: "weixin",
5911
6109
  bot_account_id: accountId,
5912
- link_status: linkStatus,
6110
+ link_status: ok ? "connected" : "error",
5913
6111
  started_at: previous?.started_at || nowIso(),
5914
6112
  last_heartbeat_at: probeAt,
5915
- last_error: mergedError,
6113
+ last_error: error,
5916
6114
  reconnect_count:
5917
6115
  runtimeHints.reconnect_count ?? previous?.reconnect_count ?? 0,
5918
6116
  restart_pending: resolveRuntimeRestartPendingForUpdate({
5919
6117
  previous,
5920
6118
  hintedRestartPending: runtimeHints.restart_pending,
5921
- nextLinkStatus: linkStatus,
6119
+ nextLinkStatus: ok ? "connected" : "error",
5922
6120
  }),
5923
6121
  busy: runtimeHints.busy ?? false,
5924
6122
  active_runs: runtimeHints.active_runs ?? 0,
@@ -5946,7 +6144,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5946
6144
  upsertRuntimeStatus({
5947
6145
  platform: "weixin",
5948
6146
  bot_account_id: accountId,
5949
- link_status: weixinBridgeStarted ? "error" : "connecting",
6147
+ link_status: "error",
5950
6148
  started_at: previous?.started_at || nowIso(),
5951
6149
  last_heartbeat_at: probeAt,
5952
6150
  last_error: probeError,
@@ -5954,7 +6152,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5954
6152
  restart_pending: resolveRuntimeRestartPendingForUpdate({
5955
6153
  previous,
5956
6154
  hintedRestartPending: undefined,
5957
- nextLinkStatus: weixinBridgeStarted ? "error" : "connecting",
6155
+ nextLinkStatus: "error",
5958
6156
  }),
5959
6157
  busy: false,
5960
6158
  active_runs: 0,
@@ -7478,11 +7676,11 @@ async function startInternalApiServer(): Promise<void> {
7478
7676
  platform,
7479
7677
  bot_account_id: botAccountId,
7480
7678
  link_status: "connecting",
7481
- started_at: previous?.started_at || now,
7679
+ started_at: now,
7482
7680
  last_heartbeat_at: now,
7483
7681
  last_error: null,
7484
7682
  reconnect_count: previous?.reconnect_count || 0,
7485
- restart_pending: false,
7683
+ restart_pending: true,
7486
7684
  busy: false,
7487
7685
  active_runs: 0,
7488
7686
  last_run_activity_at: now,
@@ -7508,6 +7706,28 @@ async function startInternalApiServer(): Promise<void> {
7508
7706
  );
7509
7707
  return;
7510
7708
  }
7709
+ if (
7710
+ previous?.link_status === "disconnected" &&
7711
+ previous?.status_source === "manual"
7712
+ ) {
7713
+ res.writeHead(200, { "Content-Type": "application/json" });
7714
+ res.end(
7715
+ JSON.stringify({
7716
+ ok: true,
7717
+ status: "noop",
7718
+ action: "stop",
7719
+ reason: "already_stopped",
7720
+ platform,
7721
+ bot_account_id: botAccountId,
7722
+ operation_id: operationId,
7723
+ runtime_instance_id: runtimeInstanceId,
7724
+ runtime_state: runtimeState,
7725
+ issued_at: now,
7726
+ bot: previous,
7727
+ }),
7728
+ );
7729
+ return;
7730
+ }
7511
7731
  await withRuntimeControlTimeout(
7512
7732
  control.stopAccount(botAccountId),
7513
7733
  `bot stop timeout platform=${platform} account=${botAccountId}`,
@@ -7549,7 +7769,7 @@ async function startInternalApiServer(): Promise<void> {
7549
7769
  platform,
7550
7770
  bot_account_id: botAccountId,
7551
7771
  link_status: "connecting",
7552
- started_at: previous?.started_at || now,
7772
+ started_at: now,
7553
7773
  last_heartbeat_at: now,
7554
7774
  last_error: null,
7555
7775
  reconnect_count: previous?.reconnect_count || 0,
@@ -8333,6 +8553,7 @@ async function startInternalApiServer(): Promise<void> {
8333
8553
  previous: prev,
8334
8554
  hintedRestartPending: runtimeHintsFromEvent.restart_pending,
8335
8555
  nextLinkStatus: prev.link_status,
8556
+ nextStatusSource: prev.status_source,
8336
8557
  });
8337
8558
  upsertRuntimeStatus({
8338
8559
  ...prev,
@@ -8390,6 +8611,7 @@ async function startInternalApiServer(): Promise<void> {
8390
8611
  previous: prev,
8391
8612
  hintedRestartPending: runtimeHintsFromEvent.restart_pending,
8392
8613
  nextLinkStatus: normalizedStatus,
8614
+ nextStatusSource: "event",
8393
8615
  });
8394
8616
  upsertRuntimeStatus({
8395
8617
  platform: p,
@@ -8690,6 +8912,14 @@ async function main(): Promise<void> {
8690
8912
  restorePersistedRuntimeStateSnapshot(
8691
8913
  loadedConfigForRuntime as Record<string, unknown> | null,
8692
8914
  );
8915
+ // 启动恢复后强制进入新生命周期 connecting 态,避免旧快照 connected 直接外显。
8916
+ markConfiguredBotsAsConnecting(
8917
+ (loadedConfigForRuntime as Record<string, unknown>) || {
8918
+ channels: {},
8919
+ bindings: [],
8920
+ },
8921
+ "startup_after_restore_connecting",
8922
+ );
8693
8923
  }
8694
8924
 
8695
8925
  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,6 +269,10 @@ export function evaluateRuntimeHealth(params: {
242
269
  };
243
270
  }
244
271
 
272
+ if (isExplicitReportedRuntimeFailure(status)) {
273
+ return { healthy: false, reason: "disconnected", evaluated_at: nowIsoText };
274
+ }
275
+
245
276
  // OpenClaw parity: startup grace is a lifecycle-level guard. Once a lifecycle
246
277
  // has just started, defer negative judgments until the connect grace elapses.
247
278
  if (lastStartAtMs != null) {
@@ -401,11 +432,28 @@ export function resolveRuntimeDisplayState(params: {
401
432
  };
402
433
 
403
434
  const linkConnected = params.status.link_status === "connected";
404
- 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
+ }
405
451
  return build("reconnecting", "status:restart_pending", "status", {
406
452
  label: "重连中",
407
453
  color: "processing",
408
- hint: "重启或重连流程进行中,尚未回到已连接状态。",
454
+ hint: linkConnected
455
+ ? "重启流程进行中,等待探测确认完成后再切换在线。"
456
+ : "重启或重连流程进行中,尚未回到已连接状态。",
409
457
  });
410
458
  }
411
459
 
@@ -458,14 +506,19 @@ export function resolveRuntimeDisplayState(params: {
458
506
  });
459
507
  }
460
508
  if (params.healthReason === "startup_grace") {
461
- if (statusText === "connected") {
509
+ const statusSource = String(params.status.status_source || "")
510
+ .trim()
511
+ .toLowerCase();
512
+ if (statusText === "connected" && statusSource === "event") {
462
513
  return build("online", "status:connected", "status", {
463
514
  label: "在线",
464
515
  color: "success",
465
- hint: "连接已建立,当前仍处于启动宽限窗口。",
516
+ hint: "已收到连接成功事件,当前仍处于启动宽限窗口。",
466
517
  });
467
518
  }
468
- return build("connecting", "health:startup_grace", "health");
519
+ return build("connecting", "health:startup_grace", "health", {
520
+ hint: "启动宽限期内先显示连接中,收到连接成功事件后再切换在线。",
521
+ });
469
522
  }
470
523
 
471
524
  const normalizedFromStatus = normalizeRuntimeDisplayStatusFromText(statusText);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ylib-syim",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "多 IM / 多 Agent 的会话路由与上下文管理(支持 /new)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -48,7 +48,7 @@
48
48
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
49
49
  "ylib-dingtalk-connector": "0.7.10-beta.15",
50
50
  "ylib-openclaw-lark": "2026.3.17-beta.20",
51
- "ylib-openclaw-weixin": "2.1.7-beta.7",
51
+ "ylib-openclaw-weixin": "2.1.7-beta.8",
52
52
  "axios": "^1.6.0",
53
53
  "dingtalk-stream": "^2.1.4",
54
54
  "fluent-ffmpeg": "^2.1.3",