@aiwayds/dsh-topics-memory 0.18.1 → 0.18.2

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/lib/index.js CHANGED
@@ -898,23 +898,26 @@ export async function apply(ctx, config = {}) {
898
898
  // The 0.1.7 host imports the old settings.yaml ONCE by "section name =
899
899
  // entry id" and renames it settings.yaml.imported — this plugin's legacy
900
900
  // section was named `topics`, so the host's import silently dropped it.
901
- // Recover it here (see legacy-import.ts). Awaited as apply()'s LAST step:
902
- // every registration above stays synchronous (fake-ctx tests dispatch
903
- // immediately), while a real boot — whose loader awaits the fiber setup —
904
- // also settles the import before activation completes. Fully contained:
905
- // any failure only warns and leaves the audit marker unwritten, so the
906
- // next boot retries; activation never depends on this.
907
- try {
908
- await runLegacySettingsImport({
901
+ // Recover it here (see legacy-import.ts). Wired through
902
+ // ctx.inject(['settings'], …) instead of a direct `ctx.settings` read: a
903
+ // real boot can run apply() before the settings service is mounted, the
904
+ // direct read came up undefined, and the import then took the silent
905
+ // no-settings exit EVERY boot (no marker, nothing ever recovered). The
906
+ // inject callback fires the moment the service exists (immediately when
907
+ // it already does — fake-ctx tests stay synchronous), fire-and-forget so
908
+ // activation never waits on it. Fully contained: any failure only warns
909
+ // and leaves the audit marker unwritten, so the next boot retries;
910
+ // activation never depends on this.
911
+ ctx.inject(['settings'], (sctx) => {
912
+ void runLegacySettingsImport({
909
913
  home: paths.resolveDshHome(),
910
- settings: ctx.settings,
914
+ settings: sctx.settings,
911
915
  logger: ctx.logger,
912
916
  getCurrent: (key) => cfgNow()[key],
917
+ }).catch((error) => {
918
+ warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`);
913
919
  });
914
- }
915
- catch (error) {
916
- warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`);
917
- }
920
+ });
918
921
  }
919
922
  /** Config defaults for harnesses that skip schemastery's default application. */
920
923
  const DEFAULTS = {
@@ -50,6 +50,21 @@ export interface LegacyImportInput {
50
50
  logger?: LegacyImportLogger | undefined;
51
51
  /** Current effective value for one legacy key (Volatile ref `.get()` at the call site). */
52
52
  getCurrent(key: LegacyKey): unknown;
53
+ /**
54
+ * Retry budget for `settings.update`. The host serializes profile writes
55
+ * (settings.update → dsh-config-editor) behind the `<profile>/package.json`
56
+ * writer lock with a 2s per-call deadline, and during BOOT the host itself
57
+ * holds that lock while preparing the profile — a single bare call almost
58
+ * always times out (2026-09-25 incident: the import failed EVERY boot and
59
+ * the migration never converged). Defaults to 5 attempts 2s apart; a
60
+ * permanently orphaned lock (a SIGKILLed host never releases it — orphan
61
+ * recovery is an operator action) still exhausts the budget and stays
62
+ * retry-worthy. Tests shrink this to keep the suite fast.
63
+ */
64
+ updateRetry?: {
65
+ attempts: number;
66
+ delayMs: number;
67
+ } | undefined;
53
68
  }
54
69
  export type LegacyImportOutcome = 'imported' | 'no-op' | 'no-legacy' | 'no-section' | 'marker-exists' | 'no-settings' | 'update-failed';
55
70
  export interface LegacyImportResult {
@@ -160,8 +160,11 @@ export async function runLegacySettingsImport(input) {
160
160
  // A settings seam that cannot accept the patch is a retry-worthy state:
161
161
  // return WITHOUT reading/marking, so a host that mounts the service later
162
162
  // (and a test harness pointing at a real home) neither imports nor writes.
163
+ // Not silent: the 2026-09-24 incident hid exactly behind this quiet exit —
164
+ // the warn is what makes a never-mounting seam observable in a boot log.
163
165
  const settings = input.settings;
164
166
  if (settings === undefined || typeof settings.update !== 'function') {
167
+ warn(`${ENTRY_ID}: legacy settings import deferred: settings seam unusable (no-settings; will retry next boot)`);
165
168
  return { outcome: 'no-settings', imported, skipped };
166
169
  }
167
170
  // Locate the legacy document.
@@ -198,12 +201,28 @@ export async function runLegacySettingsImport(input) {
198
201
  info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): nothing to import (all values equal or unknown)`);
199
202
  return { outcome: 'no-op', imported, skipped };
200
203
  }
201
- try {
202
- await settings.update(ENTRY_ID, imported);
204
+ // The update contends on the host's profile writer lock (see
205
+ // {@link LegacyImportInput.updateRetry}): retry with a fixed gap before
206
+ // conceding. Every failed attempt warns — a silent loss here cost a full
207
+ // day of "the migration never runs" debugging (2026-09-25).
208
+ const retry = input.updateRetry ?? { attempts: 5, delayMs: 2_000 };
209
+ let updateFailed;
210
+ for (let attempt = 1; attempt <= retry.attempts; attempt++) {
211
+ try {
212
+ await settings.update(ENTRY_ID, imported);
213
+ updateFailed = undefined;
214
+ break;
215
+ }
216
+ catch (error) {
217
+ updateFailed = error;
218
+ warn(`${ENTRY_ID}: legacy settings import: settings.update attempt ${attempt}/${retry.attempts} failed: ${error instanceof Error ? error.message : String(error)}`);
219
+ if (attempt < retry.attempts)
220
+ await new Promise((resolve) => setTimeout(resolve, retry.delayMs));
221
+ }
203
222
  }
204
- catch (error) {
223
+ if (updateFailed !== undefined) {
224
+ warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${updateFailed instanceof Error ? updateFailed.message : String(updateFailed)}`);
205
225
  // No marker: the next boot retries the import.
206
- warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${error instanceof Error ? error.message : String(error)}`);
207
226
  return { outcome: 'update-failed', imported, skipped };
208
227
  }
209
228
  writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'imported', source: sourceName, imported, skipped });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/index.ts CHANGED
@@ -1016,22 +1016,26 @@ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Pro
1016
1016
  // The 0.1.7 host imports the old settings.yaml ONCE by "section name =
1017
1017
  // entry id" and renames it settings.yaml.imported — this plugin's legacy
1018
1018
  // section was named `topics`, so the host's import silently dropped it.
1019
- // Recover it here (see legacy-import.ts). Awaited as apply()'s LAST step:
1020
- // every registration above stays synchronous (fake-ctx tests dispatch
1021
- // immediately), while a real boot — whose loader awaits the fiber setup —
1022
- // also settles the import before activation completes. Fully contained:
1023
- // any failure only warns and leaves the audit marker unwritten, so the
1024
- // next boot retries; activation never depends on this.
1025
- try {
1026
- await runLegacySettingsImport({
1019
+ // Recover it here (see legacy-import.ts). Wired through
1020
+ // ctx.inject(['settings'], …) instead of a direct `ctx.settings` read: a
1021
+ // real boot can run apply() before the settings service is mounted, the
1022
+ // direct read came up undefined, and the import then took the silent
1023
+ // no-settings exit EVERY boot (no marker, nothing ever recovered). The
1024
+ // inject callback fires the moment the service exists (immediately when
1025
+ // it already does — fake-ctx tests stay synchronous), fire-and-forget so
1026
+ // activation never waits on it. Fully contained: any failure only warns
1027
+ // and leaves the audit marker unwritten, so the next boot retries;
1028
+ // activation never depends on this.
1029
+ ctx.inject(['settings'], (sctx) => {
1030
+ void runLegacySettingsImport({
1027
1031
  home: paths.resolveDshHome(),
1028
- settings: ctx.settings as SettingsUpdateSeam,
1032
+ settings: (sctx as unknown as { settings?: SettingsUpdateSeam }).settings,
1029
1033
  logger: (ctx as unknown as { logger?: LegacyImportLogger }).logger,
1030
1034
  getCurrent: (key) => cfgNow()[key],
1035
+ }).catch((error) => {
1036
+ warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`)
1031
1037
  })
1032
- } catch (error) {
1033
- warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`)
1034
- }
1038
+ })
1035
1039
  }
1036
1040
 
1037
1041
  /** Config defaults for harnesses that skip schemastery's default application. */
@@ -61,6 +61,18 @@ export interface LegacyImportInput {
61
61
  logger?: LegacyImportLogger | undefined
62
62
  /** Current effective value for one legacy key (Volatile ref `.get()` at the call site). */
63
63
  getCurrent(key: LegacyKey): unknown
64
+ /**
65
+ * Retry budget for `settings.update`. The host serializes profile writes
66
+ * (settings.update → dsh-config-editor) behind the `<profile>/package.json`
67
+ * writer lock with a 2s per-call deadline, and during BOOT the host itself
68
+ * holds that lock while preparing the profile — a single bare call almost
69
+ * always times out (2026-09-25 incident: the import failed EVERY boot and
70
+ * the migration never converged). Defaults to 5 attempts 2s apart; a
71
+ * permanently orphaned lock (a SIGKILLed host never releases it — orphan
72
+ * recovery is an operator action) still exhausts the budget and stays
73
+ * retry-worthy. Tests shrink this to keep the suite fast.
74
+ */
75
+ updateRetry?: { attempts: number; delayMs: number } | undefined
64
76
  }
65
77
 
66
78
  export type LegacyImportOutcome =
@@ -200,8 +212,11 @@ export async function runLegacySettingsImport(input: LegacyImportInput): Promise
200
212
  // A settings seam that cannot accept the patch is a retry-worthy state:
201
213
  // return WITHOUT reading/marking, so a host that mounts the service later
202
214
  // (and a test harness pointing at a real home) neither imports nor writes.
215
+ // Not silent: the 2026-09-24 incident hid exactly behind this quiet exit —
216
+ // the warn is what makes a never-mounting seam observable in a boot log.
203
217
  const settings = input.settings
204
218
  if (settings === undefined || typeof settings.update !== 'function') {
219
+ warn(`${ENTRY_ID}: legacy settings import deferred: settings seam unusable (no-settings; will retry next boot)`)
205
220
  return { outcome: 'no-settings', imported, skipped }
206
221
  }
207
222
 
@@ -241,11 +256,26 @@ export async function runLegacySettingsImport(input: LegacyImportInput): Promise
241
256
  return { outcome: 'no-op', imported, skipped }
242
257
  }
243
258
 
244
- try {
245
- await settings.update(ENTRY_ID, imported)
246
- } catch (error) {
259
+ // The update contends on the host's profile writer lock (see
260
+ // {@link LegacyImportInput.updateRetry}): retry with a fixed gap before
261
+ // conceding. Every failed attempt warns — a silent loss here cost a full
262
+ // day of "the migration never runs" debugging (2026-09-25).
263
+ const retry = input.updateRetry ?? { attempts: 5, delayMs: 2_000 }
264
+ let updateFailed: unknown
265
+ for (let attempt = 1; attempt <= retry.attempts; attempt++) {
266
+ try {
267
+ await settings.update(ENTRY_ID, imported)
268
+ updateFailed = undefined
269
+ break
270
+ } catch (error) {
271
+ updateFailed = error
272
+ warn(`${ENTRY_ID}: legacy settings import: settings.update attempt ${attempt}/${retry.attempts} failed: ${error instanceof Error ? error.message : String(error)}`)
273
+ if (attempt < retry.attempts) await new Promise((resolve) => setTimeout(resolve, retry.delayMs))
274
+ }
275
+ }
276
+ if (updateFailed !== undefined) {
277
+ warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${updateFailed instanceof Error ? updateFailed.message : String(updateFailed)}`)
247
278
  // No marker: the next boot retries the import.
248
- warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${error instanceof Error ? error.message : String(error)}`)
249
279
  return { outcome: 'update-failed', imported, skipped }
250
280
  }
251
281