@operato/twin-kernel 0.7.49 → 0.7.50

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.
@@ -1,4 +1,4 @@
1
- import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -130,7 +130,18 @@ export interface FlowEquipment extends EffectivePeriod {
130
130
  export interface FlowTask {
131
131
  id: string;
132
132
  kind: string;
133
- status: 'created' | 'in-progress' | 'completed';
133
+ /**
134
+ * **작업 상태 — 어휘는 하나다**(`TaskStatus`: created · assigned · in-progress · completed).
135
+ *
136
+ * 예전에는 여기만 셋이었다(`assigned` 없음). 시뮬레이션은 배정과 착수를 한 번에 하므로 그것으로
137
+ * 충분했지만, **미러는 원본이 말한 것을 그대로 들고 있어야 한다** — 그리고 주의 계산은 이미
138
+ * `assigned` 를 기다리고 있었다(§`collectAttentions`: `status === 'created' || status === 'assigned'`).
139
+ * 즉 그 자리는 영원히 오지 않는 값을 기다렸다.
140
+ *
141
+ * 시뮬 경로의 거동은 바뀌지 않는다: 시뮬은 `assigned` 를 만들지 않고, 배정 루프는 `created` 만
142
+ * 집는다(§`assign`). 넓힌 것은 **관측이 말한 사실을 담을 자리**다.
143
+ */
144
+ status: TaskStatus;
134
145
  /**
135
146
  * **실제로 들어가고 나온 자재** — ISA-95 `JobResponse.MaterialActual`.
136
147
  *
@@ -700,7 +711,26 @@ export declare abstract class FlowEngine implements TwinKernel {
700
711
  id: string;
701
712
  since: string;
702
713
  }[];
703
- }, orders?: OrderStatusDelta[]): void;
714
+ }, orders?: OrderStatusDelta[],
715
+ /**
716
+ * **무엇을 하러 심나** — 기본은 씨앗이다(외부 호출자 전부가 그것이다).
717
+ *
718
+ * ── 왜 이 구별이 생겼나 (2026-08-22 실측) ─────────────────────────────────
719
+ * 이 함수는 원래 **스냅샷으로 커널을 세우는 문**이다(웜스타트·fork·예측). 그 목적에서는 딛고 설
720
+ * 물품·오더가 없는 작업을 빼는 것이 옳다 — 굴릴 수 없는 작업을 심으면 첫 틱에서 없는 자리를
721
+ * 가리킨다.
722
+ *
723
+ * 그런데 `settleObserved()` 가 **미러 자신의 관측을 옮길 때도** 같은 함수를 쓴다. 목적이 다르다:
724
+ * 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
725
+ * 사실이 씨앗 규칙에 걸려 사라졌다.
726
+ *
727
+ * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
728
+ * `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
729
+ * 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
730
+ *
731
+ * 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
732
+ */
733
+ purpose?: 'seed' | 'observe'): void;
704
734
  /**
705
735
  * what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
706
736
  *
@@ -832,7 +832,26 @@ export class FlowEngine {
832
832
  this.seedDanglingRefs += dropped;
833
833
  return { kept, dropped };
834
834
  }
835
- hydrateObserved(snap, orders = []) {
835
+ hydrateObserved(snap, orders = [],
836
+ /**
837
+ * **무엇을 하러 심나** — 기본은 씨앗이다(외부 호출자 전부가 그것이다).
838
+ *
839
+ * ── 왜 이 구별이 생겼나 (2026-08-22 실측) ─────────────────────────────────
840
+ * 이 함수는 원래 **스냅샷으로 커널을 세우는 문**이다(웜스타트·fork·예측). 그 목적에서는 딛고 설
841
+ * 물품·오더가 없는 작업을 빼는 것이 옳다 — 굴릴 수 없는 작업을 심으면 첫 틱에서 없는 자리를
842
+ * 가리킨다.
843
+ *
844
+ * 그런데 `settleObserved()` 가 **미러 자신의 관측을 옮길 때도** 같은 함수를 쓴다. 목적이 다르다:
845
+ * 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
846
+ * 사실이 씨앗 규칙에 걸려 사라졌다.
847
+ *
848
+ * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
849
+ * `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
850
+ * 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
851
+ *
852
+ * 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
853
+ */
854
+ purpose = 'seed') {
836
855
  /* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 주입하면 곧바로 주목 신호가 계산되므로, 늦게
837
856
  * 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
838
857
  for (const id of snap.acked ?? [])
@@ -882,7 +901,15 @@ export class FlowEngine {
882
901
  const prev = this.equipment.get(m.id);
883
902
  this.equipment.set(m.id, {
884
903
  ...(prev ?? {}),
885
- id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: null,
904
+ /*
905
+ * `taskId` — **관측이면 원본이 말한 값이다.**
906
+ *
907
+ * 씨앗에서는 `null` 로 두고 작업 복원이 다시 세운다(작업이 함께 오지 않으면 그 자원이 영원히
908
+ * 잡혀 있게 되므로). 그런데 관측에서는 `equipment.status` 가 이 값을 **직접 실어 온다** —
909
+ * 상태(busy)는 그 축에서 받고 묶임은 작업에서 되세우면, 같은 사실의 두 조각이 서로 다른 축에서
910
+ * 오게 되고 한쪽만 도착한 순간 어긋난다.
911
+ */
912
+ id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: purpose === 'observe' ? (m.taskId ?? null) : null,
886
913
  runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
887
914
  goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0,
888
915
  /*
@@ -915,7 +942,10 @@ export class FlowEngine {
915
942
  const prev = this.persons.get(p.id);
916
943
  this.persons.set(p.id, {
917
944
  ...(prev ?? {}),
918
- id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds, status: 'idle', taskId: null,
945
+ /* 설비와 같은 규율 관측이면 원본이 말한 상태·묶임을 지킨다(씨앗이면 작업 복원이 세운다). */
946
+ id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
947
+ status: purpose === 'observe' ? (p.status ?? 'idle') : 'idle',
948
+ taskId: purpose === 'observe' ? (p.taskId ?? null) : null,
919
949
  ...(p.location ? { location: p.location } : {}),
920
950
  ...(p.properties ? { properties: p.properties } : {}),
921
951
  ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
@@ -929,7 +959,10 @@ export class FlowEngine {
929
959
  const prev = this.assets.get(a.id);
930
960
  this.assets.set(a.id, {
931
961
  ...(prev ?? {}),
932
- id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location, status: 'idle', taskId: null, carrying: a.carrying,
962
+ /* 설비·사람과 같은 규율 관측이면 원본이 말한 상태·묶임을 지킨다. */
963
+ id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location,
964
+ status: purpose === 'observe' ? (a.status ?? 'idle') : 'idle',
965
+ taskId: purpose === 'observe' ? (a.taskId ?? null) : null, carrying: a.carrying,
933
966
  ...(a.properties ? { properties: a.properties } : {}),
934
967
  ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}),
935
968
  /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
@@ -1022,38 +1055,83 @@ export class FlowEngine {
1022
1055
  }
1023
1056
  /* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
1024
1057
  const seededOrderIds = new Set(this.orders.keys());
1025
- let orphaned = 0;
1058
+ /*
1059
+ * **떨어뜨린 이유를 따로 센다** — 예전에는 하나로 세고 「whose item is no longer in state」라고
1060
+ * 말했다. 그런데 오더에 걸린 경우도 같은 문장을 썼고, 그것을 본 사람이 물품 쪽을 반나절 뒤졌다.
1061
+ * 원인을 지어내는 문장은 없는 것보다 나쁘다.
1062
+ */
1063
+ let droppedNoItem = 0;
1064
+ let droppedNoOrder = 0;
1065
+ let droppedCompleted = 0;
1066
+ /*
1067
+ * **씨앗이냐 관측 정착이냐** — 아래 셋을 거를지 말지가 여기서 갈린다(§`purpose`).
1068
+ *
1069
+ * 씨앗(seed) 굴릴 수 없는 작업은 뺀다. 심으면 첫 틱에서 없는 자리를 가리킨다.
1070
+ * 관측(observe) **거르지 않는다.** 미러는 그 작업을 굴리지 않고, 들은 것을 말한다.
1071
+ */
1072
+ const filtering = purpose === 'seed';
1026
1073
  for (const t of snap.tasks ?? []) {
1027
- if (t.status === 'completed')
1074
+ if (filtering && t.status === 'completed') {
1075
+ droppedCompleted++;
1028
1076
  continue;
1077
+ }
1029
1078
  const ref = t.itemRefs?.[0];
1030
- if (ref && !this.itemByRef(ref)) {
1031
- orphaned++;
1079
+ if (filtering && ref && !this.itemByRef(ref)) {
1080
+ droppedNoItem++;
1032
1081
  continue;
1033
1082
  }
1034
1083
  /* 오더도 같다 — 이미 이행된 오더는 주입하지 않으므로(위 `remaining <= 0`), 그 오더에 딸린 작업만
1035
1084
  남으면 완료 시점에 없는 오더를 딛는다. 주입 단계에서 함께 뺀다. */
1036
- if (t.orderId && !seededOrderIds.has(t.orderId)) {
1037
- orphaned++;
1085
+ if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
1086
+ droppedNoOrder++;
1038
1087
  continue;
1039
1088
  }
1040
1089
  const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
1041
1090
  this.tasks.set(t.id, {
1042
1091
  id: t.id, kind: t.kind,
1043
- status: known && t.status === 'in-progress' ? 'in-progress' : 'created',
1092
+ /*
1093
+ * **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
1094
+ * 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
1095
+ *
1096
+ * 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
1097
+ * `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
1098
+ * 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
1099
+ */
1100
+ status: purpose === 'observe' ? t.status : known && t.status === 'in-progress' ? 'in-progress' : 'created',
1044
1101
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
1045
1102
  ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
1046
1103
  itemEpc: t.itemRefs?.[0] ?? '',
1047
1104
  fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
1048
- resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
1105
+ /*
1106
+ * 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
1107
+ * (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
1108
+ * 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
1109
+ */
1110
+ resource: purpose === 'observe' ? (t.resourceRef ?? null) : known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
1049
1111
  remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
1050
1112
  startedAtSimMs: t.startedAtSimMs,
1051
1113
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
1052
1114
  orderId: t.orderId,
1053
1115
  intent: t.intent
1054
1116
  });
1055
- /* 진행 중으로 살린 작업은 그 자원을 점유한 상태여야 한다(자원이 동시에 다른 일을 받지 않게). */
1056
- if (known && t.status === 'in-progress' && t.resourceRef) {
1117
+ /*
1118
+ * 투입 인원은 **작업이 들고 있는 사실**이므로 관측에서도 그대로 옮긴다(위 `set` 에서 빠져 있어
1119
+ * 아래에서 채운다 — 씨앗 시절 진행 중인 작업에만 필요했던 자리다).
1120
+ */
1121
+ if (purpose === 'observe' && t.personnel?.length) {
1122
+ const restored = this.tasks.get(t.id);
1123
+ if (restored)
1124
+ restored.personnel = [...t.personnel];
1125
+ }
1126
+ /*
1127
+ * **자원의 상태는 여기서 정하지 않는다 — 관측일 때는.**
1128
+ *
1129
+ * 씨앗에서는 필요하다: 진행 중인 작업을 심었으면 그 설비가 다른 일을 또 받으면 안 되므로 점유를
1130
+ * 표시한다. 그런데 미러에서는 설비·사람의 상태가 **자기 관측 축**(`equipment.status`,
1131
+ * `person.status`)에서 온다. 작업 하나로 그것을 덮으면 원본이 「유휴」라고 말한 설비를 트윈이
1132
+ * 「바쁘다」고 말한다 — 축이 둘이 되고, 어느 쪽이 사실인지 알 수 없게 된다.
1133
+ */
1134
+ if (purpose === 'seed' && known && t.status === 'in-progress' && t.resourceRef) {
1057
1135
  const mv = this.equipment.get(t.resourceRef);
1058
1136
  if (mv) {
1059
1137
  mv.status = 'busy';
@@ -1061,7 +1139,7 @@ export class FlowEngine {
1061
1139
  }
1062
1140
  }
1063
1141
  /* 사람도 같다 — 진행 중이던 작업에 투입돼 있던 사람은 여전히 점유되어 있어야 한다. */
1064
- if (known && t.status === 'in-progress') {
1142
+ if (purpose === 'seed' && known && t.status === 'in-progress') {
1065
1143
  const restored = this.tasks.get(t.id);
1066
1144
  if (restored)
1067
1145
  restored.personnel = t.personnel ? [...t.personnel] : undefined;
@@ -1074,9 +1152,21 @@ export class FlowEngine {
1074
1152
  }
1075
1153
  }
1076
1154
  }
1077
- if (orphaned) {
1078
- console.warn(`[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state — ` +
1079
- 'they cannot be continued (the item was consumed/shipped, or the observation did not carry it).');
1155
+ /*
1156
+ * **무엇을 뺐는지 그대로 말한다.** 씨앗에서만 나온다(관측은 빼지 않는다).
1157
+ *
1158
+ * 예전 문장은 셋을 하나로 뭉쳐 「whose item is no longer in state」라고 했다. 오더에 걸린 것을
1159
+ * 물품 탓으로 말했고, 그 문장을 읽은 사람이 물품 쪽을 뒤졌다. 원인을 지어내는 문장은 침묵보다 나쁘다.
1160
+ */
1161
+ const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
1162
+ if (droppedTotal) {
1163
+ const why = [
1164
+ droppedNoItem ? `${droppedNoItem} whose item is not in state` : '',
1165
+ droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : '',
1166
+ droppedCompleted ? `${droppedCompleted} already completed` : ''
1167
+ ].filter(Boolean).join(' · ');
1168
+ console.warn(`[twin-kernel] seed skipped ${droppedTotal} task(s) — ${why}. ` +
1169
+ 'They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(…, …, "observe").)');
1080
1170
  }
1081
1171
  }
1082
1172
  /**
@@ -1723,7 +1813,14 @@ export class FlowEngine {
1723
1813
  if (!this.observedDirty || !this.observer)
1724
1814
  return;
1725
1815
  this.observedDirty = false;
1726
- this.hydrateObserved(this.observer.snapshot());
1816
+ /*
1817
+ * **목적을 밝힌다 — 이것은 씨앗이 아니라 관측 정착이다**(§`hydrateObserved` 의 `purpose`).
1818
+ *
1819
+ * 이 한 낱말이 없던 동안 미러의 작업이 씨앗 규칙에 걸려 사라졌다: 포천 미러의 저널에
1820
+ * `task.status` 5,786건이 있는데 `getSnapshot().tasks` 는 0 이었고, 화면은 「아직 하나도
1821
+ * 없습니다」라고 말했다. 미러는 그 작업을 굴리지 않는다 — 들은 것을 말할 뿐이다.
1822
+ */
1823
+ this.hydrateObserved(this.observer.snapshot(), [], 'observe');
1727
1824
  }
1728
1825
  /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
1729
1826
  observedHandlers() {
@@ -3964,7 +3964,7 @@ var FlowEngine = class {
3964
3964
  this.seedDanglingRefs += dropped;
3965
3965
  return { kept, dropped };
3966
3966
  }
3967
- hydrateObserved(snap, orders = []) {
3967
+ hydrateObserved(snap, orders = [], purpose = "seed") {
3968
3968
  for (const id of snap.acked ?? []) this._acked.add(id);
3969
3969
  for (const e of snap.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
3970
3970
  for (const n of snap.locations) {
@@ -3994,12 +3994,20 @@ var FlowEngine = class {
3994
3994
  const prev = this.equipment.get(m.id);
3995
3995
  this.equipment.set(m.id, {
3996
3996
  ...prev ?? {},
3997
+ /*
3998
+ * `taskId` — **관측이면 원본이 말한 값이다.**
3999
+ *
4000
+ * 씨앗에서는 `null` 로 두고 작업 복원이 다시 세운다(작업이 함께 오지 않으면 그 자원이 영원히
4001
+ * 잡혀 있게 되므로). 그런데 관측에서는 `equipment.status` 가 이 값을 **직접 실어 온다** —
4002
+ * 상태(busy)는 그 축에서 받고 묶임은 작업에서 되세우면, 같은 사실의 두 조각이 서로 다른 축에서
4003
+ * 오게 되고 한쪽만 도착한 순간 어긋난다.
4004
+ */
3997
4005
  id: m.id,
3998
4006
  kind: m.kind,
3999
4007
  location: m.location ?? "",
4000
4008
  ...m.homeLocation ? { homeLocation: m.homeLocation } : {},
4001
4009
  status: m.status ?? "idle",
4002
- taskId: null,
4010
+ taskId: purpose === "observe" ? m.taskId ?? null : null,
4003
4011
  runMs: oee?.runMs ?? 0,
4004
4012
  setupMs: oee?.setupMs ?? 0,
4005
4013
  downMs: oee?.downMs ?? 0,
@@ -4030,10 +4038,11 @@ var FlowEngine = class {
4030
4038
  const prev = this.persons.get(p.id);
4031
4039
  this.persons.set(p.id, {
4032
4040
  ...prev ?? {},
4041
+ /* 설비와 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다(씨앗이면 작업 복원이 세운다). */
4033
4042
  id: p.id,
4034
4043
  personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
4035
- status: "idle",
4036
- taskId: null,
4044
+ status: purpose === "observe" ? p.status ?? "idle" : "idle",
4045
+ taskId: purpose === "observe" ? p.taskId ?? null : null,
4037
4046
  ...p.location ? { location: p.location } : {},
4038
4047
  ...p.properties ? { properties: p.properties } : {},
4039
4048
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
@@ -4046,11 +4055,12 @@ var FlowEngine = class {
4046
4055
  const prev = this.assets.get(a.id);
4047
4056
  this.assets.set(a.id, {
4048
4057
  ...prev ?? {},
4058
+ /* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
4049
4059
  id: a.id,
4050
4060
  assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
4051
4061
  location: a.location,
4052
- status: "idle",
4053
- taskId: null,
4062
+ status: purpose === "observe" ? a.status ?? "idle" : "idle",
4063
+ taskId: purpose === "observe" ? a.taskId ?? null : null,
4054
4064
  carrying: a.carrying,
4055
4065
  ...a.properties ? { properties: a.properties } : {},
4056
4066
  ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
@@ -4119,43 +4129,66 @@ var FlowEngine = class {
4119
4129
  });
4120
4130
  }
4121
4131
  const seededOrderIds = new Set(this.orders.keys());
4122
- let orphaned = 0;
4132
+ let droppedNoItem = 0;
4133
+ let droppedNoOrder = 0;
4134
+ let droppedCompleted = 0;
4135
+ const filtering = purpose === "seed";
4123
4136
  for (const t of snap.tasks ?? []) {
4124
- if (t.status === "completed") continue;
4137
+ if (filtering && t.status === "completed") {
4138
+ droppedCompleted++;
4139
+ continue;
4140
+ }
4125
4141
  const ref = t.itemRefs?.[0];
4126
- if (ref && !this.itemByRef(ref)) {
4127
- orphaned++;
4142
+ if (filtering && ref && !this.itemByRef(ref)) {
4143
+ droppedNoItem++;
4128
4144
  continue;
4129
4145
  }
4130
- if (t.orderId && !seededOrderIds.has(t.orderId)) {
4131
- orphaned++;
4146
+ if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
4147
+ droppedNoOrder++;
4132
4148
  continue;
4133
4149
  }
4134
4150
  const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
4135
4151
  this.tasks.set(t.id, {
4136
4152
  id: t.id,
4137
4153
  kind: t.kind,
4138
- status: known && t.status === "in-progress" ? "in-progress" : "created",
4154
+ /*
4155
+ * **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
4156
+ * 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
4157
+ *
4158
+ * 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
4159
+ * `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
4160
+ * 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
4161
+ */
4162
+ status: purpose === "observe" ? t.status : known && t.status === "in-progress" ? "in-progress" : "created",
4139
4163
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
4140
4164
  ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
4141
4165
  itemEpc: t.itemRefs?.[0] ?? "",
4142
4166
  fromNode: t.fromNode ?? "",
4143
4167
  toNode: t.toNode ?? "",
4144
- resource: known && t.status === "in-progress" ? t.resourceRef ?? null : null,
4168
+ /*
4169
+ * 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
4170
+ * (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
4171
+ * 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
4172
+ */
4173
+ resource: purpose === "observe" ? t.resourceRef ?? null : known && t.status === "in-progress" ? t.resourceRef ?? null : null,
4145
4174
  remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
4146
4175
  startedAtSimMs: t.startedAtSimMs,
4147
4176
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
4148
4177
  orderId: t.orderId,
4149
4178
  intent: t.intent
4150
4179
  });
4151
- if (known && t.status === "in-progress" && t.resourceRef) {
4180
+ if (purpose === "observe" && t.personnel?.length) {
4181
+ const restored = this.tasks.get(t.id);
4182
+ if (restored) restored.personnel = [...t.personnel];
4183
+ }
4184
+ if (purpose === "seed" && known && t.status === "in-progress" && t.resourceRef) {
4152
4185
  const mv = this.equipment.get(t.resourceRef);
4153
4186
  if (mv) {
4154
4187
  mv.status = "busy";
4155
4188
  mv.taskId = t.id;
4156
4189
  }
4157
4190
  }
4158
- if (known && t.status === "in-progress") {
4191
+ if (purpose === "seed" && known && t.status === "in-progress") {
4159
4192
  const restored = this.tasks.get(t.id);
4160
4193
  if (restored) restored.personnel = t.personnel ? [...t.personnel] : void 0;
4161
4194
  for (const id of t.personnel ?? []) {
@@ -4167,9 +4200,15 @@ var FlowEngine = class {
4167
4200
  }
4168
4201
  }
4169
4202
  }
4170
- if (orphaned) {
4203
+ const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
4204
+ if (droppedTotal) {
4205
+ const why = [
4206
+ droppedNoItem ? `${droppedNoItem} whose item is not in state` : "",
4207
+ droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : "",
4208
+ droppedCompleted ? `${droppedCompleted} already completed` : ""
4209
+ ].filter(Boolean).join(" \xB7 ");
4171
4210
  console.warn(
4172
- `[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state \u2014 they cannot be continued (the item was consumed/shipped, or the observation did not carry it).`
4211
+ `[twin-kernel] seed skipped ${droppedTotal} task(s) \u2014 ${why}. They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(\u2026, \u2026, "observe").)`
4173
4212
  );
4174
4213
  }
4175
4214
  }
@@ -4752,7 +4791,7 @@ var FlowEngine = class {
4752
4791
  settleObserved() {
4753
4792
  if (!this.observedDirty || !this.observer) return;
4754
4793
  this.observedDirty = false;
4755
- this.hydrateObserved(this.observer.snapshot());
4794
+ this.hydrateObserved(this.observer.snapshot(), [], "observe");
4756
4795
  }
4757
4796
  /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
4758
4797
  observedHandlers() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.49",
3
+ "version": "0.7.50",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {