dsh-agentone 0.5.17 → 0.5.18

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/README.md CHANGED
@@ -61,6 +61,9 @@ dsh plugin --profile web add dsh-agentone
61
61
  - `$DSH_HOME/.credentials.yaml` 写入 `refs.AGENTONE_API_KEY`(0600),内容
62
62
  是 30 天插件令牌而非真实模型密钥——密钥不出平台,TokenHub 按用户计量;
63
63
  - 只覆盖 `platform` 命名空间,用户自己配置的其它 provider 原样保留;
64
+ - 同步失败(如平台侧 AirRouter 瞬时不可达)不阻断登录/启动,原因记录在
65
+ 状态页「同步状态」;状态页轮询发现残留错误时按指数退避(1min 起、
66
+ 封顶 30min、最多 30 次)自动重试,平台恢复后无需重启 dsh 即自愈;
64
67
  - 「退出登录」(页头按钮)会吊销平台令牌、移除凭据引用与 platform
65
68
  provider(其它 provider 的默认模型设置不受影响);凭据文件损坏时也能
66
69
  正常退出(坏文件移开为 `credentials.json.corrupt`)。
package/lib/index.js CHANGED
@@ -194,6 +194,41 @@ function pluginProfileDir(config) {
194
194
  return join(resolveDshHome(config), 'profiles', PLUGIN_PROFILE);
195
195
  }
196
196
 
197
+ /** 模型同步失败自愈重试调度(模块级单例:apply 重入先清旧定时器)。
198
+ * 平台侧瞬时故障(如 AirRouter 短暂不可达)会让启动同步失败,错误一直
199
+ * 挂在状态页直到下次 dsh 重启——这里由 5s 状态轮询驱动:发现 model_error
200
+ * 就按指数退避后台重试,成功即清错误,无需用户干预。 */
201
+ export const MODEL_RETRY_BASE_MS = 60_000;
202
+ export const MODEL_RETRY_MAX_MS = 30 * 60_000;
203
+ export const MODEL_RETRY_MAX_ATTEMPTS = 30;
204
+ let modelRetryTimer = null;
205
+ let modelRetryAttempts = 0;
206
+
207
+ function clearModelRetry() {
208
+ if (modelRetryTimer !== null) {
209
+ clearTimeout(modelRetryTimer);
210
+ modelRetryTimer = null;
211
+ }
212
+ }
213
+
214
+ function scheduleModelRetry(config, log) {
215
+ if (modelRetryTimer !== null || modelRetryAttempts >= MODEL_RETRY_MAX_ATTEMPTS) return;
216
+ const delay = Math.min(MODEL_RETRY_BASE_MS * 2 ** modelRetryAttempts, MODEL_RETRY_MAX_MS);
217
+ modelRetryAttempts += 1;
218
+ modelRetryTimer = setTimeout(() => {
219
+ modelRetryTimer = null;
220
+ log.info(`[dsh-agentone] model sync self-heal retry #${modelRetryAttempts}`);
221
+ syncPlatformModel(config, log)
222
+ .then(() => {
223
+ modelRetryAttempts = 0;
224
+ })
225
+ .catch(() => {
226
+ scheduleModelRetry(config, log);
227
+ });
228
+ }, delay);
229
+ modelRetryTimer.unref?.();
230
+ }
231
+
197
232
  /** 转发 dsh plugin 子命令:desktop 走受管 pnpm,普通 dsh 走官方 CLI。 */
198
233
  async function runPluginCli(args) {
199
234
  if (desktopPluginCli) {
@@ -718,6 +753,9 @@ export function apply(ctx, config) {
718
753
  });
719
754
  }
720
755
  ctx.effect(() => {
756
+ // 模块级重试定时器跨 apply 存活:重入先清,避免旧定时器引用旧 config
757
+ clearModelRetry();
758
+ modelRetryAttempts = 0;
721
759
  const register = (path, handler) => ctx.webServer.register({ kind: 'exact', path, handler });
722
760
 
723
761
  register('/agentone/', (request, response) => {
@@ -760,6 +798,14 @@ export function apply(ctx, config) {
760
798
  if (userEmail) {
761
799
  ({ capabilities, skills, displayNames } = await cachedPlatformStatus(config, credentials));
762
800
  }
801
+ // 失败自愈:模型同步残留错误时由轮询驱动后台重试(成功即清,
802
+ // 下次轮询状态页恢复);错误已被登录/手动同步清除时取消待定
803
+ // 重试并复位退避计数。
804
+ if (credentials.model_error) scheduleModelRetry(config, log);
805
+ else {
806
+ clearModelRetry();
807
+ modelRetryAttempts = 0;
808
+ }
763
809
  }
764
810
  sendJson(response, 200, {
765
811
  platform_url: config.platformUrl,
@@ -1357,6 +1403,8 @@ export function apply(ctx, config) {
1357
1403
  return () => {
1358
1404
  pendingStates.clear();
1359
1405
  pendingCliAuth.clear();
1406
+ clearModelRetry();
1407
+ modelRetryAttempts = 0;
1360
1408
  log.info('[dsh-agentone] routes unmounted');
1361
1409
  };
1362
1410
  }, 'dsh-agentone: http routes');
package/lib/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 平台凭据持久化:$DSH_HOME/agentone/credentials.json(0600,仅本机插件可读)。
2
- import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
 
@@ -15,6 +15,26 @@ function credentialsPath(config) {
15
15
  return join(storeDir(config), 'credentials.json');
16
16
  }
17
17
 
18
+ /**
19
+ * 凭据更新互斥链:启动块、登录回调、手动同步、自愈重试可能在同一进程
20
+ * 内并发执行 read-merge-write(updateCredentials)。无锁时两个并发写会
21
+ * 互相丢补丁,且 writeFile 非原子——读到半截 JSON 会触发 loadCredentials
22
+ * 的容错把文件当损坏移开(登录态直接丢失)。这里把所有更新串行化,
23
+ * 写入改为 tmp + rename 原子替换,任一时刻磁盘上都是完整 JSON。
24
+ */
25
+ let credentialsWriteChain = Promise.resolve();
26
+
27
+ function serializedWrite(config, writeFn) {
28
+ const next = credentialsWriteChain.then(writeFn, writeFn);
29
+ // 链上错误不阻断后续更新,也不进调用方的结果
30
+ credentialsWriteChain = next.catch(() => {});
31
+ return next;
32
+ }
33
+
34
+ function tmpName(path) {
35
+ return `${path}.tmp-${process.pid}-${Date.now()}`;
36
+ }
37
+
18
38
  /**
19
39
  * 读取凭据。文件损坏(截断/手改坏)不抛出:把坏文件移开为
20
40
  * credentials.json.corrupt,返回 null 回到未登录态——否则 logout 也依赖
@@ -40,21 +60,32 @@ export async function loadCredentials(config) {
40
60
  }
41
61
  }
42
62
 
43
- export async function saveCredentials(config, credentials) {
44
- const dir = storeDir(config);
45
- await mkdir(dir, { recursive: true, mode: 0o700 });
46
- await writeFile(credentialsPath(config), JSON.stringify(credentials, null, 2) + '\n', {
47
- mode: 0o600,
48
- flag: 'w',
63
+ export function saveCredentials(config, credentials) {
64
+ return serializedWrite(config, async () => {
65
+ const dir = storeDir(config);
66
+ await mkdir(dir, { recursive: true, mode: 0o700 });
67
+ const path = credentialsPath(config);
68
+ const tmp = tmpName(path);
69
+ await writeFile(tmp, JSON.stringify(credentials, null, 2) + '\n', { mode: 0o600, flag: 'w' });
70
+ await rename(tmp, path);
49
71
  });
50
72
  }
51
73
 
52
74
  /** 读取-合并-写回:模型同步结果等增量字段落盘。 */
53
- export async function updateCredentials(config, patch) {
54
- const current = (await loadCredentials(config)) || {};
55
- await saveCredentials(config, { ...current, ...patch });
75
+ export function updateCredentials(config, patch) {
76
+ return serializedWrite(config, async () => {
77
+ const current = (await loadCredentials(config)) || {};
78
+ const path = credentialsPath(config);
79
+ const merged = { ...current, ...patch };
80
+ const dir = storeDir(config);
81
+ await mkdir(dir, { recursive: true, mode: 0o700 });
82
+ const tmp = tmpName(path);
83
+ await writeFile(tmp, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600, flag: 'w' });
84
+ await rename(tmp, path);
85
+ return merged;
86
+ });
56
87
  }
57
88
 
58
- export async function clearCredentials(config) {
59
- await rm(credentialsPath(config), { force: true });
89
+ export function clearCredentials(config) {
90
+ return serializedWrite(config, () => rm(credentialsPath(config), { force: true }));
60
91
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "0.5.17",
4
- "description": "AgentOne 平台集成插件:飞书登录、平台模型、SkillHub 技能(含升级检测与一键升级)、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错、状态页 UX 增强)",
3
+ "version": "0.5.18",
4
+ "description": "AgentOne 平台集成插件:飞书登录、平台模型(同步失败自愈重试)、SkillHub 技能(含升级检测与一键升级)、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错与并发安全写入、状态页 UX 增强)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {