@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
@@ -148,6 +148,56 @@ export interface IngestLedger {
148
148
  /** 어느 흐름에서 났나(어댑터가 말해 주면). 원본마다 흐름이 다르므로 열려 있다. */
149
149
  stream?: string
150
150
  }
151
+ /**
152
+ * **읽었는데 창을 넘길 수 없다** (2026-08-24) — 위와 **다른 사실**이다.
153
+ *
154
+ * ── 왜 갈랐나 ───────────────────────────────────────────────────────────────
155
+ * 이 칸을 만들기 전에는 두 사실이 같은 이름으로 나갔다.
156
+ *
157
+ * readFailure 원본에 닿지 못했다 → **기다리면 풀린다**
158
+ * cursorStall 읽었는데 커서가 못 넘어간다 → **기다려도 안 풀린다**
159
+ *
160
+ * 둘째는 읽기가 성공한 실패다: 한 시각에 한 페이지보다 많은 행이 몰려 커서를 그 시각 밖으로 옮길 수
161
+ * 없다. 화면이 「원본에 닿지 못한다」고 말하면 사람은 원본을 의심하고 기다리는데, 필요한 조치는
162
+ * 페이지를 키우거나 같은 시각 안에서 순서를 정하는 것이다. **조치가 반대인 두 사실을 한 이름으로
163
+ * 부르면 그 이름은 정보가 아니라 오해다.**
164
+ *
165
+ * 모양은 위와 같게 둔다 — 「몇 번」과 「언제부터」가 여기서도 같은 값을 한다(한 번의 밀림과 사흘째
166
+ * 못 넘어가는 것은 다르다). 넘어가면 지운다.
167
+ */
168
+ /**
169
+ * **원본이 말했는데 트윈에 세우지 않은 것** — 이유별로 센다.
170
+ *
171
+ * ── 왜 이 자리가 필요한가 (2026-08-24) ─────────────────────────────────────
172
+ * 커넥터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**: 입자가 맞지 않거나, 받으면 상태가
173
+ * 거짓이 되거나, 커널 어휘로 옮길 수 없다. 그때 커넥터는 경고를 낸다 — **그런데 그 경고가 프로비저닝
174
+ * 화면에서 한 번 스쳐 지나갈 뿐이었다.** 그 뒤로는 어디에서도 볼 수 없었다.
175
+ *
176
+ * 그래서 사용자가 「원본보다 계획이 적다」를 물으면 **답이 어디에도 없다.** 그 사실은 한 번의
177
+ * 사건이 아니라 **상태의 성질**이다 — 세우지 않은 것은 지금도 트윈에 없다.
178
+ *
179
+ * ── `unhandled` 의 형제다 ──────────────────────────────────────────────────
180
+ * `unhandled` 리듀서가 **받았는데** 담을 자리를 몰랐다
181
+ * 여기 커넥터가 **보지만 일부러 보내지 않았다** — 이유와 함께
182
+ *
183
+ * 둘 다 「사실이 조용히 사라지지 않게」 세는 것이고, 이유가 다르므로 자리도 다르다.
184
+ *
185
+ * 이유를 **문자열로 받는다**: 무엇을 왜 안 받는지는 원본마다 다르고, 계약이 그 목록을 닫으면 새 원본을
186
+ * 붙일 때마다 계약을 고치게 된다(흐름 열쇠와 같은 규율).
187
+ */
188
+ withheld?: { reason: string; count: number; lastAtMs: number }[]
189
+ cursorStall?: {
190
+ /** 연속 정체 수 — 넘어가면 0 으로 돌아간다. */
191
+ consecutive: number
192
+ /** 이 정체가 **처음** 시작된 시각(ms). */
193
+ sinceMs: number
194
+ /** 마지막 정체 시각(ms). */
195
+ lastAtMs: number
196
+ /** 사유 — 사람이 읽는 한 줄(어느 시각에서 막혔나까지 어댑터가 말해 주면 좋다). */
197
+ reason: string
198
+ /** 어느 흐름인가. 밀도가 높은 표는 원본마다 다르므로 이 이름이 조치의 절반이다. */
199
+ stream?: string
200
+ }
151
201
  }
152
202
 
153
203
  /**
@@ -182,6 +232,50 @@ export function clearReadFailure(ledger: IngestLedger): void {
182
232
  if (ledger.readFailure) delete ledger.readFailure
183
233
  }
184
234
 
235
+ /**
236
+ * 커서 정체를 장부에 적는다 — **닿지 못한 것과 가른다.**
237
+ *
238
+ * `recordReadFailure` 와 같은 모양이지만 **다른 칸**이다. 한 주기가 두 이유로 실패할 수는 없으므로
239
+ * (먼저 닿아야 읽는다) 둘이 동시에 서지 않는다 — 그래서 화면은 둘 중 하나만 보게 되고, 그 하나가
240
+ * 조치를 정한다.
241
+ */
242
+ export function recordCursorStall(ledger: IngestLedger, reason: string, nowMs: number, stream?: string): void {
243
+ const prev = ledger.cursorStall
244
+ ledger.cursorStall = {
245
+ consecutive: (prev?.consecutive ?? 0) + 1,
246
+ sinceMs: prev?.sinceMs ?? nowMs,
247
+ lastAtMs: nowMs,
248
+ reason,
249
+ ...(stream ? { stream } : {})
250
+ }
251
+ }
252
+
253
+ /**
254
+ * **세우지 않은 것을 적는다** — 이유별 누적.
255
+ *
256
+ * 주기마다 다시 알려도 시끄러워지지 않게 **이유로 묶어 센다**(줄이 늘지 않는다). 그리고 마지막 시각을
257
+ * 함께 든다 — 「지금도 그런가」와 「한때 그랬나」는 다른 사실이고, 시각 없이는 구별할 수 없다.
258
+ *
259
+ * 이유가 같고 수가 달라지면 **마지막 수로 덮는다**: 이것은 누적 사건이 아니라 **지금 세우지 않은 것의
260
+ * 수**다. 주기마다 8건이면 8이고, 더해서 800이 되면 거짓이다.
261
+ */
262
+ export function recordWithheld(ledger: IngestLedger, reason: string, count: number, nowMs: number): void {
263
+ if (!reason || !(count > 0)) return
264
+ const list = (ledger.withheld ??= [])
265
+ const at = list.find(w => w.reason === reason)
266
+ if (at) {
267
+ at.count = count
268
+ at.lastAtMs = nowMs
269
+ return
270
+ }
271
+ list.push({ reason, count, lastAtMs: nowMs })
272
+ }
273
+
274
+ /** 창을 넘겼으면 지운다 — 풀린 정체가 화면에 남아 있으면 그것도 거짓이다. */
275
+ export function clearCursorStall(ledger: IngestLedger): void {
276
+ if (ledger.cursorStall) delete ledger.cursorStall
277
+ }
278
+
185
279
  /** 창 길이 — 10분. 부하 계기판(10초)과 다른 값이라 이름을 따로 둔다(같은 이름이면 섞인다). */
186
280
  export const INGEST_WINDOW_MS = 600_000
187
281
 
@@ -562,6 +656,8 @@ export interface IngestHealthView {
562
656
  /**
563
657
  * **원본에 닿지 못하고 있다** — 없으면 닿고 있다는 뜻이다(§`IngestLedger.readFailure`).
564
658
  *
659
+ * 커서 정체(`cursorStall`)와 **섞이지 않는다**: 이쪽은 기다리면 풀리고 그쪽은 기다려도 안 풀린다.
660
+ *
565
661
  * 「유입 없음」과 **다른 판정**이다: 유입 없음은 원본이 조용한 것일 수 있고, 이것은 우리가 묻지도
566
662
  * 못한 것이다. 화면은 두 문장을 다르게 말해야 한다 — 실 원본이 끊겼을 때 사용자가 원인을 찾을 수
567
663
  * 있는지가 그 차이다.
@@ -655,6 +751,32 @@ export function ingestHealth(
655
751
  ...(ledger.readFailure.stream ? { stream: ledger.readFailure.stream } : {})
656
752
  }
657
753
  }
754
+ : {}),
755
+ /* 커서 정체도 **이 갈래에서** 내보낸다 — 유입이 없는 장부에서도 정체는 실재한다(읽었으나 못
756
+ 넘겼으면 유입이 0이다). 여기서 떨어뜨리면 바로 그 상황에서 조용해진다. */
757
+ ...(ledger?.cursorStall
758
+ ? {
759
+ cursorStall: {
760
+ consecutive: ledger.cursorStall.consecutive,
761
+ since: new Date(ledger.cursorStall.sinceMs).toISOString(),
762
+ lastAt: new Date(ledger.cursorStall.lastAtMs).toISOString(),
763
+ reason: ledger.cursorStall.reason,
764
+ ...(ledger.cursorStall.stream ? { stream: ledger.cursorStall.stream } : {})
765
+ }
766
+ }
767
+ : {}),
768
+ /*
769
+ * **세우지 않은 것도 낸다 — 양쪽 갈래에서.** 유입이 한 건도 없는 장부에서도 이 사실은 실재한다
770
+ * (원본이 말하는데 전부 안 받는 경우가 있다). 여기서 떨어뜨리면 바로 그 상황에서 조용해진다.
771
+ */
772
+ ...(ledger?.withheld?.length
773
+ ? {
774
+ withheld: ledger.withheld.map(w => ({
775
+ reason: w.reason,
776
+ count: w.count,
777
+ lastAt: new Date(w.lastAtMs).toISOString()
778
+ }))
779
+ }
658
780
  : {})
659
781
  }
660
782
  }
@@ -667,6 +789,16 @@ export function ingestHealth(
667
789
  recent: recentRaw ? toWindowView(recentRaw, lastAnyMs) : null,
668
790
  trend: ledger.trend.map(w => toWindowView(w, lastAnyMs)),
669
791
  trendDropped: ledger.trendDropped,
792
+ /* 세우지 않은 것 — 양쪽 갈래에서 같은 모양으로 낸다(§`IngestLedger.withheld`). */
793
+ ...(ledger.withheld?.length
794
+ ? {
795
+ withheld: ledger.withheld.map(w => ({
796
+ reason: w.reason,
797
+ count: w.count,
798
+ lastAt: new Date(w.lastAtMs).toISOString()
799
+ }))
800
+ }
801
+ : {}),
670
802
  total: {
671
803
  offered: ledger.offered,
672
804
  accepted,
@@ -696,6 +828,17 @@ export function ingestHealth(
696
828
  ...(ledger.readFailure.stream ? { stream: ledger.readFailure.stream } : {})
697
829
  }
698
830
  }
831
+ : {}),
832
+ ...(ledger.cursorStall
833
+ ? {
834
+ cursorStall: {
835
+ consecutive: ledger.cursorStall.consecutive,
836
+ since: new Date(ledger.cursorStall.sinceMs).toISOString(),
837
+ lastAt: new Date(ledger.cursorStall.lastAtMs).toISOString(),
838
+ reason: ledger.cursorStall.reason,
839
+ ...(ledger.cursorStall.stream ? { stream: ledger.cursorStall.stream } : {})
840
+ }
841
+ }
699
842
  : {})
700
843
  }
701
844
  }
@@ -58,7 +58,10 @@ import {
58
58
  newIngestLedger,
59
59
  recordIngest,
60
60
  recordReadFailure,
61
+ clearCursorStall,
61
62
  clearReadFailure,
63
+ recordCursorStall,
64
+ recordWithheld,
62
65
  rollIngestWindow,
63
66
  /* 별칭 — 같은 이름의 정적 메서드와 헷갈리지 않게(그 메서드가 이것을 부른다). */
64
67
  recordJournalWrite as recordLedgerWrite,
@@ -396,10 +399,68 @@ export class TwinEngine {
396
399
  */
397
400
  const state = unwrapState(this.snapshot(domainId, instanceId))
398
401
  if (!state) return
402
+ /*
403
+ * ── **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** (2026-08-24 실측) ────
404
+ *
405
+ * 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를
406
+ * 버린다 — 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그
407
+ * 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다.
408
+ *
409
+ * 그 상태에서 이 함수가 돌면 **빈 상태를 좋은 스냅샷 위에 덮는다.** 그래서 손실이 영구화됐다:
410
+ *
411
+ * 저장돼 있던 것 rev 221,884 · nowTime 2026-04-15 · items 741 · orders 2,780 · tasks 6,353
412
+ * 덮으려던 것 같은 키 · nowTime 2026-01-01 · 전부 0
413
+ *
414
+ * 리비전으로는 막을 수 없다 — 저널 줄 번호는 관측이 없어도 계속 자란다. 막는 기준은 **「들은 것이
415
+ * 있나」**다. 유입이 한 건도 없었다면 이 빈 상태는 **원본이 「비었다」고 말한 것이 아니라 우리가
416
+ * 아무것도 못 들은 것**이고, 그 둘을 같은 값으로 적으면 「모름」이 「없음」이 된다.
417
+ *
418
+ * 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다.
419
+ *
420
+ * 그리고 **거절을 말한다**: 조용히 거절하면 왜 체크포인트가 낡아 가는지 아무도 모른다.
421
+ */
422
+ if (!(inst.metrics?.ingestedTotal > 0)) {
423
+ const observed = (s: any) => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
424
+ if (observed(state) === 0) {
425
+ const prev = await this.loadSnapshot(domainId, instanceId).catch(() => null)
426
+ const had = observed(prev?.state)
427
+ if (had > 0) {
428
+ twinWarn(
429
+ `[twin-engine] "${instanceId}": checkpoint refused — this twin has heard nothing since start and its live ` +
430
+ `state is empty, while the stored snapshot holds ${had} observed fact(s) (revision ${prev?.revision}). ` +
431
+ 'Writing the empty state would destroy the only recoverable copy. The journal still holds the truth.'
432
+ )
433
+ return
434
+ }
435
+ }
436
+ }
399
437
  const revision = inst.revision ?? state.revision ?? 0
400
438
  /* 구조 리비전도 함께 — 읽는 쪽이 "이 상태가 지금의 공장인가" 를 가릴 수 있어야 한다. */
401
439
  const { structureRev } = await this.tipOf(domainId, instanceId).catch(() => ({ structureRev: null }) as any)
402
- await cacheService.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision, state, structureRev }, this.SNAPSHOT_TTL_S)
440
+ /*
441
+ * ── **이어 접을 씨앗을 함께 적는다** (2026-08-24) ──────────────────────────
442
+ *
443
+ * 이 함수는 오랫동안 `{revision, state, structureRev}` 만 적었다. 그래서 **재개점을 읽는 쪽은 다
444
+ * 있는데 쓰는 쪽이 없었다**: 조회 경로(`recover`)는 `fold` 가 있으면 꼬리만 접고, 없으면 저널을
445
+ * 0부터 접는다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다 —
446
+ * 그래서 재기동·조회마다 처음부터 다시 접었다. 규모 기준(엔티티 10만·품목 100만)에서 이것은
447
+ * 느린 것이 아니라 **못 하는 것**이다.
448
+ *
449
+ * 씨앗은 상태가 대신할 수 없다: 리듀서는 소비처가 보는 값 말고도 든다(부모를 기다리는 담김·집계
450
+ * 중인 수량·담을 줄 몰라 세어 둔 사건). 상태만 되돌리고 뒤를 접으면 0부터 접은 결과와 **조용히**
451
+ * 달라진다. 그 동치는 커널 시험이 증명한다(`observed-checkpoint.test.ts`).
452
+ *
453
+ * 관측 구동이 아니면 씨앗이 없다 — 시뮬은 리듀서를 갖지 않고, 그 상태의 권위는 커널 자신이다.
454
+ * 그때는 `fold` 를 **넣지 않는다**(빈 씨앗을 넣으면 읽는 쪽이 「이어 접을 수 있다」고 잘못 본다).
455
+ */
456
+ const reducer: ReducerCheckpoint | undefined = inst.kernel?.observedCheckpoint?.()
457
+ const fold = reducer && inst.oee ? { reducer, oee: inst.oee.serialize() } : undefined
458
+ await cacheService.setInCache(
459
+ this.SNAPSHOT_CACHE_ID,
460
+ { domainId, instanceId },
461
+ { revision, state, structureRev, ...(fold ? { fold } : {}) },
462
+ this.SNAPSHOT_TTL_S
463
+ )
403
464
  }
404
465
 
405
466
  /**
@@ -410,6 +471,64 @@ export class TwinEngine {
410
471
  */
411
472
  private static readonly FOLD_NOTE = 'reducer + oee checkpoint — the seed for folding only the tail'
412
473
 
474
+ /**
475
+ * 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 접을 재개점.
476
+ *
477
+ * ── 왜 이 자리가 생겼나 (2026-08-24) ──────────────────────────────────────
478
+ * 두 호출부가 같은 일을 조금씩 다르게 하고 있었고(겹포장을 한쪽만 벗겼다), 둘 다 **재개점을 버리고**
479
+ * 상태만 들고 갔다. 그래서 저장된 재개점을 읽는 쪽이 다 있는데도 재기동은 매번 저널을 처음부터
480
+ * 접었다(실측: 저널 2,960만 줄).
481
+ *
482
+ * 여기서 하는 일 셋:
483
+ * ① 겹포장을 벗긴다 — 옛 형식으로 저장된 값이 한 번은 반드시 나온다
484
+ * ② **그 공장이 아직 그 공장인지** 심판한다 — 아니면 씨앗을 버린다(아래)
485
+ * ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 접어** 끝까지 밀어 둔다
486
+ *
487
+ * ②가 필요한 이유: 재개점은 그때의 보드 위에서 만들어진 것이다. 그 뒤 구조가 바뀌었다면(자리가
488
+ * 빠졌다·설비가 옮겨졌다) 되세운 리듀서는 **지금 없는 자리와 설비를 든다** — 없는 냉장실이 화면에
489
+ * 나오고 그 자리의 판정이 계속 돌아간다. 오류 없이 틀리므로 눈에 띄지 않는다.
490
+ *
491
+ * ③이 필요한 이유: 스냅샷은 체크포인트 주기로 쓰이므로 마지막 주기 이후의 사실은 저널에만 있다.
492
+ * 그것을 빼고 되세우면 그만큼이 조용히 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다.
493
+ * 접는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다.
494
+ */
495
+ private static async warmSeedFor(
496
+ domainId: string,
497
+ instanceId: string
498
+ ): Promise<{ revision: number; state: any; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> {
499
+ let cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
500
+ if (!cached?.state) return null
501
+
502
+ const seedOf = (c: typeof cached) => (c?.fold?.reducer ? { fold: c.fold } : {})
503
+ if (!cached.fold?.reducer) return { revision: cached.revision, state: unwrapState(cached.state) }
504
+
505
+ const tip = await this.tipOf(domainId, instanceId).catch(() => null)
506
+ if (tip && (cached.structureRev ?? null) !== tip.structureRev) {
507
+ twinLog(
508
+ `[twin-engine] "${instanceId}": the stored fold seed is from structure ${cached.structureRev} but the factory is now ` +
509
+ `at ${tip.structureRev} — starting without it (the journal is folded from the beginning instead).`
510
+ )
511
+ return { revision: cached.revision, state: unwrapState(cached.state) }
512
+ }
513
+
514
+ if (tip && (cached.revision ?? 0) < tip.revision) {
515
+ /*
516
+ * 틈을 접는다 — `recover` 가 이 씨앗으로 **꼬리만** 접고, 끝 지점의 새 재개점을 남긴다.
517
+ * 아직 이 트윈의 런타임이 없으므로 `recover` 는 메모리 대신 저널 경로를 탄다(그것이 여기의 전제다).
518
+ */
519
+ const gap = tip.revision - (cached.revision ?? 0)
520
+ await this.recover(domainId, instanceId).catch(err =>
521
+ twinWarn(`[twin-engine] "${instanceId}": could not fold the ${gap} event(s) after the checkpoint — ${err?.message ?? err}`)
522
+ )
523
+ const advanced = await this.loadSnapshot(domainId, instanceId).catch(() => null)
524
+ if (advanced?.state && (advanced.revision ?? 0) > (cached.revision ?? 0)) {
525
+ twinLog(`[twin-engine] "${instanceId}": folded ${gap} event(s) after the checkpoint → revision ${advanced.revision}.`)
526
+ cached = advanced
527
+ }
528
+ }
529
+ return { revision: cached.revision, state: unwrapState(cached.state), ...seedOf(cached) }
530
+ }
531
+
413
532
  /** 체크포인트된 최신 스냅샷 로드(없으면 null). getFromCache 는 CacheStore 엔티티를 반환 → 페이로드는 .value. */
414
533
  static async loadSnapshot(
415
534
  domainId: string,
@@ -649,10 +768,9 @@ export class TwinEngine {
649
768
  if (!row.domainId || !row.instanceId) continue
650
769
  // 웜스타트: 캐시된 최신 스냅샷 우선(O(1) + attentions/OEE 등 라이브 파생상태 보존).
651
770
  // 없으면 저널 fold-from-0 replay(진실 폴백 — replay 는 라이브 파생상태를 못 담으므로 캐시가 더 충실).
652
- const cached = await this.loadSnapshot(row.domainId, row.instanceId).catch(() => null)
771
+ const cached = await this.warmSeedFor(row.domainId, row.instanceId).catch(() => null)
653
772
  if (cached?.state) {
654
- /* 예전에 겹포장으로 저장된 값이 남아 있을 수 있다 — 읽는 쪽에서도 벗긴다( 번은 반드시 만난다). */
655
- this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: cached.revision, state: unwrapState(cached.state) }
773
+ this.recovered[runtimeKey(row.domainId, row.instanceId)] = cached
656
774
  /*
657
775
  * **「웜스타트했다」고 말하지 않는다** — 여기서는 상태를 **찾아 둔 것**뿐이다.
658
776
  *
@@ -1470,6 +1588,44 @@ export class TwinEngine {
1470
1588
  * 관측 축(재고·위치·설비)은 **여전히 심지 않는다** — 다음 계측이 정정하고, 심으면 떠난 물건이
1471
1589
  * 되살아난다. 무엇을 넘길지는 `planLiveContinuity` 가 고르고, 어떻게 흡수할지는 커널이 정한다.
1472
1590
  */
1591
+ /*
1592
+ * ── **재개점에서 미러를 되세운다** (2026-08-24) ────────────────────────────
1593
+ *
1594
+ * 이 자리에서 미러는 오랫동안 되찾은 상태를 **버렸다**. 전제는 「진실은 원천에 있으니 다음 계측이
1595
+ * 정정한다」였고 라이브 피드에서는 옳았다. 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그
1596
+ * 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채로 남는다.
1597
+ * 그리고 그 빈 채로 화면이 「이상 없음」을 보였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다.
1598
+ *
1599
+ * 되돌리는 것은 **상태가 아니라 재개점**이다. 상태만 심으면 그 뒤를 이어 접은 결과가 0부터 접은
1600
+ * 결과와 조용히 달라진다(리듀서는 보류된 담김·집계 중인 수량도 든다). 그 동치는 커널 시험이
1601
+ * 증명한다(`observed-checkpoint.test.ts` — 재개점 + 꼬리 == 0부터 접기).
1602
+ *
1603
+ * 씨앗은 **그 공장이 아직 그 공장일 때만** 오고, 마지막 체크포인트 이후의 사실은 이미 접혀 들어
1604
+ * 있다(`warmSeedFor`). 씨앗이 없으면 전과 같이 빈 채로 시작한다 — 지어내지 않는다.
1605
+ */
1606
+ const seed = this.recovered[key]?.fold?.reducer
1607
+ if (seed) {
1608
+ if (typeof kernel.restoreObserved !== 'function') {
1609
+ twinWarn(
1610
+ `[twin-engine] "${id}": a fold seed is stored but this kernel cannot take it (no restoreObserved) — ` +
1611
+ 'the mirror starts empty and waits for the source to re-tell everything. Upgrade the kernel.'
1612
+ )
1613
+ } else {
1614
+ try {
1615
+ kernel.restoreObserved(seed)
1616
+ const st = kernel.getSnapshot?.()
1617
+ twinLog(
1618
+ `[twin-engine] mirror "${id}" resumed from the stored fold seed at revision ${this.recovered[key]?.revision} — ` +
1619
+ `items ${st?.items?.length ?? 0} · orders ${st?.orders?.length ?? 0} · tasks ${st?.tasks?.length ?? 0} ` +
1620
+ '(the journal is not folded from the beginning).'
1621
+ )
1622
+ if (this.recovered[key]?.fold?.oee) inst.oee?.restore(this.recovered[key].fold.oee)
1623
+ } catch (err: any) {
1624
+ /* 되세우기가 실패해도 미러는 돌아야 한다 — 다만 무엇을 잃었는지 말한다. */
1625
+ twinWarn(`[twin-engine] "${id}": could not resume from the stored fold seed — starting empty: ${err?.message ?? err}`)
1626
+ }
1627
+ }
1628
+ }
1473
1629
  this.seedLiveContinuity(domainId, id, kernel)
1474
1630
  delete this.recovered[key]
1475
1631
  /* 라이브 바인딩(data 채널) subdomain 필터용 Domain 1회 해석(sim 과 동일). */
@@ -2281,9 +2437,9 @@ export class TwinEngine {
2281
2437
  * 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
2282
2438
  */
2283
2439
  if (!this.recovered[key] && reg.purpose !== 'bench') {
2284
- const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
2440
+ const cached = await this.warmSeedFor(domainId, instanceId).catch(() => null)
2285
2441
  if (cached?.state) {
2286
- this.recovered[key] = { revision: cached.revision, state: cached.state }
2442
+ this.recovered[key] = cached
2287
2443
  } else {
2288
2444
  const state = await this.recover(domainId, instanceId).catch(() => null)
2289
2445
  if (state) this.recovered[key] = { revision: state.revision, state }
@@ -2332,6 +2488,23 @@ export class TwinEngine {
2332
2488
  if (mode === 'resync') {
2333
2489
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
2334
2490
  if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
2491
+ /*
2492
+ * ── **미러도 씨앗을 여기서 확보한다** (2026-08-24) ────────────────────────
2493
+ *
2494
+ * 이 줄이 없어서 미러는 저장된 체크포인트를 **한 번도 읽지 않았다.** `startLive` 는 동기라 스스로
2495
+ * 캐시를 읽을 수 없고 `recovered` 에 담겨 있기를 기대하는데, 그것을 담는 곳은 `bootstrap` 과
2496
+ * `start` 뿐이었다 — 그리고 미러의 실제 기동 경로는 **여기**다. 그래서 「읽는 쪽·쓰는 쪽이 다
2497
+ * 있는데 아무 일도 일어나지 않는」 상태가 됐다.
2498
+ *
2499
+ * 이것은 `start` 가 이미 배운 교훈과 같은 자리다: 부팅 순서에 기대면 어떤 날은 상태가 살아나고
2500
+ * 어떤 날은 조용히 빈 채로 뜬다 — **재현되지 않는 결함이 가장 나쁘다.** 그래서 순서에 기대지 않고
2501
+ * 이 자리에서 확보한다(이미 담겨 있으면 그것을 쓴다).
2502
+ */
2503
+ const key = runtimeKey(domainId, instanceId)
2504
+ if (!this.recovered[key]) {
2505
+ const seed = await this.warmSeedFor(domainId, instanceId).catch(() => null)
2506
+ if (seed?.state) this.recovered[key] = seed
2507
+ }
2335
2508
  return this.startLive(instanceId, domainId, reg.kind, reg.model as TwinModelDef)
2336
2509
  }
2337
2510
  if (mode === 'resume') {
@@ -3732,6 +3905,46 @@ export class TwinEngine {
3732
3905
  if (ledger) clearReadFailure(ledger)
3733
3906
  }
3734
3907
 
3908
+ /**
3909
+ * **읽었는데 창을 넘길 수 없다**를 적는다 — 위와 **조치가 반대인** 사실이다(§`recordCursorStall`).
3910
+ *
3911
+ * 이 문이 없던 동안 이 사실이 `recordIngestReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지
3912
+ * 못한다」고 말했는데 원본은 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다(한 시각에 한
3913
+ * 페이지보다 많은 행). 사람은 원본을 의심하고 기다리는데 **기다림으로는 영원히 풀리지 않는다.**
3914
+ *
3915
+ * 조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.
3916
+ */
3917
+ static recordIngestCursorStall(domainId: string, instanceId: string, reason: string, nowMs = Date.now(), stream?: string): void {
3918
+ const key = runtimeKey(domainId, instanceId)
3919
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3920
+ recordCursorStall(ledger, reason, nowMs, stream)
3921
+ }
3922
+
3923
+ /**
3924
+ * **원본에 있는데 세우지 않은 것을 적는다** — 이유와 수(§`IngestLedger.withheld`).
3925
+ *
3926
+ * 어댑터가 주기마다 불러도 된다: 장부가 이유로 묶어 **마지막 수로 덮는다**(누적하지 않는다).
3927
+ * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 조용히 그치면 낡은 수가 남는다.
3928
+ */
3929
+ static recordIngestWithheld(domainId: string, instanceId: string, reason: string, count: number, nowMs = Date.now()): void {
3930
+ const key = runtimeKey(domainId, instanceId)
3931
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3932
+ if (!(count > 0)) {
3933
+ if (ledger.withheld) {
3934
+ ledger.withheld = ledger.withheld.filter(w => w.reason !== reason)
3935
+ if (!ledger.withheld.length) delete ledger.withheld
3936
+ }
3937
+ return
3938
+ }
3939
+ recordWithheld(ledger, reason, count, nowMs)
3940
+ }
3941
+
3942
+ /** 창을 넘겼다 — 정체 기록을 지운다(풀린 정체가 화면에 남아 있으면 그것도 거짓이다). */
3943
+ static clearIngestCursorStall(domainId: string, instanceId: string): void {
3944
+ const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)]
3945
+ if (ledger) clearCursorStall(ledger)
3946
+ }
3947
+
3735
3948
  /**
3736
3949
  * 저널에 **적은 것**을 같은 장부에 남긴다 — 유입과 같은 10분 창에.
3737
3950
  *
@@ -154,6 +154,51 @@ export interface LiveFeedContinuity {
154
154
  * 로 알린다. 그 둘을 섞으면 조용한 원본이 끊긴 원본으로 보인다(고치려던 것의 반대 방향으로 틀린다).
155
155
  */
156
156
  onReadFailure?: (info: { reason: string; stream?: string }) => void
157
+ /**
158
+ * **읽었는데 창을 넘길 수 없다** — 어댑터가 커서 정체를 알린다 (2026-08-24).
159
+ *
160
+ * ── 왜 `onReadFailure` 와 갈라야 하나 ───────────────────────────────────────
161
+ * 이 문을 만들기 전에는 두 사실이 **같은 이름으로** 나갔다.
162
+ *
163
+ * 원본에 닿지 못했다 접속 실패·시간 초과·형식 오류 → 기다리면 풀린다
164
+ * 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행이 몰려 있다 → **기다려도 안 풀린다**
165
+ *
166
+ * 둘째는 **읽기가 성공한 실패**다. 원본은 답했고, 그 답의 모양이 커서를 이긴 것이다. 그런데 화면이
167
+ * 「원본에 닿지 못한다」고 말하면 사람을 반대 방향으로 보낸다 — 원본을 의심하고 기다린다. 실제로
168
+ * 필요한 조치는 **페이지를 키우거나 같은 시각 안에서 순서를 정하는 것**이고, 기다림으로는 영원히
169
+ * 풀리지 않는다.
170
+ *
171
+ * 조치가 반대인 두 사실을 한 이름으로 부르면, 그 이름은 정보가 아니라 오해다.
172
+ *
173
+ * ── 무엇을 알리나 ───────────────────────────────────────────────────────────
174
+ * 그 주기를 포기했을 때 부른다(`onReadFailure` 와 같은 규율). 어느 흐름인지 알면 함께 준다 —
175
+ * 밀도가 높은 표는 원본마다 다르므로 그 이름이 조치의 절반이다.
176
+ *
177
+ * **닿지 못한 것과 섞어 부르지 않는다.** 하나의 주기가 두 이유로 실패할 수는 없다(먼저 닿아야 읽는다).
178
+ */
179
+ onCursorStall?: (info: { reason: string; stream?: string }) => void
180
+ /**
181
+ * **원본에 있는데 세우지 않은 것을 알린다** — 이유와 수.
182
+ *
183
+ * ── 왜 이 통로가 필요한가 (2026-08-24) ─────────────────────────────────────
184
+ * 어댑터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**(입자가 안 맞는다 · 받으면 상태가
185
+ * 거짓이 된다 · 커널 어휘로 옮길 수 없다). 그때 어댑터는 경고를 냈는데, 그 경고가 **프로비저닝
186
+ * 화면에서 한 번 스쳐 지나갈 뿐**이었다 — 그 뒤로는 어디에서도 볼 수 없었다.
187
+ *
188
+ * 그래서 사용자가 「원본보다 이것이 적다」를 물으면 답이 어디에도 없었다. 그 사실은 한 번의 사건이
189
+ * 아니라 **상태의 성질**이다: 세우지 않은 것은 **지금도** 트윈에 없다.
190
+ *
191
+ * **주기마다 불러도 된다** — 장부가 이유로 묶어 세고 마지막 수로 덮는다(누적하지 않는다). 그래서
192
+ * 「지금 세우지 않은 것이 8건」이 8로 남고 800으로 부풀지 않는다.
193
+ *
194
+ * **풀리면 부르지 않는 것으로 끝나지 않는다** — 이유가 사라졌으면 `count: 0` 으로 부르는 것이 아니라
195
+ * 그 이유를 더 이상 보내지 않으면 된다… 가 **아니다.** 장부는 마지막 값을 들고 있으므로, 풀린 것을
196
+ * 알리려면 그 이유로 `count: 0` 을 한 번 보내라(그때 줄이 사라진다). 조용히 그치면 낡은 수가 남는다.
197
+ *
198
+ * 이유는 **자유 문자열**이다 — 무엇을 왜 안 받는지는 원본마다 다르고, 계약이 목록을 닫으면 새 원본을
199
+ * 붙일 때마다 계약을 고치게 된다(흐름 열쇠와 같은 규율). 사람이 읽을 문장으로 적어라.
200
+ */
201
+ onWithheld?: (info: { reason: string; count: number }) => void
157
202
  }
158
203
 
159
204
  export interface ReferenceAdapter {
@@ -18,7 +18,7 @@ import type { IngestWarning, ReferenceMaster } from './reference-master.js'
18
18
  * 그래서 사실 층이 스스로 완결이다.
19
19
  *
20
20
  * ── 무엇을 지어내지 않는가 ──────────────────────────────────────────────────
21
- * 「없음」의 뜻을 셋으로 갈라 둔다. 뭉치면 조치가 달라지는 것들이 한 칸에 섞인다.
21
+ * 「없음」의 뜻을 셋으로 구분해 둔다. 뭉치면 조치가 달라지는 것들이 한 칸에 섞인다.
22
22
  * · `gaps` — 원본에 있는데 우리가 옮기지 못했다. 고칠 대상이 있다(원본의 빈 칸이거나 우리 매핑).
23
23
  * · `absent` — 이 원본에 그 **개념이 없다.** 빠진 것이 아니므로 고칠 것이 없다.
24
24
  * · `grounding` — 값이 들어왔지만 **근거가 저장된 값이 아니다**(원본의 계산 규칙에서 왔다).
@@ -40,6 +40,16 @@ export interface AssessmentCounts {
40
40
  /** 공정을 말하는 투입 / 전체 투입 — 공정별 소요를 아는 정도. */
41
41
  taggedInputs?: number
42
42
  totalInputs?: number
43
+ /** 시험 명세(점검표)의 수 — 선언되지 않았으면 없다(0 과 구별한다). */
44
+ testSpecifications?: number
45
+ /** 그 명세들이 선언한 판정 기준의 수. */
46
+ criteria?: number
47
+ /**
48
+ * 그중 **아무 한계도 말하지 않는** 기준의 수 — 커널의 `criterionSaysNothing` 과 같은 판정이다.
49
+ *
50
+ * 이 수가 0 이 아니면 「관리점이 있다」가 「판정된다」를 뜻하지 않는다. 세지 않으면 그 구별이 사라진다.
51
+ */
52
+ criteriaWithoutLimit?: number
43
53
  }
44
54
 
45
55
  /** 옮기지 못한 것 한 갈래 — 건수 큰 것부터. */
@@ -65,7 +75,7 @@ export interface ImportAssessment {
65
75
  * 이름을 `origin` 이라 하지 않는다 — 화면에서 그 낱말이 이미 **트윈의 출처**(`summary.origin`:
66
76
  * 템플릿인가 원본인가)를 뜻한다. 같은 이름을 두 뜻으로 쓰면 읽는 사람이 헷갈린다.
67
77
  *
68
- * `built` 와 견주어 「전부 옮겼는가」를 사람이 직접 셀 수 있게 한다. 커넥터가 말하지 않으면 이 자리는
78
+ * `built` 와 비교해 「전부 옮겼는가」를 사람이 직접 셀 수 있게 한다. 커넥터가 말하지 않으면 이 자리는
69
79
  * 비어 있고, 화면은 분모 없이 옮긴 수만 보인다 — **분모를 지어내지 않는다.**
70
80
  */
71
81
  originCounts?: Record<string, number>
@@ -84,7 +94,7 @@ export interface ImportAssessment {
84
94
  notes: IngestWarning[]
85
95
  }
86
96
 
87
- /** 이 마스터가 개념을 선언했나 — 「없다」와 「비어 있다」를 가른다. */
97
+ /** 이 마스터가 개념을 선언했나 — 「없다」와 「비어 있다」를 구분한다. */
88
98
  const declared = (v: unknown): boolean => Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null
89
99
 
90
100
  /**
@@ -105,6 +115,21 @@ export function assessMaster(master: ReferenceMaster, siteId: string): ImportAss
105
115
  equipment: (m.equipment ?? []).length,
106
116
  materialDefinitions: (m.materialDefinitions ?? []).length,
107
117
  operations: (m.operations ?? []).length,
118
+ ...(declared(m.testSpecifications)
119
+ ? {
120
+ testSpecifications: m.testSpecifications!.length,
121
+ criteria: m.testSpecifications!.reduce((n, t) => n + (t.criteria?.length ?? 0), 0),
122
+ /*
123
+ * **한계를 말하지 않는 기준** — 커널의 `criterionSaysNothing` 과 같은 판정이다. 기준이
124
+ * 선언됐다는 사실만 있고 판정할 재료가 없는 자리이고, 그것을 세지 않으면 「관리점이 있다」가
125
+ * 「판정된다」로 읽힌다.
126
+ */
127
+ criteriaWithoutLimit: m.testSpecifications!.reduce(
128
+ (n, t) => n + (t.criteria ?? []).filter(c => !c.expression && c.limit?.minimum === undefined && c.limit?.maximum === undefined).length,
129
+ 0
130
+ )
131
+ }
132
+ : {}),
108
133
  ...(d
109
134
  ? {
110
135
  materials: (d.materials ?? []).length,
@@ -126,7 +151,7 @@ export function assessMaster(master: ReferenceMaster, siteId: string): ImportAss
126
151
  const notes: IngestWarning[] = []
127
152
  /*
128
153
  * 커넥터가 「대체」 수집기로 남기는 것 중 일부는 **빈 자리가 아니라 파생된 값**이다(산출량이 원본의
129
- * 계산 규칙에서 온 경우처럼). 같은 통로로 오지만 뜻이 달라 갈라 담는다 — 이름에 그 사실이 적혀 있다.
154
+ * 계산 규칙에서 온 경우처럼). 같은 통로로 오지만 뜻이 달라 구분해 담는다 — 이름에 그 사실이 적혀 있다.
130
155
  */
131
156
  const isDerived = (field: string) => /derived|not a stored field/i.test(field)
132
157
  for (const w of m.warnings ?? []) {
@@ -209,6 +234,16 @@ export function formatAssessment(a: ImportAssessment): string {
209
234
  out.push(' 파생된 값(저장된 값이 아니다):')
210
235
  for (const g of a.derived) out.push(` ${g.what}${g.sample ? ` — ${g.sample}` : ''}`)
211
236
  }
237
+ if (c.testSpecifications) {
238
+ /*
239
+ * 관리 기준은 계측이 오는지와 무관하게 값이 있다 — 「관리점이 셋인데 둘은 아무 한계도 말하지
240
+ * 않는다」를 여기서 볼 수 있어야 판정에 근거가 있는지 사람이 안다.
241
+ */
242
+ out.push(
243
+ ` 관리 기준: 점검표 ${n('testSpecifications', c.testSpecifications)} · 기준 ${c.criteria ?? 0}` +
244
+ (c.criteriaWithoutLimit ? ` (한계를 말하지 않는 것 ${c.criteriaWithoutLimit})` : '')
245
+ )
246
+ }
212
247
  if (a.absent.length) out.push(` 이 원본에 개념이 없는 자리: ${a.absent.join(', ')}`)
213
248
  for (const n of a.notes) out.push(` 참고 ${n.code}: ${n.message}`)
214
249
  return out.join('\n')
@@ -164,6 +164,38 @@ async function loadLiveCursor(
164
164
  .update({ id: row.id }, { lastError: `live: ${reason}` } as any)
165
165
  .catch(err => twinWarn(`[twin-live] "${source}": read failure not recorded — ${err?.message ?? err}`))
166
166
  },
167
+ /**
168
+ * **읽었는데 창을 넘길 수 없다** — 위와 조치가 반대인 사실이다 (2026-08-24).
169
+ *
170
+ * 이 문을 만들기 전에는 이 사실이 `onReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지 못한다」고
171
+ * 말했는데 실제로는 원본이 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다. 사람은 원본을
172
+ * 의심하고 기다리는데 기다림으로는 영원히 풀리지 않는다 — **원인을 반대 방향으로 가리켰다.**
173
+ *
174
+ * `status` 를 건드리지 않는 규율은 위와 같다(재부착의 자격이다). `lastError` 에는 적는다 — 목록에서
175
+ * 이유를 볼 수 있어야 하고, 「닿지 못한다」와 다른 문장이어야 한다.
176
+ */
177
+ /*
178
+ * **세우지 않은 것을 장부에 적는다** — 원본에 있는데 트윈에 세우지 않은 것(이유와 수).
179
+ *
180
+ * 로그로도 남기지만 **로그는 사람이 볼 때만 값이 있다.** 이 사실은 상태의 성질이라(세우지 않은 것은
181
+ * 지금도 없다) 조회되는 자리에 있어야 한다 — 그러지 않으면 프로비저닝 화면에서 한 번 스쳐 지나간다.
182
+ *
183
+ * `status`·`lastError` 를 건드리지 않는다: 이것은 **실패가 아니다.** 어댑터가 옳은 판단으로 안 받은
184
+ * 것이고, 오류로 적으면 화면이 원본을 의심하게 만든다.
185
+ */
186
+ onWithheld: ({ reason, count }) => {
187
+ TwinEngine.recordIngestWithheld(domainId, instanceId, reason, count)
188
+ },
189
+ onCursorStall: ({ reason, stream }) => {
190
+ TwinEngine.recordIngestCursorStall(domainId, instanceId, reason, Date.now(), stream)
191
+ twinWarn(
192
+ `[twin-live] "${source}": read the source but the cursor cannot advance` +
193
+ `${stream ? ` (${stream})` : ''} — ${reason}. Waiting will not clear this.`
194
+ )
195
+ repo
196
+ .update({ id: row.id }, { lastError: `live cursor stalled: ${reason}` } as any)
197
+ .catch(err => twinWarn(`[twin-live] "${source}": cursor stall not recorded — ${err?.message ?? err}`))
198
+ },
167
199
  onCursor: next => {
168
200
  /* 어댑터가 준 모양을 그대로 적는다 — 이 층은 흐름의 뜻을 모른다(열쇠는 어댑터가 정한다). */
169
201
  repo