@things-factory/headless-twin 10.0.13 → 10.0.15

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.
Files changed (56) hide show
  1. package/dist-server/engine/canonical-ingest.js +30 -0
  2. package/dist-server/engine/canonical-ingest.js.map +1 -1
  3. package/dist-server/engine/ingest-health.d.ts +76 -0
  4. package/dist-server/engine/ingest-health.js +93 -0
  5. package/dist-server/engine/ingest-health.js.map +1 -1
  6. package/dist-server/engine/twin-engine.d.ts +41 -0
  7. package/dist-server/engine/twin-engine.js +199 -6
  8. package/dist-server/engine/twin-engine.js.map +1 -1
  9. package/dist-server/service/reference/reference-adapter.d.ts +51 -0
  10. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  11. package/dist-server/service/reference/reference-assessment.d.ts +11 -1
  12. package/dist-server/service/reference/reference-assessment.js +22 -2
  13. package/dist-server/service/reference/reference-assessment.js.map +1 -1
  14. package/dist-server/service/reference/reference-live.js +30 -0
  15. package/dist-server/service/reference/reference-live.js.map +1 -1
  16. package/dist-server/service/reference/reference-master.d.ts +12 -2
  17. package/dist-server/service/reference/reference-master.js.map +1 -1
  18. package/dist-server/service/twin-event/twin-event-keys.d.ts +21 -1
  19. package/dist-server/service/twin-event/twin-event-keys.js +35 -3
  20. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  21. package/dist-server/service/twin-forecast/twin-forecast-query.js +24 -1
  22. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  23. package/dist-server/service/twin-model/epcis-coverage.js +32 -1
  24. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  25. package/dist-server/service/twin-model/isa95-coverage.d.ts +10 -0
  26. package/dist-server/service/twin-model/isa95-coverage.js +85 -5
  27. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  28. package/dist-server/service/twin-model/item-ref.d.ts +3 -3
  29. package/dist-server/service/twin-model/item-ref.js +4 -4
  30. package/dist-server/service/twin-model/item-ref.js.map +1 -1
  31. package/dist-server/service/twin-model/twin-model-query.js +71 -3
  32. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  33. package/package.json +3 -3
  34. package/server/engine/canonical-ingest.ts +30 -0
  35. package/server/engine/ingest-health.ts +143 -0
  36. package/server/engine/twin-engine.ts +219 -6
  37. package/server/service/reference/reference-adapter.ts +45 -0
  38. package/server/service/reference/reference-assessment.ts +39 -4
  39. package/server/service/reference/reference-live.ts +32 -0
  40. package/server/service/reference/reference-master.ts +12 -2
  41. package/server/service/twin-event/twin-event-keys.ts +35 -3
  42. package/server/service/twin-forecast/twin-forecast-query.ts +27 -1
  43. package/server/service/twin-model/epcis-coverage.ts +32 -1
  44. package/server/service/twin-model/isa95-coverage.ts +95 -5
  45. package/server/service/twin-model/item-ref.ts +4 -4
  46. package/server/service/twin-model/twin-model-query.ts +71 -3
  47. package/test/checkpoint-refuses-empty.test.ts +103 -0
  48. package/test/cursor-stall-not-read-failure.test.ts +126 -0
  49. package/test/item-ref.test.ts +3 -3
  50. package/test/mirror-resumes-from-checkpoint.test.ts +192 -0
  51. package/test/revision-axis.test.ts +13 -2
  52. package/test/standard-coverage.test.ts +44 -0
  53. package/test/status-tally.test.ts +3 -3
  54. package/test/twin-event-keys.test.ts +48 -2
  55. package/test/withheld-door.test.ts +95 -0
  56. package/tsconfig.tsbuildinfo +1 -1
@@ -174,6 +174,28 @@ export declare class TwinEngine {
174
174
  * 사건 집계가 없다 — 그 상태로 뒤를 접으면 0부터 접은 결과와 조용히 달라진다). 그래서 씨앗은 따로 든다.
175
175
  */
176
176
  private static readonly FOLD_NOTE;
177
+ /**
178
+ * 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 접을 재개점.
179
+ *
180
+ * ── 왜 이 자리가 생겼나 (2026-08-24) ──────────────────────────────────────
181
+ * 두 호출부가 같은 일을 조금씩 다르게 하고 있었고(겹포장을 한쪽만 벗겼다), 둘 다 **재개점을 버리고**
182
+ * 상태만 들고 갔다. 그래서 저장된 재개점을 읽는 쪽이 다 있는데도 재기동은 매번 저널을 처음부터
183
+ * 접었다(실측: 저널 2,960만 줄).
184
+ *
185
+ * 여기서 하는 일 셋:
186
+ * ① 겹포장을 벗긴다 — 옛 형식으로 저장된 값이 한 번은 반드시 나온다
187
+ * ② **그 공장이 아직 그 공장인지** 심판한다 — 아니면 씨앗을 버린다(아래)
188
+ * ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 접어** 끝까지 밀어 둔다
189
+ *
190
+ * ②가 필요한 이유: 재개점은 그때의 보드 위에서 만들어진 것이다. 그 뒤 구조가 바뀌었다면(자리가
191
+ * 빠졌다·설비가 옮겨졌다) 되세운 리듀서는 **지금 없는 자리와 설비를 든다** — 없는 냉장실이 화면에
192
+ * 나오고 그 자리의 판정이 계속 돌아간다. 오류 없이 틀리므로 눈에 띄지 않는다.
193
+ *
194
+ * ③이 필요한 이유: 스냅샷은 체크포인트 주기로 쓰이므로 마지막 주기 이후의 사실은 저널에만 있다.
195
+ * 그것을 빼고 되세우면 그만큼이 조용히 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다.
196
+ * 접는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다.
197
+ */
198
+ private static warmSeedFor;
177
199
  /** 체크포인트된 최신 스냅샷 로드(없으면 null). getFromCache 는 CacheStore 엔티티를 반환 → 페이로드는 .value. */
178
200
  static loadSnapshot(domainId: string, instanceId: string): Promise<{
179
201
  revision: number;
@@ -1046,6 +1068,25 @@ export declare class TwinEngine {
1046
1068
  * 그 둘을 같게 두면 조용한 원본이 끊긴 원본으로 보인다.
1047
1069
  */
1048
1070
  static clearIngestReadFailure(domainId: string, instanceId: string): void;
1071
+ /**
1072
+ * **읽었는데 창을 넘길 수 없다**를 적는다 — 위와 **조치가 반대인** 사실이다(§`recordCursorStall`).
1073
+ *
1074
+ * 이 문이 없던 동안 이 사실이 `recordIngestReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지
1075
+ * 못한다」고 말했는데 원본은 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다(한 시각에 한
1076
+ * 페이지보다 많은 행). 사람은 원본을 의심하고 기다리는데 **기다림으로는 영원히 풀리지 않는다.**
1077
+ *
1078
+ * 조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.
1079
+ */
1080
+ static recordIngestCursorStall(domainId: string, instanceId: string, reason: string, nowMs?: number, stream?: string): void;
1081
+ /**
1082
+ * **원본에 있는데 세우지 않은 것을 적는다** — 이유와 수(§`IngestLedger.withheld`).
1083
+ *
1084
+ * 어댑터가 주기마다 불러도 된다: 장부가 이유로 묶어 **마지막 수로 덮는다**(누적하지 않는다).
1085
+ * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 조용히 그치면 낡은 수가 남는다.
1086
+ */
1087
+ static recordIngestWithheld(domainId: string, instanceId: string, reason: string, count: number, nowMs?: number): void;
1088
+ /** 창을 넘겼다 — 정체 기록을 지운다(풀린 정체가 화면에 남아 있으면 그것도 거짓이다). */
1089
+ static clearIngestCursorStall(domainId: string, instanceId: string): void;
1049
1090
  /**
1050
1091
  * 저널에 **적은 것**을 같은 장부에 남긴다 — 유입과 같은 10분 창에.
1051
1092
  *
@@ -260,10 +260,61 @@ class TwinEngine {
260
260
  const state = (0, warm_start_js_1.unwrapState)(this.snapshot(domainId, instanceId));
261
261
  if (!state)
262
262
  return;
263
+ /*
264
+ * ── **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** (2026-08-24 실측) ────
265
+ *
266
+ * 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를
267
+ * 버린다 — 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그
268
+ * 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다.
269
+ *
270
+ * 그 상태에서 이 함수가 돌면 **빈 상태를 좋은 스냅샷 위에 덮는다.** 그래서 손실이 영구화됐다:
271
+ *
272
+ * 저장돼 있던 것 rev 221,884 · nowTime 2026-04-15 · items 741 · orders 2,780 · tasks 6,353
273
+ * 덮으려던 것 같은 키 · nowTime 2026-01-01 · 전부 0
274
+ *
275
+ * 리비전으로는 막을 수 없다 — 저널 줄 번호는 관측이 없어도 계속 자란다. 막는 기준은 **「들은 것이
276
+ * 있나」**다. 유입이 한 건도 없었다면 이 빈 상태는 **원본이 「비었다」고 말한 것이 아니라 우리가
277
+ * 아무것도 못 들은 것**이고, 그 둘을 같은 값으로 적으면 「모름」이 「없음」이 된다.
278
+ *
279
+ * 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다.
280
+ *
281
+ * 그리고 **거절을 말한다**: 조용히 거절하면 왜 체크포인트가 낡아 가는지 아무도 모른다.
282
+ */
283
+ if (!(inst.metrics?.ingestedTotal > 0)) {
284
+ const observed = (s) => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0);
285
+ if (observed(state) === 0) {
286
+ const prev = await this.loadSnapshot(domainId, instanceId).catch(() => null);
287
+ const had = observed(prev?.state);
288
+ if (had > 0) {
289
+ (0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": checkpoint refused — this twin has heard nothing since start and its live ` +
290
+ `state is empty, while the stored snapshot holds ${had} observed fact(s) (revision ${prev?.revision}). ` +
291
+ 'Writing the empty state would destroy the only recoverable copy. The journal still holds the truth.');
292
+ return;
293
+ }
294
+ }
295
+ }
263
296
  const revision = inst.revision ?? state.revision ?? 0;
264
297
  /* 구조 리비전도 함께 — 읽는 쪽이 "이 상태가 지금의 공장인가" 를 가릴 수 있어야 한다. */
265
298
  const { structureRev } = await this.tipOf(domainId, instanceId).catch(() => ({ structureRev: null }));
266
- await cache_service_1.cacheService.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision, state, structureRev }, this.SNAPSHOT_TTL_S);
299
+ /*
300
+ * ── **이어 접을 씨앗을 함께 적는다** (2026-08-24) ──────────────────────────
301
+ *
302
+ * 이 함수는 오랫동안 `{revision, state, structureRev}` 만 적었다. 그래서 **재개점을 읽는 쪽은 다
303
+ * 있는데 쓰는 쪽이 없었다**: 조회 경로(`recover`)는 `fold` 가 있으면 꼬리만 접고, 없으면 저널을
304
+ * 0부터 접는다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다 —
305
+ * 그래서 재기동·조회마다 처음부터 다시 접었다. 규모 기준(엔티티 10만·품목 100만)에서 이것은
306
+ * 느린 것이 아니라 **못 하는 것**이다.
307
+ *
308
+ * 씨앗은 상태가 대신할 수 없다: 리듀서는 소비처가 보는 값 말고도 든다(부모를 기다리는 담김·집계
309
+ * 중인 수량·담을 줄 몰라 세어 둔 사건). 상태만 되돌리고 뒤를 접으면 0부터 접은 결과와 **조용히**
310
+ * 달라진다. 그 동치는 커널 시험이 증명한다(`observed-checkpoint.test.ts`).
311
+ *
312
+ * 관측 구동이 아니면 씨앗이 없다 — 시뮬은 리듀서를 갖지 않고, 그 상태의 권위는 커널 자신이다.
313
+ * 그때는 `fold` 를 **넣지 않는다**(빈 씨앗을 넣으면 읽는 쪽이 「이어 접을 수 있다」고 잘못 본다).
314
+ */
315
+ const reducer = inst.kernel?.observedCheckpoint?.();
316
+ const fold = reducer && inst.oee ? { reducer, oee: inst.oee.serialize() } : undefined;
317
+ await cache_service_1.cacheService.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision, state, structureRev, ...(fold ? { fold } : {}) }, this.SNAPSHOT_TTL_S);
267
318
  }
268
319
  /**
269
320
  * 접기의 **재개점** — 리듀서 내부 상태 전부 + 가동 누적기.
@@ -272,6 +323,55 @@ class TwinEngine {
272
323
  * 사건 집계가 없다 — 그 상태로 뒤를 접으면 0부터 접은 결과와 조용히 달라진다). 그래서 씨앗은 따로 든다.
273
324
  */
274
325
  static { this.FOLD_NOTE = 'reducer + oee checkpoint — the seed for folding only the tail'; }
326
+ /**
327
+ * 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 접을 재개점.
328
+ *
329
+ * ── 왜 이 자리가 생겼나 (2026-08-24) ──────────────────────────────────────
330
+ * 두 호출부가 같은 일을 조금씩 다르게 하고 있었고(겹포장을 한쪽만 벗겼다), 둘 다 **재개점을 버리고**
331
+ * 상태만 들고 갔다. 그래서 저장된 재개점을 읽는 쪽이 다 있는데도 재기동은 매번 저널을 처음부터
332
+ * 접었다(실측: 저널 2,960만 줄).
333
+ *
334
+ * 여기서 하는 일 셋:
335
+ * ① 겹포장을 벗긴다 — 옛 형식으로 저장된 값이 한 번은 반드시 나온다
336
+ * ② **그 공장이 아직 그 공장인지** 심판한다 — 아니면 씨앗을 버린다(아래)
337
+ * ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 접어** 끝까지 밀어 둔다
338
+ *
339
+ * ②가 필요한 이유: 재개점은 그때의 보드 위에서 만들어진 것이다. 그 뒤 구조가 바뀌었다면(자리가
340
+ * 빠졌다·설비가 옮겨졌다) 되세운 리듀서는 **지금 없는 자리와 설비를 든다** — 없는 냉장실이 화면에
341
+ * 나오고 그 자리의 판정이 계속 돌아간다. 오류 없이 틀리므로 눈에 띄지 않는다.
342
+ *
343
+ * ③이 필요한 이유: 스냅샷은 체크포인트 주기로 쓰이므로 마지막 주기 이후의 사실은 저널에만 있다.
344
+ * 그것을 빼고 되세우면 그만큼이 조용히 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다.
345
+ * 접는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다.
346
+ */
347
+ static async warmSeedFor(domainId, instanceId) {
348
+ let cached = await this.loadSnapshot(domainId, instanceId).catch(() => null);
349
+ if (!cached?.state)
350
+ return null;
351
+ const seedOf = (c) => (c?.fold?.reducer ? { fold: c.fold } : {});
352
+ if (!cached.fold?.reducer)
353
+ return { revision: cached.revision, state: (0, warm_start_js_1.unwrapState)(cached.state) };
354
+ const tip = await this.tipOf(domainId, instanceId).catch(() => null);
355
+ if (tip && (cached.structureRev ?? null) !== tip.structureRev) {
356
+ (0, log_js_1.twinLog)(`[twin-engine] "${instanceId}": the stored fold seed is from structure ${cached.structureRev} but the factory is now ` +
357
+ `at ${tip.structureRev} — starting without it (the journal is folded from the beginning instead).`);
358
+ return { revision: cached.revision, state: (0, warm_start_js_1.unwrapState)(cached.state) };
359
+ }
360
+ if (tip && (cached.revision ?? 0) < tip.revision) {
361
+ /*
362
+ * 틈을 접는다 — `recover` 가 이 씨앗으로 **꼬리만** 접고, 끝 지점의 새 재개점을 남긴다.
363
+ * 아직 이 트윈의 런타임이 없으므로 `recover` 는 메모리 대신 저널 경로를 탄다(그것이 여기의 전제다).
364
+ */
365
+ const gap = tip.revision - (cached.revision ?? 0);
366
+ await this.recover(domainId, instanceId).catch(err => (0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": could not fold the ${gap} event(s) after the checkpoint — ${err?.message ?? err}`));
367
+ const advanced = await this.loadSnapshot(domainId, instanceId).catch(() => null);
368
+ if (advanced?.state && (advanced.revision ?? 0) > (cached.revision ?? 0)) {
369
+ (0, log_js_1.twinLog)(`[twin-engine] "${instanceId}": folded ${gap} event(s) after the checkpoint → revision ${advanced.revision}.`);
370
+ cached = advanced;
371
+ }
372
+ }
373
+ return { revision: cached.revision, state: (0, warm_start_js_1.unwrapState)(cached.state), ...seedOf(cached) };
374
+ }
275
375
  /** 체크포인트된 최신 스냅샷 로드(없으면 null). getFromCache 는 CacheStore 엔티티를 반환 → 페이로드는 .value. */
276
376
  static async loadSnapshot(domainId, instanceId) {
277
377
  const entry = await cache_service_1.cacheService.getFromCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId });
@@ -488,10 +588,9 @@ class TwinEngine {
488
588
  continue;
489
589
  // 웜스타트: 캐시된 최신 스냅샷 우선(O(1) + attentions/OEE 등 라이브 파생상태 보존).
490
590
  // 없으면 저널 fold-from-0 replay(진실 폴백 — replay 는 라이브 파생상태를 못 담으므로 캐시가 더 충실).
491
- const cached = await this.loadSnapshot(row.domainId, row.instanceId).catch(() => null);
591
+ const cached = await this.warmSeedFor(row.domainId, row.instanceId).catch(() => null);
492
592
  if (cached?.state) {
493
- /* 예전에 겹포장으로 저장된 값이 남아 있을 수 있다 — 읽는 쪽에서도 벗긴다( 번은 반드시 만난다). */
494
- this.recovered[(0, runtime_key_js_1.runtimeKey)(row.domainId, row.instanceId)] = { revision: cached.revision, state: (0, warm_start_js_1.unwrapState)(cached.state) };
593
+ this.recovered[(0, runtime_key_js_1.runtimeKey)(row.domainId, row.instanceId)] = cached;
495
594
  /*
496
595
  * **「웜스타트했다」고 말하지 않는다** — 여기서는 상태를 **찾아 둔 것**뿐이다.
497
596
  *
@@ -1267,6 +1366,43 @@ class TwinEngine {
1267
1366
  * 관측 축(재고·위치·설비)은 **여전히 심지 않는다** — 다음 계측이 정정하고, 심으면 떠난 물건이
1268
1367
  * 되살아난다. 무엇을 넘길지는 `planLiveContinuity` 가 고르고, 어떻게 흡수할지는 커널이 정한다.
1269
1368
  */
1369
+ /*
1370
+ * ── **재개점에서 미러를 되세운다** (2026-08-24) ────────────────────────────
1371
+ *
1372
+ * 이 자리에서 미러는 오랫동안 되찾은 상태를 **버렸다**. 전제는 「진실은 원천에 있으니 다음 계측이
1373
+ * 정정한다」였고 라이브 피드에서는 옳았다. 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그
1374
+ * 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채로 남는다.
1375
+ * 그리고 그 빈 채로 화면이 「이상 없음」을 보였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다.
1376
+ *
1377
+ * 되돌리는 것은 **상태가 아니라 재개점**이다. 상태만 심으면 그 뒤를 이어 접은 결과가 0부터 접은
1378
+ * 결과와 조용히 달라진다(리듀서는 보류된 담김·집계 중인 수량도 든다). 그 동치는 커널 시험이
1379
+ * 증명한다(`observed-checkpoint.test.ts` — 재개점 + 꼬리 == 0부터 접기).
1380
+ *
1381
+ * 씨앗은 **그 공장이 아직 그 공장일 때만** 오고, 마지막 체크포인트 이후의 사실은 이미 접혀 들어
1382
+ * 있다(`warmSeedFor`). 씨앗이 없으면 전과 같이 빈 채로 시작한다 — 지어내지 않는다.
1383
+ */
1384
+ const seed = this.recovered[key]?.fold?.reducer;
1385
+ if (seed) {
1386
+ if (typeof kernel.restoreObserved !== 'function') {
1387
+ (0, log_js_1.twinWarn)(`[twin-engine] "${id}": a fold seed is stored but this kernel cannot take it (no restoreObserved) — ` +
1388
+ 'the mirror starts empty and waits for the source to re-tell everything. Upgrade the kernel.');
1389
+ }
1390
+ else {
1391
+ try {
1392
+ kernel.restoreObserved(seed);
1393
+ const st = kernel.getSnapshot?.();
1394
+ (0, log_js_1.twinLog)(`[twin-engine] mirror "${id}" resumed from the stored fold seed at revision ${this.recovered[key]?.revision} — ` +
1395
+ `items ${st?.items?.length ?? 0} · orders ${st?.orders?.length ?? 0} · tasks ${st?.tasks?.length ?? 0} ` +
1396
+ '(the journal is not folded from the beginning).');
1397
+ if (this.recovered[key]?.fold?.oee)
1398
+ inst.oee?.restore(this.recovered[key].fold.oee);
1399
+ }
1400
+ catch (err) {
1401
+ /* 되세우기가 실패해도 미러는 돌아야 한다 — 다만 무엇을 잃었는지 말한다. */
1402
+ (0, log_js_1.twinWarn)(`[twin-engine] "${id}": could not resume from the stored fold seed — starting empty: ${err?.message ?? err}`);
1403
+ }
1404
+ }
1405
+ }
1270
1406
  this.seedLiveContinuity(domainId, id, kernel);
1271
1407
  delete this.recovered[key];
1272
1408
  /* 라이브 바인딩(data 채널) subdomain 필터용 Domain 1회 해석(sim 과 동일). */
@@ -2040,9 +2176,9 @@ class TwinEngine {
2040
2176
  * 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
2041
2177
  */
2042
2178
  if (!this.recovered[key] && reg.purpose !== 'bench') {
2043
- const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null);
2179
+ const cached = await this.warmSeedFor(domainId, instanceId).catch(() => null);
2044
2180
  if (cached?.state) {
2045
- this.recovered[key] = { revision: cached.revision, state: cached.state };
2181
+ this.recovered[key] = cached;
2046
2182
  }
2047
2183
  else {
2048
2184
  const state = await this.recover(domainId, instanceId).catch(() => null);
@@ -2085,6 +2221,24 @@ class TwinEngine {
2085
2221
  const reg = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } });
2086
2222
  if (!reg?.model)
2087
2223
  throw new Error(`instance "${instanceId}" not provisioned (no model)`);
2224
+ /*
2225
+ * ── **미러도 씨앗을 여기서 확보한다** (2026-08-24) ────────────────────────
2226
+ *
2227
+ * 이 줄이 없어서 미러는 저장된 체크포인트를 **한 번도 읽지 않았다.** `startLive` 는 동기라 스스로
2228
+ * 캐시를 읽을 수 없고 `recovered` 에 담겨 있기를 기대하는데, 그것을 담는 곳은 `bootstrap` 과
2229
+ * `start` 뿐이었다 — 그리고 미러의 실제 기동 경로는 **여기**다. 그래서 「읽는 쪽·쓰는 쪽이 다
2230
+ * 있는데 아무 일도 일어나지 않는」 상태가 됐다.
2231
+ *
2232
+ * 이것은 `start` 가 이미 배운 교훈과 같은 자리다: 부팅 순서에 기대면 어떤 날은 상태가 살아나고
2233
+ * 어떤 날은 조용히 빈 채로 뜬다 — **재현되지 않는 결함이 가장 나쁘다.** 그래서 순서에 기대지 않고
2234
+ * 이 자리에서 확보한다(이미 담겨 있으면 그것을 쓴다).
2235
+ */
2236
+ const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
2237
+ if (!this.recovered[key]) {
2238
+ const seed = await this.warmSeedFor(domainId, instanceId).catch(() => null);
2239
+ if (seed?.state)
2240
+ this.recovered[key] = seed;
2241
+ }
2088
2242
  return this.startLive(instanceId, domainId, reg.kind, reg.model);
2089
2243
  }
2090
2244
  if (mode === 'resume') {
@@ -3416,6 +3570,45 @@ class TwinEngine {
3416
3570
  if (ledger)
3417
3571
  (0, ingest_health_js_1.clearReadFailure)(ledger);
3418
3572
  }
3573
+ /**
3574
+ * **읽었는데 창을 넘길 수 없다**를 적는다 — 위와 **조치가 반대인** 사실이다(§`recordCursorStall`).
3575
+ *
3576
+ * 이 문이 없던 동안 이 사실이 `recordIngestReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지
3577
+ * 못한다」고 말했는데 원본은 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다(한 시각에 한
3578
+ * 페이지보다 많은 행). 사람은 원본을 의심하고 기다리는데 **기다림으로는 영원히 풀리지 않는다.**
3579
+ *
3580
+ * 조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.
3581
+ */
3582
+ static recordIngestCursorStall(domainId, instanceId, reason, nowMs = Date.now(), stream) {
3583
+ const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
3584
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = (0, ingest_health_js_1.newIngestLedger)());
3585
+ (0, ingest_health_js_1.recordCursorStall)(ledger, reason, nowMs, stream);
3586
+ }
3587
+ /**
3588
+ * **원본에 있는데 세우지 않은 것을 적는다** — 이유와 수(§`IngestLedger.withheld`).
3589
+ *
3590
+ * 어댑터가 주기마다 불러도 된다: 장부가 이유로 묶어 **마지막 수로 덮는다**(누적하지 않는다).
3591
+ * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 조용히 그치면 낡은 수가 남는다.
3592
+ */
3593
+ static recordIngestWithheld(domainId, instanceId, reason, count, nowMs = Date.now()) {
3594
+ const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
3595
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = (0, ingest_health_js_1.newIngestLedger)());
3596
+ if (!(count > 0)) {
3597
+ if (ledger.withheld) {
3598
+ ledger.withheld = ledger.withheld.filter(w => w.reason !== reason);
3599
+ if (!ledger.withheld.length)
3600
+ delete ledger.withheld;
3601
+ }
3602
+ return;
3603
+ }
3604
+ (0, ingest_health_js_1.recordWithheld)(ledger, reason, count, nowMs);
3605
+ }
3606
+ /** 창을 넘겼다 — 정체 기록을 지운다(풀린 정체가 화면에 남아 있으면 그것도 거짓이다). */
3607
+ static clearIngestCursorStall(domainId, instanceId) {
3608
+ const ledger = this.ingestLedgers[(0, runtime_key_js_1.runtimeKey)(domainId, instanceId)];
3609
+ if (ledger)
3610
+ (0, ingest_health_js_1.clearCursorStall)(ledger);
3611
+ }
3419
3612
  /**
3420
3613
  * 저널에 **적은 것**을 같은 장부에 남긴다 — 유입과 같은 10분 창에.
3421
3614
  *